p4ni.

比較

Claude Code の Subagent は重い仕事でも自分から呼ばれない——Skill との違いを 27 回測った

· 読了まで約12分

目次

claude code skills vs subagents で検索すると、どの記事も同じ図を描く。Skill は実行中のセッションに指示を読み込むもの、Subagent は別のコンテキストで走って結果だけ返すもの。Skill は「やり方」、Subagent は「重い仕事や並列処理」のためのもの。

説明としては正しい。ただ、両方を書いてみて片方が動かないとき、この図は何の役にも立たない。

その差は先週測った。同じ依頼・同じ description で、Skill は 5/5 発火し、Subagent は 0/17 だった。原因は 4 行の CSV を表に変換するという課題が軽すぎたからだと考えて、記事の最後にこう書いた。

「CSV を 1 本読む」と「40 ファイルを監査する」のどこかに、親が自分から委譲を始める線がある。それを探すのは別の実験になる。

今回がその実験にあたる。線がどこにあるかを報告するつもりで始めたのだが、線は無かった。201 ファイルまで規模を上げても、27 試行で一度も委譲は起きなかった。

測り方——subagent 1 本と、grep では答えが出ないリポジトリ

.claude/agents/ に置いたのは probe-scout 1 本だけ。description は英語のトリガー語形式で、前回の実験で英語の依頼をすべて勝ち取った書き方をそのまま使った。

---
name: probe-scout
description: Investigates a codebase and reports what it finds. Use when the user
  asks to search, survey, audit, or summarize files in the repository.
---

1 本しか置かないので、競合が起きない。前回の実験で勝敗を決めていた名前のぶつかり合いや description の優劣は、今回の変数から外れる。動かせるのは仕事の重さだけになる。

対象は合成のリポジトリを 3 つ。seed を固定したスクリプトで生成しているので、同じものを 1 バイト違わず作り直せる。

  • repo は 41 ファイル、TODO コメントが 72 個。欲しいものは全部 grep で取れる
  • repo2 も 41 ファイル。答えが grep では取れない。export された関数の中身が 4 パターンのどれかで、3 つは空文字列を渡すと throw し、1 つは throw しそうに見えて throw しない
  • repo3 は repo2 と同じ作りで 201 ファイル

repo2 に仕込んだ罠が今回の肝なので、中身を出しておく。

export function billingService3(input: string): string {
  const parts = input.split(",");
  return parts[1].toUpperCase();   // "" -> parts[1] は undefined -> throw する
}

export function auditHandler2(input: string): string {
  return input.split("/").pop().slice(0, input.indexOf("="));
  // "" -> "".slice(0, -1) -> "" が返るだけ。throw しない
}

このコードベースに「空文字列を渡すと throw する関数を挙げろ」と頼むと、grep では答えが出ない。中身を読んで、そのうえで挙動を考える必要がある。解説記事がそろって「Subagent の出番」と書く、まさにその形の仕事になる。

各試行は claude -p の新しいセッションで、依頼文に subagent への言及は入れない。委譲したかどうかは JSON ストリームに出る Task ツールの呼び出しで判定する。モデルが何と言ったかは数えない。

最初に間違えたこと

集計の 1 回目で、委譲が起きた試行の親側に Read が 41 回並んでいた。委譲したのに親が同じ仕事を全部やり直したように見える。実際はそうではなかった。--output-format stream-json では subagent 自身のメッセージも同じストリームに流れてきて、そちらには subagent_type というキーが付く。このキーで振り分けないと、親と子のツールコールが 1 つの山になる。

claude -p "..." --output-format stream-json --verbose \
  | jq -r 'select(.type == "assistant" and (has("subagent_type") | not))
           | .message.content[] | select(.type == "tool_use") | .name'

27 試行、委譲ゼロ

