本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
来源:xueai.miyang.cn(小山学堂 · 洛小山)
超限输出落盘存档,给模型留一张取回凭证。
截断丢信息,溢写找得回 —— 这是两种上下文观,差别不在技术,在你认不认为「丢出去的东西将来还要用」。
核心源码:packages/spill/spill-policy/src/index.ts。
一条 grep 命中几万行,或 web_fetch 抓回一整页文档,结果 2MB。这条结果接下来去哪,只有三个选项:
| 选项 | 后果 |
|---|---|
| ① 整个塞进上下文 | 下一次模型请求直接被它占满,钱包和上下文窗口一起遭殃 |
| ② 砍掉超出部分 | 省是省了 —— 可万一模型后面要找的正是被砍掉的那行报错,任务就卡死了 |
| ③ Spill(DSH) | 全文落盘存档,上下文里只留首尾预览 + 取回凭证,模型用现成的 read/grep 随时捞回 |
| # | 步骤 | 判什么 | 出处 |
|---|---|---|---|
| 1 | next() 委托 | 先让下游把结果结算好 | L194 |
| 2 | 纯文本检查 | 混入非文本块 → 整个不碰 | L200–201 |
| 3 | 字节阈值 | UTF-8 大小超 maxInlineBytes 才动手 | L202–203 |
| 4 | saveText 落盘 | 全文原样写进会话存档 | L155 |
| 5 | 替换成预览 + 凭证 | 首尾预览 + 取回提示进上下文 | L173–175 |
出处:spill-policy/src/index.ts L190–209。
任何一条命中 → 原样放行。都没命中 → 再量字节,没超限也放行。全过了才走 spill。
maxInlineBytes: 50000)| 输入 | 结果 | 存档柜 |
|---|---|---|
| 60,000 字节 纯文本 | 落盘 → 上下文留首尾预览 + 凭证 | +1 |
| 200,000 字节但混了 image 块 | 原样保留(第 2 步就没过),20 万字节全进上下文 | +0 |
| 80,000 字节纯文本,落盘时磁盘满 | catch → 返回 undefined → 原始结果原样内联,调用仍算成功 | +0 |
结果里混进任何一个非文本块(如一张截图)→ flattenPlainText 返回 undefined → 整条结果原样保留。
策略只认识最终格式化文本,不懂工具内部结构 —— 宁可不碰。宁可让上下文胖一点,也不冒险处理看不懂的结构。
出处:index.ts L80–87。
模型面向的那一臂明确跳过 read 工具 —— 防止「read 的输出被 spill 成文件 → 模型再 read → 再 spill」的死循环。
日志那一臂不跳 —— 因为日志副本进不了模型上下文,循环不成立。
同一个机制在两条臂上有不同处理,依据是循环能不能成立,不是图省事统一。
出处:L195–197、L219–222 注释。
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 是省钱的优化,优化失败最多让上下文胖一点,绝不能把一次成功的调用弄成失败,更不能弄丢信息。
优化路径上的失败,不能改变主路径的结果。 让系统回到「不省钱」的那种状态,而不是报错。
把优化失败升级成主流程失败,是最常见的过度设计。
配置校验放在插件加载时,不在每次调用时。一个负数或小数的 maxInlineBytes 会直接让部署启动失败 —— 因为坏配置该炸的是部署,轮不到某次工具调用背锅。出处:L114–119。
替换后的模型可见文本是三段式:
spillNotice 拼出,L104–108);示例措辞:
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
措辞刻意通用 —— 因为策略只知道最终文本,不了解工具内部资源。所以凭证里不会出现「读某个文件的第几行到第几行」这种精确指示。
凭证本身的字节数会先从 maxInlineBytes 预算里扣掉,再算预览能留多少(L171–172)。
否则:预览花满预算 → 凭证再往后一贴 → 替换文本反而比上限还大。
要是凭证一行就超过整个上限 → 策略干脆放弃 spill、保留内联,绝不违反自己宣称的上限(L183–185)。
本地后端把文件写到 <root>/session-<hash>/<random>-<safeName>:
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。
| 包 | 职责 |
|---|---|
| output-retention 库 | 预览机制 |
| spillStore seam(单方法抽象服务) | 存储 |
| spill-policy 插件 | 只决定什么时候 spill、怎么拼凭证 |
三个包各管一段 —— 换一个远程存储后端不用动策略一行代码。
设计笔记的替代方案一节点名了参照对象:做通用默认行为,就是冲着「类似 Claude Code 通用工具结果持久化」去的(L187)。
对比焦点在通用性:三家都承认大输出不能全喂给模型,差别是丢掉的部分还能不能找回来、这个能力覆盖多少工具。
官方博客还补了一条:截断时要告诉 Agent 为什么截了、怎么拿到完整内容。
这一条和 DSH 的凭证是同一个思路 —— 说明两家在这一点上判断一致:光告诉模型「截过了」不够,还要告诉它去哪找。
这是个很实用的标准,自己实现截断时也该照着做。
工具输出的信息分布常常是两头重:开头是命令回显和摘要,结尾是最终的报错或者结论,中间才是大段重复内容。
这也是预览和截断的本质差别:截断是单向的,一旦截掉就没了;预览是有指向的,它告诉模型剩下的在哪。
locator 是否为不透明句柄(消费方不解析、按 retrievalHint 渲染)?maxInlineBytes 是否配置过大,或结果为非文本。quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。maxInlineBytes 设为 50 KB,与 Agent Note 示例部署一致。Spill 把大输出从「塞进去还是丢掉」的二选一里解放出来:全文落盘、预览加凭证进上下文,模型用现成的 read/grep 随时捞回。
策略只处理纯文本定稿、跳过 read 防死循环,存储失败保留内联、不改判 isError。
截断丢信息,溢写找得回 —— 这是两种上下文观。
*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*