学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 07
第 4 章 · EP 07

Spill:工具输出太大怎么办

时长 15:34音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 塞进去还是丢掉1:57
1:57五步决策1:53
3:51三个最容易混淆的点2:03
5:54存储失败不改判2:16
8:10凭证长什么样2:05
10:16手推三条输出的命运1:50
12:06三家怎么处理大输出1:45
13:51可带走的原则1:41
解读全文

ep39 · Spill:工具输出太大怎么办

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
来源:xueai.miyang.cn(小山学堂 · 洛小山)

一句话速览

超限输出落盘存档,给模型留一张取回凭证。

截断丢信息,溢写找得回 —— 这是两种上下文观,差别不在技术,在你认不认为「丢出去的东西将来还要用」。

核心源码:packages/spill/spill-policy/src/index.ts。


三个候选答案

一条 grep 命中几万行,或 web_fetch 抓回一整页文档,结果 2MB。这条结果接下来去哪,只有三个选项:

选项后果
① 整个塞进上下文下一次模型请求直接被它占满,钱包和上下文窗口一起遭殃
② 砍掉超出部分省是省了 —— 可万一模型后面要找的正是被砍掉的那行报错,任务就卡死了
③ Spill(DSH)全文落盘存档,上下文里只留首尾预览 + 取回凭证,模型用现成的 read/grep 随时捞回

能力地图

POST-EXECUTE 五步决策

#步骤判什么出处
1next() 委托先让下游把结果结算好L194
2纯文本检查混入非文本块 → 整个不碰L200–201
3字节阈值UTF-8 大小超 maxInlineBytes 才动手L202–203
4saveText 落盘全文原样写进会话存档L155
5替换成预览 + 凭证首尾预览 + 取回提示进上下文L173–175

出处:spill-policy/src/index.ts L190–209。

守门顺序(四条不碰的理由)

  1. 下游监听器没接受这条结果;
  2. 别的插件已经替换过值;
  3. 这是嵌套子调用或 read 工具;
  4. 内容混了非文本块。
任何一条命中 → 原样放行。都没命中 → 再量字节,没超限也放行。全过了才走 spill。

三条输出的命运(上限 maxInlineBytes: 50000)

输入结果存档柜
60,000 字节 纯文本落盘 → 上下文留首尾预览 + 凭证+1
200,000 字节但混了 image 块原样保留(第 2 步就没过),20 万字节全进上下文+0
80,000 字节纯文本,落盘时磁盘满catch → 返回 undefined → 原始结果原样内联,调用仍算成功+0

三个最容易混淆的点

提示1 · 只处理纯文本(失败方向是保守的)

结果里混进任何一个非文本块(如一张截图)→ flattenPlainText 返回 undefined → 整条结果原样保留。

策略只认识最终格式化文本,不懂工具内部结构 —— 宁可不碰。宁可让上下文胖一点,也不冒险处理看不懂的结构。

出处:index.ts L80–87。

提示2 · read 被跳过(防死循环,且按臂分别处理)

模型面向的那一臂明确跳过 read 工具 —— 防止「read 的输出被 spill 成文件 → 模型再 read → 再 spill」的死循环。

日志那一臂不跳 —— 因为日志副本进不了模型上下文,循环不成立。
同一个机制在两条臂上有不同处理,依据是循环能不能成立,不是图省事统一。

出处:L195–197、L219–222 注释。

提示3 · 凭证不是路径(不透明句柄)

locator 是不透明句柄:本地后端给文件路径,远程后端可以给 URI 或键。消费方不解析它,按后端附带的 retrievalHint 渲染取回话术 —— 不假定 read 永远是正确的取回方式。

出处:docs/subsystems/spill.zh.md L70。


关键证据 · 存储失败不改判


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
}

出处:index.ts L153–161;设计笔记 2026-07-08 L91。

磁盘满了、权限不对、后端没挂载 → 只换来一条 warn 日志,然后原始结果原样内联进上下文。

逻辑很朴素:spill 是省钱的优化,优化失败最多让上下文胖一点,绝不能把一次成功的调用弄成失败,更不能弄丢信息。

提示4 · 通用判断标准

优化路径上的失败,不能改变主路径的结果。 让系统回到「不省钱」的那种状态,而不是报错。
把优化失败升级成主流程失败,是最常见的过度设计。

反方向的坑也被堵了

配置校验放在插件加载时,不在每次调用时。一个负数或小数的 maxInlineBytes 会直接让部署启动失败 —— 因为坏配置该炸的是部署,轮不到某次工具调用背锅。出处:L114–119。


凭证长什么样

替换后的模型可见文本是三段式:

  1. 保留的头部预览;
  2. 省略说明 + 凭证(由 spillNotice 拼出,L104–108);
  3. 保留的尾部预览。

示例措辞:

