← Back to home@himi-li

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
GitHub repo

Introduction

dsh-reasoning-loop-guard

DSH Reasoning Loop Guard

tests license

简体中文 | 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..24
block-repeat同一 blockMin 字符的块在窗口内出现 ≥ blockCount 次(跨行、跨格式)正样本 0..2,负样本 0..04
line-repeat同一行(≥ lineMin 个字符)出现 ≥ lineCount 次,且这些重复行占窗口的比例 ≥ lineShare正样本 2..2,负样本 0..03 + 10%
kgram-repeat末尾 kgram 个字符在窗口内出现 ≥ kgramThreshold 次正样本 1..5,负样本 1..112
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,而不是只刷新页面。 有两个各自独立的原因:

  1. 宿主对 node_modules 的 HMR 是关闭的(dsh-hmr 的 ignored 默认含 **/node_modules),所以包内文件的变动不会被监听到。
  2. 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 工具
字段默认值含义
enabledtrue总开关。为 false 时插件完全不注册任何流钩子。
minChars800短于此长度的推理永不判定。
every200判定节奏,单位为字符。
window4096k-gram 判据使用的滚动窗口。
kgram64k-gram 判据跟踪的尾部片段长度。
kgramThreshold12在 window 内出现多少次才触发。
periodTail1200周期判据使用的滚动窗口。
minPeriod / maxPeriod8 / 400周期判据搜索的周期范围。
minUnits4触发所需的连续相同单元数。
blockMin / blockCount100 / 4block-repeat 的块长度与出现次数。
lineMin / lineCount / lineShare10 / 3 / 0.1line-repeat 的行长度、出现次数,以及重复行至少要占窗口的比例(见上文「为什么 line-repeat 要两个条件」)。
fillerRun400filler-run:末尾连续装饰字符达到多少才判定为卡死。
failureCodeREASONING_LOOP终止块携带的失败码。
journaltrue把每次触发记录进 JSONL 日志。
journalPath""日志位置;留空表示 $DSH_HOME 下的默认路径。
journalMaxBytes524288日志超过该字节数后轮转为 <path>.1。
journalPreviewChars120每条记录保存的「肇事尾巴」字符数。
logTooltrue注册 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 的错误出口。

两臂、十一项断言:

护栏开启护栏关闭(同一条流)
退出码10
上游实际发出3120 / 7659 字符7659 / 7659 字符
上游是否跑到自己的结尾否是
stderrREASONING_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 支持更低的推理档位,那才是针对病因,可以与这个护栏一起用。

许可证

MIT