DeepSeek Harness · 上下文工程

Spill:工具输出太大怎么办

超限输出落盘存档,给模型留一张取回凭证。核心源码:packages/spill/spill-policy/src/index.ts

课程目标读完你能说清三件事:一条 2MB 的 grep 结果进来,DSH 为什么既不塞进上下文也不砍掉,选择全文落盘、上下文里只留首尾预览加一张取回凭证;post-execute 五步决策每一步在判什么、为什么 read 工具被跳过;以及存储写失败时结果为什么照样算成功。
交互演示 · 大输出处理流水线
模型上下文(模型看得到的部分)
存档柜 spillStore(会话专属文件)
POST-EXECUTE 五步决策(spill-policy/src/index.ts)
1 · next() 委托先让下游把结果结算好 · L194
2 · 纯文本检查混入非文本块就整个不碰 · L200-201
3 · 字节阈值UTF-8 大小超过 maxInlineBytes 才动手 · L202-203
4 · saveText 落盘全文原样写进会话存档 · L155
5 · 替换成预览 + 凭证首尾预览加取回提示进上下文 · L173-175
对照 · 硬截断的做法
砍到上限,只留开头被砍掉的部分不再存在
贴一个 truncated 标记告诉模型截过了,仅此而已
点「播放」运行情景 A,或滚动到此处自动播放。
演示为教学化模拟:卡片、字节数与文件路径均为课程化抽象,决策逻辑对应 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、替换。演示里那张清单就是这五步的原样搬运。

核心视觉 · 教学化结构图
工具结果定稿 tools/post-execute · next() 之后 纯文本 且 > maxInlineBytes? read 工具直接跳过,防死循环 否 · 原样放行 模型上下文 模型看得到的部分 saveText() 全文落盘 ctx.spillStore · 失败则保留内联 存档文件(0600 排他写) session-<hash>/<random>-grep.txt 首尾预览 + 取回凭证 locator + retrievalHint 模型 · read / grep 按凭证随时捞回全文
教学化结构图:节点与连线用于解释源码关系,内容经过课程化整理。
三个最容易混淆的点
只处理纯文本

结果里混进任何一个非文本块(比如一张截图),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 分支里,算成功,一个字都不藏。

packages/spill/spill-policy/src/index.ts第 153 至 161 行节选
    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
    }
源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 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 行)。

课堂练习
01

手推三条输出的命运

部署配置 maxInlineBytes: 50000。三条工具结果先后进来:一条 60,000 字节的 web_fetch 纯文本;一条 200,000 字节、但混了一个 image 块的浏览器截图结果;一条 80,000 字节的纯文本,但落盘时磁盘满了。对照第 194 至 203 行的守门逻辑和第 153 至 161 行的 catch 分支,分别写出每条结果最终进入模型上下文的形态,以及存档柜里各多了几个文件。

Takeaway:Spill 把大输出从塞进去还是丢掉的二选一里解放出来:全文落盘、预览加凭证进上下文,模型用现成的 read/grep 随时捞回。策略只处理纯文本定稿、跳过 read 防死循环,存储失败保留内联、不改判 isError。截断丢信息,溢写找得回,这是两种上下文观。