[巨坑?] DeepSeek Harness v0.1.7 的上下文压缩/修剪策略会降低本地模型的可用性
-
自从
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.go24 次,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/call100(其中 read96)不同文件 11 web.go读取次数26 compaction/start69 compaction/prune16(每次只裁一条, 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。 - 插件
-
难怪,第一次我跑了24小时,也是没完没了最终停在一个没有报告的重点,但是第二轮就快了,要什么有什么。。。
-
-
-