# 开源发布版说明 本日志保留技术过程、事实与结论;具体模型名统一替换为 `<模型>`,推理强度统一替换为 `<推理强度>`,指向个人的措辞改为“需求方/项目发起”等中性表述。除此之外不删改技术内容。 # PROJECT_LOG — DSH 流式复读熔断器(dsh-loop-breaker) - **日期**:2026-09-10 - **背景**:模型配置为 `<模型>`(DeepSeek-V4.1-Flash)+ `<推理强度>: high`。 该模型在长 agentic 上下文中偶发「退化复读」——思考链反复输出 `好的 / 好的,执行 / 好` 一类无意义内容,长时间不产出结果,按输出速度白烧 token。需求方要求装一个熔断机制。 ## 结论(一句话) 在 DSH 的 `llm/stream` 瀑布事件上包一层流式检测器,命中复读就停止拉取上游, 底层适配器随即 abort HTTP 请求、token 停止计费;再在 turn 边界注入一句补救提示让同一轮重新作答。 ## 关键发现:一个被社区误判的能力缺口 社区现成的 [dsh-thinking-loop-guard](https://github.com/unknowbug/dsh-thinking-loop-guard)(unknowbug,2026-08-27 建) 只做 **turn 边界** 检测,其 README 断言「DSH 直连远程 API,没有中间层能检测并打断流式响应」。 **这个断言不成立。** `@deepseek-ai/dsh-llm` 的 `lib/index.js:2307` 有: ```js return this.ctx.waterfall(this, "llm/stream", options, () => this.adapterStream(options, prepared)); ``` `llm/stream` 是公开的瀑布事件,能包住每一次流式模型调用。因此不必接受「turn 边界」这个限制。 ## 决定性证据:停止拉取 = 取消 HTTP `@deepseek-ai/dsh-llm-deepseek/lib/index.js:1646-1651`,适配器生成器的 finally: ```js } finally { consumer.abort("DeepSeek stream consumer stopped"); if (!exhausted && iterator.return !== void 0) try { await iterator.return(); } catch {} } ``` 即:下游只要不再迭代,`consumer.abort()` 就取消底层请求。这就是熔断能省 token 的机制基础。 ## 协议红线(必须遵守,否则炸) `@deepseek-ai/dsh-llm/lib/invariant.js` 的 `validateStream` 以 `prepend:true` 注册,位于插件**外层**: 1. 流必须以 `finish` 块收尾,否则 `LLM stream ended without a terminal finish chunk`; 2. `finish.reason.kind` 非 `error`/`aborted` 时,**不允许未闭合的内容块**; 3. `finish` 之后不得再有任何块。 → 掐断时必须**先补 `block-end`(每个打开的块)再补 `finish:{kind:'stop'}`**。 用 `aborted` 虽天然容许未闭合块,但 `dsh-agent-loop/lib/index.js:1082-1097` 会把它当失败抛出。 另一个要点:`agent-loop` 在 `finish` 非 error/aborted 且**无 tool call** 时返回 `completed` (`index.js:1116-1117`),所以 `finish:{kind:'stop'}` 能让本轮干净收尾。 ## 阈值标定(不靠猜) 用 3 份真实复读运行记录(取自 dsh-thinking-loop-guard 的 `tests/real_loop*.txt`,共 11.6KB) 与 13 份真实长负样本(6 份官方中文文档 + 3 份源码 + 60 行长表格 + 拼接)对照: | 指标 | 真实复读样本 | 真实长负样本 | |---|---|---| | 句子回收率 | **1.000 / 1.000 / 1.000** | 0.000 ~ 0.255 | | 16-gram 回收率 | 0.68 ~ 0.78 | 0.02 ~ 0.15 | 注意:这些真实复读**不是字符级复读**,而是「换着说法反复重下同一个决定」 (如 `real_loop3` 里「让我先找结构位置」反复出现)——与需求方描述的 `好的/好/好的执行` 同型。 所以精确块重复检测(社区方案走的路)抓不到,必须用**短语/句子回收率**。 ## 产物 | 路径 | 说明 | |---|---| | `仓库根\` | 源码(lib/index.js 插件本体、lib/detector.js 检测器、tests/、samples/、test.mjs、install.ps1、README.md) | | `\profiles\node_modules\dsh-loop-breaker\` | 安装副本 | | `\profiles\\cordis.patch.yml` | 装载行(末尾 insert 块,含 config) | ## 验证结果 - 检测器单元测试 **21/21 通过**:7 正样本(含「正常前缀 + 复读」)全部命中,13 真实长负样本 全部放过,1 兜底用例命中。命中位置:真实复读第 610/652/1388 字,短句空转第 421/440 字。 - 插件集成测试 **23/23 通过**:复刻 `validateStream` 验证掐断后流合法、上游确被取消 (`stats.returned`)、工具块打开时推迟、关闭后恢复掐断、补救预算按轮封顶。 - 重启前自检:包文件完整 ✅ / Host 冒烟 `exports=Config,apply,name` ✅ / `dsh --profile web --dump-config` EXIT=0 且无任何错误字样 ✅ ## 踩坑定论 1. **补救计数器**:最初在 `turn-stopping` 里 `trips.delete(key)`,导致每轮都从 1 开始数, `maxRecoveriesPerTurn` 形同虚设(测试报「补救 3 次,上限 1」)。改为按 `payload.turn` 区分轮次,跨轮才归零。 2. **测试用例本身会骗人**:S4 构造的「正常阶段」是同一句话只改数字,被短句冗余规则提前命中, 看起来像插件 bug。负样本必须真正多样。 3. **硬上限别设太低**:原设 12 万字,把 9 万字的正常长输出误判为超限。已放到 20 万字。 单次调用真实上限由 provider `max_tokens` 决定,硬上限只是极端兜底。 4. **中文路径 + Windows PowerShell 5.1**:无 BOM 的 .ps1 被按 ANSI 读,`中文需求方名` 变乱码导致 「Access denied」假象。装插件用 `pwsh`(7.x)。 ## 待需求方执行 **重启 `dsh web`**(AI 不自行重启)。重启后启动日志应出现 `loop-breaker: 熔断器已安装(llm/stream 流式检测 + turn 边界补救)`, 复读命中时出现 `loop-breaker: 已中断复读 session=... rule=...` 告警。 ## 相关外部资料(成因调研) - [deepseek-ai/DeepSeek-V3#1630](https://github.com/deepseek-ai/DeepSeek-V3/issues/1630):工具调用后重复词语输出,疑似生成退化(2026-09-08,open,模型 `deepseek-v4-flash`) - [ollama/ollama#17617](https://github.com/ollama/ollama/issues/17617):193 次近似相同响应、约 31M input token;根因链为 `` 字面量泄进正文后被当模板分隔符 → 自维持循环 - [HF DeepSeek-V4-Flash-0731 discussions#58](https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731/discussions/58):tool-call 决策点退化复读,可移植复现,3 种推理栈 - [HF discussions#39](https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731/discussions/39):reasoning loops,尤其在工具调用期间 - [deepseek-ai/DeepSeek-V3#1587](https://github.com/deepseek-ai/DeepSeek-V3/issues/1587):思考模式无限重复循环,需手动暂停 - [ggml-org/llama.cpp#26694](https://github.com/ggml-org/llama.cpp/issues/26694):长 agentic 对话中退化复读并泄漏特殊 token - [华为云论坛](https://bbs.huaweicloud.cn/forum/thread-0212722434089714791-1-1.html):`deepseek-v4.1-flash-expires-on-0910` 思考链复读(未解决) - [cc-switch#5860](https://github.com/farion1231/cc-switch/issues/5860):客户端代理把 assistant 回合拆成两条并复制 `reasoning_content`,导致 DeepSeek 无限复读(说明不全是模型的锅) --- ## 2026-09-10 重启后核验与二次调参 **1) 熔断器端到端生效(已用运行日志坐实)** 让子代理复读 600 遍「好的,执行」,其运行日志显示:assistant 消息只写到 **204 遍被截断**; 日志含 `agent/inbox/spliced` + `source:{kind:'plugin',plugin:'loop-breaker',summary:'复读熔断(tight-phrase-loop)…'}`; 该轮 2 个 step(掐断 → 补救 → 改口给替代方案)。读日志要点:`session.v3.jsonl.zstd` 是**多帧 zstd 拼接**, 须按魔数分帧解压。 **2) 修掉一个观测缺陷**:Cordis 的 `logger` 不是宿主 Service(`ctx.logger` 为 undefined), 原实现用可选链导致日志全被静默吞掉,插件是否加载无法验证。已改 `console.log`。 **3) 二次调参(对抗测试驱动)**:新增 `test-false-positive.mjs` / `eval-thresholds.mjs` / `decide.mjs`, 用 15 例真实长输出 + 3 例模板化边界做网格搜索,最终选定 `sentRecycle 0.80 / gramRecycle16 0.80 / tightRedundancy 0.90 / sentMinCount 20`: 正样本 5/5 全命中(real_loop 提前到 1700 字),真实长输出 15/15 零误杀。 **4) 固有边界**:结构性高度雷同的批量内容(40 个结构相同、只有函数名不同的函数体)16-gram 回收率 0.808, 高于真实复读样本 real_loop3 的 0.676,**无法用阈值分开**。已写入 README 并给出手动放宽方式。 **5) 待办**:本轮改动(阈值 + 日志)需再次重启 dsh web 才生效。 --- ## 2026-09-12 优化(第二轮):L0 消毒 + 碎片复读专杀规则 **触发**:真实使用中遇到思考链在「要不要动手」的决策点上退化成 `做。→(执行)→好。→执行。` 无限循环(需求方贴出的原文即此形态)。熔断器确实掐断了, 但掐断后同一运行重发、**甚至新开对话仍继续复读**。 ### 一、定位:原检测器的判据方向偏了 原来三条规则(句子回收 / 16-gram / 窗口字面冗余)本质都在量「**重复得多不多**」。 而正常的模板化长输出重复得一样多:40 个结构相同、只有函数名不同的函数体 16-gram 回收率 0.808, 比真实复读样本 real_loop3 的 0.676 还高。上一轮记的「结构性雷同内容无法用阈值分开」 那条边界,根因不是阈值没调好,而是**判据选错了**。 真正能分开两类的是「**重复单元的周期长度**」: - 退化复读的单元是几到几十字的无意义碎片(周期 4~48 字); - 正常模板化输出的单元是一个函数体、一行表格(周期几百字)。 两者差两个数量级。所以判据应该是「短周期严格重复」,而不是「高重复率」。 另一处钝感来自评估节奏:每次要攒够 400 字归一化文本才评估,且用 1600 字大窗口算整体冗余—— 复读前面只要有一段正常内容就会被稀释,判得又慢又不准。 ### 二、改动一:`lib/detector.js` 新增两条专杀规则 + 一个助推信号 | 规则 / 信号 | 判据 | 说明 | |---|---|---| | `tight-period-loop` | 尾部 200 字窗口内存在 p∈[3,48],使 `s[i]==s[i+p]` 吻合率 ≥0.92,且窗口内不同片段种类 ≤6 | 专杀碎片周期循环;周期超过 48 字的模板重复天然放过 | | `char-collapse-loop` | 尾部窗口去重字符占比 <0.10、片段长度中位 <10、片段种类 ≤6 | 最快的判据,120 字内可判(正常中文技术文本占比 0.3~0.6) | | 零信息增量(**助推,不单独触发**) | 连续 400 字没产生任何新的 3-gram → 放宽上面两条闸门(种类 ≤10、占比 <0.16) | 单独用会误杀批量同构内容,故只做助推 | 评估节奏同时调整:`evalEvery` 100→40、`minEvalChars` 400→100(各旧规则仍保留自己的长度门槛)。 两条新规则都必须叠加「**片段种类极少**」这道闸门——这一条是被实测逼出来的,见踩坑 3。 ### 三、改动二:L0 消毒(本轮的核心修复) 旧代码在掐断处写的是: ```js yield { type: 'block-end', index, block: { type, text: block.text } } ``` `block.text` 就是刚被判定的复读正文(可能是几百遍「好的执行」)。**它被原样写回了运行历史**, 成为下一次生成的先验——同前缀同退化是自回归模型的固有性质,于是「掐断后重发、甚至新开对话 仍复读」就发生了。**掐断只解决了「继续烧 token」,没解决「垃圾进历史」。** 现在改为 `sanitizeBlockText()`:保留开头 `sanitizeHeadChars`(默认 240)字,其余替换成占位符; **若开头本身就已字面塌缩(用字单调 + 碎片化)则一段都不保留**。塌缩判断直接复用检测器导出的 `isCharCollapsed()`,不另写一套标准。 新增配置:`sanitize`(默认 true)、`sanitizeHeadChars`、`degradePlaceholder`; `sanitize: false` 可回退旧行为(排查期想看模型到底复读了什么时有用)。 命中日志也加了「丢弃复读原文 N 字」,便于事后核验。 ### 四、验证结果(全部通过) | 项目 | 结果 | |---|---| | 语法检查 `node --check` | lib/detector.js、lib/index.js 均通过 | | 单元测试 `test.mjs` | **24/24**(原 21 例 + 新增 P8 碎片周期循环、P9 宣布-确认循环、K1 已知边界) | | 集成测试 `tests/guard.test.mjs` | **33/33**(原 23 项 + S10/S11/S12 三项 L0 消毒验证) | | 误杀对抗 `test-false-positive.mjs` | 10 例中 2 例误杀,**均为旧版既已存在**;本次改动新增误杀 **0** | | 宿主冒烟 `smoke-host.mjs` | HOST OK exports=Config,apply,name | | `dsh --profile web --dump-config` | exit=0、无 error/failed 字样、loop-breaker 装载行在 | 命中位置实测(同一批正样本,改动后重测): - 碎片周期循环(`做/执行/好`):第 **135** 字(`tight-period-loop`) - 短句空转(`好的,执行`):第 **132** 字(`tight-period-loop`) - 宣布-确认循环(含「OK,执行命令」):第 **132** 字(`tight-period-loop`) - 三份真实复读样本:626 / 636 / 1723 字(`sentence-recycle`,换说法型仍走这条) 即碎片型复读由原来的 400 字级提前到**百字级**。 误杀是否为新引入,用探针把新规则关掉复现旧版行为对照(`evalEvery:100, minEvalChars:400, periodMatch:1.01, collapseUniqueRatio:0, stallMinChars:0`): | 用例 | 旧规则 | 新规则 | |---|---|---| | 1 长技术报告(24节) | 误杀@613 | 误杀@633(都是 `sentence-recycle`,非本次引入) | | 9 长代码 40 个近似函数 | 误杀@876 | 误杀@753(同上) | | 10 图表型文本 60 行 | 放过 | **放过**(加闸门后修好,见踩坑 3) | ### 五、本轮踩坑定论 1. **测试夹具会骗人(第二次)**。集成测试里用 `'正常思考。'.repeat(50)`、`'…'.repeat(4)` 当「正常文本」,那本身就是退化复读的形状,新规则判它复读是对的——**错的是夹具**。 已换成真正多样的文本。顺序是:遇到误判先怀疑夹具,再动阈值。 2. **测试的日志捕获器会吞掉断言输出**。集成测试把含 `[loop-breaker]` 的 console 行收进 `logs`,而断言又把日志正文当附加信息打印,于是那行断言在输出里「消失」(值其实算对了)。 断言附加信息不要包含日志正文。 3. **「用字单调」不等于复读**。塌缩规则最初没有片段种类闸门,把图表型文本(大量 `#` 与数字, 用字同样单调,但每行都不同)判成复读。加上「片段种类 ≤6」后放过,碎片循环仍能命中。 结论:单调性与碎片性必须同时成立。 4. **掐断时写回的正文才是污染源**。真正要修的时机是「写回那一刻」,不是「掐断那一刻」。 ### 六、已知边界(未变) - **结构性雷同的长块批量内容**(40 个结构相同、只有函数名不同的函数体)与「大段分析反复重算」在统计上同型, 仍会被 `sentence-recycle` 命中(已把 K1 用例钉在测试里,647 字命中)。要保这类业务输出, 需调高 `sentRecycle` 或降低 `sentMinCount`。 - **同一句短句在同一窗口内连续重复 ≥4 次**(如批量逐项报告「该项检查通过。」×20)会命中 `tight-period-loop`。它在结构上确实与退化复读同型,无法用统计量分开。 - **L0 消毒会改写落盘文本**:运行记录里留下的是「开头一小段 + 占位符」,不再是模型原话。 - 掐断那一刻只累积到「命中时已产出的正文」,所以丢弃长度 = 触发点位置,不是整段长度。 ### 七、待需求方执行 **重启 `dsh web`**(AI 不自行重启)。重启后启动日志应出现: ``` [loop-breaker] 熔断器已安装(llm/stream 流式检测 + L0 消毒开 + turn 边界补救) ``` 命中时除原有的 `已中断复读 … rule=…` 外,还会多出 `丢弃复读原文 N 字`。 ### 八、后续可做(本轮未实施) 按收益排序: - **L1 定向催动**:把轮末那句「不要重复」换成「只允许两种输出——一次工具调用,或一句最终结论」。 退化发生在「要不要动手」的决策点,给一个确定动作比下禁令有效。 - **L4 请求前历史消毒**:在 pre-step 检查即将送给模型的尾部消息里有没有复读块,有就替换成占位符。 治「同一运行怎么重发都复读」,是本轮 L0 的运行内延伸(L0 只管新产生的,管不到已经落盘的)。 - **L2 降档重试**:第二次命中时用更低的 reasoning effort 重跑,或切备用渠道(退化与 high effort 强相关)。 - **L3 换执行者**:同轮第 3 次仍命中就停止同轮重试,交给干净上下文的子代理,或明确告知需求方—— 避免给需求方一个静默的「completed 空轮次」。