第 36 章:Tool Result 预算与外溢

"大的截一下不就行了"——截断之后模型会再读一遍。外溢到磁盘留个指针,它就不用了。


⏱️ 快速通道(5 分钟掌握核心)

  1. 截断是销毁,外溢是搬家。要同时留下预览可取回的指针
  2. 两个预算:单结果一个,单 Turn 一个——并行批次撑爆的是第二个
  3. 替换必须跨 Turn 持久化,否则你会永远重复外溢同一个结果
  4. 有些工具自己设界、被豁免;总量是尽力而为的目标,不是天花板
  5. 数 rune,不要数字节,否则预览会把一个字符切成两半

公开源码快照:实现细节取自 Kocoro 公开仓库 origin/main4ec6772 提交,复核日期为 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 runespill.go L17
单 Turn 总量目标 200,000 字符spill.go L22
总量削减时的最小外溢体量 5,000spill.go L26
挑选外溢对象时跳过不限大小的工具spill.go L75
Rune 安全的预览切片spill.go L55
替换状态同时携带 Seentoolresult_budget.go L19

以上描述的是一个有日期的实现快照,不是普遍契约。

36.7 什么时候不该外溢

外溢有一项被阈值掩盖的成本:它把「模型已经拿到的内容」变成了「模型必须开口要的内容」。每一次外溢都是一次潜在的额外往返。

所以别外溢小结果——那条下限存在正是为此。别外溢模型必定要看全文的结果;如果下一步明摆着就是「总结这份文档」,搬走它只是多加一次取回。也别在内容本身就是答案而不是证据时外溢:一个输出就是用户所要之物的工具,应该把东西送到用户手里,而不是一份预览加一个路径。

还有一个常被跳过的生命周期问题。外溢文件会堆积。它们是 session 级的,需要清理,而且它们装着工具返回的任何东西——很可能正是你的权限模型在努力控制的那些数据。一个外溢目录就是工具输出的一份明文副本,躺在磁盘上。 给它划范围、设权限、并且删掉。

36.8 常见的坑

用截断代替外溢。 模型会换个更窄的 query 重跑工具。你付两遍钱,还丢了原件。

只做单结果预算。 十份采用默认策略的 30K 结果逐个都能通过单结果外溢检查,合起来照样撑爆这一轮。36.1 就是这个 bug。

替换不持久化。 每一轮重复外溢,永远。而且症状看起来像是莫名其妙的反复变慢,不像一个 bug。

按字节切预览。 切坏多字节字符,并且悄悄让中日韩用户只拿到三分之一长度的预览。

给自我设界的工具做外溢。 纯粹的间接,没有收益——那份内容本来就有地址。

把总量当硬上限。 豁免工具和失败的外溢都会正当地超过它。假定不会超过的代码,会在生产上吃惊。

划重点

  1. 截断是销毁,外溢是搬家。 留下预览和指针,让取回始终可能。
  2. 单结果和单 Turn 都要有预算。 并行批次能通过每一个单结果检查,然后照样溢出。
  3. 持久化替换。 一个没被持久化的 Prompt 整形操作,会每一轮重做一遍。
  4. 自我设界的工具不需要外溢。 输出已经有地址,搬家买不到任何东西。
  5. 总量是目标,不是天花板。 豁免工具和失败的外溢会正当地超过它。

下一章:第 37 章处理那些留在 Prompt 里的结果——按年龄降级,而不是搬出去。

引用本文 / Cite
Zhang, Wayland (2026). 第 36 章:Tool Result 预算与外溢. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/%E7%AC%AC36%E7%AB%A0-Tool-Result%E9%A2%84%E7%AE%97%E4%B8%8E%E5%A4%96%E6%BA%A2/
@incollection{zhang2026aiagent_36_-Tool-Result,
  author = {Zhang, Wayland},
  title = {第 36 章:Tool Result 预算与外溢},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/%E7%AC%AC36%E7%AB%A0-Tool-Result%E9%A2%84%E7%AE%97%E4%B8%8E%E5%A4%96%E6%BA%A2/}
}