# 在 0.1.5-rc.3 及以上版本恢复生成中途软切 > 本文档面向已在使用本插件、且希望在新版 DSH 上保留"打转打断"能力的用户。 > 不需要打断能力的用户**无需阅读**——结论注入在所有版本都正常工作。 ## 目录 1. [为什么需要这个补丁](#一为什么需要这个补丁) 2. [能力现状速查](#二能力现状速查) 3. [使用步骤](#三使用步骤) 4. [移植原理](#四移植原理) 5. [风险与回滚](#五风险与回滚) 6. [排障](#六排障) 7. [已知限制](#七已知限制) ## 一、为什么需要这个补丁 ### 现象 模型在生成过程中出现下列情况时,插件**不会打断**,只能靠你手动中断: - 把工具调用写成普通文本(例如输出 `…`)而非真正发起调用; - 原地重复同一段内容,重复几百次也不停; - 措辞不同但反复"重新分析"同一件事(语义空转); - 输出无意义的递增数字流。 这些情况都会让一次回合白烧 token 却毫无进展。 ### 原因 打断能力依赖 DSH 宿主提供的钩子 `agent/soft-cut`。该钩子自 **0.1.5-rc.3 起被上游移除**: | DSH 版本 | 具备该钩子 | | --- | --- | | 0.1.5-rc.1 | 是 | | 0.1.5-rc.2 | 是 | | 0.1.5-rc.3 及更高(含 0.1.6 / 0.1.7 / 0.2.x) | 否 | 钩子不存在时,插件注册在其上的回调**永远不会触发**。此时插件表现如下: - 加载无报错、启动日志干净; - 结论注入照常工作(这是插件的主要功能,不受影响); - 设置页的"打断判定"开关虽然是打开的,但**不起任何作用**。 **静默失效**是这里最麻烦的地方:开关状态与实际行为不一致,��能靠观察故障反推。 ### 本补丁做什么 把 0.1.5-rc.2 中的软切机构移植回新版内核,恢复"切断当前生成、插入锚点、从断点继续"的语义。 插件代码**不需要任何改动**。 ## 二、能力现状速查 | 能力 | 不打补丁 | 打补丁后 | | --- | --- | --- | | 结论注入(插件主功能) | ✅ 正常 | ✅ 正常 | | 设置页显示 | ✅ 正常 | ✅ 正常 | | 打转判定(伪调用 / 重复 / 空转 / 数字流) | ❌ 静默失效 | ✅ 正常 | | 生成中途打断 | ❌ 静默失效 | ✅ 正常 | 不打补丁时,插件会在启动日志和设置页顶部明确提示"当前内核没有掐断能力"——请以该提示为准,不要凭"开关是开的"判断功能是否生效。 ## 三、使用步骤 ### 前提 - Node.js 18 或更高(脚本需要 `node:zlib` 的 zstd 支持与标准 ESM) - 能修改 DSH 安装目录下的 `node_modules`(即有写权限) - 改动的是宿主内核文件,**属于超出插件边界的修改**,请先理解本文第五节的风险 ### 步骤 1:定位内核文件 ```sh node tools/apply-softcut-port.mjs --check ``` 预期输出之一: ``` 内核文件:<你的路径>/node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js 移植状态:未应用 ✗ ``` 脚本会自动定位。若提示未找到,说明 DSH 不在当前工作目录下,请用环境变量指定: ```sh # PowerShell $env:SOFTCUT_TARGET = '<内核 lib/index.js 的绝对路径>' # bash / zsh SOFTCUT_TARGET='<绝对路径>' node tools/apply-softcut-port.mjs --check ``` 查找方法:在 DSH 安装目录的 `node_modules` 下找 `@deepseek-ai/dsh-agent-loop/lib/index.js`。 > **注意**:pnpm 的内容寻址目录名(形如 `@deepseek-ai+dsh-agent-loop_<哈希>`)**每次 DSH 升级都会变**,所以不要把路径写进任何脚本或配置——脚本每次运行都会重新解析。 ### 步骤 2:先在副本上试跑(可选但推荐) ```sh # 复制一份到临时目录预演,确认替换锚点能匹配 cp <内核文件> /tmp/agent-loop-test.js SOFTCUT_TARGET=/tmp/agent-loop-test.js node tools/apply-softcut-port.mjs node --check /tmp/agent-loop-test.js ``` 若报「锚点校验失败」,说明当前内核版本的代码排版与移植脚本的假设不一致。**请不要放宽匹配条件**——那会导致改坏内核。脚本会打印具体是哪一处锚点没匹配上,请对照排查。 ### 步骤 3:应用到真实内核 ```sh node tools/apply-softcut-port.mjs ``` 预期输出: ``` 内核文件:<路径> 已备份 → <路径>.bak-pre-softcut-port ✓ A. 新增 soft-cut 常量与分类预算纯函数 ✓ B. phase 初始化加 softCutBudget 分类计数 ✓ C1. buildRequest 改用 requestSignal ✓ C2. 流循环内按间隔调用 agent/soft-cut 并按分类预算裁决 ✓ D1. catch 分支区分自切与真错 ✓ D2. catch 收尾 + 软切边界落盘续跑 ✓ 移植完成。 ``` 脚本是**幂等**的:已应用时再次运行会直接跳过,不会重复改写。 ### 步骤 4:重启 DSH **必须重启**,否则改动不会生效。运行中的进程已经把代码载入内存,磁盘上的变更对它无影响。 ### 步骤 5:验证 打开设置 → 「COT 锚点」,确认顶部提示条变为**绿色「掐断能力正常」**。 如需进一步确认软切行为真的发生,可发一条容易触发打断的消息,再用取证脚本查看会话事件: ```sh node tools/trace-softcut.mjs <解包后的 session.jsonl> ``` 符合软切的输出特征: ``` 1] ASSISTANT turn=1 step=1 interrupted=false textLen=… ← 前缀按正常消息落盘 2] INBOX/SPLICED target=next-step inserted=1 3] == step/start == turn=1 step=2 ← 切开了 4] USER source=plugin:dsh-cot-anchor ← 插件注入 5] ASSISTANT turn=1 step=2 interrupted=false textLen=… ← 从断点继续 6] == turn/end == turn=1 {"kind":"completed"} ← 完整跑通,不是 aborted ``` 会话日志是 zstd 压缩的(`session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`),需先解包为明文 JSONL。 #### 移植后的一次实测确认 在一台 0.2.0 系列、已应用本移植(内核源码中可见 `DSH-SOFT-CUT-PORT` 标记)的环境上,一个真实长任务会话中软切**实际触发 4 次**(打转判定 2 次、转折句判定 2 次),取证特征与上文一致:每次被切都留下一条没有 `usage` 字段的助手消息(共 4 条)、紧随一条 `target: next-step` 的 `agent/inbox/spliced`,回合以 `{"kind":"completed"}` 正常结束而非 aborted。注意同一条注入会先以 `agent/inbox/spliced`(inserted)出现、再以 `user/message`(claimed)落盘,计数时按消息 id 去重,不要把两处重复各计一次。即移植恢复的不仅是钩子注册,"切断—落盘—续跑"整条链路在真实任务中确实工作。 需要区分的是:**软切真实开火 ≠ 成功约束住模型**。同一会话里模型在被切、被注入"停止重新分析"后仍于后续步骤再次打转。移植解决的是"能力静默失效",不保证跨步骤空转被根治——该边界见 README「能力边界」一节。 ## 四、移植原理 0.1.5-rc.2 的软切机构全部位于内核 `dsh-agent-loop` 内,插件仅提供"要不要切"的判定。两者的分工是: - **插件(裁判)**:判断是否该切断,返回 `{ kind: "cut", budgetClass, contexts: [...] }`(`budgetClass` 声明请求哪类预算,见下文「分类软切预算」); - **内核(剪刀)**:中断本次上游请求、把已生成部分按正常消息落盘、把上下文排入 next-step、继续本回合。 所以必须把"剪刀"补回内核,插件侧无需改动。移植共 4 处,均为**纯增量**(不改动原有逻辑,只插入缺失的代码路径): | 处 | 位置(按代码语义定位,勿按行号) | 内容 | | --- | --- | --- | | **A** | 最后一条 `import` 之后 | 检查间隔(32 字符)与尾部窗口常量;三类有界预算 `HEURISTIC_CUT_MAX=6` / `CONFIRMED_REPEAT_CUT_MAX=20` / `CONFIRMED_PSEUDO_CUT_MAX=3` 与汇总对象 `SOFT_CUT_LIMITS`;hard-abort 固定错误串 `SOFT_CUT_PSEUDO_ABORT_MESSAGE`;两个自包含纯函数 `createSoftCutBudget()` / `decideSoftCut()` | | **B** | `wakeDriver()` 内 `this.setPhase({ kind: "running", … })` | 新增 `softCutBudget: createSoftCutBudget()` 分类计数(每次 turn 开始归零,防跨 turn 泄漏) | | **C1** | `step()` 中 `firstAttempt = false;` 之后 | 声明独立的 `softCutAbort` 控制器与 `requestSignal`;`buildRequest()` 的最后一个 `signal` 实参改用 `requestSignal` | | **C2** | `for await (const chunk of stream)` 循环内 | 累积 `text-delta` / `reasoning-delta`;每满 32 字符调用 `agent/soft-cut` waterfall(payload 附 `budgets` 三类快照,**无前置总闸**——每个检查点必达插件);返回 cut 后经 `decideSoftCut` 三分支:`cut` 中止上游请求并跳出,`reject` 继续生成,`hard-abort` 发出 `agent/error` 后中止当前 turn | | **D1** | `step()` 内 `catch` 开头 | 区分"自己切的"与"真出错",避免软切被当成错误 | | **D2** | 紧随其后的 `try { const finish = …` 之前 | 软切边界:前缀按**正常** `assistant/message` 落盘(不带 `interrupted` 标记)、按 `trimTo` 裁掉重复尾巴、把上下文排入 next-step 让回合续跑 | C1 是最关键的一处:它让中断**只作用于当前这一次上游请求**,而不是整个 turn——这正是"切一刀后继续"与"整轮急停"的区别。 ### 分类软切预算(0.2.0 起) 每 turn 三类独立计数、互不挤占,全部有界: | 类别(wire 枚举) | 预算键(驼峰) | 上限/turn | 覆盖触发 | | --- | --- | --- | --- | | `heuristic` | `heuristic` | 6 | churn(语义空转)、transition(转折句)等启发式判定 | | `confirmed-repeat` | `confirmedRepeat` | 20 | repeat(字节级复读)、numberRunaway(递增数字流) | | `confirmed-pseudo` | `confirmedPseudo` | 3 | pseudoTool(文本化伪工具调用) | 裁决规则(内核侧纯函数 `decideSoftCut`,输入 `budgetClass` 与该类余量): - 该类计数未达上限 → `cut`:正常软切,计数 +1; - 该类已达上限 → `reject`:该类后续决策被**静默忽略**(流继续、不裁尾、不注入); - `confirmed-pseudo` 已达上限 → **`hard-abort`**:内核向会话发出 `agent/error`(错误消息固定为 `cot-anchor-budget-pseudo-abort`)并中止当前 turn。理由:伪调用误报的"cut→新 step→再误报"自我循环会让 turn 永不可完成,与其无限软切不如硬停让用户介入。 兼容口径:插件决策**缺少** `budgetClass` 字段(0.1.5 及更早版本的插件)或取值未知时,一律按 `heuristic` 归类。waterfall payload 的 `budgets` 字段仅作观测快照,不参与裁决。 移植依赖的 6 项内核能力在新版中均已存在,属纯增量,不需要额外安装依赖:`interruptedBlocks()`、`inbox.splice()`、`AssistantStreamAttempt`、`live.settle()`、`createAssistantMessage`、`AbortSignal.any`。 ## 五、风险与回滚 ### 风险 - **补丁与内核版本耦合**:上游若改动了 `step()` 的代码排版,移植脚本的锚点将无法匹配(此时脚本会安全中止、不改动文件,但软切不会生效)。 - **内核升级会覆盖补丁**:`pnpm install` / `pnpm update` 会重新写入 `dsh-agent-loop`,补丁随之消失,且**不会报错**。升级后请重跑本补丁。 - **内核补丁可能与上游未来版本冲突**,极端情况下导致 DSH 启动失败。 ### 建议 1. 先在测试用 profile 上验证,再用于日常环境。 2. 升级 DSH 前后各跑一次 `--check`,确认状态。 3. 养成看设置页能力提示的习惯——它比任何日志都可靠。 ### 回滚 ```sh node tools/apply-softcut-port.mjs --revert ``` 脚本会读取首次改写时创建的 `<文件>.bak-pre-softcut-port` 还原。**回滚后同样需要重启 DSH。** 若备份不存在(说明从未在本机应用过移植),无需回滚。 ### 从旧版移植升级(0.1.6 及更早应用过单闸版移植) 旧版移植(`softCuts` 单计数、`MAX_SOFT_CUTS_PER_TURN` 总闸)的改写产物与新锚点不兼容,直接应用会因锚点失配**零写入退出**。升级路径: ```sh node tools/apply-softcut-port.mjs --revert # 还原旧移植(读 .bak-pre-softcut-port 备份) node tools/apply-softcut-port.mjs # 应用新版移植(重新备份后写入) node tools/apply-softcut-port.mjs --check # 确认「已应用」 ``` 若 `--revert` 报备份不存在而内核源码里仍有旧移植痕迹,说明备份曾被覆盖——此时先重装 DSH(恢复 pristine 内核)再执行新版移植。 ## 六、排障 | 现象 | 原因与处理 | | --- | --- | | 脚本提示「未找到 dsh-agent-loop 内核文件」 | DSH 不在当前工作目录。用 `SOFTCUT_TARGET` 指定绝对路径。 | | 脚本提示「锚点校验失败」 | 当前内核排版与脚本假设不一致。**不要放宽匹配**。脚本会指明具体是哪一处,据此人工比对。 | | 运行后设置页仍显示「没有掐断能力」 | 未重启 DSH,或重启的不是你改动的那个安装实例。 | | `node --check` 报语法错 | 移植被部分写入。执行 `--revert` 还原后重新排查。 | | Windows 计划任务里执行脚本报 `2147942402` | 计划任务解析不到 `node`。在任务里写 `node.exe` 的**绝对路径**。 | ## 七、已知限制 - 软切是**尽力而为**的启发式机制,判定基于字符串特征,不理解语义。正常思考中出现转折句也可能被切断(代价较低:模型从断点继续,不会丢失工作)。 - 单个回合每类软切各有上限且全部有界:启发式 6 次、确证复读 20 次、伪工具 3 次;启发式与确证复读超限后该类被静默忽略,伪工具超限升级为硬中止(见「分类软切预算」)。 - 本插件**不会中止进行中的回合**。它只在模型自己写坏输出时切断当前这一次生成请求,随后继续。若你看到回合被整体取消,那不是本插件的行为。 - 补丁不修复其它因内核升级而失效的能力。本插件的结论注入与设置页在所有版本均可用。 - 移植脚本按 DSH 0.1.5-rc.2 的实现写就,面向 0.1.5-rc.3 至 0.2.x。更新到更高版本后可能需要按新排版调整锚点。