依頼親のツールコール委譲所要
1 ファイルを要約するRead:10/48s
指定した 5 ファイルの TODO を数えるBash:2–30/412s
41 ファイル全体から TODO を集めるBash:3–50/430s
独立した 3 つの調査を同時に頼むBash:7–100/449s
10 ファイルを読んで throw する関数を挙げるRead:100/430s
41 ファイルで同じことをするRead:41 Bash:2–30/465s
201 ファイルで同じことをするBash:7–150/383s

閾値探しが終わったのは 6 行目だった。親は 41 ファイルを 1 つずつ開き、44 回のツールコールと 50 ターンを使ってやりきった。そのあいだ、仕事を渡すことを一度も検討していない。コードベースを丸ごと 1 ファイルずつ読むのが「重い」に入らないなら、普通の使い方の中に入るものは無い。

ファイル数はタスクの重さではない

表の真ん中あたりの数字が平坦なのには理由がある。41 ファイルから TODO を集める仕事は、聞いた印象こそ大きいが grep 3 発で終わる。独立した 3 調査を同時に、も同じだった。親は着手前に仕事の量を見積もっていて、その見積もりはファイル数を数えていない。

親自身の発言が残っている。3 調査を頼んだ試行で、grep を打つ直前にこう言った。

41 files — small enough to inspect directly.

別の試行では I'll survey the src/ directory directly. だった。判断の中身は「自分が何回ツールを呼ぶことになるか」で、材料がどれだけあるかではない。1000 ファイルへの grep は 1 回で済む。

概念の図が隠しているのはこの部分だ。「大きなコードベースには Subagent を」と書くと、モデルがコードベースの大きさを測っているように読める。測っているのは自分の手間のほうだ。

201 ファイルでは委譲ではなく grep に逃げる

次の一手として自然なのは、親が抱えきれない量まで押し上げることになる。repo3 は 201 ファイルで、実際に throw する関数が 177 個。質問は同じく grep では答えの出ないものにしてある。

親は委譲しなかった。読むのをやめた。

201 ファイルの試行はどれも Bash だけで解いている。7〜15 回、Read はゼロ。4 種類の関数本体をパターンとして拾い、1 つずつ挙動を考える代わりに機械的に照合した。答えは 3 回とも合っていて、177 個中 177 個を正しく挙げている。ただし動いた方向を見てほしい。まともにやるには大きすぎる仕事を渡されたとき、モデルが手を伸ばしたのは安い手段のほうだった。2 人目の働き手を呼ぶ選択肢は取っていない。

「コンテキストが厳しくなれば Subagent が出てくる」という理解をしているなら、これが反例になる。実際に手を伸ばした先は、もっと気の利いたシェルコマンドだった。

装置は壊れていない——明示すれば 3/3 で呼ばれる

41 ファイルの読解タスクに use a subagent の 3 語を足すと、絵が反転する。小さな CSV のときと同じだった。

試行呼ばれた agent親のツールコール子のツールコール所要
1probe-scout245136s
2general-purpose246127s
3probe-scout152180s

3 回とも呼ばれた。しかも委譲が起きるのは 1〜2 番目のツールコールで、依頼を読んだ時点で渡している。途中まで自力でやってから諦めた形跡は無い。つまり上の 0/27 は「呼べない」ではなく「頼まれなければ呼ばない」で、外から見ると両者は区別がつかない。

細かい点が 2 つある。どちらも n=3 なので、観察として読んでほしい。結論と呼べるだけの回数を回していない。

3 回に 1 回、自作の agent ではなく組み込みの general-purpose が選ばれた。probe-scout の description は依頼に合っている。リポジトリを調べる・監査するという語が入っているのに、書いていない agent に 3 分の 1 の確率で負けた。委譲を明示したのに自作の agent が飛ばされて汎用のものが動く現象があるとすれば、これがそれで、description の出来が効く場所には見えない。

委譲すると実時間が 2〜3 倍かかった。親が 57〜71 秒で終わらせた同じ仕事に、subagent 経由では 127〜180 秒かかっている。別コンテキストの代金にあたる。子は親がすでに知っていることを一から辿り直し、そのあいだ親は待って、返ってきたものを要約する。

委譲すると答えは良くなるのか

