dsh-dupguard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/lib/index.js +215 -0
- package/package.json +47 -0
- package/plugin/host.js +212 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
本文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
|
|
5
|
+
## [1.0.0] - 2026-08-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 首次发布:实时检测流式输出中的连续重复(默认同一字符串 ≥10 次)并立即停止生成。
|
|
10
|
+
- 双入口交付:`plugin/host.js`(动态插件)与 `lib/index.js`(npm/组合常驻),行为一致、同一测试套件防漂移。
|
|
11
|
+
- 端到端测试(15 项 × 2 入口)与 GitHub Actions CI(Node 18/20/22)。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zqh260619
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# dupguard · DSH 大模型重复输出守卫
|
|
2
|
+
|
|
3
|
+
> **dupguard** — a real-time repetition guard for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness): stops model generation as soon as the same string repeats **≥ 10 times** (configurable) in the streamed output.
|
|
4
|
+
>
|
|
5
|
+
> **dupguard** 是 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 的实时重复输出守卫插件:当最新输出中同一字符串连续重复 **10 次及以上**(可配置)时,立即停止本次生成。
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-dupguard)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://github.com/zqh260619/dsh-dupguard/actions/workflows/ci.yml)
|
|
10
|
+
[](https://github.com/topics/dsh-plugin)
|
|
11
|
+
|
|
12
|
+
触发后,已生成的内容会**正常提交为助手消息**,本轮对话干净结束——不会报错、不会丢弃输出、不会污染会话日志。
|
|
13
|
+
|
|
14
|
+
When triggered, the already-generated text is committed as a normal assistant message and the turn ends cleanly — no errors, no lost output, no session-log pollution.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 特性 / Features
|
|
19
|
+
|
|
20
|
+
- **实时检测**:逐 token(`text-delta`)检测,复读出现即停,延迟为单个增量。
|
|
21
|
+
- **多种复读形态**:单字符循环、词语循环、带空格/换行分隔的复读均能识别(默认去空白后检测)。
|
|
22
|
+
- **真正的服务端停止**:提前关闭流迭代 → 适配器 `consumer.abort()` → 中断 HTTP 连接,模型在服务端停止生成。
|
|
23
|
+
- **安全停止**:绝不 `abort()` agent 步骤信号;补发协议合规的 `block-end` + `finish(stop)`,消息正常提交。
|
|
24
|
+
- **零依赖 / 零配置**:纯 JavaScript,无运行时依赖;默认配置开箱即用。
|
|
25
|
+
- **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js`),行为一致、CI 防漂移。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 快速开始 / Quick Start
|
|
30
|
+
|
|
31
|
+
### 方式一:动态插件(无需安装,进程内生效)/ Dynamic plugin (no install)
|
|
32
|
+
|
|
33
|
+
把 [`plugin/host.js`](plugin/host.js) 的全部内容作为 `code.host` 提交给 `cordis_define`,再 `cordis_run` 激活即可:
|
|
34
|
+
|
|
35
|
+
1. `cordis_define`:kind 选 `new`,idPrefix 例如 `dupguard`,`code.host` 填入 `plugin/host.js` 内容;
|
|
36
|
+
2. `cordis_run`:激活返回的 `packageId`(首次使用 mode `run`)。
|
|
37
|
+
|
|
38
|
+
动态插件随 DSH 进程存在;重启后需重新 define + run。
|
|
39
|
+
|
|
40
|
+
Paste the entire content of [`plugin/host.js`](plugin/host.js) as `code.host` in `cordis_define`, then activate the returned `packageId` with `cordis_run`.
|
|
41
|
+
|
|
42
|
+
### 方式二:npm 安装 + 组合挂载(常驻)/ npm + composition (persistent)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install dsh-dupguard
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
然后在你的 preset 组合文件(cordis.yml)中加入本插件的行并重启 DSH。具体挂载写法以官方发布指南为准:
|
|
49
|
+
[deepseek-ai/deepseek-harness · docs/user/develop/basic/publish.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.zh.md)(npm 生态与发布流程)。
|
|
50
|
+
|
|
51
|
+
Then add the plugin row to your preset composition (cordis.yml) and restart DSH; follow the official [publish guide](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md) for the exact mounting syntax.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 配置 / Configuration
|
|
56
|
+
|
|
57
|
+
修改 `plugin/host.js` 或 `lib/index.js` 顶部 `CONFIG` 常量(两个入口需保持同步,CI 会校验一致性):
|
|
58
|
+
|
|
59
|
+
Edit the `CONFIG` block at the top of `plugin/host.js` / `lib/index.js` (both entries must stay in sync; CI verifies behavioral parity).
|
|
60
|
+
|
|
61
|
+
| 配置项 / Option | 默认 / Default | 说明 / Description |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `threshold` | `10` | 触发阈值:同一字符串连续重复 ≥ 该值时停止 / stop when the same string repeats ≥ this many times |
|
|
64
|
+
| `minUnitLength` | `1` | 最小重复单元长度 / minimum repeating-unit length (`1` also catches single-char loops like `aaaaaaaaaa`) |
|
|
65
|
+
| `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
|
|
66
|
+
| `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后)/ rolling detection window in chars (after whitespace removal) |
|
|
67
|
+
| `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
|
|
68
|
+
| `monitorReasoning` | `false` | 是否检测思考文本 / also guard reasoning (thinking) text — off by default, high false-positive risk |
|
|
69
|
+
| `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 工作原理 / How it works
|
|
74
|
+
|
|
75
|
+
### 1. 拦截流式输出 / Intercept the stream
|
|
76
|
+
|
|
77
|
+
监听 `llm/stream` 瀑布事件(包裹每次流式模型调用),返回包装后的 `AsyncIterable`。与 DSH 自带
|
|
78
|
+
`@deepseek-ai/dsh-llm` invariant 插件、`dsh-session-checkpoint-policy` 同款接入方式。
|
|
79
|
+
|
|
80
|
+
Listens to the `llm/stream` waterfall (wraps every streaming model call) and returns a wrapped `AsyncIterable`.
|
|
81
|
+
|
|
82
|
+
### 2. 检测算法 / Detection
|
|
83
|
+
|
|
84
|
+
- 按块索引(`chunk.index`)分别累积文本,多块交替输出互不干扰;
|
|
85
|
+
- 去空白后做**尾部连续重复检测**:文本以某个单元(长度 1..80)连续重复 ≥ 阈值结尾即触发。
|
|
86
|
+
模型一旦复读,重复必然在尾部,因此尾部检测即可实时捕获所有循环,同时避免全窗口词频的误报
|
|
87
|
+
(如正常中文里高频的"的")。
|
|
88
|
+
|
|
89
|
+
Tails-only consecutive-run detection on the whitespace-stripped buffer: catches every loop in real time
|
|
90
|
+
without the false positives of whole-window frequency counting.
|
|
91
|
+
|
|
92
|
+
### 3. 停止机制 / Stopping
|
|
93
|
+
|
|
94
|
+
守卫生成器提前结束 → `for await` 调用上游 `iterator.return()` → 适配器 `finally` 中
|
|
95
|
+
`consumer.abort()` 中断 HTTP 连接 → 服务端真正停止生成。**绝不直接 `abort()`
|
|
96
|
+
`options.signal`**(对 loop 请求它就是 agent 步骤信号,直接中止会以 `aborted` 结束并丢弃消息)。
|
|
97
|
+
|
|
98
|
+
Graceful early end: `iterator.return()` propagates to the adapter, whose `finally` aborts the HTTP
|
|
99
|
+
connection server-side. We never abort `options.signal` directly (for loop requests it *is* the agent
|
|
100
|
+
step signal).
|
|
101
|
+
|
|
102
|
+
### 4. 协议合规收尾 / Protocol-compliant closure
|
|
103
|
+
|
|
104
|
+
停止时补发所有打开块的 `block-end`(携带完整已生成文本)与 `finish{kind:'stop'}`,满足
|
|
105
|
+
`llm-invariant` 校验器要求;agent-loop 将已生成内容正常提交为助手消息。
|
|
106
|
+
|
|
107
|
+
Emits synthetic `block-end`s plus `finish(stop)` to satisfy the `llm-invariant` validator, so the
|
|
108
|
+
agent-loop commits the partial text as a normal assistant message.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 触发示例 / What gets stopped
|
|
113
|
+
|
|
114
|
+
| 形态 / Pattern | 示例 / Example |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| 单字符循环 / single-char loop | `aaaaaaaaaa` |
|
|
117
|
+
| 词语循环 / word loop | `哈哈` ×10 |
|
|
118
|
+
| 带空格复读 / space-separated | `hello hello hello ...` ×10 |
|
|
119
|
+
| 逐行复读 / line repeats | `抱歉,我无法完成。` ×10 行 |
|
|
120
|
+
| 前缀后循环 / loop after prefix | `好的,下面开始回答:` + `循环` ×10 |
|
|
121
|
+
|
|
122
|
+
**不会触发 / Won't trigger**:正常文本中的高频词(检测只针对**连续**重复)、重复 9 次及以下、
|
|
123
|
+
reasoning 与工具参数(默认关闭)。/ high-frequency words in normal prose (consecutive runs only),
|
|
124
|
+
≤9 repeats, reasoning and tool args (off by default).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 项目结构 / Project layout
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
.
|
|
132
|
+
├── plugin/
|
|
133
|
+
│ └── host.js # 动态插件形式(cordis_define 的 code.host)
|
|
134
|
+
├── lib/
|
|
135
|
+
│ └── index.js # npm/组合常驻形式(package.json main 入口)
|
|
136
|
+
├── tests/
|
|
137
|
+
│ └── detector.test.js # 端到端测试:15 项 × 2 入口(防漂移)
|
|
138
|
+
├── .github/workflows/ci.yml # GitHub Actions:Node 18/20/22
|
|
139
|
+
├── package.json
|
|
140
|
+
├── CHANGELOG.md
|
|
141
|
+
├── LICENSE # MIT
|
|
142
|
+
└── README.md
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 测试 / Tests
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
node tests/detector.test.js # 或 npm test
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
同一套 15 项用例分别驱动两个入口(`plugin/host.js` 经 `new Function` 求值、`lib/index.js` 经
|
|
152
|
+
`require` 加载),覆盖:透传完整性、各类复读形态、阈值边界、协议闭合、上游 `return()` 调用、
|
|
153
|
+
默认不检测 reasoning/工具参数、未闭合工具调用块的闭合、多次调用状态隔离等。CI 在 Node 18/20/22
|
|
154
|
+
上运行。
|
|
155
|
+
|
|
156
|
+
The same 15-test suite drives both entries, guarding against drift between the two forms. CI runs on
|
|
157
|
+
Node 18/20/22.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 已知限制 / Limitations
|
|
162
|
+
|
|
163
|
+
- 停止时若恰有未闭合的工具调用块(顺序输出块的适配器几乎不可能),该块会按已累积参数闭合并可能被执行。
|
|
164
|
+
- 服务端停止依赖适配器在流关闭时中止底层请求的语义(已验证 `dsh-llm-deepseek`;自定义适配器需自查)。
|
|
165
|
+
- 阈值语义为 `>= threshold`:第 10 次重复出现时即停止。
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE)
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// dupguard —— DSH (DeepSeek Harness) 插件(npm / 组合常驻形式,CJS 入口)
|
|
5
|
+
//
|
|
6
|
+
// 实时检测大模型流式输出中的重复内容:当最新的输出中同一字符串连续重复
|
|
7
|
+
// CONFIG.threshold 次及以上时,立即停止本次生成。
|
|
8
|
+
//
|
|
9
|
+
// 本文件是 package.json 的 main 入口,导出 Cordis 插件对象
|
|
10
|
+
// { name, apply },供 DSH 组合(cordis.yml / preset)挂载;
|
|
11
|
+
// 与 plugin/host.js(动态 cordis_define 形式)行为完全一致,
|
|
12
|
+
// tests/detector.test.js 会对两者同时跑同一套用例,防止两份代码漂移。
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
const name = 'dupguard'
|
|
16
|
+
|
|
17
|
+
const CONFIG = {
|
|
18
|
+
// 触发阈值:同一字符串连续重复次数达到该值时停止输出(用户需求:重复十次以上)。
|
|
19
|
+
// 语义为「>= threshold」,即第 10 次重复出现时就触发。
|
|
20
|
+
threshold: 10,
|
|
21
|
+
// 参与检测的重复单元的最小/最大长度(字符数)。
|
|
22
|
+
// minUnitLength=1 意味着 "aaaaaaaaaa" 这类单字符循环也会触发。
|
|
23
|
+
minUnitLength: 1,
|
|
24
|
+
maxUnitLength: 80,
|
|
25
|
+
// 检测用滚动窗口(字符数,去除空白后)。
|
|
26
|
+
// 只需要容纳 threshold * maxUnitLength(默认 10 * 80 = 800),留足余量即可。
|
|
27
|
+
detectionWindow: 8192,
|
|
28
|
+
// 检测前先移除所有空白字符(含换行):
|
|
29
|
+
// 让 "重复 重复 重复"、"重复\n重复\n重复" 这类带分隔符的复读也能被识别。
|
|
30
|
+
stripWhitespace: true,
|
|
31
|
+
// 是否同时检测思考(reasoning)文本。默认关闭:思考内容不可见,且正常思考文本
|
|
32
|
+
// 更可能出现连续重复片段,误伤风险高。
|
|
33
|
+
monitorReasoning: false,
|
|
34
|
+
// 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
|
|
35
|
+
monitorToolArguments: false,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 尾部连续重复检测:text 是否以某个 unit(长度 minUnitLength..maxUnitLength)
|
|
40
|
+
* 连续重复 >= threshold 次结尾。是则返回 { unit, count, span },否则返回 null。
|
|
41
|
+
*
|
|
42
|
+
* 说明:由于本插件对每个增量实时调用本函数,模型一旦陷入复读循环,
|
|
43
|
+
* 循环必然发生在文本尾部,因此尾部检测即可覆盖所有循环场景;
|
|
44
|
+
* 不做全窗口词频统计,是为了避免正常文本(例如中文里高频出现的"的")
|
|
45
|
+
* 被误判为重复。
|
|
46
|
+
*/
|
|
47
|
+
function findRepeatedTail(text, threshold, minUnitLength, maxUnitLength) {
|
|
48
|
+
const n = text.length
|
|
49
|
+
if (n < threshold * minUnitLength) return null
|
|
50
|
+
const maxP = Math.min(maxUnitLength, Math.floor(n / threshold))
|
|
51
|
+
for (let p = minUnitLength; p <= maxP; p++) {
|
|
52
|
+
const unit = text.slice(n - p) // 最后一个候选单元
|
|
53
|
+
let ok = true
|
|
54
|
+
for (let k = 1; k < threshold; k++) {
|
|
55
|
+
// 向前逐段比较前 threshold-1 个副本
|
|
56
|
+
if (text.slice(n - p * (k + 1), n - p * k) !== unit) {
|
|
57
|
+
ok = false
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (ok) return { unit, count: threshold, span: p * threshold }
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 移除所有空白字符(与 CONFIG.stripWhitespace 配合)。 */
|
|
67
|
+
function stripWhitespace(text) {
|
|
68
|
+
return text.replace(/\s+/g, '')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 为一次 llm/stream 调用创建守卫。
|
|
73
|
+
* 每次模型调用都会新建一份状态,互不干扰。
|
|
74
|
+
*/
|
|
75
|
+
function createStreamGuard(options) {
|
|
76
|
+
// index -> 块状态。用 Map 按块索引累积,避免多个文本块交替输出时互相打断检测。
|
|
77
|
+
const blocks = new Map()
|
|
78
|
+
let stopped = null
|
|
79
|
+
|
|
80
|
+
const provider = typeof options === 'object' && options !== null ? String(options.provider ?? '?') : '?'
|
|
81
|
+
const model = typeof options === 'object' && options !== null ? String(options.model ?? '?') : '?'
|
|
82
|
+
|
|
83
|
+
function ensure(index, blockType) {
|
|
84
|
+
let b = blocks.get(index)
|
|
85
|
+
if (b === undefined) {
|
|
86
|
+
b = {
|
|
87
|
+
blockType,
|
|
88
|
+
text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口
|
|
89
|
+
stripped: '', // 去空白后的滚动窗口:仅用于检测
|
|
90
|
+
toolCallId: undefined,
|
|
91
|
+
toolCallName: undefined,
|
|
92
|
+
toolCallArguments: '',
|
|
93
|
+
}
|
|
94
|
+
blocks.set(index, b)
|
|
95
|
+
}
|
|
96
|
+
return b
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
|
|
100
|
+
function feedText(b, delta) {
|
|
101
|
+
b.text += delta
|
|
102
|
+
const piece = CONFIG.stripWhitespace ? stripWhitespace(delta) : delta
|
|
103
|
+
b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
|
|
104
|
+
return findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
|
|
108
|
+
function feed(chunk) {
|
|
109
|
+
switch (chunk.type) {
|
|
110
|
+
case 'block-start': {
|
|
111
|
+
ensure(chunk.index, chunk.blockType)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
case 'text-delta': {
|
|
115
|
+
const b = ensure(chunk.index, 'text')
|
|
116
|
+
const hit = feedText(b, chunk.text)
|
|
117
|
+
if (hit !== null) stopped = hit
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
case 'reasoning-delta': {
|
|
121
|
+
const b = ensure(chunk.index, 'reasoning')
|
|
122
|
+
b.text += chunk.text // 始终累积:停止时需要完整内容闭合块
|
|
123
|
+
if (CONFIG.monitorReasoning) {
|
|
124
|
+
const hit = feedText(b, chunk.text)
|
|
125
|
+
if (hit !== null) stopped = hit
|
|
126
|
+
}
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
case 'tool-call-delta': {
|
|
130
|
+
const b = ensure(chunk.index, 'tool-call')
|
|
131
|
+
if (chunk.id !== undefined) b.toolCallId = chunk.id
|
|
132
|
+
if (chunk.name !== undefined) b.toolCallName = chunk.name
|
|
133
|
+
b.toolCallArguments += chunk.argumentsDelta
|
|
134
|
+
if (CONFIG.monitorToolArguments) {
|
|
135
|
+
b.stripped = b.toolCallArguments.slice(-CONFIG.detectionWindow)
|
|
136
|
+
const hit = findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
|
|
137
|
+
if (hit !== null) stopped = hit
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
case 'block-end': {
|
|
142
|
+
blocks.delete(chunk.index)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
case 'usage':
|
|
146
|
+
case 'finish':
|
|
147
|
+
default:
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 把块状态装配为 ContentBlock(与 BlockAssembler 的 open-block 装配规则一致)。 */
|
|
153
|
+
function closeBlock(b, index) {
|
|
154
|
+
let block
|
|
155
|
+
if (b.blockType === 'text') {
|
|
156
|
+
block = { type: 'text', text: b.text }
|
|
157
|
+
} else if (b.blockType === 'reasoning') {
|
|
158
|
+
block = { type: 'reasoning', text: b.text }
|
|
159
|
+
} else {
|
|
160
|
+
block = {
|
|
161
|
+
type: 'tool-call',
|
|
162
|
+
id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index,
|
|
163
|
+
name: b.toolCallName !== undefined ? b.toolCallName : '',
|
|
164
|
+
arguments: b.toolCallArguments,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { type: 'block-end', index, block }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 满足 llm/stream 协议地收尾:
|
|
172
|
+
* 1) 闭合所有仍打开的块(否则 llm-invariant 校验器会报
|
|
173
|
+
* "LLM stream finished with N open block(s)");
|
|
174
|
+
* 2) 以 finish(stop) 结尾(否则报 "LLM stream ended without a terminal finish chunk")。
|
|
175
|
+
* agent-loop 因此把已生成内容正常提交为助手消息,本轮干净结束。
|
|
176
|
+
*/
|
|
177
|
+
function* closingChunks() {
|
|
178
|
+
for (const [index, b] of blocks) yield closeBlock(b, index)
|
|
179
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 包装上游流:逐块透传 + 检测;命中后补发闭合块并提前结束。 */
|
|
183
|
+
async function* guarded(source) {
|
|
184
|
+
for await (const chunk of source) {
|
|
185
|
+
yield chunk
|
|
186
|
+
feed(chunk)
|
|
187
|
+
if (stopped !== null) {
|
|
188
|
+
console.log(
|
|
189
|
+
'[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model +
|
|
190
|
+
' unit=' + JSON.stringify(stopped.unit) +
|
|
191
|
+
' repeat>=' + String(stopped.count) +
|
|
192
|
+
' span=' + String(stopped.span) + 'chars'
|
|
193
|
+
)
|
|
194
|
+
yield* closingChunks()
|
|
195
|
+
// 提前 return:for-await 会调用上游 iterator.return(),
|
|
196
|
+
// 适配器的 finally 随即 consumer.abort() 中断 HTTP 连接,
|
|
197
|
+
// 从而在服务端真正停止生成。这里绝不调用 options.signal.abort(),
|
|
198
|
+
// 因为 loop 请求的 options.signal 就是整个 agent 步骤的信号,
|
|
199
|
+
// 直接中止会让本轮以 aborted 结束并丢弃已生成的消息。
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return guarded
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function apply(ctx) {
|
|
209
|
+
// llm/stream:包裹每次流式模型调用的瀑布事件。
|
|
210
|
+
// 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。
|
|
211
|
+
// (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式)
|
|
212
|
+
ctx.on('llm/stream', (options, next) => createStreamGuard(options)(next()))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { name, apply }
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-dupguard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Real-time repetition guard for DeepSeek Harness (DSH): stops model generation when the same string repeats >=10 times in the streamed output. 实时检测 DSH 大模型流式输出中的重复内容,同一字符串重复十次以上立即停止生成。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"repetition-detection",
|
|
10
|
+
"loop-detection",
|
|
11
|
+
"anti-repetition",
|
|
12
|
+
"output-guard",
|
|
13
|
+
"llm",
|
|
14
|
+
"防复读",
|
|
15
|
+
"重复检测"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"main": "lib/index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./lib/index.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"lib",
|
|
24
|
+
"plugin",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"CHANGELOG.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node tests/detector.test.js"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/zqh260619/dsh-dupguard.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/zqh260619/dsh-dupguard/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/zqh260619/dsh-dupguard#readme"
|
|
47
|
+
}
|
package/plugin/host.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// dupguard —— DSH 动态 Cordis 插件(Host 端)
|
|
3
|
+
//
|
|
4
|
+
// 实时检测大模型流式输出中的重复内容:当最新的输出中同一字符串连续重复
|
|
5
|
+
// CONFIG.threshold 次及以上时,立即停止本次生成。
|
|
6
|
+
//
|
|
7
|
+
// 用法:把整个文件内容作为 cordis_define 的 code.host 传入即可,
|
|
8
|
+
// 不需要修改任何 DSH 组合(composition)文件。
|
|
9
|
+
// 需要随 DSH 常驻时,请改用 npm/组合形式 lib/index.js(与本文件行为一致,
|
|
10
|
+
// tests/detector.test.js 会对两个入口跑同一套用例防止漂移)。
|
|
11
|
+
// 安装/运行步骤与配置说明见仓库根目录的 README.md。
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
const CONFIG = {
|
|
15
|
+
// 触发阈值:同一字符串连续重复次数达到该值时停止输出(用户需求:重复十次以上)。
|
|
16
|
+
// 语义为「>= threshold」,即第 10 次重复出现时就触发。
|
|
17
|
+
threshold: 10,
|
|
18
|
+
// 参与检测的重复单元的最小/最大长度(字符数)。
|
|
19
|
+
// minUnitLength=1 意味着 "aaaaaaaaaa" 这类单字符循环也会触发。
|
|
20
|
+
minUnitLength: 1,
|
|
21
|
+
maxUnitLength: 80,
|
|
22
|
+
// 检测用滚动窗口(字符数,去除空白后)。
|
|
23
|
+
// 只需要容纳 threshold * maxUnitLength(默认 10 * 80 = 800),留足余量即可。
|
|
24
|
+
detectionWindow: 8192,
|
|
25
|
+
// 检测前先移除所有空白字符(含换行):
|
|
26
|
+
// 让 "重复 重复 重复"、"重复\n重复\n重复" 这类带分隔符的复读也能被识别。
|
|
27
|
+
stripWhitespace: true,
|
|
28
|
+
// 是否同时检测思考(reasoning)文本。默认关闭:思考内容不可见,且正常思考文本
|
|
29
|
+
// 更可能出现连续重复片段,误伤风险高。
|
|
30
|
+
monitorReasoning: false,
|
|
31
|
+
// 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
|
|
32
|
+
monitorToolArguments: false,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 尾部连续重复检测:text 是否以某个 unit(长度 minUnitLength..maxUnitLength)
|
|
37
|
+
* 连续重复 >= threshold 次结尾。是则返回 { unit, count, span },否则返回 null。
|
|
38
|
+
*
|
|
39
|
+
* 说明:由于本插件对每个增量实时调用本函数,模型一旦陷入复读循环,
|
|
40
|
+
* 循环必然发生在文本尾部,因此尾部检测即可覆盖所有循环场景;
|
|
41
|
+
* 不做全窗口词频统计,是为了避免正常文本(例如中文里高频出现的"的")
|
|
42
|
+
* 被误判为重复。
|
|
43
|
+
*/
|
|
44
|
+
function findRepeatedTail(text, threshold, minUnitLength, maxUnitLength) {
|
|
45
|
+
const n = text.length
|
|
46
|
+
if (n < threshold * minUnitLength) return null
|
|
47
|
+
const maxP = Math.min(maxUnitLength, Math.floor(n / threshold))
|
|
48
|
+
for (let p = minUnitLength; p <= maxP; p++) {
|
|
49
|
+
const unit = text.slice(n - p) // 最后一个候选单元
|
|
50
|
+
let ok = true
|
|
51
|
+
for (let k = 1; k < threshold; k++) {
|
|
52
|
+
// 向前逐段比较前 threshold-1 个副本
|
|
53
|
+
if (text.slice(n - p * (k + 1), n - p * k) !== unit) {
|
|
54
|
+
ok = false
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (ok) return { unit, count: threshold, span: p * threshold }
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 移除所有空白字符(与 CONFIG.stripWhitespace 配合)。 */
|
|
64
|
+
function stripWhitespace(text) {
|
|
65
|
+
return text.replace(/\s+/g, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 为一次 llm/stream 调用创建守卫。
|
|
70
|
+
* 每次模型调用都会新建一份状态,互不干扰。
|
|
71
|
+
*/
|
|
72
|
+
function createStreamGuard(options) {
|
|
73
|
+
// index -> 块状态。用 Map 按块索引累积,避免多个文本块交替输出时互相打断检测。
|
|
74
|
+
const blocks = new Map()
|
|
75
|
+
let stopped = null
|
|
76
|
+
|
|
77
|
+
const provider = typeof options === 'object' && options !== null ? String(options.provider ?? '?') : '?'
|
|
78
|
+
const model = typeof options === 'object' && options !== null ? String(options.model ?? '?') : '?'
|
|
79
|
+
|
|
80
|
+
function ensure(index, blockType) {
|
|
81
|
+
let b = blocks.get(index)
|
|
82
|
+
if (b === undefined) {
|
|
83
|
+
b = {
|
|
84
|
+
blockType,
|
|
85
|
+
text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口
|
|
86
|
+
stripped: '', // 去空白后的滚动窗口:仅用于检测
|
|
87
|
+
toolCallId: undefined,
|
|
88
|
+
toolCallName: undefined,
|
|
89
|
+
toolCallArguments: '',
|
|
90
|
+
}
|
|
91
|
+
blocks.set(index, b)
|
|
92
|
+
}
|
|
93
|
+
return b
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
|
|
97
|
+
function feedText(b, delta) {
|
|
98
|
+
b.text += delta
|
|
99
|
+
const piece = CONFIG.stripWhitespace ? stripWhitespace(delta) : delta
|
|
100
|
+
b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
|
|
101
|
+
return findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
|
|
105
|
+
function feed(chunk) {
|
|
106
|
+
switch (chunk.type) {
|
|
107
|
+
case 'block-start': {
|
|
108
|
+
ensure(chunk.index, chunk.blockType)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
case 'text-delta': {
|
|
112
|
+
const b = ensure(chunk.index, 'text')
|
|
113
|
+
const hit = feedText(b, chunk.text)
|
|
114
|
+
if (hit !== null) stopped = hit
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
case 'reasoning-delta': {
|
|
118
|
+
const b = ensure(chunk.index, 'reasoning')
|
|
119
|
+
b.text += chunk.text // 始终累积:停止时需要完整内容闭合块
|
|
120
|
+
if (CONFIG.monitorReasoning) {
|
|
121
|
+
const hit = feedText(b, chunk.text)
|
|
122
|
+
if (hit !== null) stopped = hit
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
case 'tool-call-delta': {
|
|
127
|
+
const b = ensure(chunk.index, 'tool-call')
|
|
128
|
+
if (chunk.id !== undefined) b.toolCallId = chunk.id
|
|
129
|
+
if (chunk.name !== undefined) b.toolCallName = chunk.name
|
|
130
|
+
b.toolCallArguments += chunk.argumentsDelta
|
|
131
|
+
if (CONFIG.monitorToolArguments) {
|
|
132
|
+
b.stripped = b.toolCallArguments.slice(-CONFIG.detectionWindow)
|
|
133
|
+
const hit = findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
|
|
134
|
+
if (hit !== null) stopped = hit
|
|
135
|
+
}
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
case 'block-end': {
|
|
139
|
+
blocks.delete(chunk.index)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
case 'usage':
|
|
143
|
+
case 'finish':
|
|
144
|
+
default:
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 把块状态装配为 ContentBlock(与 BlockAssembler 的 open-block 装配规则一致)。 */
|
|
150
|
+
function closeBlock(b, index) {
|
|
151
|
+
let block
|
|
152
|
+
if (b.blockType === 'text') {
|
|
153
|
+
block = { type: 'text', text: b.text }
|
|
154
|
+
} else if (b.blockType === 'reasoning') {
|
|
155
|
+
block = { type: 'reasoning', text: b.text }
|
|
156
|
+
} else {
|
|
157
|
+
block = {
|
|
158
|
+
type: 'tool-call',
|
|
159
|
+
id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index,
|
|
160
|
+
name: b.toolCallName !== undefined ? b.toolCallName : '',
|
|
161
|
+
arguments: b.toolCallArguments,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { type: 'block-end', index, block }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 满足 llm/stream 协议地收尾:
|
|
169
|
+
* 1) 闭合所有仍打开的块(否则 llm-invariant 校验器会报
|
|
170
|
+
* "LLM stream finished with N open block(s)");
|
|
171
|
+
* 2) 以 finish(stop) 结尾(否则报 "LLM stream ended without a terminal finish chunk")。
|
|
172
|
+
* agent-loop 因此把已生成内容正常提交为助手消息,本轮干净结束。
|
|
173
|
+
*/
|
|
174
|
+
function* closingChunks() {
|
|
175
|
+
for (const [index, b] of blocks) yield closeBlock(b, index)
|
|
176
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 包装上游流:逐块透传 + 检测;命中后补发闭合块并提前结束。 */
|
|
180
|
+
async function* guarded(source) {
|
|
181
|
+
for await (const chunk of source) {
|
|
182
|
+
yield chunk
|
|
183
|
+
feed(chunk)
|
|
184
|
+
if (stopped !== null) {
|
|
185
|
+
console.log(
|
|
186
|
+
'[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model +
|
|
187
|
+
' unit=' + JSON.stringify(stopped.unit) +
|
|
188
|
+
' repeat>=' + String(stopped.count) +
|
|
189
|
+
' span=' + String(stopped.span) + 'chars'
|
|
190
|
+
)
|
|
191
|
+
yield* closingChunks()
|
|
192
|
+
// 提前 return:for-await 会调用上游 iterator.return(),
|
|
193
|
+
// 适配器的 finally 随即 consumer.abort() 中断 HTTP 连接,
|
|
194
|
+
// 从而在服务端真正停止生成。这里绝不调用 options.signal.abort(),
|
|
195
|
+
// 因为 loop 请求的 options.signal 就是整个 agent 步骤的信号,
|
|
196
|
+
// 直接中止会让本轮以 aborted 结束并丢弃已生成的消息。
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return guarded
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
apply(ctx) {
|
|
207
|
+
// llm/stream:包裹每次流式模型调用的瀑布事件。
|
|
208
|
+
// 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。
|
|
209
|
+
// (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式)
|
|
210
|
+
ctx.on('llm/stream', (options, next) => createStreamGuard(options)(next()))
|
|
211
|
+
},
|
|
212
|
+
}
|