(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
措辞刻意通用 —— 因为策略只知道最终文本,不了解工具内部资源。所以凭证里不会出现「读某个文件的第几行到第几行」这种精确指示。

提示5 · 预算要先扣掉自己的开销

凭证本身的字节数会先从 maxInlineBytes 预算里扣掉,再算预览能留多少(L171–172)。

否则:预览花满预算 → 凭证再往后一贴 → 替换文本反而比上限还大。

要是凭证一行就超过整个上限 → 策略干脆放弃 spill、保留内联,绝不违反自己宣称的上限(L183–185)。

存档文件本身也讲究

本地后端把文件写到 <root>/session-<hash>/<random>-<safeName>:

  • 根目录私有(0700);
  • 写入用 open(path, 'wx', 0o600) —— 排他且仅所有者可读;
  • 预先植入的符号链接没法重定向写入。

出处:docs/subsystems/spill.zh.md L85。同族设计还有附件系统:引用进日志、字节放外部 store,正文只留轻量引用(docs/subsystems/attachment.zh.md)。


横向对比 · 三家怎么处理大输出

机制覆盖范围
DSH全文落盘 + 预览 + 凭证通用
Claude Code上限 25,000 tokens(maxResultSizeChars);超限落盘后附带说明路径通用
Grok Build默认上限 20,000 字节(DEFAULT_TOOL_OUTPUT_CHARS),超限截断 + truncated 标志;bash 是特例(全量先写 terminal log)仅 bash

出处:study/chapters/02-tool-system.md L664–666、L670;xai-grok-tools/src/lib.rs L11;bash/mod.rs L379–381。

DSH 的版本切得最碎

包职责
output-retention 库预览机制
spillStore seam(单方法抽象服务)存储
spill-policy 插件只决定什么时候 spill、怎么拼凭证
三个包各管一段 —— 换一个远程存储后端不用动策略一行代码。

设计笔记的替代方案一节点名了参照对象:做通用默认行为,就是冲着「类似 Claude Code 通用工具结果持久化」去的(L187)。

对比焦点在通用性:三家都承认大输出不能全喂给模型,差别是丢掉的部分还能不能找回来、这个能力覆盖多少工具。

第二家值得一提的一条原则

官方博客还补了一条:截断时要告诉 Agent 为什么截了、怎么拿到完整内容。

这一条和 DSH 的凭证是同一个思路 —— 说明两家在这一点上判断一致:光告诉模型「截过了」不够,还要告诉它去哪找。
这是个很实用的标准,自己实现截断时也该照着做。

为什么留首尾而不是只留开头

工具输出的信息分布常常是两头重:开头是命令回显和摘要,结尾是最终的报错或者结论,中间才是大段重复内容。

  • 只留开头 → 丢掉最有价值的报错;
  • 只留结尾 → 丢掉上下文;
  • 首尾都留,用一行省略说明连起来 → 模型能判断要不要去捞全文。
这也是预览和截断的本质差别:截断是单向的,一旦截掉就没了;预览是有指向的,它告诉模型剩下的在哪。

审查清单

  • 大输出是否有「塞进去 / 砍掉」之外的第三条路?
  • 守门顺序是否保守(判断不了就整条不碰)?
  • 是否跳过 read 防死循环?日志那一臂是否分别处理?
  • locator 是否为不透明句柄(消费方不解析、按 retrievalHint 渲染)?
  • 存储写失败是否保留内联、不改判 isError?
  • 配置校验是否在插件加载时(坏配置炸部署,不炸调用)?
  • 凭证字节数是否先从预算里扣掉?
  • 凭证比上限还大时,是否放弃 spill?
  • 存档文件是否排他写入 + 仅所有者可读(符号链接无法重定向)?

排查路径

  1. 大输出没被溢写 → 先查是否混了非文本块(第 2 步就没过)。
  2. 上下文被大输出撑爆 → 查 maxInlineBytes 是否配置过大,或结果为非文本。
  3. read 工具输出反复落盘 → 查模型那一臂的 read 跳过是否生效。
  4. 磁盘满时任务报错 → 查 catch 分支是否错误地升级成了 isError。
  5. 换存储后端要改策略 → 说明 spillStore seam 没做干净。

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号或产品行为。
  2. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  3. 源码时效:依据本地仓库 deepseek-harness-master,核对日期 2026-08-13;横向对比部分基于已公开材料。
  4. 演示说明:原文交互演示的卡片、字节数与文件路径为教学化抽象,决策逻辑对应真实源码 L190–209;演示中 maxInlineBytes 设为 50 KB,与 Agent Note 示例部署一致。
  5. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。

Takeaway

Spill 把大输出从「塞进去还是丢掉」的二选一里解放出来:全文落盘、预览加凭证进上下文,模型用现成的 read/grep 随时捞回。

策略只处理纯文本定稿、跳过 read 防死循环,存储失败保留内联、不改判 isError。

截断丢信息,溢写找得回 —— 这是两种上下文观。

*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*