跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • 用户
  • 群组
皮肤
  • 浅色
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • 深色
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • 默认(LCZ-Blue)
  • 不使用皮肤
  • LCZ-Green
  • LCZ-Blue
  • LCZ-Black
折叠
品牌标识

抡锤者

首页 版块 标签
硬件
装备秀
AI 广场
  1. 主页
  2. 版块
  3. LLM讨论区
  4. [巨坑?] DeepSeek Harness v0.1.7 的上下文压缩/修剪策略会降低本地模型的可用性

[巨坑?] DeepSeek Harness v0.1.7 的上下文压缩/修剪策略会降低本地模型的可用性

已定时 已固定 已锁定 已移动 LLM讨论区
dsharness本地模型
5 帖子 5 发布者 388 浏览 1 关注中
  • 从旧到新
  • 从新到旧
  • 最多赞同
回复
  • 在新帖中回复
登录后回复
此主题已被删除。只有拥有主题管理权限的用户可以查看。
  • snailiumS
    snailiumS
    snailium
    德高望重
    编写于 最后由 snailium 编辑
    #1

    自从 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(溢出前的对话预算)

    三个结论:

    1. 这套默认值明显是为大窗口准备的,代价落在本地小窗口上。 同样是 65536 的 headroom,在 1M 的官方窗口上只让触发点从 74.4 % 掉到 67.8 %(-6.6 个点,几乎无感);换到 131072 的本地窗口,它把触发点从 80 % 压到 37.5 %(-42.5 个点),留给对话的预算只剩 32,951 tokens。
    2. 窗口越大,同一个 headroom 越无害;越接近 64K,越由它说了算。 把同一张卡放大到 256K,stock 就是 68.8 %,调优只需再拿回约 11 个点;到 40K 那一档,压力预算直接变成负数 —— stock 状态下压根没有"提前压缩"这条路径。
    3. 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 % 的窗口)。所以这一档的优化重点不在阈值,而在两处:

    1. 把 pruner 的 thresholdChars 抬高(8192 → 32768),别让一份整文件读被裁掉 —— 在"读完就丢、丢了再读"的场景里,这一条最直接。
    2. 让模型无关的 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),让一份整文件读不至于被裁;在"读完就丢、丢了再读"的场景里,这一条比换模型或调采样参数都有效。

    九、怎么规避(以及一个反例)

    三条路,按优先级:

    1. 把 headroom 设成"刚好让比例项赢"的值(这条路由是 9831)。我们自己的插件会从路由的 contextWindow 和请求的 maxTokens 算出这个数并写进 profile 补丁,同时把结果记成一条 context-trim/tuned 记录,事后能对上账。
    2. 或者显式压低它:- id: compaction-basic + config: { headroomTokens: 8192, maxTokens: 8192 },阈值就从 37.5 % 回到接近 80 %。
    3. 抬高 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 状态下压根没有压力触发,你把它打开,只是给每一次越线都加了一次压缩。那条路由我们的默认行为是保持不动,让溢出路径兜底。

    十、我们踩完之后改了三件事

    都不是什么高级操作,只是下次不会再踩:

    1. headroom 按路由算,不再拿一个固定值套所有卡。大窗口上它能把触发点从 37.5 % 拉回 80 %;但小窗口上方向是反的 —— 我们另一台 8GB 卡(Bonsai 2,40960/8192)实测,把压力触发打开反而让压缩从 15 次涨到 24 次,因为那条路由本来就没有压力触发,打开只是给每次越线都加了一次压缩。现在我们的默认是:小窗口保持 stock,让溢出路径兜底。
    2. 不再用 provider 上报的 inputTokens 判断"离上限还有多远"。改成两段式心算:固定开销(工具 schema + system prompt)+ 对话内容。这样一眼就能看出"窗口 128K,但真正能放对话的只有 33K"。
    3. 长任务开跑前先量一次基线:数 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。

    1 条回复 最后回复
    3
    • imbiplaza ASUSI
      imbiplaza ASUSI
      imbiplaza ASUS
      谪仙人
      编写于 最后由 编辑
      #2

      难怪,第一次我跑了24小时,也是没完没了最终停在一个没有报告的重点,但是第二轮就快了,要什么有什么。。。

      1 条回复 最后回复
      0
      • 毅袁毅
        毅袁毅
        毅袁
        编写于 最后由 编辑
        #3

        感谢分享,已经修复巨坑!

        1 条回复 最后回复
        0
        • W
          W
          wwcd
          编写于 最后由 编辑
          #4

          谢谢.dsh 升级很容易升一堆 的坑.
          特别是用本地 vllm 的.
          主要是插件各种矛盾

          1 条回复 最后回复
          0
          • suboyangS
            suboyangS
            suboyang
            劳动模范
            编写于 最后由 编辑
            #5

            在吗 我用dsh 0.2rc 也发现了和你一样的问题,我运行本地qwen27B 很多任务都做不下来,dsh一边压缩信息,一边大规模阅读我本地信息,导致很长时间上下文都被占满,再压缩。但是我换成codex 或者hermes就正常了。

            1 条回复 最后回复
            0

            你好!看起来您对这段对话很感兴趣,但您还没有一个账号。

            厌倦了每次访问都刷到同样的帖子?您注册账号后,您下次访问时都将自动回到上次浏览的位置,并可选择接收新回复的通知(通过电子邮件或推送通知)。您还可以收藏帖子、为帖子点赞,以此向其他社区成员表达您的感谢。

            有了你的建议,这篇帖子会更精彩哦 💗

            注册 登录
            回复
            • 在新帖中回复
            登录后回复
            • 从旧到新
            • 从新到旧
            • 最多赞同


            • 登录

            • 登录或注册以进行搜索。
            • 第一个帖子
              最后一个帖子
            0
            • 版块
            • 最新
            • 标签
            • 热门
            • 用户
            • 群组