【第8回】「最大 9 並列」と書いても 1 件ずつしか走らなかった —— Agent dispatch の本当のボトルネックは main の context 圧迫だった
1 日 90 件規模の定期テキスト生成パイプラインで、Agent dispatch の並列度がプロンプト指示通りに上がらず、E2E 40 分 timeout・16 分打ち切りと 2 連敗した。「1 message に N 並べろ」を強調しても model は守らない。直したのは subagent definition の self-fetch / self-save 設計で main の context を ~100x 圧縮しただけ。結果 4.5 分で 90/90 完走。並列度は Agent tool の async launch で勝手に効いていた。プロンプトで戦わない、main を軽くするのが効く、という話。
← 前の記事: 【第7回】サブエージェントのネスト解禁——4 つの「発火モード」と、集約親で Main コンテキストを守るこの記事は「Claude Code サブエージェント」シリーズの一篇です。Claude が書いています(題材は筆者自身のパイプライン構築の実話)。
並列度が上がらない、と思っていた
毎日定刻に走る、テキスト生成の precompute パイプラインがある。数十種のドメイン軸 × 数テーマで、 1 日あたり計 90 件の生成を一括で回す。実体は Claude Code 上で ralph-loop から Claude を起こし、main claude が Agent tool で subagent を発射する形。
これが、遅かった。
| run | 設計 | 所要 | 結果 |
|---|---|---|---|
| v3 | main → general-purpose Task → main saves | 40 分 | 72/90 で timeout 打ち切り |
| v4 | 上に「 1 message に N 並べろ 」を強調追加 | 16 分 | 54/90 で reach limit 直撃 |
| 今回 | subagent definition self-contained + flock | 4.5 分 | 90/90 PASS iter 1 |
最初の 2 回は、原因を「並列度が足りない」だと思っていた。プロンプトに「最大 9 並列」と書いてあるのに、main が 1 件ずつ別 message で発射していたのを transcript で見つけたからだ。
$ jq -c 'select(.type=="assistant")
| .message.content
| map(select(.name=="Agent"))
| length' v3-session.jsonl | sort | uniq -c
81 1
81 件の Agent call が全部 「 1 message に 1 件」 で出ていた。完全なシーケンシャルに見える。 これを「 N 並べる」 へ矯正できれば速くなる、と思った。
プロンプトで矯正してみた、効かなかった
v4 で prompt.md を強くした。
+ **真の並列で叩く** ことが目的。各 dispatch を別 message にすると 1 件ずつ
+ 逐次実行されるので、必ず **1 つの assistant message に複数の Task / Agent
+ tool call を並べる** こと。
+
+ ⚠️ 旧プロンプトには「最大 9 並列」と書いてあったが、これを **1 件/message
+ で逐次実行する** と誤解されていた。「★最重要」 「絶対 NG」 「⚠️」 と最大限の強調で念押しした。 それでも v4 session の transcript はこうなった。
81 1 ← v4 も同じ。1 message に 1 件のまま。
Agent call の発話分割パターンを、プロンプトで矯正することはできなかった。
そして v4 は 16 分時点で You've hit your session limit · resets 4pm に当たって全 iter が即終了した。 reach limit を消費し切ったのに、 54 件しか生成できないまま終わった。
ここで気づいた——「並列度が上がらない」 という見立て自体が、たぶん間違っている。
仕組みを実機で測りなおす
別セッションを立てて、Agent tool の挙動だけを測った。 3 つの subagent を 1 message にまとめて起動し、各々が date で開始 / 終了時刻を記録する。
n=3, sleep=10s
A: start 01:09:13.197 end 01:09:25.455
B: start 01:09:15.329 end 01:09:33.010
C: start 01:09:18.976 end 01:09:31.924
C の start は A の sleep 終了 (23.2) より 前 にある。3 件とも A の sleep 中に並走している。 1 message に並べたら並列に動く、これは事実。
しかし問題はここではなかった。
n=32, 各 ~30s, 1 message にまとめて発射
launch 完了まで: 27 秒
duration: 24-37 秒
実効並列度: ~13-15
32 件を 1 message に並べても、launch そのものはローリングで 27 秒かかる。 逆に言うと—— main が逐次に発射しても、 1 件あたり 1 秒以下で次の launch を出せれば、実効並列度は変わらない 。
Agent tool は async launch だ。 main は dispatch を投げるだけで、 subagent の処理を待たない。完了通知は後から非同期で届く。だから 1 message に 1 件でも、 main が次々と発射し続けられるなら、 subagent は裏で並列に走り続ける。
問題は「 main が次々と発射し続けられない」 ことのほうだった。
ボトルネックは main の context 圧迫だった
v3 / v4 の旧設計はこうなっていた。
1 件 dispatch するたびに、 main は 6KB 弱の text を context に積む 。 90 件で 540KB 相当。 これが効いた。 main の context が肥大すると、次の tool_use を出すまでの間隔が伸びる。
- 短いセッションのうちは「 1 件 0.5 秒で launch」 → subagent は並列で走る
- 100KB を超えたあたりから「 1 件 5 秒、 10 秒で launch」 → subagent は 1 つ終わってから次が来る
- 終盤は完全に逐次
「並列指示が効いていない」 のではない。 並列で走る subagent を main が「養えなくなっていく」 のが先に来ていた 。 そして context が肥大した main が長時間 token を吐き続ければ、 reach limit に届くのも早い。 v4 が 16 分で session limit を踏んだのはこれだ。
「 1 message に N 並べる」 はこの問題を解消しない。 N 並べてもその message の生成自体が長くなる。
直したのは main の負担、 1 dispatch ~30 バイト
今回の設計はこう。
要点は 2 つ。
1. subagent definition の self-fetch / self-save
.claude/agents/quest-generator.md に専用 subagent を 1 つ定義する。 frontmatter で tools: ["Bash", "Read"] model: opus を固定する。 system prompt 相当には次の手順を書く。
- Step A.
uv run python -m engine.cli prompts {DATE} --indexes {N}で自分の prompt を取得 - Step B. その prompt の指示どおりに生成
- Step C.
engine.cli save {DATE} --index {N}に heredoc で結果を流す - Step D.
{"index": N, "ok": true}だけ return
main が subagent に渡すのは "date: X, index: N" だけ。 prompt 本体は subagent が自分で fetch するので、 main の context には載らない。 narration 本体も main を経由しない。 main は ok フラグだけ受け取る。
これで 1 dispatch あたり main が読み書きする text は ~5 KB → ~60 byte に圧縮 された。 ~100x の削減。
2. save の race condition を fcntl.flock で塞ぐ
並列で engine.cli save を叩くと、 read-modify-write が衝突して silent loss する。実測で 32 並列 / 1 件 silent loss 、 tmp ファイル名衝突で FileNotFoundError も発生した。次のような単純な lock で直る。
import fcntl
def _save_entry_under_lock(path: Path, index: int, payload: dict) -> int:
lock_path = path.with_suffix(".lock")
with open(lock_path, "w") as lock_fp:
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_EX)
return _apply_entry_update(path, index, payload)flock は同一 OS 上の全プロセスから見えるので、 30 並列の subagent が全員 lock 経由で直列に save する。 30 並列で 90 件を save しても衝突ゼロ。
それで何が起きたか
新設計で再度走らせた。
[run.py 2026-06-19 01:44:11] target date: 2026-06-23 (hard limit 3600s)
Starting ralph-loop (max 10 iterations, threshold 100)
--- Iteration 1/10 ---
Result: PASS (score: 100/100)
Finished: success after 1 iteration(s)
[run.py] ralph-loop done rc=0 elapsed=258.2s
[run.py] done in 263.0s, ok=True
4 分 23 秒で 90/90 完走、 iter 1 で score 100/100 PASS。 v3 (40 分・ 72 件で timeout) と v4 (16 分・ 54 件で reach limit) から 10x の改善。
おもしろいのは、 main session の transcript を見ると—— 今回も 1 message に 1 件しか出していなかった 。
$ jq -c '...' 2026-06-23-main.jsonl | sort | uniq -c
92 1 ← 今回も同じパターン
92 件の Agent call が全部「 1 message に 1 件」 で発射されていた。 「 N 並べる」 はやはり守られていない。 にも関わらず 4.5 分で完走。 main が軽くなったので、 1 件 1 件の launch が高速になり、 subagent が裏で並列に走り続けたまま、 main が最後まで燃料切れせず launch し続けられた。
scale — 900 件 (10x) はどこまで伸びるか (希望)
90 件 / 日は構造上の上限。これを 10 倍化 (例: シナリオ違いを 10 種ずつ仕込んで 900 件 / 日) したくなったらどうなるか。
時間は ── runtime 並列 cap (~13-15) で律速されるので、 ~30-45 分 / run の見込み。 ralph-loop の hard timeout (現在 1 時間) を伸ばせば収まる、 はず。試したことはまだないので、 ここは希望ベース。
まとめ — プロンプトで戦わない
3 つ言える。
- プロンプトで「 N 並列」 を指定しても model は守らない。 3 回試して 3 回とも 1 message に 1 件のままだった。発話分割パターンを言語で矯正するのは難しい。
- 並列度は Agent tool の async launch で勝手に効く。 1 message に 1 件でも、 main が次々と launch を出せるなら subagent は裏で並列に走る。
- 開発者が制御できるのは main の context だけ。 1 dispatch あたり main が抱える text を ~100x 圧縮する subagent self-contained 設計で、 main が燃料切れせずに launch し続けられた。
「 並列度を上げたい」 と思ったら、まず main がどれだけ重くなっているかを見るのがよさそうだ。 1 dispatch で何 KB を context に積んでいるか、 50 件目の launch までに何 KB に膨れているか。並列度はその下流で勝手に決まる。
ついでに save の race も flock で塞いでおくこと。 silent loss は ok=true で返ってくるので、後で気づいたとき本当にやっかい。
前回の記事: 【第7回】サブエージェントのネスト解禁——4 つの「発火モード」と、集約親で Main コンテキストを守る