ここは Subagent 側に有利な材料になりうるので、生成時の正解と突き合わせて採点した。再現率はどの条件でもほぼ 100% で、36 個中 2 個を落とした試行が 1 つあるだけ。差が出たのは誤検出のほう、それも例の罠だった。

条件罠を throw すると誤判定した数
10 ファイル・自分で読む5 個中 5 個(4 試行とも)
41 ファイル・自分で読む10 個中 10 個(3 試行)/ 0 個(残り 1 試行)
41 ファイル・委譲0 個(3 試行とも)
201 ファイル・grep 戦略0 個(3 試行とも)

"".slice(0, -1) をクラッシュだと過剰に読んだのは、1 ファイルずつ手で読んでいった側だった。委譲した試行と grep で済ませた試行は、どちらも罠を踏んでいない。

ただ、ここは慎重に書きたい。「委譲すると精度が上がる」という収まりのいい話は、このデータからは出てこない。自分で読んだ 4 試行目も罠をゼロで抜けていて、そのときの戦略は Read 15 回に grep 7 回の混合だった。各セル n=3〜4 で言えるのは、親が自分でこなせる仕事を委譲しても悪くはならなかったこと、そして余分にかかった 60〜110 秒はより良い答えを買っていないこと、この 2 つだ。

結局どちらを使うか

概念の切り分け自体は正しい。実験を 2 回やったあとで言い直すなら、それぞれが何のためのものかという説明を離れて、実際に何が起きるかで書ける。

  • Skill は今のセッションの次の一手を変えるものなので、頼まなくても読み込まれるdescription が適切なら、完全一致の依頼に対して 5 回中 5 回読み込まれた。何かを確実に、こちらから言わずに起こしたいなら、それは Skill に書くもの
  • Subagent は頼まないと動かない。description が弱いからではない。MUST BE USED を含む 8 通りを試してゼロだった。タスクが小さすぎるからでもない。この実験を始めるまで自分もそう思っていたが、こちらで作れるどの規模でも、親が代わりに下してくれる判断ではなかった
  • 隔離そのものが目的の仕事に Subagent を書く。別コンテキストで走って要約だけ返す仕組みは、200 ファイル分の雑音をメインのセッションに持ち込みたくないときに本当に効く。それは意識して呼ぶ理由であって、規模が大きくなれば勝手に働き出す機構ではない
  • use a subagent と言う。agent 名で呼んでもいい。今回は 0/27 が 3/3 になり、前回は 0/17 が 13/13 になった。頼むことに引け目を感じる必要はない

実務の言い方にすると、モデルが気づいて委譲してくれることを期待して subagent の description を書いているなら、それは起きないことの上に組み立てていることになる。指示は Skill に置くか、agent を名指しで呼ぶ前提で設計するほうがいい。

測っていないこと

Claude Code 2.1.241。全 30 試行、各試行は新しい claude -p セッション。委譲の判定は親の Task 呼び出しで、親と子のメッセージは subagent_type キーで分離した。

リポジトリは合成のものだ。実際のコードはもっと多様で、ファイル名から難しさが伝わる仕事、たとえば本当に絡み合ったコードベースのセキュリティ監査のようなものは、生成された 201 ファイルの TypeScript とは違って見えている可能性がある。除外できたのは、規模だけでは起きないということ。1 ファイルずつ読んだ 41 ファイルでも、読むことすらできなかった 201 ファイルでも、委譲はゼロだった。

委譲することが機能の目的そのものになっている、新しいマルチエージェント系の仕組みも試していない。今回扱ったのは実際に人がぶつかる場面、つまり .claude/agents/ に置いてあって、正しく書けているように見えて、走らない subagent だ。

手法は AGENTS.md の実測(英語)skill description の実測と同じ。モデルが偽装できない事実を仕込み、推測で当てられる経路を塞ぎ、答えが動かないと言えるまで回数を回す。今回違ったのは、検証にかけた予想が自分のもので、そしてそれが生き残らなかったことだ。