DSH本地模型的上下文救赎:dsh-command-context-trim 插件
-
本文介绍用于DeepSeek Harness的上下文裁剪、调优插件 dsh-command-context-trim
一切的一切始于那场 DeepSeek API 断网故障。
DeepSeek API 断网那天,一开始我并不慌。家里服务器上跑着两张卡,本地 Qwen3.8:27b 随便用。但是,当把模型从云端 DeepSeek 切换回本地之后,我开始慌了——会话中已有的上下文超出了本地模型的上限。
DeepSeek Harness 上下文压缩机制是通过大模型来概括精简上下文,但是云端大模型不可用,上下文压缩也就不可用。就在这时,一直在角落里默不作声的 Grok 提议:“你需要的并不是上下文压缩,而是上下文裁剪。不需要大模型参与,只需要剪掉超出限额部分的上下文就好”。于是,
dsh-command-context-trim插件项目就这样开始了。插件最一开始的功能很简单,不靠大模型,只靠脚本搜索上下文,保留头部的系统提示和工具集,从最古老的对话部分开始,将对话成对剪掉(一则用户消息和一串模型回复为一对)。原则上保留最后一对对话,这样可以保证可以继续会话内容。
就这样度过了 DeepSeek API 的断网期。很快,迎来了
DSH v0.1.7-rc更新。这一版更新为了配合 DeepSeek 云端 API,在上下文机制和计算上引入了头部预留(roomhead),固定为 65536 tokens (64K)。本地模型的 128K 上下文顿时有一半不能用了。表现现象就是上下文还没填到 50% 就触发压缩(compaction)。于是,插件里面又添加了上下文计算调优功能。总算解决了上下文计算,新的问题又出现了—— DSH内置的上下文修剪(prune)功能会将上下文中最后一组会话剪掉! 表现出来的现象是,模型先读进一个文件 -> 文件太大触发修剪(prune)-> 刚读进来的代码被修剪掉 -> 模型再次读入同一个文件…… 如此循环。于是,插件中又多了上下文修剪调优功能。
DSH v0.1.7-rc引入的另外一个大规模更新就是独立的插件设置页,对插件设置页的制作提出了规范化的要求。这时好事。于是,我又花了几天时间,指导AI适配新的插件设置页。终于,在
DSH v0.2.0-rc2上线之际,插件的更新完成了!欢迎大家试用。插件的用法和详细的计算我让AI总结了一下贴在下面了
一、安装
dsh plugin --profile web add dsh-command-context-trim注意:最新版本只支持
dsh v0.1.7-rc.2和dsh v0.2.0-rc.2
二、用法
调优分两条路线,headless 和 web 不一样,不是抄来抄去,是机制决定的。先说为什么。
DSH 里的 compaction 有两个"平面":profile 平面(headless / tui 走这条,agent 直接长在 host 平面里)和 preset 平面(web 走这条,每个会话的 agent 是在各自的 preset 域里创建的)。自动调优靠监听 agent 的生命周期事件,web 上这些事件到不了 host 平面,所以自动路径在 web 上不会触发——不是坏了,是那条路本身不通。
headless:两个环境变量
host 平面拥有 agent,自动路径确实会触发。设两个环境变量就行(供 CI / 脚本覆盖用,不用改配置):
export DSH_TRIM_AUTO_TUNE=1 # 重新调优本进程的 compaction 行(触发点、headroom) export DSH_TRIM_PRUNER=1 # 工具结果裁剪阈值,同样按路由窗口派生两个都默认关。开了之后每次启动都会重新调优,路由窗口变了也跟着变,不用手动干预。
还有第三个
DSH_TRIM_TUNE_STOCK_DISABLED,但别开——它是给小窗口路由用的,而我们实测过在小窗口上打开会让压缩从 15 次涨到 24 次(见下面「不划算」那节)。
️ 前提:这条路由上要声明 contextWindow,否则派生不出窗口大小。我们是在干净DSH_HOME上实测过这条链路的。web:跑一遍
/context-tune preset inplaceweb 走 preset 平面,所以调优参数得注入到 preset 里去。做法是执行一次:
/context-tune preset inplaceinplace的意思是覆盖基础 preset 自己的那个 id,而不是新增一个 id——所以你不用在新建会话界面上去挑,preset 名字和你之前用的完全一样。执行完需要重启 dsh 才生效。 这里有个反直觉的地方,值得单独说:
会话一旦开始,它的 preset 就锁死了(DSH 报
agent-preset/locked),fork 也会继承父会话被同样锁死——所以你没法给一个跑着的会话换一套参数。但锁的是 id,不是 id 背后的定义:那个定义在每次 adopt / resume 时都会被重新组合一遍。所以:- 新会话、重启后的会话 ⇒ 直接吃到新参数,不需要 fork;
- 正在跑的会话 ⇒ 绑定的是它当初组合的那一份,要重启才生效(或者等它自然结束再开新会话)。
想先看会写成什么而不落盘,就加
check:/context-tune preset check它会打印生成的行和路由覆盖率,一个字都不写。
不想要了怎么办
调优写进 preset 的是我们自己生成的、带标记的块(
# >>> dsh-command-context-trim: ... >>>)。想去掉,一条命令:/context-tune reset它只删我们自己写的块,别的工具写的标记块会被列出来但不动——我们是靠标记精确认出"哪些行是我们写的",不是靠匹配描述文字,所以不会误伤你自己调过的 preset。想先看会删什么,加
check。删完同样对已经跑着的会话无效,要等新会话或重启。
三、用例
拿我们自己的配置举例:Qwen3.8:27b 跑在 Intel Arc B70 上,窗口开 131072(128K),输出预留 16384。
DSH 的压缩触发点是这么算的:
min(thresholdRatio × contextWindow, contextWindow − R − headroom)问题出在
headroom这个参数——它是写死的 65536,跟你的窗口多大没关系。在百万 token 的云端路由上这几乎无所谓。在本地卡上它决定一切。代进 128K 这条路由:
min(0.8 × 131072, 131072 − 16384 − 65536) = min(104857, 49152) = 49152 ← 也就是窗口的 37.5 %你配的是 80%,实际生效的是 37.5 %。 也就是说,窗口才用了不到一半,DSH 就开始调 summarizer 了——而且剩下的 6 万多 token 白留着,谁也没用上。
我们做的事很简单:把这条路由的 headroom 设成 0,让比例重新说了算。
min(0.8 × 131072, 131072 − 16384 − 0) = min(104857, 114688) = 104857 ← 窗口的 80 %为什么这件事值得管:压缩是要调一次模型的,而且这一调用卡住整个 turn。我们用本地 summarizer 实测过,一次 209 到 372 秒——三分钟到六分钟,agent 站在那儿不动。
所以触发点从 37.5 % 挪到 80 %,省下的不只是 token,是每隔多久你要看它干等一次。
四、数据
路由 窗口 W 输出预留 R DSH 默认触发点 调优后(headroom=0, r=0.8) 可用上下文 Qwen3.8:27b @ Intel Arc B70131072 16384 49152 — 37.5 % 104857 — 80.0 % +55705 tokens, 2.1× Bonsai 2 @ RTX 5060 8GB40960 8192 根本没有压力触发路径 32768 — 80.0 % 事实负优化,见下 deepseek-official(云)1000000 256000 678464 — 67.8 % 744000 — 74.4 % 几乎不动 三点值得说:
一、同样的
thresholdRatio,实际生效的完全不是一回事。 云端那条 1M 窗口的路由,调完只从 67.8 % 挪到 74.4 %,没什么感觉;128K 这条从 37.5 % 挪到 80 %,可用上下文翻了一倍多。这个差异完全来自窗口大小和那个固定 headroom 的比例关系,不是模型的问题。二、省下的是"卡住的时间",不只是 token。 触发点每往后推一档,就少一次 summarizer 调用;按我们实测的 209–372 秒算,那是很实在的等待。
三、
️ 这个插件不是无条件帮你省事。 窗口小到一定程度,headroom 会把触发点直接关掉(消息预算 ≤ 65536)。这时候你把触发点打开,不会减少已有的压缩,只会增加事件——每一次还都是一次卡住 turn 的 summarizer 调用。我们实测过:Bonsai 2 @ RTX 5060 8GB(40960 / 8192),同一个任务,压缩次数从 15 次涨到 24 次。开了之后 prompt 量也跟着涨,净亏。所以这类路由我们默认不动,让 DSH 自己的溢出路径兜底——它照样会在真超窗时压缩重试,只是变成罕见事件而不是常态。
规则一句话:调大窗口的路由(消息预算 > 65536),别动小窗口的路由。
顺带另一件事:窗口真的被撑爆时,插件会先做免模型裁剪再交给压缩——
/trim把最旧的那一段直接丢掉,不调模型、不产生摘要。这也是为什么它和调优触发点是配套的:能不摘要就不摘要,实在不行才摘要。
细节都在这里 → https://github.com/snailium/dsh-command-context-trim#readme (中文 README 里有完整公式、触发点推导、裁剪的受保护规则、以及 preset 路线为什么是锁死的)
-
-
方向很对:本地模型跑长会话,上下文只增不减是最贵的开销,裁剪比换更大模型性价比高。几个建议明确写进策略的点:
- 保留「决策点」,丢「探索过程」:当前目标、约束、已改文件清单、未完成 TODO、报错原文必须留;反复试错的中间轮次可压掉。
- tool_call / tool_result 要成对:裁掉 assistant 的调用却留下结果(或反之)会让下一轮请求结构不合法,直接报错。
- 摘要回灌优于硬截断:把旧轮压成结构化摘要(目标/约束/改动/待办/报错)放固定前缀,命中 prefix cache 的概率更高,也更可回放。
- 别把秒级时间戳/随机数放 system prompt 开头——会打掉 prefix cache(站内有人实测过命中率 0%、成本翻二十几倍)。
- 原始会话单独留档,裁剪策略要可审计,出问题能回溯。
如果能量一下裁剪前后的 pp/tg 与缓存命中率,这篇说服力会更足。
-