自从 dsh v0.1.7-rc.2 上线之后,我花了好几天的时间做插件适配。在这个过程中,切换到本地模型的任务给人感觉越来越不对劲,上下文占用基本上不会超过50%就会触发上下文压缩,而且简单的任务执行起来变迟缓了。于是,我决定给我的 dsh-command-context-trim 插件里面加上“上下文调优”功能。过程中暴露了dsh v0.1.7的上下文机制问题,还挺严重的,覆盖面不仅仅是 上下文压缩(compaction),还有 上下文修剪(prune)。
补充说明一下,新发布的 DeepSeek Harness桌面版 用的就是 v0.1.7-rc2 版本
目前插件的基本调优功能已经完成了,还在优化中,[email protected]只是预览发布。等插件优化完毕我会单独开一个帖子介绍。
下面是我用AI总结的这两天的发现。
Intel Arc B70 上跑 dsh + Qwen3.8-27B:128K 窗口只留给对话 33K,两天的排查记录
同样一条 131072 的路由、同一个 agent 任务,一轮 2 小时超时、报告没写出来;另一轮 8 分钟做完,还输出了 215 行报告。差别只有一个:压缩阈值从哪里触发。
把过程写出来的原因很土:这类"窗口标着 128K、实际用不到一半"的坑,光看配置文件根本看不出来。整件事前后折腾了两天:第一天先赔了一轮 2 小时超时,才回头去读源码;第二天把 0.1.5 的同一个文件翻出来做对照,又照着 session 记录把 meter 的计费方式和 pruner 的调用顺序逐条对完,才敢把上面那句结论写下来。
我们量下来的结论:dsh 0.1.7 的压缩阈值里有一项固定 65536 的 headroom,再加上工具 schema 折算出来的 16,201,一条 131072 的路由,压缩在对话量只有 32,951 tokens(窗口的 25.1 %)时就触发。压缩一旦在这个位置触发,pruner 又会把刚读进来的整份源码裁掉,agent 只能重读 —— 这是一个自我维持的循环。
文中用来做对照的调优插件、以及跑这两轮实验的容器镜像,都是我们自己的项目:
- 插件
dsh-command-context-trim:它只做一件事 —— 从路由真实的窗口和请求预留算出那个"刚好让比例项赢"的 headroom,写进 profile 补丁,并留一条可对账的记录。
- 容器镜像
ghcr.io/snailium/dsh-container/dsh:dsh 本体加常用插件、后端测试脚本都装好了,一条 docker run 就能起一个隔离 home 跑 agent 任务(本文两轮数据就是它跑出来的,两轮的唯一差别是 DSH_TRIM_AUTO_TUNE 开关)。
一、先说清楚 dsh 是什么,不然下面的数字没有参照
dsh(DeepSeek Harness)是一个 agent harness:你给它一个模型端点(本地 llama.cpp 也行),它像 Claude Code 那样跑一个会自己读文件、执行命令、写报告的小 agent。它有两个和我们这篇文章直接相关的机制:
- compaction(压缩):对话变长时,把较老的一段历史交给模型摘要成一条"压缩检查点",替换掉原文,腾出上下文。
- tool-result pruner(工具结果裁剪):对单条过长的工具输出做"保留头部 + 中间省略 + 保留尾部"的裁剪,默认
thresholdChars: 8192(约 2K tokens)。
两个机制都发生在"上下文压力"判断之后,而这一次的问题就出在那个判断上。
二、现象
同一个 A1 任务(让 agent 对我们自己的 Go 项目做安全审查),同一个 B70 SYCL 后端、同一条 lc / Qwen3.8-27B-Q4_K_M.gguf 路由:
|
第一轮(stock) |
第二轮(调优后) |
| 墙钟 |
2 小时超时(exit 124) |
8 分 1 秒(exit 0) |
| 报告 |
没有(最后一句卡在"我再确认几个细节就写报告") |
215 行、11 条发现 |
| 工具调用 |
100 |
29 |
| 不同文件 |
11 |
11 |
| 压缩次数 |
69 |
0 |
| 模型侧上报的输入 tokens(见第七节:这个数不等于 prompt 大小) |
1,978,300 |
57,990 |
第一轮里 web.go 被读了 26 次、yamlstore.go 24 次,100 次调用只覆盖 11 个文件 —— 典型的"读完就丢、丢了再读"。
三、源码定位:0.1.7 的公式,和 0.1.5 的公式
下面这两段代码对照,是这两天里最值钱的产出 —— 下面所有数字都是从它们推出来的。
以下行号来自 npm 上装出来的包(0.1.7-rc.2 与 0.1.5-rc.3),不是 master 源码,读者可以直接对照自己 node_modules 里的同一文件。
0.1.7-rc.2,@deepseek-ai/dsh-compaction-basic/lib/index.js:
:63 const headroomTokens = config.headroomTokens ?? 65536;
:124 function resolveCompactSpec(policy, contextWindow, reservedCompletionTokens) {
:128 const messageBudgetTokens = contextWindow - reservedCompletionTokens;
:130 const pressureBudgetTokens = messageBudgetTokens - policy.headroomTokens;
:131 if (pressureBudgetTokens <= 0) throw new TargetPressureConfigError(...);
:132 const thresholdTokens = Math.floor(Math.min(contextWindow * policy.thresholdRatio, pressureBudgetTokens));
:757 定义了预留量是哪来的:
function reservedCompletionTokens(agent, defaultMaxTokens) {
return agent.session.requestHeader()?.config.maxTokens ?? defaultMaxTokens ?? 0;
}
也就是说,0.1.7 的触发点是 min(窗口 × 比例, 窗口 − 预留 − 65536),而且那个 65536 是绝对值,不随窗口缩放。
0.1.5-rc.3 的同一个文件则简单得多:
:108 function resolveCompactSpec(policy, contextWindow) {
:111 const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio);
没有预留项,没有 headroom 项 —— thresholdRatio: 0.8 就是窗口的 80 %。这就是"0.1.5 上没这个问题"的根源。
四、把公式代进我们这条路由
路由声明 contextWindow: 131072,请求的 maxTokens 是 16384(从 session 的 request/header 里读出来的),比例 0.8:
|
计算 |
结果 |
| 0.1.5(无预留、无 headroom) |
floor(131072 × 0.8) |
104857(80 %) |
0.1.7 stock(headroom = 65536) |
floor(min(104857, 131072 − 16384 − 65536)) |
49152(37.5 %) |
0.1.7 调优后(headroom = 9831) |
floor(min(104857, 131072 − 16384 − 9831)) |
104857(80 %) |
9831 就是"让比例项刚好赢"的最大 headroom:(131072 − 16384) − floor(131072 × 0.8)。也就是说,在 0.1.7 上,thresholdRatio: 0.8 这条设置本身几乎不生效,真正决定触发点的是那个 65536。
把四种情况排在一起看
同一套公式,窗口不同,代价完全不同。下表里 O 是各自请求的 maxTokens,比例统一取 0.8,headroomTokens 取 stock 的 65536;"留给对话"一列扣掉了上面量到的 16,201 固定开销。
| 场景 |
窗口 W |
请求预留 O |
stock 触发点 |
stock 占窗口 |
调优后触发点 |
调优后占窗口 |
留给对话(stock → 调优后) |
| deepseek 官方(1M / 256K 预留) |
1,000,000 |
256,000 |
678,464 |
67.8 % |
744,000 |
74.4 % |
662,263 → 727,799 |
| 我们本地(128K / 16K 预留) |
131,072 |
16,384 |
49,152 |
37.5 % |
104,857 |
80.0 % |
32,951 → 88,656 |
| 同一张卡放大到 256K |
262,144 |
16,384 |
180,224 |
68.8 % |
209,715 |
80.0 % |
164,023 → 193,514 |
| Bonsai 2 小窗口(40K / 8K 预留) |
40,960 |
8,192 |
无压力触发 |
— |
32,768 |
80.0 % |
16,567(溢出前的对话预算) |
三个结论:
- 这套默认值明显是为大窗口准备的,代价落在本地小窗口上。 同样是 65536 的 headroom,在 1M 的官方窗口上只让触发点从 74.4 % 掉到 67.8 %(-6.6 个点,几乎无感);换到 131072 的本地窗口,它把触发点从 80 % 压到 37.5 %(-42.5 个点),留给对话的预算只剩 32,951 tokens。
- 窗口越大,同一个 headroom 越无害;越接近 64K,越由它说了算。 把同一张卡放大到 256K,stock 就是 68.8 %,调优只需再拿回约 11 个点;到 40K 那一档,压力预算直接变成负数 —— stock 状态下压根没有"提前压缩"这条路径。
- 40K 这一档反过来不能调(这一档我们单独在第六节展开)。 stock 没有压力触发,压缩只在真正溢出时发生(很少);把触发打开(headroom 设 0)只是给每一次越线都加一次压缩 —— 我们另一台 8GB 卡实测 15 次涨到 24 次。这一档该做的是抬高 pruner 的
thresholdChars,让溢出路径和 trim 去兜。
一句话概括:官方 1M 和本地 256K 这两档,调优是"拿回被 headroom 吃掉的比例"(收益分别是 6.6 和 11 个点,越大的窗口越无感);本地 128K 是收益最大的一档(+42.5 个点);40K 反过来,调优是"给本来没有的触发加负担"。
最后留意"留给对话"那一列的占比:16,201 的固定开销在 1M 窗口上占 1.6 %,在 128K 上占 12.4 %,在 40K 上占 39.6 % —— 工具面越重的 harness,小窗口越吃亏,这一条和模型本身无关。
五、固定开销有多大:工具 schema 是算进阈值的
阈值比较用的是 dsh 自己的度量(@deepseek-ai/dsh-token-meter),而它把工具 schema 也算进去:
:83 function estimateToolsTokens(header) {
:85 return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
这次 harness 的工具面是 66 个工具,schema JSON 共 60,327 字符;system prompt 4,458 字符。按 dsh 自己的折算(CHARS_PER_TOKEN = 4.0、BLOCK_OVERHEAD = 4):
| 项 |
字符 |
折算 tokens |
| 工具 schema(66 个) |
60,327 |
15,086 |
| system prompt |
4,458 |
1,115 |
| 固定开销合计 |
|
16,201 |
于是在 stock 的 49152 阈值下,留给对话内容的只有约 32,951 tokens(窗口的 25.1 %);而触发那一刻真实请求大约是 49152 + 16384 ≈ 65536 tokens,只有窗口的一半 —— 离"撞墙"还差得远。
六、40K 这一档单独说:它的病和上面相反
上面 128K 的问题是"压缩得太早",40K 这一档(我们的 Bonsai 2:窗口 40960、预留 8192)恰好相反 —— stock 状态下根本没有提前压缩,而把它打开只会更慢。
先看它为什么没有:
| 项 |
数值 |
| 窗口 W |
40,960 |
请求预留 O(maxTokens) |
8,192 |
| messageBudget = W − O |
32,768 |
| stock headroomTokens |
65,536 |
| pressureBudget = messageBudget − headroom |
-32,768 |
压力预算为负,resolveCompactSpec 直接抛 TargetPressureConfigError;这个异常在 agent/pre-step 的监听里被 catch 住,只记一条 warning(每个路由只报一次),然后 return next() —— 回合照常继续,只是这条路由没有压力触发。压缩只能等真实溢出(agent/request-error + CONTEXT_WINDOW_EXCEEDED)才发生,很少。
所以这一档有两种做法,我们两种都跑过:
| 做法 |
触发点 |
实测(同一个 A1 任务) |
| 保持 stock(我们现在的默认) |
没有提前触发,只在真溢出时压 |
0–15 次压缩,随任务长度变化 |
| 打开压力触发(headroom 设 0) |
min(0.8 × 40960, 32768) = 32,768 |
压缩次数 15 → 24,更慢 |
那次对照是在 40960 / 16384 预留的配置下跑的;换成现在的 8192 预留,具体次数会变,但机制不变:stock 本来没有的触发,打开它只是给每次越线都加一次压缩。
还有一条纯算术上的坑,跟模型无关:40K 的窗口里,16,201 的工具面固定开销占掉 39.6 %,再扣掉 8,192 的预留,真正能放对话的只有 16,567 tokens(约 40 % 的窗口)。所以这一档的优化重点不在阈值,而在两处:
- 把 pruner 的
thresholdChars 抬高(8192 → 32768),别让一份整文件读被裁掉 —— 在"读完就丢、丢了再读"的场景里,这一条最直接。
- 让模型无关的 trim 去修墙。压缩要调模型、还要等;trim 是纯文本操作,不占上下文预算,也不打断回合。
一句话:40K 这一档不是"阈值调高一点"能救的,它的 stock 行为已经是最省的;能做的是别再让 pruner 把刚读进来的东西拿走。
七、session 里的实测数字
证据都在 dsh 自己写的 session 记录里(session.v4.jsonl.zstd,一条 zstd -dc 就能解开)。第一轮:
| 指标 |
数值 |
tool/call |
100(其中 read 96) |
| 不同文件 |
11 |
web.go 读取次数 |
26 |
compaction/start |
69 |
compaction/prune |
16(每次只裁一条,shadowedRange.start == end) |
| 触发时对话内容量(由"内容 + 16,201 固定开销 ≈ 49,152"反推) |
约 3 万 tokens 量级 |
溢出证据(llm/retry / agent/request-error) |
0 / 0 |
顺带一句方法上的话:我们一开始想直接重建"触发那一刻的对话内容"来交差,但独立重建比 dsh 的 meter 低约 20–30 %(差异来自它逐节点计费、并把工具 schema 与结构化块一并计入),所以这里只给量级,不给假精度。
"溢出证据"那一行是关键:整个 run 里一次真实上下文溢出都没有发生(0 次重试、0 次请求错误),窗口上界 131072 从没被触到,所以 16 次裁剪全部来自"压力路径",也就是第五节那个被压到 49152 的阈值。
链路是这样的:
agent 读一份 8.5–27 KB 的 Go 源码
→ 对话内容 + 16,201 固定开销越过 49152(对话其实只有 ~31K)
→ 压缩流程先跑 pruner:8192 阈值 → 保留 4096 头 + 1024 尾,中间挖空
→ agent 刚拿到的代码没了 → 重新读同一个文件
→ 再越过 49152 → 再裁 —— 自我维持
八、两个坑,踩过的可以直接跳过
坑一:provider 上报的 inputTokens 不是 prompt 大小,别拿它当证据。 这台后端在 KV 缓存命中时只上报"本次新评估"的 token:第一轮 97 条模型响应里,这个数字是 445、801、938 … 偶尔跳到 32,739(恰好是 surface 刚变过、缓存失效的那几次)。我们一开始也被它带偏过,用它算出来的"触发点中位数"完全对不上公式;真正能对上的量是"活 surface 内容 + 固定开销"。
坑二:prune 之后如果因此不再压缩,内容就白丢了。 compactIfNeeded 的顺序是先判阈值、再裁剪、再重新测量:
if (measurement.totalTokens < spec.thresholdTokens) return null; // 没到阈值:不裁
if (prune !== void 0) { prune.pruneSession(agent.session); … } // 到了阈值:先裁
if (measurement.totalTokens < spec.thresholdTokens) return null; // 裁完降到阈值下:不压缩,但裁掉的不还
也就是说,agent 可能既拿不到摘要、又丢掉了刚读的代码,而这两件事在日志里看不出区别。我们的做法是把 pruner 的 thresholdChars 抬高一点(比如 32768),让一份整文件读不至于被裁;在"读完就丢、丢了再读"的场景里,这一条比换模型或调采样参数都有效。
九、怎么规避(以及一个反例)
三条路,按优先级:
- 把 headroom 设成"刚好让比例项赢"的值(这条路由是 9831)。我们自己的插件会从路由的
contextWindow 和请求的 maxTokens 算出这个数并写进 profile 补丁,同时把结果记成一条 context-trim/tuned 记录,事后能对上账。
- 或者显式压低它:
- id: compaction-basic + config: { headroomTokens: 8192, maxTokens: 8192 },阈值就从 37.5 % 回到接近 80 %。
- 抬高 pruner 的
thresholdChars(比如 32768),让一份整文件读不再被裁。
对照实验(同一任务、同一后端、fresh home):
|
第一轮(headroom 65536) |
第二轮(headroom 9831) |
| 触发点 |
37.5 % |
80 % |
| 压缩 |
69 |
0 |
| 裁剪 |
16 |
0 |
read 次数 |
96(11 个文件) |
11(11 个文件,各一次) |
| 结果 |
2 小时超时 |
8 分钟、215 行报告 |
有一点必须先说清楚:两轮各只跑了一次,而且第二轮恰好采用了更省上下文的策略(用 grep/sed 定点查,而不是整文件读),所以"8 分钟 vs 2 小时"不能当作同策略的纯粹对比。能确定的是机制:阈值低 → 压缩频繁 → 刚读进来的代码被裁掉 → 只能重读。
反例,必须说:调优不是万能的。小窗口上它的作用是相反的(第六节单独讲了这一档)。我们另一台 8GB 卡上的 Bonsai 2(40960 / 8192)实测:把压力触发"打开"之后,压缩次数从 15 涨到 24 —— 因为那条路由的 message budget(32768)本来就低于 65536,stock 状态下压根没有压力触发,你把它打开,只是给每一次越线都加了一次压缩。那条路由我们的默认行为是保持不动,让溢出路径兜底。
十、我们踩完之后改了三件事
都不是什么高级操作,只是下次不会再踩:
- headroom 按路由算,不再拿一个固定值套所有卡。大窗口上它能把触发点从 37.5 % 拉回 80 %;但小窗口上方向是反的 —— 我们另一台 8GB 卡(Bonsai 2,40960/8192)实测,把压力触发打开反而让压缩从 15 次涨到 24 次,因为那条路由本来就没有压力触发,打开只是给每次越线都加了一次压缩。现在我们的默认是:小窗口保持 stock,让溢出路径兜底。
- 不再用 provider 上报的
inputTokens 判断"离上限还有多远"。改成两段式心算:固定开销(工具 schema + system prompt)+ 对话内容。这样一眼就能看出"窗口 128K,但真正能放对话的只有 33K"。
- 长任务开跑前先量一次基线:数
compaction/start 和 compaction/prune。如果压缩次数和 read 次数一起飙,先去看阈值,而不是先去调采样参数 —— 我们第一轮就是先怀疑后端,白折腾了半天。
十一、可以照搬的一点
这套量法不依赖我们的工具,任何人对着自己的 session 记录几条命令就能复现:读 request/header 里的 config.maxTokens 得到预留 O,用窗口和 headroomTokens 代入 min(W × 比例, W − O − headroom) 算出真实触发点,再数一下 compaction/prune 和重复的 read。
如果算出来发现"留给对话的预算"远小于你以为的窗口大小,那问题不在模型、也不在后端 —— 在阈值这一层。窗口 128K 的卡上,我们这里能用的只有 32,951 tokens。
想直接跑起来看自己的路由属于哪一种:用上面那个镜像起一个隔离 home,先读一次 request/header 里的 config.maxTokens,跑一遍任务,再 zstd -dc 数一下 compaction/prune —— 三次以内就能判断你需不需要调 headroom。