模型在思考里反复输出「好。/(写)/执行。」,界面一直转圈,最后只能手动点停止。不仅浪费tokens,还浪费时间。这篇文章记一下我是怎么一路挖到根上、又怎么把它压住的。

一、先说现象

那天在 ISP 那个工作区里跑一个网络探测的活,跑到一半发现会话卡住了。看顶部是"思考中",正文区也在动,但点开思考块看到的是这个:

好。
(写)
好。
执行。
好。
(写)
好。
执行。
······

没有任何工具调用。不是 reasoning-delta 之外还有别的东西,就纯思考在滚。它并不是在执行什么,就是在反复跟自己说"我要开始了"。社区里管这个叫"唱歌"。触发条件很一致:上下文长、reasoningEffort 开 high、思考语言是中文。中文短句的熵比英文低,"好。""执行。"这种两三个字的句子一旦开始重复,模型很难自己拐回来。

正常跑一轮和复读状态下的 token 曲线,横轴时间,纵轴累积输出

二、几个我一开始也怀疑过的方向

先说我排掉的,免得有人照着走弯路。以为是模型抽风。同一个模型在短上下文里表现正常,同一段提示词换个新会话就没事。这不是能力问题,是长上下文下的采样退化。

以为能加重复惩罚。我去翻了 dsh-llm-deepseek/lib/index.js 的请求构造,1706 到 1711 行,实际只发三个采样字段:

max_tokens: options.maxTokens ?? model?.maxTokens ?? connection.maxTokens,
...options.temperature === void 0 ? {} : { temperature: options.temperature },
...options.stop === void 0 ? {} : { stop_sequences: options.stop },

没有 frequency_penalty,也没有 presence_penalty。DeepSeek 走的是 Anthropic Messages 兼容端点,那个协议本身就没这些字段。所以"调个参数压一下重复"这条路,在 DSH 上走不通。以为是慢。我盯着 turn/end 事件看了整整一轮,它压根没出现过。会话状态一直停在 running。

三、四个地方同时缺位

真正的原因不是某一个 bug,是四个环节本来该各管一段,结果每一段都没管住。

每环都标了代码位置,可以自己去看

1. 自带的重复检测,盯的是工具调用

DSH 有个 dsh-repeat-tool-reminder,名字看着就是干这个的。但它的判定键是「工具名 + 参数」的组合,挂在 tools/post-execute 上,而且官方文档写得很清楚,它只提醒、不阻止:

达到配置的重复次数时,它会要求模型检查上一次结果并改变方法或结束任务。提醒只是建议,绝不会阻止或延迟合理的重复调用。

dsh-repeat-tool-reminder/README.zh.md

我们这次的情况是一次工具都没调,它自然一次都不会响。它防的是"Agent 反复 ls 同一个目录",不是"模型在脑子里念经"。另一个 guard/timeout-policy 挂在 tools/execute,一样的道理。

2. agent 循环根本没有轮次上限

这块我读了源码才确认。一轮由若干 step 组成,每个 step 是"请求模型 → 跑工具 → 再来一次",退出条件是 turn/end 被结算。而 turnEnds 是从已经完成的消息里推导出来的——流不结束,step 就不结算,turnEnds 一直是 null,那个 while 永远不 break。

官方文档自己写了:

没有内置轮次预算:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期扩展点(如 agent/turn-stopping)执行取消。

dsh-agent-loop/README.zh.md:219

就是说这部分是被有意留白的,等插件来补。

3. 唯一能强制收口的旋钮,设成了 25.6 万

dsh-llm-deepseek/lib/types/defaults.d.ts 里两行:

export declare const DEFAULT_CONTEXT_WINDOW = 1000000;
export declare const DEFAULT_MAX_TOKENS = 256000;

lib/index.js:21 是同一个值,256e3。

这个默认值对大模型是合理的,写长报告、改大文件都需要空间。问题是它同时也是这个链路上唯一的硬闸。25.6 万 token 配上 237 tok/s 的出流速度,意味着一次退化能烧十几分钟到一小时,才被 stop_reason: max_tokens 收掉。

4. 协议层没有重复惩罚可加

第二节已经说过了,这里只补一句结论:能动手的位置只剩两个,一是在流里把退化截断,二是在最坏的情况下靠 max_tokens 兜底。

四、两层闸

上面那层管别浪费,下面那层管一定能停

第一层用社区插件 dsh-repeat-guard。它接管 llm/stream,一个 chunk 一个 chunk 地扫思考段,命中白名单里的短句就把当前这块闭合、补一个 finish(stop)、把流结束掉。对 agent-loop 来说这就是一次正常完成,已经生成的思考照常落盘,后面的流程照走。然后它挂在 agent/turn-stopping 上塞一条纠正消息,把模型拉回原来的活。

