Skip to content

dsh-loop-guard

Verified

@logictan/dsh-loop-guard · v1.1.5 · MIT · Web UI

Thinking-loop guard for dsh: observes the llm/stream waterfall and breaks a model that degrades into a loop. Detects two per-call shapes (reasoning-only calls, restated-material calls) and re-fires instead of latching; plus two mid-stream breakers that en

Install

dsh plugin add @logictan/dsh-loop-guard

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Readme

dsh-loop-guard — DSH 思考循环守护

为 DeepSeek Harness(DSH)提供的思考循环守护插件:模型陷入「只想不做」的退化循环时自动打断,不必再手动中止

🌏 中文 | English

dsh · dsh-plugin · plugin · guard · thinking-loop · reasoning · repetition · AI agent · 思考循环 · 死循环 · 推理退化

简介

长上下文 + 高 reasoning effort 下,模型会退化:推理里开始出现低熵重复(好。执行。好。、Write. / Output. / Let me write. / Go. 这种短句轮转),然后停不下来。

麻烦的是 DSH 自带的 guard/ 家族抓不到这种情况——它们都围绕工具调用:

内置 guard 挂载点 能抓什么
guard/timeout-policy tools/execute 工具调用超时
guard/repeat-tool-reminder tools/post-execute 重复的同一个工具调用链

而退化循环的特征恰恰是完全不调工具:只有 reasoning-delta,零 text-delta、零 tool-call-delta。于是:

  • 两个 guard 都不触发;
  • agent-loop 的 turn() 从已完成的消息推导 StepEndReason,流不结束 → step 不 settle → turnEnds 永远为 null → while (true) 永不 break;
  • 回合停不下来,只能由你手动中止——而界面上只显示「思考中」,看起来和认真推理没区别。不点开 thinking 块根本察觉不到它在空转。

本插件接管 llm/stream 瀑布流,按每次模型调用的 chunk 组成做判定,在退化发生时从流内部切断,让回合正常结束。

效果:思考循环被截断,并注入纠正提示

上图是实际运行效果:推理块里反复出现 OK. / Writing. / Let me write. / Go. / Executing. / Now. 的轮转,插件在重复累积到阈值时截断该次调用,并在下方注入一条提示,让模型回到原本的任务。

功能特性

  • 四层检测,各管一种形态:纯推理无输出、复述上一步材料、推理内部的周期循环、以及推理内部的短语池重排。后两条是互补的,见下。
  • 能终止「永不结束的回合」:这是本插件存在的核心理由。退化循环里流永远不结束,任何「调用结束后再判定」的检测都够不到;只有从流内部切一刀才行。
  • 切断后任务继续,不必手动重启:切断只是结束当前这次调用,回合以正常完成收尾,会话照常可用——你原本的任务可以直接往下走。这是它和「卡死到只能手动中止」的根本区别。
  • 在 1% 处就切断:实测一个 330,188 字符的循环在 3,264 字符(1.0%)时被切断;一个 124,070 字符的在 17,888 字符(14.4%)时被切断。原来这两个都要跑满全程、最后靠人手动中止。
  • 误报极低:在真实会话 119 个「有产出」的调用上,零误报;在标定用的 252 组参数里,选中的这组也是零误报。
  • 精确判据:判定用的是逐字周期和跨调用复述,不是时长、也不是比率。
  • 纠正信息指向原任务:切断时注入的提示只说「不要重复,继续完成任务」,不会让模型去"给个结论然后收尾"——后者会让它偏离原本在做的事。
  • 反应不闩锁:一次 steer 常常打不破强循环,所以阈值到了会重新计数、再次开火(上限 maxFires)。
  • 跟随界面语言:注入的提示读宿主 locale 设置,中文环境说中文(默认即中文)。
  • 绝不静默重试循环:没有模型回退、没有把退化的模型再喂一遍——那比循环本身更糟。唯一的自动重发是响应体损坏(见下):请求本身没问题,是上游返回的 JSON 解析失败,重发同一个请求才是正确的修法。
  • 响应体损坏自动重试:模型返回的响应体 JSON 解析失败(界面上的「本轮运行失败 … JSON at position N」)时自动重发同一请求,默认最多 2 次,超出就交给下游恢复。判据很窄——错误码与 V8 解析错误特征同时命中才算,Too many pending requests、Provider finish_reason: error 这类同样报 PI_AI_ERROR 的情况不重试。
  • 离线分析器:tools/analyze-session.mjs 用插件运行时同一个检测器复跑 session jsonl,回答「这次到底该不该响」。
  • 可观测:切断时写 warn 日志,并注明是哪条规则命中的。

