DeepSeek Harness 进阶:上下文涨满就压缩,长会话不崩
生命不息,折腾不止。上一课我们盯住了会话的事件流,这一课顺着它钻进 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 命令 |
三个核心设计点,是我觉得最值得学的地方:
- 摘要本身是一条
user/message,不是新事件类型。压缩这活儿只有一次「对模型可见」的改动——把旧范围换成一条带surfaceOp: { op: 'replace', start, end }的 user 消息。dsh 刻意不扩展 SurfaceEventType(只有能产生消息的事件才进模型),所以模型看到的就是「旧对话被一条摘要替换了」,干净利落。 - 日志里的锁是一对标记。压缩开始前先追加一条
compaction/start拿锁,做完摘要、替换完 surface,最后追加compaction/end释放。好处是:如果中途崩溃,留下的是一条没有配对 end 的 start(一个能被检测到的「遗留锁」),而不是一条假报「压缩已完成」的 end。这个顺序设计特别严谨。 - 被压掉的内容不丢。那些被摘要遮蔽(shadowed)的事件仍然躺在会话日志里,所以回放会话时能确定性地重演同一份压缩后的对话,审计链条不断。
三、开箱即用:挂两行插件就够,参数看这张表
好消息是,发布版的 dsh base 里已经把默认后端和 /compact 命令挂在了一起,大多数情况下你不用从零配。但要想明白它什么时候触发、调成适合自己的节奏,还是得自己写配置。
最小的可用组合(来自官方 README)长这样:
1 | - name: '@deepseek-ai/dsh-session' |
只想要自动压缩 + 手动命令,两行就够:
1 | - name: '@deepseek-ai/dsh-compaction-basic' |
要调参数,就给 basic 加 config 子键,还可以按「供应商 + 模型」做精确覆盖:
1 | - name: '@deepseek-ai/dsh-compaction-basic' |
关键参数(默认值我核对了官方 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 富余。取的是两者较小值,这点很重要,后面踩坑清单里会说。
三种触发方式:
- 自动(pressure):每个 step 前,dsh 会把当前对话按路由到的模型窗口计价,压力一过阈值就把最旧的一段压掉。
- 溢出恢复(context-overflow):收到 provider 明确报的
CONTEXT_WINDOW_EXCEEDED后,绕过阈值直接做一次最大幅度的压缩,然后重试那次请求。 - 手动
/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 | interface CompactionResult { |
有个容易踩懵的细节:shadowedRange 是一段表层位置跨度(surface-position span),不是数值区间。经过上一次 replace 后,一条高 seq 的摘要节点可能落在更老范围的位置上,这时 start 完全可能大于 end。要拿被遮蔽节点的权威集合,认 shadowedSeqs 才对。
后端写入的摘要消息还带一个稳定标记,让任何消费方都能在不问「是哪个后端压的」的前提下识别出「这是一条压缩历史」。那个标记从包根导出,也从一个不依赖 Cordis 的子路径导出:
1 | import { compactCheckpointSource, isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint' |
客户端和 wire 程序都能用同一个判定函数,压缩历史的识别不绑死在某一个后端的实现上。
六、踩坑清单:这些默认值会骗你
最后是几条我核对源码时发现的真实坑,务必记住:
- 默认 1,000,000 token 的窗口,让自动压缩几乎从不触发。当某个模型没有精确的 context 值时,配置文件里的
defaultContextWindow默认是 100 万,此时压力阈值根本够不着,会话会一路涨到撞上 provider 的真实上限才报错,而不是先压到 80% 再压(见 GitHub discussion #1933)。想要自动压缩真的生效,要么把thresholdRatio调低,要么显式给它一个靠谱的defaultContextWindow。 - 0.8 这个阈值可能永远触发不了。因为触发点是
min(W×0.8, W−O−headroomTokens)的较小值,如果输出预留 + 65536 的 headroom 把天花板压得比 80% 还低,那实际触发点会被压缩得更靠下(discussion #6671 讲的就是这个)。 - quota 类报错不算溢出。
quota_limit_reached(400)这类错误不被归类为context-overflow,溢出自恢复不会触发(discussion #3399),别指望靠它救场。 - 压缩不是万能的。它减不掉 system prompt、工具 schema、会话前缀这三大件,也拆不开单条超大的工具调用;真要是有一次工具返回了几十万 token,压缩也爱莫能助。
- 工具输出有前置裁剪。挂上
dsh-compaction-tool-result-pruner后,单条工具输出超过 8192 个 Unicode 码点时,会只保留开头 4096 + 末尾 1024 个码点——先剪都剪不动的,才轮到摘要。 - 安全边界不会被切坏。压缩的边界由
toolPairingBalancedBefore/After对齐校验,绝不会把一个「还没等到回复的 assistant 工具调用」拦腰切开,这点对 Agent 的正确性至关重要。
生命不息,折腾不止。压缩把「装得下」解决了,但「跑得久」还有两块拼图没动:下一篇我们钻进 dsh 的 goal 插件组——一个完成目标怎么跨最多 256 轮自动续跑、为什么会话一恢复它就「自觉停火」等你 resume,
/goal的四个动作(pause / resume / edit / clear)到底怎么用;顺手把后台 job 的run_in_background和job_output/job_list/job_kill一起过一遍,长任务三件套就齐活了。