溢出存储
一句话版:
ctx.spillStore是工具输出溢出能力缝:超长工具结果经spill-policy判定后,把完整文本落盘,模型侧只留下有界预览 + 定位符。spill-local把溢出文本存成会话作用域的本地文件,定位符是文件路径,检索提示让模型用read/grep。
工具输出有时巨大(网页抓取、命令日志)。把全文塞进模型上下文既贵又没用——spill 把全文存下来,只给模型一个"去哪找"的定位符。
一、三个包的职责
| 包 | 角色 | ctx key |
|---|---|---|
spill | 定义溢出存储缝(服务 + 词汇类型) | ctx.spillStore |
spill-local | 把溢出文本存进会话作用域的本地文件 | 注册在 ctx.spillStore |
spill-policy | 应用工具执行后的溢出策略 | 监听 ctx.tools |
三者按关切拆分、可独立演化/替换:存储缝说做什么(WHAT),本地后端说怎么做(HOW),策略决定何时溢出并组装通知。
二、服务定义(spill)
SpillStore(ctx.spillStore)定义溢出后端做什么——持久化工具的超大文本,返回一个面向模型的定位符 + 检索指引——而不说怎么做。
| Member | 语义 |
|---|---|
saveText(input) | 原样持久化 input.content;resolve 一个 SpillRef(不透明定位符、精确写入字节数、检索提示)。遇到真实存储失败拒绝(权限、ENOSPC、后端不可用),由调用方决定如何降级 |
存储按请求的 owner 会话在保存时分组为命名空间;后端选择自己的私有表示,可从调用方的 suggestedName 派生名字,但绝不把它当路径信任。这个缝只拥有存储:无保留策略(归 @deepseek-ai/dsh-output-retention)、无工具结果替换(归 spill-policy)、无检索/搜索 API(后端的 retrievalHint 告诉模型怎么用定位符)。
词汇:
SaveTextSpill(owner、source、suggestedName、content)是请求;SpillRef(locator、bytes、retrievalHint)是结果SpillLocator是 branded、渲染给模型的不透明字符串——对spill-local是本地路径,未来后端可能返回 URI、键或命令 token,而不改 policy/tool 消费者SpillOwner.sessionId是保存时的存储命名空间:fork 的会话从种子日志继承已有定位符,不复制也不重新归属;fork 后的新溢出用子会话 idSpillSource记录产生它的toolName、callId、label,用于后端命名与检视,不是访问控制
三、本地后端(spill-local)
SpillStore 的本地文件系统实现。注册为 ctx.spillStore,把工具的超大文本持久化到私有、会话作用域的文件;定位符是文件路径,检索提示让模型在该路径上用 read 或 grep。
存储布局:文件落在 <root>/session-<hash>/<random>-<safeName>
| 段 | 含义 |
|---|---|
root | 配置的 root(解析为绝对路径);省略时在 OS 临时目录下懒创建私有(0700) 的每进程目录。可预测、可读的 root 会让本机其他用户读到溢出输出或种下符号链接 |
session-<hash> | sha256(sessionId) 的短前缀,让同一会话的溢出文件聚在一起,未来可按会话清理 |
<random>-<safeName> | 不可预测的十六进制前缀(防共享 root 下的符号链接种植)+ 调用方 suggestedName 净化成单个安全路径段(防路径穿越;镜像 JSONL 持久化后端的 encodeSegment)。写是独占 + 仅属主(open(path, 'wx', 0o600)):任何已存在路径——无论是否符号链接——都会失败,种下的目标无法重定向它 |
配置:
| Key | 默认 | 含义 |
|---|---|---|
root | 私有 0700 临时目录 | 溢出文件的根目录;设为已知位置可保留它们 |
saveText 在真实存储失败时拒绝(权限、ENOSPC);溢出策略把拒绝视作 best-effort,保留内联结果。
四、溢出策略(spill-policy)
工具结果溢出策略:一个 tools/post-execute 变换器,把超大的明文工具结果挡在模型上下文之外。当最终结果超过 maxInlineBytes,它经 ctx.spillStore 保存完整文本,把模型侧结果换成有界头尾预览 + 后端定位符 + 检索提示。
此插件不注册服务,也不拥有存储或预览机制:预览是 @deepseek-ai/dsh-output-retention(TextRetainer),存储是 ctx.spillStore。它只决定何时溢出并组装通知。
配置:
| Key | 默认 | 含义 |
|---|---|---|
maxInlineBytes | (省略) | 明文结果的模型侧上下文上限,UTF-8 字节(非负整数,加载时校验)。省略则彻底禁用该策略(插件什么都不注册)。设置后,更大的结果被溢出并替换为按同一预算派生的预览(头/尾切分) |
行为:
-
让工具跑完(经
next()委托,所以它约束下游 hook 接受的任何东西) -
跳过:嵌套执行(
exec.parent存在,其持久副本由下面的 dispatch-log 臂约束)、被接受的值替换(注册表必须重新校验并重渲染它们)、read(避免read → 溢出 → 再 read循环)、任何非accept决策(block的纠正反馈透传) -
仅在内容是纯文本(全是
text块)时扁平化;含任何非文本块的结果原样不动 -
UTF-8 大小
≤ maxInlineBytes→ 不变 -
否则保存全文,把结果替换为预览 + 通知,尺寸让整个替换(预览 + 空行 + 通知)保持在
maxInlineBytes内——通知的字节成本从预算里预留,所以预览收缩以适应,模型侧结果永不超上限:<保留的头/尾预览>(Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)通知单独就占满预算(上限极小或定位符很长)时,预览为空、只返回通知。若连这个"仅通知"的替换都会超过
maxInlineBytes,策略就保留内联结果——它绝不发出超上限的替换(而合规替换总比原文小,所以溢出也绝不增加字节)。
Best-effort:没有会话 owner、没有 ctx.spillStore 后端、或 saveText 拒绝 ⇒ 策略记警告并返回原结果。溢出失败绝不把一次成功调用变成 isError 或隐藏内联结果。成功的替换只改 content,规范的程序值被保留。
dispatch-log 臂:第二个监听 tools/code-dispatch-log 的监听器,把同一上限、替换管线、best-effort 兜底应用到每个 run_code 子调用结果的持久副本(artifact 标签 dispatch,按子调用 id 键控)。程序值不动(它已整体越过 worker 边界),read 子调用也受限:日志副本不是模型上下文,read → 溢出 → 再 read 循环不会发生,而 read 恰恰是产出巨大日志的工具。
范围:策略只看到最终的格式化模型侧结果——不是工具的内部资源或规范值。若 provider 已截断(如 web-fetch-http.maxBodyChars),溢出产物保存的是工具返回的完整格式化结果,不是完整原始源。provider/资源上限保持强制且独立。glob/grep 自己拥有条目级呈现溢出(其完整获取值在渲染前仍存在);bash 流自己拥有采集时溢出。通用策略把它的 waterfall 监听器前置再委托,所以普通工具自有的异步投影先完成,再做通用字节约束,与插件加载顺序无关。
五、挂载状态
| 包 | 默认挂载 | 默认配置 |
|---|---|---|
spill-local | base 默认挂载 | 无(root 省略 → 私有 0700 临时目录) |
spill-policy | base 默认挂载 | maxInlineBytes: 50000 |
六、对模型的可见性
超长明文结果:≤ maxInlineBytes 的结果、嵌套结果、read 结果、被 block 的决策、含非文本块的结果都不变。超长明文模型侧结果变成一个有界头尾预览 + (Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>);存储或属主失败则保留原结果可见。
- Token 效应:成功的替换至多
maxInlineBytesUTF-8 字节,并在历史里保留到压缩;完整溢出文本不重发给模型 - KV cache 效应:append-only;新可见内容跟在可复用请求前缀之后,不失效既有 KV cache 条目
七、已知限制
- 只有最终明文结果可溢出——混合内容结果、被 block 的反馈、
read透传;早先发生的 provider 截断或工具自有保留无法在这里恢复 - 放不下的通知为该调用禁用替换——极小上限或长定位符会让超大原文保持内联,尽管后端已存了一份未引用的溢出
- 本地溢出文件在外部清理前一直存在——后端没有会话生命周期删除或按龄保留策略(因为持久化、恢复、fork 的会话可能仍引用路径)
- 定位符要求同居文件系统的消费者——远程或虚拟部署需要另一个
SpillStore后端,其定位符与检索提示在那里才有意义
验证
# spill-local / spill-policy 已默认挂载(base)
dsh web --dump-config | grep -iE "spill"
# 会话里看被溢出的工具结果(模型侧是预览 + (Omitted ...) 通知)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"Omitted ' | tail