# dsh-loop-detector **DeepSeek Harness (dsh) 的死循环 / 重复输出检测插件。** [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) 本地大模型(尤其是量化版 / RL 微调的思考模型)有时会卡死:要么无限重复同一句话, 要么用换措辞的 query 反复搜索同一个主题却毫无新信息。本插件实时监听 agent 的输出流, 检测这两类循环,并**硬中断**——不是模型可以无视的软提醒。 English docs: [README.md](README.md) > **v2.0.0 适配 dsh 0.2.x。** 如果你在 dsh 0.2.x 上装过 v1 却"从没见它触发过",那是必然的, > 见下面一节。 ## 为什么 v1 在新版 dsh 上不工作 v1 是对着更早的 dsh 写的,在 0.2.x 上有三处失效(对应 v2 的三个修复): | 问题 | v1 的做法 | 0.2.x 的现实 | v2 的修法 | |---|---|---|---| | 收不到流式文本 | 监听 `session/event` 的 `assistant/chunk` | `assistant/chunk` 在 v2 会话事件模型里已不是实时事件(只剩旧格式迁移包在读它) | 改听 **`agent/assistant-stream`** | | 打断了但没用 | `agent.steer()` 当作中断手段 | `steer()` 只把消息排进**下一步**,不中止当前生成——模型会把整段循环文本吐完 | 先 `agent.cancel(cause, { keepInbox: true })` 掐断,再 `steer()` 引导 | | 重试次数永远耗不尽 | 在 `turn/end` 归零计数 | steer 每次都会开新 turn,计数刚涨就被清零 | 由**真实用户输入**归零(用消息 id 排除插件自己注入的引导消息) | 另外 v1 的意图级检测按"非字母数字"切词,一整句中文会退化成单个 token, **中文场景下永远判不出"换措辞重搜"**;v2 改为"停用字切分 + 中文 bigram + Jaccard 相似度"。 ## 功能 - **文本级重复检测** —— 监听 `agent/assistant-stream` 的 `text-delta` 与可选 `reasoning-delta`,三层检测: 1. **周期扫描**:尾部按固定周期(默认 8–256 字符)重复 4 次以上 2. **n-gram**:最近 2048 字符内同一 64 字符块出现 ≥3 次 3. **大段锚段**:尾部 512 字符在更早文本再次出现并向前扩展(抓"整段重复", 例如模型把同一段规划输出两遍) - **抗误报**:重复块必须有 ≥2 种字符、且实义字符(字母/数字/汉字)占比 ≥30%。 markdown 分隔线 `--------`、`|:---|:---|`、纯涂抹不会误判。 - **工具调用意图级重复检测** —— 监听 `tools/post-execute`,把每次调用的参数抽成关键词集合, 与"最近 3 次调用的关键词并集"比较;新词占比过低**或**相似度过高,连续 2 次即判定 "换措辞做同一件事"(字符串检测看不到的意图级循环)。`toolPatterns` 支持通配符, 可让 `grep` / 命令行等任意工具参与。 - **硬中断**:命中后真正掐断当前生成,并把引导消息交给模型重来; `maxRetries` 次仍循环则直接中止整个回合。 为什么需要硬中断?dsh 自带的 `dsh-repeat-tool-reminder` 只是**软提醒**(作为上下文注入)。 实测本地模型(如关思考的 Qwen / Ornith 35B MoE)会无视提醒继续循环——这正是本插件存在的意义。 ## 快速开始 前置:已运行的 dsh profile(`dsh 0.2.x`,Node ≥ 22.18,dsh 自带运行时满足)。 ### 方式一:本地 link 安装(开发/自用推荐) ```powershell # 在 profile 目录下把本仓库链接为依赖 cd $env:USERPROFILE\.dsh\profiles\desktop pnpm add link:"D:\path\to\dsh-loop-detector" ``` > **依赖解析约定**:本插件的运行时依赖(`@deepseek-ai/cordis`、`dsh-agent`、`dsh-llm`、 > `dsh-tools`、`schemastery`)全部声明在 **`peerDependencies`** 里。dsh 的 profile 解析器 > 对 `link:` 出来的插件是这样处理的:只要插件 `package.json` 的 `peerDependencies` 里出现了 > 某个包名,就把该请求路由到 **dsh 自带的安装副本**。所以不要把这些包挪进 `dependencies`—— > 那样 Node 会去插件的 `node_modules` 里找,找不到就会整个插件加载失败。 ### 方式二:直接复制 ```powershell Copy-Item -Recurse -Force "D:\path\to\dsh-loop-detector" ` "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-loop-detector" ``` ### 注册为 bundle(不做这一步,GUI 里看不到它) 插件自带 `dsh.bundle.patch`(指向仓库里的 [`cordis.patch.yml`](cordis.patch.yml)), 这就是它的自挂载 patch。所以只需要把包名加进 profile 的 `dsh.profile.bundles`: ```jsonc // $env:USERPROFILE\.dsh\profiles\desktop\package.json "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", // …已有的条目… "dsh-loop-detector" // ← 加上这一行 ] } } ``` **重启 dsh 生效**:插件会出现在「设置 → 插件 → 已安装」列表里,可以直接用那里的开关启停。 (只做 `pnpm add link:` 而不加进 bundles 的话,插件能用,但**不会出现在插件页**—— dsh 的「已安装」列表读的就是这个数组。) 想改配置(覆盖默认值)就写在 profile 的 `cordis.patch.yml` 末尾,按 id 覆盖即可—— 这样升级插件不会丢掉你的设置: ```yaml - id: loop-detector config: minLen: 1024 checkReasoning: false ``` 临时停用:用 GUI 开关,或在 profile patch 里写 ```yaml - id: loop-detector disabled: true ``` 然后故意让模型循环,日志会出现: ``` [loop-detector] session xxx 正文本命中: periodic repeat: period=8 x4|片段 "abcdefgh" [loop-detector] session xxx 检测到死循环(重试 1/1): periodic repeat: period=8 x4;已打断并引导重试 [loop-detector] session xxx 意图级重复: repeated web_search intent: novelty=0.29 similarity=0.71 ``` ## 配置项 ### 打断行为 | 字段 | 默认 | 含义 | |---|---|---| | `minLen` | `512` | 输出达到多少字符后才开始检测(防短文本误判)| | `maxRetries` | `1` | 打断并引导重试的次数;耗尽后强制中止 | | `action` | `'steer'` | `'steer'` = 打断 + 引导重试;`'cancel'` = 命中直接中止 | | `cooldownMs` | `1500` | 命中后的冷却时间,避免中止过程中的残余分片连环触发 | | `checkText` | `true` | 是否检测正文本 | | `checkReasoning` | `true` | 是否检测思考内容 | | `checkEvery` | `64` | 每新增多少字符才跑一次检测(节流,省 CPU)| | `bufferLimit` | `16384` | 每个流保留的尾部字符数(防缓冲无限增长)| ### 工具调用意图级重复 | 字段 | 默认 | 含义 | |---|---|---| | `toolPatterns` | `['web_search']` | 参与检测的工具名(`*` 通配符);空数组关闭该功能 | | `historySize` | `3` | "主题词集"取最近几次调用的关键词并集 | | `noveltyThreshold` | `0.25` | 新词占比低于该值算"没有新信息" | | `similarityThreshold` | `0.5` | 与主题词集的 Jaccard 相似度高于该值也算同一主题(中文常用)| | `stallLimit` | `2` | 连续几次"没有新信息"才判定为意图级循环 | ### 文本检测器微调(一般不用改) | 字段 | 默认 | 含义 | |---|---|---| | `minPeriod` / `maxPeriod` | `8` / `256` | 周期扫描范围 | | `periodRepeats` | `4` | 周期至少重复几次 | | `ngramSize` / `ngramRepeats` | `64` / `3` | n-gram 块长度与最少出现次数 | | `ngramWindow` / `ngramStep` | `2048` / `16` | n-gram 窗口与抽样步长 | | `anchorSize` / `anchorWindow` | `512` / `8192` | 大段锚段长度与窗口 | | `minDistinctChars` | `2` | 重复块至少要有几种不同字符(拦 `-----`)| | `minSubstantiveRatio` | `0.3` | 重复块实义字符最低占比(拦分隔线)| | `legacySessionEvents` | `false` | 旧版 dsh 兼容通道:改听 `session/event` 的 `assistant/chunk` | 非法配置(未知 `action`、`minLen < 1`、比例不在 `[0,1]`)会在挂载时**明确报错**,不会静默回落。 ## 检测原理 ### 文本级([`src/detector.ts`](src/detector.ts)) ``` 1) 周期扫描 尾部 periodRepeats 个周期逐字符一致, 周期 ∈ [minPeriod..maxPeriod] 2) n-gram 最近 ngramWindow 内同一 ngramSize 字符块出现 >= ngramRepeats 次 3) 大段锚段 尾部 anchorSize 字符在更早文本再现, 并向前扩展 >= anchorSize 每一层都要求重复块"有信息量": >= minDistinctChars 种字符 且 实义字符占比 >= minSubstantiveRatio ``` ### 工具调用意图级([`src/intent.ts`](src/intent.ts)) ``` 关键词 = 英文/数字按词 + 中文按"停用字切分"后的 bigram 主题词集 = 最近 historySize 次调用关键词的并集 新词占比 novelty = 新词数 / 本次关键词数 相似度 similarity = |交集| / |并集| (Jaccard) novelty < noveltyThreshold 或 similarity >= similarityThreshold -> 记一次"停滞" 连续 stallLimit 次停滞 -> 意图级重复 -> 打断 ``` 中文用 bigram 是因为按空格切词对中文完全无效;只用"新词占比"会漏判 (中文换措辞天然产生新 bigram),所以再加一条相似度判据。 ## 开发与测试 ```powershell npm test # node --test 原生跑 .ts (Node >= 22.18 的 TypeScript 类型剥离) ``` 39 项测试覆盖:三层检测器的命中和不误报(分隔线、正常散文、短文本)、 中英文关键词与意图判定、配置校验,以及**用真实 cordis Context 与 dsh-agent 作用域派发器** 验证 `agent/assistant-stream` / `agent/pre-step` / `tools/post-execute` 的接线与 "打断 + 引导 / 重试耗尽中止"的行为。 本地跑测试需要 `@deepseek-ai/cordis`、`@deepseek-ai/dsh-agent`、`@deepseek-ai/dsh-llm` 可解析。裸 `node --test` 不经过 dsh 的 profile 解析器,所以仓库里可能有一个被 `.gitignore` 忽略的 `node_modules` 链接(指向 dsh 安装目录的 `node_modules`)—— 它**只服务于本地测试**;dsh 运行时用的是自己的解析器,不依赖这个链接。 如果你在别处 clone 后想跑测试,可以自己建一个: ```powershell New-Item -ItemType Junction -Path .\node_modules ` -Target "$env:LOCALAPPDATA\Programs\dsh\resources\app\dsh\node_modules" # 换成你的 dsh 安装目录 ``` ## 局限 - 检测是启发式的,阈值请按你的模型调;本地小模型建议 `minLen` 调到 256~512 之间试。 - 意图级检测按"每次调用的参数字符串"比对,不做语义理解;`toolPatterns` 范围开太大 (例如 `'*'`)可能对正常的多样化工具调用产生误报,建议只列真正需要防循环的工具。 - 打断是**进程内**行为:dsh 重启、会话恢复后计数从零开始。 - `subagent` 各自独立计数(按 agent 分键),父子重复不会合并。 ## License MIT — 见 [LICENSE](LICENSE)。