生命不息,折腾不止。上一课我们盯住了会话的事件流,这一课顺着它钻进 dsh 的上下文压缩(compaction)——长会话涨到模型窗口门口时,怎么自动把旧历史压成一段摘要,对话继续往前走、审计轨迹一点不丢。

一、先想明白:上下文是有物理上限的,没有「无限对话」这种好事

不管你是用 Chat 界面还是自己跑的 Agent,都有一个绕不过去的物理事实——模型的上下文窗口是硬的,token 装满了就装不下,再发请求要么报错、要么被截断。

很多框架对这事的态度是「装不下就报错,用户自己重开一局」。这对 Agent 很致命:一个跑了半小时、追着一个目标死磕的任务,眼看要收尾了,上下文撞墙,前功尽弃。理想做法是别删历史,而是把「模型现在能看到的东西」做一次瘦身。

dsh 给的答案是 compaction:把较早的一整段对话,替换成一句摘要;最近的历史原样保留;会话从摘要那条消息继续往下走。听起来简单,但 dsh 把它做得格外讲究,下面慢慢拆。

二、它又是一层「能力 seam」:接口、后端、命令三分

老读者应该熟悉这个套路了——dsh 里但凡是个可替换的能力,都照 bash 的标准拆成三层,压缩也不例外:

角色 包 作用
Service Definition(接口) @deepseek-ai/dsh-compaction 定义 ctx.compaction 这个抽象服务,规定「压缩做什么」,本身不干活
Service Provider(后端) @deepseek-ai/dsh-compaction-basic 默认后端,基于 LLM 摘要实现,真正执行压缩
Consumer(消费方) @deepseek-ai/dsh-command-compact 给人类提供 /compact 命令

三个核心设计点,是我觉得最值得学的地方:

  1. 摘要本身是一条 user/message,不是新事件类型。压缩这活儿只有一次「对模型可见」的改动——把旧范围换成一条带 surfaceOp: { op: 'replace', start, end } 的 user 消息。dsh 刻意不扩展 SurfaceEventType(只有能产生消息的事件才进模型),所以模型看到的就是「旧对话被一条摘要替换了」,干净利落。
  2. 日志里的锁是一对标记。压缩开始前先追加一条 compaction/start 拿锁,做完摘要、替换完 surface,最后追加 compaction/end 释放。好处是:如果中途崩溃,留下的是一条没有配对 end 的 start(一个能被检测到的「遗留锁」),而不是一条假报「压缩已完成」的 end。这个顺序设计特别严谨。
  3. 被压掉的内容不丢。那些被摘要遮蔽(shadowed)的事件仍然躺在会话日志里,所以回放会话时能确定性地重演同一份压缩后的对话,审计链条不断。

三、开箱即用:挂两行插件就够,参数看这张表

好消息是,发布版的 dsh base 里已经把默认后端和 /compact 命令挂在了一起,大多数情况下你不用从零配。但要想明白它什么时候触发、调成适合自己的节奏,还是得自己写配置。

最小的可用组合(来自官方 README)长这样:

1
2
3
4
5
- name: '@deepseek-ai/dsh-session'
- name: '@deepseek-ai/dsh-token-meter'
- name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
- name: '@deepseek-ai/dsh-compaction-basic'
- name: '@deepseek-ai/dsh-command-compact'

只想要自动压缩 + 手动命令,两行就够:

1
2
- name: '@deepseek-ai/dsh-compaction-basic'
- name: '@deepseek-ai/dsh-command-compact'

要调参数,就给 basic 加 config 子键,还可以按「供应商 + 模型」做精确覆盖:

1
2
3
4
5
6
7
8
9
- name: '@deepseek-ai/dsh-compaction-basic'
config:
thresholdRatio: 0.8
retainRatio: 0.16
modelPolicies:
- provider: local
model: small-context
thresholdRatio: 0.7
retainTokens: 2048

关键参数(默认值我核对了官方 config-catalog 和 config.ts 源码):

