dsh-reasoning-loop-guard
Detects verbatim reasoning loops in DSH LLM streams and cuts them early. Zero-dependency Cordis plugin with a fire journal and a reasoning_loop_log tool.
- Stars
- 1
- Language
- JavaScript
- Created
- Oct 6, 2026
- Updated
- Oct 6, 2026
Introduction
dsh-reasoning-loop-guard
简体中文 | English
AI 使用说明 —— 本项目由 AI 助手协助整理撰写:源码、测试、文档与提交信息均由 AI 智能体(DSH Agent,运行于 DeepSeek Harness)在人类指导下起草;需求定义、方案决策与最终验收由人类作者完成。
检测并提前中断 DSH 中的推理复读——也就是「思维链循环卡死」:模型其实已经想完了,却卡在「准备输出」上无限打转,白烧掉几分钟和几十万字符,直到用户放弃并按下停止。
它把「静默卡死好几分钟」变成「立刻可见的报错」。
它解决的问题
在一次被完整记录的会话里(12 轮、324 步),12 轮中有 11 轮以完全相同的方式结束:
- 模型早已做完真正的工程工作——看截图、算坐标、决定改哪个脚本。
- 然后它卡在「准备输出」阶段,反复催促自己却始终不落笔:
Let me write. Go. OK. Emit. Now./Writing. OK. Let me write. Go. - 单步输出了 240,000–280,000 字符,耗时 208 秒,直到用户按下停止才结束。
这 11 条 assistant 消息的 stopReason 都是空字符串,内容只有一个 reasoning 块——没有 text,也没有 tool-call。模型没有产出任何一个可执行动作,它只是在自己的思考里空转。
而 DSH 当时没有任何护栏能发现这一点并提前叫停。
工作原理
插件挂载在 llm/stream 瀑布钩子上——正是 DSH 自己的 llm-invariant 校验所用的同一个扩展点——只测量流式推理文本,并在判定成立的瞬间停止向上游拉取,随后发出终止块:
{ type: "finish", reason: { kind: "error", failure: { message, code: "REASONING_LOOP" } } }
DSH 随后走它既有的 provider 错误路径处理:本步以一个可见错误结束,而不是静默空转。
每 every 个字符,在滚动缓冲区上评估五条判据:
| 判据 | 定义 | 实测峰值(11 正样本 / 166 负样本) | 阈值 |
|---|---|---|---|
periodic-run(主判据) | 缓冲区尾部存在 ≥ minUnits 个连续相同的单元,周期 p ∈ [8, 400] | 正样本 0..8 个单元,负样本 0..2 | 4 |
block-repeat | 同一 blockMin 字符的块在窗口内出现 ≥ blockCount 次(跨行、跨格式) | 正样本 0..2,负样本 0..0 | 4 |
line-repeat | 同一行(≥ lineMin 个字符)出现 ≥ lineCount 次,且这些重复行占窗口的比例 ≥ lineShare | 正样本 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。
为什么 line-repeat 要两个条件
「同一行出现两次就判循环」在写代码、写文档时是常态——重构时同一个标识符落在两行,写 changelog 时写了两遍 ## Unreleased,都会中断用户的流。这个问题没能在 177 样本定标集上暴露,因为那 11 正 + 166 负全是散文,根本不含「边写代码边推理」这一整个失败形态:在那个形态上,0/166 误报说明不了任何事。
真正的定标改在本机 2799 条真实推理流(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。 一个 41 字符的长标识符只出现 2 次就能把 share 顶到 19.5%,光靠 share 地板拦不住它。取 3 与取 4 在语料上完全等价,取 3 留余量。
block-repeat只需把blockCount从 3 提到 4:3 会在健康推理引用长文本时触发(十六进制 dump、插件名清单、系统提示词复述),4 在语料上只剩 3 次触发且全为真循环。这里刻意不加 share 地板——从 0% 到 25% 结果都一样,加了只是没有数据支撑的复杂度。
顺带修掉一个语义缺陷:这两条规则原来在第一个达标的单元上就返回,所以日志里的 count 永远等于阈值本身,从未反映真实重复次数。现在都改为全扫描取最大重复单元,count 因此是真实值。
为什么先做归一化
前四条判据都在归一化文本上计数:先把空白、标点、符号剥掉,再数重复。这一步不是美化,而是修一个真实的误报——最初的实现直接在原始文本上跑周期判据,于是模型画一条 ________ 分隔线就成了「8 个字符重复 6 次」,护栏真的中断了那次流,日志里留下一条 8 个下划线的记录。
清洗类是 [\s\p{P}\p{S}]。它是原先那份手写字符表的严格超集:_ 属于 \p{Pc}(连接符标点),─ / ▁ 属于 \p{So},旧表都漏掉了。在全部 11 个真实退化样本上,新旧两类产出的文本逐字节相同——也就是说这次加宽只删掉了装饰,没有删掉任何一个真实循环赖以成立的字符。
装饰不是复读,但「一直在画装饰」是
归一化带来一个代价:一个只输出装饰的流,在四条计数判据眼里永远是空的,可以无限跑下去。filler-run 就是为这一种情况存在的兜底判据,并且刻意做得很迟钝:
- 它锚定在文本末尾。 模型完全可以合法地画一张宽表格或 ASCII 图;一旦它接着写散文,那段装饰就不再位于尾部、不再计数。只有持续输出装饰的流才会触发——扫全窗口会在模型早已画完的图上误报。
- 门槛远高于任何合法排版。 实测合法形状的最长装饰段:150 宽的 ASCII 框 151 字符、20 列 markdown 表行 142、setext 下划线 62、
---分隔线 5;而当初那条误报只有 8 个下划线。默认值 400 是实测最宽合法形状的 2.6 倍,真正卡住的流会在一个every周期内越过它。
在六种喂入粒度下(chunk = 1 / 8 / 40 / 200 / 1000 / 4000),五条判据合计仍为 11/11 全部命中、0/166 零误报。
只测量 reasoning-delta,绝不测量 block-end——后者会重放整个块的文本,等于人为制造出它正要寻找的那种重复。也绝不测量 text-delta:正常的长输出(表格、代码)本来就可能重复,漏报好过误杀。
安装
本包尚未发布到 npm,从 GitHub 源码安装:
# 在你的 DSH profile 目录下执行(例如 ~/.dsh/profiles/desktop)
npm install github:himi-li/dsh-reasoning-loop-guard
然后在 profile 的 package.json 里注册 bundle:
{
"dependencies": {
"dsh-reasoning-loop-guard": "github:himi-li/dsh-reasoning-loop-guard"
},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "...", "dsh-reasoning-loop-guard"]
}
}
}
dependencies 和 bundles 两处都要写。 DSH 的插件页只列出存在于 profile dependencies 里的包(这是「已安装」的判据),而 bundles 决定它是否被装载。只写 bundles 也能跑,但卡片不会出现在插件页上。
装载方式有两种。用上面那行 dependencies(配合 pnpm install)即可;也可以直接在 profile 的 cordis.patch.yml 里挂载(本包自带的 cordis.patch.yml 就是这一份):
- insert:
- id: reasoning-loop-guard
name: dsh-reasoning-loop-guard
config: {}
改完需要重启 DSH,而不是只刷新页面。 有两个各自独立的原因:
- 宿主对
node_modules的 HMR 是关闭的(dsh-hmr的ignored默认含**/node_modules),所以包内文件的变动不会被监听到。 - Node 的 ESM 解析器在进程内永久缓存每个包的
exports映射。本包新增locale/*.json这类 export 之后,同一个宿主进程里永远解析不到它们——表现为插件页上的标题一直是英文包名,而图标(直读文件、不走 exports)却正常。只有重启才能让宿主重读 manifest。
给包作者看的验证命令,无需启动 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
# 触发日志(见下)
journal: true
journalPath: "" # 默认:$DSH_HOME/dsh-reasoning-loop-guard/fires.jsonl
journalMaxBytes: 524288
journalPreviewChars: 120
logTool: true # 注册 reasoning_loop_log 工具
| 字段 | 默认值 | 含义 |
|---|---|---|
enabled | true | 总开关。为 false 时插件完全不注册任何流钩子。 |
minChars | 800 | 短于此长度的推理永不判定。 |
every | 200 | 判定节奏,单位为字符。 |
window | 4096 | k-gram 判据使用的滚动窗口。 |
kgram | 64 | k-gram 判据跟踪的尾部片段长度。 |
kgramThreshold | 12 | 在 window 内出现多少次才触发。 |
periodTail | 1200 | 周期判据使用的滚动窗口。 |
minPeriod / maxPeriod | 8 / 400 | 周期判据搜索的周期范围。 |
minUnits | 4 | 触发所需的连续相同单元数。 |
blockMin / blockCount | 100 / 4 | block-repeat 的块长度与出现次数。 |
lineMin / lineCount / lineShare | 10 / 3 / 0.1 | line-repeat 的行长度、出现次数,以及重复行至少要占窗口的比例(见上文「为什么 line-repeat 要两个条件」)。 |
fillerRun | 400 | filler-run:末尾连续装饰字符达到多少才判定为卡死。 |
failureCode | REASONING_LOOP | 终止块携带的失败码。 |
journal | true | 把每次触发记录进 JSONL 日志。 |
journalPath | "" | 日志位置;留空表示 $DSH_HOME 下的默认路径。 |
journalMaxBytes | 524288 | 日志超过该字节数后轮转为 <path>.1。 |
journalPreviewChars | 120 | 每条记录保存的「肇事尾巴」字符数。 |
logTool | true | 注册 reasoning_loop_log 工具。 |
validateConfig() 会拒绝那些会静默失效的配置——kgram > window、minPeriod >= maxPeriod、periodTail < 2 * maxPeriod、failureCode 为空、every 非正数等等——并在报错信息里点名字段。
触发日志、GUI 日志面板与 reasoning_loop_log 工具
GUI 里的「触发日志」面板
在 DSH 的插件页打开本插件,详情页里会多出一个「触发日志」面板:最近的触发记录(时间、判据、模型、已读字符数、肇事尾巴的预览)、按判据与按模型的汇总、一键清空,以及日志路径的复制按钮。没有触发记录时它显示一句明确的空态文案,而不是一片空白。
列表一次取一页、最多 100 条,最新在前。每行始终显示表头(时间、判据、度量、位置、模型),把详情——完整度量、来源(turn / step / 尝试号)、预览、当时生效的阈值——收在点击之后。最新一条默认展开,因为要解释的通常就是它;工具栏另有全部展开 / 全部收起。你自己点开或收起的行会保留选择,直到批量操作覆盖它。
这个面板由两半组成,都在本包内:
| 文件 | 作用 |
|---|---|
lib/log-route.js | 向宿主的 webServer 注册 GET /reasoning-loop-guard/log,返回 { path, enabled, version, stats, total, matched, entries };支持 limit / rule / sessionId / since 查询,POST {"action":"clear"} 清空。 |
lib/client.js | 手写的惰性 CJS bundle(零构建步骤),以包名为键注册 plugins.bundle.config 槽位并渲染卡片。运行时只向平台种子表 require 两个词:react 与 @deepseek-ai/dsh-client-ui-primitives。 |
路由自带同源栅栏。 宿主的 webServer 不提供任何鉴权,所以这道栅栏由插件自己写:非回环 Host、Sec-Fetch-Site: cross-site、或与 Host 不同源的 Origin,一律 403。请求体上限 16 KiB(超出回 413 并断开),非 GET/POST 回 405。你的浏览器本来就带着 DSH 的渲染进程访问令牌,因此同源栅栏不会妨碍正常使用——但一个恰好能访问到该端口的其他程序会被挡在外面。
若你的宿主根本没提供
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","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 被截断时出现,是未截断的原文尾巴。
日志是有界的(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 不在其中,因此 dsh-llm-retry 不会自动重试。原因是:复读是这次请求本身的性质,自动重试会把整个 prompt 再发一遍,白烧同样的 token 再复读一次。如果你确实想要重试,把 failureCode 设为 EMPTY_RESPONSE。
每条流都新建一个检测器。 自动重试会从零开始计数,上一次尝试的重复不会累积到下一次。
对工具服务没有硬依赖。 工具是通过 ctx.inject(["tools"], …) 注册的——一种可选注入。若写成硬 inject,那么在任何没有 tools 服务的宿主上插件都会变成 inactive,等于为了一个诊断功能而把护栏本身也关掉了。
零运行时依赖。 除了声明为 peer 的 DSH 宿主包之外,插件不导入任何东西。
测试
npm test
四套测试,必须全部通过:
| 套件 | 覆盖内容 |
|---|---|
test/test-guard.mjs | 六种 chunk 大小下的检测器定标、分离度、guardStream 协议一致性(恰好一个终止 finish、提前停止、已中止信号的处理、健康流不被改动)、消息渲染。 |
test/test-journal.mjs | $DSH_HOME 解析、preview 截断、记录形状、解析容错、过滤、stats 聚合、轮转,以及「日志故障永不抛异常」这条保证。 |
test/test-card.mjs | 日志路由的同源栅栏判定、方法与查询参数、上限与关闭态、注册走服务,以及客户端 bundle 的协议形态(在 window.__ModuleLoader__ 伪装下真的加载它)与卡片的纯函数。 |
test/smoke/smoke.mjs | 用桩宿主驱动真实的 apply():配置校验、全局只注册一个 llm/stream 监听器、工具注册,以及该工具的端到端行为。 |
test/smoke/real-protocol.mjs | 真实的 @deepseek-ai/dsh-llm 不变量校验门,断言护栏的输出是一条合法的流。 |
两个 smoke 套件通过 test/smoke/resolve-hook.mjs 把 @deepseek-ai/* 解析到 app 与 profile 的安装位置,因此不必启动 DSH 就能验证宿主侧的那一半。
端到端验证记录
开发期间,护栏还额外用真实的 dsh CLI 驱动一个真实的 agent loop做过端到端验证,跑在一个隔离 profile 上,该 profile 的默认模型是一个只用于测试的适配器,回放一段退化推理流。这里没有任何东西是仿制品:不是 loop,不是瀑布钩子,不是不变量校验门,也不是 CLI 的错误出口。
两臂、十一项断言:
| 护栏开启 | 护栏关闭(同一条流) | |
|---|---|---|
| 退出码 | 1 | 0 |
| 上游实际发出 | 3120 / 7659 字符 | 7659 / 7659 字符 |
| 上游是否跑到自己的结尾 | 否 | 是 |
| stderr | REASONING_LOOP: … 周期 64 字符,重复 6 次,已读到 3120 字符 | — |
| stdout | 空 | TG-FAKE-OK |
也就是说:护栏确实把上游生成器截断了(而不是等流跑完才报个错);而关掉护栏后,同一条流完整跑完、毫发无损——这排除了「测试装置本身是坏的」这种可能。
这套端到端装置属于开发脚手架,不随包发布。上面四套测试才是随包交付的。
测试夹具
护栏的阈值是针对一次真实故障定标的,但那段会话的推理文本属于隐私,因此仓库里提交的 test/fixtures/ 夹具是合成的。它们复现了原始数据实测出来的形状——相同的行数(11 / 166)、相同的逐行字符数(从一条 24.4 万字符的大块,到 502 字符的小块),以及相同的复读几何(单元周期经过挑选,使 k-gram 判据计得 20..162 次命中、周期判据计得 5..50 个单元)。
生成器带随机种子且完全确定,因此重新生成会产出逐字节相同的文件,任何 diff 都是真实变更。
已知边界
- 它不是通用看门狗。 这几条判据针对的是一种特定的退化形态——尾部连续重复。换一种卡法(比如无限工具调用循环,或者模型在语义上绕圈但并不逐字重复)不会触发它。
- 首次触发的位置取决于喂入粒度:最早约 3000 字符,最晚约 56000 字符。记录在案的 11 次故障都会在 10,000 字符以内被拦住。
- 定标样本曾是单个会话的 177 条,样本量偏小——已知有盲区。 它全是散文,不含「边写代码边推理」这一形态,因此在那上面报出的 0/166 误报不能外推。
line-repeat/block-repeat的阈值后来改用 2,799 条真实语料重新定标(见上文「为什么line-repeat要两个条件」)。若实践中仍出现误报,优先调高lineShare(比例地板)或minUnits/kgramThreshold;若误报来自装饰(比如你的模型习惯画很宽的图),调高fillerRun。 - 只输出装饰的流会被兜底判据拦下,阈值可调。 见上文「装饰不是复读」:默认 400 已高于实测最宽合法排版(151),但如果你的场景里合法图形更长,把它调高即可。
- 插件只缓解症状。如果你的 provider 支持更低的推理档位,那才是针对病因,可以与这个护栏一起用。