dsh-loop-breaker
只想要“照着做一遍”的封装版本(含安装、配置与排错表),见同名技能包 llm-degeneration-loop-breaker;本仓库是代码本体。
只要检测器、不想装 DSH? 核心判定逻辑已抽成零依赖独立包 → core/README.md
DSH 的流式复读熔断器。解决 DeepSeek flash 偶发的「退化复读」——思考链或正文反复输出
同一小段内容(好的 / 好的,执行 / 好),长时间不产出有效结果,按输出速度白白烧 token。
装好之后:命中复读 → 当场掐断生成(底层 HTTP 随即取消,token 停止计费)→ 同一轮注入一句 补救提示让模型重新作答。
为什么不用现成的 dsh-thinking-loop-guard
社区已有一个 unknowbug/dsh-thinking-loop-guard
解决同类问题,但它只有 turn 边界 检测(挂在 agent/turn-stopping 上读完整 reasoning)。
问题在于:turn 边界 = 这一轮的 token 已经烧完了。它能阻止循环蔓延到下一轮,却救不回
正在烧的那一轮。
它的 README 明确断言「DSH 直连远程 API,没有中间层能检测并打断流式响应」。这个判断是错的:
DSH 的 @deepseek-ai/dsh-llm 暴露了 llm/stream 瀑布事件,包住每一次流式模型调用:
'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
本插件就挂在这里,因此能做到它做不到的事:流式过程中命中,就地掐断。
工作机制
模型流式输出
→ llm/stream 瀑布(本插件包住这条流)
→ 逐块统计 reasoning / text
→ 规则任一命中
→ 停止拉取上游
└─ 适配器生成器 finally 执行 consumer.abort()
└─ 底层 HTTP 请求当场取消,token 停止计费
→ 补上合法的流收尾(闭合内容块 + finish:{kind:'stop'})
└─ L0 消毒:写回会话的正文换成「开头一小段 + 占位符」,复读原文不落盘
→ 本轮 step 正常结束(没有 tool call → 无后续动作)
→ agent/turn-stopping:注入一句补救提示,同一轮重新作答
第二层是关键体验保障:如果只掐断不补救,模型往往只产出了半截思考、没产出正文, 用户会拿到一个「只有思考、没有答案」的空轮次。
L0 消毒:为什么掐断时还要改写正文
掐断只解决了「继续烧 token」,没解决「垃圾进历史」。补 block-end 时如果把复读原文
(可能是几百遍「好的执行」)原样写回,它会被持久化进会话历史,成为下一次生成的先验——
同前缀同退化是自回归模型的固有性质,于是出现「掐断后重发、甚至新开对话仍然复读」。
因此写回前先过一遍 sanitizeBlockText():保留开头 sanitizeHeadChars 字(保住「刚才在
做什么」的语义),其余替换成占位符;若开头本身就已字面塌缩(用字单调且碎片化),
则一段都不保留。可用 sanitize: false 回退成旧行为(排查期想看模型到底复读了什么时有用)。
为什么必须补 finish 块
dsh-llm 内置 validateStream(以 prepend: true 注册,位于本插件外层)校验流协议:
- 流必须以
finish块收尾,否则LLM stream ended without a terminal finish chunk; finish.reason.kind不是error/aborted时,不允许有未闭合的内容块。
所以掐断时必须先为每个打开的内容块补 block-end,再补 finish:{kind:'stop'},顺序不能反。
用 aborted 虽然天然允许未闭合块,但 agent loop 会把它当失败抛出,用户看到报错。
开着工具调用块时不掐断
半截的工具调用参数是无意义且危险的(可能被执行、报 JSON 解析错)。因此只要还有非
text/reasoning 的块开着,就推迟掐断;等工具块关闭后若仍在复读,恢复掐断(有测试覆盖)。
检测规则与阈值依据
复读有五种真实形态,规则分别针对:
| 规则 | 抓什么 | 默认阈值 |
|---|---|---|
tight-period-loop |
碎片周期循环:做。→(执行)→好。→执行。 这类在决策点原地打转 |
尾部 200 字内存在周期 p∈[3,48]、自吻合率 ≥ 0.92,且窗口内片段种类 ≤6 |
char-collapse-loop |
字符集塌缩:整段用字单调到只有个位数种字 | 尾部窗口去重字符占比 < 0.10、片段长度中位 <10、片段种类 ≤6 |
sentence-recycle |
换着说法反复重下同一个决定 | 句子回收率 ≥ 0.80(且 ≥20 句、窗口 ≥600 字) |
gram-recycle-16 |
大段分析反复重算 | 16-gram 回收率 ≥ 0.80(窗口 ≥900 字) |
tight-phrase-loop |
好的/好/好的执行 这类短句空转 |
窗口内 3-gram 冗余度 ≥ 0.90(窗口 ≥400 字) |
max-chars / max-reasoning-chars |
兜底硬上限 | 20 万字 / 12 万字(0 = 关闭) |
判据为什么从「重复率」改成「周期长度」:前三类本质都在量「重复得多不多」,而正常的 模板化长输出重复得一样多——40 个结构相同、只有函数名不同的函数体 16-gram 回收率 0.808, 比真实复读样本 real_loop3 的 0.676 还高,光靠重复率阈值分不开这两类。
真正能分开的是重复单元的周期长度:退化复读的单元是几到几十字的无意义碎片(周期 4~48 字),正常模板化输出的单元是一个函数体、一行表格(周期几百字),差两个数量级。 所以用「短周期严格重复」当判据,能精确抓住碎片循环,同时天然放过结构性雷同的批量内容。
另有一个零信息增量信号(连续 stallMinChars 字没产生任何新的 3-gram)不单独触发,
只作为助推:命中它时放宽上面两条的闸门(片段种类 ≤10、去重字符占比 <0.16)。单独用会误杀
批量同构内容——那确实没有新 3-gram,但不是退化。
还要注意「用字单调」与「碎片循环」都必须叠加片段种类极少这一条:图表型文本(大量 #
与数字)用字同样单调,但每一行都不同,靠这道闸门排除在外。
阈值不是拍脑袋定的,是用真实样本标定的(samples/ + test.mjs):
| 样本 | 句子回收率 | 16-gram 回收率 |
|---|---|---|
| 3 份真实复读会话记录 | 1.000 / 1.000 / 1.000 | 0.68 ~ 0.78 |
| 6 份官方中文文档 + 3 份源码 + 60 行长表格 | 0.000 ~ 0.255 | 0.02 ~ 0.15 |
两类之间留了很大余量,因此阈值取中间,宁可漏过不可误杀。实测命中位置(2026-09-12 加入 短周期与塌缩规则后重测):
- 三份真实复读样本:第 626 / 636 / 1723 字命中(
sentence-recycle) - 碎片周期循环(
做/执行/好):第 135 字命中(tight-period-loop) - 短句空转(
好的,执行):第 132 字命中(tight-period-loop) - 「正常前缀 + 短句空转」:第 1403 字命中(前面的正常前缀把新规则的短窗口稀释了,回落到周期规则)
即复读开始后约 130~2000 字内被拦下,碎片型复读由原来的 400 字级提前到百字级。 作为对照,ollama 那份报告里一次失控跑了 31M token。
配置
- id: loop-breaker
name: 'dsh-loop-breaker' # 与包名一致(install.ps1 就装在这个名字下);若装在 @local\ 作用域里则写 '@local/dsh-loop-breaker'
config:
enabled: true # 总开关
recover: true # 命中后是否注入补救提示
maxRecoveriesPerTurn: 2 # 同一轮最多补救几次(按轮号归零,跨轮重新计数)
nudge: '' # 补救提示文本,留空用内置文案
# --- L0 消毒:防止复读原文落盘污染后续生成 ---
sanitize: true # 是否改写掐断时写回会话的正文
sanitizeHeadChars: 240 # 保留原文开头的字数(0 = 全丢;开头已塌缩时同样全丢)
degradePlaceholder: '' # 替换复读原文的占位符,留空用内置文案
# --- 检测阈值 ---
windowChars: 1600 # 滚动统计窗口(归一化字符数)
periodMax: 48 # 短周期检测的最大周期;超过它的重复视为长块模板重复
periodMatch: 0.92 # 短周期自吻合率阈值
periodMaxPieces: 6 # 窗口内片段种类上限(用字单调 ≠ 复读,还要碎片少)
collapseUniqueRatio: 0.10 # 去重字符占比阈值(正常中文 0.3~0.6)
stallMinChars: 400 # 零信息增量助推门槛(0 = 关闭)
sentRecycle: 0.80 # 句子回收率阈值
gramRecycle16: 0.80 # 16-gram 回收率阈值
tightRedundancy: 0.90 # 窗口字面冗余度阈值
maxChars: 200000 # 单次输出硬上限(0 = 关闭)
maxReasoningChars: 120000 # 单次思考链硬上限(0 = 关闭)
调参方向:
- 误杀正常长输出 → 调高
sentRecycle/gramRecycle16/tightRedundancy,或调大windowChars - 误杀批量同构内容(清单 / 条款 / 近似函数) → 这类靠句子回收命中,调高
sentRecycle - 误杀碎片循环判定 → 调高
periodMatch,或调低periodMax(周期越小越像退化) - 漏过复读 → 反向调低;或调小
windowChars(循环占比更高,更早命中) - 只想掐断不想要补救 →
recover: false - 排查期想看模型到底复读了什么 →
sanitize: false(让原文落盘)
安装
三种装法,按你的情况选一种。
1) 装成 DSH 插件(本项目的原始形态)
git clone https://github.com/CHIP-PHILO-GH/dsh-loop-breaker
cd dsh-loop-breaker
pwsh -File install.ps1
install.ps1 是幂等的,可重复执行。默认装到
$env:DSH_HOME\profiles\node_modules\dsh-loop-breaker(DSH_HOME 未设置时取 ~/.dsh),
也可以用 -Destination 指定别处。
装载行在 $env:DSH_HOME\profiles\<profile>\cordis.patch.yml。
⚠️ 插件市场装卸会回滚该文件,若行丢失按上面配置块补回即可。
改动后需重启 dsh web 生效——本项目不做热重载。
(用 Windows PowerShell 5.1 跑 install.ps1 会因无 BOM 的中文被按 ANSI 误读而报错,请用 pwsh 7。)
2) 只要检测器,不装 DSH
检测器本体是零依赖的独立包 core/,不依赖 DSH,也不依赖任何第三方包:
node core\cli.mjs 输出.txt # 直接判一段输出是不是复读了
node core\cli.mjs --self-test # 冒烟自检
import { createLoopDetector } from 'dsh-loop-breaker/core'
详见 core/README.md。
3) 适用形态 / 不适用
它主要抓紧邻的短周期复读:例如“做。→(执行)→好。→执行。”这类由少数短碎片连续拼装的流式退化输出。基准中的合成样本显示,长间隔重复、较短英文周期、以及长度不足的样本可能漏检;这些不是通用复读检测器应承诺覆盖的形态。
已知边界是:长技术报告、含大量近似函数的代码等正常长输出,可能因结构性重复而误杀;仓库测试和基准结果已保留这些边界。请不要把本项目当作通用复读检测器,也不要把基准数字解读为真实生产分布上的准确率。
4) 只想跑一遍测试验证它有效
不需要安装任何东西,也不需要联网:
node test.mjs # 流式正/负样本回归(24 例)
node test-false-positive.mjs # 误杀对抗测试(10 例真实长输出形态)
node benchmarks/lab/run-benchmark.mjs # 合成量化基准,详见 benchmarks/README.md
node core\cli.mjs --self-test # 核心包自检
这三条就是 CI 里跑的全部内容,任何 Node ≥18 的机器上都能跑通。
插件集成测试(33 例:流协议合法性、上游取消、工具块推迟、补救预算、L0 消毒)
本插件运行时依赖 DSH 宿主提供的 @deepseek-ai/dsh-llm 与
@deepseek-ai/schemastery(已在 package.json 里以 peerDependencies 声明,不把宿主运行时复制进自身依赖树),
只能在装了 DSH 的环境里从安装目录跑:
node "$env:DSH_HOME\profiles\node_modules\dsh-loop-breaker\tests\guard.test.mjs"
它复刻了 dsh-llm 的 validateStream,专门验证掐断后的流仍然合法。
重启前自检:
dsh --profile web --dump-config 2>&1 | Select-String -Pattern 'loop-breaker|error|failed|invalid config'
踩坑记录
- 协议收尾:不补
finish块会让内置校验器抛错;补了finish:{kind:'stop'}但留着未闭合 块同样抛错。必须先闭合块(见上)。 - 补救计数器:最初在
turn-stopping里trips.delete(key),导致每轮都从 1 开始数,maxRecoveriesPerTurn形同虚设。现在按payload.turn区分轮次,跨轮才归零。 - 测试用例本身会骗人:S4 最初构造的「正常阶段」文本是同一句话只改数字,被短句冗余规则 提前命中,看起来像插件 bug。负样本必须真正多样,否则标定出的阈值不可信。
- 硬上限不能设太低:一开始设 12 万字,把 9 万字的正常长输出拼接测试判定为超限。
单次调用的实际上限由 provider 的
max_tokens决定,硬上限只是极端兜底,已放到 20 万字。 - 测试夹具会骗人(第二次):集成测试里用
'正常思考。'.repeat(50)、'…'.repeat(4)当 「正常文本」,那本身就是退化复读的形状,新规则判它复读是对的——错的是夹具。已换成真正 多样的文本。遇到误判先怀疑夹具,再动阈值。 - 测试的日志捕获器会吞掉断言输出:集成测试把含
[loop-breaker]的 console 行收进logs, 而断言又把日志正文当作附加信息打印,于是那行断言在输出里凭空「消失」(值其实算对了)。 断言附加信息不要包含日志正文。 - 「用字单调」不等于复读:新增的塌缩规则一开始没有片段种类闸门,把图表型文本(大量
#与数字,用字同样单调但每行都不同)判成复读。必须叠加「片段种类极少」才能区分。 - 熔断没解决的另一半是「垃圾进历史」:掐断只停止烧 token;复读原文若被原样写回会话, 会成为下一次生成的先验。真正的修复在写回那一刻(L0 消毒),不在掐断那一刻。
已知边界
- 只覆盖
text-delta/reasoning-delta。模型反复用完全相同的参数调工具属于另一类循环, 由官方@deepseek-ai/dsh-repeat-tool-reminder负责(base 组合默认已启用)。 - 掐断的那次调用可能拿不到
usage(请求已取消),该轮的 token 计量会缺失,属预期。 - 判定是启发式的:它能让复读早点结束,但不能让模型变聪明。
- 同一句短句在同一窗口内连续重复 4 次以上(例如批量逐项报告「该项检查通过。」×20)会命中
tight-period-loop。它在结构上与退化复读同型,无法用统计量分开;要保这类输出请调高periodMatch或调低periodMax。 - L0 消毒会改写落盘文本:会话记录里留下的不再是模型原话,而是「开头一小段 + 占位符」。
排查期想看原文请设
sanitize: false。 - 掐断那一刻只累积到「命中时已产出的正文」,所以被丢弃的原文长度 = 触发点位置,不是整段长度。
回滚
删掉 cordis.patch.yml 里的 loop-breaker 行(或把 enabled 改 false)后重启 dsh web。
实测记录(2026-09-10 重启后核验)
端到端验证:熔断确实生效
在真实运行时里让一个子代理去复读(要求输出 600 遍「好的,执行」),然后直接读它的会话日志核对
($env:DSH_HOME\sessions\<工作区编码>\<会话 id>\session.v3.jsonl.zstd;注意是多帧 zstd 拼接,
必须按魔数 28 B5 2F FD 分帧逐帧解压,一次性解压只会得到空):
- 该 assistant 消息里「好的,执行」只写了 204 遍就被截断,没有跑满 600 遍;
- 日志中出现
agent/inbox/spliced,内容带source: {kind:'plugin', plugin:'loop-breaker', form:'notice', summary:'复读熔断(tight-phrase-loop),已要求重新作答'}; - 该轮有 2 个 step:step1 被掐断 → 注入补救 → step2 模型改口给出可用替代方案,全程约 6 秒。
这同时证明插件确实挂载成功。
日志为什么曾经是哑的
Cordis 的 logger 在当前版本不作为宿主 Service 提供(ctx.logger 为 undefined),
可选链会把全部日志静默吞掉——启动日志里能看到 [dsh-cost-meter]、[dsh-turn-cost],是因为它们走 console.log。
现已改为 console.log:启动打印 [loop-breaker] 熔断器已安装…,命中时打印规则、判定值与被截样本片段。
对正常长输出的影响(15 例真实长输出 + 3 例模板化边界,逐例实测)
零误杀:
- 官方中文文档 6 份、官方源码 3 份(句子回收率 ≤0.24,16-gram ≤0.05)
- 大表格 200 行数值 / 120 行文本(16-gram 均 0.000)
- 长清单 50 条、长 JSON 150 行、长数字序列 0..399、图表型文本
- 长报告 12 节各不相同(句子回收率 0.000)
已知边界(会被判为复读而中断):
- 结构性高度雷同的批量内容,例如 40 个结构相同、只有函数名不同的 handler、每节只换编号的条款。 实测「近似函数 40 个」的 16-gram 回收率为 0.808,而真实复读样本 real_loop3 只有 0.676—— 两者无法用任何阈值分开。这是文本层面的固有边界,调参解决不了。
- 需要这类输出时,调高
sentRecycle/gramRecycle16,或临时enabled: false。
取舍原则:宁可漏过也不误杀。上面这一节是 2026-09-10 版本的实测记录;2026-09-12 加入短周期
与字符塌缩规则后,碎片型复读在百字级即可判定,而长负样本的误杀情况与旧版持平
(test-false-positive.mjs 复测:10 例中 2 例误杀,探针确认均为旧版既已存在的误杀,
本次改动引入的新误杀为 0)。开发过程、阈值推导与踩坑的完整记录见 docs/PROJECT_LOG.md。
许可
MIT。判定逻辑与标定数据可自由取用。
No comments yet. Be the first to write one.