模型在思考里反复输出「好。/(写)/执行。」,界面一直转圈,最后只能手动点停止。不仅浪费tokens,还浪费时间。这篇文章记一下我是怎么一路挖到根上、又怎么把它压住的。
一、先说现象
那天在 ISP 那个工作区里跑一个网络探测的活,跑到一半发现会话卡住了。看顶部是"思考中",正文区也在动,但点开思考块看到的是这个:
好。
(写)
好。
执行。
好。
(写)
好。
执行。
······
没有任何工具调用。不是 reasoning-delta 之外还有别的东西,就纯思考在滚。它并不是在执行什么,就是在反复跟自己说"我要开始了"。社区里管这个叫"唱歌"。触发条件很一致:上下文长、reasoningEffort 开 high、思考语言是中文。中文短句的熵比英文低,"好。""执行。"这种两三个字的句子一旦开始重复,模型很难自己拐回来。

二、几个我一开始也怀疑过的方向
先说我排掉的,免得有人照着走弯路。以为是模型抽风。同一个模型在短上下文里表现正常,同一段提示词换个新会话就没事。这不是能力问题,是长上下文下的采样退化。
以为能加重复惩罚。我去翻了 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 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期扩展点(如
dsh-agent-loop/README.zh.md:219agent/turn-stopping)执行取消。
就是说这部分是被有意留白的,等插件来补。
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,会话正常退出。放以前就是卡在那儿等人点停止。
五、具体改了什么

装插件时先撞了个墙
按官方来:
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


六、跑完看什么
改完其实不用重启。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。
如果你觉得这篇文章不错,有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力(饿饿吃饭)。


Comments NOTHING