参数 默认 含义
thresholdRatio 0.8 上下文占用到窗口的 80% 就开始压
retainRatio 0.16 最近一段按窗口 16% 的比例原样保留(与 retainTokens 互斥)
retainTokens — 换成「绝对 token 数」来定义近期保留量,二选一
headroomTokens 65536 在阈值之上额外留的富余量
maxTokens 文档标 8192(新源码里 fallback 到 headroomTokens,以你装的版本为准) 摘要那一次生成的输出上限
compactionRetries 1 压完一次还超阈值时,再补压的额外次数
maxOverflowRetries 1 确认撞上下文溢出后,自动恢复的重试次数,0 关掉恢复
summarizationProvider / summarizationModel — 指定「用哪个模型写摘要」,不成对设置则继承当前对话目标

触发点算起来是这个公式:floor(min(W × thresholdRatio, W − O − headroomTokens)),其中 W 是上下文窗口、O 是保留输出、再减掉 headroom 富余。取的是两者较小值,这点很重要,后面踩坑清单里会说。

三种触发方式:

  1. 自动(pressure):每个 step 前,dsh 会把当前对话按路由到的模型窗口计价,压力一过阈值就把最旧的一段压掉。
  2. 溢出恢复(context-overflow):收到 provider 明确报的 CONTEXT_WINDOW_EXCEEDED 后,绕过阈值直接做一次最大幅度的压缩,然后重试那次请求。
  3. 手动 /compact:随时喊一嗓子,哪怕没到压力阈值也能当场压。

四、/compact 手动瘦身:一个不占模型 turn 的「免单」命令

有了 dsh-command-compact,在聊天界面里直接敲:

1
/compact

它会把一段「平衡的、有含金量」的旧历史立刻压成摘要,然后告诉你替换了多少历史项、估算省了多少 token。几个实测行为(整理自官方 README):

输入 结果
/compact 压一段旧历史,报告替换项数 + 估算 token
没有可压的历史 返回 No compactable history yet.,什么都不动
/compact 任何参数 返回 Usage: /compact (no arguments)——它不接受参数

两个特别省心的细节:

  • 不消耗模型 turn。这条斜杠命令和它的结果这一整套生命周期(command/run / command/done)都是 log-only 事件,压根不进模型历史——你喊它「减肥」,它不会先让你多付一笔 token 再说。
  • Agent 正在思考、或已在压缩时,它会老实告诉你「现在不可用」,而不是硬塞进去;你在压缩进行期间发的消息会排队,等压缩的持久化检查点落地后才开始跑。

顺带一提:压缩那一下毕竟要多做一次模型调用来写摘要,省钱的关键就是别让旗舰模型干这脏活。用 summarizationProvider / summarizationModel 指定一个小模型来写摘要——像走中转站(比如 ai.aklibk.com)这类多模型聚合、按量付费的服务,冲个便宜模型专门写摘要,三下两下就把长会话的成本打下来了。

五、再往下挖一层:三个仅写日志的事件 + 一份 CompactionResult

上一课我们在 session/event 上挂监听器、给会话做带哈希的日报。压缩这活儿也逃不过这个入口——它往 SessionEventMap 里(声明合并)追加了三种事件,而且三者都是仅写日志,绝不上 surface:

事件 载荷 作用
compaction/start { turn } 拿锁;数字代表还没结束的自动轮次,null 代表独立手动尝试
compaction/summary { summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, usage?, ... } 摘要投影 + 被遮蔽范围 + 估算 token + 摘要调用的信封(provider/model/用量)
compaction/end { turn, error? } 释放锁;error 记录这一次失败的尝试

所以上一课那个日报插件,把开关里的 event.type 再认一下 compaction/summary,就能顺带把「这次瘦身压掉了哪些 seq、省了多少 token、用的哪个 provider/model」一起记进日报——审计链条从「说了什么」延伸到「忘了什么、怎么忘的」,价值拉满。

