安装
在 DeepSeek Harness 里通过 dsh-market 安装
dsh plugin --profile web add dshmarket
或使用命令行
dsh plugin --profile web add github:BOWLUNA/dsh-zcode-breaker
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络。请先审阅源码,并尽量锁定 commit(github:owner/repo#sha)。
README
English | 简体中文
给 DeepSeek Harness 自动压缩用的 rapid-refill 熔断器:拦住「压完立刻又满、于是每一步都再压一次」这个死循环,并告诉用户是哪个过大的读取或工具输出造成的。思路来自 ZCode 的同名实现。
dsh plugin --profile web add dsh-zcode-breaker
Node: 本插件所挂的宿主在 node 20 上装不出来 —— 在 node 20 上
npm install @deepseek-ai/dsh只装入 10 个包、没有dsh可执行文件,而 node 24 上是 488 个。package.json目前仍声明>=20;徽章写的是实测值。 提高声明下限是一件待拍板的事,不在这里悄悄改掉。
从 ZCode 取了什么,又在哪里走得更远
每一行都应该可核对。中间那列指向 ZCode 源码的文件与行号,而且这些引用不是手打上去就信的:控制工作区的 verify-zcode-citations.mjs 会把每一条拿到一份检出里去解析,并把它找到的那一行原文打出来。最后一列给的是你现在就能跑的命令;没有可主张的,就如实写「暂未超过」,不编。
其中两条路径在 2026-09-22 之前是错的:写的是 core/src/compact/turn-loop-state.ts 与 core/src/compact/runtime/methods/compact.ts,那是从一份设计笔记里抄的,不是从源码里核的。真实位置在 packages/core/src/runtime/methods/ 下。守卫抓不到这个错,因为不带行号的路径根本不算它要看的引用 —— 这也正是中间那列现在必须带行号的原因。
| ZCode 有什么 | 本插件取了什么 | 本插件多了什么(优于在哪) | 证据 |
|---|---|---|---|
rapid-refill 状态机 —— consecutiveRapidRefills、toolTurnsSinceCompact、shouldBlock,计算在 zcode/apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:158 |
同样这三件状态 | 工具轮次是从 DSH 的持久会话日志里读出来的(一条带至少一次工具调用的 assistant 消息),而不是 ZCode 自己 turn loop 里的计数器。这与压缩接缝判断表面平衡时用的是同一个单位 —— 是量出来的,不是猜出来的步数 | node --test test/tracker.test.js —— 「a healthy gap never trips the breaker, however long the run」 |
拒绝的判定 —— shouldBlock: consecutiveRapidRefills >= MAX_CONSECUTIVE_RAPID_REFILLS(…/turn-loop-state.ts:165),在 if (context.rapidRefill.shouldBlock) 处被采纳(…/runtime/methods/compact.ts:230) |
同样的拒绝,且在摘要调用之前 | 拒绝会锁定,而且发生在摘要之前而不是之后:模型调用一次都不花,且被跳闸的会话会一直保持跳闸,直到被重新武装 | node --test test/tracker.test.js —— 「the trip happens before the futile compaction, not after it」「once tripped, every later attempt stays refused」 |
阈值是模块级常量 —— RAPID_REFILL_TOOL_TURN_THRESHOLD = 3 与 MAX_CONSECUTIVE_RAPID_REFILLS = 3(…/turn-loop-state.ts:21) |
同样两个阈值:toolTurnThreshold 与 maxConsecutiveRapidRefills |
它们是行配置而不是编译期常量,所以 profile 行与 preset 行可以不一样 —— 而且引擎挂载时会把实际拿到的值打出来 | node tools/boot-check.mjs --port 32100 会打出 compaction-breaker armed: rapid below 7 tool turns, trip at 3 in a row —— 那是一个配了 7 的行,默认值不可能产生这个输出 |
停机提示是 CLI 打出来的一个字符串 —— Autocompact stopped because the context refilled within fewer than … tool turns(…/runtime/helpers/model-errors.ts:52),并带 reason: "compact_rapid_refill_breaker"(:57) |
熔断期间注入一个 prompt 段,另有 /compaction-breaker status|reset |
DSH 的宿主会吞掉自动压缩路径抛出的错误并继续这一轮。同样一个抛出,会让用户只看到压缩被静默关掉而没有任何解释;注入的提示段才是让「停下」可见的东西 | node --test test/engine.test.js —— 「the trip registers exactly one prompt section」(名字为 compaction-breaker:tripped)、「the /compaction-breaker command reports state and resets on request」 |
microcompact.ts —— 整条清空旧工具结果、保留最近 5 条(…/packages/core/src/compact/microcompact.ts:14) |
不取 | 暂未超过。 DSH 自带一个不同的确定性 pruner;本插件刻意不重做 ZCode 那套,也不主张在这件事上强过它 | — |
| 熔断挂在 ZCode 自己的 turn loop 上 | 挂在 DSH 的 agent/pre-step 步压力路径上,替换 ctx.compaction 里的一行 |
不依赖外部 CLI 的轮次概念,并且以「单槽位服务的一行替换」接入 | node tools/boot-check.mjs --port 32100 —— 断言 B 是从 cordis.patch.yml 里读行名;docs/MEASUREMENTS.md 记录了真实会话里到达的 trigger=pressure |
怎么自己复核这张表
git clone https://github.com/BOWLUNA/dsh-zcode-breaker && cd dsh-zcode-breaker
npm install --no-audit --no-fund @deepseek-ai/dsh@0.1.6-alpha.2 # 本插件所挂的宿主
node --test test/tracker.test.js # 状态机与拒绝,含跳闸的先后顺序
node --test test/engine.test.js # 包覆层:委派、prompt 段、命令
node tools/boot-check.mjs --port 32100 # 真装真启动(四条断言)
node --test 不需要任何测试运行器,也不需要任何依赖。前两条就是上表的证据列;第三条是本仓库里唯一会真正 apply 插件的一道检查。
它解决的具体问题
@deepseek-ai/dsh-compaction-basic 里自动压缩有两条触发路径:
| 触发 | 入口 | 次数上限 |
|---|---|---|
| 步边界压力 | agent/pre-step 调 compactIfNeeded(agent, "pressure") |
无 |
| 溢出恢复 | agent/request-error 调 compactIfNeeded(agent, "context-overflow") |
maxOverflowRetries |
压力那条是无上限的:测得的压力一旦超过 thresholdRatio,下一步就再压一次,而每次压缩都是一次完整的摘要模型调用。于是一次过大的文件读取或工具输出就能造出这样一个循环——每一步烧掉一次模型调用,直到会话被放弃,而转录里只会反复出现下面这一行:
compaction (step pressure): shadowed N surface nodes (seqs A-B, ~T tokens)
它做了什么
它继承 BasicCompactionEngine,只包住一个方法:compactIfNeeded。
- 状态按 session 隔离,多会话互不污染。
- 「工具轮次」取自持久会话日志:一条带至少一个工具调用的助手消息。这与压缩接缝自己判断 surface 配对时用的口径一致,而不是猜的步数。
- 距上次压缩不足
toolTurnThreshold个工具轮次又需要压缩,记一次 rapid refill。 - 连续达到
maxConsecutiveRapidRefills次时,在这次压缩真正执行之前就拒绝它,那一发摘要调用永远不会被花掉。 - 拒绝会锁定状态,并抛出携带可操作建议的
CompactionRapidRefillError。 - 熔断期间还会注入一个 prompt 段。因为宿主会吞掉
compactIfNeeded抛出的错误并继续该轮,只抛错的话用户什么都看不到。 - 命令
/compaction-breaker用来查看状态并重新武装。
其余全部保留父类行为:触发策略、保留比例、surface 改写、工具配对安全与摘要实现。
两个平面,以及为什么两个都要管
标准 harness 里 compaction-basic 存在两份:一份是 @deepseek-ai/dsh-base 的宿主平面行,另一份在 agent preset 的 compaction 分组里,该分组声明了 isolate: { compaction: true, toolResultPruner: true }。agent 会话的 ctx.compaction 解析到那个隔离域里,而不加入任何 preset 的会话解析到宿主那一行。这就是为什么只打 profile 补丁改变不了 agent 的压缩,也是本节后半段那个 preset 行存在的原因。
这个接缝是单槽位的:同一 isolate 作用域出现第二个 provider 会让 ctx.provide() 抛错,而 ctx.reflect.set() 只接受持有该服务的 fiber 的写入。因此每个平面都靠替换它那一行来覆盖,而不是遮蔽它。
| 平面 | 服务谁 | 本包怎么覆盖它 |
|---|---|---|
| 宿主 | 不加入任何 agent preset 的会话 | 自动,通过安装器挂上的 bundle patch |
| agent 域 | 每一个普通会话 | 在你的 agent preset 里替换一行,因为任何 profile 补丁都够不到一个域 |
支持哪些 harness 版本,以及为什么停在这里
>=0.1.5-rc.2 <0.1.6-0 || >=0.1.6-alpha.1 <0.1.7-0
0.1.7 不支持,区间就是这么写的。 这是实测出来的,不是假设。在一个真实的 0.1.7-alpha.2 实例上
(本项目实验室里的 dsh017):
| 0.1.6-alpha.2 | 0.1.7-alpha.2 | |
|---|---|---|
dsh plugin add |
exit 0 | exit 0 |
--dump-config |
exit 0 · 576 行 · stderr 0 B | exit 0 · 1237 行 · stderr 0 B |
| 真启动端口应答 | t=1500ms 应答 · stderr 0 B | t=2000ms 应答 · stderr 0 B |
| 宿主平面是否由本引擎服务 | 是 | 是 —— 探针读到 ctx.get("compaction") 是 BreakerCompactionEngine,compactIfNeeded 是函数 |
| preset 平面 | <DSH_HOME>/.agent-presets/ 里的 preset 会被发现 |
不会 —— 种了 breaker-trip 之后 list() 返回 [] |
0.1.7 把 preset 发现机制换成了声明式注册表(dsh-agent-preset-registry,自述为
"Declarative Agent preset registry and profile-backed editing"),而注册表不扫描目录。
本插件的宿主那一半在 0.1.7 上是好的;但上面那段 README 让你去配的preset 那一半不是。
凭宿主那一半就说「支持 0.1.7」是半句真话,所以区间把它排除了 —— 包括 0.1.7 的正式版,
而一个写成 ... <0.2.0-0 的区间是会接受它的。
tools/verify-version-consistency.mjs 现在会在「声明区间覆盖了实测不支持清单里的版本」时让构建失败 ——
这个缺口就是这么被找出来的。
安装
dsh plugin --profile web add dsh-zcode-breaker
安装会挂上 cordis.patch.yml,它把宿主平面的 compaction-basic 行停掉,把这个引擎放到它原来的位置。之所以是替换而不是包覆,是单槽位逼出来的。
接着,为了 agent 会话,替换你的 preset 里 agent.cordis.yml 中 compaction 分组的那一行:
- id: compaction
name: cordis:group
group: true
isolate:
compaction: true
toolResultPruner: true
config:
- id: compaction-breaker
name: 'dsh-zcode-breaker'
config:
toolTurnThreshold: 2
maxConsecutiveRapidRefills: 3
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
请把 preset 复制到你的用户 preset 目录再改,而不是直接改随包分发的那份,否则 harness 升级会覆盖你的改动。
配置
父类的每个键都保留下来,并在子类上重新声明,因此既不会被父类的严格校验器拒掉,也不会丢失:
| 键 | 默认 | 含义 |
|---|---|---|
thresholdRatio |
0.8 |
触发压缩的压力比例 |
retainRatio |
0.16 |
压缩后保留的尾部比例 |
retainTokens |
无 | 用绝对 token 数代替上面的比例 |
summarizationProvider |
空 | 摘要用哪条路由 |
summarizationModel |
空 | 摘要用哪个模型 |
maxTokens |
8192 |
摘要输出上限 |
compactionRetries |
1 |
摘要失败后的重试次数 |
maxOverflowRetries |
1 |
溢出恢复的重试次数 |
modelPolicies |
无 | 按 provider 与模型覆写上述各项 |
auto |
true |
自动压缩总开关 |
本插件新增的键:
| 键 | 默认 | 含义 |
|---|---|---|
toolTurnThreshold |
2 |
间隔小于这个工具轮次数就算 rapid;恰好等于不算 |
maxConsecutiveRapidRefills |
3 |
连续第几次 rapid refill 时拒绝 |
announceInPrompt |
true |
熔断后是否注入那段建议 prompt |
合计 13 个配置项,并且本文件声明兼容 dsh >=0.1.5-rc.2 <0.1.6-0 || >=0.1.6-alpha.1 <0.1.7-0。
用户能看到的面
| 面 | 内容 |
|---|---|
| 日志 | 一行 warn,写明连续次数、阈值与上一次间隔 |
/compaction-breaker |
状态报告;/compaction-breaker reset 重新武装 |
| prompt 段 | 熔断期间注入,指示模型把情况转述给人 |
状态语义——三个刻意的决定
- 拒绝即锁定。被拒绝的尝试不会 commit,所以若不锁定,间隔一超过阈值就会自动放开,变成「拦两次放一次」。锁定才让它成为停机而不是减速。
- 只有两条出路:reset 子命令,或一次成功的手动
/compact。人工主动压缩是新信息,不该被自动路径的历史拦住。 - 重置不清零工具轮次时钟。那是会话的单调时钟,清零会让之后所有间隔都显得健康。被清掉的只有 rapid 计数、上次压缩标记、锁定与拒绝次数。
熔断不是全局禁用:会话照常可用,只是它这一个 session 的自动压缩停了。
兼容性与已知边界
- 它与其它压缩引擎互斥。若干后端都占同一个
ctx.compaction槽位,它们都无法与本引擎同时挂载。这是接缝本身的限制,不是本插件的选择。 - 宿主平面那一行不受影响,因此不组合 preset 的会话完全不受影响。
- 父类默认值被继承:父类自己的 schema 不带默认值,默认值由它的 resolver 提供。因此父类将来新增的键需要在这里重新声明,才能继续可配。
- 状态放在模块级 WeakMap,而不是类的私有字段。这是刻意的:域交给使用方的那个
ctx.compaction对象,不保证是私有初始化器跑过的那个,而私有字段读取会在压缩路径里抛错——偏偏那里宿主会把错误吞掉。 - 模型驱动的那一段端到端验收尚未跑过。 直到「引擎在真实域里服务
ctx.compaction」为止的每一步都已验证,详见docs/MEASUREMENTS.md。
与别的后端组合
策略核心是导出可复用的,别的后端大约二十行就能接入:
import { RapidRefillTracker } from 'dsh-zcode-breaker/tracker';
const tracker = new RapidRefillTracker({ toolTurnThreshold: 2, maxConsecutiveRapidRefills: 3 });
tracker.noteToolTurn();
const gate = tracker.gate();
if (gate.blocked) throw new Error('rapid refill loop');
const result = await myEngine.compact();
if (result !== null) tracker.commitCompaction(gate.projectedConsecutiveRapidRefills);
测试
npm test
19 个测试、2 个测试套件,不需要任何服务、模型或会话:策略核心在 test/tracker.test.js,引擎接线在 test/engine.test.js。接线那套需要 peer 包可解析,解析不到时会跳过而不是失败。
路线图
- 跑模型驱动的验收:在真实会话里造出回填循环,看熔断真的跳闸。
- 支持运行时选择父类,从而与用户偏好的任意后端组合。
- 把策略核心提给上游
@deepseek-ai/dsh-compaction-basic。
许可
MIT
评论
评论存放在 GitHub Discussions。用 GitHub 账号登录后可发表评论或点表情。