# @blueriverlhr/dsh-better-webui-repeater-detect 会话内**模型复读探测**(host + client):实时检测模型输出是否在**重复同一句或高度 相似的内容**(含「换种说法」的复读),检测到就中断生成、给模型注入一条可见提示 (**并说明可能是一次误报**),同一会话累计触发达到上限则硬停并报错。dsh 原生无法 设置惩罚系数(`frequency` / `presence_penalty` 在 pi-ai 全链路无字段,见 `docs/discussion/design.md` §11),本包用输出侧检测来兜住复读。 > **为什么叫「复读探测」而不是「复读卫士」**:检测器会有误报——例如在写代码、表格 > 或其它结构化文本时,多行使用相似的格式写法是正常的。因此软中断会明确告知模型 > 「这可能是一次误报」,并且检测器**只跳过代码围栏与标签名,不跳过思考**:正文与 > 思考(原生 `reasoning-delta` 与可见 `` 块)都会被检测,模型陷入 > **原地打转的循环思考**(同一段想法反复推演、不产出正文)也会被截停(v0.28 用户 > 裁决),避免一直烧 token。 ## 检测原理(文本向量化 + 距离) - 按行累计输出文本,每行**字符频率向量化**(bag-of-characters → 稀疏向量,即轻量 seq2vec:无需分词,中文 / 英文 / 混合文本通用)。 - **相似度**用**元素重叠**:`|A ∩ B| / max(|A|, |B|)` —— 较长一行里有多少比例的 字符也出现在较短一行里,正是用户说的「匹配 X% 的元素数量」。删一字 / 换一字后 相似度仍 ≥ 95%,而真正不同的行远低于此;按 max 长度归一化后,短句嵌进长句不会 误报。 - **哈希桶滑动窗口**:窗口内保留最近 `windowSize` 行;新行加入与它最相似的桶 (≥ `similarity` 阈值,按桶代表向量匹配,而非两两比对 —— 所以多行各带一处小改 的同一基准句都会进同一个桶,正确累积成复读);某桶成员数 ≥ `threshold` 即触发。 - **误报抑制 —— 代码与标签名不计数,但思考计数**(用户裁决 v0.25 + v0.28): 检测器对进入窗口的每一行做如下处理: - **围栏代码块跳过**:跟踪 markdown 围栏(``` 或 ~~~),围栏内每一行(含开/闭栏 行)跳过、不入窗口 —— 「代码里多行相似格式」不误报; - **思考计数**(v0.28 起):可见思考块(`` / ``)的**内容行照常进入窗口**——模型反复推演同一段 思考(循环思考)会像正文复读一样被截停;块自身的 `` / `` 标签行剥空后自然忽略。原生推理(`reasoning-delta` chunk)同样**计入**(包装器 同时把 `text-delta` 与 `reasoning-delta` 喂给检测器);只有 `tool-call-delta` (结构化工具参数)从不计数; - **标签名剥离**:向量化前先 `stripTags` 剥掉 XML/HTML 式标签(``、 ``、``),重复的结构化包装(同一标签包不同内容)不会 因共享标签字符而虚高相似度误报;纯标签行剥后为空,直接被忽略。同标签包**相同 内容**仍正常触发(内容确实在复读)。 - 围栏关闭后检测恢复正常。 - 检测是纯函数、实时:每行仅与桶代表比较,几十微秒级,包在 `llm/stream` 里逐 chunk 跑,不会拖慢生成。 ## 触发后的行为(两段式升级) - **软停**(同会话前 `hardStop - 1` 次触发):`agent.cancel`(`hook` 原因 + `keepInbox`)中断本次生成,并 `agent.followup` 注入一条模型可见通知(来源标注 复读探测)作为下一回合。**通知会说明这可能是一次误报**(例如多行相似格式的代码/ 表格),并告诉模型:如果刚才并非真正的复读,按原计划继续;如果确实在复读,换一种 全新的表述或方法继续。随后在包装流里抛普通 Error,agent-loop 按 aborted 回合处理 (追加已输出的部分内容)。**不硬停、不烧更多 token**。 - **硬停**(同会话第 `hardStop` 次触发,默认 3):从包装流抛 `LlmError`(code `REPEATER_DETECT`),回合以 `kind: 'error'` 结束,UI 渲染可见的 turn-error(含 消息 + code)。该路径**不走** `agent/request-error` 重试,绝不静默重试烧 token。 **每次硬停后计数清零**——下一次生成从零开始,而不是离下一次硬停只差一次触发。 - **新会话(`agent/created`)重置计数**;每次触发后检测窗口也重置(围栏状态一并 清空),模型被提醒后从干净窗口重新开始。 ## 配置(dsh 原生插件配置页 · 复读探测卡) | 字段 | 说明 | 默认 | |---|---|---| | enabled | 总开关 | true | | windowSize | 检测窗口(行) | 50 | | threshold | 窗口内触发阈值(行) | 5 | | similarity | 相似度阈值(%,元素重叠) | 95 | | hardStop | 同会话硬停前允许的触发次数 | 3 | - **设置卡**(**dsh 原生「设置 → 插件 → 插件配置」页**的 `settings.plugin.item` 键控插槽,key `better-webui-repeater-detect`; **默认折叠**,与内置插件配置卡同款 chrome/控件样式——`bwpc-*` 规范类): 卡片头放标题 + 描述 + chevron,展开后卡内开关 + 四字段 + 「应用」「恢复默认」 按钮。v0.22 起不再有独立 settings.section 页;v0.23 起 settings 包删除,改挂 dsh 原生插件配置页。 - 配置持久化在 `better-webui-repeater-detect` 设置命名空间(settings.yaml 里 `better-webui-repeater-detect:` 一节),重启后依然生效。 - 只作用于 `isAgentLoopRequest`(会话主循环);compaction / 会话标题等辅助调用不 经过,不会误伤。 - 服务依赖(硬):`llm`、`agents`、`connection`、`settings` - 独立 locale NS(`better-webui-repeater-detect`)与独立 style 标签 (`better-webui-repeater-detect-style`);经 `/better-webui-repeater-detect` RPC 通道与宿主通信(read / apply / reset) 独立安装:把 `@blueriverlhr/dsh-better-webui-repeater-detect` 加进 `dsh.profile.bundles` 并声明依赖(见根 README「按需安装」)。更常见的做法是装 根元包一起带上。