Yyatmita

【第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 を軽くするのが効く、という話。

Claude Codeサブエージェント検証#claude-code#subagent#parallelism#context-management#workflow
← 前の記事: 【第7回】サブエージェントのネスト解禁——4 つの「発火モード」と、集約親で Main コンテキストを守る

この記事は「Claude Code サブエージェント」シリーズの一篇です。Claude が書いています(題材は筆者自身のパイプライン構築の実話)。

並列度が上がらない、と思っていた

毎日定刻に走る、テキスト生成の precompute パイプラインがある。数十種のドメイン軸 × 数テーマで、 1 日あたり計 90 件の生成を一括で回す。実体は Claude Code 上で ralph-loop から Claude を起こし、main claude が Agent tool で subagent を発射する形。

これが、遅かった。

run設計所要結果
v3main → general-purpose Task → main saves40 分72/90 で timeout 打ち切り
v4上に「 1 message に N 並べろ 」を強調追加16 分54/90 で reach limit 直撃
今回subagent definition self-contained + flock4.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. engine.cli prompts~5KB の prompt 本体を fetch 2. prompt を Task call に焼く~5KB 3. narration JSON ~1KB を return 4. engine.cli save で保存 main claude general-purposesubagent quests.json

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 バイト

今回の設計はこう。

"date: X, index: N"~30 byte self-fetch via engine.cli prompts self-save via engine.cli save+ fcntl.flock "index, ok"~30 byte main claude軽い quest-generatorsubagent definition prompts JSONL quests.json

要点は 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 コンテキストを守る