第 36 章:Tool Result 预算与外溢
"大的截一下不就行了"——截断之后模型会再读一遍。外溢到磁盘留个指针,它就不用了。
⏱️ 快速通道(5 分钟掌握核心)
- 截断是销毁,外溢是搬家。要同时留下预览和可取回的指针
- 两个预算:单结果一个,单 Turn 一个——并行批次撑爆的是第二个
- 替换必须跨 Turn 持久化,否则你会永远重复外溢同一个结果
- 有些工具自己设界、被豁免;总量是尽力而为的目标,不是天花板
- 数 rune,不要数字节,否则预览会把一个字符切成两半
10 分钟路径:36.1-36.3 → 36.5 → Kocoro OSS Lab
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
36.1 从四个比答案还贵的 grep 说起
一个并行批次里发出四个 grep。单看没有一个过分。每个回来六万字符的匹配。
grep("handleRequest") → 61,204 字符
grep("func New") → 58,910 字符
grep("TODO") → 74,332 字符
grep("import") → 66,180 字符
─────────────
260,626 字符 ≈ 74K token
单独看,没有一个吓人。加起来是 200K 窗口的三分之一,在一轮里到齐,而模型回答这个问题大概只会从里面用到二十行。
第一反应是截断——每个结果封顶一万字符,收工。这招只灵一次。模型读到被截断的结果,没找到它要的东西,于是用更窄的 pattern 再 grep 一次。现在你付了两遍钱,内容还是没拿到。
截断销毁的是模型可能还需要的信息,外溢只是把它搬走。 本章的一切都建立在这个区别上。
36.2 预览 + 指针
机制很简单:一个结果超过阈值,就把完整内容写进 Runtime 管理的文件,并把 Prompt 里的内容替换成两样东西——开头的一段预览,以及一个模型之后可以去读的路径。
公开 Runtime 里,阈值是 50,000 字符,预览是开头 2,000 个 rune。于是模型仍然看得见它拿到的是什么东西、里面大概有什么;够用就往下走。不够用,就对外溢路径调一次 file_read 把剩下的取回来——而那个时刻,模型已经知道自己在找什么了。
最后这半句才是真正的收益。取回发生在模型把问题收窄之后。 你把「以防万一先扛着 26 万字符」换成了「现在只带 8K,需要什么之后精确去取」。
注意预览是按 rune 数,不是字节数算的。在 UTF-8 字符串的第 2000 个字节上切一刀,很可能切在一个字符中间,模型看到的是乱码;而在中日韩文本里,按字节数还会让你实际拿到的预览只有预期的三分之一左右。
36.3 被并行执行撑爆的那个预算
只有单结果阈值是不够的,36.1 就是原因。那四个 grep 每一个都不到 50,000,没有一个触发单结果外溢。这一轮照样是 26 万字符。
所以还有第二个预算:单 Turn 总量,快照里是 200,000 字符。批次跑完之后,Runtime 从最大的可外溢结果开始外溢,直到总量落回目标之下——从大到小,因为这样能用最少的外溢操作腾出最多的预算。
这里还有一条下限:在总量削减过程中,小于 5,000 字符的一律不外溢。低于这个体量,外溢的开销——一次文件写、Prompt 里多一个路径、之后大概率还要一次 file_read——比你腾出来的内容还贵。
总量是尽力而为的目标,不是硬天花板。 一轮完全可能正当地停在它之上:有些工具声明自己不限大小,在挑选外溢对象时会被跳过;而一次失败的外溢会把内容留在原地,而不是把它丢掉。超出目标应当被当作值得排查的信号,而不是一个你的代码可以假定不会发生的状态。
36.4 那个看起来像 bug 的豁免
file_read 被豁免于外溢。乍看不对,直到你看见它换了什么做法:它在工具内部自己封顶在 500,000 rune,并留下明确的截断标记。
这里的理由值得抽出来。给 file_read 做外溢,等于把一个文件的内容写进另一个文件,然后递给模型一个路径——而模型本来就有路径,就是它刚读的那个。你除了加一层间接和一次白跑的往返,什么也没做成。
所以规则可以推广:一个本来就返回可取回引用的工具,不需要外溢,它需要的是自我设界。 外溢是给那些「输出在别处不存在」的工具用的。读取类工具,按定义就有来源。
这也是为什么这个豁免是工具自己声明的属性,而不是分发器某处一份名单上的名字。只有工具知道自己的输出可不可恢复。
36.5 替换必须活得比这一轮长
这是最容易被忽略、而做错代价很大的部分。
你在第 12 轮外溢了一个结果。第 13 轮,对话历史被重建后送给模型。如果这次重建用的是原始工具结果,而不是外溢后的替换,那六万字符就原样回到 Prompt 里——然后你再外溢一次,每一轮都外溢一次,永远。
修法是:把替换做成持久化状态,而不是一次按轮次的变换。记录哪些结果被替换过、被替换成了什么,并且跨 Checkpoint 以及终态保存一起带着,这样重建历史时重构出的是外溢版本,而不是原件。
这份状态里的 Seen 那一半和替换表一样重要:它区分「这个结果从没被外溢过」和「这个结果被外溢过,替身在这里」。没有它,一条缺失的记录就是歧义的,而这里的歧义意味着要么重复外溢,要么悄悄丢内容。
任何没有被持久化的 Prompt 整形操作,都会每一轮重做一遍。 这对外溢成立,对压缩成立,对第 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 什么时候不该外溢
外溢有一项被阈值掩盖的成本:它把「模型已经拿到的内容」变成了「模型必须开口要的内容」。每一次外溢都是一次潜在的额外往返。
所以别外溢小结果——那条下限存在正是为此。别外溢模型必定要看全文的结果;如果下一步明摆着就是「总结这份文档」,搬走它只是多加一次取回。也别在内容本身就是答案而不是证据时外溢:一个输出就是用户所要之物的工具,应该把东西送到用户手里,而不是一份预览加一个路径。
还有一个常被跳过的生命周期问题。外溢文件会堆积。它们是 session 级的,需要清理,而且它们装着工具返回的任何东西——很可能正是你的权限模型在努力控制的那些数据。一个外溢目录就是工具输出的一份明文副本,躺在磁盘上。 给它划范围、设权限、并且删掉。
36.8 常见的坑
用截断代替外溢。 模型会换个更窄的 query 重跑工具。你付两遍钱,还丢了原件。
只做单结果预算。 四个 60K 的结果每一个都能通过单结果检查,照样撑爆这一轮。36.1 就是这个 bug。
替换不持久化。 每一轮重复外溢,永远。而且症状看起来像是莫名其妙的反复变慢,不像一个 bug。
按字节切预览。 切坏多字节字符,并且悄悄让中日韩用户只拿到三分之一长度的预览。
给自我设界的工具做外溢。 纯粹的间接,没有收益——那份内容本来就有地址。
把总量当硬上限。 豁免工具和失败的外溢都会正当地超过它。假定不会超过的代码,会在生产上吃惊。
Kocoro OSS Lab(10 分钟上手)
- 读
spill.go,找到总量削减那一趟是怎么挑候选的。注意它按体量排序、并跳过不限大小的工具——两个决定,两个都是承重的。 - 找到
minAggregateSpillSize这个常量,想清楚把它设成 0 会坏在哪里。 - 在你自己的 Agent 里:一个并行工具批次结束后,把各结果大小加起来。如果这个数在你的上下文窗口里占了可观的比例,你就有 36.1 那个问题,而单结果上限救不了你。
划重点
- 截断是销毁,外溢是搬家。 留下预览和指针,让取回始终可能。
- 单结果和单 Turn 都要有预算。 并行批次能通过每一个单结果检查,然后照样溢出。
- 持久化替换。 一个没被持久化的 Prompt 整形操作,会每一轮重做一遍。
- 自我设界的工具不需要外溢。 输出已经有地址,搬家买不到任何东西。
- 总量是目标,不是天花板。 豁免工具和失败的外溢会正当地超过它。
下一章:第 37 章处理那些留在 Prompt 里的结果——按年龄降级,而不是搬出去。