Repository navigation
feat: pin historical thinking wire form via compress.reasoning.normalize (#482) - #484
ranxianglei wants to merge 1 commit into
Conversation
…ize (#482) Add a request-time normalization pass (after #368 dropCompressReasoning) that folds each assistant message's reasoning parts into one <thinking>-marked text part, so serialized bytes stay deterministic across host metadata-table refreshes. Default-on kill-switch compress.reasoning.normalize:false for direct providers needing structured thinking replay.
📦 Built Plugin ArtifactBranch: Option A — Install from npm PR tag (recommended)opencode plugin opencode-acp@pr-484 --globalEach push to this PR publishes a new version under the Option B — Install from GitHubopencode plugin "github:ranxianglei/opencode-acp#2026-10-16_normalize-thinking-wire-form" --globalOption C — Download artifact
tar xzf opencode-acp-pr484.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
This comment is automatically updated on each push. |
[bot] 🏷 已完成对 PR #484 的独立审核(拉取分支、逐段读 diff、追配置级联、跑定向测试、核 CI)。先报一个必须先处置的发现,再给八问结论。
|
| PR #483 | PR #484(本 PR) | |
|---|---|---|
| 提交时间 | 01:46(更早) | 02:59(更晚) |
| 实现 | 新建独立模块 lib/messages/reasoning-canonicalize.ts |
复用现有 lib/messages/reasoning-strip.ts |
| 配置开关 | ❌ 无(「默认开启,未加配置开关」) | ✅ compress.reasoning.normalize |
| 文档/devlog | REQ+WORKLOG | REQ+WORKLOG+DESIGN + CONFIGURATION/README 四份 |
| 测试 | 单文件 11 例 | 单测 + 校验 + 级联 + e2e,含 #368 keep-case 隔离 |
| 版本字段 | ❌ 改 package.json 1.18.3→1.18.4(非 release 分支,违反 §5.1.1.1) |
✅ 未动 |
两者同一症状、同一机制(transform 里把 reasoning 折叠成带 <thinking> 标记的 text)、同一根因——100% 重复。#484 严格优于 #483:它做了 #482 明确要的「可后续加开关」,且没有 #483 的版本号违规。
建议:关闭 #483 作重复(保留信息到 #482),合入 #484。注意这反了通常「后到的关、先到的留」的默认序——因为先到者(#483)更差且有策略违规,所以保留晚到、更完整的 #484。此处置涉及关掉一个竞争 PR,我不擅自动手,等你确认;你说一声我就去关 #483 并打 重复 标签。
PR #484 结构化八问(独立审核,非照抄 issue)
-
是什么问题:同一会话同一模型,宿主元数据表刷新一次后,带思考的历史 assistant 消息在 wire 上换了序列化形态(思考从独立字段并入正文),按内容哈希算的消息 id 全部失效 → 下游 billion-context 压缩账本认不回已折叠内容 → 原文重新上线 → 上下文膨胀 → 反复压缩(旗舰会话实测 986 条失配 ≈ 2×带思考消息数)。
-
什么原因导致的:根因在产生端——宿主按【当前】元数据表(v1
capabilities.interleaved)现算每个请求的思考放哪个字段,这个选择不随消息持久化。修复落在messages.transform(交宿主序列化前最后一个可控边界)把形态钉死,方向正确,打到根因而非表面现象。 -
影响范围与严重程度:所有带历史思考消息的长会话受影响;默认开启改变了上游模型看到的历史思考形态。openai-compat / litellm lane 影响小(该字段多被中转丢弃);直连需结构化 thinking 回放的 provider(Anthropic 原生 thinking block / Gemini thoughtSignature)会失去结构化字段——已知边界,有
normalize:false逃生开关单独关闭。整体属「体验/确定性改进 + 特定 lane 有界影响」。 -
怎么修复的:在 transform 管线 Protected-message exemption inflates the incompressible context floor: per-compression reasoning is never reclaimable (~83.5% of measured residual) #368 drop 之后加一步归一化
normalizeThinkingForm(lib/messages/reasoning-strip.ts)——把每个 assistant 消息的全部 reasoning part 合并进一个<thinking>标记 text part,R/T 用空串拼接(对齐宿主lowerAssistantMessage的.join("")语义,kernel mint 前还原为拆分态 → 与拆分形态 mint 出完全相同 id,存量账本零别名精确对齐)。幂等(只变一次)、无状态、不动持久化格式与内部 tag。门控compress.reasoning.normalize(默认开,消费端判!== false)。 -
是否有回归问题:无。逐项审过的既有行为:
- Protected-message exemption inflates the incompressible context floor: per-compression reasoning is never reclaimable (~83.5% of measured residual) #368 drop gate:归一化在其后运行,被裁的 reasoning 保持被裁;4 个既有 e2e keep-case 已用
normalize:false隔离,继续只验 drop gate(diff 里确认隔离真实存在,非改名); - 前缀缓存:幂等 + 字节确定 → 稳定;
- 持久化状态格式:无写入、无 tag 变更;
- config 字节稳定性:
mergeCompress仅在显式设置时才转发normalize(override ?? base,未设则不注入 key),未提及该键的配置 resolved 对象与 pre-Historical thinking message wire formats drift as the metadata table is refreshed, leading to downstream compaction ledger mismatches (normalize the thinking format at request time). | 历史思考消息的 wire 形态随元数据表刷新漂移,下游压缩账本失配(请求时归一化思考形态) #482 字节一致,有专门守卫测试; - 级联:我独立追过
resolveCompressOverrides(lib/messages/inject/utils.ts:290,provider/model 的reasoning逐字段合并 model>provider)+applyCompressOverrides(shallow-spread 到全局config.compress.reasoning)——per-lanenormalize:false确实生效,不是只在声明里成立。 - 高危模式逐项:无 fail-fast 变吞错、无阈值/时序漂移、除披露的 wire 形态变更外无输出格式意外。
- Protected-message exemption inflates the incompressible context floor: per-compression reasoning is never reclaimable (~83.5% of measured residual) #368 drop gate:归一化在其后运行,被裁的 reasoning 保持被裁;4 个既有 e2e keep-case 已用
-
是否破坏缓存:否——幂等(wire 只变一次)+ 序列化字节确定,前缀缓存不受影响。
-
是否改了设计方向:否——沿用既有 transform 管线与 feat: per-model growth-nudge floor (modelMinNudgeLimits) for mixed-context installs #344 级联,仅新增一步变换 + 一个配置项,架构/边界/数据流不变。
-
是否引入了新的配置:是——
compress.reasoning.normalize(boolean,默认开启,支持 global/provider/model 三层覆盖,model 优先)。无需迁移(默认即生效);需结构化 thinking 回放的 lane 设false,CONFIGURATION.md/.zh-CN 已给示例。
代码整洁:命名达意(normalizeThinkingForm/isReasoningWithText/donor 复用首个 text part 身份),注释讲「为什么」(#482 根因 + join 语义对齐),类型谓词避免 as any,schema 三处 + top/provider/model 三层校验齐全。一处极小瑕疵:normalizeThinkingForm 末尾的 if (!inserted) 防御块实际不可达(reasoningParts.length>0 已保证主循环命中 reasoning 槽位),属无害防御性死代码,非阻塞,可留可删。
测试有效性:非恒真——精确字节断言(<thinking>\nthink\n</thinking>\nanswer、<thinking>\nab\n</thinking>\nxy)、R-only 无悬挂换行、多 part 空串拼接、幂等二跑零改动、tool part 原位保序、计数;e2e 覆盖默认折叠 + kill-switch 保留独立 reasoning。这个 bug 回归时抓得住。
收工判定
PR #484 可合:CI 六项全绿(e2e/build/test22/pr-validation/test24/build-artifact),本地定向套件 137 例通过(103 单元/配置 + 34 e2e),无回归,行为变更已披露且带逃生开关。合并前唯一前置:分支 behind master,需先 rebase。
待你拍板:是否关闭重复的 PR #483(我推荐关,理由是它更差且版本号违规)。
按 §5.1.1.2,合并不由我执行——rebase 干净后请你点 Merge:#484
背景 / 来源
来源: ranxianglei/billion-context#3301 分析 元数据刷新后 fold re-anchor 失配 时发现
同一会话、同一模型,宿主读取的模型元数据表刷新一次后,带思考的历史 assistant 消息在 wire 上换了序列化形态(思考从独立字段并入正文),按内容哈希计算的消息 id 全部失效 → 下游 billion-context 压缩账本认不回已折叠内容 → 原文重新上线 → 上下文膨胀 → 再压一遍。长会话反复发生(旗舰会话实测 986 条失配 ≈ 2×带思考消息数)。
根因:宿主按【当前】元数据表决定思考放哪个字段,这个选择不随消息存储、每次请求现算。插件
messages.transform是交给宿主序列化前最后一个可控边界——在边界上把形态钉死,输出就对元数据表 / 版本 / provider 配置字节确定。查重: 无(仓库内检索 reasoning 相关 issue 均为 #368 裁剪类,与本项「形态钉死」机制不同)。
修法
在 transform 管线加一步归一化,紧随 #368 的
dropCompressReasoning之后(lib/hooks.tscreateChatMessageTransformHandler):对每个 assistant 消息,把所有 reasoning part 合并进一个 text part,固定标记形态:lowerAssistantMessage的.join("")语义一致)→ kernel 在 minting 前还原为拆分形态时,归一化后的 wire 与拆分形态 mint 出完全相同的 id,存量账本零别名精确对齐;配置门控:新增
compress.reasoning.normalize(布尔),默认开启(消费端判normalize !== false)。复用现有 #344 级联(model > provider > global)——provider / model 层可设normalize: false单独关闭某 lane。影响面与已知边界
normalize: false单独关闭(CONFIGURATION.md 示例已给出)。issue 标注「可后续加开关」,本次已提供该开关。测试
tests/reasoning-strip.test.ts:+8(精确字节合并、R-only 无悬挂\n、多 part 空串拼接、幂等、非 assistant / 无 reasoning 不动、tool part 原位保留、计数);tests/config-validation.test.ts:+4(接受合法 boolean、拒绝非 boolean 顶层 + provider/model 覆盖);tests/config-providers.test.ts:+4(field-wise 继承、model 优先 provider、applyCompressOverrides 透传、未设置时不污染 resolved 对象的字节稳定性守卫);tests/e2e-message-transform.test.ts:+2(默认管线折叠成<thinking>text、normalize:false保持独立 reasoning);并把 4 个既有 Protected-message exemption inflates the incompressible context floor: per-compression reasoning is never reclaimable (~83.5% of measured residual) #368 e2e keep-case 用normalize:false隔离,使其继续只验证 drop gate;文件
lib/messages/reasoning-strip.ts(新增normalizeThinkingForm)、lib/hooks.ts(管线接入)、lib/messages/index.ts(导出)、lib/config.ts(类型 + mergeCompress 条件转发)、lib/config-validation.ts(字段白名单 + 类型校验)、dcp.schema.json(3 处);CONFIGURATION.md/CONFIGURATION.zh-CN.md、README.md/README.zh-CN.md;devlog/2026-10-16_normalize-thinking-wire-form/{REQ,WORKLOG,DESIGN}.md。Closes #482