# dsh-guard-repeat-output

**在模型陷入重复循环、把上下文烧光之前拦住它。**

[DSH](https://github.com/deepseek-ai) 的插件。它盯着模型的输出流，一旦发现车轱辘话，立刻掐掉。

**语言：** [English](./README.md) | 简体中文

---

## 三十秒了解

模型偶尔会卡住。本该把话说完，它却开始一遍遍重复同一句：

```
let me write the call let me write the call let me write the call ...
```

没人拦着的话，它会一直重复下去——也一直烧 token——直到自己停下，或者被人手动取消。这些废话全部留在上下文里，把真正有用的内容挤走。

这个插件做两件事：

- **输出过程中管**：实时检测，发现循环就丢弃这一次的回复，重新问一遍。
- **事后也能救**：如果某个会话早就被污染了（比如插件装上之前就出过问题），`/guard-cleanup` 能把历史里那些废话块遮掉，让它不再被反复重放。

实际效果（生产环境跑了约两天）：拦下 **12 次**真实重复崩塌，**全部恢复成功，0 次失败**；被丢弃的文本**一次都没有混进会话历史**。

## 问题有多严重

不是罕见情况。生产服务器上抓到的两次真实事故：

| 长度 | 内容 |
|---|---|
| 137 万字符 | `"Let me produce."` 重复 27,269 次 |
| 25 万字符 | `"Let me write."` 重复 1,958 次，与 `"Let me write the call."` 重复 1,952 次交替出现 |

过程中没有任何机制察觉。这一轮继续跑、继续计费，直到模型自己停下或有人取消。这些 token 之后全部堆在上下文窗口里。

## 它怎么工作

### 输出进行中

1. **清洗乱码** —— 控制字符、替换符、零宽字符这类东西本来就不该出现在回复里，在转发前直接删掉，不让它们进日志。这一步不需要判断，见到就删。如果乱码特别密集（比如连着一长串 NUL 字节），说明模型已经崩了，直接当异常处理。

2. **检测重复** —— 在模型的思考内容里滚动检查，看短语重复程度、信息量、段落结构。

3. **丢弃** —— 判定异常后，这一次的回复不算数，不会被当成正常消息存下来，所以那些废话不会进入对话历史。

4. **重试** —— 原样重新问一遍，不改任何参数。为什么不改？见下一节。

5. **收尾** —— 重试次数用完后，在估计的起点处切断，并让模型收个尾，保证这一轮正常结束而不是报错失败。

### 事后清理

6. **`/guard-cleanup`** —— 扫描会话历史，找出已经存进去的重复内容，用一小段说明把它遮住。这样下次请求就不会再重放它。**不会删除任何数据**，原始内容仍保留在日志里供审计。

   为什么需要这个：实时检测救不了它装上之前就已经发生的问题。有一次真实事故，插件上线后发现上下文里仍然塞着 **20 万字符**的废话（两个块，分别 11 万和 9 万字符），每次请求都在重放——正是这些内容在诱导模型再次进入循环。

   **一条安全规则**：如果某条消息里带着工具调用，就跳过它、不遮。因为遮掉它会让它调用的工具结果变成孤儿，服务方会因此拒绝整个请求。真正失控的思考过程不会带工具调用（那次事故里 72 万字符的块，工具调用是 0 次），所以这条规则基本不影响效果。

### 为什么重试时不调整推理档位

早期版本会在重试时把推理档位降一档，想法是「换个参数也许就不重复了」。实测之后删掉了：

| 观察 | 结果 |
|---|---|
| 降档后恢复的次数 | 9 / 9 |
| 但其中之后又再次崩塌的 | **6 / 9（67%）** |
| 降档被服务方拒绝的次数 | 7 次，其中 **3 次直接把这一轮搞失败** |

**67% 这个数字是关键**：降档并没有让模型免疫，真正起作用的是「重新生成一遍」。而每次被拒绝都是同一个原因——服务方不接受模型没有声明的档位（比如 `does not support reasoning effort "medium"`）——本来已经拦下的问题，反而变成了整轮失败。**一个补救措施如果能把正在抢救的那一轮弄死，那还不如不做。**

测量脚本保留在 [`verify/effort-necessity.mjs`](./verify/effort-necessity.mjs)，你可以自己复现这个结论。

## 兼容性

**支持的 DSH 版本范围：`>=0.1.6-alpha.2 <0.3.0-0`。**

| DSH | 状态 |
| --- | --- |
| `0.2.1-alpha.2` | 已验证——能启动、guard 处于 active、配置可解析 |
| `0.2.0-rc.2` | 已验证——真实流量（[`CHANGELOG.md`](./CHANGELOG.md) 里记的那些定罪就是它跑出来的） |
| `0.1.6-alpha.2` … `0.2.0-rc.1` | 声明的下界，与同作者另两个插件一致；未做机器验证 |
| `>=0.3.0-0` | **不支持** |

这个插件不从 `@deepseek-ai/*` 里 import 任何东西——只依赖 Node 内置模块和自身文件——所以没有包层面的版本耦合。它真正耦合的是四个宿主接缝：

| 接缝 | 用途 |
| --- | --- |
| `llm/stream` | 包住 delta 流——整个实时防护就挂在这里 |
| `agent/request-error` | 重新发起被丢弃的那次尝试 |
| `agent/pre-step` | 每步的重试预算与续写消息 |
| `commands`（经 `ctx.inject`） | 注册 `/guard-cleanup` |

`agents` 和 `sessionQuery` 都通过 `ctx.get(...)` 读取，取不到只会返回 `undefined` 而不会抛错，所以某个服务缺失只会让对应功能退化，不会让插件起不来。但宿主一旦改名或移除上面这四个接缝，对本插件就是破坏性变更。

包里把这个范围声明成了对 `@deepseek-ai/dsh` 的 `peerDependencies`，所以 DSH 插件管理器的兼容闸门会**强制**它：在范围外的版本上安装或启用会被直接拒绝，而不是等到运行时才失败。闸门比较时**包含预发布版本**，所以上面列的 alpha / rc 版本能正常匹配，不会被跳过。拒绝并非永久——确认风险后可以用插件管理器授予「精确版本豁免」（`dsh plugin allow-version`，或插件管理器界面）。只有在核对过上面那几个宿主接缝确实没变之后，才该这么做。

## 安装

**推荐：在 DSH 网页界面里装。** 打开侧边栏的「插件」，安装 `dsh-guard-repeat-output` 并启用。管理器会读取包里的 `dsh.bundle` 声明，自动把它注册成一个 profile 层——**不需要你写任何配置**。

**手动安装。** 装包和注册成插件层是**两件独立的事**，只做前一件的话，包装上了但永远不会被加载：

```bash
dsh plugin --profile web add dsh-guard-repeat-output
```

```jsonc
// <profile>/package.json —— 把包名加进 bundle 列表
"dsh": { "profile": { "bundles": [
  "@deepseek-ai/dsh-base",
  "@deepseek-ai/dsh-web-app",
  "dsh-guard-repeat-output"
] } }
```

**不要**把配置块抄进去。包内已经自带一份完整的 patch（[`cordis.patch.yml`](./cordis.patch.yml)），包含所有默认值，插件行就是它插入的。

### 改默认值

所有默认值都由包内的 patch 生效了。要改某一项，往你 profile 的 `cordis.patch.yml` 里加一段按 id 定位的 patch 即可——用户层会在所有 bundle 层之后应用：

```yaml
- id: guard-repeat-output
  config:
    maxDegenerationRetries: 3
    modelIncludes: ['deepseek', 'glm']
```

## 配置

每个选项的详细说明（以及默认值是怎么测出来的）都写在 [`cordis.patch.yml`](./cordis.patch.yml) 里。常用的几个：

| 选项 | 默认值 | 作用 |
|---|---|---|
| `modelIncludes` | `['deepseek']` | 只对这些模型生效，按 id 子串匹配。**空数组 = 所有模型**。 |
| `truncateChannels` | `['reasoning']` | 允许拦截哪些内容。其它内容只观察、不干预。 |
| `maxDegenerationRetries` | `2` | 重试几次后放弃重试，改为截断收尾。 |
| `holdbackChars` | `4096` | 提前扣留多少字符，好让切断点落在真正的起点。 |
| `sanitizeGarbage` | `true` | 是否清洗乱码字符。 |
| `cleanupCommand` | `true` | 是否注册 `/guard-cleanup` 命令。 |
| `copyDir` | `null` | 把丢弃的内容另存一份，方便事后分析误报。**绝不会被读回请求**。 |
| `logPath` | `null` | 记录每次判定的日志文件，方便事后排查。 |

> **关于 `modelIncludes` 的两种「空」**：`[]` 是有意表示「所有模型」。但如果你写的是 `['   ']`（有内容但全是空格），会被**拒绝并报错**——因为它看起来是你想限定范围、只是写错了。如果静默当成「所有模型」，结果和你的意图正好相反；而所有阈值都只在 DeepSeek 上验证过，贸然放开风险更大。

### 路径写法

`copyDir` 和 `logPath` 在各平台上写法一致，不假设你在用 Linux：

| 写法 | 解析结果 |
|---|---|
| `logs/guard.log` | `$DSH_HOME/logs/guard.log`（没有则 `~/.dsh/logs/guard.log`） |
| `~/logs/guard.log` | 用户主目录下 |
| `/var/log/guard.log` | 原样使用（Linux / macOS 绝对路径） |
| `C:\logs\guard.log` | 原样使用（Windows 绝对路径） |
| `null` | 关闭该功能 |

相对路径是相对 DSH 主目录，不是相对当前工作目录（后者每次启动都可能不同）。路径不会用字符串拼接，所以 Windows 下不会出现混用的分隔符。

## 开发

### 它挂载在哪

- `llm/stream` —— 清洗、检测、拦截。
- `agent/request-error` —— 重新发起被判定为退化的请求。
- `agent/pre-step` —— 兜底：确保重试时提示语一定送到。
- `commands` —— 注册 `/guard-cleanup`（服务可用时）。

注意它**没有**挂 `agent/request`：重试时的请求和原来完全一样，这是刻意的。

### 测试

```bash
# 语料是真实抓取的模型输出，不随包发布。
# 把第一个参数指向任何包含 legit/L00001.txt 和 unreg/deg1-seq2608.txt 的目录。
node verify/integration.mjs <corpusRoot>   # 端到端检测与干预
node verify/mutations.mjs                  # 检测器对照测试
node verify/cross-platform.mjs             # 路径解析与文件名安全
node verify/maintenance.mjs                # 清洗与事后清理
node verify/config.mjs                     # 配置校验
```

`verify/effort-necessity.mjs` 是运维诊断脚本，不是自包含测试：它读取实际运行的守卫日志和会话目录，用来复现上面的测量结论。

## 许可证

MIT