使用

开箱即用

装好就能用,不需要任何配置。 默认值已按真实数据标定:

  • 纯推理空转、复述上一步、推理内部周期循环 → 自动打断回合;
  • 单次调用刷屏式重复可见输出 → 自动从流内部切断;
  • 正常的长推理、正常的重复性输出(表格、日志、CSS、JSON)→ 不误伤。

熔断后会发生什么

会话继续,任务可以往下走——不需要你手动重启或重新发指令。

项 结果
本次调用 被切断,已产生的推理作为正常消息落盘
回合 以 turn/end 结束,原因是 { kind: 'completed' }——和正常完成一样
会话 存活,agent 回到 idle,可直接继续
已执行的工具调用 保留(tool/result 不回滚)
注入的提示 一条 notice,以插队消息(next-step)注入,说明「已连续重复 N 字符,响应被中途截断,不要重复,继续完成任务」
终止 chunk 协议合法:切断前先闭合所有 block,再用 stop 结束,已用 DSH 自己的 @deepseek-ai/dsh-llm/invariant 验证

所以体验是:循环 → 在几百字符处被切断 → 提示它回到任务 → 继续干活。

为什么回合是 completed 而不是报错:早先的版本用 error finish,那会让 DSH 的会话渲染器拿不到 assistant/message(只产生 assistant/attempt),于是抛出 conversation Definition "assistant-step" withdrew materialized target "chat",而且 turn() 会在 throwError 处提前抛出、永远走不到「是否再开一个回合」那一行——既崩界面又无法续跑。改为闭合 block + stop finish 后走的是正常路径,两个问题一起消失。

代价是回合结束原因不再有辨识度(completed 与正常完成无法区分)。痕迹留在两处:注入的 notice,以及宿主日志里的 warn。这是刻意的取舍——熔断的目的是让会话继续可用,不是制造告警。

插件只结束这次调用,从不结束 agent——它永远不会调用 agent.cancel()。

自动续跑(resumeAfterBreak)

只在关掉 breakCorrection 时才有意义。 纠正提示本身就是插队消息(next-step),而回合只有在 next-step 空了才收尾,所以「切断 + 纠正」已经能让任务自己往下走。这个选项补的是剩下那种情形:breakCorrection: false 时没有任何消息留下,会话会停在原地等你,resumeAfterBreak 才把它推起来。

- id: loop-guard
  config:
    breakCorrection: false
    resumeAfterBreak: true

关键在于「等」,这不是实现细节而是整个方案成立的前提:切断发生在流包装器里,此时 agent 还处于 running,而 DSH 在这个阶段刻意压制唤醒——wakeDriver() 只肯为 maintenance 或 aborted 挂起唤醒,所以此刻发 steer() / followup() 都不会置 wakeRequested,kick() 的 finally 找不到唤醒依据,会话就此停下。唤醒只有在 agent 回到 idle 后才有效,插件用 whenIdle() 等这个时刻。

续跑走插队通道(next-step),不走排队通道(next-turn)。 这不是风格问题:claim() 每回合只从 next-turn 取一条,所以 N 条排队消息就是 N 个回合,一条条慢慢放。一个回合里可能熔断几十次,排队通道会把它们堆成上百条待办,之后每回合只消化一条。next-step 在下个步骤边界整批取走,因此不会堆积。插件发出的所有提示都是插队消息。

