Yyatmita

【第6回】Claude Code サブエージェントで並列オーケストレーション——プロセス数とメモリ増減を計測してみた

Claude Code のサブエージェントで取り込み工程を 1 ファイル 1 ワーカーに並列 fan-out。API 413 (Request too large / max 32MB) の回避に加え、ワーカーを増やすとプロセス数・メモリ (RSS) がどう変わるかを ps で実測(3→21 ファイルで RSS ×1.56 の頭打ち)。foreground/background のプロセスモデル、ReAct 自作基盤との使い分けも整理する。

Claude Codeサブエージェント検証#claude-code#subagent#fan-out#ralph-loop#performance
← 前の記事: 【フォロー】CLAUDE.md 継承の誤情報はどこから来たか——claude-code-guide の回答を追跡する

前回(第5回)で「自作しない、スキルでラップして着替えさせる」「サブエージェントはネストできない」という話をして、連載は一区切りとした。が、その設計をそのまま実戦投入したら綺麗な事例が出たので、しれっと第6回として続ける。

複数の PDF 書類を読み取って構造化データに起こす社内パイプラインで、取り込み工程が API の 32MB 制限にぶつかって落ちた。直し方を考えるうちに「これは map 処理だった」と腑に落ち、Claude Code のサブエージェントで fan-out したら 413・速度・メモリが一度に片づいた、という記録。前回の「ネスト禁止」も、ここでは安全装置として効いてくる。

フック: 単体は数 MB なのに 32MB 超過で落ちる

取り込み工程(PDF を 1 枚ずつ読んで Markdown に起こす)で、返ってきたのはこのエラー:

API error: 413 Request too large (max 32MB)

Request too large / max 32MB / 413 ― この文言で検索してここに来た人へ。単体ファイルを小さくしても直りません。真因は下記の「会話履歴への base64 累積」です。

奇妙だったのは、入力 PDF は 1 個あたり最大でも 6MB 弱、大半は 2〜4MB で、32MB にはまるで届かないこと。なのに 21 個(合計 61MB)のまとまった入力で必ず落ちる。リトライしても 3 回とも同じ 413 で、費用だけ溶けた。

エラーに添えられる "Try with a smaller file" は定型文で本質を外している。問題は単体ファイルの大きさではなかった。

真因: 読んだ PDF は会話履歴に「残り続ける」

マルチターンの LLM では、一度 Read したファイルは以降の全ターンのリクエストに毎回再送される。PDF は base64 で会話履歴に載る。

取り込み工程は「不足分を順次 Read して 1 枚ずつ起こす」設計だった。1 セッションで 21 枚を読み進めると、

ターンが進むほど添付が積み上がる
2MB + 2MB + 4MB + … → ある時点で合計が 32MB を超える → 413

単体は小さいのに、1 セッションに全部読み込む設計だから累積で爆発する。会話のターン数が異常に多かった(30 ターン超)のがその裏付けだった。

発想の転換: これは map 処理だった

ここで気づく。取り込み工程は本来 入力 1 ファイル → 出力 1 ファイル の 1 対 1 写像だ。各ファイルは独立していて互いを参照しない。関数型でいう map そのものである。

map の各要素は独立に処理されるべきもの。なのに実装は「1 つの巨大なセッションに全要素を詰め込む」形になっていた。map を map らしく書いていなかったのが事故の正体だった。

直し方は自明になる ― 1 ファイル = 1 セッション(サブエージェント) に分解する。

修正: Claude Code のサブエージェントで fan-out

オーケストレータ(親セッション)の役割を「振り分け」だけに限定する。

  • 親は ファイルを 1 つも Read しない
  • 未処理のファイルごとに、Task ツールで サブエージェントを 1 つ起動する(1 メッセージにまとめて投げれば並列に走る)
  • 各サブエージェントは 自分の担当 1 ファイルだけを Read して結果を Write し、終わる

こうすると、PDF の base64 は サブエージェントの隔離コンテキストに閉じる。親には「書いた」という短い結果しか返らない。リクエストサイズはファイル数に依存しなくなる。

