一切的一切始于那场 DeepSeek API 断网故障。
DeepSeek API 断网那天,一开始我并不慌。家里服务器上跑着两张卡,本地 Qwen3.8:27b 随便用。但是,当把模型从云端 DeepSeek 切换回本地之后,我开始慌了——会话中已有的上下文超出了本地模型的上限。
DeepSeek Harness 上下文压缩机制是通过大模型来概括精简上下文,但是云端大模型不可用,上下文压缩也就不可用。就在这时,一直在角落里默不作声的 Grok 提议:“你需要的并不是上下文压缩,而是上下文裁剪。不需要大模型参与,只需要剪掉超出限额部分的上下文就好”。于是,dsh-command-context-trim 插件项目就这样开始了。
插件最一开始的功能很简单,不靠大模型,只靠脚本搜索上下文,保留头部的系统提示和工具集,从最古老的对话部分开始,将对话成对剪掉(一则用户消息和一串模型回复为一对)。原则上保留最后一对对话,这样可以保证可以继续会话内容。
就这样度过了 DeepSeek API 的断网期。很快,迎来了 DSH v0.1.7-rc 更新。这一版更新为了配合 DeepSeek 云端 API,在上下文机制和计算上引入了头部预留(headroom),固定为 65536 tokens (64K)。本地模型的 128K 上下文顿时有一半不能用了。表现现象就是上下文还没填到 50% 就触发压缩(compaction)。于是,插件里面又添加了上下文计算调优功能。
总算解决了上下文计算,新的问题又出现了—— DSH内置的上下文修剪(prune)功能会将上下文中最后一组会话剪掉! 表现出来的现象是,模型先读进一个文件 -> 文件太大触发修剪(prune)-> 刚读进来的代码被修剪掉 -> 模型再次读入同一个文件…… 如此循环。于是,插件中又多了上下文修剪调优功能。
DSH v0.1.7-rc 引入的另外一个大规模更新就是独立的插件设置页,对插件设置页的制作提出了规范化的要求。这是好事。于是,我又花了几天时间,指导AI适配新的插件设置页。
终于,在 DSH v0.2.0-rc.2 上线之际,插件的更新完成了!
(那之后插件一直在更新,现在是 0.6.x。下面讲它现在的样子。)
它现在做什么
一、/trim:免模型裁剪。 把最旧、最不值钱的那一段上下文直接丢掉,不调用模型、不产生摘要。裁剪按从旧到新的优先级进行,最后一条消息降级为“尽量不裁”,用户指令始终受保护。
二、撞墙时自动裁剪。 一个 prepend 的 agent/request-error 监听器响应 CONTEXT_WINDOW_EXCEEDED,先免模型地腾出空间,再请循环重试;实在腾不出空间,才让 DSH 自己的恢复路径(剪枝 + 摘要)接手。它只在撞墙时触发,不在普通压缩时触发——所以它不会打扰正常运行。
三、调优上下文计算。 这是上面那条「头部预留」问题的解法。DSH 的压缩触发点是:
min(thresholdRatio × contextWindow, contextWindow − R − headroom)
headroom 固定 65536,与窗口大小无关。正是它把 128K 路由的触发点从比例想要的 80% 压到了 37.5%:
min(0.8 × 131072, 131072 − 16384 − 65536) = min(104857, 49152) = 49152 ← 37.5%
插件把这条路由的 headroom 设成 0,让比例重新说了算:
min(0.8 × 131072, 131072 − 16384 − 0) = 104857 ← 80%
同一组参数,效果是可用上下文 +55705 tokens(2.1 倍)。
四、调优剪枝阈值。 就是上面那条「读完文件就被剪掉」问题的解法。工具结果的剪枝阈值按路由派生,而不是用 DSH 固定的 8192 字符:
max(8192, min(32768, 2 × (contextWindow − maxTokens)))
一句话概括整个插件的判断:能不摘要就不摘要——免模型的裁剪先上,实在不行才让模型去总结。
实测
同一条路由,只改 headroom 这一个变量:
| 路由 | 窗口 | 输出预留 | DSH 默认触发点 | 调优后 |
|---|---|---|---|---|
| Qwen3.8:27b @ Intel Arc B70 | 131072 | 16384 | 49152 — 37.5 % | 104857 — 80.0 % |
| Bonsai 2 @ RTX 5060 8GB | 40960 | 8192 | 无主动触发路径 | 32768 — 80.0 % |
| deepseek-official(云) | 1000000 | 256000 | 678464 — 67.8 % | 744000 — 74.4 % |
最后一行值得看一眼:同样的参数,在 1M 窗口的云路由上几乎不动(67.8 % → 74.4 %),在 128K 上却是翻倍。这个差异完全来自窗口大小和那个固定 headroom 的比例关系,不是模型的问题。
而压缩本身是要调一次模型的,这一调用还会卡住整个 turn。我们用本地 summarizer 实测过,一次 209 到 372 秒。所以触发点每往后推一档,省下的不只是 token,是每隔多久你要看它干等一次。
装与用
# 从 npm
dsh plugin --profile web add dsh-command-context-trim
headless / tui:host 平面拥有 agent,自动路径会触发,两个环境变量即可。
export DSH_TRIM_AUTO_TUNE=1 # 每次启动按当前路由重新调优触发点
export DSH_TRIM_PRUNER=1 # 按路由窗口派生工具结果剪枝阈值
web:会话的 agent 活在自己的 preset 域里,生命周期事件到不了 host 平面,所以调优参数要注入 preset。
/context-tune preset inplace # 覆盖基础 preset 自己的 id,不用去新建会话界面挑
执行完需要重启 dsh 才生效。这里有个反直觉的地方:会话一旦开始,它的 preset id 就锁死了(agent-preset/locked),fork 也继承父会话被同样锁死——但锁的是 id,不是 id 背后的定义,那个定义在每次 adopt / resume 时都会重新组合。所以新会话和重启后的会话直接吃到新参数,正在跑的那个要重启。
想撤销:
/context-tune reset
它只删本插件写进补丁的那些标记块,别的工具写的块会被列出来但不动。
已知限制
小窗口路由(消息预算 ≤ 65536)的调优参数仍在调整中——这条路径上「多压缩」和「少压缩」的取舍还没有定论,欢迎带上实测数据来 issue 里讨论。另外 /trim 与 /context-tune 的完整参数、受保护内容规则和 preset 锁定语义都在 中文 README 里。
MIT 许可。