续跑消息带纠正文本,从不是空的。空消息等于把同一段退化历史原样再喂一遍、不带任何新信息——那正是产生循环的输入。

该选项默认关闭:它是在模型已经证明「不能自主行动」之后、不经过你同意就再进一次模型。无人值守的长任务才建议打开。

安装

说明:本插件需要 DSH 的 llm/stream 瀑布流,DSH 0.1.2-rc.1 及以后的各条线都可用。

本插件发布在 @logictan/dsh-plugins-all 聚合包里,随该包一起装上:

dsh plugin --profile web add @logictan/dsh-plugins-all@latest

装完重启 dsh web。聚合包会把本插件的 patch 行并入 profile,并把 @logictan/dsh-loop-guard 作为普通依赖带进来——不需要手工改 profile 的 cordis.patch.yml。

只想单独装本插件时:

dsh plugin --profile web add @logictan/dsh-loop-guard

更新与卸载

两者都由聚合包统一处理:

dsh plugin --profile web add @logictan/dsh-plugins-all@latest   # 更新
dsh plugin --profile web remove @logictan/dsh-plugins-all       # 卸载

然后重启 dsh web。本插件不写任何自己的配置文件,也没有全局注册表或系统级写入; 用户在插件设置页里改过的参数存在宿主自己的设置文档中,随该文档一起管理。

配置

默认配置就能用,通常不需要动它。 只有想调整灵敏度或开启自动续跑时才需要改。

在插件设置页里改(推荐)

DSH 的 设置 → 插件 → 思考循环守护 里有一张配置卡片,上面是下面这些选项的全部可编辑字段, 带范围校验、逐项「已覆盖」标记和「恢复默认」。保存后即刻生效,不需要重启—— 插件每次模型调用都读取当前配置。

  • 留空 = 继承组合配置(profile 的 cordis.patch.yml 里写的值)或插件默认值;
  • 恢复默认 = 清掉所有用户层覆盖,回到组合配置的值。

在 cordis.patch.yml 里改

需要把值固化进部署(比如给整个团队发一份预设)时才用这种方式:

- id: loop-guard
  config:
    maxThinkingSteps: 2
    resumeAfterBreak: true

设置页里的用户层优先于这里写的组合层,所以两者不冲突:组合层定基线,设置页做个人调整。

全部选项

interface Config {
  // ── 跨调用判定(调用结束后) ──────────────────────────────
  /** 连续多少次「停滞调用」后反应。默认 3。 */
  maxThinkingSteps?: number
  /** 单次调用的推理至少这么长才参与判定。默认 2048 字符。 */
  minReasoningChars?: number
  /** 跨调用相似度:上一步的推理有多少重现才算「复述」。0 关闭。默认 0.8。 */
  similarityThreshold?: number
  /** 命中后做什么:'warn' | 'steer'(默认) | 'cancel'。 */
  escalate?: 'warn' | 'steer' | 'cancel'
  /** 同一个 agent 最多反应多少次。默认 4。 */
  maxFires?: number
  /** escalate 为 'cancel' 时的取消原因。默认 'thinking-loop'。 */
  cancelCause?: string

  // ── 流内切断(调用进行中) ────────────────────────────────
  /** 连续多少个相同的可见输出 chunk 就切断。0 关闭。默认 60。 */
  maxRepeatedText?: number
  /** 可见输出尾部最长重复周期(字符)。0 关闭。默认 512。 */
  maxRepeatedCycleChars?: number
  /** 可见输出尾部至少要重复多长才判定。默认 256。 */
  minRepeatedCycleChars?: number
  /** 推理尾部最长重复周期(字符)——**这条终止 #5976 的永不结束回合**。0 关闭。默认 512。 */
  maxRepeatedReasoningCycleChars?: number
  /** 推理尾部至少要重复多长才判定。默认 384。 */
  minRepeatedReasoningCycleChars?: number
  /** 推理里「重复行」累计到多少字符就切断——**这条抓没有周期的短语池重排**。0 关闭。默认 2048。 */
  maxRepeatedReasoningLineChars?: number
  /** 重复行占比要达到多少才判定。默认 0.6。 */
  minRepeatedReasoningLineCoverage?: number

