工具输出太大时,全文落盘,上下文只留一张书签
工具输出超过上下文预算时,Harness 用 shell 硬截断兜底、用 spill-policy 做 token 预算软截断:全文落盘到会话私目录,只在模型视野留一条带 locator 的书签,任何一步失败都降级回原样内联。
本章以中文撰写
凌晨三点,你让 Harness 在一个两万行的日志目录上跑 bash 去定位一个边界条件。命令本身三行,输出却滚成一面墙——rg 打印了 14,940 行匹配。你盯着终端想:这堵墙全会塞进模型上下文吗?答案是:不会,但也不是被丢掉。你以为 Harness 的做法是”截断到前 N 字节就完事”,其实它布了两道完全不同的防线:第一道是 shell 自己的硬截断,把超长输出就地砍断并附一句固定的 <response clipped>;第二道才是本章主角——spill-policy 的token 预算软截断,它把全文异步落盘到会话私目录,只在模型视野里留下一条带定位符的”书签”,并且这条书签的字节数本身也要挤进同一份预算。你看到的不是”输出被删了”,而是”全文被搬到了上下文之外,只留下入口”。
上一章 第 20 章 把工具执行放进了真实的进程世界:bash 在 PTY 里跑、SSH 在远端跑。本章回答那个世界必然带来的问题——当工具的产出比模型上下文还大时,Harness 靠什么既不撑爆上下文、又不丢掉任何一个字节。
两道防线:硬截断与软截断不是一回事
在读任何 spill 代码之前,必须先把一个常见误解拆掉:shell 工具里那个 maxOutputChars 和 spill-policy 里那个 maxInlineTokens,是两套独立机制,服务于两个不同问题。前者是”输出产生时的物理截断”,后者是”进模型上下文前的预算治理”。它们甚至不属于同一个包。
bash 工具自己的 maybeTruncate 是一道最后兜底的物理闸门。它的逻辑直白到近乎朴素:内容没超限就原样返回;内容超限就按 UTF-8 安全方式砍断,然后在尾部硬接一句固定文案。这句文案是常量,逐字长这样:
<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>
注意它没有留下任何定位符——它假设你会自己用 grep -n 回去找。这是一道”告诉模型’你被剪了’,但不帮你找回原文”的防线。它的上限来自工具配置,默认值是一个字符数(不是 token):maxOutputChars 默认 16_000,且必须是正安全整数,否则工具在加载阶段就抛 tool-bash-persistent: maxOutputChars must be a positive safe integer。
而 spill-policy 要解决的是另一个问题:即使输出只有几千字符,它也可能因为含图片而在 token 计价上远超预算;即使它没超过 shell 的 16000 字符上限,它也可能在上下文窗口里挤掉别的东西。所以它不在输出产生时动手,而在”工具决策要进入模型视野”的那一刻动手,而且动的不是字符刀,而是 token 预算。
| 维度 | shell 硬截断(maybeTruncate) | spill-policy 软截断(bound) |
|---|---|---|
| 发生位置 | 工具捕获输出后、返回前 | 工具决策后、进入模型上下文前(hook 层) |
| 计量单位 | 字符(maxOutputChars,默认 16000) | token(maxInlineTokens,由配置显式开启) |
| 超限动作 | 就地砍断 + 固定 <response clipped> 文案 | 全文落盘 + head/tail 保留 + 带 locator 的 notice |
| 是否保留全文 | 否,截断即丢弃 | 是,全文持久化到会话私目录 |
| 失败行为 | 不涉及(纯本地字符串操作) | 任何一步失败都降级回原样内联内容 |
| 图片内容 | 不感知(只看文本字符) | 感知(逐张图计价 llm.imageRequestPricing) |
| 默认开关 | 总是生效 | 默认关闭(配置缺省即不挂钩子) |
为什么两道防线要分开,而不是合成一个? 因为它们的失败模式完全不同。shell 截断是同步、纯函数、零 I/O 的——它在工具进程里就完成,出错不可能。spill 截断是异步、跨服务、要写盘的——它依赖 ctx.spillStore 后端、依赖 llm.imageRequestPricing 计价器、依赖会话归属。把这两件事塞进一条路径,意味着一个写盘抖动就能让工具结果整个消失。Harness 的选择是:让物理截断永远兜住上限,让预算治理只在自己可靠时才生效。
本节源码依据(6 处)
spill-policy 的武装:默认关闭,挂两个钩子
spill-policy 的模块头注释把它的自我约束写得很清楚:它是”Token-budgeted tool content with recoverable text and image addresses”——按 token 预算治理工具内容,但前提是文本和图片地址都能找回。并且注明策略在 post-execute 阶段结算,“canonical program values remain intact”(规范化程序值不被破坏),而”Missing recovery storage or image pricing keeps the original content and reports the reason through the logger”——只要找回存储或图片计价任何一样缺失,就保留原始内容、只通过 logger 报告原因。这是整个包的设计宪法。
它在 apply() 里做的第一件事,就是判断要不要存在:
const cap = config.maxInlineTokens
if (cap === undefined) return
if (!Number.isSafeInteger(cap) || cap < 0) {
throw new Error(`spill-policy: maxInlineTokens must be a non-negative integer (got ${cap})`)
}
这三行决定了一个反直觉事实:在默认安装里,spill-policy 是完全沉默的。它不挂任何钩子、不碰任何工具结果、不写任何盘。只有当某个 agent preset 显式配了 maxInlineTokens,它才武装起来。武装的方式是注册两个 prepend: true 的钩子:
| 钩子 | 面向谁 | 触发时机 | 处理对象 |
|---|---|---|---|
tools/post-execute | 模型视野 | 工具决策产生后、进上下文前 | 完整决策结果(含图片) |
tools/ptc-dispatch-log | PTC 日志副本 | PTC 子调用写日志时 | 仅 dispatch 内容 |
两个钩子都带 prepend: true,意思是它们的回调先于下游钩子执行。这一点在 post-execute 钩子里至关重要:它先 await next() 拿到下游决策,然后才对下游产出的内容做 bound()——注释里写明”downstream hooks see full content”,即下游钩子看到的是完整内容,spill 只裁剪最终进模型的那一份。也就是说,如果你在别的钩子里加工了工具输出,spill 裁剪的是你加工后的成品,而不是原料。
为什么策略要在 post-execute 而不是工具结果产生时结算? 因为”什么该进模型”是一个会被下游层层改写的决策。spill 把自己钉在这条管道的末端,等于承认自己没有资格定义”内容是什么”,它只负责”这份最终内容还剩多少 token 预算”。
本节源码依据(4 处)
bound():一次带预算预留的裁剪决策
bound() 是整个包的决策核心。它不是”超长就裁”那么简单,而是一串带预算会计的判断。逐步拆开看:
第一步,先算”这份内容现在值多少 token”。 文本块按字符估计,图片块则要找 provider 和 model,然后问 llm.imageRequestPricing(provider, model) 要一张计价表。provider/model 从 session.requestHeader()?.config 取,取不到再退回 exec.agent?.options。这意味着——spill 认的 token 价,和模型实际计费用的是同一份计价器,不是它自己拍脑袋估的。图片计价失败会直接抛错:the current model has no image token calculator 或 image token calculator returned an inconsistent occurrence count。
第二步,预算够就原样放行。 如果全部块的总价 <= maxTokens,bound 返回 undefined——也就是”不裁剪”。注意这里没有任何副作用:不写盘、不留痕、不改内容。这是一个纯判断。
第三步,预算不够才动刀,但动刀前要先预留”通知自己”的预算。 这是最反直觉的一步。裁剪之后,内容末尾会追加一条 spill notice,这条 notice 本身也要占 token。所以 bound 会先算:GAP 占位符值多少、最坏情况的 notice 文本值多少,把这两份从预算里扣掉,剩下的才是”可以保留给原文”的预算。如果连最坏情况的 notice 自己都塞不进预算,它直接抛 spill notice for ${toolName} exceeds maxInlineTokens——宁可让这次裁剪失败(从而降级回原文),也绝不生成一条连自己都放不下的书签。
const reserved = price(GAP) + price({ type: 'text', text: `\n\n${worstNotice.text}` })
if (price(worstNotice) > maxTokens) throw new Error(`spill notice for ${toolName} exceeds maxInlineTokens`)
const retained = retainContent(content, Math.max(0, maxTokens - reserved), price)
第四步,保留头和尾,中间插占位。 retainContent 把块序列从头吃起、从尾吃起,直到预算用尽,中间被跳过的部分(文本字节 + 整张图片)被统计为 omittedBytes / omittedImages。最终拼成:[...head, GAP, ...tail, footer]。GAP 是固定文本 "\n\n[...]\n\n",footer 是用真实省略量生成的 notice。最后相邻文本块会被合并,避免出现一堆碎片。
第五步,写盘。 写盘用的是 spillStore.saveText({ owner: { sessionId }, source: { kind: 'tool', toolName, callId, label }, suggestedName: toolName + '.txt', content: fullText(content) })。注意 fullText 会把每张图片渲染成一行只读路径说明,而不是把图片二进制塞进文本文件。
| bound() 阶段 | 干什么 | 失败时的行为 |
|---|---|---|
| 计价 | llm.imageRequestPricing 逐块估价 | 抛错 → 外层 catch → 保留原文 |
| 预算判断 | 总价 ≤ maxTokens? | 是 → 返回 undefined,零副作用 |
| 预留 | 扣掉 GAP + 最坏 notice 的 token | notice 超预算 → 抛错 → 保留原文 |
| 裁剪 | retainContent 留 head/tail | — |
| 写盘 | spillStore.saveText 全文落盘 | 缺 session owner / 缺 store → 抛错 → 保留原文 |
| 拼装 | head + GAP + tail + footer | — |
为什么通知的预算要”预留最坏情况”,而不是先裁完再写实际通知? 因为 notice 里的省略字节数是裁剪的结果——你不先预留,就不知道裁剪能留多少。这是一个先有鸡还是先有蛋的循环:要决定留多少原文,得先知道通知占多少;要知道通知占多少,得先知道省略了多少。Harness 的解法是用最坏情况近似:按”可能省略的全部字节”先算一条最长的 notice,把它的位置钉死在预算里,剩下的才是原文的活动空间。这样无论最终省略多少,都不会发生”原文留多了、把通知挤出预算”的溢出。
本节源码依据(7 处)
通知长什么样:从 SavedText 到一条真实书签
裁剪的产物是一条 notice。它不是随手拼的字符串,而是由 formatSpillNotice 按固定模板生成的。模板把四个字段缝在一起:省略描述(describeOmitted)、图片提示、定位符(locator)、取回指引(retrievalHint)。其中 locator 来自后端(spill-local 给的是文件路径),retrievalHint 是后端告诉模型”怎么把这份东西取回来”的话。
这条书签最终在真实会话里长什么样?我们直接从 snapshots/session/bash-spill/session.v4.jsonl 这份真实录制里 grep 出来,逐字是:
(Omitted 1494 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5e53dc8acfe4/2ce9d7a31a38-bash.txt. Use read with offset/limit, or grep this path to search within it.)
把这一行拆开,正好对应模板的四个部分:
| 书签片段 | 对应字段 | 它在告诉模型什么 |
|---|---|---|
(Omitted 1494 bytes. | describeOmitted({kind:'exact', count:1494}, 'bytes') | 被搬走了多少字节 |
Full formatted result stored at: | 固定 LOCATION 前缀 | 这是个存储定位符,不是别的 |
/tmp/.../2ce9d7a31a38-bash.txt | locator(后端路径) | 全文确切在哪个文件 |
Use read with offset/limit, or grep this path to search within it. | retrievalHint | 该怎么取回:分段 read 或直接 grep |
注意路径里的 session-5e53dc8acfe4 和 2ce9d7a31a38-bash.txt 不是随便命名的——前者是 sessionId 的 sha256 前 12 位(让同一会话的所有 spill 文件聚在一个目录),后者是 6 字节随机 hex 前缀加工具名 bash(让同一会话的多次 bash spill 互不覆盖)。
为什么取回指引要写给”模型”看,而不是写死成一个命令? 因为取回动作本身也是一次工具调用。retrievalHint 的职责是教会模型在需要时自己发起 read 或 grep,而不是 Harness 替它做。这把”找回全文”从一个被动的文件路径,变成了模型可主动执行的下一步动作——spill 因此不是终点,而是一个可以接着操作的入口。
本节源码依据(4 处)
本地 spill 后端:一次拒绝覆盖的原子写
书签里那个文件不是随便写出来的。spill-local 的 saveTextFile 在写盘时做了三件防事故的事,每一件都对应一个真实的并发场景:
第一,目录和文件权限位钉死。 会话私目录用 mkdir(dir, { recursive: true, mode: 0o700 }),文件用 open(path, 'wx', 0o600)。0o700 意味着只有当前用户能进这个目录,0o600 意味着只有当前用户能读写这个文件。这很重要——spill 文件里存的是工具的完整原始输出,可能包含环境变量、密钥路径、用户数据。它不是缓存,它是敏感原文的副本。
第二,wx 标志拒绝覆盖。 open(path, 'wx', 0o600) 里的 wx 是关键:如果这个文件名已经存在,open 会直接失败而不是把旧文件冲掉。配合文件名前缀那 6 字节随机 hex,两次 spill 永远不可能撞名覆盖。代码里还专门留了一个 for 循环重试,注释写明是处理”mkdir 和 open 之间,或外部权限/IO 竞争”的竞态。
第三,路径段一律编码。 文件名里 suggestedName 会经过 encodeSegment,把 ../、绝对路径、NUL、分隔符全部中和成 ~XXXX 形式。因为工具名、callId、label 都是不可信字符串,直接拼进路径就是一个路径遍历漏洞。
| 写盘决策 | 代码 | 防的是什么事故 |
|---|---|---|
| 目录 0o700 | mkdir(dir, { recursive: true, mode: 0o700 }) | 同机其他用户读到 spill 原文 |
| 文件 0o600 | open(path, 'wx', 0o600) | 文件被其他进程读 |
| wx 拒绝覆盖 | open(..., 'wx', ...) | 两次 spill 撞名互删 |
| 随机 hex 前缀 | ${randomBytes(6).toString('hex')}-... | 文件名可预测导致的预创建攻击 |
| 路径段编码 | encodeSegment(suggestedName) | ../ 路径遍历 |
为什么 spill 文件要单独放在会话私目录,而不是写进系统临时目录? 因为这份文件的生命周期和会话绑定——会话结束、fork、恢复时,spill 文件要不要跟着走,是一个会话级的策略问题。把它放进 /tmp 就等于交给操作系统的清理规则,Harness 自己失去了对这份”原文副本”的生命周期控制。放在会话私目录,它才能和会话日志一起被管理、被归档、被审计。
本节源码依据(4 处)
失败降级:spill 自己出错时,结果反而要原样留下
最后一条,也是最能体现这个包设计克制的一条:bound 的整个主体被一个 catch 包住,catch 里只做一件事——ctx.logger.warn('spill-policy: ${String(error)}; keeping the inline content'),然后返回 undefined(不裁剪)。
把这句话的含义读出来:任何一步出错——计价器缺失、写盘失败、找不到会话归属、notice 自己超预算——最终的结果都是”把完整原文原封不动地留在上下文里,只在日志里 warn 一声”。它宁可让上下文被撑大,也绝不因为自己这个”治理者”出了故障而让工具结果消失。
这和我们开头说的模块宪法一字不差:“Missing recovery storage or image pricing keeps the original content and reports the reason through the logger。” spill 的存在是为了优化上下文,不是为了保住正确性。一旦优化器自己不可靠,正确路径(原样内联)就必须立刻接管。
为什么降级方向是”保留原文”而不是”退化成 shell 那种硬截断”? 因为硬截断是不可逆的丢失,而”保留原文进上下文”只是一次昂贵但正确的选择。spill 的价值主张是”找回原文”——如果 spill 出错时顺手把原文也砍了,那就既没找回、又丢了,等于一次失败双重付费。Harness 宁可承担一次上下文膨胀的代价,也要保证” spill 失败 = 输出完好”这个不变量。
本节源码依据(3 处)
小结
- spill 不是截断,是搬运。 它把全文搬出上下文落盘,只在模型视野留一条带 locator 的书签;真正的”砍断”是 shell 自己那道
maxOutputChars硬截断,两者单位、时机、失败行为都不同。 - 默认关闭是刻意的。
maxInlineTokens缺省时 spill-policy 一个钩子都不挂;它是一个 opt-in 的优化器,不是常驻的守卫。 - 通知要先给自己留预算。 bound 在裁剪前先按最坏情况预留 GAP + notice 的 token,宁可裁剪失败降级,也不生成一条放不下自己的书签。
- 写盘拒绝覆盖、权限钉死。
wx+0o600+ 随机 hex 前缀 + 路径编码,让 spill 原文副本既不被互删、也不被同机读取。 - spill 出错 = 原文完好。 任何一步失败都降级回”完整内联 + warn”,优化器永远不能把自己的故障变成结果的丢失。
下一章:第 22 章
只读验证
# 验证源码身份(应输出 477b4f420553e8a52c2fbccc464d7561b239c443)
git -C "$DSH_SOURCE_DIR" rev-parse HEAD
# 逐字核对本章引用的关键常量与错误消息
grep -n "maxInlineTokens must be a non-negative integer" "$DSH_SOURCE_DIR/packages/spill/spill-policy/src/index.ts"
grep -n "spill notice for" "$DSH_SOURCE_DIR/packages/spill/spill-policy/src/index.ts"
grep -n "keeping the inline content" "$DSH_SOURCE_DIR/packages/spill/spill-policy/src/index.ts"
grep -n "<response clipped>" "$DSH_SOURCE_DIR/packages/shell/tool-bash-persistent/src/index.ts"
grep -n "Omitted.*bytes. Full formatted result stored at" "$DSH_SOURCE_DIR/snapshots/session/bash-spill/session.v4.jsonl"