结果太大了:Spill、截断和外溢
当工具输出超过上下文窗口能承受的大小时,DSH 的 spill-policy 插件以 prepend:true 注册到 tools/post-execute,先让下游 hook 看完整结果再截断,通过 SpillStore best-effort 存全文、head/tail 50/50 split 生成预览,且不替换 canonical value。本章拆解该机制的两个 arm、字节预算策略、防循环设计与 shell 独立截断的区别。
一个直觉错误
你可能觉得大结果的截断是工具自己做的——read 工具读了个大文件自己截断,bash 工具输出太多自己砍。这个直觉在 shell 层面部分成立(shell 确实有独立截断),但 DSH 处理”模型看到的输出太大”这个问题的核心机制不在工具层内部,而是一个完全独立的插件:@deepseek-ai/dsh-spill-policy。
它不修改任何工具的代码,不需要工具开发者配合,不在工具 execute 函数里执行。它以 { prepend: true } 注册到 tools/post-execute waterfall hook——意味着它坐在 hook 链的最外层,所有工具结果出来之后统一处理。
更反直觉的是它的时序:它先让其他 hook 看到完整结果,自己最后才检查大小做截断。以及:顶层 read 工具故意不 spill,防止死循环。还有一个关键区分:spill 只替换模型看到的文本,不替换工具返回的 canonical value——你在 Code Mode 里通过程序调用工具,拿到的永远是完整结果。
下面我们按执行时序把它拆开:它在哪一层介入、什么时候开始截断、哪些东西会改、哪些东西保证不改。
插件架构:不配置就不存在
spill-policy 的入口是 apply() 函数。它做的第一件事不是注册 hook,而是读 config.maxInlineBytes:
export function apply(ctx: Context, config: Config): void {
const maxInlineBytes = config.maxInlineBytes
if (maxInlineBytes === undefined) return // 什么都不注册
if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) {
throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`)
}
const cap: number = maxInlineBytes
// ... 注册两个 hook
}
三种情况:
- 未配置 maxInlineBytes(undefined):直接 return,不注册任何 hook。完全的 no-op——插件加载了但什么都不做,再大的工具结果也原封不动进上下文。
- 配置了但不合法(负数或小数):在加载时直接 throw Error。这让部署失败,而不是让每个超大工具调用在运行时抛错。
- 配置合法:注册两个 hook,系统开始工作。
为什么坏配置要在加载时而不是运行时报错?源码注释说得清楚:“A bad config must fail the deployment, not the tool.” 如果你配了 maxInlineBytes = -1,TextRetainer 的 assertBudget 会在每次超大结果时抛错,工具调用变成 isError——这比部署时就挂掉难排查一个数量级。
Onion Hook 与 prepend:true 的含义
tools/post-execute 是一个 waterfall 形的 hook 链,类似 Koa middleware 的洋葱模型。每个 hook 拿到 (exec, result, next),你可以:
- 在
await next()之前做”进入”逻辑 await next()等所有下游 hook 执行完- 在
await next()之后做”退出”逻辑
spill-policy 用 { prepend: true } 注册——这意味着它排在 hook 链的最外层。它的 next() 包含所有其他 post-execute hook。实际执行顺序是:
- spill-policy 进入 → 调用
await next() - 所有下游 hook 依次处理完整结果(diff hook、summary hook 等都看到原始全文)
- 下游 hook 全部完成,控制权回到 spill-policy
- spill-policy 检查返回的 decision,决定是否截断
这个时序设计解决了一个核心矛盾:下游 hook 需要完整内容(比如 diff hook 要对完整输出做差异计算),但模型不需要看全文。如果 spill 先截断再调用 next(),下游 hook 拿到的就是残缺版本,功能就坏了。
所以 spill 选择做”最后一个动手的人”:先让所有人看完完整结果,都处理完了,再把给模型看的那份版本截短。
flowchart TD
accTitle: Spill-Policy Onion Execution
accDescr: spill-policy 以 prepend:true 坐最外层,先 await next() 让下游看完整结果,回来才做大小检查和截断替换。
A["tools/post-execute 触发"] --> B["spill-policy hook 进入"]
B --> C["await next()"]
C --> D["下游 hook 1: 看到完整 result"]
D --> E["下游 hook 2: 看到完整 result"]
E --> F["所有下游完成,decision 返回"]
F --> G{"decision.kind === 'accept'?<br/>纯文本?<br/>非子调用?<br/>非 read?"}
G -->|任一不满足| H["直接返回,不修改"]
G -->|全满足| I{"totalBytes > cap?"}
I -->|否| H
I -->|是| J["spillReplacement()"]
J --> K["SpillStore.saveText 存全文"]
K --> L["preview: head/tail 50/50 split"]
L --> M["spillNotice: 定位器+提示"]
M --> N["返回替换后 content"]
模型面 Arm 的过滤条件
模型面 arm(tools/post-execute hook)在 await next() 拿到 decision 之后,做四层过滤:
if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value')
|| exec.parent !== undefined || exec.name === 'read') return decision
逐条解释:
decision.kind !== 'accept'——如果下游 hook 把工具调用 block 了(比如返回 corrective feedback),spill 不管。它只处理正常接受的结果。
Object.hasOwn(decision, 'value')——如果 decision 携带了结构化 value replacement(不是纯文本内容),跳过。spill 只处理纯文本 content blocks。
exec.parent !== undefined——子调用(run_code 内部调用工具)不在这个 arm 处理。子调用的结果截断由第二个 arm(dispatch-log)负责。为什么分开?因为子调用的结果同时有两个消费方:程序(要完整值)和日志(可以截断)。
exec.name === 'read'——顶层 read 工具不 spill。这是防循环的关键设计。
为什么 read 不能 spill
想象一下如果 read 会 spill:
- 模型调用 read 读一个 2MB 的文件
- spill-policy 存全文到
/tmp/dsh-spill-xxx/session-abc/a1b2c3-read.txt - 模型看到预览 + notice:“Full result stored at /tmp/dsh-spill-xxx/…”
- 模型决定调用 read 工具去读那个 spill 文件
- 这个 read 结果又超 cap,又被 spill…
- 无限循环
所以模型面 arm 直接跳过顶层 read。如果 read 结果太大,模型就看到完整的大结果(消耗 token),或者 read 工具自己有 offset/limit 参数让模型分段读。spill 不是万能的——对 read 工具,模型需要学会自己分段获取。
但是在 dispatch-log arm 里,read 子调用不跳过——因为那个 arm 截断的是日志拷贝,不是模型上下文,不存在循环风险。
Durable Log Arm:code-dispatch-log
第二个 arm 监听 tools/code-dispatch-log waterfall:
ctx.on('tools/code-dispatch-log', async (dispatch, next): Promise<ContentBlock[]> => {
const content = await next()
const text = flattenPlainText(content)
if (text === undefined) return content
const totalBytes = Buffer.byteLength(text, 'utf8')
if (totalBytes <= maxInlineBytes) return content
const replacedText = await spillReplacement(
text, totalBytes, ownerSessionId(dispatch.exec), dispatch.name, dispatch.subCallId, 'dispatch')
if (replacedText === undefined) return content
return [{ type: 'text', text: replacedText }]
}, { prepend: true })
这个 arm 处理的是 run_code(Code Mode)内部子调度的日志拷贝。当 Code Mode 里的程序通过 SDK 调用工具(比如 read 一个大文件),工具结果会被写到 session log 的 tool/code-dispatch 事件里,用于 replay 和 UI 展示。
关键区别:
- read 子调用也 spill。注释原文:“read sub-calls spill too: the log copy is not model context, so the read → spill → read-again loop the post-execute arm avoids cannot happen here, and read is precisely the tool that produces huge logs.”
- 程序拿到的返回值完全不受影响。注释:“The program’s returned value is untouched (it already crossed the worker boundary whole); only the session log’s copy shrinks.”
- 用的是同一个
spillReplacement()函数,产生字节相同的投影,label 不同('dispatch'vs'result')。
spillReplacement:核心替换逻辑
spillReplacement() 是两个 arm 共享的核心函数。它的返回值只有两种:一个替换后的文本 string,或者 undefined(意味着”不替换,保留原文”)。
第一步:检查前置条件
if (sessionId === undefined) {
ctx.logger.warn(`spill-policy: no session owner for ${toolName} ${label}; keeping the inline content`)
return undefined
}
const spillStore = ctx.get('spillStore')
if (!spillStore) {
ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline content')
return undefined
}
没有 session owner(比如直接/测试调用)或没有 SpillStore 后端——都返回 undefined,保留原文。这不是错误,是”环境不支持 spill”的合法状态。
第二步:存全文(best-effort)
let ref: SpillRef
try {
ref = await spillStore.saveText(save)
} catch (error: unknown) {
ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`)
return undefined
}
SpillStore.saveText 失败(权限、ENOSPC、后端不可用)——catch 住,打 warn log,返回 undefined。绝不让存储故障变成工具调用失败。注释说:“a storage failure must never fail the call or hide the content.”
这个 best-effort 设计的哲学是:spill 是优化(减少 token 占用),不是必要条件。做不到就退化成”完整结果内联”——代价是多花 token,但功能不中断。
第三步:计算字节预算
这是最精细的部分。你不能简单地”取前 N 字节当预览”,因为最终输出要包含三个部分:
- 预览文本(preview)
- 两个换行符
\n\n(2 字节) - spill notice 提示行(告诉模型省略了多少、存在哪、怎么取回)
这三个加起来必须 <= cap。但 notice 的长度在你生成预览之前不确定(它依赖省略字节数的位数)。解决方案:
// 用 totalBytes(全文字节数)估算 notice 上界——因为 totalBytes 的位数 >= omittedBytes 的位数
const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
const previewBudget = Math.max(0, cap - reserve)
先用最坏情况的 notice 长度做预留(全文字节数的位数是省略字节数位数的上界),然后剩下的预算才分给预览。
第四步:head/tail 50/50 split
function preview(text: string, budget: number): { text: string; omitted: Omitted } {
const headBytes = Math.ceil(budget / 2)
const tailBytes = Math.floor(budget / 2)
const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes })
retainer.push(text)
const kept = retainer.finish()
return { text: kept.text, omitted: kept.omittedBytes }
}
预算一分为二:前一半给头部(文件开头/输出开头通常有上下文信息),后一半给尾部(错误信息、stack trace 通常在末尾)。中间部分丢弃。TextRetainer 是 @deepseek-ai/dsh-output-retention 提供的工具类,它按 UTF-8 字节精确切割。
第五步:最终安全检查
if (Buffer.byteLength(replacedText, 'utf8') > cap) {
ctx.logger.warn(`spill-policy: spill notice for ${toolName} exceeds maxInlineBytes; keeping the inline content`)
return undefined
}
return replacedText
即使用了上界估算,代码还是做了最终检查:替换后的文本超过 cap 就放弃替换,保留原文。这个不变量是铁的:spill 策略永远不会产生比 cap 大的输出。做不到就不做——一个 slightly-over-cap 但保留完整的结果,好过一个超 cap 的截断结果(后者违反了对模型的承诺)。
SpillStore:存储抽象与本地实现
SpillStore 是一个 Cordis 抽象 Service——它定义了 saveText() 接口但不规定怎么存。
export abstract class SpillStore extends Service {
constructor(ctx: Context) {
super(ctx, 'spillStore')
}
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
}
每个 Context 只能有一个 SpillStore 实现(Cordis 的 duplicate-service 语义)。目前唯一的实现是 LocalSpillStore——写本地文件系统。
LocalSpillStore 的安全设计
本地存储要防范三种攻击:
- 路径遍历:untrusted 的 suggestedName 可能包含
../ - Symlink 种植:攻击者预先在 spill 目录创建符号链接,让写操作指向敏感文件
- World-readable:其他本地用户读取 spill 的工具输出
解决方案:
- spill root 目录用
mkdtemp(不可预测后缀)+0700权限创建 - session 子目录用 sha256(sessionId) 的前 12 字符命名 +
0700 - 文件名用 6 字节随机 hex 前缀 + encodeSegment(suggestedName)——encodeSegment 把所有非
[A-Za-z0-9._-]字符转义为~XXXX,.和..全量转义 - 写入模式用
'wx'(exclusive create)+0o600——如果文件已存在(不管是真文件还是 symlink),open 直接失败
const handle = await open(path, 'wx', 0o600)
这意味着即使攻击者猜到了路径并预先植入了 symlink,exclusive open 也会拒绝写入。
SpillRef 返回值
saveText 成功后返回 SpillRef:
return {
locator: SpillLocator(saved.path), // 本地实现就是文件路径
bytes: saved.bytes,
retrievalHint: 'Use read with offset/limit, or grep this path to search within it.',
}
locator 是一个 branded string——本地实现用文件路径,远程/数据库实现可以用 URI。retrievalHint 告诉模型怎么取回全文(对本地实现:用 read 工具配合 offset/limit,或用 grep 搜索)。
Canonical Value vs Model-Facing Content
这是最容易搞混的概念对,必须彻底分清:
| Canonical Value | Model-Facing Content | |
|---|---|---|
| 产生方 | 工具 execute() 函数返回值 | output.render() 生成的 ContentBlock[] |
| 消费方 | Code Mode 程序、跨 worker 传递 | 模型上下文窗口、session log |
| spill 影响 | 不碰 | 替换 |
| 完整性 | 永远完整 | 可能被截断 |
当 Code Mode 里的代码通过 SDK 调用工具:
const result = await tools.read({ path: '/some/huge/file.json' })
// result 是完整的文件内容——spill 不碰 canonical value
程序拿到的是完整值,因为 canonical value 在 spill 介入之前就已经跨 worker boundary 传递完毕了。Spill 替换的只是后续写进 session log 和发给模型看的那份文本拷贝。
dispatch-log arm 的注释说得很精确:“The program’s returned value is untouched (it already crossed the worker boundary whole); only the session log’s copy shrinks to preview + locator.”
Shell 的独立截断:maybeTruncate
Shell(persistent bash 工具)有自己的截断机制,完全独立于 spill-policy:
const TRUNCATED_MESSAGE = '<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>'
function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
if (content.length <= maxOutputChars && !incomplete) return content
return content.length <= maxOutputChars
? content + TRUNCATED_MESSAGE
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
}
区别一览:
| Shell maybeTruncate | Spill Policy | |
|---|---|---|
| 触发层 | 工具 execute 内部 | post-execute hook(工具外部) |
| 截断方式 | 硬截断前 N 字符 + 提示 | head/tail 50/50 split + locator |
| 全文保留 | 不保留(截了就没了) | 存到 SpillStore |
| 度量单位 | 字符数(maxOutputChars) | UTF-8 字节数(maxInlineBytes) |
| 默认值 | 16,000 字符 | 必须配置,否则不生效 |
| 能取回吗 | 不能(建议 grep) | 能(read spill 文件) |
Shell 的截断是防护机制——防止失控进程无限输出把内存撑爆。它发生在 shell 工具的 execute 内部,spill-policy 看到的已经是截断后的结果。如果截断后的结果仍然超过 maxInlineBytes(比如 maxOutputChars = 16000 字符但 maxInlineBytes 配的更小),spill-policy 还会再做一次 spill。两层截断可以叠加但互不干扰。
Shell 还有另一层:PTY scrollback 层面的 truncation(lossy 标志 + stdoutSpillPath),当进程输出超过终端回滚缓冲区大小时,早期输出被丢弃。这是最底层的保护,在 maybeTruncate 之前就已经发生了。
SpillStore 服务定义的设计哲学
看 @deepseek-ai/dsh-spill 这个包——它只定义 Service 抽象,不做任何实际存储:
/**
* Semantics every implementation must honor:
* - saveText persists the FULL content verbatim and returns an opaque locator,
* exact byte length, and model-facing retrieval guidance.
* - Storage is scoped by the request's owner session; the backend chooses a
* private (not world-readable) location and a collision-free name.
* - saveText REJECTS on a real storage failure; the caller decides how to degrade.
*/
export abstract class SpillStore extends Service {
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
}
三层分离:
- dsh-spill:定义”什么是 spill 存储”——只有 saveText 一个方法
- dsh-spill-local:本地文件系统实现
- dsh-spill-policy:决定何时 spill、如何构造预览——不拥有存储也不拥有预览算法
策略不拥有存储,存储不知道策略。Preview 算法由 @deepseek-ai/dsh-output-retention(TextRetainer)提供。这意味着:
- 你可以换存储后端(比如 S3、数据库)而不改策略
- 你可以调整预览算法而不改存储
- 你可以禁用策略但保留存储(其他功能也可能用 SpillStore)
实验:验证核心行为
实验 1:确认时序。 在 spill-policy 之后注册一个 post-execute hook 打印 result.content,执行超大结果工具调用——你会看到完整内容被打印,因为 spill 先 await next() 让你跑完才截断。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "await next" "$repo/packages/spill/spill-policy/src/index.ts"
实验 2:验证 read 跳过。 注册名为 read 的工具返回超大文本,配置 maxInlineBytes 很小。执行后 content 保持完整不被 spill。换成 web_fetch 同样大文本——被 spill。
实验 3:验证 best-effort。 把 StubStore.fail 设为 true,执行超大结果。工具调用成功(非 isError),content 是完整原文,日志有 warn。
实验 4:观察 50/50 split。 配置 maxInlineBytes = 200,执行返回 1000 字节的工具。替换后内容开头 ~80 字节是原文头部,结尾 ~80 字节是原文尾部,中间被 notice 替换。
实验 5:dispatch-log arm 验证。 Code Mode 里程序调用 read 读大文件。session log 里 tool/code-dispatch 事件是 spill 后的预览 + locator,但程序变量拿到完整内容。
容易踩的坑
坑一:以为 spill 是工具层内置功能。 你在自定义工具里不需要做任何截断处理——spill-policy 统一在外部处理。但如果部署时没加载 spill-policy 插件或没配 maxInlineBytes,再大的结果也完整进上下文。这不是 bug 是配置问题。
坑二:以为 spill 失败会让工具调用报错。 不会。SpillStore.saveText 是 best-effort,任何存储故障只产生 warn log,工具调用本身正常返回完整结果内联。你永远不会因为磁盘满了而看到 isError。
坑三:在自定义 post-execute hook 里以为拿到的是 spill 后的内容。 不是。spill-policy 用 prepend:true 坐最外层——你的 hook 在 await next() 链里面,看到的是完整 content。spill 发生在所有其他 hook 完成之后。如果你的 hook 需要处理 spill 后的版本,你需要自己排在 spill 之后(但 prepend:true 意味着没有 hook 在 spill”后面”——spill 是最后动手的)。
坑四:以为 Code Mode 里工具返回值被截断了。 没有。canonical value 跨 worker boundary 传递时已经完整了,spill 碰的是后来写进 log 的那份拷贝。程序逻辑永远看到完整数据。
坑五:把 shell 截断当成 spill。 Shell 的 maybeTruncate 是硬截断——按字符数从前面截,不保存全文,不提供 locator。它是防无限输出的保护,不是节省 token 的优化。两者可以叠加但机制完全独立。
坑六:以为 notice 里的 locator 是模型能直接调用的。 模型看到 locator 后需要用 read 工具(带 offset/limit)或 grep 去读。但因为 read 本身不被模型面 arm spill,模型读 spill 文件不会再次触发 spill——这正是 read 跳过规则的设计目的。
收口
Spill 这套机制最后落在几条设计取舍上:
- 独立插件,不侵入工具层——零耦合,不配置就不存在
- 先让所有人看完整结果,最后才截断——prepend:true + await next()
- Read 工具跳过防循环——模型面 arm 不处理,dispatch-log arm 处理
- Best-effort 存储——失败降级为内联完整结果,绝不让 spill 故障变成工具故障
- 字节预算精确控制——reserve notice 开销,最终输出永远
<= cap - 只替换模型看的文本,不碰程序拿的值——canonical value 完整性不受影响
- 与 shell 硬截断互不干扰——两层机制各司其职
到这里,从模型输出 tool-call 到结果回到模型上下文的完整流水线就闭合了。工具执行这部分的每个环节——调度分组、作用域过滤、参数校验、并行屏障、审批沙箱、子进程执行、结果 spill 回模型——都有明确的边界守护和失败退化策略。