  // ── 切断后的行为 ──────────────────────────────────────────
  /** 日志里标注的错误码。默认 'REPETITIVE_OUTPUT'。(切断本身走 `stop`,不产生错误) */
  breakCode?: string
  /** 切断时注入一条纠正提示,让模型回到原任务。默认 true。 */
  breakCorrection?: boolean
  /** 切断后等回合收尾,再插队一条纠正消息把任务推起来。仅在 breakCorrection 关闭时有用。默认 false。 */
  resumeAfterBreak?: boolean

  // ── 响应体损坏时重试 ──────────────────────────────────────
  /** 响应体 JSON 解析失败时自动重发同一请求。默认 true。 */
  retryRequestFailures?: boolean
  /** 同一次尝试最多重发几次,超出交给下游恢复。默认 2。 */
  maxRequestRetries?: number
}

常见需求

在设置页里填对应的值即可;下面给出等价的 cordis.patch.yml 写法(要固化部署时用):

# 1. 更灵敏:连续 2 次停滞就反应
- id: loop-guard
  config:
    maxThinkingSteps: 2

# 2. 无人值守:切断后不留提示,由续跑插队推起来
- id: loop-guard
  config:
    breakCorrection: false
    resumeAfterBreak: true

# 3. 只想要推理循环这一条,其余全部关掉
- id: loop-guard
  config:
    maxThinkingSteps: 999
    maxRepeatedText: 0
    maxRepeatedCycleChars: 0

# 4. 硬停:不 steer,直接中止回合
- id: loop-guard
  config:
    escalate: cancel

各阈值是怎么定的

不是拍脑袋,是在真实会话上标定的(一份 174 MB、4628 次调用,其中 1005 次推理 ≥2048 字符;后续又在新的复现里继续修正):

规则 结果
maxPeriod: 64(可见输出的旧默认值) 一个都抓不到
实测周期 89 / 102 / 105 / 154 / 187 / 235 / 382 / 409 字符
有产出的调用被误判 0 / 997

周期上限必须高于所有实测周期,而不是取已见样本的中段。 这条规则是踩出来的:上限最初定在 256(当时量到的周期是 89–235),结果真实循环出现 409 和 382 的周期时,trailingCycle 直接返回 0 —— 静默失效:不触发、不报错、不打日志,回合一直跑到手动中止。所以现在是 512,并且有一条回归断言直接钉住「默认值必须大于每一个实测周期」。

可见输出侧也栽在同一个坑里(v1.0.0 修复)

上面那条教训只被用在了推理侧,可见输出侧的默认值还停在 64,于是同一个 bug 在文本上又发生了一次。

这次的循环逃出了思维链,跑到了可见输出里:模型把「好。 / 我写报告。 / (写) / 现在。」这类短句反复吐了 44,387 字符,用户手动中止。

项 实测
循环长度 44,387 字符
循环起点 第 139 字符(0.3%)
精确最小周期 172 字符(尾窗 512 / 1024 / 2048 / 4096 / 8192 / 16384 全部一致)
trailingCycle(文本, 64, 256)(旧默认) 0 ← 静默失效
trailingCycle(文本, 256, 256) 344
trailingCycle(文本, 512, 512) 512

把这段文本按 delta 逐个喂给真实的 TextRepetitionDetector(不是喂结算后的消息),各档位的表现:

周期上限 触发
64(旧默认) 0 次
128 0 次
256 1 次,在 576 字符(1.3%) 处切断
512(现默认) 1 次,在 576 字符(1.3%) 处切断

