# Issue #12:长思考流截断后的有界恢复 ## 状态与目标 - 记录日期:2026-09-30。 - 当前状态:定向离线回归、全量离线与清单检查已通过;修订恢复提示后,一次真实 MiMo Deep 截断恢复成功。首次真实失败记录保留;真实 dsh UI 仍待完成。 - 目标:上游在仅返回思考、尚未产生正文或工具调用时无正常结束帧地关闭流,插件最多追加一次请求,尝试根据已收到的思考生成回答。 - 适用范围:默认对全部模型开放,覆盖 Chat Completions、Messages、Responses 三种上游协议。模型进入恢复流程仍必须满足相同的触发条件。 - 非目标:无限续接、完整重放原请求、自动恢复已经输出正文或工具调用的回合、保证上游一定能完成回答。 ## 已有事实与本次变化 v1.3.1 的实现已经能区分正常结束与无结束帧的 EOF。无输出的 EOF 会返回可重试的 `TRANSPORT`;已有输出的 EOF 会返回不可重试的 `STREAM_CUT`。这能防止宿主将同一段长思考整轮重复发送,但纯思考被截断的回合仍没有正文。 README 中 304 秒、608 秒、912 秒的重试记录是旧版本的历史证据,不是本次恢复实现的验收结果。上游截断时刻和恢复请求的成功率仍受模型、负载与网络影响。 本次增加的是适配器内部的一次恢复请求。宿主看到的仍是一个逻辑回合;本机统计必须记下最多两次实际发出的物理请求。 ## 触发条件 以下条件必须全部满足: 1. 第一段流自然到达 EOF,且没有协议认可的正常结束帧。 2. 第一段收到非空思考内容。 3. 第一段未产生正文,也未产生任何工具调用,包括未完整结束的工具调用。 4. 用户未取消,调用信号未中止,整体截止时间仍有余量。 5. 尚未发起过恢复请求。 6. 思考检查点不超过 131072 个字符,且原始上下文与恢复材料通过保守容量检查。 正常 `stop`、输出预算用尽、明确的上游失败、地区拒绝、限流、网络异常或取消不能被解释为上述 EOF。第一段完全无输出时,保持原有的空输出失败处理。 第二段不会再次进入恢复流程。已经产生正文或工具的第一段仍按原有截断规则结束,避免重复回答或重复执行工具。 恢复段只有在正常结束且产生非空白正文时才算成功。仅有思考、空白正文或结束帧不能被记为已恢复。 ## 恢复材料与工具边界 恢复请求保留原始会话输入,将第一段已收到的思考作为明确标注的检查点文本,要求模型直接整理回答。检查点属于输入材料,不能变成系统指令,也不能被当成待执行的工具结果。不会重新向宿主播放第一段已经输出的思考块。 恢复提示明确说明检查点已经满足原任务要求的长分析,要求结论优先、最多 800 词,不再 重新开始分析或探索更多构造。800 词是提示约束,不是硬性 token 限制,模型可能不遵守。 这是一种降级回答,原任务要求的长篇或逐步分析可能被简化;不能承诺每次一定完成。 检查点只在当前调用内存中存在,结束后释放;本机统计、警告和探测摘要不保存或打印思考原文、用户提示词、工具结果、图像和凭据。 恢复段关闭工具选择,不允许新的工具调用执行。上游免费车道要求的四个工具指纹声明可以保留,但工具禁用必须使用每种协议认可的方言,不能把 Chat 的设置直接当成 Messages 的契约。若上游仍返回工具调用,不能转交宿主执行。 第一段与恢复段属于同一个稳定上游 session;恢复段使用新的 request ID,便于区分两次请求。 本地转发收到最终失败时,即使已经收到部分正文,非流式 Chat/Responses 也返回 JSON 502;Chat SSE 返回 error 后 `[DONE]`,不能伪造正常 `stop`。客户端断开会取消当前 生成段,并阻止后续恢复请求。实际回归结果以本次测试记录为准,真实 dsh UI 尚未运行。 ## 次数、时限与容量 | 约束 | 默认值 | 行为 | | --- | --- | --- | | 恢复次数 | 1 | 每个逻辑回合最多追加一次物理请求 | | 逻辑回合总时限 | 480 秒 | 从第一段发起时计算,包含第一段、恢复准备和恢复段 | | 恢复段时限 | 180 秒 | 同时受剩余总时限约束,取两者较小值 | | 恢复输出上限 | 8192 tokens | 同时受用户原有上限与模型输出容量约束;扣除首段已知输出 tokens 后不足 512 就不恢复 | | 检查点大小 | 131072 字符 | 超出时不恢复,不能无提示地裁剪后声称保留了完整思考 | | 上下文容量 | 模型已知容量的文本字节估算 | 估算原始文本、检查点、控制提示与输出预留;不计算图片 token,不是精确 tokenizer 校验 | 用户取消必须中止当前请求并阻止后续请求。取消、总时限耗尽、容量检查拒绝、第二段再次截断或第二段失败都应给出可识别的最终失败,不能伪造正常完成。 “所有模型开放”指不设模型名单;不表示忽略容量、工具、取消或时限保护,也不表示已经逐一完成真实模型验证。 本机设置 `streamRecovery: false` 关闭恢复;对象形式支持 `enabled` 开关与数值边界。 数值只允许向下调整,不能突破上述默认上限。超过容量或检查点上限时跳过恢复;恢复失败 返回 `STREAM_CUT`,防止宿主重新发送整个长思考回合。用户取消是例外,仍返回 `kind: 'aborted'` 和 `code: 'ABORTED'`,不能改写成截断或重试。 文本字节检查会略去 `image_url` 和 `data` 内容,不能保证多模态输入的实际 token 总量 低于模型窗口;模型容量未知时也不能宣称已经通过精确容量校验。上游仍可能拒绝恢复请求。 ## Usage 与本机统计 ### 逻辑回合 适配器最终只发出一个逻辑回合的 usage 汇总和最终结束结果。usage 汇总只相加上游实际报告的已知计数;缺少某一段的 usage 时,不能根据思考字符数推算 token,也不能把未知用量写成已确认的零。 如果宿主 usage 契约不能表达缺失计数,兼容字段只返回已知部分;本机样本同时明确标记存在缺失,避免把该汇总描述成整轮完整用量。 ### 物理请求 每个实际发出的请求分别进入 `recordUsage`,因此一段截断后恢复成功的逻辑回合会留下两条样本: - 第一段:截断失败;保留实际已知 usage;缺 usage 时显式标记 `noUsage`。 - 第二段:按实际结束结果记录成功或失败;保留其实际已知 usage;缺 usage 时同样标记 `noUsage`。 请求总数和各模型 calls 增量对应物理请求数。恢复元数据包括 `recoveryId`、`attempt`(0/1)、`recoveryAttempt`、`recoveryScheduled`、`recovered` 和 `noUsage`。缺失信息记录在各段样本的 `noUsage` 中,通过相同 `recoveryId` 关联判断;没有单独的整轮缺失字段。验收须读取落盘样本,而不是只检查适配器回调。 `src/store.js` 采用显式样本字段列表,已经加入上述恢复元数据的持久化;新字段不能只由适配器传入,否则落盘时会被丢弃。正常已完成但无 usage 的请求也保留缺失标记。 失败段的耗时不能加入成功解码速度;恢复段测速只使用该段自己的解码窗口与对应已知 token,不能把两段时间和一段 token 混合相除。 ## 验收条件 | 场景 | 预期结果 | 当前证据状态 | | --- | --- | --- | | 三种协议:第一段只有思考且 EOF,第二段正常回答 | 两次上游请求;只输出一次非空白正文;最终正常结束 | 离线套件结果见下节;真实 Chat/MiMo 成功一次,其他模型及协议未逐一实测 | | 任意模型进入同一恢复条件 | 不受模型名单限制 | 离线套件结果见下节;真实模型不能宣称逐一验证 | | 第一段已有正文 | 不追加恢复请求,保留截断失败 | 离线套件结果见下节 | | 第一段已有工具调用,含不完整调用 | 不恢复、不重复执行工具 | 离线套件结果见下节 | | 第一段有正常终止帧或达到输出上限 | 保持协议对应结束结果,不恢复 | 离线套件结果见下节 | | 第一段或两段间取消 | 不产生第二次请求;结果为取消 | 离线套件结果见下节 | | 第二段取消、超时、截断或明确错误 | 最终失败,不能发出第三次请求;取消保持 ABORTED | 离线套件结果见下节 | | 第二段只有空白正文 | 不记为恢复成功 | 离线套件结果见下节 | | 三种协议的恢复请求工具设置 | 声明满足指纹要求;工具调用关闭且不可执行 | 离线套件结果见下节 | | 检查点超过字符上限或文本上下文估算不足 | 拒绝恢复,保留可识别失败 | 离线套件结果见下节 | | 剩余已知输出预算不足 512 tokens | 不发起恢复请求 | 离线套件结果见下节 | | 第一段无 usage、第二段有 usage | 最终 usage 为第二段已知值;第一条样本 noUsage 与 recoveryId 落盘 | 离线套件结果见下节 | | 两段均有 usage | 已知计数相加;请求计数为 2,不能重复记账 | 离线套件结果见下节 | | 两段均无 usage | 各段 noUsage 明确;不以零值伪装完整用量 | 离线套件结果见下节 | | 本地转发与宿主消费 | 一个逻辑回合能接收恢复正文及最终结束结果 | 真实插件 + 本地 HTTP 集成验证通过;真实 dsh UI 未执行 | | 真实上游长思考请求 | 记录实际模型、协议、耗时、两段样本和最终结果 | 第一次恢复达到总截止后失败;提示修订后第二次恢复成功,见下节 | 离线测试应通过本地上游服务控制 EOF、结束帧、usage、工具和取消,验证实际请求数及发出的请求体。真实探测使用 `scripts/probes/long-think-truncation.mjs`,会访问上游并消耗免费车道额度;未运行时不得写成通过。 ## 验收记录 以下是本次主代理提供的实际离线结果,记录于 2026-09-30;本记录编辑者未重复运行这些测试。 | 检查 | 已收到的结果 | 限制 | | --- | --- | --- | | `scripts/recovery-test.mjs` | 100/100 通过,退出码 0 | 本地受控上游回归,不代表真实模型成功率 | | `scripts/fingerprint-test.mjs` | 新增 11 项通过 | 不把新增断言数写成套件总数 | | `scripts/truncation-test.mjs` | 35/35 通过 | 本地截断与终帧回归 | | `npm run typecheck` | 退出码 0 | 静态类型检查,不替代运行时验收 | | 真实插件转发 + 本地 HTTP | 12 项集成验证通过 | 2 项恢复成功与落盘、6 项部分正文后的 EOF/错误、4 项首段/恢复段 SSE/JSON 客户端断开;不是在线上游或真实 dsh UI | | `node scripts/build-manifest.mjs --force` | 退出码 0;29 个发布文件,465385 字节 | 只生成本地清单,没有提交或发布 | | `npm test` | 19/19 套件通过,退出码 0 | 包含 manifest、release、catalog 与 recovery | | `npm run manifest:check` | 退出码 0 | 两份清单与全部 29 个发布文件一致 | | `git diff --check` | 退出码 0 | 无空白错误,不替代行为验证 | Responses cancelled、failed 缺少 status、Chat content_filter、两段间取消仍保留已知 usage 等边界也已通过本次 recovery 回归。测试扩展已结束,发布清单已刷新,最终 19 个 套件统一运行通过。 ### 真实上游第一轮:恢复失败 执行命令: ```powershell node .verify/issue-12/repro.mjs --mode thinking --effort deep ``` 证据文件: - `.verify/issue-12/2026-09-30T08-59-34-775Z-thinking-result.json` - `.verify/issue-12/2026-09-30T08-59-34-775Z-thinking-trace.jsonl` MiMo V2.6 Flash · Deep,Chat 协议,原输出预算 32768。第一段在 303.882 秒自然 EOF, 没有 finish 或 `[DONE]`;插件实际发起第二次 HTTP 请求。两段累计收到思考 63123 字符,没有正文和工具。总截止在 480.024 秒结束请求,最终为 `error / STREAM_CUT`, 不可重试;探测端未主动取消。两条物理请求样本均带 `noUsage`,因此零值汇总表示没有 已知计数,不能解释为两段没有花费 token。 这次失败说明初版恢复提示仍让模型延续原任务的长分析。随后收紧提示:检查点已满足旧 任务的长分析要求,直接输出结论优先、最多 800 词的答案,不重新分析或探索额外构造。 保留失败证据,不将“发出了恢复请求”当成“恢复成功”。 ### 真实上游第二轮:提示修订后恢复成功 执行同一命令,证据文件: - `.verify/issue-12/2026-09-30T09-08-57-348Z-thinking-result.json` - `.verify/issue-12/2026-09-30T09-08-57-348Z-thinking-trace.jsonl` | 观测 | 实际结果 | | --- | --- | | 模型、档位、协议 | MiMo V2.6 Flash · Deep,Chat | | 第一段 | 304.161 秒自然 EOF,无 finish、无 `[DONE]` | | 恢复段 | 32.777 秒,收到 4264 字符非空白正文,无工具 | | 正常收尾 | 原始 `stop` 在 336.910 秒,usage 在 336.920 秒,`[DONE]` 在 336.924 秒 | | 逻辑结果 | `stop`,总耗时 336.942 秒;探测端未主动取消 | | 请求次数 | 2;使用同一 session、恢复段新 request ID 的约定由代码和离线检查覆盖 | | 第一条样本 | `attempt:0`、`ok:false`、`truncated:true`、`noUsage:true`、`recoveryScheduled:true` | | 第二条样本 | `attempt:1`、`ok:true`、`recoveryAttempt:true`、`recovered:true` | | 已知 usage | uncached input 12380、cached input 192、output 1475,其中 reasoning 397;total 14047 包含 cached input | 两段样本共享 `recoveryId`,只记两次物理请求。第一段用量未知,最终 usage 仅汇总 已收到的第二段计数;14047 不能描述成整轮完整 token 总量。 该结果证明这一条真实请求发生了截断并通过新请求生成答案,不能推出所有模型、三种 真实协议或任意长任务都已通过。真实 dsh UI 未运行;本地插件转发的 12 项集成回归 已通过。最终全量离线检查、发布清单一致性与静态类型检查均已通过。 双语 README 属于发布文件,修改后必须重新生成并核对 `feed/manifest.json` 与 `catalog/integrity.json`。本设计文档及探测脚本不在发布文件列表中。提交、推送、tag、Release 与 CDN purge 仍由用户的发布授权决定。 ## 合并前 review 修复 review 提出两处,均在真实插件 + 本地 HTTP 的入口上复现后再修,没有只靠读代码定案。 1. **一个恢复回合把丢弃内容的告警累积了三倍。** `payloadFor` 复用回合级共享的 `warnings` 数组,而检查点容量估算要另建一份 payload、续写段还要再建一份,三次 `buildPayload` 各自向同一个数组 push。取证:构造一段带两张无法解析图片的恢复回合 (Chat 协议,首段纯思考 EOF,续写正常 `stop`),两段样本的 `warnings` 各含 **6** 条 `image-dropped`,用户可见的那行 warn 同样重复 6 次;`src/adapter.js` 改动前只有一次 build,因此这是本 PR 引入的。另一半问题是首段样本持有的是数组引用,后段 build 追加 时它也会跟着变。 修法:告警只由首段(canonical)payload 收集,估算与续写段各用一次性数组,丢弃内容 的集合本来就不会变。修后同一回合为 2 条,warn 行 1 条。 2. **首段撞上限时的文案把没发生过的恢复说成已发生。** `expired` 只判断是否超时,不区分 段号,首段超时也报 "automatic recovery reached its time limit"。改为按该段实际生效的 deadline 报 `our free model reached its Ns time limit before its finish token`,续写段 另加后缀;这条只在 `streamRecovery` 启用时才会触发,关掉后仍无任何客户端时限。 新增回归:`恢复回合的丢弃内容告警不按 payload 重建次数累积`(同时钉住样本数组与用户 可见文本),另在整轮时限那条既有检查里追加钉住文案中的实际秒数。recovery 由 98 项增至 99 项——新增的是检查项数,追加断言不单独计数。 本次修复改动 `src/adapter.js` 与双语 README,属于发布文件,因此 `node scripts/build-manifest.mjs --force` 重新生成清单:29 个发布文件, 465932 字节;`npm run manifest:check` 退出码 0。 > 复核修正:上段的"100 项"是把"新增一项检查"和"在既有检查里追加断言"都按检查项计了。 > 以套件实际输出为准,recovery 为 **99/99**,双语 README 与本文的该项数字已改回 99, > 清单随之重算为 29 个发布文件 / 465928 字节。其余数字均出自当次运行输出。 ### Issue #15:物理请求与逻辑回合分开统计 已修复。每个实际发出的 HTTP 请求仍由 `recordUsage` 单独记录,因此首段中断、续写成功 会留下 2 条物理样本,其中首段的 `ok:false` 仍计入请求失败;适配器另在最终结果确定时 调用一次 `recordTurn`,把同一过程记为 1 个对话回合、0 次回合失败、1 次已恢复。失败、 取消和生成器清理路径同样只记一条逻辑回合。 `stats.json` 的 `failedRequests` 保存累计物理失败数,`logical` 保存累计回合结果;看板 同时展示两套数字,避免把恢复过程中的内部失败说成用户回合失败。v2 及更早数据无法从 物理样本可靠还原回合边界,迁移会用保留的物理记录建立估算基线,并以 `estimated` 标记; 升级后的记录精确累加。请求、回合和 Token 总量使用 lifetime 累计;速度、首帧和热力图 仍使用保留窗口。回归覆盖恢复成功、失败、取消、生成器 `return()`、样本保留和落盘重载。