dsh-loop-guard
推理循环守卫 —— 模型陷入「只出思考、不出结果」的死循环、整条流卡住不动、或者反复吐同一小段文本时,提前掐断这一次尝试,并止住把它反复重试的风暴。
一句话:把一次本来要卡几十分钟到几小时的会话,压缩成几秒钟的一次干净中止。
| 项目 | 值 |
|---|---|
| 包名 | dsh-loop-guard |
| 版本 | 0.5.1 |
| 许可 | MIT |
| 形态 | DSH 插件:宿主半区(index.js)+ 设置页(client.js)+ 中英双语界面文案(locale/*.json) |
| 第三方依赖 | 无(只用 Node 内置模块) |
| 可选依赖 | webServer —— 缺了守卫照常工作,只是没有设置页 |
| 不碰什么 | 不改写、不丢弃、不重排任何上游数据块;统计只放内存,不写盘 |
| 发布方式 | 未发布到 npm(private: true)。请从 GitHub 仓库安装 |
配套文档:安装 / 升级 / 卸载 / 回滚 · 配置详解(每个键调大调小会怎样) · 常见问题 19 条
1. 它解决什么现象
用户报的是三句话:「对话无限重复」「卡会话」「一个子会话运行时间过长」。
查会话记录,根因是同一个:
模型陷入推理循环 / 流卡死
→ 这次尝试以错误收场
→ 宿主按默认重试策略把它再试 5 次(同一次确定性失败)
→ 但每次尝试本身要跑 1–5 分钟,甚至几小时
→ 会话卡死数十分钟到数小时;子会话整个时间预算被烧光后被杀,零产出
重试把一次本来几秒就能判定的失败,放大成了几十分钟到几小时的卡死。
三种坏形态都来自真实会话(数字取自作者的会话记录,会话标识已隐去):
| 形态 | 现象 | 实测数字 |
|---|---|---|
| 分片洪水 | 同一步重试 5 次,每次都只出思考、不出正文 | 5 次尝试分别产出 20,704 / 16,902 / 3,163 / 21,103 / 11,827 个推理分片,正文分片 0 个;整步 18.2 分钟;重试退避 522 / 1001 / 1949 / 3850 / 7259 ms |
| 卡住不动 | 某一步耗时 3.48 小时 | 6 次尝试里 5 次推理分片为 0,时间戳之间是 1.95 小时 + 1.5 小时的「一个分片都没来」空档 |
| 输出退化重复 | 思考里同一句话重复几千次 | 单块 85,705–102,796 字符、非空行 5,947–7,074 行、唯一行占比 0.5%–3.8%、最高频行出现 2,931–3,332 次 |
放大效应还有规模证据:扫一台开发机的 333 个会话,其中 37 个 depth=1 / turn=1 的子会话总时长恰好 18.4 分钟 —— 它们撞在同一个硬顶上。
还有一种「慢速渗流」形态:5,450 个推理分片 / 260 秒 / 产出 0,全程没有任何 ≥180 秒的空档。只盯「分片数量」或「有没有空档」的规则都抓不到它 —— 这正是
noOutput规则存在的原因。
2. 它拦哪几类问题
四种坏形态,五条规则(第 5 条是兜底天花板,不是一种「形态」)。任一命中就掐断,优先级从上到下:
| # | 规则 | 坏形态 | 判据(源码 detectTrip 的真实条件) |
默认阈值 |
|---|---|---|---|---|
| 1 | chunk |
分片洪水:一直在思考,一个字的正文都不出 | 推理分片数 ≥ reasoningChunkLimit 且 产出分片 = 0 |
20,000 片 |
| 2 | stall |
卡住不动:一个分片都不来 | 距上一个分片(或起始时刻,即首块之前也算)≥ stallTimeoutMs |
180,000 ms(3 分钟) |
| 3 | noOutput |
慢速渗流:分片慢慢来,但一直不出结果 | 已耗时 ≥ noOutputMs 且 产出分片 = 0 |
600,000 ms(10 分钟) |
| 4 | repeat(行判据) |
输出退化重复:在重复同一小段文本 | 窗口 ≥ repeatMinChars,且某个子窗内:非空行数 ≥ repeatMinLines、唯一行占比 ≤ repeatUniqueRatio、最高频那一行出现次数 ≥ repeatCycles |
1,000 字符 / 30 行 / 0.2 / 6 次 |
| 4 | repeat(片段兜底) |
同上,但整块几乎只有一行(行判据结构上抓不到) | 窗口 ≥ repeatMinChars,且整窗非空行数 < repeatMinLines,且最坏子窗的 8 字符不重叠片段唯一率 ≤ repeatGramRatio |
0.10 |
| 5 | absolute |
兜底:任何单次尝试都不该跑这么久 | 已耗时 ≥ attemptBudgetMs(不看产出) |
1,800,000 ms(30 分钟) |
两个关键定义:
- 产出分片 =
text-delta+tool-call-delta。reasoning-delta(思考)不算产出。 - 窗口 = 最近
repeatWindowChars(默认 6000)个字符,只吃推理分片与文本分片(工具调用分片不参与)。判据不是看整个窗口,而是在窗口上枚举若干子窗(尺度 =repeatMinChars× 2^k,再加上窗口本身),取最坏的那个值 —— 这样「前半段正常、后半段开始循环」也不会被前面的正常内容稀释掉。
2.1 触发之后会发生什么
| 步骤 | 行为 |
|---|---|
| ① 掐断 | 立刻停止向上游取值,向下游吐一个终结块:{type:'finish', reason:{kind:'error', failure:{code:'LOOP_GUARD', message:'…'}}}。触发之前已经到达的每个块原样透传 |
| ② 止住重试风暴 | 失败码 LOOP_GUARD 回到宿主重试层时,本插件只认自己这个码:同一个 turn/step 允许 maxGuardedRetries 次(默认 1)纠正重试,之后返回「终结」—— 不再把同一次循环重试 5 遍。其它任何失败码一律交给默认策略,绝不干预 |
| ③ 给它一条走得通的路 | 最近 120 秒内循环过的 agent,下一次请求把 reasoningEffort 降一档(max → high → low → off)。当前档位不在这个阶梯里时是空操作(宁可不降,也不瞎猜档位名) |
这条链路在真机上验证过(一次空转 4.3 分钟、零产出的尝试):
22:18:23 step/start
22:18:27 起连续 5,450 个推理分片、产出 0
22:22:43 守卫触发,注入 finish{failure.code:'LOOP_GUARD'}(已耗时 260 秒)
失败到达重试层 → 守卫放行 1 次纠正重试(retriesAllowed=1)
22:23:56 该步正常产出 reasoning + text + tool-call + tool-call,step 正常收尾,后续 step 继续
也就是说:一次空转 4.3 分钟、零产出的尝试被拦下,一次纠正重试就恢复了正常产出。
口径说明:这一次是早期版本用一条更激进的规则(「已耗时 ≥ 240 秒且产出为 0」)拦下的;那条规则后来被删掉了,因为它会误杀「先长时间思考、再开始作答」的正常请求。它在这里的价值是证明了整条链路可用(掐断 → 纠正重试 → 恢复正常产出)。
2.2 防误杀设计(这是本插件最重要的取舍)
| 规则 | 会不会误杀「正常的长回答 / 长思考」 | 为什么 |
|---|---|---|
chunk |
不会 | 带「产出为 0」这个必要条件 —— 只要模型真的在出正文或调工具,它绝不插手,不管思考了多久 |
noOutput |
这是五条里最可能误杀的一条 | 同样要求「产出为 0」,所以「合法地思考超过 10 分钟才吐第一个字」会被它掐掉。默认给到 600 秒,实测没有这种用例(见 §8.3),如果你确实有超长思考的正常请求,优先调大它 |
stall |
基本不会 | 沉默就是沉默,有没有出过文本都拦。默认 3 分钟,离实测的 1.95 小时空档有两个数量级余量 |
repeat |
不会(但有边界) | 只在「行数远多于唯一行数」时命中,正常叙述每行都不一样。边界是「常量模板 + 只变计数器」这种形状,余量只有 1.06×(见 §8.2) |
absolute |
可能 | 不看产出,跑到 30 分钟无条件切断 —— 这是故意的最后兜底 |
2.3 它不做什么(边界)
- 不修复模型的循环。 循环是上游模型的退化行为;本插件做的是「尽早掐断 + 一次纠正重试 + 降一档推理档位」。真正的产出恢复取决于模型下一次尝试是否正常。
- 不保证一定解开卡死的上游连接。 上游彻底挂住时,
iterator.return()未必能打断那个挂起的next()(取决于适配器实现)。保证的是下游不会再被吊住:超时那一刻已经吐了终结块并结束自己的流。 - 不改写、不丢弃、不重排任何上游数据块,也不写任何会话数据。
- 不干预别人的失败。 只接管自己产生的
LOOP_GUARD,其它失败码一律next()。 - 不做遥测、不发网络请求。 唯一的落盘是一个设置文件(见 §6)。
3. 快速开始
前置:DSH 宿主能正常启动,且你的环境里有 plugin_manager 工具(带插件系统的 DSH 都有)。
方式一:从 GitHub 安装
dsh plugin add github:zuaonew-ops/dsh-loop-guard
这条链路未实测(见 §10 第 10 条);下面的方式二已实测。
方式二:克隆到本地,按目录安装(已实测)
git clone https://github.com/zuaonew-ops/dsh-loop-guard.git
然后在宿主里:
plugin_manager action=install_bundle target="<克隆下来的绝对路径>"
重启一次宿主
装完必须重启一次 DSH 宿主。 宿主对浏览器半区(dsh.client)的扫描是增量的,否定结论会缓存到进程重启;不重启的话守卫可能已经在工作,但设置页进不了浏览器的 boot graph(表现为左侧导航里没有「循环守卫」)。
确认装上了
- 打开 设置 → 左侧导航「循环守卫」;
- 或者问宿主:
curl -s http://127.0.0.1:<宿主端口>/loop-guard/state
期望看到:
| 字段 | 期望 |
|---|---|
ok |
true |
value.enabled |
true |
value.config |
12 个键,值与 §5.2 的默认值一致 |
value.stats.attempts |
随便发一轮对话后再查,数字会变大 —— 这就是「监听真的挂上了」的证据 |
value.stats.guarded |
通常是 0(只有真的出现那四种坏形态才会加一) |
value.recent |
通常是 [] |
完整的安装 / 升级 / 卸载 / 回滚 / 排障 / 端到端自检步骤见 docs/INSTALL.md。
4. 设置页
位置:设置 → 左侧导航 「循环守卫」(每 5 秒自动刷新)。
| 区块 | 内容 |
|---|---|
| 总开关 | 开启/关闭守卫(关掉后完全不干预,但设置页仍然可用) |
| 阈值 | 11 个数值输入框(时间类的按秒填,宿主内部按毫秒记;0–1 的比例直接填小数) |
| 降档开关 | 「重试时降低推理档位」 |
| 统计 | 尝试次数 / 拦下的循环 / 放行的重试 / 终止的重试 / 省下的时间(估算) |
| 按规则分布 | 五条规则各自的命中次数:分片洪水 / 卡住不动 / 慢速渗流 / 输出退化重复 / 绝对上限 |
| 最近触发 | 每次触发的时间 / turn·step / 推理分片数 / 已耗时 / 产出分片数(默认保留 50 条) |
| 操作 | 保存、清零统计 |
HTTP 端点(设置页用的就是这三个):
| 方法 | 路径 | 用途 |
|---|---|---|
GET |
/loop-guard/state |
当前开关 + 12 个可改配置项 + 统计 + 最近触发 |
POST |
/loop-guard/config |
改配置(只接受 13 个可改键的子集,未知键 400) |
POST |
/loop-guard/reset |
统计清零 |
统一信封 {ok:true, value} / {ok:false, error:{code, message}};请求体上限 64 KiB;方法不对返回 405 + Allow;路径不对返回 404。三道安全闸见 §6.3。
5. 配置
5.1 三层配置,谁覆盖谁
| 层 | 位置 | 生效时机 | 优先级 |
|---|---|---|---|
| ① bundle 补丁 | 包内 cordis.patch.yml 的 config: |
宿主启动 / 该行重载时 | 最低 |
| ② 设置文件 | $DSH_HOME/loop-guard-settings.json(DSH_HOME 未设时是 ~/.dsh/loop-guard-settings.json) |
只在插件挂载时读一次 | 覆盖 ① |
③ 设置页 / POST /loop-guard/config |
内存 + 原子写回 ② | 立即 | 覆盖 ① ② |
由此推出三条使用规则:
- 界面里改过的值,重启后仍然生效 —— 因为文件优先于 yml。
- 想回到 yml 的值:删掉设置文件,再重启宿主。
- 运行中直接改设置文件没有用 —— 文件只在挂载时读一次。要么用设置页,要么重启宿主。
5.2 全部 16 个键
「界面可改」= 能在设置页里改、并且会被写盘记住。共 13 个:总开关 + 11 个阈值 + 降档开关。剩下 3 个只能写在 cordis.patch.yml 里。
| 键 | 类型 | 默认值 | 取值范围 | 界面可改 | 什么时候该动它 |
|---|---|---|---|---|---|
enabled |
boolean | true |
true / false |
✅ | 想彻底关掉守卫(一条监听都不装,完全不干预)时设为 false |
reasoningChunkLimit |
number | 20000 |
整数 ≥ 100 |
✅ | 抓「分片洪水」的灵敏度。调小=更早掐断(更激进);调大=更宽容 |
stallTimeoutMs |
number | 180000(3 分钟) |
整数 ≥ 10000 |
✅ | 抓「一个分片都不来」。调小:网络慢/长排队时可能误杀;调大:真卡死时等更久 |
noOutputMs |
number | 600000(10 分钟) |
整数 ≥ 10000 |
✅ | 抓「慢慢来但一直不出结果」。五条里最可能误杀的一条:有超长思考的正常请求就调大它 |
repeatWindowChars |
number | 6000 |
整数 1000–200000 |
✅ | 重复检测的观察窗口。调大:能发现更长的循环周期,但 CPU 开销上升、判定更迟;调小:更快更省,但长周期循环可能看不出来 |
repeatMinChars |
number | 1000 |
整数 500–repeatWindowChars |
✅ | 判据的最低字符门槛(窗口短于它就不判)。设成等于 repeatWindowChars = 退回旧语义(只判满窗),这是「灵敏度回退」开关 |
repeatMinLines |
number | 30 |
整数 5–5000 |
✅ | 窗口里最少要有多少非空行。调大:只抓「很多行在重复」;调小:单行/少行退化更容易走片段兜底 |
repeatCycles |
number | 6 |
整数 2–100 |
✅ | 最高频那一行至少要重复几次。调小=更早判定;调大=要重复更多轮才算 |
repeatUniqueRatio |
number | 0.2 |
0.01–0.9 |
✅ | 唯一行占比上限。调大=更激进(更容易命中);调小=更保守 |
repeatGramRatio |
number | 0.10 |
0.01–0.9 |
✅ | 单行/少行退化的片段唯一率上限。调高会更容易误杀长单行自然语言(见 §5.4) |
attemptBudgetMs |
number | 1800000(30 分钟) |
整数 ≥ 10000 |
✅ | 绝对天花板,不看产出。调小:长任务会被切断(实测有一条一直在正常出文本的长流会在 12 分钟处被切);调大:真循环的兜底时间变长 |
maxGuardedRetries |
number | 1 |
整数 0–5 |
✅ | 掐断后允许宿主再纠正重试几次。0 = 拦到就终结;调大 = 多给几次机会(但每次都可能再烧时间) |
lowerEffortOnRetry |
boolean | true |
true / false |
✅ | 纠正重试时是否把推理档位降一档。关掉它就没有第 ③ 道防线 |
settingsPath |
string | ''(空) |
绝对或相对路径 | ❌ 只能写 yml | 想换设置文件的落盘位置时填;留空 = $DSH_HOME/loop-guard-settings.json |
maxRecent |
number | 50 |
整数 1–500 |
❌ 只能写 yml | 设置页「最近触发」保留多少条 |
debug |
boolean | false |
true / false |
❌ 只能写 yml | 排障:把每次拦截写进宿主日志 |
GET /loop-guard/state的value.config里是 12 个键(不含enabled,它在value.enabled里单独给)。「12」和「13」不矛盾:13 = 可改且可持久化的键,12 = 其中「阈值与行为」的部分。每个键的默认值来历、调大调小的副作用、以及
repeat判据全貌见 docs/CONFIG.md。
5.3 校验规则(三处输入源严格程度不同)
| 情况 | 结果 |
|---|---|
cordis.patch.yml 里写了不存在的键 / 类型不对 / 数字越界 / repeatMinChars > repeatWindowChars |
抛错并点名,这一行加载失败 |
| 设置页里填了非法值 | 只在本页提示,不发请求(11 条针对性文案,中英双语) |
POST /loop-guard/config 收到未知键 / 类型错 / 越界 |
400 + unknown-key / bad-type / bad-range |
| 设置文件里单键数值越界或不是整数 | 忽略该值 → 退回 cordis.patch.yml 的值,并打一条告警(不抛错,插件照常挂载) |
设置文件里 repeatMinChars > repeatWindowChars(跨字段,合并后判定) |
夹紧到窗口大小 + 告警。判越界看的是「生效窗口」,不是出厂默认的 6000 |
| 设置文件是坏 JSON / 内容不是对象 | 告警 + 忽略整个文件(退回 cordis.patch.yml 的值) |
| 设置文件里有不认识的键 / 类型不对 | 静默丢弃该键(不告警) |
一句话:设置文件里写什么都不会让插件挂载失败,设置页一定打得开;坏值只会被「退回 yml 的值」或「夹紧到生效窗口」并留一条告警(一个坏键一条)。而 cordis.patch.yml 是「严进」的 —— 那是你手写的正式配置,写错就该报错。
5.4 三条「不要踩」的
| 别踩 | 为什么 |
|---|---|
别把 repeatGramRatio 往上调 |
它一调高就更容易误杀长单行自然语言:实测中文自然散文单行约 0.36、英文约 0.45,而真退化单行只有 0.007–0.009。默认 0.10 |
别把 stallTimeoutMs / noOutputMs / attemptBudgetMs 调到 10000 以下 |
在 cordis.patch.yml 里会被配置校验直接拒绝(抛错并点名);在设置页里会被本地拦下(不发请求);在设置文件里会被忽略并退回 yml 的值 + 告警 |
别在 cordis.patch.yml 里写不存在的键 |
未知键直接抛错,会让这一行加载失败(不静默兜底) |
6. 安全与隐私
6.1 它接触什么、写什么
| 项 | 说明 |
|---|---|
| 网络 | 不出网:无第三方依赖、无 HTTP 客户端、无遥测/统计上报 |
| 上下文数据 | 只在内存里逐块计数与做窗口指标;不改写、不丢弃、不重排、不落盘任何数据块 |
| 会话数据 | 不读写任何会话文件/数据库 |
| 落盘 | 只有一个设置文件($DSH_HOME/loop-guard-settings.json,13 个键,原子写) |
| 统计 | 只在内存(尝试数 / 命中数 / 最近 50 条触发的计数与规则名;不记录对话正文) |
| 请求参数 | 降档只在最近 120 秒内循环过的 agent 的下一次请求上生效,且只改 reasoningEffort 一个字段 |
6.2 安装与卸载的写入面
- 安装(
install_bundle)会在 profile 的cordis.yml/cordis.patch.yml里落一行insert(id: loop-guard/name: dsh-loop-guard/config: {}),并把包内容放进插件目录。 - 卸载(
remove_bundle)会移除那一行;设置文件不会自动删(见 §7)。
6.3 HTTP 端点的三道安全闸
设置页的三条路由统一过闸:
| 闸 | 不满足时 |
|---|---|
Sec-Fetch-Site: cross-site |
403 + cross-site |
Origin 存在但与本机不同源 |
403 + bad-origin(Origin 缺失时放行,所以 curl 能用) |
POST 的 content-type 不是 application/json |
415 + unsupported-media-type |
为什么需要这三道闸:DSH 的 webServer 路由契约是 {kind, path, handler},没有全局 Origin/CSRF 防护,插件路由也不在 GUI 的登录门后面。没有闸的话,任意网页都能用 content-type: text/plain 发跨站简单请求打到这里并让服务端真的执行。
另:请求体 > 64 KiB → 413;body 不是合法 JSON → 400;路径对但方法不对 → 405 + Allow。
7. 卸载与回滚
7.1 临时停用(最轻)
设置页把总开关关掉,或在 cordis.patch.yml 的 config 里写 enabled: false 后重载该行。三条监听一条都不装,完全不干预。
7.2 卸载
plugin_manager action=remove_bundle target="dsh-loop-guard"
然后重启宿主。残留与清理:
| 残留 | 位置 | 要不要清 |
|---|---|---|
| 设置文件 | $DSH_HOME/loop-guard-settings.json(DSH_HOME 未设时是 ~/.dsh/loop-guard-settings.json) |
想彻底干净就删掉它 |
| profile 里的那一行 | profile 的 cordis.yml |
remove_bundle 会处理;若手工改过 yml,请自行确认 |
| 统计 | 无 | 统计只在内存里,不落盘,重启即消失 |
7.3 回滚到旧版本
- 把旧版本的
dsh-loop-guard目录放回原处(或从仓库切到旧的 tag/commit); - 重新
install_bundle并重启宿主; - 设置文件是「宽进」的:旧版本不认识的键会被静默丢弃,越界值会被退回 yml 的值 —— 降级不会让插件挂载失败(见 §5.3)。
恢复出厂配置:删掉设置文件 + 重启(之后就完全按 cordis.patch.yml 里的值走)。完整流程见 docs/INSTALL.md §5–§7。
8. 实测数据
所有数字都来自作者开发机上已经发生过的真实会话记录,用插件自己的判定函数做离线重放得到;重放脚本是外部工具,不在本仓库内(见 §11.4),因此这些数字无法在仓库里一键复现。语料含真实对话,属于个人数据,没有随仓库发布。
8.1 全语料重放(判定正确性)
| 项 | 值 |
|---|---|
| 会话 / 记录 / 块 / 字符 | 340 / 14,764 / 21,072 / 42,413,941 |
| 解码错误 | 0 |
| 命中记录数 | 222(216 + 6,全部走 repeat 的行口径) |
| 块级命中 | 218 |
| 非退化会话命中 | 0(记录级 0 / 块级 0) |
| 7 个已知真退化短块 | 7 / 7 全部命中(触发窗口 1,024 / 1,024 / 2,560 / 2,048 / 1,536 / 1,024 / 2,048) |
| 旧判据(v0.4)的 23 个命中 | 一个不丢(记录级与块级都 0 丢失) |
不同次重放的记录/块/字符数会略有差异(14,346 → 14,764),因为语料在持续增长(同一台机器上的会话还在追加),不是判定变了 —— 命中数(222)、分布(216 + 6)、非退化(0)、短块(7/7)每次都相同。
8.2 误杀余量(最关键)
剔除两个已知退化会话之后,正常文本离阈值还有多远:
| 判据 | 正常文本里最接近阈值的值 | 阈值 | 余量 |
|---|---|---|---|
| 片段唯一率(k-gram 兜底) | 0.4032 | 0.10 | 4.03× |
| 唯一行占比(行判据) | 0.2574(一个 markdown 围栏块) | 0.20 | 1.29× |
| 「常量模板 + 只变计数器」这种形状 | 0.1060 | 0.10 | 1.06× |
另外 6,483 个非退化块里,落在「危险区间」(0.10, 0.20] 的有 0 个。
不能只报一个数:全语料口径是 4.03×,但「模板 + 计数器」这种形状只剩 1.06×,而且子窗有 ±30% 的相位噪声(同一段文本平移 0–255 字符,最差子窗的片段唯一率在 0.1060–0.1720 之间漂移)。所以:
- 语料里 0 误杀是真的;
- 但「正常文本至少离阈值 3.92×」不成立 —— 灵敏度在 v0.5 提高了(分段取最坏值恒 ≤ 整窗值),「常量模板 + 只变计数器」这类输出会比以前更早被判死。回退办法:把
repeatMinChars设成等于repeatWindowChars,即退回旧语义(只判满窗)。
8.3 时间类规则的误杀面上界
| 指标 | 有产出的真实 LLM 调用 | 零产出的调用 |
|---|---|---|
| 样本数 | 14,750 | 16 |
| p50 / p90 / p99 | 0.13 / 0.31 / 0.95 分钟 | 0.24 / 2.76 / 3.39 分钟 |
| 最长 | 7.48 分钟 | 3.39 分钟 |
| ≥ 180 秒 | 8 次 | — |
| ≥ 300 秒 | 3 次 | — |
≥ 600 秒(noOutputMs 默认线) |
0 次 | 0 次 |
≥ 1800 秒(absolute 默认线) |
0 次 | 0 次 |
结论:600 秒这条线高于全部观测样本,观测误杀面为 0;但余量只有 1.34×(600 ÷ 449 秒)—— 真正会被误杀的只有「思考超过 600 秒才吐出第一个正文/工具分片」的调用,14,750 次里一次都没有。
8.4 性能开销(CPU)
| 口径 | 修复前 | 现在 | 倍数 |
|---|---|---|---|
| 默认窗口(6000 字符),每 1000 字符输出 | — | 3.25 ms | — |
| 真实流分片(中位 2 字符,即最坏喂料粒度),每 1000 字符 | 106 ms | 3.34 ms | 约 30× |
| 大窗口 200,000 字符,每 1000 字符 | 307 ms | 14.33 ms | 约 21× |
| 全语料重放墙钟 | 445.5 s | 165.6 s | 约 2.7× |
| 指标重算次数 | 301,756 | 173,716 | 约 0.58× |
另一条要分开看的开销:分段判据本身比「整窗等价实现」贵 —— 窗口 6000(19 个子窗)时
recomputeRepeat平均 1,034.2 µs/次,而整窗等价实现是 150.6 µs/次(6.87×)。这是 v0.5「取最坏子窗」换来的灵敏度代价,不是退化;上表说的是相对更早版本的整体提速。
8.5 验证强度
- 17 个套件 = 1,126 条断言 PASS / 0 FAIL(unit 106 / packaging 43 / host 85 / contract 51 / contract-f11 24 / gram-falsify 23 / falsify 12 / client 108 / http 67 / v5-checks 41 / throttle 11 / endgate 15 / rename-diff 33 / 作者单测 407 / 冒烟 71 / live 17 / corpus 12)。跑动前后各取一次
index.js哈希,两次相等 → 跑动中没有第三方写入。 - 作者单测 407 PASS / 0 FAIL(含「设置文件里的坏值怎么处理」「逐键校验基准用生效窗口、不用默认 6000」等回归钉)。
- 一轮独立复现:复现者自己重写了一遍判据(只读状态字段,不复用插件内部逻辑),在 5 个样本 × 4 组配置 × 每个节流点共 2,980 个采样点上与插件的
detectTrip逐点一致;并独立重放了全语料,命中数与作者侧逐项一致。 - 同一轮复现用
Proxy追踪状态字段读取,机械证明了「stall/noOutput判定路径一次都不读repeat的字段」(返回这两条规则时读取次数 = 0,不是靠论证);并在 135 种文本形状 × 3 组配置 = 405 次流尾补算里,对「行口径与片段口径都还有 >1.5× 余量」的形状误判 0 次。 - 上述套件与离线重放脚本都不在本仓库内,是作者的验证工具(见 §11.4)。
8.6 数字口径提醒
- 各次重放的记录数/块数/字符数不一样(14,346 → 14,764),原因是语料在持续增长;命中数与分布每次都相同。
- 「221 / 222」两个命中数并存:222 = 含「流尾补算」的最终口径,221 = 不含它的旧口径(只有 1 条靠流尾补算才命中)。
- 界面上的「省下的时间」是估算:每次触发时按「这次尝试本来还会跑到
attemptBudgetMs(30 分钟)」算差值再求和(absolute那条已跑满预算,按 0 计,不虚报)。它不是实测省下的墙钟时间。 - 「
chunk档约 4.9 分钟命中」是按整步平均速率推算的,不是实测。
9. 已知限制
照实写,不修饰:
| # | 限制 |
|---|---|
| 1 | 它治不好模型的循环,只能快速止损。 真正的产出恢复取决于模型下一次尝试是否正常 |
| 2 | 它不能保证一定解开卡死的上游连接。 上游彻底挂住时,iterator.return() 未必能打断那个挂起的 next();保证的是下游不会再被吊住 |
| 3 | noOutputMs 是五条里最可能误杀的一条。 余量只有 1.34×(600 ÷ 449 秒)。有超长思考的正常用法请调大 |
| 4 | repeat 的灵敏度在 v0.5 提高了。 「常量模板 + 只变计数器」这种形状的余量只剩 1.06×,且子窗有 ±30% 相位噪声。要退回旧语义就把 repeatMinChars 设成等于 repeatWindowChars |
| 5 | 全部命中都来自两个会话。 语料里 222 次命中只落在两个已知退化会话上(216 + 6),证据强度受这两个会话的多样性限制 |
| 6 | 统计不落盘。 只放内存:宿主重启、或点「清零」,统计就归零。只有那 13 个可改配置项会写盘 |
| 7 | absolute(30 分钟)对「一直在正常出文本的超长任务」是硬切。 观测数据里有产出的调用最长 7.48 分钟、没有 ≥30 分钟的,但这是观测上界,不是保证 |
| 8 | 内部调用(压缩会话、生成标题等)同样受约束。 它们的正常输出是文本(产出 > 0),所以 chunk / noOutput 不会命中;但若某次压缩合法地零产出超过 10 分钟会被掐(观测里零产出调用最长 3.39 分钟) |
| 9 | 降档只对广告了推理档位的模型有效。 当前档位不在 off/low/high/max 阶梯里时是空操作(宁可不降,也不瞎猜) |
| 10 | 多开宿主会共用同一份设置文件。 设置文件按 $DSH_HOME 定位、与 profile 无关;而且只在挂载时读一次。所以 A 实例保存的设置不会立刻影响已在运行的 B 实例,两者各自写盘、互相覆盖 |
10. 未验证项
以下条目没有实测证据,单独列出(不要当成已验证):
| # | 未验证的事 | 现状 |
|---|---|---|
| 1 | 真实浏览器里的设置页交互 | 只做过桩测试(真执行模块工厂 + mini-React 渲染 + 真点击,108 项断言)。真实浏览器里的人工点击没验证过 |
| 2 | 分段判据在真机上的触发 | 真机历史里的命中记录是旧版本的片段兜底命中(2 条)。本轮的 222 次命中全部是离线重放,不是活的流 |
| 3 | stall 在其它 provider 上的误杀面 |
只在一个 provider 上观测过。残余风险:适配器自身没有空闲超时 且 模型合法地 >3 分钟不吐分片 |
| 4 | repeatWindowChars 调到 200000 的真实会话行为 |
只测了 CPU 开销与单元级判定,没有真实长会话跑过这个配置 |
| 5 | 流尾补算路径的内存/GC 影响 | 只测了判定正确性与 CPU 时间,没有测内存 |
| 6 | 「恰好结束于 noOutputMs 边界」的活流 |
单测覆盖了,真实复现做不到(阈值下限 10000 ms) |
| 7 | 纠正重试的成功率 | 只有 1 个真机样本(就是 §2.1 那条链路)。「1 次纠正重试通常够不够」没有统计意义 |
| 8 | repeat 的 lineTrip 分支在真机上的记录 |
旧版本报告里有一次文字记录,但不在当前 recent 里、无法独立复核 |
| 9 | 真实收益的墙钟数字 | §8.6 的「省下的等待」里,触发点是按速率推算的;界面上的「省下的时间」也是估算值 |
| 10 | 打包/发布链路本身 | 本文描述的是本地目录安装与离线验证;从 GitHub 公开仓库安装(dsh plugin add github:…)、版本升级路径未实测 |
11. 开发与测试
11.1 仓库结构
| 文件 | 作用 |
|---|---|
index.js |
插件宿主半区:三条监听(llm/stream / agent/request-error / agent/request)、五条判定规则、设置文件读写、HTTP 路由、settings.section 界面注册 |
client.js |
设置页(浏览器半区):渲染 UI、校验输入、调三个端点。不在浏览器里调用任何第三方库(DICT 内置中英双语文案) |
cordis.patch.yml |
bundle 补丁:往 cordis.yml 里插一行(id: loop-guard / name: dsh-loop-guard / config: {}) |
locale/{zh,en}.json |
设置页的文案(meta + ui.loopGuard 两段) |
docs/ |
INSTALL.md / CONFIG.md / FAQ.md / MANIFEST.sha256(交付清单) |
icon.svg |
插件图标 |
没有任何构建步骤:index.js 与 client.js 都是 ESM 源文件,改了直接生效(刷新生效方式见 §11.2)。仓库里没有 node_modules、没有打包器、没有第三方依赖 —— 装上就能用,也不引入新的供应链面。
11.2 改完代码怎么生效
| 改了什么 | 怎么生效 |
|---|---|
index.js |
重载那一行插件,或直接重启宿主 |
client.js / locale/*.json |
重启一次宿主 + 刷新页面(浏览器半区是启动时扫描的) |
cordis.patch.yml 里的 config |
重载该行即重新挂载 → 会重新读一次设置文件(所以「改 yml 让文件重新生效」也是一招) |
| 设置文件 | 重新挂载(重启或重载该行)才读得到新值 —— 运行中改文件无效 |
重载命令(entry id 是 include:<补丁 id>):
set_plugin target="include:loop-guard" enabled=false
set_plugin target="include:loop-guard" enabled=true
写裸的
loop-guard会报unknown-plugin—— 补丁插入的行是include:前缀的。
11.3 静态检查
node --check index.js
node --check client.js
node -e 'for (const f of ["locale/zh.json","locale/en.json","package.json"]) JSON.parse(require("fs").readFileSync(f,"utf8")); console.log("json ok")'
node -e 'const fs=require("fs"),c=require("crypto");let bad=0;for(const l of fs.readFileSync("docs/MANIFEST.sha256","utf8").split(/\r?\n/)){const m=l.match(/^([0-9A-F]{64}) +(\d+) (.+)$/);if(!m)continue;const b=fs.readFileSync(m[3]);if(b.length!==+m[2]||c.createHash("sha256").update(b).digest("hex").toUpperCase()!==m[1]){bad++;console.error("BAD "+m[3])}}console.log(bad?"FAIL":"manifest ok");process.exit(bad?1:0)'
(交付清单是三列格式:哈希 / 字节数 / 路径,sha256sum -c 不适用;最后一条是等效的逐条校验。)
11.4 关于测试
本仓库不含测试套件,也没有「一键跑完」的入口。上文 §8 的验证是这样做的:
- 用插件自己的判定函数,对已经发生过的真实会话记录做离线重放(外部脚本,不在仓库);
- 加上作者自己的单测与套件(
unit/falsify/contract/host/packaging等 17 个套件,也是外部工具,不在仓库); - 另请他人独立重写判据做交叉复现(见 §8.5)。
所以如果你是来改判定逻辑的:建议先搭一个「已确认无退化」的会话反例集,改完用 detectTrip 对着它跑一遍,确认 repeat 命中数与非退化命中数都没变,再上真机。
12. 兼容性
| 项 | 要求 | 不满足时 |
|---|---|---|
| DSH 宿主 | 有 plugin_manager(bundle 安装)与 settings.section 插槽 |
装不上 / 设置页不出现 |
| 设置页 | 宿主启用了 webServer |
守卫照常工作,只是没有 /loop-guard/* 路由、设置页打不开 |
| Node | 宿主自带的 Node(无需额外安装) | — |
| 平台 | 只用 Node 内置模块(fs / path / os / crypto 等),无原生扩展 |
— |
| 模型 / provider | 完全无关:只看 llm/stream、agent/request-error、agent/request 三个事件 |
— |
两种半区各自独立:只装宿主半区也能拦截,只是没界面;只装浏览器半区没有拦截、没有数据源。
13. 许可
MIT © 2026 zuaonew-ops。见 LICENSE。
用之前请先读 §9「已知限制」与 §10「未验证项」—— 这是一个止损工具,不是质量保证。
No comments yet. Be the first to write one.