作者在 README 里给过一组数:一个 330,188 字符的复读,在 3,264 字符(1.0%)的地方就被切了。

第二层是把 maxTokens 从 256000 打到 32000:

- id: llm-deepseek
  name: "@deepseek-ai/dsh-llm-deepseek-api-key"
  config:
    maxTokens: 32000

这层不讲道理,但好用。哪怕插件没拦住,stop_reason=max_tokens 也会把流收掉,turn/end 照常结算。

为什么要两层——因为我实测碰到过一个反例。插件切流之前有个"探针"动作:命中之后要再往下取一格,如果下一格不是 reasoning-delta(换成正文、换成工具调用、或者流已经结束),它判定"命中那行本来就是这段思考的最后一句",属于正常收尾,于是放行。

我遇到的那次,思考尾巴上有 89 个"好。",但最后一个"好。"之后流就结束了。探针取到 done,放了。那一轮最后是怎么结束的?被 32000 截断的,turn/end 是 completed,会话正常退出。放以前就是卡在那儿等人点停止。

五、具体改了什么

先确认基线:patch、插件目录、peer 链接三项都在

装插件时先撞了个墙

按官方来:

dsh plugin --profile web add dsh-repeat-guard
→ Failed to resolve dependency: error sending request for url
  (https://github.com/ADWMC/helm-d/releases/latest/download/helmd.tgz)

报的是 @adwmc/helm-d 拉不到——它是 GitHub release 的直链,我这边网络不通。问题在于 pnpm 解析依赖树是整棵一起解,一个 URL 依赖挂掉,整棵都失败,而且报错还指着那个跟我要装的包毫无关系的 helm-d。

绕开就行,用 npm pack 自己把 tgz 拉下来手工放进去:

$tmp = Join-Path $env:TEMP "dsh-rg"
New-Item -ItemType Directory -Force $tmp | Out-Null
Push-Location $tmp
npm pack dsh-repeat-guard@0.2.2
tar -xzf dsh-repeat-guard-0.2.2.tgz
Pop-Location
Copy-Item "$tmp\package" "$P\node_modules\dsh-repeat-guard" -Recurse -Force

然后撞第二个墙:peer 依赖

手工放进去的包,pnpm 那套软链逻辑不会管它,运行时直接报:

ERR_MODULE_NOT_FOUND Cannot find package '@deepseek-ai/schemastery'
  imported from ...\dsh-repeat-guard\lib\config.js

而且这些包不在 node_modules/@deepseek-ai/ 顶层,是藏在 dsh/node_modules/@deepseek-ai/ 里的。按插件自己 package.json 里声明的 peer 列表逐个建 junction:

$dshDir = "<dsh 安装目录>\@deepseek-ai\dsh"
$linkRoot = "$P\node_modules\@deepseek-ai"
New-Item -ItemType Directory -Force $linkRoot | Out-Null
foreach ($n in @('cordis','dsh-agent','dsh-client-ui-renderer',
                 'dsh-client-ui-settings','dsh-llm','dsh-settings','schemastery')) {
  cmd /c mklink /J "$linkRoot\$n" "$dshDir\node_modules\@deepseek-ai\$n" | Out-Null
}

这一步我返工过。第一版路径少算了一层,脚本打印"7 个 peer 未解析",但紧接着的 import() 自检居然显示成功——因为之前手工建的 junction 还在,解析到的是残留链接。要验证 peer 有没有真的建对,只能删掉一个再重跑,看它会不会自己补回来。

配置

profile 的 package.json 里,dependencies 加 "dsh-repeat-guard": "0.2.2",dsh.profile.bundles 数组里加同名项。然后 cordis.patch.yml 末尾加两段。

两个细节我踩过:id 必须逐字等于插件里的 ENTRY_ID = 'dsh-repeat-guard',插件靠这个定位自己的配置项;字段名是 threshold,不是 consecutiveHits,我第一次写错了。

- id: llm-deepseek
  name: "@deepseek-ai/dsh-llm-deepseek-api-key"
  config:
    maxTokens: 32000

- id: dsh-repeat-guard
  name: "dsh-repeat-guard"
  config:
    threshold: 1
整理成幂等脚本后重跑,全部走"已存在"分支
dump-config 确认两个条目都进了组合树

六、跑完看什么

改完其实不用重启。maxTokens 在 DSH 里是 volatile 引用,宿主每一步现取。翻会话日志里的 request/header 能看到切换点:

seq 10   maxTokens: 256000
seq 24   maxTokens: 256000
seq 319  maxTokens: 32000   ← 同一个 PID 里的
seq 405  maxTokens: 32000

插件那边,配置里会多出一个 count 字段,是它自己的拦截计数,每掐一次 +1 然后写回 cordis.patch.yml:

- id: dsh-repeat-guard
  config:
    threshold: 1
    count: 5

对应那个会话里有五条注入:

seq 1106  1147  1179  1193  1207
source: { kind: "plugin:repeat-guard", form: "notice",
          summary: "复读已截断:请继续执行" }

把每次拦截前的思考尾巴捞出来看,形状完全对得上:

seq 1179: 好。好。| 好。| 执行 grep。| 好。| 好。好。| 好。| 输出。| 好。| 好。| 好。| …
seq 1193: 好。| 好。| 好。| …(整段就剩这个)

再拿插件自己的 lib/detect.js 把那段原文回放一遍,两次都命中:

seq 1201 len=602  →  findDegenerateLine = "好。"
seq 1215 len=466  →  findDegenerateLine = "好。"

其中 1215 就是被探针放过、最后靠 32000 兜住的那次。

七、顺便记三个坑

dsh plugin add 会重解整棵树。 profile 里只要有一个不可达的 URL 依赖,装任何东西都会失败,报错还指向那个无关的包。

手工装的包不会自动解 peer。 而且"假通过"特别难查,因为残留的 junction 会让自检显示一切正常。

session 日志是多帧 zstd。 想从会话文件里取证得先解压,而 Node 的 zstdDecompressSync 只解第一帧:

node -e "const z=require('zlib'); console.log(z.zstdDecompressSync(buf).length)"
# 212   ← 一个 2MB 的会话,只解出 212 字节

流式接口更干脆,直接抛 ZSTD_error_prefix_unknown。原因是这些文件压了 312 帧、715 帧。得按 magic 切:

const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
const marks = []
let at = buf.indexOf(MAGIC)
while (at !== -1) { marks.push(at); at = buf.indexOf(MAGIC, at + 4) }
if (marks.length === 0 || marks[0] !== 0) marks.unshift(0)

const parts = []
for (let i = 0; i < marks.length; i++) {
  const slice = buf.subarray(marks[i], i + 1 < marks.length ? marks[i + 1] : buf.length)
  try { parts.push(zlib.zstdDecompressSync(slice)) } catch {}
}
fs.writeFileSync(out, Buffer.concat(parts))

还有个跟代码无关的:$env:DSH_HOME 和 $env:DSH_PROFILE_DIR 不是系统环境变量,是 dsh 拉子 shell 的时候才注入的。在外面新开的 PowerShell 里它们都是空的,拿去做路径判断会全部得到 False。我自己就先被这个骗了一次,以为文件不存在。

八、换台机器怎么办

整理成了一个幂等脚本 dsh-loop-guard-install.ps1:

pwsh -NoProfile -File .\dsh-loop-guard-install.ps1 -Profile web -MaxTokens 32000

它依次做:找 dsh 安装目录 → npm pack 取包 → 解压 → 按 peer 声明建 junction → 幂等改 package.json 和 cordis.patch.yml → 跑 --dump-config 校验 → 跑 import() 自检。

三项全绿才算装好:

OK   组合树含 dsh-repeat-guard
OK   组合树含 maxTokens: 32000
OK   RESOLVED Config,apply

换机器第一个坑通常是 dsh 命令本身不在 PATH。 WorkBuddy 升级 node 会删掉旧版本目录,npm 全局安装就在里面,跟着一起没了。得建一个运行时解析版本目录的 shim,别硬编码路径。

另外桌面版(exe)和 CLI 版布局不一样:exe 版把 @deepseek-ai/* 封在 app.asar 里,profile 目录一般叫 desktop 而不是 web。动手前先用 Get-Process 确认进程路径。

九、还没完的部分

那个探针边界是有意的设计取舍,不是 bug——它要避免把"正常收尾的最后一句"当成复读切掉。想彻底盖住,可以再叠一层按逐字周期判的插件(比如 dsh-loop-guard),它不依赖"还有没有下一格"这个条件。但就现在的两层,已经不会再出现停不下来的情况了,这一层算加固,不算必需。

还有一个我暂时没动的:本地那份 anchor-gate.mjs。它的 promoteAfterFirstResponse 逻辑是模型答一次就把 1024 的 bootstrap 上限拆掉,而锚点判定用的是 /\bwe\b/——中文思考里根本不会出现 we,所以那个门在中文环境下基本是空转的。现在有全局 32000 兜着,问题不大,但这个逻辑本身值得再看一眼。

行号对应 @deepseek-ai/dsh 0.2.0-rc.2。0.2.1-rc.2 起有些默认值动过,动手前先用 dsh --profile web --dump-config 核一下 llm-deepseek 的条目名和 maxTokens。

如果你觉得这篇文章不错,有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力(饿饿吃饭)。

  • reward_image1
  • reward_image2
欢迎来到XiaoZou123,这里是一个电脑极客、数码爱好者网站。我平时喜欢关注数码新闻,研究计算机技术。如果你看我头像觉得我是二次元,那我其实还算不上!
最后更新于 2026-10-19