Skip to content

feat: pin historical thinking wire form via compress.reasoning.normalize (#482) - #484

Open
ranxianglei wants to merge 1 commit into
masterfrom
2026-10-16_normalize-thinking-wire-form
Open

ranxianglei wants to merge 1 commit into
masterfrom
2026-10-16_normalize-thinking-wire-form

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

行为变更(请 review 重点确认):本 PR 默认开启一项新的 wire 形态归一化。

  • 旧行为:历史 assistant 消息的 reasoning 以独立字段随 wire 发送,其序列化位置依赖宿主【每次请求现算】的元数据表选择(v1 capabilities.interleaved)。
  • 新行为:默认把每个 assistant 消息的所有 reasoning part 折叠进一个带 <thinking> 标记的 text part,使序列化字节对元数据表 / 版本 / provider 配置确定。
  • 逃生开关:compress.reasoning.normalize: false(恢复旧的独立字段形态,供需要结构化 thinking 回放的直连 lane 使用)。

背景 / 来源

来源: 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.ts createChatMessageTransformHandler):对每个 assistant 消息,把所有 reasoning part 合并进一个 text part,固定标记形态:

<thinking>
{全部 R 拼接}
</thinking>
{全部 T 拼接}
  • R / T 用空串拼接(与宿主 lowerAssistantMessage 的 .join("") 语义一致)→ kernel 在 minting 前还原为拆分形态时,归一化后的 wire 与拆分形态 mint 出完全相同的 id,存量账本零别名精确对齐;
  • 多个 part:R 全拼、T 全拼;tool / file 等其它 part 原位保留;
  • 幂等:归一化后不再携带 reasoning part,第二次运行零改动 → 前缀缓存稳定(只变一次);
  • 无状态写入、确定性变换,不动持久化状态格式与内部 tag。

配置门控:新增 compress.reasoning.normalize(布尔),默认开启(消费端判 normalize !== false)。复用现有 #344 级联(model > provider > global)——provider / model 层可设 normalize: false 单独关闭某 lane。

影响面与已知边界

  • 默认开启 = 行为变更:上游模型看到的历史思考从独立字段变为正文里的带标记文本。openai-compat / litellm lane(reasoning 字段大多被中转丢弃)实际影响小;
  • 已知边界:直连需要结构化 thinking 回放语义的 provider(Anthropic 原生 thinking block / Gemini thoughtSignature)会失去结构化字段——此类 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 对象的字节稳定性守卫);
  • E2E 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;
  • 定向套件全绿(config-protected-tools / config-providers / config-validation / reasoning-strip / e2e-message-transform / e2e-blocks-nudges / image-archive = 151 pass),typecheck + build 通过。

文件

  • 源码: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:devlog/2026-10-16_normalize-thinking-wire-form/{REQ,WORKLOG,DESIGN}.md。

Closes #482

…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.
@github-actions

Copy link
Copy Markdown
Contributor

📦 Built Plugin Artifact

Branch: 2026-10-16_normalize-thinking-wire-form (a698a50)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-484 --global

Each push to this PR publishes a new version under the pr-484 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-10-16_normalize-thinking-wire-form" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr484.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 已完成对 PR #484 的独立审核(拉取分支、逐段读 diff、追配置级联、跑定向测试、核 CI)。先报一个必须先处置的发现,再给八问结论。


⚠️ 头号发现:#482 有两个并行 PR,本 PR 是更完整的那个

issue #482(历史思考 wire 形态漂移)被两个 PR 同时认领,违反「一个 issue 至多一个 PR」:

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)

  1. 是什么问题:同一会话同一模型,宿主元数据表刷新一次后,带思考的历史 assistant 消息在 wire 上换了序列化形态(思考从独立字段并入正文),按内容哈希算的消息 id 全部失效 → 下游 billion-context 压缩账本认不回已折叠内容 → 原文重新上线 → 上下文膨胀 → 反复压缩(旗舰会话实测 986 条失配 ≈ 2×带思考消息数)。

  2. 什么原因导致的:根因在产生端——宿主按【当前】元数据表(v1 capabilities.interleaved)现算每个请求的思考放哪个字段,这个选择不随消息持久化。修复落在 messages.transform(交宿主序列化前最后一个可控边界)把形态钉死,方向正确,打到根因而非表面现象。

  3. 影响范围与严重程度:所有带历史思考消息的长会话受影响;默认开启改变了上游模型看到的历史思考形态。openai-compat / litellm lane 影响小(该字段多被中转丢弃);直连需结构化 thinking 回放的 provider(Anthropic 原生 thinking block / Gemini thoughtSignature)会失去结构化字段——已知边界,有 normalize:false 逃生开关单独关闭。整体属「体验/确定性改进 + 特定 lane 有界影响」。

  4. 怎么修复的:在 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)。

  5. 是否有回归问题:无。逐项审过的既有行为:

  6. 是否破坏缓存:否——幂等(wire 只变一次)+ 序列化字节确定,前缀缓存不受影响。

  7. 是否改了设计方向:否——沿用既有 transform 管线与 feat: per-model growth-nudge floor (modelMinNudgeLimits) for mixed-context installs #344 级联,仅新增一步变换 + 一个配置项,架构/边界/数据流不变。

  8. 是否引入了新的配置:是——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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant