1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サブエージェントは 3,093 件のツール結果を読み、親には 1 件も渡さない — だから報告は裏取りするしかない

1
Posted at

3行で

  • ログ 389 ファイルから委譲 138 件を取り出した。サブエージェントは 1 回の委譲あたり中央値 20 回ツールを呼び、合計 3,093 件のツール結果を読んでいた。
  • 親に返るのは 中央値 1,151 字の文章 1 つだけ。読んだツール結果は 0 件が親に届く。
  • サブエージェントが書いた本文 530,652 字のうち、親に届いたのは 55.0%残りは親が見ることのないまま消える。

委譲は「文脈を汚さない」ための仕組みだ

サブエージェントに仕事を渡す理由ははっきりしている。大量のツール出力を親の文脈に入れないためだ。100 ファイルを探させて、結論だけ受け取る。ファイルの中身は親のウィンドウに入らない。

これは設計として正しい。問題は、その「入らない」がどのくらいなのかを、私が数字で把握していなかったことだ。測った。

サブエージェント側でやったこと(1委譲あたり)
  ツール呼び出し 中央値 20 / 平均 22 / 最大 70 / 合計 3,093
      0 回以下    8 =   5.8%
      5 回以下   22 =  15.9%
     20 回以下   71 =  51.4%
     50 回以下  125 =  90.6%
  output_tokens 中央値 12,298 / 最大 49,545 / 合計 2,021,336

**中央値 20 回。**半分の委譲が 20 回以上ツールを呼んでいる。最大は 70 回。1 委譲あたり中央値 12,298 トークンを出力していて、合計は 202 万トークンになる。

一方、親が受け取るものはこれだ。

親に返ってきたもの(Agent の結果 66 件)
  指示 中央値 816 字 / 最大 4,201 字
  戻り 中央値 1151 字 / 最大 15,404 字
  サブが書いた本文 530,652 字 → 親に届いた 292,082 字 = 55.0%
  サブが読んだツール結果 3,093 件 → 親に届いた 0 件

**816 字の指示を出して、1,151 字が返る。**その裏で 20 回のツール呼び出しが起きている。

0 件、というのが要点

数字のうち一番大事なのは 3,093 → 0 だ。

サブエージェントが読んだ grep の結果、ファイルの中身、コマンドの出力。**そのどれ 1 つも親には届かない。**届くのは、サブエージェントがそれらを読んだうえで書いた文章だけだ。

つまり親から見ると、サブエージェントの報告は検証不能な二次情報になる。

サブ:  20回ツールを呼ぶ → 3,093件の一次データを読む
        ↓
        1,151字の文章にまとめる
        ↓
親:    文章だけを受け取る(一次データは無い)

親は「そのファイルに本当にそう書いてあったか」を確かめられない。確かめるには、親が自分でもう一度読むしかない。それは委譲した意味を打ち消す行為だ。

この非対称は避けられない。委譲の利益(文脈が汚れない)と、委譲のコスト(検証できない)は、同じ性質の裏表だからだ。文脈に入らないから安いし、文脈に入らないから確かめられない。

55% は消える

もう 1 つ気になったのがこれだ。

サブが書いた本文 530,652 字 → 親に届いた 292,082 字 = 55.0%

サブエージェントが書いた文章のうち、親に届くのは 55.0%。残りの 45% は、サブエージェント自身の中間思考や経過報告で、最終的な戻り値には入らない。

これも設計通りではある。だが「サブエージェントが 20 回ツールを呼んで、その過程で 45% の文章を書いて捨てている」というのは、どこで判断が起きたかが親から見えないということでもある。前に書いた通り、思考の本文はログにも残らない。過程は二重に見えない。

それで、報告をどう扱うか

1. 報告を根拠として使うなら、一次データで裏を取る。特に「ログを見たらこうなっていた」「調べたらこの件数だった」という数字の報告は、サブエージェント側で検証されていない。私は自分の運用規約にこう書いている。

- **サブエージェントの報告は実物で裏取りする**: 委譲成果の監査では自己申告を鵜呑みに
  せず、各主張を diff・テスト結果・ログの実データに突き合わせて確認する。

これは精神論ではなく、構造上そうなっているからそう書いてある。3,093 件が 0 件になる経路に、検証は含まれていない。

2. 裏取りが不可能な仕事を委譲しない。「探して結論を出す」は委譲に向く。結論が間違っていても、親が自分で 1 回検索すれば分かるからだ。「大量のファイルを読んで要約する」は向かない。裏取りに、委譲したのと同じ量の作業が要る。

3. 一次データそのものを返させる。「ファイルパスと行番号を返せ」「コマンドと出力を返せ」と指示すれば、戻り値に一次データが入る。1,151 字という中央値には、まだ余裕がある(最大は 15,404 字だった)。要約ではなく証拠を返させるほうが、後で使える。

3 番目が一番効いた。要約を求めると要約が返ってくるが、それは検証できない。「どこを見てそう言ったか」を返させると、親が数行の確認で済む。

測るスクリプト

#!/usr/bin/env python3
"""委譲したサブエージェントが「見たもの」と「返したもの」の差を測る。

    python3 delegation_gap.py <ログのルート>

サブエージェントの作業は isSidechain の行に、agentId ごとに残る。
親に返るのは Agent ツールの結果1つだけ。その落差を数える。
"""
import sys, os, json, glob, collections, statistics as st


