DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

CHIP-PHILO-GH /

CHIP-PHILO-GH/dsh-loop-breaker

Verified

DSH 插件:流式复读熔断 —— 在流式输出里边收边判,命中退化复读就地掐断并消毒正文,避免白烧 token。

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@df773eb9

dsh-loop-breaker

CI License: MIT

只想要“照着做一遍”的封装版本(含安装、配置与排错表),见同名技能包 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'

踩坑记录

  1. 协议收尾:不补 finish 块会让内置校验器抛错;补了 finish:{kind:'stop'} 但留着未闭合 块同样抛错。必须先闭合块(见上)。
  2. 补救计数器:最初在 turn-stopping 里 trips.delete(key),导致每轮都从 1 开始数, maxRecoveriesPerTurn 形同虚设。现在按 payload.turn 区分轮次,跨轮才归零。
  3. 测试用例本身会骗人:S4 最初构造的「正常阶段」文本是同一句话只改数字,被短句冗余规则 提前命中,看起来像插件 bug。负样本必须真正多样,否则标定出的阈值不可信。
  4. 硬上限不能设太低:一开始设 12 万字,把 9 万字的正常长输出拼接测试判定为超限。 单次调用的实际上限由 provider 的 max_tokens 决定,硬上限只是极端兜底,已放到 20 万字。
  5. 测试夹具会骗人(第二次):集成测试里用 '正常思考。'.repeat(50)、'…'.repeat(4) 当 「正常文本」,那本身就是退化复读的形状,新规则判它复读是对的——错的是夹具。已换成真正 多样的文本。遇到误判先怀疑夹具,再动阈值。
  6. 测试的日志捕获器会吞掉断言输出:集成测试把含 [loop-breaker] 的 console 行收进 logs, 而断言又把日志正文当作附加信息打印,于是那行断言在输出里凭空「消失」(值其实算对了)。 断言附加信息不要包含日志正文。
  7. 「用字单调」不等于复读:新增的塌缩规则一开始没有片段种类闸门,把图表型文本(大量 # 与数字,用字同样单调但每行都不同)判成复读。必须叠加「片段种类极少」才能区分。
  8. 熔断没解决的另一半是「垃圾进历史」:掐断只停止烧 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。判定逻辑与标定数据可自由取用。

—/ 5

No ratings yet

Verified DSH bundle

Commit df773eb980db

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout