DeepSeek Harness 的 spill:上下文放不下时溢出到哪、边界在哪
工具跑完,返回二十万字节的构建日志。这份文本直接塞回会话历史,接下来每一轮请求都要带着它走。DeepSeek Harness 在 packages/spill 下放了一套专门处理这件事的机制:把超限的结果原样写到磁盘,会话里只保留头尾预览和一行「完整结果存在哪」的提示。
先说清限定条件。deepseek-harness 仓库建立于 2026-08-13,我们采集快照是 2026-08-16,中间只隔三天;版本停在 0.1.0-rc.5,GitHub 上一个 Release 都没有,README 自述处于开发者预览阶段并明确写了未来会出现破坏兼容性的变更。下面提到的所有配置键、默认值、错误文案,都是快照里的写法,随时可能改。
三段式:定义、后端、策略
docs/subsystems/spill.md:5 把这块拆成三个包:服务定义 dsh-spill(挂在 ctx.spillStore)、本地提供方 dsh-spill-local、消费方 dsh-spill-policy。三者职责切得很干净——定义只规定「怎么存」,策略才决定「什么时候存、存完把上下文改成什么样」。
seam 本身窄到只有一个方法:saveText(input) → Promise<SpillRef>。文档对它的要求是原样完整持久化,真实的存储失败(权限、ENOSPC、后端不可用)必须 reject,不许悄悄降级。更值得注意的是它明确不含什么:不含保留期策略、不含工具结果替换逻辑、不含任何检索或搜索 API(spill.md:83)。也就是说,ctx.spillStore 是个只写的口子;写进去的东西怎么被找回来,是策略层和模型自己的事。
截至 2026-08-16 的快照,packages/spill 整个目录是 30 个文件、12 个 .ts、1,473 行 TS 代码、3 个 package.json——在会话与上下文这一整块我们统计过的八个目录里,spill 的 TS 行数是最少的一个。
唯一的开关,以及「省略即什么都不注册」
策略层的配置只有一个键:maxInlineBytes,单位是 UTF-8 字节。
最容易翻车的是它的缺省行为。packages/spill/spill-policy/src/index.ts:111-113 里,如果 maxInlineBytes 是 undefined,apply 直接 return,整个插件什么都不注册——不是「用一个内置默认值」,是真正的 no-op。所以如果你把 dsh-spill-policy 装上了却发现大结果照样整份进上下文,第一件要确认的事不是阈值调得对不对,而是这个键到底有没有出现在你的 composition 里。
给了值也要过一道加载期校验:必须是非负整数,否则抛 spill-policy: maxInlineBytes must be a non-negative integer (got X)(src/index.ts:117-118)。这道校验刻意放在加载期而不是每次调用时,源码注释说得很直白——坏配置应该让部署失败,而不是把每一次超限的工具调用变成 isError。
那 50000 这个数字是哪来的?它在 packages/bundle/base/cordis.patch.yml:349-352 里,是出厂 composition 给的值,不是库级默认值。同一个数字还出现在设计笔记 .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md:127 和示例 packages/examples/acp-demo/tests/load-path.e2e.ts:56。这个区分不是咬文嚼字:库里根本没有默认值,一旦你自己写 composition 而没抄这一段,行为就是「完全不启用」。仓库里这类「库里 required、数字只存在于 bundle」的配置不止 spill 一处,会话标题和投影缓存也是同样的套路。
触发条件是严格大于:if (totalBytes <= maxInlineBytes) return decision(src/index.ts:203)。正好等于 cap 的结果不会被溢出。另一条日志臂在 :225 用同样的判断。
预算:通知行先从 cap 里咬掉一口
超限之后不是简单地「截断加省略号」。策略要保证一条不变式:替换后的文本永远不超过 cap。而替换后的文本 = 头尾预览 + \n\n + 通知行,通知行本身是要占字节的,所以预算得倒着算。
源码的做法是先按「最坏情况的省略字节数」(也就是整份文本的字节总数)去造一条通知行,量出它的长度再 +2(\n\n 的两个字节),这就是保留额;previewBudget = cap - reserve(src/index.ts:171-172)。用最坏情况定价的原因写在注释里:省略字节数越大、数字的位数越多,按总字节数造出来的通知行长度是真实通知行的上界,因此保留额一定够。
拿到预览预算之后,头尾对半分:headBytes = Math.ceil(budget / 2)、tailBytes = Math.floor(budget / 2)(src/index.ts:96-97)。预算是奇数时头部多拿一个字节。
通知行的格式固定为 (<省略描述> Full formatted result stored at: <locator>. <retrievalHint>)(src/index.ts:107)。其中 retrievalHint 由本地后端写死成一句话:'Use read with offset/limit, or grep this path to search within it.'(packages/spill/spill-local/src/index.ts:60)。换句话说,模型能不能把溢出的内容捞回来,靠的不是任何检索 API,而是这句提示加上它手里已有的 read 与 grep 工具。
还有一条兜底:万一通知行本身就超过了 cap(cap 设得极小,或者 spill 根路径特别长),就没有任何「不超 cap 的替换」可选,此时策略放弃替换、保留原始内联内容(src/index.ts:183-185)。这时候文件其实已经写下去了,源码注释称它为「无害孤儿,清理被推迟」。
哪些调用会被跳过
tools/post-execute 这条臂上,四种情况直接放行(src/index.ts:196-197):决策不是 accept、决策带了 value 的替换、嵌套子调用(exec.parent !== undefined)、以及工具名是 read。
read 被跳过的理由写在注释里,是为了避免 read → spill → 再 read 的循环——读文件的结果被溢出成另一个文件,模型只能再 read 一次,绕回原地。
第二条臂 tools/code-dispatch-log 用同一个 cap 去约束 tool/code-dispatch 事件里对 run_code 子调用结果的日志副本(src/index.ts:217-225)。这条臂不跳过 read,源码给出的理由是日志副本不进模型上下文,不存在那个循环。同一个配置值管着两件目的不同的事,读配置时别当成一件。
文件落在哪,权限是什么
本地后端的目录布局在 packages/spill/spill-local/src/store.ts:会话目录是 <root>/session-<hash>,hash 取 sha256(sessionId) 十六进制的前 12 位(store.ts:73-76)。文件名是 randomBytes(6).toString('hex') 加连字符,再接一段经 encodeSegment 净化过的名字(store.ts:110-111)。
encodeSegment 对任意 JS 字符串做单射编码:保留 [A-Za-z0-9._-](~ 除外),其余一律按 ~XXXX 转义;空串编码成 ~,. 和 .. 整体转义(store.ts:48-63)。单射意味着不同的原名不会撞成同一个文件名。
写入用 open(path, 'wx', 0o600)——独占创建、仅属主可读写;目录用 mkdir(..., { mode: 0o700 })(store.ts:109,113)。默认 root 是 mkdtempSync(join(tmpdir(), 'dsh-spill-')) 懒创建的进程私有临时目录(store.ts:27-30)。这里只陈述源码里写的权限位与路径来源:这些工具结果里可能包含你项目里的任意内容,落到临时目录之后归谁管、留多久,仓库这一层没有给答案(见下一节)。
明确不做的几件事
这是本篇最该记住的部分,全部来自各包 README 的自述限制:
- seam 没有检索与删除 API;
SpillOwner只用来划分写入命名空间,不做读取鉴权(packages/spill/spill/README.md:41-42)。 - 只有「最终的纯文本结果」可以溢出;通知行塞不下时会留下一份没人引用的 spill 文件(
packages/spill/spill-policy/README.md:57-58)。 - 结合前面的目录规则可知:没有保留期策略,也就没有自动清理这一说——这是
spill.md:83对 seam 范围的明确划界。
两处文档与源码对不上
第一处:docs/subsystems/spill.md:85 描述本地后端时写的是「a sha256(sessionId) session subdir」,而源码 packages/spill/spill-local/src/store.ts:74 取的是 createHash('sha256').update(sessionId).digest('hex').slice(0, 12),即十六进制前 12 位,该函数自己的 JSDoc 称之为「a short stable hash」。两处不一致;以我们实读的仓库状态为准。
第二处:同样是 spill.md:85,只说了「超过 maxInlineBytes 就替换成 head/tail 预览加 spill 引用」,没有提到 read 被跳过(spill-policy/src/index.ts:196-197),也没有提到通知行要从 cap 里预留、预览预算是 cap - reserve(src/index.ts:171-172)以及装不下就整体放弃替换的不变式(src/index.ts:183-185)。这两条在 packages/spill/spill-policy/README.md:20-29 里是写了的。位置差异如上,我们不推断原因。
实际用途很直接:只看子系统页会以为整个 sessionId 的哈希是目录名、会以为所有工具一视同仁,去仓库里排查时就对不上。要核这两处,打开上面三个文件的对应行自己比一遍即可,不需要运行任何东西。
怎么判断「是不是 spill 在起作用」
不需要装环境,看四个位置就能定位大部分困惑:
- composition 里有没有
maxInlineBytes这个键——没有则插件整个未注册(src/index.ts:111-113)。 - 有值的话,结果字节数是不是严格大于它(
src/index.ts:203),注意单位是 UTF-8 字节。 - 这次调用是不是落在四种跳过情形里,尤其是不是
read、是不是嵌套子调用(src/index.ts:196-197)。 - 上下文里有没有那行
Full formatted result stored at: ...(src/index.ts:107)。没有它,就说明这次没走替换路径。
如果四条都对得上、上下文里却还是整份原文,那大概率不是 spill 这条线的问题——它只作用于 tools/post-execute 上被 accept 的纯文本结果,工具结果的另一层裁剪(按 Unicode 码点做头尾保留的那一层)走的是完全不同的包和不同的计量单位,关于它我们另有一篇专门讲。
延伸阅读
- 从头读起:DeepSeek Harness 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 的 token 计量:文档两种 baseline,源码三元联合
- DeepSeek Harness 里的 TurnTrigger:文档还留着,packages 里搜不到
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。