使い分け: ReAct 自作基盤と Claude Code サブエージェント

ここは混同しやすいので整理しておく。以前 並列 ReAct エージェントでローカル LLM と Claude を比べたで、ドキュメントチェックを 自作の ReAct エージェント基盤で並列化した。今回使ったのは Claude Code 標準のサブエージェント。別物なので使い分ける。

ReAct 自作基盤Claude Code サブエージェント
実体別プロセスを起動同一 CLI プロセス内の並行エージェントループ(in-process
プロバイダOllama / Claude を env で差し替え可Claude 固定(親のセッションに従う)
結果の受け渡しファイル出力(stdout を介さない)サブエージェントの戻り値 + ファイル
ネスト設計次第不可(サブエージェントはさらにサブエージェントを産めない)
向くケースプロバイダ切替・プロセス完全分離・リソース隔離が要るセッション内で独立な小タスクを大量に並列したい(今回の map)

ざっくり言うと、ローカル LLM に差し替えたい・プロセスを完全に隔離したいなら ReAct 自作基盤Claude Code のセッションの中で「独立した大量の小仕事」を隔離コンテキストで捌きたいなら標準サブエージェント。今回は後者がドンピシャだった。

罠その 1: サブエージェントの戻り値でテキストが再汚染する

一度ハマった。base64 の累積は防げても、サブエージェントが「起こした全文」を戻り値として親に返すと、今度はテキストとして親の履歴に積み上がる。形を変えて同じ問題が再発する。

これは ReAct 基盤の記事で「応答テキストを stdout 経由で受け取らない」と書いたのと同じ話で、サブエージェントでも効く。対策は単純で、サブエージェントの最終返信を 1 行のステータスに限定する。

WROTE output/<name>.md

本文は書き出したファイルにだけ存在すればよく、親に返す必要はない。後続の工程はファイルを直接読む。

罠その 2: 「ネスト禁止」がここで効く(再爆発しない)

「サブエージェントがさらにサブエージェントを呼んで再爆発しないか」を心配したが、これは 第5回で触れた サブエージェントのネスト禁止がそのまま効く。Claude Code のサブエージェントは定義上さらに子を産めないので、ワーカーが勝手に fan-out を増殖させることが 構造的に起きない。制約だと思っていたものが、ここでは安全装置として働いた。

その上で、ワーカーは専用エージェント(toolsReadWrite に限定)として定義した。これは再帰防止というより 最小権限の hygieneと、変換仕様をエージェントのシステムプロンプト側に置いて発射のたびに注入せずに済ませる(DRY)ため。「仕様の制約に逆らわず、層の選び方で逃げる」という第5回の発想がそのまま活きる。

罠その 3: 予算と時間はファイル数で動かす

並列ワーカー方式にすると、固定のタイムアウト・費用上限が合わなくなる。3 ファイルには過剰、50 ファイルには不足。そこで 実行直前にファイル数から動的に算出する。

  • 費用上限 = ファイル数 × 単価(下限あり)
  • タイムアウト = ファイル数 × 秒/枚(下限・上限でクランプ)

静的な設定値は「単独起動時のフォールバック」に格下げし、実運用の値は呼び出し側が組み立てる。

検証: 落ちていた入力そのもので測る

413 で止まっていた実例そのもの(PDF 21 個 / 合計 61MB)で計測した。

指標修正前修正後
413 エラー3 回(全リトライで失敗)0 回
完走上限到達で費用空費完走・全 21 件変換・検証 PASS
実時間(完走せず)262 秒

速度: map を並列で回した効果

逐次なら 21 × 約 40 秒 ≈ 14 分 の見積もりに対し、並列実測は 約 4.4 分(≒ 3 倍速)。線形(21 倍速)にはならない ― 並列上限(ハーネス側で約 10 同時)、API レート制限、末尾レイテンシで頭打ちになる。

メモリ: ファイル数を 7 倍にしても頭打ち

ここが in-process サブエージェントの効きどころ。「ワーカーを 21 個出したらメモリが 21 倍では?」という不安があったが、Claude Code のサブエージェントは OS プロセスを fork しない。親プロセス(CLI 1 個)の中で並行するエージェントループであり、ランタイムや常駐サービスは全ワーカーで共有される。

baseline 差分で実測(実行起因のプロセスだけを抽出)すると:

3 ファイル21 ファイル倍率
実行中の CLI プロセス数11不変
ピーク RSS(実行起因)約 360 MB562 MB×1.56

ファイル数 7 倍に対して、メモリ増分は 1.56 倍。ピークを決めるのは「同時に生きているファイルのコンテキスト数」で、これは並列上限で天井がある。完了したワーカーのコンテキストは随時解放される。RSS の時系列も、立ち上がり → 並列ピーク → ドレインで漸減、という教科書どおりの形を描いた。

もし安直に「1 ファイル = 1 CLI プロセス」で並列化していたら(= ReAct 自作基盤の素朴な実装)、CLI 1 個が数百 MB のベースラインを持つので 21 ファイルで数 GB に膨らみ、今度はメモリで死んでいた。in-process のサブエージェントはそれを避ける選択でもある。

補足: foreground / background とプロセスモデル

サブエージェントの戻り値・並列・フォアグラウンド/バックグラウンド・ネストの基本挙動は 第2回で実験済みなのでそちらに譲るが、今回プロセスモデルの面を ps で追加計測したので一点だけ。

気になって run_in_background: true(非同期)でもサブエージェントを起動して測ったが、background でも新しい OS プロセスは増えなかった。生存中ずっと claude プロセス数はフラットのまま。foreground と同じく in-processで、run_in_background が変えるのは「親が待たずに先へ進み、完了通知を受ける」というスケジューリング/通知だけ。プロセスモデルは fg/bg で同じだった。

ただし今回の取り込み工程では foreground を使う。理由は完成判定が「出力ファイルの本数」で行われるから。background で投げて親が先に exit すると、まだファイルが書かれていない段階で判定が走って 偽の失敗になる。親は全ワーカーの完了を待ってから終わる必要がある(バリア同期)。background が活きるのは「親が待つ間に別の仕事も進めたい」ケースで、親が待つだけの今回は foreground でよい

俯瞰: パイプライン全体を map-reduce で見る

この一件は、パイプライン全体を map-reduce の言葉で整理すると腑に落ちる。

工程map-reduce 上の役割性質
分割(まとまった入力 PDF → 個別ファイル)partitionテキスト抽出ベース、API に画像を送らない
取り込み(ファイル → 構造化レコード)map1 対 1・独立・並列可
集計・統合・レポート生成reduce全レコードを畳む・横断処理

取り込み工程が独立な map だからこそ、分割して並列化すると 413・速度・メモリが同時に解決した。3 つが 1 つの修正で効いたのは、どれも「要素を独立に扱う」という map の性質から出てくる帰結だからだ。

逆に reduce 側(複数レコードを横断して集約する工程)は、同じノリで「ファイル並列」にすると 横断ではじめて見える集計結果を取りこぼす。reduce を速くしたいなら、並列化ではなく「全部読まずに検索で投入量を減らす」方向になる。独立な写像は並列で、統合タスクは投入削減で攻める ― この住み分けが効く。

はまり所まとめ

  • 413 の "smaller file" は ミスリード。多くは「1 セッションへの累積添付」が真因
  • 1 対 1 写像の工程を 1 セッションに全部詰める と、入力数に比例してリクエストもメモリも膨らむ
  • fan-out する時は 戻り値も絞る(全文を親に返すとテキストで再汚染)
  • 再爆発はサブエージェントの ネスト禁止が防いでくれる。ツール最小化は最小権限の hygiene
  • Claude Code のサブエージェントは fork ではない。メモリは並列上限で頭打ちになり、ファイル数に線形には増えない
  • タイムアウト・予算は 入力規模から動的に組み立てる
  • そして ― プロバイダ切替やプロセス隔離が要るなら ReAct 自作基盤、セッション内の独立並列なら Claude Code サブエージェント、と使い分ける