Spill:工具输出太大怎么办
超限输出落盘存档,给模型留一张取回凭证。核心源码:packages/spill/spill-policy/src/index.ts。
read 工具被跳过;以及存储写失败时结果为什么照样算成功。
packages/spill/spill-policy/src/index.ts 第 190 至 209 行。演示中 maxInlineBytes 设为 50 KB,与 Agent Note 示例部署一致。一条 grep 命中了几万行,或者 web_fetch 抓回一整页文档,结果 2MB。这条结果接下来去哪,只有三个选项。
选项一,整个塞进上下文。下一次模型请求直接被它占满,钱包和上下文窗口一起遭殃。选项二,砍掉超出的部分。省是省了,可万一模型后面要找的正是被砍掉的那行报错,任务就卡死了。选项三是 DSH 的做法:全文落盘存档,上下文里只留首尾预览,外加一句提示,告诉模型全文存在哪个路径、用 read 或 grep 就能捞。丢出去的信息随时找得回来,这就是 Spill(溢写)。
干这件事的插件叫 dsh-spill-policy。它挂在工具执行流水线的 tools/post-execute 事件上,等一条工具结果彻底定稿后才出手。整个决策就五步,Agent Note 2026-07-08 写得很清楚:委托、纯文本检查、字节阈值、saveText、替换。演示里那张清单就是这五步的原样搬运。
只处理纯文本结果里混进任何一个非文本块(比如一张截图),flattenPlainText 返回 undefined,整条结果原样保留。策略只认识最终格式化文本,不懂工具内部结构,所以宁可不碰。出处:index.ts 第 80 至 87 行。
read 被跳过模型面向的那一臂明确跳过 read 工具,防止 read 的输出被 spill 成文件、模型再 read、再 spill 的死循环。日志那一臂不跳,因为日志副本进不了模型上下文,循环不成立。出处:第 195 至 197 行与第 219 至 222 行注释。
凭证不是路径locator 是不透明句柄:本地后端给的是文件路径,远程后端可以给 URI 或键。消费方不解析它,按后端附带的 retrievalHint 渲染取回话术,不假定 read 永远是正确的取回方式。出处:docs/subsystems/spill.zh.md 第 70 行。
策略的入口是一串放行判断,四条不碰的理由挨个查:下游监听器没接受这条结果、别的插件已经替换过值、这是嵌套子调用或 read 工具、内容混了非文本块,任何一条命中就原样放行。都没命中,再量字节,没超过 maxInlineBytes 也放行。全过了才走 spill。
这里有个容易忽略的顺序:判断的第一步是 await next(),先委托。让下游监听器(比如某个替换了内容的 hook)把结果彻底结算好,spill 再对定稿动手。所以哪怕别的插件换过内容,换上来的内容照样被 spill 管住。
出处:packages/spill/spill-policy/src/index.ts 第 194 至 209 行,核对日期 2026-08-13。跳过 read 的原因写在源码注释里:避免 read 的输出被 spill 成文件、模型再 read、再 spill 的循环。
大纲里那个边界条件:spill 存储写失败,这次工具调用算成功还是失败?答案在 catch 分支里,算成功,一个字都不藏。
let ref: SpillRef
try {
ref = await spillStore.saveText(save)
} catch (error: unknown) {
// Best-effort: a storage failure (permissions, ENOSPC, backend down) must
// never fail the call or hide the content — keep the original inline.
ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`)
return undefined
}
packages/spill/spill-policy/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。磁盘满了、权限不对、后端没挂载,都只换来一条 warn 日志,然后原始结果原样内联进上下文。设计文档里的原话是「spill 失败绝不会把成功的工具调用变为 isError 结果,也不会隐藏内联结果」(Agent Note 2026-07-08 第 91 行)。逻辑很朴素:spill 是省钱的优化,优化失败最多让上下文胖一点,绝不能把一次成功的调用弄成失败,更不能弄丢信息。
还有一个反方向的坑也被堵了:配置校验放在插件加载时,不放在每次调用时。一个负数或小数的 maxInlineBytes 会直接让部署启动失败(第 114 至 119 行),因为坏配置该炸的是部署,轮不到某次工具调用背锅。
替换后的模型可见文本是三段式:保留的头部预览、省略说明加凭证、保留的尾部预览。凭证那一行由 spillNotice 拼出(第 104 至 108 行),Agent Note 给的示例是「(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)」。措辞刻意通用,因为策略只知道最终文本,不了解工具内部资源。
一个细节能看出这套代码的较真程度:凭证本身的字节数会先从 maxInlineBytes 预算里扣掉,再算预览能留多少(第 171 至 172 行)。不然预览花满预算、凭证再往后一贴,替换文本反而可能比上限还大。要是凭证一行就超过整个上限,策略干脆放弃 spill、保留内联,绝不违反自己宣称的上限(第 183 至 185 行)。
存档文件本身也讲究。本地后端把文件写到 <root>/session-<hash>/<random>-<safeName>,根目录私有(0700),写入用 open(path, 'wx', 0o600) 排他且仅所有者可读,预先植入的符号链接没法重定向写入(docs/subsystems/spill.zh.md 第 85 行)。同族设计还有附件系统:引用进日志、字节放外部 store,思路一样,正文只留轻量引用(docs/subsystems/attachment.zh.md)。
Claude Code · 上限 + 落盘
官方博客的原话是「For Claude Code, we restrict tool responses to 25,000 tokens by default」,源码对应 Tool.ts 的 maxResultSizeChars(书稿 study/chapters/02-tool-system.md 第 664 至 666 行)。书稿第 670 行提到工具结果超限落盘后会附带说明路径,方向与 DSH 一致。博客还补了一条原则:截断时要告诉 Agent 为什么截了、怎么拿到完整内容。
Grok Build · bash 专属落盘
工具输出默认上限 20,000 字节(DEFAULT_TOOL_OUTPUT_CHARS,crates/codegen/xai-grok-tools/src/lib.rs 第 11 行),超限截断并随结果返回 truncated 标志。bash 工具是特例:全量输出先写进会话目录的 terminal log 文件(bash/mod.rs 第 379 至 381 行),截断的部分可以从文件里找回。只是这份落盘是 bash 专属的,别的工具输出被截就是被截了。
对比的焦点在通用性。三家都承认大输出不能全喂给模型,差别是丢掉的部分还能不能找回来、这个能力覆盖多少工具。Grok 只给 bash 落盘;Claude Code 与 DSH 做成了通用机制。DSH 的版本切得最碎:预览机制归 output-retention 库,存储归 spillStore seam(一个方法的抽象服务),策略插件只决定什么时候 spill、怎么拼凭证。三个包各管一段,换一个远程存储后端不用动策略一行代码。Agent Note 的替代方案一节还点名了参照对象:做通用默认行为,就是冲着「类似 Claude Code 通用工具结果持久化」去的(第 187 行)。
手推三条输出的命运
部署配置 maxInlineBytes: 50000。三条工具结果先后进来:一条 60,000 字节的 web_fetch 纯文本;一条 200,000 字节、但混了一个 image 块的浏览器截图结果;一条 80,000 字节的纯文本,但落盘时磁盘满了。对照第 194 至 203 行的守门逻辑和第 153 至 161 行的 catch 分支,分别写出每条结果最终进入模型上下文的形态,以及存档柜里各多了几个文件。