# loop-detector · 退化复读检测器 判断一段模型输出是**退化成复读了**还是**正常的模板化长文**。零依赖,Node ≥18,不绑定任何框架。 这个包是从 [`dsh-loop-breaker`](../README.md)(DSH 的流式复读熔断器)里抽出来的核心—— 插件负责"在哪掐断",它负责"凭什么判定"。任何 Agent 框架、任何脚本都能直接用它。 ## 为什么不能只看"重复率" 这是本项目最重要的一条结论,有实测数据支撑: | 材料 | 16-gram 回收率 | |---|---| | 40 个结构相同、只有函数名不同的函数体(**正常**的批量输出) | **0.808** | | 真实复读样本 `real_loop3`(**退化**) | 0.676 | **正常的模板化长输出重复得比真复读还多。** 任何架在两者之间的重复率阈值都只能选择犯哪一种错, 区分不开这两类。 能分开的是**重复单元的周期长度**: - 退化复读重复的是几到几十字的**无意义碎片**(周期 4–48 字); - 正常模板化输出重复的是一个函数体、一行表格(周期几百字)。 相差两个数量级。所以判据是「短周期**严格**重复」,而不是「高重复率」。 ## 判据一览 | 规则 | 抓什么形态 | 默认阈值 | |---|---|---| | `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 = 关闭) | 另有一个**零信息增量**信号(连续 400 字没产生任何新的 3-gram)**不单独触发**,只作为助推: 命中它时放宽上面两条的闸门(片段种类 ≤10、去重字符占比 <0.16)。 单独用会误杀批量同构内容——那确实没有新 3-gram,但不是退化。 「用字单调」与「碎片循环」都必须**叠加"片段种类极少"**这一条: 图表型文本(大量 `#` 与数字)用字同样单调,但每一行都不同,靠这道闸门排除在外。 ## 安装与使用 零依赖,直接把 `index.js` 拷进项目即可;也可以作为包使用。 ```js import { createLoopDetector } from './core/index.js' const detector = createLoopDetector() // 可选:传入配置覆盖 DEFAULTS // 流式场景:拿到一片就喂一片 for await (const chunk of stream) { const hit = detector.feed(chunk) // reasoning 文本用 feedReasoning() if (hit !== null) { console.log('命中复读:', hit.rule, hit.detail) break // 此处停止拉取上游即可省下后续 token } } // 非流式场景:整段喂进去也行 const result = createLoopDetector().feed(entireText) ``` 导出: | 导出 | 说明 | |---|---| | `createLoopDetector(config?)` | 创建检测器。返回 `{ feed, feedReasoning, tripped, evidence, size }` | | `isCharCollapsed(raw, config?)` | 单独判断一段文本是否已经「字面塌缩」(用字单调 + 碎片化) | | `DEFAULTS` | 全部默认阈值,可整体覆写 | `feed()` 的返回值是 `null`(尚未命中)或判定对象 `{ rule, detail, sample }`;命中后内部会锁定结论,重复调用返回同一对象。 ## 命令行 ```bash node cli.mjs 输出.txt # 读文件 cat 输出.txt | node cli.mjs # 读标准输入 node cli.mjs --self-test # 内置自检 node cli.mjs --json 输出.txt # 结构化输出 ``` 退出码:`0` 正常 / `2` 命中复读 / `1` 用法错误——方便接进脚本做断言。 ## 调参方向 | 症状 | 怎么调 | |---|---| | 误杀正常长输出 | 调高 `sentRecycle` / `gramRecycle16` / `tightRedundancy`,或调大 `windowChars` | | 误杀批量同构内容(清单、条款、近似函数) | 这类靠句子回收命中,调高 `sentRecycle` | | 误杀碎片循环判定 | 调高 `periodMatch`,或调低 `periodMax`(周期越小越像退化) | | 漏过复读 | 反向调低;或调小 `windowChars`(循环占比更高,更早命中) | 注:`periodMatch` 在核心 API 中会参与短周期分支比较;CLI 当前没有对应命令行选项。 | 只想要硬上限兜底 | 把 `sentRecycle`、`gramRecycle16`、`tightRedundancy`、`periodMatch` 设为 `1.01` | ## 已知边界(不是 bug,是文本层面的固有边界) - **结构性完全雷同的批量内容**(40 个结构相同、只有函数名不同的 handler、每节只换编号的条款)会被判为复读。 实测其 16-gram 回收率 0.808,高于真复读样本的 0.676——**任何阈值都分不开**。 需要这类输出时,调高 `sentRecycle` / `gramRecycle16`,或干脆关掉检测。 - **同一句短句在同一窗口内连续重复 4 次以上**(例如批量逐项报告「该项检查通过。」×20) 会命中 `tight-period-loop`。它在结构上与退化复读同型,统计量分不开。 - 判定是启发式的:它能让复读**早点结束**,不能让模型**变聪明**。 - 窗口 1600 字意味着复读要跑够一段才判定,这是「宁漏勿杀」的自觉取舍。 想要更早命中就调小 `windowChars`。 ## 测试 ```bash node cli.mjs --self-test # 包内自检 cd .. && node test.mjs # 正/负样本流式回归(仓库根) cd .. && node test-false-positive.mjs # 误杀对抗测试(仓库根) ``` `--self-test` 只做冒烟;完整回归在仓库根。两者都不需要联网或安装任何依赖。 ## 许可 MIT。见仓库根的 [LICENSE](../LICENSE)。