跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • 用户
  • 群组
皮肤
  • 浅色
  • 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 广场 App
snailiumS

snailium

@snailium
体面乡绅
取消关注 关注
关于
帖子
16
主题
3
分享
0
群组
1
粉丝
1
关注
0

帖子

最新 最佳 有争议的

  • DSH本地模型的上下文救赎:dsh-command-context-trim 插件
    snailiumS snailium
    AI Agent dsharness 本地模型

    本文介绍用于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 inplace

    web 走 preset 平面,所以调优参数得注入到 preset 里去。做法是执行一次:

    /context-tune preset inplace
    

    inplace 的意思是覆盖基础 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 B70 131072 16384 49152 — 37.5 % 104857 — 80.0 % +55705 tokens, 2.1×
    Bonsai 2 @ RTX 5060 8GB 40960 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 路线为什么是锁死的)


  • 整活:8GB显存也要跑agent
    snailiumS snailium
    AI Agent rtx5060 qwen-27b 量化

    RTX 5060 8GB 上跑 Ternary Bonsai 2 27B:40K 上下文 + MTP,agent 任务全套跑通。

    折腾了几天,把实测数据和踩坑记录整理一下,都是 8GB 卡上的真实数字。最大的感受:

    8GB 上卡你的不是解码速度,是上下文。

    -c 从 32768 提到 40960,agent 类任务就从"全部撞墙"变成"全部跑通"。差这 8K,
    就是能干活和不能干活的分界。

    仓库(预编译二进制 + 完整配方 + 编译踩坑):
    https://github.com/snailium/bonsai2-8gb


    一、模型背景:Ternary Bonsai 2 27B 是什么

    先说清楚这是个什么东西,不然下面的数字没有参照。

    一句话:Qwen3.8-27B 的三值量化版。权重被压到 {−1, 0, +1} 三个值(1.58-bit 那一类做法),
    配合 group size 128 的缩放因子,实际是 1.75 bpw。

    基座 Qwen3.8-27B(阿里,Apache 2.0)
    量化 三值 {−1,0,+1},g128,1.75 bpw(PTQ1_0)
    发布方 PrismML
    体积 5.54 GiB(原始 PTQ1_0)/ 5.87 GiB(带 MTP 头的 lean 版)
    架构特点 混合注意力:64 层里只有 16 层是 full attention,其余 48 层是 linear attention
    上下文 原生 262,144

    为什么 8GB 能塞下 27B:就是靠 1.75 bpw。同一个基座,常规量化的体积对比:

    量化 体积 8GB 能装?
    Bonsai 2 PTQ1_0(三值) 5.54 GiB ✅
    Unsloth UD-IQ1_S 5.77 GiB ✅
    Unsloth UD-IQ2_XXS 6.77 GiB ✅
    Unsloth UD-Q2_K_XL 9.15 GiB ❌
    Unsloth UD-Q4_K_M 15.33 GiB ❌

    也就是说:同一个 27B,4-bit 量化是 15.33 GiB,8GB 的卡连边都摸不到。
    能跑起来完全是三值量化的功劳。

    那个"只有 16 层是 full attention"很关键:KV cache 只在这 16 层上产生,
    所以 KV 的增长比同规模的稠密模型小得多——这是能用 q4_0 量化 KV 开到 40K 的前提。

    一个必须知道的坑:不能换 stock llama.cpp

    三值量化需要旋转过的权重基底(Hadamard 变换),这些在 mainline llama.cpp 里
    完全没有(PrismML 的三值类型是 fork 独有的;有人提过 Q2_0 的移植 PR,
    2026-07 关掉了没合)。

    后果:

    • stock llama.cpp 直接拒绝加载 PTQ1_0 / PQ2_0
    • 更坑的是旧版 Q2_0 文件能加载但不报错,输出是乱码

    所以必须用 PrismML 那条 fork 线(我们用的是 sudoingX/llama.cpp 的 bonsai2 分支,
    在 PrismML fork 基础上叠了三个还没合入的 PR)。模型卡上也写了这一点。

    MTP 是什么,为什么要它

    Qwen3.8-27B 自带一个 nextn 头(多 token 预测),可以拿来做投机解码:
    草稿头一次猜几个 token,主干批量验证。

    8GB 上要它的理由有两层:

    1. 提速:短 prompt 下 decode 从 54 提到 65–73 tok/s,接受率 0.85 左右
    2. 更重要的:MTP 会影响显存布局。这个我们踩坑了——关掉 MTP 反而没法用
      (见下面"坑 2")

    另外 MTP 头有两种装法:单独一个 sidecar 文件,或者嫁接进同一个 GGUF(作为 blk.64)。
    单独装会重复一份词表 embedding,代价可以直接从文件大小看出来:

    文件 大小
    原版 PTQ1_0 5,946,648,928 B(5.54 GiB)
    mtp-lean(MTP 头 + 复用已有 embedding) 6,297,658,848 B(5.87 GiB)
    mtp fat(MTP 头 + 自带一份 embedding 副本) 7,012,820,512 B(6.53 GiB)

    fat − lean = 682 MiB,就是那份重复的 embedding;lean − orig = 335 MiB,
    是 MTP 头本身。8GB 的卡上 682 MiB 很值钱,所以我们用 lean 版。

    (代价:lean 版需要 build 里有 Hadamard 修复,也就是 bonsai2 分支带的那几个 PR。
    官方预编译的 release 二进制起不来 draft 图。)


    二、平台

    GPU      RTX 5060 8GB     8151 MiB - 447 driver reserved = 7704 可用
             sm_120 (Blackwell) —— 不是 Ampere/Ada,坑不太一样
    模型      Ternary-Bonsai-2-27B-PTQ1_0-mtp-lean.gguf   5.87 GiB
    后端      sudoingX/llama.cpp  branch bonsai2 @ dcc3be7
              (含 #218 专用 mat-vec + #217/#205 Hadamard 修复 + #220 GDN gather)
    

    三、启动参数,逐条说一下为什么

    GGML_CUDA_BATCH_INVARIANT=1
    
    llama-server \
      -m Ternary-Bonsai-2-27B-PTQ1_0-mtp-lean.gguf \
      -ngl 99 -fa on -np 1 \
      -c 40960 \
      -ctk q4_0 -ctv q4_0 --kv-mean-center kv-mean-center.gguf \
      --spec-type draft-mtp --spec-draft-n-max 1 \
      --reasoning-effort low --reasoning-budget 4096 \
      --jinja \
      --temp 0.7 --top-p 0.80 --top-k 20 --presence-penalty 1.5
    
    参数 为什么这么设
    -c 40960 这是本文的重点。 带 MTP 时能加载的最大值,用满 7508 MiB。32K 不够跑 agent 任务,49K 直接 cudaMalloc 失败
    -ctk q4_0 -ctv q4_0 不加这个 40K 装不下。KV 量化是能开到 40K 的前提
    --kv-mean-center q4_0 的必需配套。它给 K cache 做均值居中校准,找回量化丢掉的精度。注意这个文件必须用相同的 cache 设置(-fa on -ctk q4_0)生成,否则服务端会拒绝加载——这是它有意的安全检查
    --spec-type draft-mtp --spec-draft-n-max 1 MTP 投机解码。n-max 设 1 而不是 2/3:我们在自己卡上扫过,acceptance 随 n-max 单调下降(0.85 → 0.76 → 0.67),速度并不单调,1 最稳。另外 n-max 越大,MTP 的 compute buffer 越占显存,直接挤压上下文
    GGML_CUDA_BATCH_INVARIANT=1 让"单发解码"和"投机 batch 内验证"的 logits 逐位一致,MTP 严格无损。不加的话批量验证会改变浮点累加顺序,近似解会不一致
    -fa on flash attention,配合 q4_0 KV
    -np 1 MTP 要求单 slot
    --reasoning-effort low --reasoning-budget 4096 agent 任务用。low 让模型思考简短聚焦,4096 是服务端硬截断思考的预算(实测精确停在 2064–2070 tokens)。纯生成任务(写文档)直接 --reasoning off 更省——没有规划阶段,思考只是抢输出的 token
    --temp 0.7 --top-p 0.80 --presence-penalty 1.5 量化模型容易复读。presence-penalty 是关键——它的默认是 0.0,在这个模型上会出复读循环(我们实测过一次 1898 轮重复,正文出不来),1.5 正好。Unsloth 的模型卡也是这么建议的

    显存占用:7508 MiB used / 198 free。

    四、实测

    llama-bench tg128:54.31 tok/s(官方 release 二进制同卡 39.56 → +37%)。

    短 prompt 服务端解码:code 70.7 / bash 68.9 / prose 64.6 tok/s,MTP 接受率 0.85 / 0.78 / 0.67。

    40960 上下文下跑一整套 agent 任务:

    任务 thinking 结果 decode
    生成单页 HTML off PASS 65.9 tok/s
    生成 SVG off PASS 67.4 tok/s
    27 文件安全审计(31 次工具调用) low + budget 4096 PASS 38.7 tok/s
    采集主机配置 low + budget 4096 PASS 45.1 tok/s
    多源检索计算(46 次工具调用) low + budget 4096 PASS 33.8 tok/s

    同样的配置在 32K 下,后两个任务全部撞墙:研究任务每轮要 7–25 次 compaction
    (其中 1–4 次失败,报 summary is not smaller than the shadowed content),审计任务
    根本跑不完。只多 8K,就是"全挂"到"全过"。

    agent 任务的 decode(33–45)明显低于生成任务(66–67),是上下文深度造成的——
    这些 run 的 prompt 有 9–15K tokens。另外每轮对话都要完整重算 prefill
    (上下文裁剪会打断前缀缓存),约 72 秒/次工具调用,这是长任务的真实墙钟成本。

    五、两个坑,踩过的可以直接跳过

    坑 1:带 MTP 的上下文上限是 40960,不是 64K。 同一张卡上逐档试出来的:

    -c 带 MTP 无 MTP
    32768 7284 MiB ✅ —
    40960 7508 MiB ✅ —
    49152 ❌ cudaMalloc OOM —
    65536 ❌ 7268 MiB ✅
    81920 — 7636 MiB ✅
    90112 — ❌

    坑 2:这个 build 关掉 MTP 之后,模型输出会坍缩成 /。

    上面那张表里"无 MTP"一列看着很诱人——能把窗口开到 80K,省显存也是真的
    (7268 MiB,跟文档里的 7,266 几乎分毫不差)。但代价是模型直接不会写字了:

    32K + MTP             -> 正常
    32K 无 MTP            -> 全是 "/"        (多次复现)
    64K 无 MTP            -> 全是 "/"        (3/3)
    无 MTP + 关 thinking  -> 正文直接是 200 个连续 "/"
    无 MTP + 原版 PTQ1_0  -> 同样坍缩
    

    不是 -mtp-lean 文件的问题(原版 GGUF 一样),也不是 reasoning 路径的问题。
    所以"拿 MTP 换上下文"这条路,至少在这个 build 上走不通。

    六、能跑通还有一半功劳在 harness:dsh + 我们自己写的两个小插件

    上面那些 agent 任务不是我手动盯着一步步喂的,是模型自己调工具、自己决定下一步,
    跑在 DeepSeek Harness (dsh)
    的 headless 容器里。

    速度这块已经很多人卷了,但**"小窗口下 agent 怎么不把自己搞死"讲的人不多**——
    而 8GB 卡上这就是生死线。我们前后写了两个 dsh 插件来治这个,都有点用:

    插件一:一个不花模型调用的"后悔药" —— context-trim

    上下文满了怎么办?常规操作是让模型自己压缩历史。问题是压缩本身也要跑一次模型,
    而且它得把"要压缩的那段"塞进已经快满的窗口里——于是就经常压不动。我们实测撞到的:

    summary is not smaller than the shadowed content (1180 >= 1180)
    

    翻译一下:"我想把它变小,但变小之后还是一样大。" 32K 的卡上,到这一步基本就死了。

    我们这个小东西的思路特别土:一次模型都不调,纯记账。 把最老最没用的那段对话
    直接换成一行标记,或者把超长的工具输出掐头去尾留一段。腾地方不要钱,所以
    它在所有请求都快挂掉的时候反而最管用。

    效果:那次 27 个文件的安全审计,它出手 11 次,一共腾出约 3.7 万 tokens。
    没它的话,这个任务在 40K 窗口里根本走不到"开始写报告"那一步。

    一句话总结:别指望模型自己管上下文,裁剪这种事应该是 harness 的零成本杂活。

    插件二:它钻牛角尖的时候,得有人踹一脚 —— repeat-tool-breaker

    小模型 + 低显存,"原地打转"是高频死法。我们碰到的真实案例:
    同一个 API 打了 29 次,每次参数都不一样,跑满一小时没结果。

    最阴险的地方在于——从字面上看它每次都在做不同的事。所以任何"检测重复调用"的
    工具都抓瞎,你只能干看着它烧时间。

    我们的做法是不看字面、看语义:比如"往 weather.gc.ca 这个域名一共打了多少次"
    单独计数。不管你 URL 参数怎么变,同一个目标的调用会攒起来,钻牛角尖就现形了。

    喊停也不是一上来就拦死,分三级:

    第几次 反应
    7 次 轻轻提醒一句:"你在重复,考虑换条路"
    11 次 要求它写出进度摘要 + 至少两个还没试过的方案
    12+ 次 才真拦

    T5 那次的效果:模型连着 7 次 curl 同一个天气 API、5 次失败,第 7 次触发提醒,
    下一次调用它就换思路了——从"怎么调通这个端点"变成去搜气象局官网数据,
    最后算出正确答案。

    一句话总结:小模型本来就更容易钻牛角尖,而且钻起来更慢更烧时间。
    与其调 temperature / penalty 试图让它别钻,不如在 harness 层给它一句
    "你该换个思路了"。
    后半句是我们反复验证过的:光调采样参数,救不回来。

    顺手记一个 bug:判断"失败"不能只看 isError。curl ... | python3 ... | head
    的退出码是 0,脚本里把 HTTP 400 吃掉了也是 0——我们那次 7 个调用里 5 个失败,
    全被判成了成功,所以提醒一直不吭声。最后得去读文本里的报错特征才行。

    七、thinking 也有个方向性的坑

    模板里 reasoning_effort 只接受 xhigh / medium / low 三个值。默认 xhigh 会注入
    一段"仔细思考、验证假设、考虑替代方案"的系统提示,在量化模型上会失控——我们实测过
    一次 24508 token 的回复里 86% 烧在 thinking,正文一个字没出来。

    low 注入的是相反指令("Keep your thinking brief and focused"),配
    --reasoning-budget 4096 在 agent 任务上表现最好;纯生成任务直接 --reasoning off。

    方向性坑:服务端设了 --reasoning off 之后,客户端发 reasoning_effort: high
    是压不过它的
    (实测 0 个 reasoning frame)。想两种模式共用一个 server,要反过来做:
    服务端开着 thinking,需要关的请求带 chat_template_kwargs: {"enable_thinking": false}。

    八、复现入口

    仓库:https://github.com/snailium/bonsai2-8gb

    • bonsai2-universal.tar.gz — 预编译二进制,sm_75 → sm_120 全架构
      (我们从源码编的:官方预编译包只含 Ampere/Ada,而 CUDA 12.4 编不了 sm_120)
    • RECIPE.md — 完整配方、每个参数的依据、各项实测数据、12 条注意事项
    • BUILD.md — 编译三个坑:CUDA 12.4 编不了 Blackwell、glibc 2.43 的 rsqrt
      声明冲突、cmake 会静默选错 nvcc(报 ptxas fatal: Value 'sm_52' is not defined)

    这两个插件是我们自己写的,源码和设计说明都公开:

    • snailium/dsh-command-context-trim
      —— 零模型调用的上下文裁剪
    • snailium/dsh-repeat-tool-breaker
      —— 语义指纹的重复/失败检测

    写它们的原因很直接:8GB 这种小窗口跑 agent,光靠调模型参数是救不回来的,
    必须在 harness 层动手
    。如果有人在别的低显存场景做 agent,这两个的思路应该能照搬——
    插件的阈值和开关都是可配的,不绑定任何特定模型。


  • [巨坑?] DeepSeek Harness v0.1.7 的上下文压缩/修剪策略会降低本地模型的可用性
    snailiumS snailium
    LLM讨论区 dsharness 本地模型

    自从 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。


  • 让computer use 速度飞起来:JEV-Ultrafast 的 DOM 决策链路实测
    snailiumS snailium
    AI Agent dsharness 编程

    其实把截图分析改成CDP就已经加速很多了。但是有些靠脚本加载的网页,从DOM那里读出来的就是一堆JS调用,还要fallback到截图分析。对应Hermes里面就是browser(看DOM)和browser_vision(看截图)两个工具。

    JEV实际加速了多少,要跟没有JEV的DOM路线对比

  • 登录

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