def result_text(b):
    ct = b.get("content")
    if isinstance(ct, list):
        return "".join(x.get("text", "") for x in ct if isinstance(x, dict))
    return ct if isinstance(ct, str) else ""


def main(root):
    files = glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)
    # agentId -> サブエージェント側の作業量
    agents = collections.defaultdict(
        lambda: {"tool": 0, "result": 0, "text": 0, "out": 0})
    # 親に返ってきたもの
    returned = []
    subtypes = collections.Counter()

    for f in files:
        names, inputs = {}, {}
        for ln in open(f, errors="replace"):
            try:
                d = json.loads(ln)
            except json.JSONDecodeError:
                continue
            msg = d.get("message") or {}
            content = msg.get("content")
            side = bool(d.get("isSidechain"))

            if side:
                a = agents[d.get("agentId") or "?"]
                a["out"] += (msg.get("usage") or {}).get("output_tokens", 0)

            if not isinstance(content, list):
                continue
            for b in content:
                if not isinstance(b, dict):
                    continue
                if side:
                    a = agents[d.get("agentId") or "?"]
                    if b.get("type") == "text":
                        a["text"] += len(b.get("text", ""))
                    elif b.get("type") == "tool_use":
                        a["tool"] += 1
                    elif b.get("type") == "tool_result":
                        a["result"] += 1
                    continue
                # 親側: Agent 呼び出しと、その戻り
                if b.get("type") == "tool_use":
                    names[b.get("id")] = b.get("name")
                    inputs[b.get("id")] = b.get("input") or {}
                elif (b.get("type") == "tool_result"
                      and names.get(b.get("tool_use_id")) == "Agent"):
                    i = inputs.get(b.get("tool_use_id"), {})
                    returned.append((len(i.get("prompt", "") or ""),
                                     len(result_text(b))))
                    subtypes[i.get("subagent_type") or "(既定)"] += 1

    if not agents:
        print("サブエージェントの記録が無い")
        return 1

    tools = sorted(a["tool"] for a in agents.values())
    outs = sorted(a["out"] for a in agents.values())
    print(f"ログ {len(files)} ファイル / 委譲(agentId){len(agents)}")
    print()
    print("サブエージェント側でやったこと(1委譲あたり)")
    print(f"  ツール呼び出し 中央値 {st.median(tools):.0f} / "
          f"平均 {st.mean(tools):.0f} / 最大 {max(tools)} / 合計 {sum(tools):,}")
    for k in (0, 5, 20, 50):
        c = sum(1 for x in tools if x <= k)
        print(f"    {k:3d} 回以下 {c:4d} = {100 * c / len(tools):5.1f}%")
    print(f"  output_tokens 中央値 {st.median(outs):,.0f} / "
          f"最大 {max(outs):,} / 合計 {sum(outs):,}")
    print()

    if returned:
        p = [x for x, _ in returned]
        r = [y for _, y in returned]
        text_total = sum(a["text"] for a in agents.values())
        print(f"親に返ってきたもの(Agent の結果 {len(returned)} 件)")
        print(f"  指示 中央値 {st.median(p):.0f} 字 / 最大 {max(p):,}")
        print(f"  戻り 中央値 {st.median(r):.0f} 字 / 最大 {max(r):,}")
        print(f"  サブが書いた本文 {text_total:,} 字 → 親に届いた {sum(r):,}"
              f"= {100 * sum(r) / text_total:.1f}%")
        print(f"  サブが読んだツール結果 {sum(a['result'] for a in agents.values()):,}"
              f"→ 親に届いた 0 件")
        print()
        for k, v in subtypes.most_common():
            print(f"    {v:4d} {k}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else
                  os.path.expanduser("~/.claude/projects")))
python3 delegation_gap.py ~/.claude/projects

この記事の限界

  • 1人分のログである。389 ファイル・委譲 138 件。
  • 委譲 138 件に対し、親側で結果が取れたのは 66 件。差の 72 件は、ワークフロー経由で起動したものや、結果が別ファイルに記録されたものを含む。**「1委譲あたり」の作業量(138 件で計算)と「戻りの長さ」(66 件で計算)は、母集団が違う。**同じ委譲について両方を突き合わせてはいない。
  • 「親に届いた 0 件」は、ツール結果ブロックが 0 という意味である。サブエージェントが本文の中に出力を引用していれば、内容としては届いている。引用の有無は数えていない。
  • **Explore が 49 件と大半を占める。**探索用のサブエージェントは元々「結論だけ返す」設計なので、この偏りが 55.0% という数字を押し下げている可能性がある。
  • **委譲が悪いという話ではない。**202 万トークンぶんの作業を親の文脈に入れずに済んでいる。測ったのは利益ではなくコストのほうだけだ。

まとめ

  • 委譲 1 回あたり、サブエージェントは中央値 20 回ツールを呼ぶ。合計 3,093 件の一次データを読む。
  • 親に届くのは中央値 1,151 字の文章 1 つ。一次データは 0 件
  • サブエージェントが書いた文章の 45% は親に届かない
  • だから報告は構造上、検証不能な二次情報になる。裏取りするか、証拠ごと返させるかしかない。

委譲が安いのは、親が見ないからだ。同じ理由で、親は確かめられない。


関連

一次データが0件しか返らない相手に、規約が届いたかどうかも同じく返ってきません。委譲先に何をどう渡すかは、報告の信頼性そのものを決めます。

委譲を含む構成パターン別に、どの層に何を置くかをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?