周期 172 正好落在 128 与 256 之间,所以 64 和 128 都看不见它。

误报标定:扫描会话库里全部 2,973 条 ≥1500 字符的真实可见输出文本(跨多个工作区),周期上限从 64 试到 4096 —— 周期规则只在那一条循环上触发,其余 2,972 条(报告、代码、表格、日志)在所有档位都是 0。

选 512 而不是刚好够用的 256,理由和推理侧一致:上限低于真实周期是静默失效,而这一侧的实测周期已经涨过一次(12 → 26 → 172)。多出来的精度代价是零——2,973 条真实文本里误报数不变,都是 0。

minRepeatedReasoningCycleChars 默认 384(比可见输出的 256 更严):推理是私有的草稿空间,合理地会复述计划,所以要求更长的逐字重复才动手。

为什么是 384 而不是 512:把跨 22 个会话、9,195 次调用的语料按结局分成三类后,这条规则真正要救的那一类——只产出推理、既不输出文本也不发起工具调用,于是自然结束回合的调用——在语料里有 60 次(另 56 次推理长度 ≥384 字符,52 次即 93% 确实是所在回合的最后一步,且 89% 发生在前一次切断之后的同一回合里)。它们的重复尾巴集中在最后 384–516 字符:512 一次都抓不到(0/56),384 抓到 40 次(71%,占全部 60 次的 67%)。曲线很陡:480 抓到 41%、448 抓到 64%、400 起进入 71% 的平台。原先的 512 是在单一复现上调出来的,那条会话的重复恰好很长,「不损失召回」的结论在更宽的语料上不成立。降到 384 的代价经实测为零:在 879 次真正产出文本或工具调用的调用上重放,384 只多命中 1 次,而那一次本身也是同样的短语池循环(只是它之后又继续产出了),有效误报 0。

为什么还需要「重复行」这条规则

因为提高周期上限永远解决不了这一种形态。

一条实测复现(同一个会话,330,188 字符的推理)是这样写的:

Let me read the section. / Executing. / Go. / Now. / Writing. / OK. / Let me write.
Go. / Making the call. / Now. / OK. / Let me read. / Go. / Writing. / OK. / Now.
Let me write. / Go. / Executing. / OK. / Let me read the README section. / Go. / Now.

大约 11 个句子,每一轮重新洗牌。所以它没有任何周期:

检查 结果
trailingCycle(尾部, cap, 512),cap 从 64 试到 4096 全部返回 0
尾部 8192 字符的最小周期 6767(≈窗口本身,即没有周期)

周期规则因此完全看不见它,回合一路跑到 330,188 字符,最后靠人手动中止。这也解释了为什么之前几次把上限从 64 提到 256 再到 512 都没修好这个形态——不是上限不够大,是判据选错了。

它真正有的是极小的行词汇量。所以新增一条规则:统计「已经出现过的行」占了多少字符。

两条规则是互补的,不是重复:

循环形态 抓它的规则
短句精确周期(Go. / OK.,短于 2 字符不计) reasoning-cycle
长句短语池重排(本轮这种) reasoning-lines

标定(同一份真实会话,146 次有推理的调用:17 次被中止的循环 + 119 次有产出的调用):

项 结果
330,188 字符的循环 在 3,264 字符(1.0%) 处切断
124,070 字符的循环 在 17,888 字符(14.4%) 处切断
有产出的调用被误判 0 / 119
标定扫描里零误报的参数组 252 组,选中的是其中之一

minRepeatedReasoningLineCoverage 默认 0.6:正常推理会复用措辞("Let me check"、"OK"),但绝大部分文本是新的,重复占比很低;短语池循环则趋近 1.0。

短于 2 字符的行分子分母都不计——生成的代码里 } 和 ); 会合法地重复上百次,不能让它把比例推上去。

常见问题

Q:熔断后我需要做什么?

通常什么都不用做。 熔断只结束当前这次调用,回合正常收尾,会话继续可用——你的任务可以直接往下走。注入的提示会让模型回到原本的任务。

