第 36 章:Tool Result の予算と退避
「大きいのは切り詰めればいい」——切り詰めればモデルは読み直す。ディスクへ退避してポインタを残せば、その必要はない。
5 分で要点掴む
- 切り詰めは破棄、退避は移動。プレビューと再取得できるポインタの両方を残す
- 予算は 2 つ:結果単体と Turn 合計——並列バッチが破るのは後者
- 置換は Turn をまたいで永続化する。しないと同じ結果を永久に退避し続ける
- 自分で上限を持つツールは除外される。合計はベストエフォートの目標で、天井ではない
- バイトではなく rune で数える。さもないとプレビューが文字を半分に切る
10 分パス:36.1-36.3 → 36.5 → Kocoro OSS Lab
公開ソースのスナップショット:実装例は 2026-07-27 に確認した Kocoro 公開
origin/mainのコミット4ec6772に基づきます。学ぶべき対象は不変条件であり、定数は最新ソースで再確認してください。
36.1 答えより高くついた 4 つの grep
1 つの並列バッチで grep が 4 本飛びます。個別に見れば不当なものは 1 つもありません。それぞれ 6 万文字のマッチが返ります。
grep("handleRequest") → 61,204 文字
grep("func New") → 58,910 文字
grep("TODO") → 74,332 文字
grep("import") → 66,180 文字
─────────────
260,626 文字 ≈ 74K token
単体ではどれも警戒するほどではありません。合計すると 200K ウィンドウの 3 分の 1 が 1 つの Turn に届きます。しかもモデルがその問いに答えるのに使うのは、全部のうちおそらく 20 行程度です。
最初の発想は切り詰めです。各結果を 1 万文字で頭打ちにして先へ進む。これは 1 回しか効きません。モデルは切り詰められた結果を読み、欲しいものが見つからず、もっと狭いパターンでもう一度 grep します。2 度払って、内容はまだ手元にありません。
切り詰めはモデルがまだ必要とするかもしれない情報を破棄し、退避はそれを移動させるだけです。 この章のすべてはこの区別の上に載っています。
36.2 プレビューとポインタ
仕組みは単純です。結果が閾値を超えたら、完全なペイロードを Runtime 管理のファイルへ書き、Prompt 内の内容を 2 つのものへ置き換えます。冒頭のプレビューと、モデルが後で読めるパスです。
公開 Runtime では閾値が 50,000 文字、プレビューは先頭 2,000 rune です。モデルは自分が受け取ったのがどんな種類のもので、中身がおおよそ何かを依然として見られます。足りればそのまま進みます。足りなければ、退避先のパスに file_read して残りを取り寄せます——そのときモデルは、自分が何を探しているかをすでに知っています。
最後の一文が本当の利得です。再取得はモデルが問いを絞り込んだ後に起きます。 「念のため 26 万文字を抱える」を「いまは 8K だけ持ち、必要になったものだけ後で精密に取る」へ変換しています。
プレビューがバイトではなく rune で測られている点にも注意してください。UTF-8 文字列をバイト 2000 で切ると文字の途中に着地しうるので、モデルには壊れた rune が見えます。さらに CJK テキストでは、バイト基準は意図した長さのおよそ 3 分の 1 のプレビューしか与えません。
36.3 並列実行が破る予算
結果単体の閾値だけでは足りません。理由が 36.1 です。あの 4 本の grep はどれも 50,000 未満で、単体の退避を 1 つも起動しませんでした。それでも Turn は 26 万文字でした。
そこで 2 つ目の予算があります。Turn 合計、スナップショットでは 200,000 文字です。バッチ完了後、Runtime は大きい対象から順に退避し、合計が目標を下回るまで続けます。大きい順なのは、最少の退避操作で最大の予算を空けられるからです。
ここにも下限があります。合計削減の過程では 5,000 文字未満は退避しません。その規模を下回ると、退避のオーバーヘッド——ファイル書き込み、Prompt 内のパス、そしておそらく後続の file_read——のほうが、取り除いた内容より高くつきます。
合計はベストエフォートの目標であって、ハードな天井ではありません。 Turn が正当にそれを超えて終わることはあります。サイズ無制限を宣言したツールは退避候補の選定でスキップされますし、失敗した退避は内容を捨てずにその場へ残します。目標超過は「コードが起こらないと仮定してよい状態」ではなく「調査に値する兆候」として扱ってください。
36.4 バグに見える除外
file_read は退避から除外されています。一見おかしいのですが、代わりに何をしているかを見ると腑に落ちます。ツール内部で 500,000 rune に自ら上限を設け、明示的な切り詰めマーカーを残しています。
ここの理屈は取り出す価値があります。file_read を退避するとは、あるファイルの内容を別のファイルへ書き、モデルにパスを渡すことです。モデルはすでにパスを持っていました。たった今読んだそれです。間接を 1 層と無駄な往復を 1 回足した以外、何も達成していません。
だから規則はこう一般化できます。再取得可能な参照をすでに返すツールに退避は要らない。要るのは自分で設ける上限です。 退避は、出力がほかのどこにも存在しないツールのためのものです。読み取り系は定義上、出どころを持っています。
だからこそこの除外は、ディスパッチャのどこかにある名簿の名前ではなく、ツール自身が宣言する属性です。出力が復元可能かどうかを知っているのはツールだけです。
36.5 置換はその Turn より長く生きる必要がある
見落としやすく、間違えると高くつく部分です。
12 回目の Turn で結果を退避します。13 回目の Turn では、会話履歴が再構築されてモデルへ送られます。その再構築が退避後の置換ではなく元のツール結果を使えば、6 万文字がそのまま Prompt へ戻ってきます。そしてまた退避し、また戻り、毎 Turn、永遠に続きます。
修正は、置換を Turn ごとの変換ではなく永続化された状態にすることです。どの結果が何に置き換えられたかを記録し、Checkpoint と終端保存の両方をまたいで運びます。そうすれば再構築された履歴は原本ではなく退避版を再現します。
その状態の Seen の側は置換マップと同じくらい重要です。「この結果は一度も退避されていない」と「この結果は退避され、代替はこれ」を区別します。これがないと欠落エントリは曖昧になり、ここでの曖昧さは再退避か内容の静かな喪失のどちらかを意味します。
永続化されていない Prompt 整形操作は、毎 Turn やり直されます。 これは退避にも、圧縮にも、第 37 章のあらゆる書き換えにも当てはまります。
36.6 スナップショットの根拠
| 観測 | 4ec6772 のソース位置 |
|---|---|
| 結果単体の退避閾値 50,000 文字 | spill.go L15 |
| コンテキスト内プレビュー 2,000 rune | spill.go L17 |
| Turn 合計の目標 200,000 文字 | spill.go L22 |
| 合計削減時の最小退避サイズ 5,000 | spill.go L26 |
| 退避候補選定でサイズ無制限ツールをスキップ | spill.go L75 |
| rune 安全なプレビュー切り出し | spill.go L55 |
置換状態がマップと併せて Seen を持つ | toolresult_budget.go L19 |
これらは特定時点の実装例であり、普遍的な契約ではありません。
36.7 退避すべきでないとき
退避には閾値が隠しているコストがあります。モデルがすでに持っている内容を、モデルが取りに行かなければならない内容へ変えることです。退避 1 回は往復 1 回の潜在的な追加です。
だから小さな結果は退避しません。下限があるのはまさにこのためです。モデルが全文を確実に必要とする結果も退避しません。次のステップが明らかに「この文書を要約する」なら、移動は取得を 1 回足すだけです。そして内容が証拠ではなく答えそのものであるときも退避しません。出力がユーザーの求めたものであるツールは、プレビューとパスではなく本体を届けるべきです。
飛ばされがちなライフサイクルの問題もあります。退避ファイルは溜まります。セッション単位で、掃除が要り、そしてツールが返したものを何でも抱えています。それはあなたの権限モデルが封じ込めようとしているデータそのものかもしれません。退避ディレクトリはツール出力の平文コピーがディスク上にある状態です。 範囲を切り、権限を付け、消してください。
36.8 よくある落とし穴
退避の代わりに切り詰める。 モデルは狭いクエリでツールを再実行します。2 度払って原本も失います。
結果単体だけ予算を張る。 60K の結果 4 つは単体チェックをすべて通過し、それでも Turn を破ります。36.1 がこのバグです。
置換を永続化しない。 毎 Turn 再退避が永遠に続き、症状はバグではなく謎の反復的な遅さに見えます。
プレビューをバイトで切る。 マルチバイト文字を壊し、CJK 利用者には黙って 3 分の 1 の長さしか与えません。
自分で上限を持つツールを退避する。 利得のない間接です。その内容にはすでにアドレスがありました。
合計をハード上限として扱う。 除外ツールと失敗した退避はどちらも正当にそれを超えます。そうでないと仮定したコードは本番で驚きます。
Kocoro OSS Lab(10 分)
spill.goを読み、合計削減のパスが候補をどう選ぶか探します。サイズでソートし、サイズ無制限ツールをスキップする——2 つの判断、どちらも耐荷重です。minAggregateSpillSize定数を見つけ、0 にすると何が壊れるか考えてください。- 自分の Agent で、並列ツールバッチ後に結果サイズを合計します。その数がコンテキストウィンドウの無視できない割合なら、36.1 を抱えており、結果単体の上限では救えません。
ここだけは押さえる
- 切り詰めは破棄、退避は移動。 プレビューとポインタを残し、再取得を可能にしておく。
- 結果単体と Turn 合計の両方に予算を。 並列バッチは単体チェックを全通過してからあふれます。
- 置換を永続化する。 永続化されていない Prompt 整形は毎 Turn やり直されます。
- 自分で上限を持つツールに退避は要らない。 出力にすでにアドレスがあるなら、移動は何も買いません。
- 合計は目標であって天井ではない。 除外ツールと失敗した退避は正当にそれを超えます。
次章:第 37 章は Prompt に残る結果を扱います。外へ出すのではなく、年齢順に劣化させる話です。