第 36 章:Tool Result 预算与外溢
"大的截一下不就行了"——截断之后模型会再读一遍。外溢到磁盘留个指针,它就不用了。
⏱️ 快速通道(5 分钟掌握核心)
- 截断是销毁,外溢是搬家。要同时留下预览和可取回的指针
- 两个预算:单结果一个,单 Turn 一个——并行批次撑爆的是第二个
- 替换必须跨 Turn 持久化,否则你会永远重复外溢同一个结果
- 有些工具自己设界、被豁免;总量是尽力而为的目标,不是天花板
- 数 rune,不要数字节,否则预览会把一个字符切成两半
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
36.1 从十份比答案还贵的搜索结果说起
一个并行批次里,向一个合成的 MCP repo_search 工具发出十次调用。它继承运行时默认的结果策略,每次都带回大约三万字符的匹配。
repo_search("handleRequest") → 31,204 字符
repo_search("func New") → 28,910 字符
repo_search("TODO") → 34,332 字符
repo_search("import") → 26,180 字符
repo_search("error") → 32,441 字符
repo_search("context") → 29,608 字符
repo_search("session") → 30,774 字符
repo_search("tool_use") → 27,955 字符
repo_search("cache") → 33,116 字符
repo_search("message") → 25,480 字符
─────────────
300,000 字符 ≈ 86K token
合成工具名是有意选择的。Kocoro 内建 grep 声明了更低的、约 20,000 字符的工具级限制,这些结果会更早外溢。继承默认策略的 MCP 工具能把总量故障单独暴露出来:每项都不到 50,000,加起来却远远超过。
单独看,没有一个吓人。加起来却接近 200K token 窗口的一半,而且在同一轮里到齐;模型回答问题时,大概只会从里面用到二十行。
第一反应是截断——每个结果封顶一万字符,收工。这招只灵一次。模型读到被截断的结果,没找到它要的东西,于是用更窄的 pattern 再调用一次 repo_search。现在你付了两遍钱,内容还是没拿到。
截断销毁的是模型可能还需要的信息,外溢只是把它搬走。 本章的一切都建立在这个区别上。
36.2 预览 + 指针
机制很简单:一个结果超过阈值,就把完整内容写进 Runtime 管理的文件,并把 Prompt 里的内容替换成两样东西——开头的一段预览,以及一个模型之后可以去读的路径。
公开 Runtime 里,默认阈值是 50,000 字符,预览是开头 2,000 个 rune。工具可以声明更低的限制,也可以声明不限大小、由自己设界;工具策略优先。于是模型仍然看得见它拿到的是什么东西、里面大概有什么;够用就往下走。不够用,就对外溢路径调一次 file_read 把剩下的取回来——而那个时刻,模型已经知道自己在找什么了。
最后这半句才是真正的收益。取回发生在模型把问题收窄之后。 你把「以防万一先扛着 30 万字符」换成了「现在只带 20K,需要什么之后精确去取」。
注意预览是按 rune 数,不是字节数算的。在 UTF-8 字符串的第 2000 个字节上切一刀,很可能切在一个字符中间,模型看到的是乱码;而在中日韩文本里,按字节数还会让你实际拿到的预览只有预期的三分之一左右。
36.3 被并行执行撑爆的那个预算
只有单结果阈值是不够的,36.1 就是原因。那十份采用默认策略的搜索结果,每一份都不到 50,000,没有一份触发单结果外溢。这一轮照样达到 30 万字符。
所以还有第二个预算:单 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 |
| 更低的工具级策略会覆盖默认值;不限大小的工具会绕过它 | spill.go L150 |
内建 grep 声明了 20,000 字符的结果限制 | grep.go L58 |
| 上下文内预览 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 重跑工具。你付两遍钱,还丢了原件。
只做单结果预算。 十份采用默认策略的 30K 结果逐个都能通过单结果外溢检查,合起来照样撑爆这一轮。36.1 就是这个 bug。
替换不持久化。 每一轮重复外溢,永远。而且症状看起来像是莫名其妙的反复变慢,不像一个 bug。
按字节切预览。 切坏多字节字符,并且悄悄让中日韩用户只拿到三分之一长度的预览。
给自我设界的工具做外溢。 纯粹的间接,没有收益——那份内容本来就有地址。
把总量当硬上限。 豁免工具和失败的外溢都会正当地超过它。假定不会超过的代码,会在生产上吃惊。
划重点
- 截断是销毁,外溢是搬家。 留下预览和指针,让取回始终可能。
- 单结果和单 Turn 都要有预算。 并行批次能通过每一个单结果检查,然后照样溢出。
- 持久化替换。 一个没被持久化的 Prompt 整形操作,会每一轮重做一遍。
- 自我设界的工具不需要外溢。 输出已经有地址,搬家买不到任何东西。
- 总量是目标,不是天花板。 豁免工具和失败的外溢会正当地超过它。
下一章:第 37 章处理那些留在 Prompt 里的结果——按年龄降级,而不是搬出去。