深入浅出 DeepSeek Harness 第三部:工具执行——不是调一个函数那么简单 第 21 章

工具输出太大时,全文落盘,上下文只留一张书签

工具输出超过上下文预算时,Harness 用 shell 硬截断兜底、用 spill-policy 做 token 预算软截断:全文落盘到会话私目录,只在模型视野留一条带 locator 的书签,任何一步失败都降级回原样内联。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

凌晨三点,你让 Harness 在一个两万行的日志目录上跑 bash 去定位一个边界条件。命令本身三行,输出却滚成一面墙——rg 打印了 14,940 行匹配。你盯着终端想:这堵墙全会塞进模型上下文吗?答案是:不会,但也不是被丢掉。你以为 Harness 的做法是”截断到前 N 字节就完事”,其实它布了两道完全不同的防线:第一道是 shell 自己的硬截断,把超长输出就地砍断并附一句固定的 <response clipped>;第二道才是本章主角——spill-policy 的token 预算软截断,它把全文异步落盘到会话私目录,只在模型视野里留下一条带定位符的”书签”,并且这条书签的字节数本身也要挤进同一份预算。你看到的不是”输出被删了”,而是”全文被搬到了上下文之外,只留下入口”。

上一章 第 20 章 把工具执行放进了真实的进程世界:bash 在 PTY 里跑、SSH 在远端跑。本章回答那个世界必然带来的问题——当工具的产出比模型上下文还大时,Harness 靠什么既不撑爆上下文、又不丢掉任何一个字节。

spill-truncation 机制全图:左侧是 bash 工具自己的 maybeTruncate 硬截断防线(maxOutputChars 默认 16000,截断后追加固定 TRUNCATED_MESSAGE);中间是 spill-policy 的两道钩子——tools/post-execute 面向模型视野、tools/ptc-dispatch-log 面向 PTC 日志副本,都先 next() 拿到下游决策再对内容做 bound();bound() 先用 llm.imageRequestPricing 给内容标 token 价,预算够就原样放行,预算不够就调用 spillStore.saveText 把全文写进会话私目录,再用 retainContent 只保留 head/tail 两端、在中间插入 GAP 占位、末尾追加一条带 locator 的 spill notice;右侧是 spill-local 本地后端:session 私目录按 sessionId 的 sha256 前 12 位命名、文件名是 6 字节随机 hex 加工具名、open 用 wx 0o600 拒绝覆盖;任何一步失败都降级回原样内联内容,绝不因为 spill 自己出错而让结果消失。

两道防线:硬截断与软截断不是一回事

在读任何 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-logPTC 日志副本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 的 tokennotice 超预算 → 抛错 → 保留原文
裁剪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.txtlocator(后端路径)全文确切在哪个文件
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 都是不可信字符串,直接拼进路径就是一个路径遍历漏洞。

写盘决策代码防的是什么事故
目录 0o700mkdir(dir, { recursive: true, mode: 0o700 })同机其他用户读到 spill 原文
文件 0o600open(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"