31分が9分になった日と、そこで止まった理由
4サイト分の記事パイプラインを回していた頃、型チェックが終わるのを眺めている時間がひどく無駄に思えていました。テストは走らせられる。リントも走らせられる。ただ、一度に一つずつしか動かない。
Task ツールと SubAgent を使って並列化したところ、CI 相当の一連の処理は 31分から9分になりました。胸のすく変化でした。
けれど、そこから先はどれだけ並列度を上げても縮みませんでした。並列化が効くのは全体のうち「独立して動かせる部分」だけで、残りは最初から直列の宿命を負っている。境界線をどこに引くか。個人開発の運用で見えてきたその判断基準を、実測とともにお伝えします。
Task ツールが実際にやっていること
Task ツールは、Claude Code が新しいエージェント(SubAgent)を起動するための組み込みツールです。挙動として押さえておくべき性質は三つあります。
コンテキストが完全に分離されます。 SubAgent は親エージェントの会話履歴を引き継ぎません。これは制約ではなく設計上の利点です。各エージェントが独立した文脈で動くため、同時に走らせても互いの推論を汚しません。
独立した呼び出しは同一メッセージ内でまとめて発行できます。 依存関係のない Task 呼び出しを一つのメッセージにまとめると、実行が重なります。逆に、前の結果を使って次の引数を決める場合は、待ってから次を出すしかありません。並列化の可否は、突き詰めれば「引数が先に確定しているか」だけで決まります。
結果は文字列で返ってきます。 構造化データが欲しければ、SubAgent 側に JSON で返すよう指示したうえで、親エージェント側で検証する必要があります。この検証を省くと、後述の通り静かに壊れます。
分割してよいもの、分割してはいけないもの
並列エージェント設計で最初に決めるべきは、プロンプトの書き方ではありません。何を分割し、何を分割しないかです。私は次の表を基準にしています。
| 処理の性質 |
並列化 |
判断の根拠 |
| 読み取り専用の解析(型チェック・リント・テスト) |
可 |
副作用がなく、順序に意味がない |
| 独立したファイル群への処理(モジュール別レビュー) |
可 |
書き込み先が重ならない |
| 異なるリソースへの問い合わせ(DB・API・ファイル) |
可 |
待ち時間が I/O に支配される |
| 同一ファイルへの書き込み |
不可 |
後勝ちで片方の編集が消える |
| 前段の結果に依存する処理(ビルド→テスト) |
不可 |
引数が実行時まで確定しない |
| 共有状態の変更(環境変数・グローバル設定) |
不可 |
読み取り側のエージェントが不定状態を見る |
この判断を誤ったときが厄介です。エラーは出ません。全エージェントが成功を報告し、集約結果も整っている。それでも成果物だけが壊れている。私が一度やったのは、二つの SubAgent に同じ設定ファイルへの追記を任せたケースでした。両方とも「追記しました」と返してきて、実際に残っていたのは片方の変更だけ。ログを遡るまで気づけませんでした。
書き込みが絡む処理は、疑わしければ直列に倒す。速度より先に、結果の正しさを守る。
3エージェント並列の設定例
大きな変更を加えたあと、個人開発のリポジトリで私が実際に走らせている構成です。三つの SubAgent に、それぞれ独立した読み取り専用の仕事を与えます。
Agent 1:型チェックとリント
## SubAgent: TypeScript Checker
あなたはコード品質チェッカーです。以下を実行し、結果を JSON のみで返してください。
前置きや説明文は不要です。
1. `npx tsc --noEmit 2>&1` を実行してエラーを収集
2. `npx eslint src/ --format json 2>&1` を実行
3. 件数と深刻度を集計
出力形式:
{"tsc_errors": 0, "eslint_errors": 0, "eslint_warnings": 0, "details": ""}
いずれかのコマンドが実行できなかった場合は
{"error": "実行できなかった理由"} を返してください。
Agent 2:テスト実行とカバレッジ
## SubAgent: Test Runner
1. `npx jest --coverage --json 2>&1 | tail -1` を実行
2. カバレッジサマリーを抽出
3. 失敗したテスト名を配列で収集
出力形式:
{"total_tests": 0, "passed": 0, "failed": 0, "coverage_pct": 0.0, "failed_tests": []}
タイムアウトした場合は {"error": "timeout"} を返してください。
Agent 3:依存関係の脆弱性チェック
## SubAgent: Security Auditor
1. `npm audit --json 2>&1` を実行
2. high / moderate の脆弱性を抽出
3. 修正コマンドを組み立てる
出力形式:
{"high_risk": 0, "moderate_risk": 0, "affected_packages": [], "fix_command": ""}
三つとも読み取り専用で、書き込み先が重なりません。だから同一メッセージ内でまとめて起動できます。
集約ロジックは「検証つき」で書く
ここが実装の要です。SubAgent は、指示したとおりの形式で返さないことがあります。前置きの一文を添えてくる。フィールドを一つ落とす。タイムアウトして何も返さない。
そのすべてを親エージェント側で吸収します。
// aggregate.ts — SubAgent の出力を検証しながら集約する
type Ok<T> = { ok: true; name: string; data: T };
type Fail = { ok: false; name: string; reason: string };
type AgentResult<T> = Ok<T> | Fail;
export function parseAgentOutput<T extends object>(
name: string,
raw: string,
required: (keyof T)[],
): AgentResult<T> {
// SubAgent は説明文を添えて返すことがあるため、最初の JSON ブロックだけを取り出す
const block = raw.match(/\{[\s\S]*\}/);
if (!block) return { ok: false, name, reason: "JSON ブロックが見つかりません" };
let data: T;
try {
data = JSON.parse(block[0]) as T;
} catch (e) {
return { ok: false, name, reason: `パース失敗: ${(e as Error).message}` };
}
if ("error" in data) {
return { ok: false, name, reason: String((data as Record<string, unknown>).error) };
}
const missing = required.filter((k) => data[k] === undefined);
if (missing.length > 0) {
return { ok: false, name, reason: `必須フィールド欠落: ${missing.join(", ")}` };
}
return { ok: true, name, data };
}
export function aggregate(results: AgentResult<object>[]) {
const succeeded = results.filter((r): r is Ok<object> => r.ok);
const degraded = results.filter((r): r is Fail => !r.ok);
return {
// 全滅したときだけ止める。一部失敗は「劣化して続行」に倒す
verdict:
degraded.length === results.length
? "aborted"
: degraded.length > 0
? "partial"
: "complete",
succeeded: succeeded.map((r) => r.name),
degraded: degraded.map((r) => ({ name: r.name, reason: r.reason })),
};
}
なぜ正規表現で最初の JSON ブロックを抜くのか。「JSON のみで返してください」と書いても、実際には 了解しました。結果は以下の通りです。 という一文が先頭に付いてくることが、体感で十回に一回ほどあるためです。厳密に JSON.parse(raw) すると、そこで落ちます。
なぜ verdict を三値にするのか。一つの SubAgent がタイムアウトしただけで全体を落とすと、残り二つの成果まで捨てることになります。partial という状態を持たせておけば、「型チェックとテストは通った、監査だけ再実行」という判断を人間が下せます。
実測:どこが縮み、どこが縮まなかったか
並列化前後で計測した内訳です。単位は秒、三回の中央値です。
| 区間 |
直列 |
3並列 |
備考 |
| 依存関係のインストール |
212 |
212 |
並列化前の共通処理・縮まない |
| 型チェック + リント |
388 |
497 |
最長の1本(テスト)に律速される |
| テスト + カバレッジ |
497 |
| 脆弱性チェック |
171 |
| 結果の集約と書き出し |
19 |
34 |
検証処理を足したぶん増加 |
| 合計 |
1,287(約21分) |
743(約12分) |
この計測時点での値 |
冒頭で31分と書いたのは、キャッシュが効いていない初回を含めた体感値です。安定して計測すると 21分 → 12分。それでも 42% 短縮です。
注目していただきたいのは、並列区間が もっとも遅い一本の時間に律速される 点です。テストが 497秒 かかる限り、他をどれだけ速くしても並列区間は 497秒 を下回りません。並列度を4、5と増やしても無駄でした。効くのは並列度ではなく、最長の一本を短くすることです。私はここで初めて、テストの分割(統合テストを別ジョブへ)に手をつけました。
そして依存関係のインストールに 212秒。全体の 28% を占める直列区間です。ここを縮めない限り、並列化の上限は先に見えてしまいます。並列化とは、直列区間を可視化する作業でもあるのだと思っています。
私がつまずいた三つの落とし穴
共有ディスクを三つのエージェントで奪い合う。
実行環境のディスクが逼迫していると、SubAgent が同時にキャッシュを展開しようとして途中で書き込みに失敗します。しかも失敗の仕方が悪く、コマンド自体は 0 で終了して、内容が欠けたまま返ってくることがありました。並列化の前に、実行環境の空き容量とテンポラリ領域の書き込み権限を確認する。地味ですが、これを怠ると原因究明に半日かかります。
エージェントが古いクローンを読んでいる。
作業用に浅いクローンを使い回していたところ、あるエージェントが数日前の状態を読んで「この処理は未実装です」と正しく報告してきたことがあります。エージェントは嘘をついていません。見せていた入力が古かっただけです。判断を伴う SubAgent には、必ず最新化した入力を渡す。入力の鮮度は、プロンプトの巧拙よりずっと結果を左右します。
検証と反映を同じ一呼吸でまとめてしまう。
「ゲートを実行して、通ったら push して」という指示を一つのメッセージにまとめると、ゲートの結果を読む前に push の呼び出しが発行されることがあります。私はこれで、通っていない成果物を反映させてしまいました。検証と、検証結果に依存する行動は、必ず別のターンに分ける。並列化してよいのは「引数が先に確定している呼び出し」だけ、という原則がここにも効いてきます。
やりがちなアンチパターン
SubAgent に文脈を託そうとする。
# ❌ これまでの会話を踏まえて、プロジェクト全体を理解した上でリファクタリングしてください
# ✅ src/api/users.ts の getUserById を、以下の要件でリファクタリングしてください:
# - 引数を { id: string } のオブジェクトに変更
# - 見つからない場合は null ではなく NotFoundError を投げる
# - 既存のテスト tests/api/users.test.ts を通すこと
「これまでの会話」は SubAgent には存在しません。必要な情報はすべてタスク指示に書き切ります。
出力を検証せずに次へ流す。
前掲の parseAgentOutput を挟むだけで、原因不明の集約バグはほとんど消えます。
再試行で粘らせる。
「もし失敗したら再試行して」という指示は、失敗が観測できなくなるぶん厄介です。それよりも {"error": "理由"} を返させ、判断を親側に集める。構造化された失敗は、静かな成功よりずっと扱いやすいものです。
コードレビュー支援での実運用
いま最も恩恵を感じているのが、変更差分のレビュー支援です。
変更差分が確定
│
├─ 変更ファイルの一覧を取得(ここは直列。以降の引数を決めるため)
│
├─ [並列] ──────────────────────────
│ ├─ Agent B: ロジック変更部分のレビュー
│ ├─ Agent C: テストが変更を覆っているかの確認
│ └─ Agent D: ドキュメントとの整合性確認
│
└─ 親: 三つの結果を検証して優先度つきに集約
最初の一段だけが直列なのは、B・C・D に渡すファイルリストがそこで初めて確定するためです。ここを並列にしようとして、しばらく悩みました。引数が確定していないものは並列にできない。単純な原則ですが、設計図に落とすと迷いが消えます。
小さく始めるための順序
いきなり五並列を組む必要はありません。私が人にお勧めしているのは次の順序です。
まず、いま直列で走らせている処理を書き出し、それぞれに「書き込みがあるか」「前段の結果を使うか」の二つを○×で付けます。両方×のものだけが並列化の候補です。
次に、そこから二本だけ選んで同時に走らせます。型チェックとリントの組み合わせが、たいてい一番安全です。
最後に parseAgentOutput 相当の検証を足してから、三本目を追加します。検証を後回しにすると、並列度が上がった瞬間に原因不明の不整合が始まります。
この順序を守れば、並列化は怖いものではなくなります。まずは「テストとリントを同時に走らせる」だけでも、待ち時間の質が変わることを実感していただけるはずです。
私自身まだ試行錯誤の途中で、テストの分割は道半ばです。お読みいただきありがとうございました。