🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

推理循环熔断 himi-li/dsh-reasoning-loop-guard

DeepSeek 客户端
himi-li16 天前有提交已提供中文文档扫描:含敏感能力兼容 / 相关生态

AI 使用说明 —— 本项目由 AI 助手协助整理撰写:源码、测试、文档与提交信息均由 AI 智能体DSH…

30.8/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/18 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

相关生态插件(非 dsh 原生,按其对应运行时安装)
git clone https://github.com/himi-li/dsh-reasoning-loop-guard.git

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么
生态插件
可安装,未声明 dsh 能力
装得上吗
本站已在 dsh 0.2.1-rc.5 下真实安装成功
非静态推断
L5 · 运行时冒烟等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 16 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查⚠ 装前注意

未发布到 npm registry,仅可从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包dsh-reasoning-loop-guard(未发布到 npm,仅可源码安装)
✓Node 引擎要求 ^22.19.0 || >=24.0.0 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/14 13:14:04

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 23 处,展示前 22 条)
  • 读取本地文件
    lib/index.js:46
    return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
  • 发起网络请求
    lib/client.js:527
    fetch(ROUTE + '?limit=' + String(PAGE_LIMIT))
  • 发起网络请求
    lib/client.js:577
    fetch(ROUTE, {
  • 发起网络请求
    lib/client.js:609
    fetch(ROUTE, {
  • 写入剪贴板
    lib/client.js:640
    Promise.resolve(ui.writeClipboard(path)).then(done).catch(() => {})
  • 发起网络请求
    lib/client.js:1307
    fetch(ROUTE + '?limit=1')
  • 写入本地文件
    lib/journal.js:239
    mkdirSync(dirname(path), { recursive: true });
  • 读取本地文件
    lib/journal.js:250
    if (!existsSync(path)) return;
  • 读取本地文件
    lib/journal.js:251
    if (statSync(path).size < maxBytes) return;
  • 删除本地文件
    lib/journal.js:253
    rmSync(backup, { force: true });
  • 写入本地文件
    lib/journal.js:254
    renameSync(path, backup);
  • 写入本地文件
    lib/journal.js:276
    appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
  • 读取本地文件
    lib/journal.js:293
    records = existsSync(path) ? parseJournal(readFileSync(path, "utf8")) : [];
  • 读取本地文件
    lib/journal.js:293
    records = existsSync(path) ? parseJournal(readFileSync(path, "utf8")) : [];
  • 读取本地文件
    lib/journal.js:314
    records = existsSync(path) ? parseJournal(readFileSync(path, "utf8")) : [];
  • 读取本地文件
    lib/journal.js:314
    records = existsSync(path) ? parseJournal(readFileSync(path, "utf8")) : [];
  • 删除本地文件
    lib/journal.js:362
    rmSync(target, { force: true });
  • 读取本地文件
    lib/settings.js:135
    raw = readFileSync(path, "utf8");
  • 写入本地文件
    lib/settings.js:175
    mkdirSync(dirname(path), { recursive: true });
  • 写入本地文件
    lib/settings.js:176
    writeFileSync(temp, payload, { encoding: "utf8", mode: 0o600 });
  • 写入本地文件
    lib/settings.js:177
    renameSync(temp, path);
  • 删除本地文件
    lib/settings.js:181
    rmSync(temp, { force: true });
扫描于 2026/11/3(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/schemastery
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 检测逐字重复推理循环
  • 提前截断推理流
  • 记录触发日志

检测 DSH LLM 流中逐字重复的推理循环并提前截断,零依赖 Cordis 插件,带触发日志和 reasoning_loop_log 工具。

README

由 DeepSeek 最新模型翻译生成
dsh-reasoning-loop-guard AI 使用说明 —— 本项目由 AI 助手协助整理撰写:源码、测试、文档与提交信息均由 AI 智能体(DSH Agent,运行于 DeepSeek Harness)在人类指导下起草;需求定义、方案决策与最终验收由人类作者完成。 检测并提前中断 DSH 中的推理复读——「思维链循环卡死」:模型其实已经想完了,却卡在「准备输出」上无限打转,白烧掉几分钟和几十万字符,直到用户放弃并按下停止。它把「静默卡死好几分钟」变成「立刻可见的报错」。 为什么需要它 在一次被完整记录的会话里(12 轮、324 步),12 轮中有 11 轮以完全相同的方式结束:模型早已做完真正的工程工作——看截图、算坐标、决定改哪个脚本——然后卡在「准备输出」阶段,反复催促自己却始终不落笔(Let me write. Go. OK. Emit. Now.)。单步输出 24–28 万字符、耗时 208 秒,直到用户按下停止。这 11 条 assistant 消息的 stopReason 都是空字符串,内容只有一个 reasoning 块——没有 text,也没有 tool-call:模型没产出任何可执行动作,只是在思考里空转。而 DSH 当时没有任何护栏能发现并提前叫停。 做法。
展开完整文档(README 剩余 1 节)
dsh-reasoning-loop-guard

AI 使用说明 —— 本项目由 AI 助手协助整理撰写:源码、测试、文档与提交信息均由 AI 智能体(DSH Agent,运行于 DeepSeek Harness)在人类指导下起草;需求定义、方案决策与最终验收由人类作者完成。

检测并提前中断 DSH 中的推理复读——「思维链循环卡死」:模型其实已经想完了,却卡在「准备输出」上无限打转,白烧掉几分钟和几十万字符,直到用户放弃并按下停止。它把「静默卡死好几分钟」变成「立刻可见的报错」。

为什么需要它

在一次被完整记录的会话里(12 轮、324 步),12 轮中有 11 轮以完全相同的方式结束:模型早已做完真正的工程工作——看截图、算坐标、决定改哪个脚本——然后卡在「准备输出」阶段,反复催促自己却始终不落笔(Let me write. Go. OK. Emit. Now.)。单步输出 24–28 万字符、耗时 208 秒,直到用户按下停止。这 11 条 assistant 消息的 stopReason 都是空字符串,内容只有一个 reasoning 块——没有 text,也没有 tool-call:模型没产出任何可执行动作,只是在思考里空转。而 DSH 当时没有任何护栏能发现并提前叫停。

做法。 插件挂在 llm/stream 瀑布钩子上(正是 DSH 自己的 llm-invariant 所用的同一个扩展点),只测量流式推理文本,每 200 字符在滚动缓冲区上评估五条判据;命中即停止向上游拉取,并发出终止块:

{ type: "finish", reason: { kind: "error", failure: { message, code: "REASONING_LOOP_GUARD" } } }

DSH 随后走它既有的 provider 错误路径:本步以一个可见错误结束,而不是静默空转。效果:记录在案的 11 次真实故障全部在 10,000 字符以内被拦住;在本机 2,799 条真实推理流(1,920 万字符)上只触发 7 次,且全部是真循环。

工作原理

每 every 个字符,在滚动缓冲区上评估五条判据:

| 判据 | 定义 | 实测峰值(11 正 / 166 负) | 阈值 |
| periodic-run(主判据) | 缓冲区尾部存在 ≥ minUnits 个连续相同的单元,周期 p ∈ [8, 400];单元本身是更短串的重复(xxxx…、abab…)时不算 | 正 0..8,负 0..2 | 4 |
| block-repeat | 同一 blockMin 字符的块在窗口内出现 ≥ blockCount 次,且相邻两次的间距全部相等 | 正 0..2,负 0..0 | 4 |
| line-repeat | 同一散文行(≥ lineMin 字符)出现 ≥ lineCount 次,且占窗口比例 ≥ lineShare;代码 / 标记 / diff 行被测量但从不被报告 | 正 2..2,负 0..0 | 3 + 10% |
| kgram-repeat | 末尾 kgram 个字符在窗口内出现 ≥ kgramThreshold 次 | 正 1..5,负 1..1 | 12 |
| filler-run(兜底) | 原始文本末尾连续 ≥ fillerRun 个装饰字符(空白 / 标点 / 符号) | 见下文「装饰不是复读」 | 400 |

五条判据是析取的:正样本不必触发某一条特定的判据,只要够到任意一条阈值即可。记录在案的 11 次故障里,2 次由 periodic-run 拦下、9 次由 line-repeat 拦下;负样本的最佳比值只到 0.50,正样本最低 1.00,分离点在 1.00。只测量 reasoning-delta,绝不测量 block-end——后者会重放整个块的文本,等于人为制造出它正要寻找的那种重复。也绝不测量 text-delta:正常的长输出(表格、代码)本来就可能重复,漏报好过误杀。

一条窗口内的共现不是复读。 上面三条计数判据最初只看「窗口里出现了几次」。一次真实会话里因此连续迎来 5 次误报,全是这个错误:模型在推理里反复引用同一行 Java 语句、几条并列的 runcode 命令共享同一段前缀、以及它真在逐字打出 36 个 x 的图块。真正的复读是时间里的重复——等距推进,或一直延续到尾部。判别量因此分成几层,见下文。

为什么 line-repeat 要三个条件

同一行出现两次就判循环在写代码、写文档时是常态——重构时同一个标识符落在两行,写 changelog 时写了两遍 ## Unreleased。这个问题没能在 177 样本定标集上暴露,因为那 11 正 + 166 负全是散文,根本不含「边写代码边推理」这一整个失败形态:在那个形态上,0/166 误报说明不了任何事。

真正的定标改在本机 2,799 条真实推理流(1,920 万字符,含 11 个已知真循环)上做。旧默认在该语料上触发 180 次(line-repeat 177、block-repeat 3),新默认下只剩 7 次且全部是真循环:
- 只看 count 不够。 语料里最常见的口头禅 Let me write. 最高能到 17 次,而最弱的真循环是 29 次,只有 1.7 倍余量,不足以押上「打断用户」的代价。
- share 才是判别量。 重复行占窗口的比例:健康流最高 6.1%,最弱真循环 10.2%。取 8% 会放进两条误报,取 12% 会漏掉真循环,所以默认 10%。
- count 也不能退回 2。 一条 72 字符的长散文行只出现 2 次就能把 share 顶到 18.7%,光靠 share 地板拦不住它。(这个论证原文用的是 41 字符长标识符,但标识符如今已被下面的第三条排除,故改用散文行。)
- block-repeat 只需把 blockCount 从 3 提到 4:3 会在健康推理引用长文本时触发(十六进制 dump、插件名清单、系统提示词复述),4 在语料上只剩 3 次触发且全为真循环。这里刻意不加 share 地板——从 0% 到 25% 结果都一样,加了只是没有数据支撑的复杂度。

第三条:被报告的行必须不是代码。 归一化剥掉空白、标点与符号,而这恰好是区分两行代码的全部信息。一次真实误报:801 字符的推理片段里有五行

if (e.isKeyboardEvent()) { (普通引用,出现三次)
- if (e.isKeyboardEvent()) { (diff 的删除行)
+ if (e.isKeyboardEvent()) { (diff 的新增行)

全部归一化成 ifeisKeyboardEvent,于是「同一行出现 5 次、占窗口 22.6%」,两条地板同时越过。而模型并没有循环——它在推理一处代码改动,下一个尝试是 4,084 字符的普通工作、一条判据都不触发。这个触发点又恰好落在 minChars 大小的第一个判定窗口里,那里几个重复已经占了很大比例。

任何阈值都够不着它:lineCount 要从 3 提到 6 才能挡下这一次,而 6 已在真循环占据的区间内;把 lineShare 抬过去则会牺牲夹具里的真实检测。所以规则改为不报告 CODE_LINE 认定为代码、标记或 diff 行的行,但仍把它们计入分母(只从分母里也删掉会反过来抬高代码密集流的占比,是同一个错误的镜像)。

| 候选做法 | 结果 |
| 抬高 lineCount / lineShare | 要么漏检,要么只是把门槛挪到别处 |
| 归一化前先剥 diff 标记 | 无效——STRIP 本来就剥 - / + 与空白,剥与不剥结果相同 |
| 干脆去掉归一化 | 无效——同一片段仍达 count=3 · share=11.3%,照常触发 |
| 按行的种类排除(采用) | 误报消失,且是分类排除、不是抬阈值 |

在本机 3,862 条真实推理流上复核:这条修复去掉该次误报与另一条同形态误报(从 HTML 里引用的 出现三次),其余零变化——夹具仍是 166 健康 0 误报 / 11 退化全命中,语料里两条 line-repeat 触发仍是同样的真循环(count 30 与 19,未变)。标记集刻意取窄:花括号、标记语言标签、行首 diff 标记,这些都不会出现在普通散文里;而更宽的做法(排除任何括号或运算符)会连 The config (see above) is wrong. 一起压制,等于让规则对真实的散文口头禅失明。

顺带修掉一个语义缺陷:line-repeat / block-repeat 原来在第一个达标的单元上就返回,所以日志里的 count 永远等于阈值本身,从未反映真实重复次数。现在都改为全扫描取最大重复单元,count 因此是真实值。

一次窗口不是一个循环:第二层判别

2026-10-18 的五次误报各自暴露出同一个前提错误,但需要三个不同的答案:

| 现场 | 旧判据看到的 | 实际发生的事 | 修法 |
| 推理里四次引用同一行 Java 语句 StarSystemAPI sys = (StarSystemAPI)Global…getContainingLocation(); | line-repeat:count=4、share=10.2%,刚过两条地板 | 四条引用散布在整个窗口里,间距 424 / 602 / 259,毫无节律 | 把该行认成代码——CODE_LINE 加上行尾分号与点号调用链 |
| 四条并列的 runcode 命令(加行星 / 加小行星带 / 加星云 / 加行星环)共享读当前星系的前缀 | block-repeat:同一 100 字符块出现 4 次 | 间距 1130 / 359 / 742,是并列命令而非复读 | 要求重复块等距出现(count 改报最长等距链长,原始次数留在 rawCount) |
| 模型在写 6×6 图块的字面量 "xxxxxxxx…" | periodic-run:units=4、period=8 | 单元 xxxxxxxx 本身就是同一个字符的重复——它在打字,不是在循环 | 单元是更短串的整数次重复时直接否决(isRepeatedUnit) |

三条都是分类判断,不是抬阈值。抬阈值已经被实测排除:lineCount 要从 3 提到 6 才挡得住第一例,而 6 已在真循环占据的区间内。

复核口径:在同一批语料上(chunk 7 / 41 / 200 三档各跑一遍),三处修改前后的触发集合逐字节相同(count 29 / 19 的两条 line-repeat、periodic-run 的两条),夹具仍是 166 健康 0 误报 / 11 退化全命中,而五条误报全部转为 inert。

为什么先做归一化

前四条判据都在归一化文本上计数:先把空白、标点、符号剥掉,再数重复。这一步不是美化,而是修一个真实的误报——最初的实现直接在原始文本上跑周期判据,于是模型画一条 ________ 分隔线就成了「8 个字符重复 6 次」,护栏真的中断了那次流。

清洗类是 [\s\p{P}\p{S}]。它是原先那份手写字符表的严格超集:_ 属于 \p{Pc},─ / ▁ 属于 \p{So},旧表都漏掉了。在全部 11 个真实退化样本上,新旧两类产出的文本逐字节相同——加宽只删掉了装饰,没有删掉任何一个真实循环赖以成立的字符。

装饰不是复读,但「一直在画装饰」是

归一化带来一个代价:一个只输出装饰的流,在四条计数判据眼里永远是空的。filler-run 就是为这一种情况存在的兜底,并且刻意做得很迟钝:

- 它锚定在文本末尾。 模型完全可以合法地画一张宽表格或 ASCII 图;一旦它接着写散文,那段装饰就不再位于尾部、不再计数。只有持续输出装饰的流才会触发——扫全窗口会在模型早已画完的图上误报。
- 门槛远高于任何合法排版。 实测合法形状的最长装饰段:150 宽的 ASCII 框 151 字符、20 列 markdown 表行 142、setext 下划线 62、--- 分隔线 5。默认值 400 是实测最宽合法形状的 2.6 倍,真正卡住的流会在一个 every 周期内越过它。

在六种喂入粒度下(chunk = 1 / 8 / 40 / 200 / 1000 / 4000),五条判据合计仍为 11/11 全部命中、0/166 零误报。

安装

本包尚未发布到 npm,从 GitHub 源码安装。DSH 按 profile 隔离:桌面版用 desktop,网页版用 web,装进哪个 profile 就只在那个形态下生效。

桌面版:必须用 DSH Desktop 内置的命令运行时(独立安装的 npm 版 dsh CLI 会拒绝 --profile desktop),且要先启动一次 Desktop 初始化它的 profile、完全退出应用,再执行
dsh plugin --profile desktop add github:himi-li/dsh-reasoning-loop-guard

网页版:任意 dsh CLI 均可;web profile 不存在时会从随附模板自动创建并初始化
dsh plugin --profile web add github:himi-li/dsh-reasoning-loop-guard

两种形态的命令只差一个名字。 通用格式是 dsh plugin --profile <name> add <包名>:web、headless、sdk、sdk-minimal、acp 首次使用时会从随附模板自动初始化,只有 desktop 需要「先启动一次、再完全退出」。同一个包可以分别装进多个 profile,互不影响。卸载用 dsh plugin --profile <name> remove dsh-reasoning-loop-guard。

这条命令把包装进 profile 的 dependencies,并自动把声明了 dsh.bundle 的包追加进 dsh.profile.bundles——两处都会写,不需要手改 package.json。本包不需要 allowBuilds 授权。 从 git 安装的插件通常要跑 prepare 构建脚本,而 pnpm ≥10 默认拒绝,用户得先往 pnpm-workspace.yaml 里写 allowBuilds 再重试;本包直接分发构建好的纯 JavaScript(lib/ 已入库),没有 prepare 脚本,首次 add 即可装成。

不想用 dsh plugin 的话,也可以自己在 profile 的 package.json 里写 dependencies(配 pnpm install)和 dsh.profile.bundles 两处,或者在 profile 的 cordis.patch.yml 里直接挂载(本包自带的 cordis.patch.yml 就是这一份)。dependencies 和 bundles 两处都要写:插件页只列出存在于 dependencies 里的包,而 bundles 决定它是否被装载。

改完需要重启 DSH,而不是只刷新页面。 两个独立原因:① 宿主对 node_modules 的 HMR 是关闭的(dsh-hmr 的 ignored 默认含 /node_modules),包内文件变动不会被监听;② Node 的 ESM 解析器在进程内永久缓存每个包的 exports 映射,本包新增 locale/.json 这类 export 之后,同一个宿主进程里永远解析不到它们——表现为插件页上的标题一直是英文包名,而图标(直读文件、不走 exports)却正常。

给包作者:不启动 DSH 也能确认元数据读得对(icon / locale 是否生效)——
node --input-type=module -e "import { readPluginMeta } from '@deepseek-ai/dsh-app-boot'; console.log(readPluginMeta('dsh-reasoning-loop-guard', 'file:///' + process.argv[1].replace(/\\/g,'/') + '/'))" "$PWD"

配置

所有字段都可以在 profile 的 cordis.patch.yml 里覆盖:

- insert:
- id: reasoning-loop-guard
name: dsh-reasoning-loop-guard
config:
enabled: true
minUnits: 4 # periodic-run:连续重复单元数 kgramThreshold: 12 # kgram-repeat:尾部 k-gram 的出现次数 lineCount: 3 # line-repeat:重复行出现次数 lineShare: 0.1 # line-repeat:重复行占窗口的最低比例 blockCount: 4 # block-repeat:同一块的重复次数 fillerRun: 400 # filler-run:末尾连续装饰字符数 minChars: 800 # 低于这么多字符不做判定 every: 200 # 每 N 个字符评估一次 failureCode: REASONING_LOOP_GUARD

触发日志(见下) journal: true journalPath: "" # 默认:$DSH_HOME/dsh-reasoning-loop-guard/fires.jsonl journalMaxBytes: 524288 journalPreviewChars: 120 logTool: true # 注册 reasoning_loop_log 工具

功能开关(见「功能开关」) settingsPath: "" # 默认:$DSH_HOME/dsh-reasoning-loop-guard/config.json(只在 patch 里设) recovery: enabled: false # 中断后追加纠正消息并重跑同一步 message: "" # 留空使用内置文案 maxRetries: 2 # 每步最多重试几次(0–10) effort: enabled: false # 按档位回写 reasoningEffort value: low # off | low | high | max stripHistory: enabled: false # 剥离历史轮的思维链,只留活跃轮 notify: enabled: true # 每次中断后在角落弹一条提示

| 字段 | 默认值 | 含义 |
| enabled | true | 总开关。patch 里为 false 时插件完全不注册任何流钩子(只读一次,改了要重启);设置文件里同名键由流监听器逐请求读取,关掉下一次请求就生效。 |
| minChars / every | 800 / 200 | 短于此长度的推理永不判定;判定节奏,单位为字符。 |
| window / kgram / kgramThreshold | 4096 / 64 / 12 | k-gram 判据的滚动窗口、跟踪的尾部片段长度、在窗口内出现多少次才触发。 |
| periodTail / minPeriod / maxPeriod / minUnits | 1200 / 8 / 400 / 4 | 周期判据的滚动窗口、周期搜索范围、触发所需的连续相同单元数。 |
| blockMin / blockCount | 100 / 4 | block-repeat 的块长度与出现次数。 |
| lineMin / lineCount / lineShare | 10 / 3 / 0.1 | line-repeat 的行长度、出现次数,以及重复行至少要占窗口的比例。 |
| fillerRun | 400 | filler-run:末尾连续装饰字符达到多少才判定为卡死。 |
| failureCode | REASONING_LOOP_GUARD | 终止块携带的失败码。刻意带命名空间(见「关键设计决策」)。 |
| journal / journalPath / journalMaxBytes / journalPreviewChars | true / "" / 524288 / 120 | 是否记录、日志位置、轮转大小、「肇事尾巴」保存字符数。 |
| logTool | true | 注册 reasoning_loop_log 工具。 |
| settingsPath | "" | 功能开关配置文件位置;只在 patch 里设。 |
| recovery. | false / "" / 2 | 中断一次流之后追加纠正消息并重跑同一步;每步最多自动恢复几次(0–10)。 |
| effort. | false / low | 按档位回写请求的 reasoningEffort:off / low / high / max。 |
| stripHistory.enabled | false | 剥离历史轮的思维链,只保留活跃轮。 |
| notify.enabled | true | 每次中断后,在 web 界面的角落弹一条短提示,说明是哪条判据触发的。 |

validateConfig() 会拒绝那些会静默失效的配置——kgram > window、minPeriod >= maxPeriod、periodTail < 2 maxPeriod、failureCode 为空、every 非正数等等——并在报错信息里点名字段。

功能开关

上表里的 enabled、settingsPath 与其余开关的七个字段也可以在 GUI 里改——settingsPath 例外,它只在 patch 里设,GUI 只读显示。在插件页打开本插件,详情页的「触发日志」面板下方有六个区块:循环守卫(检测核心)、自动恢复、降低思考强度、剥离历史思维链、中断时提示,以及只读的配置文件路径。改完点保存,值写进 $DSH_HOME/dsh-reasoning-loop-guard/config.json。

检测核心与中断提示默认开启,三个可选臂默认关闭。 核心开关是插件本身;提示开关只往屏幕上弹一条提示、不改变任何发给 provider 的内容,所以也默认开着。三条臂都改变了护栏之外的行为,默认取保守的一侧。关掉检测核心,三条臂随之失效:它们只可能响应守卫自己抛出的失败,而守卫不再观察任何流之后就不会再抛出这种失败。
核心开关与 patch 里的同名键不是一回事,但不会互相打架。 patch 里的 enabled: false 表示「整插件不注册任何钩子」,只在装载时读一次;设置文件里的 enabled 由流监听器逐请求读取。判定写成「显式 false 才算关」:键缺失表示「按出厂设置」,也就是开着——这样旧的 config.json 不会被误读成关闭。保存后不需要重启:config.json 逐键覆盖 patch 配置,每次读取都重新折叠,下一次请求就生效。写入是原子的(临时文件 + rename),读侧对缺失或损坏的 config.json 一律回落到基线,绝不抛异常——一个写坏的设置文件最多让开关回到默认值,不会让护栏变成 inactive。GUI 只能写这八个键,检测阈值永远留在 patch 里,这样「这次触发的阈值到底来自哪个文件」不会变成一个需要猜的问题。

四个开关各自的机制与代价:

- 自动恢复(recovery.)。守卫中断一次流之后,向会话追加一条纠正消息并重跑同一步。为什么必须先追加消息:重试会重发完全相同的请求(buildRequest 从会话日志重新推导,被中断的 assistant/attempt 不参与推导),所以不带新消息的裸重试必然复现同一个循环。重试预算按步计(sessionId:turn:step)——按会话计会让一轮坏掉就永久禁用,按 attempt 计会让 maxRetries 失去意义。这条消息带的是产出方自己的 source.kind(会话格式 v4 拒绝 kind: "plugin"),副作用是 chat UI 会把它渲染成上下文注记而不是用户气泡。本臂的任何内部故障都落到 next(),失败保持与没有插件时一样终局。
- 降低思考强度(effort.)。按档位回写请求的 reasoningEffort。这是针对病因的一臂——复读是推理档位太高时的症状,护栏本身只缓解症状——所以如果你的 provider 支持更低的档位,这一臂比护栏更根本。它必须跑在 dsh-agent 自己的 agent/request 监听器之外(核心会重新推导 provider / model / reasoningEffort 并丢掉我们设的值),因此用 { prepend: true } 注册并在 next() 之后回写。
- 中断时提示(notify.enabled)。每次守卫中断一条流,在窗口角落弹一条 Toast,说明是哪条判据、以及它的测量值。它不改变任何发给 provider 的东西,也不改变纠正消息的发送者或内容——只是把「刚刚发生过一次中断」这件事说出来。宿主侧新增一条 SSE 路由 GET /reasoning-loop-guard/events(走的是与日志路由同一道同源回环栅栏,被拒时同样 403),触发时推一帧 {"type":"fire","record":…};web 半开一个 EventSource 订阅它。为什么不是轮询日志:要么间隔短到每秒一个请求,要么延迟久到用户会把这次中断归到别处;为什么通道不是日志:日志关掉或写失败时提示照样要发——日志是事后记录,这是实时信号。开关每次触发时重新读取,所以页面开着不动,改设置也立刻生效。
- 剥离历史思维链(stripHistory.enabled)。DeepSeek 适配器会把历史 assistant 消息的 reasoning 作为线上 thinking 块回传,于是第 n 轮模型会读到 1..n-1 轮自己的思维链(包括开启循环的那句「让我再验证一遍」),回放历史还会撑大 prompt 前缀、损害 KV 缓存复用。边界按「最后一个真实用户轮」切:之前的 reasoning 是历史、丢弃,之后属于活跃轮、保留。搞错不是装饰性 bug——DeepSeek 会直接拒绝移除了活跃轮 reasoning 的 thinking 请求(The reasoning_content in the thinking mode must be passed back to the API.),而 DSH 把工具结果作为 user 角色消息投递,所以朴素的「最后一条 user 消息」边界会落在工具循环中间并剥掉活跃轮。它的代价要明说:交给 llm/stream 的 request 对象是冻结的且 next() 不接参数,改 messages 只能带着新 options 对象重入 runtime,而该新对象不在 AGENT_LOOP_REQUESTS weak set 里,于是 dsh-agent-loop 的「请求仍与会话日志推导一致」不变量对它被跳过——这正是它默认关闭的原因,也是这一臂只删内容、绝不发明内容的原因。

触发日志、GUI 面板与 reasoning_loop_log

在 DSH 的插件页打开本插件,详情页里会多出一个「触发日志」面板:最近的触发记录(时间、判据、模型、已读字符数、肇事尾巴预览)、按判据与按模型的汇总、一键清空,以及日志路径的复制按钮;没有记录时显示明确的空态文案。列表一次取一页、最多 100 条,最新在前,最新一条默认展开;工具栏有全部展开 / 全部收起(逐条详情)与收起列表 / 展开列表(整个列表连同行头),两级折叠各管一层、互不覆盖。

与护栏同源的两半都在本包内:

| 文件 | 作用 |
| lib/log-route.js | 向宿主的 webServer 注册 GET /reasoning-loop-guard/log,返回 { path, enabled, version, settings, stats, total, matched, entries };支持 limit / rule / sessionId / since 查询,POST 只接受 {"action":"clear"} 与 {"action":"set","patch":{…}} 两个显式变更(都是破坏性的,刻意不能由链接或预取触发)。 |
| lib/events.js | 进程内的触发广播 + GET /reasoning-loop-guard/events 的 SSE 端点,供角落那条提示订阅。订阅者抛异常只摘掉它自己;路由被销毁时一并断开它持有的连接。 |
| lib/client.js | 手写的惰性 CJS bundle(零构建步骤),以包名为键注册 plugins.bundle.config 槽位并渲染卡片,另在 shell.overlay 槽位挂一个 Toast,只在触发时出现;运行时只向平台种子表 require 两个词:react 与 @deepseek-ai/dsh-client-ui-primitives。 |

两条路由都自带同源栅栏。 宿主的 webServer 不提供任何鉴权,所以这道栅栏由插件自己写:非回环 Host、Sec-Fetch-Site: cross-site、或与 Host 不同源的 Origin,一律 403;日志路由的请求体上限 16 KiB(超出回 413 并断开),非 GET/POST 回 405;事件路由只接受 GET/HEAD。

若你的宿主根本没提供 webServer 服务,ctx.inject(["webServer"], …) 会静默跳过两条路由的注册,护栏本体照常工作。这是刻意的:一个诊断面板与一条提示都不该让护栏变成 inactive。

护栏每触发一次,就向 $DSH_HOME/dsh-reasoning-loop-guard/fires.jsonl 追加一行 JSON:

{"v":1,"at":1760000000000,"iso":"2026-10-06T15:20:00.000Z","rule":"periodic-run",
"atChars":3120,"failureCode":"REASONING_LOOP_GUARD","pluginVersion":"0.2.0",
"sessionId":"...","provider":"...","model":"...","purpose":"...",
"reasoningEffort":"max","turn":17,"step":2,"attemptId":"...","cwd":"...",
"units":6,"period":64,"elapsedMs":4210,"fromStartMs":18730,"ttftMs":14520,
"reasoningChars":3120,"aborted":false,
"preview":"Let me write. Go. OK. Emit. Now. …","previewRaw":"…",
"thresholds":{"minChars":800,"every":200,"window":4096,"kgram":64,"kgramThreshold":12,
"periodTail":1200,"minPeriod":8,"maxPeriod":400,"minUnits":4,"blockMin":100,
"blockCount":4,"lineMin":10,"lineCount":3,"lineShare":0.1,"fillerRun":400}}

未知字段会直接省略,因此旧记录只是字段更少,不会写成 null。几个值得留意的:ttftMs 把「模型很慢、然后才开始打转」和「从第一个 token 就在打转」分开;aborted 记录判定落地前调用方是否已经放弃;thresholds 是产生这次判定的确切配置——几个月后要复盘一次误报,靠的就是它;previewRaw 只在 preview 被截断时出现。iso 存 UTC,界面一律显示本地时间:落盘的 iso 是规范的 UTC 形式(因为它必须无歧义),而面板的行时间与「时间跨度」、reasoning_loop_log 的 list 行与 stats 的 span: 行都经同一个 localStamp() 渲染,stats 的「按天」也按本地日切分。

日志是有界的(journalMaxBytes,默认 512 KiB → 轮转为 fires.jsonl.1),每次写入都包在 try/catch 里,日志故障只会告警,绝不影响流。设为 journal: false 可整体关闭。插件还会注册一个只读的维护工具 reasoning_loop_log:

| action | 返回 |
| list(默认) | 最近的触发记录,最新在前。可按 rule、sessionId、since(epoch 毫秒)过滤,用 limit 限制条数。 |
| stats | 汇总:总数、按判据、按模型、按天,以及最早/最晚时间戳。 |
| path | 解析后的日志路径。 |
| clear | 删除日志(连同轮转出的 .1)。 |

于是「最近到底有没有在触发、是在哪个模型上触发的?」只需一次工具调用,而不用去会话日志里翻。

关键设计决策

failureCode 故意放在默认可重试集合之外。 可重试的失败码是 EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT,REASONING_LOOP_GUARD 不在其中,因此 dsh-llm-retry 不会自动重试。原因是:复读是这次请求本身的性质,自动重试会把整个 prompt 再发一遍,白烧同样的 token 再复读一次。如果你确实想要重试,把 failureCode 设为 EMPTY_RESPONSE。
failureCode 默认带命名空间,是为了不和别的插件串台。 0.2.0 之前默认值是通用的 REASONING_LOOP,而其他插件也会自带循环检测,并按失败码驱动自己的恢复逻辑。本机实测到的真实冲突:dsh-our-free-model v2.0.0 在 vendor/channel-pack/pack.js 里编进了自己的 loop-recovery,用 error.code === "REASONING_LOOP" 精确匹配决定是否自动续跑——于是本守卫的中断被它当成自己的失败捕获,随即用 agent.followup() 注入一条 source.kind: "user" 的消息重排了一整轮。后果有两个且都很难查:① 本插件的三个功能开关全关时,看起来仍然「自动触发了」——因为续跑根本不来自本插件;② 用户点停止按钮也停不下来——按钮只中止当前流,那条已经排队的续跑消息照样会跑起来。改成 REASONING_LOOP_GUARD 后精确匹配不再命中。这类串扰无法从原理上根除:命名空间只挡住了精确匹配通用码的逻辑,若某个插件改成前缀匹配、或也去认 REASONING_LOOP_GUARD,冲突会回来——那时只能把 failureCode 改到一个双方都不认的值。反向也要注意:别的插件自己检测到的循环仍会走它自己的恢复路径,本插件的开关管不到它。

应急止血:环境变量。 又遇到「开关全关却自动续跑、停止按钮也停不下来」时,可以先用环境变量把第三方那一侧关掉,不必等本插件发新版:

| 环境变量 | 设为 | 作用 |
| DSH_REASONING_LOOP_GUARD | 0 | 关掉第三方插件自带的循环检测。 |
| DSH_REASONING_LOOP_AUTO_RESUME | 0 | 关掉第三方插件的自动续跑。 |

两个变量都在第三方插件里以「未设即开」的方式读取(只在值为 0 / false / no / off 时判为关),所以必须显式设成 0。它们只影响那个插件自己的行为,不影响本守卫——本插件不读任何 DSH_ 环境变量。设成系统/用户环境变量,不要写进 ~/.dsh/.env(DSH 的启动器把 DSH_ 前缀列为「只允许启动环境设置」的保留前缀,写在 .env 里会被直接拒绝):

当前用户级,永久生效。必须注销重登(或重启系统)才生效,只重启 DSH 不够——环境变量在登录时由 explorer.exe 一次性广播给进程环境块,已有进程不会收到后写入的值。
[Environment]::SetEnvironmentVariable('DSH_REASONING_LOOP_GUARD', '0', 'User')
[Environment]::SetEnvironmentVariable('DSH_REASONING_LOOP_AUTO_RESUME', '0', 'User')

每条流都新建一个检测器。 自动重试会从零开始计数,上一次尝试的重复不会累积到下一次。

对工具服务没有硬依赖。 工具是通过 ctx.inject(["tools"], …) 注册的——一种可选注入。若写成硬 inject,那么在任何没有 tools 服务的宿主上插件都会变成 inactive,等于为了一个诊断功能而把护栏本身也关掉了。

两条可选臂注入的服务名是 agents,不是 agent。 @deepseek-ai/dsh-agent 把自己的注册表注册为 super(ctx, "agents"),所以 ctx.inject(["agent"], …) 永远解析不到:Cordis 在依赖不满足时不会调用回调,连一条告警都不会有。0.2.2 及以前正是写成了单数 agent,后果是这两条臂从未注册,而 GUI 开关照旧显示「开」。单数 agent 只是该插件在 typert 里注册的 wire 类型名,不是 Cordis 服务名。这条已由 test/smoke/smoke.mjs 的两组断言钉住。

零运行时依赖。 除了声明为 peer 的 DSH 宿主包之外,插件不导入任何东西。

测试

npm test

九套测试,必须全部通过:

| 套件 | 覆盖内容 |
| test/test-guard.mjs | 六种 chunk 大小下的检测器定标、分离度、guardStream 协议一致性(恰好一个终止 finish、提前停止、已中止信号的处理、健康流不被改动)、消息渲染,以及「共现不是复读」的三条分类断言(代码行不被报告、非等距的块只算 2、非原始单元不算周期)。 |
| test/test-journal.mjs | $DSH_HOME 解析、preview 截断、记录形状、解析容错、过滤、stats 聚合、轮转,以及「日志故障永不抛异常」这条保证。 |
| test/test-settings.mjs | 功能开关的两条承重性质:保存对下一次 get() 可见,以及坏文件表现得像全新安装、绝不像崩溃。 |
| test/test-recovery.mjs | 自动恢复臂的契约全在何时行动:禁用时让路、只认护栏自己的失败、返回 {kind:"retry"} 前必须追加良构消息、必须耗尽每步预算、任何内部故障都要落到 next()。 |
| test/test-strip.mjs | 剥离历史思维链臂的边界判定——每条检查都因「剥掉活跃轮 reasoning 会被 DeepSeek 拒绝」这一失败模式而存在。 |
| test/test-events.mjs | 触发广播与 SSE 端点:一个抛异常的订阅者只摘掉自己、同源栅栏与方法限制、断开时解绑、开关逐次重读(页面开着改设置立刻生效)、销毁路由时连同连接一起断开。 |
| test/test-card.mjs | 日志路由的同源栅栏判定、方法与查询参数、上限与关闭态、注册走服务,以及客户端 bundle 的协议形态、卡片的纯函数,和角落提示的帧解析与 key 重播。 |
| test/smoke/smoke.mjs | 用桩宿主驱动真实的 apply():配置校验、全局只注册一个 llm/stream 监听器、工具注册、端到端行为,以及可选注入的服务名(必须索要 agents,不得索要单数 agent)。 |
| test/smoke/real-protocol.mjs | 真实的 @deepseek-ai/dsh-llm 不变量校验门,断言护栏的输出是一条合法的流(该构建若没有独立的 invariant.js,这一项自动降级为 skip 而不是失败)。 |

两个 smoke 套件通过 test/smoke/resolve-hook.mjs 把 @deepseek-ai/ 解析到 app 与 profile 的安装位置,因此不必启动 DSH 就能验证宿主侧的那一半。

开发期间护栏还额外用真实的 dsh 命令行驱动一个真实的智能体循环做过端到端验证(隔离配置档,默认模型是一个只用于测试的适配器,回放一段退化推理流),两臂十一项断言:护栏开启时退出码 1、上游只发出 3120 / 7659 字符、stderr 是 REASONING_LOOP_GUARD: …;护栏关闭时同一条流退出码 0、7659 / 7659 字符完整跑完、stdout 输出 TG-FAKE-OK。也就是说护栏确实把上游生成器截断了,而关掉后同一流毫发无损——排除了「测试装置本身是坏的」。这套端到端装置属于开发脚手架,不随包发布。

测试夹具

护栏的阈值是针对一次真实故障定标的,但那段会话的推理文本属于隐私,因此仓库里提交的 test/fixtures/ 夹具是合成的。它们复现了原始数据实测出来的形状——相同的行数(11 / 166)、相同的逐行字符数(从一条 24.4 万字符的大块,到 502 字符的小块),以及相同的复读几何。生成器带随机种子且完全确定,因此重新生成会产出逐字节相同的文件,任何 diff 都是真实变更。

已知边界

- 它不是通用看门狗。 这几条判据针对的是一种特定的退化形态——尾部连续重复。换一种卡法(无限工具调用循环,或模型在语义上绕圈但并不逐字重复)不会触发它。
- 首次触发的位置取决于喂入粒度:最早约 3000 字符,最晚约 56000 字符。记录在案的 11 次故障都会在 10,000 字符以内被拦住。
- 定标样本曾是单个会话的 177 条,样本量偏小——已知有盲区。 它全是散文,不含「边写代码边推理」这一形态,因此在那上面报出的 0/166 误报不能外推;line-repeat / block-repeat 的阈值后来改用数千条真实语料重新定标(见「为什么 line-repeat 要三个条件」)。若实践中仍出现误报,优先调高 lineShare(比例地板)或 minUnits / kgramThreshold;若误报来自装饰(比如你的模型习惯画很宽的图),调高 fillerRun。若误报来自代码或标记语言,说明 CODE_LINE 的标记集还不够——那是要改代码、而不是调阈值的信号。
- 只输出装饰的流会被兜底判据拦下,阈值可调。 默认 400 已高于实测最宽合法排版(151),但如果你的场景里合法图形更长,把它调高即可。
- 护栏本身只缓解症状。 如果你的服务商支持更低的推理档位,那才是针对病因——可以打开降低思考强度开关,或者在你的 DSH 配置里直接调低。
- 检测核心与三个功能臂的开关语义不同,别混淆。 检测核心默认开启,它就是插件本身;中断提示也默认开启,它只往屏幕上写一行;三条臂默认关闭,代价在各自小节里写明——尤其 stripHistory 会让 dsh-agent-loop 的「请求仍与会话日志推导一致」不变量被跳过,recovery 会向会话追加一条消息。
- 失败码与第三方插件的串扰已由命名空间修掉,但同类冲突无法从原理上根除。 又遇到串扰时可先用 DSH_REASONING_LOOP_GUARD=0 / DSH_REASONING_LOOP_AUTO_RESUME=0 应急(见「关键设计决策」)。

许可证

MIT
预览分享卡片长什么样 ↓himi-li/dsh-reasoning-loop-guard
分享 · DeepSeek Harness Hub
himi-li/dsh-reasoning-loop-guard

AI 使用说明 —— 本项目由 AI 助手协助整理撰写:源码、测试、文档与提交信息均由 AI 智能体DSH…

这是你的仓库?认领条目

回到顶部