自分のコンテンツリポジトリに対して、エージェントに素朴な質問を投げました。「この中でいちばん大きい MDX ファイルはどれですか」。
返ってきたのは、実際の最大ファイルではありませんでした。
手元で find と wc -c を組み合わせて確かめると、答えは 53,595 バイトの記事です。エージェントが挙げてきたのは、それより一回り小さいファイルでした。ツールの実行自体は成功していて、エラーもありません。
原因にたどり着くまで、しばらくかかりました。ツールの出力が 10 万文字の境界を越えていたのです。
10万文字を超えた出力に何が起きているのか
Managed Agents では、agent_toolset と MCP ツールの出力が 10 万文字(およそ 25K トークン)を超えると、全文がサンドボックス内のファイルへ自動的に書き出されます。モデルに渡るのは切り詰められたプレビューと、そのファイルパスです。必要になった時点で全文を読み出せる、という設計になっています。
コンテキストが溢れて処理そのものが失敗するよりは、はるかに良い挙動です。ログ全文やビルド出力を扱うタスクが、境界を意識せずに走るようになりました。
ただ、この「切り詰められたプレビュー」が何を切り詰めているのかは、仕様を読むだけでは分かりませんでした。実際に境界の向こう側へ出力を送り込んで、何が残り何が消えるのかを測るまで、私は事態を誤解していました。
実測: 手元のリポジトリで、どのコマンドが境界を越えるか
測定対象は、日本語 791 本・英語 791 本の MDX を抱えた実際のコンテンツリポジトリです。日常的にエージェントへ投げているコマンドを、そのまま流してバイト数を数えました。
| コマンド | 出力バイト数 | 10万文字比 | 行数 |
grep -rn 'claude' content/articles/ja --include='*.mdx' | 1,201,154 | 12.01x | 5,595 |
cat src/data/articles.json | 822,582 | 8.23x | — |
find content -name '*.mdx' -exec wc -c {} + | 149,838 | 1.50x | 1,654 |
ls -R content | 87,135 | 0.87x | — |
4 つのうち 3 つが境界を越えていました。特別に乱暴なコマンドは一つもありません。どれも「ファイル一覧を取る」「横断的に検索する」という、エージェントに最初に覚えさせる類の操作です。
規模が育つと、日常の操作がそのまま境界を越える。これが出発点でした。
生き残る行と、必ず消える行
次に、先頭 10 万文字に何行が収まるかを数えました。
| コマンド | 全行数 | 先頭10万文字に残る行 | 生存率 |
find ... -exec wc -c {} + | 1,654 | 1,105 | 66.8% |
grep -rn 'claude' ... | 5,595 | 466 | 8.3% |
生存率は 8.3% から 66.8% まで大きくばらつきます。ここまでは想像の範囲でした。
問題は、この数字が実務上ほとんど意味を持たないことです。
find ... -exec wc -c {} + の最終行、つまり 1,654 行目はこうなっています。
658964 total
私が知りたかったのは、この行と、サイズ順に並べたときの上位数件でした。そして最終行は、定義上、先頭カットで必ず失われます。
先頭 10 万文字の最後に残っていたのは、こういう行でした。
9377 content/articles/en/claude-code/claude-code-gate-and...
アルファベット順で en/claude-code/ のあたりにいる、何の変哲もない 1 ファイルです。ここでプレビューは途切れます。
ここが、事前の予想と逆だった点です。私は「大きい出力は一部しか見えない」と理解していました。実際には「大きい出力は、いちばんどうでもいい部分だけが見える」のです。
CLI の慣習では、結論は末尾に置かれます。wc の total、git の N files changed、テストランナーの N passing, M failing、ビルドツールの終了サマリ。詳細の生存率が 8% でも 67% でも、要約行の生存率はどちらのケースでも 0% でした。先頭カットは、出力の中で最も情報密度の高い一行を、構造的に狙い撃ちにします。
エージェントが間違ったファイル名を答えたのは、幻覚ではありませんでした。渡された素材の中で、いちばんもっともらしい答えを返していただけです。
文字単位のカットは構造を壊す
もう一つ、行指向でない出力を試しました。記事メタデータをまとめた articles.json、822,582 バイトです。
先頭 10 万文字だけを取り出して、パースを試みます。
import json, pathlib
raw = pathlib.Path("src/data/articles.json").read_text()
print(f"bytes={len(raw)} ({len(raw)/100_000:.2f}x)") # bytes=822582 (8.23x)
cut = raw[:100_000]
try:
json.loads(cut)
print("先頭100Kのみ: パース成功")
except json.JSONDecodeError as e:
print(f"先頭100Kのみ: パース失敗 -> {e}")
print(f"カット位置の直前40文字: ...{cut[-40:]!r}")
実行結果です。
bytes=822582 (8.23x)
先頭100Kのみ: パース失敗 -> Expecting property name enclosed in double quotes: line 3087 column 6
カット位置の直前40文字: ...'ull,\n "author": "Claude Lab",\n '
カットは行でもレコードでもなく、文字数で入ります。オブジェクトの途中、キーとキーの隙間で切れました。
行指向のテキストは、削られても劣化が緩やかです。残った 466 行はそれぞれ独立した意味を持ち続けます。構造化データにはその余地がありません。8.23 倍のうち 12% が残ったのではなく、単に壊れたテキストが残っただけです。
JSON を素で吐くツールをエージェントに握らせている場合、境界を越えた瞬間に出力は全損する、と考えて設計するのが安全でした。
境界は動く — 今日通っているコマンドが二ヶ月後に通らない
表の最後、ls -R content は 87,135 バイトで、境界の 0.87 倍でした。今日は退避が起きません。
このリポジトリは 1 記事あたり平均 110.2 バイトずつ、この出力を太らせます。
# 現在値と、境界に到達するまでの残り本数を出す
now=$(ls -R content | wc -c)
ja=$(find content/articles/ja -name '*.mdx' | wc -l)
python3 -c "
n, a = $now, $ja
per = n / a
need = (100_000 - n) / per
print(f'現在 {n} bytes / JA {a} 本 / 1本あたり {per:.1f} bytes')
print(f'到達まで残り約 {need:.0f} 本(日英2本/日なら約 {need/2:.0f} 日)')"
現在 87135 bytes / JA 791 本 / 1本あたり 110.2 bytes
到達まで残り約 117 本(日英2本/日なら約 58 日)
約 2 ヶ月後、このコマンドは黙って向こう側へ渡ります。プロンプトもツール定義も、コードも一行たりとも変わっていないのに、エージェントが受け取る情報の性質が変わる。
私はこの種の劣化がいちばん厄介だと考えています。回帰テストは通ります。ツール呼び出しは成功を返します。変わるのは、答えの正しさだけです。しかも劣化は連続的ではなく、境界をまたいだ日に一気に来ます。
出力封筒 — 判断材料を先頭へ押し出す
対処の方針は単純でした。先頭カットと戦うのをやめて、先頭カットを前提に出力を組み直します。
判断に必要なものを先頭に置き、詳細は後ろに流し、全文は自分でファイルへ書いてパスを渡す。ツールと モデルの間に挟む「封筒」です。
#!/usr/bin/env python3
"""outbox.py — ツール出力を、判断材料が先頭に来る封筒に詰め直す。
使い方: <command> | python3 outbox.py --label build --tail 20
標準入力を受け取り、ヘッダ・シグナル行・末尾行・先頭抜粋を stdout へ出す。
全文は必ずファイルへ書き、そのパスをヘッダに載せる。
"""
import sys, os, re, json, hashlib, pathlib, argparse
SPILL_LIMIT = 100_000 # 全文がファイルへ退避される閾値
HEADER_BUDGET = 2_000 # 判断用ヘッダに割く上限(文字)
EXCERPT_BUDGET = 12_000 # 先頭抜粋に割く上限(文字)
# 既定のシグナル。テストランナーやビルドツールの語彙に合わせて --signal で差し替える
ERROR_RE = re.compile(r"\b(error|failed|failure|fatal|panic|exception)\b", re.I)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--label", default="tool-output")
ap.add_argument("--dir", default=os.environ.get("OUTBOX_DIR", "/tmp/outbox"))
ap.add_argument("--signal", default=None, help="重要行を拾う正規表現(既定はエラー語)")
ap.add_argument("--tail", type=int, default=20, help="末尾から常に残す行数")
args = ap.parse_args()
raw = sys.stdin.read()
lines = raw.splitlines()
# 全文を先に保存する。ここを後回しにすると、途中で落ちたときに何も残らない
digest = hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:12]
outdir = pathlib.Path(args.dir)
outdir.mkdir(parents=True, exist_ok=True)
full = outdir / f"{args.label}-{digest}.txt"
full.write_text(raw, encoding="utf-8", errors="replace")
pat = re.compile(args.signal, re.I) if args.signal else ERROR_RE
hits = [(i, l) for i, l in enumerate(lines, 1) if pat.search(l)]
header = {
"label": args.label,
"bytes": len(raw),
"lines": len(lines),
"spill_ratio": round(len(raw) / SPILL_LIMIT, 2),
"signal_hits": len(hits),
"full_output": str(full), # 全文が要るときはここを読ませる
"tail_lines_included": min(args.tail, len(lines)),
}
parts = ["<<<OUTBOX-HEADER",
json.dumps(header, ensure_ascii=False, indent=2),
"OUTBOX-HEADER"]
if hits:
parts.append(f"--- signal lines (first 40 of {len(hits)}) ---")
parts += [f"{i}: {l[:300]}" for i, l in hits[:40]]
# CLI が結論を置く場所。ここを落とすと封筒の意味がなくなる
parts.append(f"--- tail {header['tail_lines_included']} lines ---")
parts += lines[-args.tail:]
body = "\n".join(parts)
if len(body) > HEADER_BUDGET + EXCERPT_BUDGET:
body = body[: HEADER_BUDGET + EXCERPT_BUDGET] + "\n--- excerpt truncated; read full_output ---"
sys.stdout.write("\n".join([
body,
f"--- head excerpt ({EXCERPT_BUDGET} chars max) ---",
raw[:EXCERPT_BUDGET],
]))
if __name__ == "__main__":
main()
同じ 2 コマンドを封筒に通した実測です。
| コマンド | 素の出力 | 封筒あり | 比率 | 要約行の生存 |
find ... -exec wc -c {} + | 149,838 | 17,182 | 11.5% | あり |
grep -rn 'claude' ... | 1,201,154 | 31,279 | 2.6% | (要約行なし) |
find の側は 11.5% に畳まれ、658964 total が末尾ブロックに残りました。エージェントは合計値を見た上で答えられます。
grep の側で起きたことの方が、実務上は効きました。1,201,154 バイト、境界の 12 倍あった出力が 31,279 バイトになり、退避そのものが発生しなくなります。ファイルを読み直す往復が消え、全文はいつでもパス経由で取りに行ける状態が残ります。
構造化データには別の封筒が要る
JSON にはテキスト用の封筒が使えません。末尾 20 行を足しても妥当な JSON にはならないからです。
構造化データの封筒は、要約を「値」ではなく「形」で作ります。件数・キー一覧・忠実度を落とさない少数のサンプル。そして出力自体が必ずパースできることを、封筒の中で自己検証します。
#!/usr/bin/env python3
"""json_outbox.py — 大きなJSONを、妥当なJSONのまま畳む封筒。"""
import json, sys, hashlib, pathlib, collections
def envelope(path, keep=8, outdir="/tmp/outbox"):
raw = pathlib.Path(path).read_text()
data = json.loads(raw)
# トップレベルが配列でなくても、最初に見つかった配列を対象にする
items = data if isinstance(data, list) else next(
(v for v in data.values() if isinstance(v, list)), [])
digest = hashlib.sha256(raw.encode()).hexdigest()[:12]
outdir = pathlib.Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
full = outdir / f"json-{digest}.json"
full.write_text(raw)
# 全件走査すると大きなファイルで無駄に遅い。形の把握には先頭2000件で十分だった
keys = collections.Counter()
for it in items[:2000]:
if isinstance(it, dict):
keys.update(it.keys())
body = {
"header": {
"source": str(path),
"bytes": len(raw),
"item_count": len(items),
"keys": [k for k, _ in keys.most_common()],
"full_output": str(full),
"sample_policy": f"first {keep} items, full fidelity",
},
"sample": items[:keep],
}
return json.dumps(body, ensure_ascii=False, indent=1), len(raw)
if __name__ == "__main__":
out, n = envelope(sys.argv[1])
json.loads(out) # 自己検証: 封筒の出力は必ずパースできること
print(f"raw={n} envelope={len(out)} ratio={len(out)/n*100:.2f}% parse=OK")
raw=822582 envelope=5688 ratio=0.69% parse=OK
822,582 バイトが 5,688 バイト、0.69% になりました。しかも出力は妥当な JSON です。素の先頭カットが JSONDecodeError を返していたのと対照的でした。
ここで二つ目の、予想と違った発見がありました。
最初はサンプル件数を 50 件ほど入れていました。件数を減らすと情報が落ちる、という思い込みです。実際に使ってみると、8 件と 50 件で判断の質が変わりませんでした。エージェントがこの封筒から取り出しているのは「どんなキーがあるか」「全部で何件か」で、個々のレコードの中身ではなかったのです。
サンプルは形を示すためのもので、内容を伝えるためのものではありませんでした。件数を 8 に落として空いた予算は、キー一覧の網羅性に回した方が効きます。
運用に落とすときの判断
すべてのツールに封筒をかける必要はありません。私はこの基準で切り分けています。
- 封筒をかける: 出力サイズが対象の規模に比例して伸びるもの。
grep / find / git log / ログ取得 / API のリストエンドポイント。今日 0.5 倍でも、半年後に 2 倍を超えます。
- かけない: 出力が定数サイズに収まるもの。設定値の取得、単一レコードの参照、ステータス確認。封筒のヘッダの方が中身より大きくなり、かえって読みにくくなります。
- 順序に意味がある出力は
--tail を厚くする: 時系列ログは末尾が現在です。既定の 20 行では足りない場面があり、デプロイログでは 60 行に上げました。
もう一点、spill_ratio をヘッダに載せているのは、境界への距離を運用中に可視化するためです。0.9 を超えた値がログに出てきたら、そのツールは近いうちに向こう側へ渡ります。到達してから気づくより、二ヶ月前に気づける方がずっと楽でした。
個人開発で自動化を回していると、この手の「静かに壊れる」経路を一つ潰すたびに、任せられる範囲が目に見えて広がります。エラーを出して止まってくれる不具合より、成功を返しながら間違える不具合の方に、時間を取られてきました。
次に測るなら
手を動かすなら、まず自分のツール群を一度そのまま流して、バイト数を数えるところからをお勧めします。
for c in "grep -rn TODO ." "find . -type f -exec wc -c {} +" "git log --stat -50"; do
n=$(eval "$c" 2>/dev/null | wc -c)
printf "%-40s %10d %5.2fx\n" "${c:0:38}" "$n" "$(python3 -c "print($n/100000)")"
done
1.0 を超えている行があれば、そのツールの出力からは要約行が消えています。1.0 に届いていない行は、いつ届くかを見積もる番です。
私自身まだ測りきれていない領域が残っていますが、境界の位置を数字で把握しておくだけで、エージェントの答えを疑う勘所がずいぶん変わりました。お読みいただきありがとうございました。
参考リンク