只有想让它在切断后不等你发话就自动继续、且不留纠正提示时,才需要 breakCorrection: false + resumeAfterBreak: true。开着 breakCorrection(默认)时纠正提示本身就插队了,resumeAfterBreak 没有额外作用。

Q:怎么知道熔断发生过?

两处痕迹:界面上的一条「上下文注入」提示(dsh-loop-guard · 已截断重复的思考内容(N 字符)),以及宿主日志里的 warn。回合结束原因看不出来——它是 completed,和正常完成一致。这是刻意的:熔断的目的是让会话继续可用,不是制造告警。

Q:会误伤正常的长推理吗?

不会。判定用的是逐字周期和跨调用复述,不是时长、也不是比率。实测 997 个有产出的真实调用零误报;生成的表格、日志、CSS、JSON 这些「合理重复」的输出也都不会被判为循环。可见输出侧的周期规则在 2,973 条真实长文本(≥1500 字符,跨多个工作区)上标定,周期上限从 64 试到 4096,误报 0 次。

Q:为什么不直接自动重试那个请求?

因为循环的重试会重发同一个请求——历史完全没变,等于把已经退化的模型再喂一遍,大概率再循环一次。而且它绕过了回合边界,你连「发生过循环」都看不到。resumeAfterBreak 是等当前回合收尾后插队一条带纠正信息的消息,历史里带着纠正,是更好的形态。

Q:那「本轮运行失败 … JSON at position N」为什么就重试?

因为那是另一类故障:请求本身是好的,上游返回的响应体被损坏了(JSON.parse 拒绝),回合直接死在 agent/request-error 上。同一个请求再发一次通常就成功了,不重试才是白白浪费。这跟「把退化的模型再喂一遍」不是一回事。

重试由 retryRequestFailures(默认开)与 maxRequestRetries(默认 2,刻意低于宿主 llm-retry 的 5)控制,预算是按 agent、按 (回合, 步骤) 计的:坏掉的一步不会吃掉整个回合的额度,重试耗尽后失败照常交给下游恢复。

Q:它会自己换模型或降档吗?

不会,这是刻意的。静默给退化模型重新计费比循环本身更糟。

Q:切断时那段已经产生的推理会丢吗?

不会,通过 assistant/attempt 落盘,可以在 session jsonl 里查到。未闭合的 block 会被丢弃。

Q:怎么看某次会话该不该触发?

用离线分析器,它跑的是插件运行时同一个检测器:

node node_modules/@logictan/dsh-loop-guard/tools/analyze-session.mjs <你的 session.jsonl>

支持 assistant/chunk(v1)与 assistant/attempt(v2)两种持久化格式。加 --json 输出逐条记录。

开发

本包在 dsh-plugins monorepo 内以自制子插件形态维护(无上游 fork)。lib/ 不入版本控制, 由 prepare / prepack 自动生成——pnpm install 就会构建它,不需要手工跑:

pnpm install      # 依赖 + 自动构建 lib/
pnpm test         # 构建 + 运行测试套件
npm run build     # 只构建

两半的构建方式不同,见 build.mjs:宿主半边 src/index.ts 走 tsc(声明落在 lib/types/), 浏览器半边 src/client.js 已经是客户端模块加载器的协议格式,逐字拷贝到 lib/client.js。

测试包含三类:纯函数与静态断言、通过真实 llm/stream 链路的运行时断言,以及用真实会话片段做的反例回归(test/fixtures-reasoning-bleed.json,含实测周期 89–235 的循环样本与「高 repeatRatio 但无周期」的正常样本)。

其中一条测试会把插件产出的终止 chunk 送进 DSH 自己的 @deepseek-ai/dsh-llm/invariant 校验——因为切断时推理 block 还开着,只有 error/aborted 才被允许。

如需自定义或修改插件,直接使用 DSH 的 Creator mode 即可快速开发。

许可

MIT