一次成功压缩返回给调用方的是一份 CompactionResult,字段值得背下来:

1
2
3
4
5
6
7
8
9
10
11
interface CompactionResult {
compactionId: CompactionId // 贯穿这次压缩完整生命周期的稳定身份
sourceCommandId?: CommandId // 手动触发时的来源命令 id
startSeq: SessionSeq // compaction/start 的 seq
summarySeq: SessionSeq // compaction/summary 的 seq
endSeq: SessionSeq // compaction/end 的 seq
summary: ContentBlock[] // 后端产出的摘要内容块
shadowedRange: { start: SessionSeq; end: SessionSeq } // 被遮蔽的范围(注意:是 surface 位置跨度,不是数值区间!)
shadowedSeqs: SessionSeq[] // 所有被遮蔽的 surface 节点 seq,按 surface 顺序
shadowedTokenCount: number // 被遮蔽内容的估算 token 数
}

有个容易踩懵的细节:shadowedRange 是一段表层位置跨度(surface-position span),不是数值区间。经过上一次 replace 后,一条高 seq 的摘要节点可能落在更老范围的位置上,这时 start 完全可能大于 end。要拿被遮蔽节点的权威集合,认 shadowedSeqs 才对。

后端写入的摘要消息还带一个稳定标记,让任何消费方都能在不问「是哪个后端压的」的前提下识别出「这是一条压缩历史」。那个标记从包根导出,也从一个不依赖 Cordis 的子路径导出:

1
import { compactCheckpointSource, isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint'

客户端和 wire 程序都能用同一个判定函数,压缩历史的识别不绑死在某一个后端的实现上。

六、踩坑清单:这些默认值会骗你

最后是几条我核对源码时发现的真实坑,务必记住:

  1. 默认 1,000,000 token 的窗口,让自动压缩几乎从不触发。当某个模型没有精确的 context 值时,配置文件里的 defaultContextWindow 默认是 100 万,此时压力阈值根本够不着,会话会一路涨到撞上 provider 的真实上限才报错,而不是先压到 80% 再压(见 GitHub discussion #1933)。想要自动压缩真的生效,要么把 thresholdRatio 调低,要么显式给它一个靠谱的 defaultContextWindow。
  2. 0.8 这个阈值可能永远触发不了。因为触发点是 min(W×0.8, W−O−headroomTokens) 的较小值,如果输出预留 + 65536 的 headroom 把天花板压得比 80% 还低,那实际触发点会被压缩得更靠下(discussion #6671 讲的就是这个)。
  3. quota 类报错不算溢出。quota_limit_reached(400)这类错误不被归类为 context-overflow,溢出自恢复不会触发(discussion #3399),别指望靠它救场。
  4. 压缩不是万能的。它减不掉 system prompt、工具 schema、会话前缀这三大件,也拆不开单条超大的工具调用;真要是有一次工具返回了几十万 token,压缩也爱莫能助。
  5. 工具输出有前置裁剪。挂上 dsh-compaction-tool-result-pruner 后,单条工具输出超过 8192 个 Unicode 码点时,会只保留开头 4096 + 末尾 1024 个码点——先剪都剪不动的,才轮到摘要。
  6. 安全边界不会被切坏。压缩的边界由 toolPairingBalancedBefore/After 对齐校验,绝不会把一个「还没等到回复的 assistant 工具调用」拦腰切开,这点对 Agent 的正确性至关重要。

生命不息,折腾不止。压缩把「装得下」解决了,但「跑得久」还有两块拼图没动:下一篇我们钻进 dsh 的 goal 插件组——一个完成目标怎么跨最多 256 轮自动续跑、为什么会话一恢复它就「自觉停火」等你 resume,/goal 的四个动作(pause / resume / edit / clear)到底怎么用;顺手把后台 job 的 run_in_background 和 job_output / job_list / job_kill 一起过一遍,长任务三件套就齐活了。