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> {
// 説明文が混ざっていても JSON 本体だけを取り出す(実装は次節の extractJsonBlock)
const block = extractJsonBlock(raw);
if (!block) return { ok: false, name, reason: "JSON ブロックが見つかりません" };
let data: T;
try {
data = JSON.parse(block) 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 という状態を持たせておけば、「型チェックとテストは通った、監査だけ再実行」という判断を人間が下せます。
実測:抜き出しの正規表現が落ちる条件
この記事を書き直すにあたって、私が長らく使っていた抽出の一行を疑ってみました。raw.match(/\{[\s\S]*\}/) という、欲張りな正規表現です。前置きは確かに吸収してくれます——けれど、後ろに何か続いたときはどうなるのか。手元で確かめないまま何か月も使い続けていたことに気づいたのは、集約のログに パース失敗 が散発していた理由を探していたときでした。
そこで、実際に返ってくる形を七つ用意し、三つの実装に同じ入力を通して比較しました。環境は Node v22.23.2、抽出後に JSON.parse が通るかどうかだけを見ています。
- 欲張り:
/\{[\s\S]*\}/ — 最初の { から最後の } まで
- 控えめ:
/\{[\s\S]*?\}/ — 最初の { から最初の } まで
- 括弧を数える: 文字列リテラルとエスケープを見ながら、対応する
} まで走査
| SubAgent が返した形 |
欲張り |
控えめ |
括弧を数える |
| A. JSON のみ |
成功 |
成功 |
成功 |
| B. 前置きが付く(了解しました。〜) |
成功 |
成功 |
成功 |
C. 後ろに補足が付き、そこに {"extends": "next"} が含まれる |
失敗 |
成功 |
成功 |
| D. コードフェンスで囲まれ、後に「以上です。」が続く |
成功 |
成功 |
成功 |
E. 入れ子のオブジェクトを含む("meta": {...}) |
成功 |
失敗 |
成功 |
F. 文字列の中に波括弧がある("型 {id: string} が一致しません") |
成功 |
失敗 |
成功 |
| G. 失敗の JSON を返したあと、再試行の JSON を続けて返す |
失敗 |
成功 |
成功 |
欲張りは七つのうち二つで落ちました。しかも落ちる二つは、どちらも上のエージェント定義で普通に起こる形です。C は「JSON のみで」と頼んでも解説を添えてくる典型で、G は再試行を自分で挟んだエージェントの返し方そのものです。
そして、欲張りを控えめに変えるだけの手当ては、別の二つを新しく壊します。E は npx jest --json の生の出力を少しでも通せば現れる形ですし、F にいたっては Agent 1 の details フィールドの中身そのものです。TypeScript のエラーメッセージには {id: string} のような型が当たり前に混ざります。つまり控えめへの変更は、自分が書いたエージェント定義を自分で壊す手当てだったのです。
JSON の切り出しは、正規表現の欲張りと控えめを切り替える問題ではなく、括弧の対応を数える問題です。 この一文にたどり着くまで、私は正規表現の側でずいぶん粘っていました。
// 最初の { から、対応する } までを返す。文字列リテラル内の括弧とエスケープは数えない
export function extractJsonBlock(raw: string): string | null {
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
for (let i = 0; i < raw.length; i++) {
const c = raw[i];
if (inString) {
if (escaped) { escaped = false; continue; }
if (c === "\\") { escaped = true; continue; }
if (c === '"') inString = false;
continue;
}
if (c === '"') { inString = true; continue; }
if (c === "{") {
if (depth === 0) start = i;
depth++;
continue;
}
if (c === "}") {
depth--;
if (depth === 0 && start >= 0) return raw.slice(start, i + 1);
}
}
return null;
}
長さにして 20行ほどです。私はこの 20行を惜しんで、一行の正規表現で何か月も済ませていました。並列度を上げるほど集約の入力は増えますから、抽出の取りこぼしは並列化の効果をそのまま削っていたのかもしれません。
なお G のように JSON が二つ続く場合、この実装は先に現れた方——つまり失敗の記録の方——を返します。エージェントに再試行させず {"error": "理由"} で返させる方針とは、ここで噛み合います。再試行を内側に隠すと、抽出器はどちらを正解として選ぶべきか判断できなくなります。
実測:どこが縮み、どこが縮まなかったか
並列化前後で計測した内訳です。単位は秒、三回の中央値です。
| 区間 |
直列 |
3並列 |
備考 |
| 依存関係のインストール |
212 |
212 |
並列化前の共通処理・縮まない |
| 型チェック + リント |
388 |
497 |
最長の1本(テスト)に律速される |
| テスト + カバレッジ |
497 |
| 脆弱性チェック |
171 |
| 結果の集約と書き出し |
19 |
34 |
検証処理を足したぶん増加 |
| 合計 |
1,287(約21分) |
743(約12分) |
この計測時点での値 |
冒頭で31分と書いたのは、キャッシュが効いていない初回を含めた体感値です。安定して計測すると 21分 → 12分。それでも 42% 短縮です。
3並列は、すでに理論下限に届いていました
この表を並列度の観点で読み直すと、当時の私が見落としていたことが出てきます。直列で残る区間は、インストールの 212秒 と集約の 34秒、合わせて 246秒。並列区間が下がりうる限界は、最長の一本である 497秒。足すと 743秒 で、これは 3並列の実測値とぴたりと一致します。
つまり、並列度を4に増やしても5に増やしても、この構成では 743秒 から一秒も動きません。増やしても無駄だったのではなく、増やす前からすでに下限に着いていたというのが正確なところでした。実測が動かなかったのは当然だったのだと、計算にしてみて初めて腑に落ちました。
| 構成 |
並列区間の律速 |
合計 |
種別 |
| 直列 |
— |
1,287秒(約21.4分) |
実測 |
| 3並列(現状) |
テスト 497秒 |
743秒(約12.4分) |
実測 |
| 並列度を4以上へ |
テスト 497秒 |
743秒(変化なし) |
計算 |
| テストを2本に分割 |
型チェック 388秒 |
634秒(約10.6分) |
試算 |
| 型チェックも2本に分割 |
テスト半分 248秒 |
494秒(約8.2分) |
試算 |
| 並列区間がゼロになったとして |
— |
246秒(約4.1分) |
理論下限 |
直列で残る 246秒 は、元の 1,287秒 の 19.1% にあたります。ここから導かれる短縮の上限は 5.23倍。私が実際に得ていた 1.73倍 は、その三分の一の地点でした。
この計算がありがたいのは、次に手をつける場所を先に教えてくれるところです。テストを二本に割れば 109秒 縮みますが、その瞬間に律速は型チェックへ移ります。型チェックを放置したまま テストを四本、八本と割り続けても、634秒 より下には行きません。並列度を上げる前に、律速がどの一本に乗っているかを毎回言い当てられるかどうか。 そこで手が止まるなら、まだ計測が足りていないのだと思っています。
そして依存関係のインストールに 212秒。全体の 28% を占める直列区間です。ここを縮めない限り、並列化の上限は先に見えてしまいます。並列化とは、直列区間を可視化する作業でもあるのだと感じています。
私がつまずいた三つの落とし穴
共有ディスクを三つのエージェントで奪い合う。
実行環境のディスクが逼迫していると、SubAgent が同時にキャッシュを展開しようとして途中で書き込みに失敗します。しかも失敗の仕方が悪く、コマンド自体は 0 で終了して、内容が欠けたまま返ってくることがありました。並列化の前に、実行環境の空き容量とテンポラリ領域の書き込み権限を確認します。地味ですが、これを怠ると原因究明に半日かかります。
エージェントが古いクローンを読んでいる。
作業用に浅いクローンを使い回していたところ、あるエージェントが数日前の状態を読んで「この処理は未実装です」と正しく報告してきたことがあります。エージェントは嘘をついていません。見せていた入力が古かっただけです。判断を伴う SubAgent には、必ず最新化した入力を渡します。入力の鮮度は、プロンプトの巧拙よりずっと結果を左右するのかもしれません。
検証と反映を同じ一呼吸でまとめてしまう。
「ゲートを実行して、通ったら push して」という指示を一つのメッセージにまとめると、ゲートの結果を読む前に push の呼び出しが発行されることがあります。私はこれで、通っていない成果物を反映させてしまいました。検証と、検証結果に依存する行動は、必ず別のターンに分けます。並列化してよいのは「引数が先に確定している呼び出し」だけ、という原則がここにも効いてきます。
やりがちなアンチパターン
SubAgent に文脈を託そうとする。
# ❌ これまでの会話を踏まえて、プロジェクト全体を理解した上でリファクタリングしてください
# ✅ src/api/users.ts の getUserById を、以下の要件でリファクタリングしてください:
# - 引数を { id: string } のオブジェクトに変更
# - 見つからない場合は null ではなく NotFoundError を投げる
# - 既存のテスト tests/api/users.test.ts を通すこと
「これまでの会話」は SubAgent には存在しません。必要な情報はすべてタスク指示に書き切ります。
出力を検証せずに次へ流す。
前掲の parseAgentOutput を挟むだけで、原因不明の集約バグはほとんど消えます。ただし挟むのは extractJsonBlock を入れ替えたあとにしてください。抽出が取りこぼしていると、検証は「JSON ブロックが見つかりません」という正しくない理由を返し続けます。
再試行で粘らせる。
「もし失敗したら再試行して」という指示は、失敗が観測できなくなるぶん厄介です。それよりも {"error": "理由"} を返させ、判断を親側に集めます。構造化された失敗は、静かな成功よりずっと扱いやすいものです。
コードレビュー支援での実運用
いま最も恩恵を感じているのが、変更差分のレビュー支援です。
変更差分が確定
│
├─ 変更ファイルの一覧を取得(ここは直列。以降の引数を決めるため)
│
├─ [並列] ──────────────────────────
│ ├─ Agent B: ロジック変更部分のレビュー
│ ├─ Agent C: テストが変更を覆っているかの確認
│ └─ Agent D: ドキュメントとの整合性確認
│
└─ 親: 三つの結果を検証して優先度つきに集約
最初の一段だけが直列なのは、B・C・D に渡すファイルリストがそこで初めて確定するためです。ここを並列にしようとして、しばらく悩みました。引数が確定していないものは並列にできません。単純な原則ですが、設計図に落とすと迷いが消えます。
小さく始めるための順序
いきなり五並列を組む必要はありません。私が人にお勧めしているのは次の順序です。
まず、いま直列で走らせている処理を書き出し、それぞれに「書き込みがあるか」「前段の結果を使うか」の二つを○×で付けます。両方×のものだけが並列化の候補です。
次に、そこから二本だけ選んで同時に走らせます。型チェックとリントの組み合わせが、たいてい一番安全です。
続いて extractJsonBlock と parseAgentOutput 相当の検証を足してから、三本目を追加します。検証を後回しにすると、並列度が上がった瞬間に原因不明の不整合が始まります。
最後に、三本の所要時間を並べて律速の一本を書き出します。そこが分かってから四本目を考えても、遅すぎることはありません。
まずは手元の三本を並べて、いちばん長い一本に印を付けるところから始めていただければと思います。私自身、その印を付けるまでに何か月もかけてしまいました。お読みいただきありがとうございました。