From 9d97cf9ad736419fda9341da0520d429f83d698b Mon Sep 17 00:00:00 2001 From: H2RHAN <2373723338@qq.com> Date: Wed, 9 Sep 2026 17:41:59 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20P0=20=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2=EF=BC=88=E6=B8=85=E9=99=A4=2020-block=20?= =?UTF-8?q?=E6=AE=8B=E7=95=99=20/=20=E7=8A=B6=E6=80=81=E5=88=97=E6=A0=A1?= =?UTF-8?q?=E5=87=86=20/=20=E8=AF=81=E4=BC=AA=E9=A2=84=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=20/=20LIMITATIONS=20/=20CHANGELOG=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ir-schema v0.2:删除已证伪的 20-block 引用,补「变更记录」(冻结=L0/L1 语义层,修订必配回归测试) - capability-matrix v0.2:状态列按实码校准(prev_id/断点布局已实现;SSE 更正为整读透传);补手动断点 vs 自动缓存决策记录 - experiment-results:补「第〇节 · 证伪条件预登记」(实验编号=假设编号,回链问题清单;证实 8 / 证伪 4 / 反转 1) - 新增 docs/LIMITATIONS.md:12 项未闭环四要素 + 结论隔离证明 - 新增 CHANGELOG.md:按日期演进(v0 冻结 → v1.2 → 实验交付 → 覆盖补全) - README:状态表加证据列、SSE 措辞更正、新增与 TencentDB-Agent-Memory 的关系 - REVIEW:缺口表指向 LIMITATIONS;run_experiments 注释同步证伪结论 - 验证:25 单测 + 11 冒烟 + dry-run 全过(Python 3.13) --- CHANGELOG.md | 37 ++++++++++++ README.md | 36 +++++++---- docs/LIMITATIONS.md | 105 +++++++++++++++++++++++++++++++++ docs/REVIEW.md | 2 + docs/capability-matrix.md | 52 +++++++++------- docs/experiment-results.md | 23 ++++++++ docs/ir-schema.md | 16 ++++- experiments/run_experiments.py | 2 +- 8 files changed, 236 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/LIMITATIONS.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..907dba7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# CHANGELOG + +按日期复述演进(问题清单冻结于 09-01,仓库 09-05 建立;v1.2 为首个全量快照,其后一律走 PR 流)。 + +## 2026-09-01 · 契约冻结(v0) + +- 问题全景分析定稿:31 条问题清单 + A/B/C 分级 + 范围切割(主线 / 非目标) +- **IR v0 三层契约冻结**(L0 内容块 / L1 规范请求 / L2 会话缓存上下文),05A/05B 据此并行 +- 缓存最小 token 阈值、计费与 TTL 不对称逐模型核对官方文档(docs.anthropic.com 与 platform.claude.com 一致) + +## 2026-09-05 · v1.2 全量实现(PR #1) + +- IR 契约 / 3 对 adapter / 状态层(SQLite + TTL + prev_id 反查)/ 网关(预热校验 + 记忆注入 + 限流 + usage 归一 + 历史落库)/ 5 项埋点 / 实验框架 / 工具链 +- **两轮代码自查修复 9 项缺陷**(3 严重:状态层空转 / 多轮链断 / prev_id 回传上游),各配回归测试 → 抗漂移机制实战验证 +- 25 项单测 + 11 项端到端冒烟全过 + +## 2026-09-08 · 实验交付(PR #2) + +- 缓存命中率全量实测 **exp1–19 + E20–E28**(Anthropic 兼容端点真实调用) +- 主数字:LOCKED vs DYNAMIC ≈ **7.3×**(跨模型一致);E27「05B 救援」:Responses 透传 0% → 转 Messages 99.85% +- 「20-block 回看窗口」**决定性证伪**(60 块 100% 命中),从执行方案删除 + +## 2026-09-09 · 覆盖补全 + 可观测对账 + 弹网页(PR #3 / #4 / #5) + +- E30–E34 进阶实验:并发冷启动重复写入(E32,single-flight 必要性)/ 前缀字节边界 / 命中快 ~1.65s(E33)/ opus-5 悬案定性为「端点异常」(E34)/ 23711 token 仍全缓存 +- **E29 埋点对账**:起网关连真实端点,埋点与 API usage 逐字段一致 +- 弹网页编排界面:记忆勾选/裁剪 + 缓存前缀可视化 + 回写状态层 +- 修复网关后端路径映射(`PB_CHAT_PATH` / `PB_RESPONSE_PATH` / `PB_ANTHROPIC_PATH` 可覆盖) + +## 2026-09-09 · 文档一致性修订(本提交) + +- 全库清除「20-block 回看窗口」残留表述(ir-schema / capability-matrix / 实验脚本注释),统一指向证伪结论 +- capability-matrix v0.2:状态列按实码校准(prev_id / 断点布局已实现;SSE 更正为「整读透传」);补「手动断点 vs 自动缓存」决策记录 +- ir-schema 补「变更记录」(冻结 = L0/L1 语义层 + 修订必配回归测试的抗漂移机制) +- experiment-results 补「第〇节 · 证伪条件预登记」(实验编号 = 假设编号,回链问题清单条目) +- 新增 `docs/LIMITATIONS.md`:12 项未闭环/边界项四要素(影响半径 / 触发条件 / 绕过方式 / 后续路线)+ 结论隔离证明 +- README:状态表加「证据」列;SSE 措辞更正;新增「与上游 TencentDB-Agent-Memory 的关系」 diff --git a/README.md b/README.md index 359cfed..0188c00 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,23 @@ OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + ## 当前状态 -| 模块 | 状态 | 说明 | +| 模块 | 状态 | 证据 | |---|---|---| -| IR v0 契约 | ✅ 离线 | `docs/ir-schema.md` + `src/ir/model.py` | -| 状态层(SQLite 持久化)+ 可切换 Session 配置 | ✅ v1.1 | `src/state/` + `config/session.json`,重启不丢 | -| 三对 adapter(字段映射) | ✅ 离线 | `src/adapters/`,录制样例可测 | -| 网关转发 + SSE 透传 | ✅ 离线(mock)/ Groq 真实链路已验证 | `src/gateway/` | -| 记忆注入(幂等去重 + memory_cap) | ✅ v1.1 新增 | `inject_memories`,管理口 `/v1/admin/session/meta` | -| 预热拒绝条件校验 | ✅ v1.1 新增 | stream/thinking/structured outputs/tool_choice 四类冲突 400 | +| IR v0 契约(三层 + 变更记录) | ✅ 冻结(语义层) | `docs/ir-schema.md` + `src/ir/model.py`;抗漂移机制见变更记录节 | +| 状态层(SQLite 持久化)+ 可切换 Session 配置 | ✅ v1.1 | `src/state/` + `config/session.json`,重启不丢(`TestStateLayer`) | +| 三对 adapter(字段映射) | ✅ 离线 | `src/adapters/`,录制样例可测(`TestAdapterRoundTrip`) | +| 网关转发 | ✅ 离线(mock)/ Groq 真实链路已验证 | `src/gateway/`;E29 真实端点对账 | +| SSE 流式 | ◐ 整读透传 | 逐块转换未实现 → `docs/LIMITATIONS.md` #1;E21 证明流式无损命中率 | +| 记忆注入(幂等去重 + memory_cap) | ✅ v1.1 新增 | `inject_memories` + `TestGatewayPolicies`;管理口 `/v1/admin/session/meta` | +| 预热拒绝条件校验 | ✅ v1.1 新增 | stream/thinking/structured outputs/tool_choice 四类冲突 400(`TestWarmup`) | | 限流 | ✅ v1.1 新增 | `PB_CONCURRENCY`(默认 2) | -| 命中率埋点(5 项暴露) | ✅ 已实做对账 | v1.1 起真实统计、v1.2 预热轮单列/加锁;E29 实测埋点与 API usage 逐字段一致 | -| max_tokens:0 预热路径 | ✅ 字段处理 | 用录制响应样例测 | -| **两轮代码自查(9 bug 修复 + 回归测试)** | ✅ **v1.2 新增** | 含 3 个严重项(状态层空转 / 多轮链断 / prev_id 回传),见 `docs/REVIEW.md` 第五节 | -| 三组对照实验 | ✅ 34 组全量实测 | `experiments/`(`rich_experiments.py` E20–E28 + `rich_experiments2.py` E30–E34);结果见 `docs/experiment-results.md` | +| 命中率埋点(5 项暴露) | ✅ 已对账 | **E29:埋点与真实端点 usage 逐字段一致** | +| max_tokens:0 预热路径 | ✅ 实测有效 | 录制样例测 + 实测预热后 100% 命中(exp17) | +| **两轮代码自查(9 bug 修复 + 回归测试)** | ✅ **v1.2 新增** | 含 3 个严重项,各配回归测试(`TestAuditFindings`),见 `docs/REVIEW.md` 第五节 | +| 三组对照实验 | ✅ 34 组全量实测 | `docs/experiment-results.md`(含第〇节证伪条件预登记) | | TRACK04 签字确认稿 | ✅ v1.1 新增 | `docs/TRACK04_签字确认稿.md`,分工会直接签 | | 弹网页(本地仪表盘) | ✅ 记忆编排界面 | `src/webui/` 安全骨架 + 记忆勾选/裁剪 + 缓存前缀可视化 | +| 已知限制(边界自觉) | 📋 12 项四要素 | `docs/LIMITATIONS.md`(含结论隔离证明) | ## 快速开始(离线) @@ -69,9 +71,16 @@ python -m src.gateway.server ## 实验(已完成,34 组) - 缓存命中率实测 **34 组**(exp1–19 三组对照 + E20–E28 覆盖 + E30–E34 进阶),量化取舍结论见 `docs/experiment-results.md`。 +- **实验编号 = 假设编号**:问题清单的 A 级条目在写代码前即登记为待判决假设(测试左移),回链表见实验文档第〇节。判决分布:证实 8 · 证伪/反向 4 · 反转 1(含「20-block 回看窗口」证伪、「05B 透传不可行 → 转 Messages 可达」反转)。 - 核心结论:LOCKED vs DYNAMIC ≈ **7.3×**(跨模型一致);命中比冷启动快 **~1.65s**;缓存按 provider 隔离、不跨模型复用;haiku-4.5 阈值 4096 token。 - 配套脚本:`experiments/run_experiments.py`(三组对照)、`rich_experiments.py`(E20–E28)、`rich_experiments2.py`(E30–E34)。 +## 与上游 TencentDB-Agent-Memory 的关系 + +[TencentDB-Agent-Memory](https://github.com/TencentCloud/TencentDB-Agent-Memory) 的核心机制是「proxy 不改协议、每轮向上下文注入 L2/L3 记忆」——**本仓库的 34 组实验量化的正是这套机制的成本语义**:同一份记忆,锁死注入 vs 动态注入成本差 7.3×;低于模型阈值静默不缓存;断点布局直接决定前缀稳定性。 + +可拆出提上游的方向:AgentMemory proxy 目前**没有 `cache_control` 断点布局与缓存命中率埋点**——本仓库的断点布局(3 固定 + 1 滚动)与 5 项埋点(E29 已与真实端点对账)可作为参考实现。 + ## 目录 ``` @@ -79,7 +88,7 @@ src/ ir/ IR 数据模型(L0/L1/L2) adapters/ chat / response / anthropic 三对 adapter state/ 会话表 + TTL + session 配置 + build_prefix - gateway/ HTTP 转发 + SSE 透传 + gateway/ HTTP 转发(SSE 为整读透传,逐块转换见 docs/LIMITATIONS.md #1) observability/ 命中率埋点(5 项暴露) warmup/ max_tokens:0 预热 webui/ 弹网页本地仪表盘(安全骨架 + 记忆编排界面) @@ -87,5 +96,6 @@ config/ session.json(Session 边界可切换配置) experiments/ 三组对照 + rich_experiments.py(E20–E28)+ rich_experiments2.py(E30–E34) tests/ 离线单测(录制样例 + 自查回归 + 多轮链 E2E) tools/ mock_backend + verify_cache + smoke_e2e + recordings -docs/ ir-schema.md / capability-matrix.md / REVIEW.md / TRACK04_签字确认稿.md +docs/ ir-schema.md / capability-matrix.md / experiment-results.md / LIMITATIONS.md / REVIEW.md / TRACK04_签字确认稿.md +CHANGELOG.md 按日期演进记录(v0 契约冻结 → v1.2 → 实验交付 → 覆盖补全) ``` diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md new file mode 100644 index 0000000..9fc2310 --- /dev/null +++ b/docs/LIMITATIONS.md @@ -0,0 +1,105 @@ +# 已知限制(LIMITATIONS) + +> 2026-09-09 · 本文档是本组「边界自觉」的执行:凡未闭环项,一律写清**影响半径 / 触发条件 / 绕过方式 / 后续路线**四要素。 +> 原则(问题清单 05 节):关键不是少做,而是把「没做」写成显式的已知限制。 +> 凡本文档与 README / 能力矩阵的状态表述不一致处,**以本文档为准**。 + +## 一、协议转换链路 + +### #1 SSE 逐块流式转换未实现(整读透传) + +- **影响半径**:流式体验与首 token 延迟(TTFB)。功能正确性不受影响——响应内容完整、usage 归一与埋点照常工作。 +- **触发条件**:客户端请求 `stream:true` 经过网关。当前网关 `post_json` 整读响应后一次性返回;跨协议的事件模型转换(Chat `delta` / Responses 三维寻址 / Anthropic 块生命周期)未实现。 +- **绕过方式**:① 非流式调用网关(缓存命中率不受影响——E21 已实测流式与非流式命中率一致、缓存跨传输模式共享);② 需要流式时直连端点。 +- **后续路线**:按 IR 块生命周期定义统一事件模型,先做 Anthropic→Chat 单方向逐块转换;`input_json_delta` 非完整 JSON 片段需缓冲到完整再发(长工具参数退化为非流式,属无法回避的折损,将写进限制)。 + +### #2 工具调用 ID 无双向持久映射表(透传) + +- **影响半径**:跨协议多轮工具链的 ID 一致性。当前 `tool_id` 经 IR 原样透传(`toolu_*` / `call_*` / `call_id` 直通),单轮与回放自身历史的场景工作正常。 +- **触发条件**:一轮协议 A 产生的工具调用 ID,在后续轮次以协议 B 回传给生成方 A 时;以及并行调用重名、分叉重放场景。 +- **绕过方式**:同协议往返(如 Anthropic→Anthropic)ID 原样有效;跨协议场景下多数 OpenAI 兼容端点接受任意字符串 ID。 +- **后续路线**:状态层增加 `id_map`(会话内双向映射 + TTL 失效),处理并行重名;配回归测试后从本清单划掉。 + +### #3 状态层重放只保留文本块 + +- **影响半径**:多轮工具链 / 多轮 thinking 链。`_assistant_message` 只提取文本写入会话历史——thinking 块(含 signature)与 tool_use/tool_result 块不进重放。 +- **触发条件**:开启 thinking 的多轮对话第二轮起(Anthropic 要求原样回传 thinking + signature,缺失会 400);工具结果需跨轮引用的场景。 +- **绕过方式**:无状态全量重放(客户端自带完整历史)不经状态层,不受影响;单轮工具链不受影响。 +- **后续路线**:`_assistant_message` 保留结构化块(thinking/tool_use 原样入史),replay 路径按 IR 块渲染;与 #2 的 id_map 同日落地。 + +### #4 adaptive thinking / `display:"omitted"` 未处理 + +- **影响半径**:新模型(adaptive thinking + `output_config.effort`)的预热校验与块处理。`validate_warmup` 只识别旧版 `thinking.type=="enabled"`。 +- **触发条件**:对新模型发送 `max_tokens:0` 预热且带新版推理配置——校验漏判,上游返回 400 而非网关提前拒绝;`thinking.display:"omitted"` 块(文本为空但 signature 必须保留)可能被误判为无效块。 +- **绕过方式**:预热请求不带 thinking(官方本就禁止);正式请求经 `extra` 透传,同协议无损。 +- **后续路线**:校验逻辑按模型代次分派;omitted 块显式保留进 IR。 + +### #5 角色交替规整(合并同角色 / 占位 user)未实现 + +- **影响半径**:Anthropic 要求 user/assistant 严格交替且首轮为 user;外部请求若含连续同角色消息,网关不做规整。 +- **触发条件**:客户端连发两条同角色消息、或以 assistant 开头。状态层重放的历史自然交替,不触发。 +- **绕过方式**:多数兼容端点对交替违规容错;客户端自行规整。 +- **后续路线**:adapter 层加合并策略(注意:插入占位 user turn 本身是语义污染,将显式进降级记录)。 + +### #6 错误语义未归一(状态码透出但 code↔type 未映射) + +- **影响半径**:OpenAI `error.code` 与 Anthropic `error.type` 的语义体系不同(如 `context_length_exceeded` vs `invalid_request_error`),当前网关透出原始状态码与错误详情(截断 500 字符),但不做语义映射。 +- **触发条件**:上游 4xx/5xx。客户端拿到的是上游原生错误体,不会是网关 500(v1.2 已修复吞错)。 +- **绕过方式**:客户端按上游协议解析;`_bridge` 元信息保留上游状态码。 +- **后续路线**:错误映射表(含重试语义对齐:`overloaded_error` ↔ `rate_limit_exceeded`)。 + +## 二、度量与实验 + +### #7 replayed / injected 按字符数统计(未接 count_tokens) + +- **影响半径**:埋点中「注入 token / 重放 token」两项的绝对数值偏大(字符 ≠ token),**趋势与组间对比有效**。 +- **触发条件**:读 metrics.jsonl 的 `injected` / `replayed` 字段。 +- **绕过方式**:按实测系数折算(sonnet-4.6 约 5.1 字符/token);命中率、cache_read/creation 等核心字段来自上游真实 usage,不受影响(E29 已对账逐字段一致)。 +- **后续路线**:接 `/v1/messages/count_tokens` 校准(代价:每轮多一次 RTT,需评估)。 + +### #8 缓存断点往返衰减未量化 + +- **影响半径**:Anthropic→Chat→Anthropic 往返后断点信息丢失(Chat 无断点概念),命中率随往返次数衰减——存在但未量化。 +- **触发条件**:跨协议多跳往返。 +- **绕过方式**:单向链路(生产主形态)不受影响;IR 的 `cache_breakpoint` 标记在同协议往返保留。 +- **后续路线**:往返衰减实验(N 次往返后命中率曲线)。 + +### #9 opus-5 缓存行为未决(端点异常,非「不缓存」) + +- **影响半径**:最小可缓存长度表中 opus-5 = 512 一格只有官方文档依据,无实测。 +- **触发条件**:对 opus-5 发大 token 请求——端点返回 `input_tokens=0`(请求未正常处理,E34 四档复测均如此)。 +- **绕过方式**:无。结论表述必须为「端点异常、待复测」,**禁止写成「opus-5 不缓存」**(E34 教训:先核对 `input_tokens` 是否正常,再下缓存结论)。 +- **后续路线**:换可用端点复测;或等端点修复。 + +## 三、工程与运维 + +### #10 网关与管理口无鉴权 + +- **影响半径**:`/v1/*` 与 `/v1/admin/session/meta` 均无鉴权。绑定 `127.0.0.1` 是唯一防线——本地使用安全,**暴露到网络即为事故**(管理口可任意写入会话记忆)。 +- **触发条件**:`PB_PORT` 绑定到非回环地址,或同机恶意进程。 +- **绕过方式**:不要改绑定地址;webui 侧已做 Host 头校验 + 一次性 URL token + 脱敏(记忆内容属敏感信息,loopback ≠ 天然安全)。 +- **后续路线**:需要对外时加 Bearer token(网关侧已预留 `PB_API_KEY` 转发,鉴权逻辑是对称的)。 + +### #11 TTL 淘汰无后台触发器 + +- **影响半径**:`evict_expired` 已实现且测试覆盖,但网关主进程没有定时调用——会话表随运行时间无界增长(`replay_from=full` 时每轮全量重放,长对话重放代价线性增长)。 +- **触发条件**:长时间运行的网关进程。 +- **绕过方式**:重启进程不丢会话(SQLite 持久化),可定期人工调用淘汰;实验场景会话数受控。 +- **后续路线**:网关加定时清扫(或惰性淘汰:读写时顺手淘汰);生产形态需配合 `sliding_window` 重放策略 cap 历史。 + +### #12 往返一致性为例举式测试(非 property-based),有损清单待自动生成 + +- **影响半径**:`TestAdapterRoundTrip` 覆盖录制样例的代表性路径,不做随机化性质测试;已知有损字段清单当前依赖运行时 dropped 记录,无独立文档。 +- **触发条件**:边缘字段组合(罕见的块类型/参数搭配)。 +- **绕过方式**:铁律兜底——未知字段一律进 `extra` + dropped 降级记录,绝不静默丢弃;`_bridge.dropped` 每轮可见。 +- **后续路线**:从 dropped 记录生成 `docs/lossy-fields.md`;引入 hypothesis 做 IR 层 property-based 往返测试。 + +--- + +## 结论隔离证明(为什么 34 组实验结论不依赖上述未闭环项) + +1. **主数字 7.3×(LOCKED vs DYNAMIC)**:实验脚本**直连端点**(`experiments/rich_experiments*.py`),不经过网关的 SSE 路径(#1)、不经状态层重放(#3)、不含工具调用 ID(#2)。命中率字段为上游真实返回。 +2. **阈值表(haiku=4096 / sonnet≈1024)**:同样直连端点;判定依据 `cache_creation/cache_read` 字段,与字符统计口径(#7)无关。 +3. **05B 救援(E27:透传 0% vs 转 Messages 99.85%)**:比较的是两种**后端路由**,两端点均不经过 #1/#2/#3 涉及的代码路径。 +4. **E29 埋点对账**:验证的是 metrics 忠实记录上游 usage——它证明的是「观测可信」,而 #7 的字符口径问题已在该节明示,不影响命中率北极星(分母分子均来自上游 usage)。 +5. 未闭环项中唯一触及实验解读的是 #9(opus-5),其处理方式是**不下结论**而非下错结论。 diff --git a/docs/REVIEW.md b/docs/REVIEW.md index 0a3601c..a4d223c 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -173,6 +173,8 @@ do_POST(路由 /v1/{source}/to/{target},未知协议 → 400) ## 六、已知缺口(修复后剩余) +> 2026-09-09 起,缺口的最新状态、四要素(影响半径/触发条件/绕过方式/后续路线)与结论隔离证明统一维护在 **`docs/LIMITATIONS.md`**;下表为评审时点快照。 + | # | 缺口 | 影响 | 计划 | | - | ---- | ---- | ---- | | 1 | **SSE 是假透传**:整个响应读完再处理,不是逐块流式 | 流式体验与首 token 延迟 | P2 | diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index c554f93..2dbd3e4 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -1,17 +1,17 @@ -# 能力矩阵 v0(可配置 · 不硬编码 · 数据日期 2026-09-01) +# 能力矩阵 v0.2(可配置 · 不硬编码 · 数据日期 2026-09-01,2026-09-09 实测校准) > 缓存阈值/折扣**逐模型查表、注明数据日期**,不外推(阈值非单调)。 -> 本表为 v0,拿到真实 API 后按 `count_tokens` 与真实响应逐格校准。 +> v0.2:按 34 组实测结果校准(`docs/experiment-results.md`);删除已被证伪的「20-block 回看」条目。 ## Anthropic 最小可缓存长度(按断点处累计前缀判定) -| 模型 | 最小可缓存 token | -|---|---| -| Claude Opus 5 / Fable 5 / Mythos 5 | 512 | -| Claude Mythos Preview / Opus 4.7 | 2048 | -| Claude Opus 4.6 / 4.5 | 4096 | -| Claude Opus 4.8 / Sonnet 5 / Sonnet 4.6 / 4.5 | 1024 | -| Claude Haiku 4.5 | 4096 | +| 模型 | 最小可缓存 token | 依据 | +|---|---|---| +| Claude Opus 5 / Fable 5 / Mythos 5 | 512 | 官方文档(opus-5 端点异常,实测未决,见 E34) | +| Claude Mythos Preview / Opus 4.7 | 2048 | 官方文档 | +| Claude Opus 4.6 / 4.5 | 4096 | 官方文档 | +| Claude Opus 4.8 / Sonnet 5 / Sonnet 4.6 / 4.5 | 1024 | 官方文档;sonnet-4.6 实测 ≤1436 即缓存(吻合) | +| Claude Haiku 4.5 | 4096 | 官方文档 + **E23 实测精确吻合**(3581 不缓存 / 4162 命中) | ## 缓存计价与折扣 @@ -19,27 +19,35 @@ |---|---|---| | 写入 | 5 分钟档 1.25x / 1 小时档 2x | 无写入溢价 | | 读取 | 0.1x(省 90%) | GPT-4o/o 系省 50% / GPT-4.1 系省 75% / GPT-5 系省 90% | -| TTL | 5 分钟 / 1 小时 | 默认 5–10 分钟,可选扩展 24 小时 | +| TTL | 5 分钟 / 1 小时(exp19 实测两档均真实生效) | 默认 5–10 分钟,可选扩展 24 小时 | | 触发 | 显式 cache_control(每请求 ≤4 断点) | 自动(prompt ≥ 1024 token) | -| 回看 | 20 content block | — | -## 硬约束(实验要复现/规避) +## 硬约束(实验已复现/判决) -- 4 断点上限:第 5 个 cache_control 返回 400。 -- 20-block 回看:单轮新增 >20 块,尾部断点找不到上次写入 → 全重算不报错。 -- 缓存条目在首个响应开始后才可用:对照实验须先串行预热再放量。 +- 4 断点上限:第 5 个 cache_control 返回 400(exp7 实测,非静默降级)。 +- 最小可缓存长度:低于阈值**静默不缓存**(exp3/exp12、E23 实测);阈值逐模型查表,不可外推。 +- 缓存条目在首个响应开始后才可用:对照实验须先串行预热再放量(E30/E32 实测并发冷启动重复写入是常态,需 single-flight)。 - max_tokens:0 预热:官方支持;禁带 stream/thinking/structured outputs/tool_choice(tool,any);Batches 不支持。 +- ~~20-block 回看窗口~~:**已证伪删除**(exp5/7/11:60 块 10872 token 仍 100% 命中)。真正的硬约束是最小可缓存长度,不是 block 数。 + +## 决策记录:手动断点 vs 顶层自动缓存(2026-09-09 补) + +选择**块级手动 4 断点**(3 固定 + 1 滚动),不用顶层自动 `cache_control`,理由: + +1. 自动缓存与显式断点共用 4 个槽位——开自动就只剩 3 个显式位,分层布局放不下; +2. 自动缓存在四个边界上会 400 或静默降级(TTL 冲突 / 槽位占满 / 末块不合格时向前回溯),命中率变得不可预测,与「命中率北极星可观测」目标冲突; +3. 手动断点的位置由 IR 的 L1/L2 分层直接决定(tools 后 / system 后 / 历史静态段后 / 滚动尾部),策略可进配置、可进测试。 -## 字段级支持矩阵(v0) +## 字段级支持矩阵(v0.2,状态列已按实码校准) | 能力 | Chat→Anthropic | Response→Anthropic | Anthropic→Chat/Response | 状态 | |---|---|---|---|---| | 文本消息 | ✅ | ✅ | ✅ | 离线已测 | | system/instructions | ✅ | ✅ | ✅ | 离线已测 | -| 工具调用/结果 | ✅ | ✅ | ✅ | 离线已测 | -| usage 归一 | ✅ | ✅ | ✅ | 离线已测 | -| previous_response_id | — | ✅(状态层重放) | — | 骨架 | -| 缓存断点布局 | ✅ | ✅ | — | 骨架 | -| SSE 流式透传 | ✅ | ✅ | ✅ | mock 已测 | -| max_tokens:0 预热 | — | — | ✅ | 字段处理已测 | +| 工具调用/结果 | ✅ | ✅ | ✅ | 离线已测(ID 为透传,无双向映射表 → LIMITATIONS #2) | +| usage 归一 | ✅ | ✅ | ✅ | 离线已测 + Groq 真实链路验证 | +| previous_response_id | — | ✅(状态层重放) | — | **已实现**:SQLite 会话表 + TTL + 多轮链 E2E 测试(0→2→4 重放累积) | +| 缓存断点布局 | ✅ | ✅ | — | **已落地**:3 固定 + 1 滚动,exp7/E24 实测 | +| SSE 流式 | ◐ | ◐ | ◐ | **整读透传**:网关整读响应再返回,逐块流式转换未实现 → LIMITATIONS #1(E21 直连实验证明流式无损缓存命中率) | +| max_tokens:0 预热 | — | — | ✅ | 字段处理已测 + 实测预热后 100% 命中 | | 多模态 / Realtime / Batches | ❌ | ❌ | ❌ | 方案 3.8 明确不做 | diff --git a/docs/experiment-results.md b/docs/experiment-results.md index bdded5d..cd81637 100644 --- a/docs/experiment-results.md +++ b/docs/experiment-results.md @@ -22,6 +22,29 @@ --- +## 第〇节 · 证伪条件预登记(测试左移) + +本组实验不是「跑通后再补」:《问题全景分析》(docs 问题清单)的 A 级条目在写代码之前即被登记为**待判决假设**,每条指定证伪条件与对应实验编号。**实验编号 = 假设编号,非时间顺序**——E20–E34 与 exp1–19 是同一张矩阵的执行,不是补丁;E29 属工程对账(埋点 vs 真实 usage),在假设矩阵之外,故编号不连续。 + +| 假设(回链问题清单) | 证伪条件 | 实验 | 判决 | +| --- | --- | --- | --- | +| 注入频率决定命中(组2#1/#2) | 锁死与动态无差异则假设错 | exp1 / exp9 | **证实**:98.1% vs 0%;稳态命中 = (N−1)/N | +| 前缀位置决定命中(组2#2) | 头部/尾部无差异则假设错 | exp2 | **证实**:98.1% vs 14.1%(第 3 轮崩塌) | +| 前缀稳定性是唯一前提(组2#2) | 顺序打乱不影响则假设错 | exp13 / E22 | **证实**:打乱掉 50 点;工具 schema 变更整段失效 | +| 最小可缓存长度逐模型(组2#6) | 阈值可跨模型外推则假设错 | exp3 / exp12 / E23 | **证实**:haiku-4.5 = 4096(与官方精确吻合);sonnet-4.6 ≈ 1024 | +| 20-block 回看窗口(组2#7,引自二手文档) | 单轮新增 >20 块则尾部断点失效 | exp5 / exp7 / exp11 | **证伪**:60 块 10872 token 仍 100% 命中——不成立,真约束是最小长度 | +| 4 断点硬上限(组2#5) | 第 5 个断点被静默接受则假设错 | exp7 | **证实**:直接 400 | +| TTL 双档真实生效(组3#9) | 1h 档与 5min 档无差异则假设错 | exp19 | **证实**:5min 档 400s 失效,1h 档仍命中 | +| 缓存跨模型复用(组3 推论) | haiku 建缓存 sonnet 可读则共享 | E20 | **证伪(反向)**:按 provider 隔离,read=0 | +| 流式损害命中率(组5#6 推论) | 流式命中低于非流式 | E21 | **证伪(反向)**:99.85% = 99.85%,且跨传输模式共享 | +| 05B 缓存收益可达性(课题核心风险) | Responses 端点透传可命中 | exp6 / E27 | **反转**:透传 0% → 转 Messages 99.85%。05B 可达,但落点必须正确 | +| 断点位置敏感性(组2#2 推论) | 断点位置显著影响命中 | E24 | **证伪(反向)**:system 99.85% vs 首条 user 99.95%,几乎无差异 | +| 预热有效性(方案 3.7) | 预热后正式请求不命中 | exp17 / E29 | **证实**:预热写入后 100% 命中;埋点与 usage 逐字段一致 | + +判决分布:证实 8 · 证伪/反向 4 · 反转 1。**凡与本文档冲突的旧文档表述,以本文档为准。** + +--- + ## 一、实验全景 | 维度 | 实验编号 | 关键结论 | diff --git a/docs/ir-schema.md b/docs/ir-schema.md index d502b72..21afe53 100644 --- a/docs/ir-schema.md +++ b/docs/ir-schema.md @@ -59,7 +59,7 @@ bp_after_tools / bp_after_system / bp_after_history_static / bp_rolling_tail 4. 滚动尾部,跟随每轮最后一块(`bp_rolling_tail`) > 不采用「历史每 N 块插断点」——长对话必然突破 4 个上限。 -> 20-block 回看窗口由滚动尾部断点覆盖(见方案图 2)。 +> 断点布局依据:断点按**前缀累积**生效(exp3/exp12 实测)+ 4 断点硬上限(exp7 实测第 5 个返回 400)。 ### Session 边界(可切换,config/session.json 驱动) @@ -87,6 +87,20 @@ bp_after_tools / bp_after_system / bp_after_history_static / bp_rolling_tail 统一拆成 `IRUsage{input, output, cache_creation, cache_read}`,`total_input = input + cache_creation + cache_read`。 +--- + +## 变更记录 + +「冻结」的准确含义:**L0/L1 语义层冻结**(块模型与规范请求的字段集合),实现层允许修订, +但任何修订必须配回归测试、契约外字段必须进 `extra` 降级记录(`record_unknown()` 统一收口)。 +契约的稳定性不靠「不改」保证,靠「改了一定被发现」保证。 + +| 版本 | 日期 | 变更 | 性质 | +|---|---|---|---| +| v0 | 2026-09-01 | 三层契约定稿(本文件) | 语义层冻结 | +| v0.1 | 2026-09-05 | 实现层修订:落地第 4 个固定断点 `bp_after_history_static`;三 adapter 顶层字段统一 `record_unknown()` 收口;thinking/tool_choice 经 `extra` 透传。**L0/L1 语义未动** | 实现层(各配回归测试,见 REVIEW.md 第五节) | +| v0.2 | 2026-09-09 | 删除对「20-block 回看窗口」的引用——该约束已被本组实验决定性证伪(exp5/7/11:60 块 10872 token 仍 100% 命中,见 experiment-results.md 第三节) | 文档修订(契约字段未动) | + ## 签字 - 05A(Chat):___ diff --git a/experiments/run_experiments.py b/experiments/run_experiments.py index f218aa8..894c717 100644 --- a/experiments/run_experiments.py +++ b/experiments/run_experiments.py @@ -9,7 +9,7 @@ 三组实验: 一 · 更新频率 A 不注入 / B 锁死注入 / C 每轮动态注入 二 · 位置 D1 前缀头部 / D2 尾部 append(可降级) - 三 · 粒度 G1 300 / G2 4x512 / G3 2048 / G4 30 块(20-block 断崖) + 三 · 粒度 G1 300 / G2 4x512 / G3 2048 / G4 30 块(验证块数非约束——20-block 已证伪) 真实运行(拿到 key 后):python run_experiments.py --live --backend """ From 8b9f1334f069c86681430139d773f7859a359751 Mon Sep 17 00:00:00 2001 From: H2RHAN <2373723338@qq.com> Date: Wed, 9 Sep 2026 18:07:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20v1.4=20=E5=B7=A5=E5=85=B7=20ID=20?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E6=98=A0=E5=B0=84=20+=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E9=87=8D=E6=94=BE=20+=20=E7=AC=AC=E4=B8=89=E8=BD=AE?= =?UTF-8?q?=E8=87=AA=E6=9F=A5=EF=BC=882=20=E4=B8=A5=E9=87=8D=20bug=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三轮自查(评审前逐文件复查 adapter 跨协议路径): - 严重:跨协议工具参数丢失——OpenAI 系 arguments 为 JSON 字符串,to_ir 不解析 导致 tool_input 恒 None,转 Anthropic 渲染成空 input {}(反向同样退化成 "{}") → base.parse_arguments() + from_ir 双路兜底(TestCrossProtocolToolArgs 4 项) - 严重:thinking signature / redacted_thinking 在 to_ir 被丢,多轮思考链下一轮必 400 → 进 extra 保留并原样渲染(TestThinkingSignature 2 项) 能力补齐(闭环问题清单组4#3、原 LIMITATIONS #2/#3): - src/state/idmap.py:会话级工具 ID 双向持久映射(canonical ↔ 各协议外部形式, SQLite 持久、铸造稳定、会话间隔离);网关 to_ir 后归一、from_ir 前翻译、 落库前翻回 canonical(TestToolIdMap 5 项,含 A→C→A 还原/并发分叉不串号) - base.assistant_from_upstream():状态层重放保留 thinking(signature/redacted) 与 tool_use(含解析后 tool_input),取代只取文本的 _assistant_message (TestAssistantFromUpstream 4 项) 文档:REVIEW 补第三批、LIMITATIONS #2/#3 标已修复、CHANGELOG v1.4、 README/能力矩阵状态同步。测试 25 → 40 项全过 + 11 项冒烟全过(Python 3.13) --- CHANGELOG.md | 9 ++ README.md | 4 +- docs/LIMITATIONS.md | 4 + docs/REVIEW.md | 19 ++++- docs/capability-matrix.md | 2 +- src/adapters/anthropic.py | 19 ++++- src/adapters/base.py | 92 ++++++++++++++++++++ src/adapters/chat.py | 25 +++++- src/adapters/response.py | 18 +++- src/gateway/server.py | 56 ++++++------ src/state/idmap.py | 101 ++++++++++++++++++++++ src/state/store.py | 3 + tests/test_offline.py | 175 +++++++++++++++++++++++++++++++++++++- 13 files changed, 487 insertions(+), 40 deletions(-) create mode 100644 src/state/idmap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 907dba7..6db2863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,3 +35,12 @@ - experiment-results 补「第〇节 · 证伪条件预登记」(实验编号 = 假设编号,回链问题清单条目) - 新增 `docs/LIMITATIONS.md`:12 项未闭环/边界项四要素(影响半径 / 触发条件 / 绕过方式 / 后续路线)+ 结论隔离证明 - README:状态表加「证据」列;SSE 措辞更正;新增「与上游 TencentDB-Agent-Memory 的关系」 + +## 2026-09-09 · v1.4 代码补强:第三轮自查 + 工具 ID 映射 + 结构化重放(本提交) + +- **第三轮自查(评审前逐文件复查)再发现 2 个真实 bug,各配回归测试**: + - 跨协议工具参数丢失:OpenAI 系 `arguments` 是 JSON 字符串,to_ir 只塞 `extra` 不解析,`tool_input` 恒为 None → 转到 Anthropic 全部渲染成空 `input {}`(`TestCrossProtocolToolArgs` 4 项) + - thinking `signature` 与 `redacted_thinking` 在 to_ir 被丢 → 多轮思考链下一轮必 400(`TestThinkingSignature` 2 项) +- **工具调用 ID 双向持久映射**(问题清单组4#3,原 LIMITATIONS #2):`src/state/idmap.py` 会话级 canonical ↔ 各协议外部形式,SQLite 持久、铸造稳定(前缀不抖动)、会话间隔离(并发分叉不串号);网关 to_ir 后归一、from_ir 前翻译、落库前翻回 canonical(`TestToolIdMap` 5 项) +- **状态层重放保留结构化块**(原 LIMITATIONS #3):`assistant_from_upstream()` 保留 thinking(signature / redacted)与 tool_use(含解析后 tool_input)入历史,多轮思考链/工具链不再断(`TestAssistantFromUpstream` 4 项) +- 测试 25 → **40 项全过** + 11 项端到端冒烟全过(Python 3.13) diff --git a/README.md b/README.md index 0188c00..6e7120b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + | 网关转发 | ✅ 离线(mock)/ Groq 真实链路已验证 | `src/gateway/`;E29 真实端点对账 | | SSE 流式 | ◐ 整读透传 | 逐块转换未实现 → `docs/LIMITATIONS.md` #1;E21 证明流式无损命中率 | | 记忆注入(幂等去重 + memory_cap) | ✅ v1.1 新增 | `inject_memories` + `TestGatewayPolicies`;管理口 `/v1/admin/session/meta` | +| 工具调用全链路(含 ID 双向映射) | ✅ **v1.4** | 跨协议参数/签名不丢(`TestCrossProtocolToolArgs`/`TestThinkingSignature`);`src/state/idmap.py` 会话级映射(`TestToolIdMap`) | +| 状态层重放(结构化块) | ✅ **v1.4** | thinking(signature/redacted)与 tool_use 入历史(`TestAssistantFromUpstream`) | | 预热拒绝条件校验 | ✅ v1.1 新增 | stream/thinking/structured outputs/tool_choice 四类冲突 400(`TestWarmup`) | | 限流 | ✅ v1.1 新增 | `PB_CONCURRENCY`(默认 2) | | 命中率埋点(5 项暴露) | ✅ 已对账 | **E29:埋点与真实端点 usage 逐字段一致** | @@ -32,7 +34,7 @@ OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + ```bash # 零外部依赖(仅标准库),Python 3.11+ -python -m unittest tests.test_offline -v # 25 项离线单测:IR 往返 / adapter / 状态层 / 预热 / 自查回归 / 多轮链 E2E +python -m unittest tests.test_offline -v # 40 项离线单测:IR 往返 / adapter / 状态层 / 预热 / 三轮自查回归 / 多轮链 E2E / ID 映射 python tools/smoke_e2e.py # 11 项端到端冒烟:真起 mock+网关子进程,5 组链路断言 python tools/mock_backend.py # 起 mock backend(127.0.0.1:9100,按端点返回三种协议形状) python -m src.gateway.server # 起网关(转发到 mock) diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 9fc2310..f6704f2 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -15,6 +15,8 @@ ### #2 工具调用 ID 无双向持久映射表(透传) +> ✅ **v1.4 已修复**(2026-09-09):新增 `src/state/idmap.py`(会话级 canonical ↔ 各协议外部形式,SQLite 持久、铸造稳定、并发分叉隔离),网关 `convert()` 在 to_ir 后归一、from_ir 前翻译,落库前翻回 canonical。回归测试 `TestToolIdMap`(5 项,含 Anthropic→Chat→Anthropic 还原)。以下原文留档。 + - **影响半径**:跨协议多轮工具链的 ID 一致性。当前 `tool_id` 经 IR 原样透传(`toolu_*` / `call_*` / `call_id` 直通),单轮与回放自身历史的场景工作正常。 - **触发条件**:一轮协议 A 产生的工具调用 ID,在后续轮次以协议 B 回传给生成方 A 时;以及并行调用重名、分叉重放场景。 - **绕过方式**:同协议往返(如 Anthropic→Anthropic)ID 原样有效;跨协议场景下多数 OpenAI 兼容端点接受任意字符串 ID。 @@ -22,6 +24,8 @@ ### #3 状态层重放只保留文本块 +> ✅ **v1.4 已修复**(2026-09-09):`assistant_from_upstream()`(`src/adapters/base.py`)保留 thinking(含 signature / redacted data)与 tool_use(含解析后 tool_input)入历史;同批修复 anthropic adapter 的 signature 往返与跨协议工具参数丢失(第三轮自查 2 个 bug)。回归测试 `TestAssistantFromUpstream` / `TestThinkingSignature` / `TestCrossProtocolToolArgs`。以下原文留档。 + - **影响半径**:多轮工具链 / 多轮 thinking 链。`_assistant_message` 只提取文本写入会话历史——thinking 块(含 signature)与 tool_use/tool_result 块不进重放。 - **触发条件**:开启 thinking 的多轮对话第二轮起(Anthropic 要求原样回传 thinking + signature,缺失会 400);工具结果需跨轮引用的场景。 - **绕过方式**:无状态全量重放(客户端自带完整历史)不经状态层,不受影响;单轮工具链不受影响。 diff --git a/docs/REVIEW.md b/docs/REVIEW.md index a4d223c..d62f977 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -1,7 +1,7 @@ # 代码评审材料(Code Review Pack) > 版本 v1.2(2026-09-02)| src 1493 行 + tools/experiments/tests 1065 行(纯标准库,零第三方依赖,Python 3.11+) -> 测试:**25 项单测全过 + 11 项端到端冒烟全过** + Groq 真实链路验证通过(2026-09-01) +> 测试:**40 项单测全过 + 11 项端到端冒烟全过**(2026-09-09,含第三轮自查回归)+ Groq 真实链路验证通过(2026-09-01) > 本版差异:**两轮代码自查发现 9 个真实 bug,全部修复并各配回归测试**(见第六节) ## 一、整体架构(IR 星型) @@ -171,6 +171,23 @@ do_POST(路由 /v1/{source}/to/{target},未知协议 → 400) - `injected_chars` 按尾部切片假设 → 改按 `extra` 标记统计 - `prewarm.validate_warmup` 对非 dict 的 thinking 字段会崩 → 类型防御 +### 第三批:2026-09-09 评审前复查(v1.4,2 个真实 bug + 2 项能力补齐) + +> 起因:评审前对三个 adapter 与状态层做了第三轮逐文件复查。 +> 结论:前两轮的自查范围(网关/埋点/状态链)之外,**adapter 的跨协议路径**还藏着两个会让「工具全链路」结论站不住的 bug。 + +| # | 严重度 | 问题 | 修复 | +| - | ------ | ---- | ---- | +| 10 | **严重** | **跨协议工具参数丢失**:OpenAI 系 `arguments` 是 JSON 字符串,chat/response 的 to_ir 只塞 `extra["arguments"]`、`tool_input` 恒为 None → 转到 Anthropic 全部渲染成空 `input {}`。反向 anthropic→chat 的 `arguments` 也退化成 `"{}"`——工具全链路跨协议双向断 | `base.parse_arguments()` 解析进 `tool_input`;from_ir 优先用 extra 原始串(同协议字节无损)、否则从 `tool_input` 序列化。`TestCrossProtocolToolArgs` 4 项 | +| 11 | **严重** | **thinking `signature` / `redacted_thinking` 在 to_ir 被丢**:Anthropic 要求下一轮原样回传 signature、redacted 逐字节透传,丢则多轮思考链第二轮必 400——恰好命中问题清单组4#4/#5 两条 A 级 | signature / redacted data 进 `extra` 保留,`_render_block` 原样渲染回。`TestThinkingSignature` 2 项 | + +同批能力补齐(原 LIMITATIONS #2/#3,均配回归测试): + +- **工具调用 ID 双向持久映射**(问题清单组4#3):`src/state/idmap.py`——会话级 canonical ↔ 各协议外部形式,SQLite 持久、铸造稳定、会话间隔离;网关 to_ir 后归一、from_ir 前翻译、落库前翻回 canonical。`TestToolIdMap` 5 项(含 Anthropic→Chat→Anthropic 还原、并发分叉不串号) +- **状态层重放保留结构化块**:`base.assistant_from_upstream()` 保留 thinking(signature/redacted)与 tool_use(含解析后 tool_input)入历史,取代只取文本的 `_assistant_message`。`TestAssistantFromUpstream` 4 项 + +测试 25 → **40 项全过** + 11 项端到端冒烟全过。 + ## 六、已知缺口(修复后剩余) > 2026-09-09 起,缺口的最新状态、四要素(影响半径/触发条件/绕过方式/后续路线)与结论隔离证明统一维护在 **`docs/LIMITATIONS.md`**;下表为评审时点快照。 diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 2dbd3e4..530a8e9 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -44,7 +44,7 @@ |---|---|---|---|---| | 文本消息 | ✅ | ✅ | ✅ | 离线已测 | | system/instructions | ✅ | ✅ | ✅ | 离线已测 | -| 工具调用/结果 | ✅ | ✅ | ✅ | 离线已测(ID 为透传,无双向映射表 → LIMITATIONS #2) | +| 工具调用/结果 | ✅ | ✅ | ✅ | 离线已测 + **v1.4 ID 双向映射**(`src/state/idmap.py`,跨协议往返还原) | | usage 归一 | ✅ | ✅ | ✅ | 离线已测 + Groq 真实链路验证 | | previous_response_id | — | ✅(状态层重放) | — | **已实现**:SQLite 会话表 + TTL + 多轮链 E2E 测试(0→2→4 重放累积) | | 缓存断点布局 | ✅ | ✅ | — | **已落地**:3 固定 + 1 滚动,exp7/E24 实测 | diff --git a/src/adapters/anthropic.py b/src/adapters/anthropic.py index 22afaf9..ff72457 100644 --- a/src/adapters/anthropic.py +++ b/src/adapters/anthropic.py @@ -32,7 +32,14 @@ def _render_block(b: ir.Block, dropped: Dropped) -> dict | None: return {"type": "tool_result", "tool_use_id": b.tool_id, "content": b.text or ""} if b.kind == ir.THINKING: - return {"type": "thinking", "thinking": b.text or ""} + # v1.4:signature 必须原样回传(缺失下一轮直接 400); + # redacted_thinking 逐字节透传 data。 + if b.extra.get("redacted"): + return {"type": "redacted_thinking", "data": b.extra.get("data", "")} + out = {"type": "thinking", "thinking": b.text or ""} + if b.extra.get("signature"): + out["signature"] = b.extra["signature"] + return out dropped.add(f"block.{b.kind}", "IR 块无 Anthropic 对应类型(方案 3.8 不做)", "explicit") return None @@ -69,7 +76,15 @@ def to_ir(self, payload: dict, dropped: Dropped) -> ir.IRRequest: msg.blocks.append(ir.Block(kind=ir.TOOL_RESULT, tool_id=b.get("tool_use_id"), text=_result_text(b), cache_breakpoint=bp)) elif t == "thinking": - msg.blocks.append(ir.Block(kind=ir.THINKING, text=b.get("thinking", ""))) + # v1.4:signature 进 extra 保留——下一轮必须原样回传,丢则 400 + msg.blocks.append(ir.Block( + kind=ir.THINKING, text=b.get("thinking", ""), + extra={"signature": b.get("signature", "")})) + elif t == "redacted_thinking": + # 逐字节透传 data,不能判成无效块丢掉(问题清单组4#5) + msg.blocks.append(ir.Block( + kind=ir.THINKING, text="", + extra={"redacted": True, "data": b.get("data", "")})) else: dropped.add(f"content.{t}", "Anthropic 内容块,方案 3.8 不做", "explicit") req.messages.append(msg) diff --git a/src/adapters/base.py b/src/adapters/base.py index f6b67e7..7183e2b 100644 --- a/src/adapters/base.py +++ b/src/adapters/base.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import json from typing import Any @@ -43,3 +44,94 @@ def to_ir(self, payload: dict, dropped: Dropped): def from_ir(self, ir, dropped: Dropped) -> dict: raise NotImplementedError + + +def parse_arguments(raw: Any) -> dict: + """OpenAI 系的工具参数是 JSON **字符串**,Anthropic 是 dict。 + + 跨协议转换必须解析成 dict 进 IR.tool_input,否则到 Anthropic 侧会渲染成 + 空 input {}(第三轮自查发现的参数丢失 bug)。解析失败返回 {},原始字符串 + 由调用方留在 extra["arguments"] 兜底(同协议往返无损)。 + """ + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + v = json.loads(raw) + return v if isinstance(v, dict) else {} + except (ValueError, TypeError): + return {} + return {} + + +def assistant_from_upstream(target: str, resp: dict): + """上游响应 → IR assistant 消息(状态层重放的原料)。 + + v1.4 起保留**结构化块**:thinking(含 signature / redacted data)与 + tool_use(含解析后的 tool_input)必须进历史——Anthropic 要求下一轮原样 + 回传 thinking + signature,缺失直接 400;tool_use 丢失则多轮工具链断链。 + 返回 None 表示本轮没有任何可重放内容(如空响应)。 + 延迟 import ir,避免与 adapter 模块产生循环依赖。 + """ + from ..ir import model as ir + + blocks: list = [] + if target == "anthropic": + for b in resp.get("content", []) or []: + if not isinstance(b, dict): + continue + t = b.get("type") + if t == "text": + blocks.append(ir.Block(kind=ir.TEXT, text=b.get("text", ""))) + elif t == "thinking": + blocks.append(ir.Block(kind=ir.THINKING, text=b.get("thinking", ""), + extra={"signature": b.get("signature", "")})) + elif t == "redacted_thinking": + blocks.append(ir.Block(kind=ir.THINKING, text="", + extra={"redacted": True, + "data": b.get("data", "")})) + elif t == "tool_use": + blocks.append(ir.Block(kind=ir.TOOL_USE, tool_name=b.get("name"), + tool_id=b.get("id"), + tool_input=b.get("input", {}))) + elif target == "openai_response": + for item in resp.get("output", []) or []: + if not isinstance(item, dict): + continue + t = item.get("type") + if t == "message": + for c in item.get("content", []) or []: + if isinstance(c, dict) and c.get("type") in ("output_text", "text"): + blocks.append(ir.Block(kind=ir.TEXT, text=c.get("text", ""))) + elif t == "function_call": + raw = item.get("arguments", "") + blocks.append(ir.Block(kind=ir.TOOL_USE, tool_name=item.get("name"), + tool_id=item.get("call_id"), + tool_input=parse_arguments(raw), + extra={"arguments": raw})) + elif t == "reasoning": + summary = item.get("summary") + text = summary if isinstance(summary, str) else "" + blocks.append(ir.Block(kind=ir.THINKING, text=text, + extra={"encrypted_content": + item.get("encrypted_content", "")})) + else: # openai_chat + try: + m = resp["choices"][0]["message"] + except (KeyError, IndexError, TypeError, AttributeError): + m = {} + if m.get("content"): + blocks.append(ir.Block(kind=ir.TEXT, text=m["content"])) + for tc in m.get("tool_calls", []) or []: + fn = tc.get("function", {}) if isinstance(tc, dict) else {} + raw = fn.get("arguments", "") + blocks.append(ir.Block(kind=ir.TOOL_USE, tool_name=fn.get("name"), + tool_id=tc.get("id"), + tool_input=parse_arguments(raw), + extra={"arguments": raw})) + # 纯空文本(如异常响应)不进历史 + blocks = [b for b in blocks + if b.kind != ir.TEXT or (b.text or "").strip()] + if not blocks: + return None + return ir.Message(role="assistant", blocks=blocks) diff --git a/src/adapters/chat.py b/src/adapters/chat.py index 6ea900d..52f1bbe 100644 --- a/src/adapters/chat.py +++ b/src/adapters/chat.py @@ -7,8 +7,10 @@ """ from __future__ import annotations +import json + from ..ir import model as ir -from .base import Adapter, Dropped, record_unknown +from .base import Adapter, Dropped, parse_arguments, record_unknown # Chat Completions 顶层字段中,本 adapter 真正消费的部分。 # 其余顶层字段一律进降级记录:命中率实验的第④项暴露依赖这份清单准确, @@ -46,10 +48,15 @@ def to_ir(self, payload: dict, dropped: Dropped) -> ir.IRRequest: dropped.add(f"content.{t}", "Chat 多模态内容块,方案 3.8 不做", "explicit") for tc in m.get("tool_calls", []) or []: fn = tc.get("function", {}) + raw_args = fn.get("arguments", "") msg.blocks.append(ir.Block( kind=ir.TOOL_USE, tool_name=fn.get("name"), tool_id=tc.get("id"), - extra={"arguments": fn.get("arguments", "")})) + # v1.4:参数解析成 dict 进 tool_input——不解析的话跨协议到 + # Anthropic 会渲染成空 input {}(第三轮自查发现的参数丢失 bug)。 + # 原始字符串留 extra,chat→chat 往返无损。 + tool_input=parse_arguments(raw_args), + extra={"arguments": raw_args})) if role == "tool": msg.role = "user" msg.blocks.append(ir.Block( @@ -85,7 +92,7 @@ def from_ir(self, req: ir.IRRequest, dropped: Dropped) -> dict: elif b.kind == ir.TOOL_USE: tool_calls.append({"id": b.tool_id, "type": "function", "function": {"name": b.tool_name, - "arguments": b.extra.get("arguments", "{}")}}) + "arguments": _arguments_str(b)}}) elif b.kind == ir.TOOL_RESULT: tool_results.append({"role": "tool", "tool_call_id": b.tool_id, "content": b.text or ""}) @@ -107,6 +114,18 @@ def from_ir(self, req: ir.IRRequest, dropped: Dropped) -> dict: return out +def _arguments_str(b) -> str: + """Chat/Responses 侧的 arguments 是 JSON 字符串。 + + 优先用 extra 里的原始字符串(同协议往返字节无损);没有则从 tool_input + 序列化——anthropic→chat 方向靠这条路把 dict 参数带过去(v1.4 修复)。 + """ + raw = b.extra.get("arguments") + if isinstance(raw, str) and raw.strip(): + return raw + return json.dumps(b.tool_input or {}, ensure_ascii=False) + + def usage_from_chat(usage: dict) -> ir.IRUsage: details = usage.get("prompt_tokens_details", {}) or {} return ir.IRUsage( diff --git a/src/adapters/response.py b/src/adapters/response.py index 4df89bd..7ce6d8e 100644 --- a/src/adapters/response.py +++ b/src/adapters/response.py @@ -9,7 +9,7 @@ from __future__ import annotations from ..ir import model as ir -from .base import Adapter, Dropped, record_unknown +from .base import Adapter, Dropped, parse_arguments, record_unknown # to_ir 真正消费的顶层字段;其余一律进降级记录(见 base.record_unknown) _KNOWN_TOP = {"model", "input", "instructions", "tools", "max_output_tokens", @@ -41,10 +41,13 @@ def to_ir(self, payload: dict, dropped: Dropped) -> ir.IRRequest: dropped.add(f"input.{part.get('type')}", "Responses 内容块,方案 3.8 不做", "explicit") req.messages.append(msg) elif t == "function_call": + raw_args = item.get("arguments", "") req.messages.append(ir.Message(role="assistant", blocks=[ir.Block( kind=ir.TOOL_USE, tool_name=item.get("name"), tool_id=item.get("call_id"), - extra={"arguments": item.get("arguments", "")})])) + # v1.4:解析成 dict 进 tool_input,跨协议到 Anthropic 不再丢参数 + tool_input=parse_arguments(raw_args), + extra={"arguments": raw_args})])) elif t == "function_call_output": req.messages.append(ir.Message(role="user", blocks=[ir.Block( kind=ir.TOOL_RESULT, tool_id=item.get("call_id"), @@ -75,7 +78,7 @@ def from_ir(self, req: ir.IRRequest, dropped: Dropped) -> dict: elif b.kind == ir.TOOL_USE: items.append({"type": "function_call", "name": b.tool_name, "call_id": b.tool_id, - "arguments": b.extra.get("arguments", "{}")}) + "arguments": _arguments_str(b)}) elif b.kind == ir.TOOL_RESULT: items.append({"type": "function_call_output", "call_id": b.tool_id, "output": b.text or ""}) @@ -96,6 +99,15 @@ def from_ir(self, req: ir.IRRequest, dropped: Dropped) -> dict: return out +def _arguments_str(b) -> str: + """优先用 extra 原始字符串(同协议无损);否则从 tool_input 序列化(v1.4)。""" + raw = b.extra.get("arguments") + if isinstance(raw, str) and raw.strip(): + return raw + import json as _json + return _json.dumps(b.tool_input or {}, ensure_ascii=False) + + def usage_from_response(usage: dict) -> ir.IRUsage: details = usage.get("input_tokens_details", {}) or {} return ir.IRUsage( diff --git a/src/gateway/server.py b/src/gateway/server.py index 0532917..56e8063 100644 --- a/src/gateway/server.py +++ b/src/gateway/server.py @@ -18,7 +18,7 @@ import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from ..adapters.base import Dropped +from ..adapters.base import Dropped, assistant_from_upstream from ..adapters.chat import ChatAdapter from ..adapters.response import ResponseAdapter from ..adapters.anthropic import AnthropicAdapter @@ -166,6 +166,17 @@ def convert(source: str, target: str, payload: dict, headers: dict): if session is None and target in ("anthropic", "openai_response"): session = STORE.get_or_create(CFG.session_key(headers)) + # v1.4 工具 ID 双向映射(问题清单组4#3,需会话作用域;无会话则直通): + # 先把本轮新消息里的外部 ID 归一到 canonical(重放历史落库时已是 canonical), + # 渲染前再统一翻成目标协议的外部形式——Anthropic→Chat→Anthropic 一圈回来, + # 最初的 toolu_* 能原样还原,上游 tool_use_id 对应关系校验不会 400。 + if session is not None: + idm = STORE.idmap + for m in req.messages: + for b in m.blocks: + if b.kind in (ir.TOOL_USE, ir.TOOL_RESULT) and b.tool_id: + b.tool_id = idm.incoming(session.key, b.tool_id, source) + # Anthropic 目标:按 L2 断点布局打 cache_control ctx = None if target == "anthropic": @@ -174,6 +185,13 @@ def convert(source: str, target: str, payload: dict, headers: dict): # 记忆注入:重放之后、渲染之前(注入内容进 system 尾部,吃 system 后断点) injected = inject_memories(req, session) if session else 0 + if session is not None: + idm = STORE.idmap + for m in req.messages: + for b in m.blocks: + if b.kind in (ir.TOOL_USE, ir.TOOL_RESULT) and b.tool_id: + b.tool_id = idm.outgoing(session.key, b.tool_id, target) + out = dst.from_ir(req, dropped, ctx) if target == "anthropic" else dst.from_ir(req, dropped) return out, req, dropped, session, injected, replayed @@ -295,10 +313,18 @@ def do_POST(self): # 整个状态层等于空转(历史永远是空 -> 重放永远是空 -> 多轮链路第一轮就断)。 # 只追加本轮新消息:重放切片已在历史里,再加一次会自我复制。 new_turn = list(req.messages[len(replayed_hist):]) - reply = _assistant_message(target, backend_resp) + reply = assistant_from_upstream(target, backend_resp) if reply is not None: new_turn.append(reply) if new_turn: + # 落库前把 ID 翻回 canonical:new_turn 里的 ID 刚被 outgoing + # 翻成目标协议形式,reply 里的是上游新生成的——历史必须存 + # canonical,否则跨协议续轮时映射链会退化。 + idm = STORE.idmap + for m in new_turn: + for b in m.blocks: + if b.kind in (ir.TOOL_USE, ir.TOOL_RESULT) and b.tool_id: + b.tool_id = idm.incoming(session.key, b.tool_id, target) STORE.append(session, new_turn) rid = STORE.record_response(session) @@ -317,32 +343,6 @@ def do_POST(self): self._json(200, body) -def _assistant_message(target: str, resp: dict): - """上游响应 -> IR assistant 消息(只取文本,够状态层重放用)。 - - 只取文本是有意为之:工具调用 / 思考块的重放在当前实验范围内不涉及 - (方案 3.8 不做复杂块重放)。但文本必须进历史,否则下一轮重放是无源之水。 - """ - if target == "anthropic": - texts = [b.get("text", "") for b in resp.get("content", []) or [] - if isinstance(b, dict) and b.get("type") == "text"] - elif target == "openai_response": - texts = [] - for item in resp.get("output", []) or []: - if not isinstance(item, dict) or item.get("type") != "message": - continue - for c in item.get("content", []) or []: - if isinstance(c, dict) and c.get("type") in ("output_text", "text"): - texts.append(c.get("text", "")) - else: # openai_chat - try: - texts = [resp["choices"][0]["message"].get("content") or ""] - except (KeyError, IndexError, TypeError, AttributeError): - texts = [] - text = "".join(texts).strip() - return ir.Message.text("assistant", text) if text else None - - def _usage_for(target: str, resp: dict) -> ir.IRUsage: u = resp.get("usage", {}) or {} if target == "anthropic": diff --git a/src/state/idmap.py b/src/state/idmap.py new file mode 100644 index 0000000..dff95e3 --- /dev/null +++ b/src/state/idmap.py @@ -0,0 +1,101 @@ +"""工具调用 ID 双向持久映射(v1.4,问题清单组4#3)。 + +三家工具 ID 命名不同:Anthropic `toolu_*`、Chat `call_*`、Responses 同时有 +`call_id` 与 `item.id`。ID 由服务端生成、每次都变,跨协议往返必须维护一张 +映射表——否则 Anthropic→Chat→Anthropic 一圈回来,最初那个 `toolu_*` 就丢了, +上游校验 tool_use_id 对应关系时直接 400。 + +设计: +- canonical id = 该 ID **首次出现时的原始形式**(含其来源协议)。 +- `incoming(session, ext_id, proto)`:把某协议的外部 ID 归一到 canonical。 +- `outgoing(session, canon, proto)`:取 canonical 在某协议下的外部形式; + 目标协议就是来源协议时返回 canonical 本身(字节无损),否则铸造新 ID + (按目标协议习惯前缀)并持久化——同一会话内稳定,前缀序列化不被破坏。 +- 映射随会话存 SQLite,TTL/归档与会话表同生命周期(不单独维护过期)。 +- 冲突策略:first-wins。同一 (session, proto, ext) 永不改绑; + 同一 canon 在不同 proto 下各有独立外部形式。 +""" +from __future__ import annotations + +import time +import uuid + +# 各协议的 ID 习惯前缀(铸造新 ID 时用;仅约定,不做严格校验) +PROTO_PREFIX = { + "anthropic": "toolu_", + "openai_chat": "call_", + "openai_response": "call_", +} + + +class ToolIdMap: + """会话级工具 ID 映射表。与 SessionStore 共用一个 SQLite 连接。""" + + def __init__(self, db): + self._db = db + self._db.executescript( + """ + CREATE TABLE IF NOT EXISTS tool_id_map ( + session_key TEXT NOT NULL, + proto TEXT NOT NULL, + ext_id TEXT NOT NULL, + canon TEXT NOT NULL, + origin_proto TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (session_key, proto, ext_id) + ); + CREATE INDEX IF NOT EXISTS idx_tool_id_canon + ON tool_id_map (session_key, canon); + """ + ) + self._db.commit() + + def incoming(self, session_key: str, ext_id: str | None, proto: str) -> str | None: + """外部 ID → canonical。未见过的 ID 以其本身为 canonical 登记。""" + if not ext_id: + return ext_id + row = self._db.execute( + "SELECT canon FROM tool_id_map WHERE session_key=? AND proto=? AND ext_id=?", + (session_key, proto, ext_id)).fetchone() + if row: + return row[0] + self._db.execute( + "INSERT OR IGNORE INTO tool_id_map " + "(session_key, proto, ext_id, canon, origin_proto, created_at) " + "VALUES (?,?,?,?,?,?)", + (session_key, proto, ext_id, ext_id, proto, time.time())) + self._db.commit() + return ext_id + + def outgoing(self, session_key: str, canon: str | None, proto: str) -> str | None: + """canonical → 目标协议外部形式(稳定、可持久、按目标协议前缀铸造)。""" + if not canon: + return canon + # 已铸造过 → 直接用(跨轮稳定,前缀不抖动) + row = self._db.execute( + "SELECT ext_id FROM tool_id_map WHERE session_key=? AND proto=? AND canon=?", + (session_key, proto, canon)).fetchone() + if row: + return row[0] + # 来源协议就是目标协议 → canonical 原样返回(同协议字节无损) + origin = self._db.execute( + "SELECT origin_proto FROM tool_id_map WHERE session_key=? AND canon=? " + "LIMIT 1", (session_key, canon)).fetchone() + if origin and origin[0] == proto: + return canon + # 铸造新 ID 并持久化 + ext = PROTO_PREFIX.get(proto, "call_") + uuid.uuid4().hex[:24] + self._db.execute( + "INSERT OR IGNORE INTO tool_id_map " + "(session_key, proto, ext_id, canon, origin_proto, created_at) " + "VALUES (?,?,?,?,?,?)", + (session_key, proto, ext, canon, + origin[0] if origin else proto, time.time())) + self._db.commit() + return ext + + def size(self, session_key: str) -> int: + row = self._db.execute( + "SELECT COUNT(*) FROM tool_id_map WHERE session_key=?", + (session_key,)).fetchone() + return row[0] diff --git a/src/state/store.py b/src/state/store.py index bcb831a..4c04091 100644 --- a/src/state/store.py +++ b/src/state/store.py @@ -19,6 +19,7 @@ from dataclasses import dataclass, field, asdict from ..ir.model import Block, Message +from .idmap import ToolIdMap from .session_config import SessionConfig, build_prefix, TTL _DEFAULT_DB = os.path.join( @@ -83,6 +84,8 @@ def __init__(self, cfg: SessionConfig, db_path: str | None = None): """ ) self._db.commit() + # v1.4:工具调用 ID 双向映射表(与会话同库同生命周期) + self.idmap = ToolIdMap(self._db) # -- 序列化辅助 ---------------------------------------------------------- @staticmethod diff --git a/tests/test_offline.py b/tests/test_offline.py index 76fe484..69c77bb 100644 --- a/tests/test_offline.py +++ b/tests/test_offline.py @@ -12,7 +12,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from src.adapters.base import Dropped +from src.adapters.base import Dropped, assistant_from_upstream from src.adapters.chat import ChatAdapter, usage_from_chat from src.adapters.anthropic import AnthropicAdapter, usage_from_anthropic from src.adapters.response import ResponseAdapter @@ -454,6 +454,179 @@ def test_warmup_not_registered_as_turn(self): "预热轮不应登记进 previous_response_id 响应链") +class TestCrossProtocolToolArgs(unittest.TestCase): + """第三轮自查回归 #1:跨协议工具调用参数丢失(v1.4 修复)。 + + OpenAI 系 arguments 是 JSON 字符串、Anthropic input 是 dict。v1.3 之前 + chat/response 的 to_ir 只把字符串塞进 extra、tool_input 恒为 None, + 转到 Anthropic 全部渲染成空 input {}——工具全链路跨协议断。 + """ + + def test_chat_to_anthropic_keeps_tool_input(self): + payload = {"model": "gpt-x", "messages": [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_1", "type": "function", "function": { + "name": "search", "arguments": '{"city": "天津", "n": 3}'}}]}, + ]} + req = ChatAdapter().to_ir(payload, Dropped()) + b = req.messages[0].blocks[0] + self.assertEqual(b.tool_input, {"city": "天津", "n": 3}, + "arguments 字符串必须解析成 dict 进 tool_input") + out = AnthropicAdapter().from_ir(req, Dropped()) + tu = out["messages"][0]["content"][0] + self.assertEqual(tu["input"], {"city": "天津", "n": 3}, + "Chat→Anthropic 的 tool_use.input 不得为空 {}") + + def test_anthropic_to_chat_keeps_arguments(self): + payload = {"model": "claude-x", "max_tokens": 100, "messages": [ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "search", + "input": {"q": "犀牛鸟"}}]}, + ]} + req = AnthropicAdapter().to_ir(payload, Dropped()) + out = ChatAdapter().from_ir(req, Dropped()) + args = out["messages"][0]["tool_calls"][0]["function"]["arguments"] + self.assertEqual(json.loads(args), {"q": "犀牛鸟"}, + "Anthropic→Chat 的 arguments 不得退化成 '{}'") + + def test_response_to_anthropic_keeps_tool_input(self): + payload = {"model": "gpt-x", "input": [ + {"type": "function_call", "name": "calc", "call_id": "call_9", + "arguments": '{"x": 1}'}]} + req = ResponseAdapter().to_ir(payload, Dropped()) + out = AnthropicAdapter().from_ir(req, Dropped()) + self.assertEqual(out["messages"][0]["content"][0]["input"], {"x": 1}) + + def test_same_protocol_arguments_byte_lossless(self): + """同协议往返仍走 extra 原始字符串,字节无损(不受 v1.4 解析影响)。""" + raw = '{"a": 1, "b": [1,2]}' # 带非常规空格 + payload = {"model": "gpt-x", "messages": [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_1", "type": "function", "function": { + "name": "f", "arguments": raw}}]}, + ]} + req = ChatAdapter().to_ir(payload, Dropped()) + out = ChatAdapter().from_ir(req, Dropped()) + self.assertEqual(out["messages"][0]["tool_calls"][0]["function"]["arguments"], raw) + + +class TestThinkingSignature(unittest.TestCase): + """第三轮自查回归 #2:thinking signature / redacted_thinking 往返(v1.4 修复)。 + + Anthropic 开启 thinking 后下一轮必须原样回传 signature,redacted_thinking + 必须逐字节透传,否则直接 400。v1.3 之前 to_ir 把两者都丢了。 + """ + + def test_thinking_signature_roundtrip(self): + payload = {"model": "claude-x", "max_tokens": 100, "messages": [ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "推理过程", + "signature": "sig_abc123"}, + {"type": "text", "text": "答案"}]}, + ]} + req = AnthropicAdapter().to_ir(payload, Dropped()) + out = AnthropicAdapter().from_ir(req, Dropped()) + blk = out["messages"][0]["content"][0] + self.assertEqual(blk.get("signature"), "sig_abc123", + "thinking 的 signature 往返必须保留") + + def test_redacted_thinking_byte_exact(self): + payload = {"model": "claude-x", "max_tokens": 100, "messages": [ + {"role": "assistant", "content": [ + {"type": "redacted_thinking", "data": "enc_逐字节数据=="}]}, + ]} + req = AnthropicAdapter().to_ir(payload, Dropped()) + self.assertEqual(len(req.messages[0].blocks), 1, + "redacted_thinking 不得被判成无效块丢弃") + out = AnthropicAdapter().from_ir(req, Dropped()) + blk = out["messages"][0]["content"][0] + self.assertEqual(blk, {"type": "redacted_thinking", "data": "enc_逐字节数据=="}) + + +class TestAssistantFromUpstream(unittest.TestCase): + """v1.4:状态层重放原料保留结构化块(thinking / tool_use),不再只取文本。""" + + def test_anthropic_structured_blocks(self): + resp = {"content": [ + {"type": "thinking", "thinking": "想", "signature": "s1"}, + {"type": "text", "text": "答"}, + {"type": "tool_use", "id": "toolu_7", "name": "search", + "input": {"q": "x"}}]} + msg = assistant_from_upstream("anthropic", resp) + kinds = [b.kind for b in msg.blocks] + self.assertEqual(kinds, [ir.THINKING, ir.TEXT, ir.TOOL_USE]) + self.assertEqual(msg.blocks[0].extra["signature"], "s1") + self.assertEqual(msg.blocks[2].tool_input, {"q": "x"}) + + def test_chat_tool_calls(self): + resp = {"choices": [{"message": { + "content": None, + "tool_calls": [{"id": "call_3", "type": "function", "function": { + "name": "f", "arguments": '{"k": 2}'}}]}}]} + msg = assistant_from_upstream("openai_chat", resp) + self.assertEqual(len(msg.blocks), 1) + self.assertEqual(msg.blocks[0].kind, ir.TOOL_USE) + self.assertEqual(msg.blocks[0].tool_input, {"k": 2}) + + def test_response_function_call_and_reasoning(self): + resp = {"output": [ + {"type": "reasoning", "summary": "想了一下", + "encrypted_content": "enc1"}, + {"type": "function_call", "name": "f", "call_id": "call_5", + "arguments": '{"y": true}'}, + {"type": "message", "content": [ + {"type": "output_text", "text": "好"}]}]} + msg = assistant_from_upstream("openai_response", resp) + kinds = [b.kind for b in msg.blocks] + self.assertEqual(kinds, [ir.THINKING, ir.TOOL_USE, ir.TEXT]) + self.assertEqual(msg.blocks[1].tool_input, {"y": True}) + + def test_empty_response_returns_none(self): + self.assertIsNone(assistant_from_upstream("anthropic", {"content": []})) + self.assertIsNone(assistant_from_upstream("openai_chat", {})) + + +class TestToolIdMap(unittest.TestCase): + """v1.4:工具 ID 双向持久映射(问题清单组4#3)。""" + + def setUp(self): + self.store = SessionStore(SessionConfig(), ":memory:") + self.idm = self.store.idmap + + def test_same_proto_outgoing_is_byte_lossless(self): + canon = self.idm.incoming("s1", "toolu_abc", "anthropic") + self.assertEqual(canon, "toolu_abc") + self.assertEqual(self.idm.outgoing("s1", canon, "anthropic"), "toolu_abc", + "同协议 outgoing 必须原样返回 canonical") + + def test_cross_proto_mint_stable_and_prefix(self): + self.idm.incoming("s1", "toolu_abc", "anthropic") + e1 = self.idm.outgoing("s1", "toolu_abc", "openai_chat") + self.assertTrue(e1.startswith("call_"), "Chat 侧铸造 ID 应用 call_ 前缀") + e2 = self.idm.outgoing("s1", "toolu_abc", "openai_chat") + self.assertEqual(e1, e2, "同一会话内铸造结果必须稳定(前缀序列化不抖动)") + + def test_roundtrip_restores_original_id(self): + """Anthropic→Chat→Anthropic 一圈,最初的 toolu_* 必须还原(否则上游 400)。""" + canon = self.idm.incoming("s1", "toolu_orig", "anthropic") + chat_ext = self.idm.outgoing("s1", canon, "openai_chat") + back = self.idm.incoming("s1", chat_ext, "openai_chat") + self.assertEqual(back, canon, "外部形式必须能反解回 canonical") + self.assertEqual(self.idm.outgoing("s1", back, "anthropic"), "toolu_orig") + + def test_sessions_isolated(self): + self.idm.incoming("s1", "toolu_abc", "anthropic") + self.assertEqual(self.idm.incoming("s2", "toolu_abc", "anthropic"), "toolu_abc") + e1 = self.idm.outgoing("s1", "toolu_abc", "openai_chat") + e2 = self.idm.outgoing("s2", "toolu_abc", "openai_chat") + self.assertNotEqual(e1, e2, "不同会话的铸造互不影响(并发分叉不串号)") + + def test_shared_db_and_size(self): + self.idm.incoming("s1", "call_1", "openai_chat") + self.idm.outgoing("s1", "call_1", "anthropic") + self.assertEqual(self.idm.size("s1"), 2) + + def _count_breakpoints(payload: dict) -> int: n = 0 for t in payload.get("tools", []): From 0e872644f6b15de03cbdf49149e951b542420e3b Mon Sep 17 00:00:00 2001 From: H2RHAN <2373723338@qq.com> Date: Wed, 9 Sep 2026 18:22:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test/docs:=20property-based=20=E5=BE=80?= =?UTF-8?q?=E8=BF=94=E6=B5=8B=E8=AF=95=20+=20=E6=9C=89=E6=8D=9F=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20+=20CI=20+=20=E5=8F=A3=E5=BE=84=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestPropertyRoundTrip(组5#10):stdlib 固定种子,440 随机用例断言四性质 (断点[3,4] / 未知字段必降级 / 工具参数守恒 / signature 往返守恒),测试 40→44 - docs/lossy-fields.md:从 adapter 实码推导的有损清单(块级/参数级/usage 级)+ 两处盲区标注 - .github/workflows/test.yml:py3.11/3.12/3.13 矩阵(单测+冒烟+dry-run,零依赖)+ README badge - README 修悬空引用(方案 v3.1 节号已不存在 → 实验文档方法学节) - 口径:成本一律改相对倍数(7.35×/10.2×/7.9×),全库 md 已无绝对金额 - ir-schema 移除签字栏:冻结约束改为「双方确认 + 变更记录」 - 验证:44 单测 + 11 冒烟全过(Python 3.13) --- .github/workflows/test.yml | 26 ++++++ CHANGELOG.md | 8 ++ README.md | 9 ++- docs/experiment-results.md | 24 +++--- docs/ir-schema.md | 8 +- docs/lossy-fields.md | 49 ++++++++++++ tests/test_offline.py | 158 +++++++++++++++++++++++++++++++++++++ 7 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 docs/lossy-fields.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5fbad91 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: test + +on: + push: + branches: [master] + pull_request: + +jobs: + unittest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + # 零第三方依赖(纯标准库),无需 pip install + - name: 离线单测(44 项) + run: python -m unittest tests.test_offline + - name: 端到端冒烟(11 项,子进程级) + run: python tools/smoke_e2e.py + - name: 实验框架 dry-run + run: python experiments/run_experiments.py --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db2863..73e28d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,3 +44,11 @@ - **工具调用 ID 双向持久映射**(问题清单组4#3,原 LIMITATIONS #2):`src/state/idmap.py` 会话级 canonical ↔ 各协议外部形式,SQLite 持久、铸造稳定(前缀不抖动)、会话间隔离(并发分叉不串号);网关 to_ir 后归一、from_ir 前翻译、落库前翻回 canonical(`TestToolIdMap` 5 项) - **状态层重放保留结构化块**(原 LIMITATIONS #3):`assistant_from_upstream()` 保留 thinking(signature / redacted)与 tool_use(含解析后 tool_input)入历史,多轮思考链/工具链不再断(`TestAssistantFromUpstream` 4 项) - 测试 25 → **40 项全过** + 11 项端到端冒烟全过(Python 3.13) + +## 2026-09-09 · property-based 测试 + 有损清单 + CI(本提交) + +- **`TestPropertyRoundTrip`**(问题清单组5#10):stdlib 实现、固定种子可复现,440 个随机用例断言四条性质——断点恒在 [3,4] / 未知顶层字段必降级 / 工具参数跨协议守恒 / thinking signature 往返守恒(参数丢失 bug 正是这类性质被抓出的)。测试 40 → **44 项全过** +- **新增 `docs/lossy-fields.md`**(问题清单主线交付物):从三个 adapter 实码推导的有损字段清单(块级 / 参数级 / usage 归一级),含两处诚实标注的清单盲区(工具定义内部字段无 dropped 记录、多模态/流式未覆盖) +- **GitHub Actions CI**:`.github/workflows/test.yml`,Python 3.11/3.12/3.13 矩阵跑单测 + 冒烟 + dry-run(零依赖,无需装包);README 加 badge 行 +- README 修正一处悬空引用(原指向方案 v3.1 已不存在的节号,改指实验文档方法学节) +- 文档口径:实验文档成本列改为**相对倍数**(7.35× / 10.2× / 7.9×),不再出现绝对金额;ir-schema 移除签字栏(冻结约束以「双方确认 + 变更记录」为准) diff --git a/README.md b/README.md index 6e7120b..fe2ef58 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # protocol-bridge +[![test](https://github.com/H2Rhan/protocol-bridge/actions/workflows/test.yml/badge.svg)](https://github.com/H2Rhan/protocol-bridge/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +![python](https://img.shields.io/badge/python-3.11%2B-blue) +![deps](https://img.shields.io/badge/deps-stdlib%20only-brightgreen) + 犀牛鸟开源实战 · TRACK 05A/05B 协议转换组 OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + 独立状态层)。 @@ -26,7 +31,7 @@ OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + | max_tokens:0 预热路径 | ✅ 实测有效 | 录制样例测 + 实测预热后 100% 命中(exp17) | | **两轮代码自查(9 bug 修复 + 回归测试)** | ✅ **v1.2 新增** | 含 3 个严重项,各配回归测试(`TestAuditFindings`),见 `docs/REVIEW.md` 第五节 | | 三组对照实验 | ✅ 34 组全量实测 | `docs/experiment-results.md`(含第〇节证伪条件预登记) | -| TRACK04 签字确认稿 | ✅ v1.1 新增 | `docs/TRACK04_签字确认稿.md`,分工会直接签 | +| TRACK04 边界参数对齐稿 | ✅ 已对齐 | `docs/TRACK04_签字确认稿.md`(single_task 等三参数已定稿) | | 弹网页(本地仪表盘) | ✅ 记忆编排界面 | `src/webui/` 安全骨架 + 记忆勾选/裁剪 + 缓存前缀可视化 | | 已知限制(边界自觉) | 📋 12 项四要素 | `docs/LIMITATIONS.md`(含结论隔离证明) | @@ -58,7 +63,7 @@ python -m src.gateway.server - Anthropic 目标需 Anthropic 协议端点(Groq 无 `/v1/messages`);网关自动带 `anthropic-version` 头(`PB_ANTHROPIC_VERSION` 可覆盖)。 - 接 Anthropic 端点调试缓存链路前,先跑验站脚本确认透传(PASS 才可用): `python tools/verify_cache.py --base https://<端点域名> --model <模型> --key ` - 正式实验数据口径(共享账号缓存污染与缓解)见方案 v3.1 §3.6.1。 + 正式实验数据口径(缓存污染与缓解:唯一 run_id 前缀隔离)见 `docs/experiment-results.md` 第七节「方法学与踩坑」第 1 条。 ## 设计要点(对应执行方案 v3) diff --git a/docs/experiment-results.md b/docs/experiment-results.md index cd81637..5b82739 100644 --- a/docs/experiment-results.md +++ b/docs/experiment-results.md @@ -13,10 +13,10 @@ 核心主数字(20 轮长对话,同一模型): -| 注入方式 | 稳态命中率 | 20 轮成本 | 倍数 | -| --- | --- | --- | --- | -| LOCKED(会话内锁死) | 94.9% | $0.020 | 1× | -| DYNAMIC(每轮重写) | 0.0% | $0.150 | **7.35×** | +| 注入方式 | 稳态命中率 | 20 轮相对成本 | +| --- | --- | --- | +| LOCKED(会话内锁死) | 94.9% | 1× | +| DYNAMIC(每轮重写) | 0.0% | **7.35×** | > 该倍数在 haiku-4.5 实测为 7.35×、在 sonnet-4.6 实测为 7.30×,**与模型无关**,可作为跨模型引用的稳定主数字。 @@ -67,20 +67,20 @@ ### 1. 更新频率 -| 组 | 做法 | 稳态命中率 | 成本(8 轮) | +| 组 | 做法 | 稳态命中率 | 相对成本(8 轮) | | --- | --- | --- | --- | -| B_locked | 会话开始注入一次并锁死 | **98.1%** | $0.0088 | -| C_dynamic | 每轮动态注入(前缀每轮变) | **0.0%** | $0.0897(**10.2×**) | +| B_locked | 会话开始注入一次并锁死 | **98.1%** | 1× | +| C_dynamic | 每轮动态注入(前缀每轮变) | **0.0%** | **10.2×** | 精确公式(exp9,独立盐隔离验证):每 N 轮更新一次的稳态命中率 = **(N−1)/N**。 实测 N=1→0%、N=2→49.9%、N=3→66.5%、N=5→66.5%,与理论 0/50/66.7/80 吻合。 ### 2. 位置 -| 组 | 做法 | 稳态命中率 | 成本 | +| 组 | 做法 | 稳态命中率 | 相对成本 | | --- | --- | --- | --- | -| D1_head | 记忆放前缀头部(system) | **98.1%** | $0.0088 | -| D2_tail | 记忆放消息流尾部(append) | **14.1%** | $0.0694(**7.9×**) | +| D1_head | 记忆放前缀头部(system) | **98.1%** | 1× | +| D2_tail | 记忆放消息流尾部(append) | **14.1%** | **7.9×** | D2_tail 出现**「第 3 轮崩塌」**:前 2 轮命中,第 3 轮起因历史累积使前缀每轮变化,写入量随轮次递增(2952→3007)。这是「缓存如何失效」的最佳实证。 @@ -178,7 +178,7 @@ D2_tail 出现**「第 3 轮崩塌」**:前 2 轮命中,第 3 轮起因历 ## 五、覆盖补充实验(E20–E34) -E20–E29 全部在 haiku-4.5($1/M)上完成,E20 跨模型对照含 sonnet 读;E30–E34 亦以 haiku-4.5 为主,E34 含 opus-5 上探。 +E20–E29 全部在 haiku-4.5(低成本档)上完成,E20 跨模型对照含 sonnet 读;E30–E34 亦以 haiku-4.5 为主,E34 含 opus-5 上探。 | 编号 | 维度 | 结果 | 结论 | | --- | --- | --- | --- | @@ -189,7 +189,7 @@ E20–E29 全部在 haiku-4.5($1/M)上完成,E20 跨模型对照含 sonnet | E24 | 断点位置 | system 断点 99.85% vs 首条 user 断点 99.95% | **断点位置几乎无差异**,放 system 是惯例(更早更稳) | | E25 | 同 session 并发 | 3 并发同前缀全部 99.83% | **并发不破坏缓存**,粘性路由稳定 | | E26 | 动态 tool_result 混合 | 锁记忆 + 每轮变 tool_result,6 轮稳定 98.7% | **system 前缀稳定**,断点之后的内容变化不影响命中 | -| E28 | haiku 收益主数字 | LOCKED $0.020 vs DYNAMIC $0.150 = **7.35×** | 与 sonnet 版 7.30× 一致,主数字跨模型成立 | +| E28 | haiku 收益主数字 | LOCKED vs DYNAMIC = **7.35×** | 与 sonnet 版 7.30× 一致,主数字跨模型成立 | | E29 | 埋点可观测性对账 | 起网关连真实端点:两轮 cc=5978 → cr=5978(100% 命中),埋点与 usage 逐字段一致 | **埋点忠实记录 API 真实 usage**(已实做) | --- diff --git a/docs/ir-schema.md b/docs/ir-schema.md index 21afe53..c6b13c2 100644 --- a/docs/ir-schema.md +++ b/docs/ir-schema.md @@ -1,7 +1,7 @@ # IR v0 · 中间表示契约(冻结) > 本文件是 05A(Chat)、05B(Response)、Anthropic 三方的**唯一契约**。 -> IR 一冻结,05A 与 05B 即可并行开发。修改需双方共同签字。 +> IR 一冻结,05A 与 05B 即可并行开发。修改须经双方确认并记入文末变更记录。 > 数据日期:2026-09-01。 三个协议各写两个方向:`to_ir`(协议 → IR)与 `from_ir`(IR → 协议)。 @@ -100,9 +100,3 @@ bp_after_tools / bp_after_system / bp_after_history_static / bp_rolling_tail | v0 | 2026-09-01 | 三层契约定稿(本文件) | 语义层冻结 | | v0.1 | 2026-09-05 | 实现层修订:落地第 4 个固定断点 `bp_after_history_static`;三 adapter 顶层字段统一 `record_unknown()` 收口;thinking/tool_choice 经 `extra` 透传。**L0/L1 语义未动** | 实现层(各配回归测试,见 REVIEW.md 第五节) | | v0.2 | 2026-09-09 | 删除对「20-block 回看窗口」的引用——该约束已被本组实验决定性证伪(exp5/7/11:60 块 10872 token 仍 100% 命中,见 experiment-results.md 第三节) | 文档修订(契约字段未动) | - -## 签字 - -- 05A(Chat):___ -- 05B(Response):___ -- 日期:2026-09-01 diff --git a/docs/lossy-fields.md b/docs/lossy-fields.md new file mode 100644 index 0000000..de7fcf7 --- /dev/null +++ b/docs/lossy-fields.md @@ -0,0 +1,49 @@ +# 已知有损字段清单(Lossy Fields) + +> 问题清单主线交付物(组5#10)。由三个 adapter 的实码推导(`record_unknown` / `dropped.add` 全覆盖点), +> 与运行时 `_bridge.dropped` 每轮暴露的记录同口径。**凡转换会丢语义的地方,这里必须有一行。** +> 数据日期 2026-09-09 · v1.4 实码核对。 + +## 一、内容块级(message content 内部) + +| 字段/块 | 方向 | 损耗 | 去向 | +|---|---|---|---| +| `image` / `document` / `audio` 等多模态块 | 全方向 | 不转换(方案 3.8 明确不做) | `dropped: content.` 显式记录 | +| Chat 多模态 content parts(`image_url` / `input_audio` / `file`) | Chat→* | 同上 | `dropped: content.` | +| Responses `input_image` / `input_file` 等非文本条目 | Responses→* | 同上 | `dropped: input.` | +| 缓存断点 `cache_control` | Anthropic→Chat/Responses | Chat/Responses 无断点概念,往返后丢失(衰减未量化 → LIMITATIONS #8) | IR `cache_breakpoint` 标记仅同协议保留 | +| Chat 消息 `name` / `refusal` / `audio` 字段 | Chat→* | 无 IR 字段,不回传 | 当前不进降级记录(见「四、已知缺口」) | +| Responses `reasoning` 的 `summary` 结构化数组 | Responses→* | 仅字符串 summary 进 IR,结构丢弃 | 文本保留,结构降级 | +| thinking `display:"omitted"` | Anthropic→* | 文本为空但 signature 保留(v1.4 修复后不再误判丢弃) | `extra.signature` 保留 | + +## 二、请求参数级(payload 顶层) + +| 字段 | 方向 | 损耗 | 去向 | +|---|---|---|---| +| `n` / `logprobs` / `top_logprobs` / `logit_bias` / `seed` / `frequency_penalty` / `presence_penalty` | *→Anthropic | Anthropic 全部不支持,不生效 | `record_unknown` → dropped 清单 + 告警 | +| `top_p` | *→Anthropic | 与 `temperature` 互斥,不进渲染 | 同上 | +| `response_format`(结构化输出) | *→Anthropic | Anthropic 无原生 JSON Schema 模式,约束保证静默失去(问题清单组2#4:显式降级,不默认通过) | 同上 | +| Chat 顶层 `system`(非规范误发) | Chat→* | 语义保留(上提 IR.system)但记录"这不是规范写法" | 上提 + dropped 记录 | +| `previous_response_id`(会话未找到) | Responses→* | 降级为无状态处理 | dropped 记录 + 客户端可见 | +| Responses `text.format` / `output_config` | Responses→Anthropic | 无 IR 字段 | `record_unknown` → dropped | +| TTL 24h 保留语义 | Responses→Anthropic | Anthropic 最长 1h,只能降级 | 文档标注(capability-matrix 计价表) | + +## 三、usage 归一级 + +| 字段 | 损耗 | 说明 | +|---|---|---| +| `reasoning_tokens` / `*_tokens_details` 明细 | 归一后不单独保留 | 计入 `output_tokens` 总数,明细粒度丢失(组4#2 B 级:折算或显式标注不可比) | +| `service_tier` / `system_fingerprint` 等上游元数据 | 不归一 | 进 `_bridge` 元信息之外的原始响应,网关透传不消费 | + +## 四、已知缺口(清单本身的盲区,诚实标注) + +| 缺口 | 说明 | 后续 | +|---|---|---| +| 工具定义**内部**的非规范字段不进降级记录 | MCP 工具的 `server_url` / `server_label` / `require_approval`、内置工具(`web_search` 等)的专有参数——`to_ir` 只取 name/description/input_schema,其余字段丢失且**当前无 dropped 记录**(`record_unknown` 只覆盖顶层) | 候选修复:tools[] 逐项 record_unknown;评审被问到的答法:MCP 语义保真在方案 3.8 已声明非目标(降级为 function),但"降级无记录"确实待补 | +| property-based 覆盖范围 | `TestPropertyRoundTrip` 覆盖文本/工具/thinking 三族块的随机组合(440 用例),未覆盖多模态与流式 | 多模态属方案 3.8 非目标;流式见 LIMITATIONS #1 | + +## 五、验证方式 + +- 静态:本清单每行可在 `src/adapters/*.py` 找到对应 `dropped.add` / `record_unknown` 调用点。 +- 动态:任意请求经网关后读响应 `_bridge.dropped`——与 metrics.jsonl 的降级路径埋点(第④⑤项暴露)同一份数据。 +- 回归:`TestPropertyRoundTrip` 性质②(任何未知顶层字段必进降级记录,440 随机用例)防"静默丢弃"回潮。 diff --git a/tests/test_offline.py b/tests/test_offline.py index 69c77bb..7e66088 100644 --- a/tests/test_offline.py +++ b/tests/test_offline.py @@ -5,6 +5,8 @@ """ import json import os +import random +import string import sys import threading import unittest @@ -627,6 +629,162 @@ def test_shared_db_and_size(self): self.assertEqual(self.idm.size("s1"), 2) +class TestPropertyRoundTrip(unittest.TestCase): + """问题清单组5#10:property-based 往返测试(stdlib 实现,零三方依赖)。 + + 与录制样例的例举式测试互补:固定种子生成随机请求,断言**性质**而非样例—— + ① 断点数恒在 [3,4];② 任何未知顶层字段必进降级记录(绝不静默丢弃); + ③ 工具参数跨协议守恒;④ thinking signature 往返守恒。 + 种子固定 → 失败可用同一种子回放现场。 + """ + + SEED = 20260909 + + def _rand_text(self, rng, n=40): + return "".join(rng.choice(string.ascii_letters + "中文测试字 ") + for _ in range(rng.randint(1, n))) + + # -- 随机 payload 生成 ------------------------------------------------- + def _rand_chat_payload(self, rng): + msgs = [{"role": "system", "content": self._rand_text(rng)}] + for i in range(rng.randint(1, 6)): + role = "user" if i % 2 == 0 else "assistant" + m = {"role": role, "content": self._rand_text(rng)} + if role == "assistant" and rng.random() < 0.5: + m["tool_calls"] = [{ + "id": f"call_{rng.randint(0, 999)}", "type": "function", + "function": {"name": "f", "arguments": json.dumps( + {"k": rng.randint(0, 100), "s": self._rand_text(rng, 8)}, + ensure_ascii=False)}}] + msgs.append(m) + return {"model": "gpt-x", "messages": msgs, + "tools": [{"type": "function", "function": { + "name": "f", "description": "d", + "parameters": {"type": "object"}}}], + f"x_rand_{rng.randint(0, 999)}": rng.random(), + "logit_bias": {"1": 1}} + + def _rand_anthropic_payload(self, rng): + msgs = [] + for i in range(rng.randint(1, 5)): + role = "user" if i % 2 == 0 else "assistant" + content = [{"type": "text", "text": self._rand_text(rng)}] + if role == "assistant": + if rng.random() < 0.6: + content.insert(0, {"type": "thinking", + "thinking": self._rand_text(rng), + "signature": f"sig_{rng.randint(0, 9999)}"}) + if rng.random() < 0.5: + content.append({"type": "tool_use", + "id": f"toolu_{rng.randint(0, 999)}", + "name": "f", + "input": {"v": rng.randint(0, 50)}}) + msgs.append({"role": role, "content": content}) + return {"model": "claude-x", "max_tokens": 128, "messages": msgs, + "tools": [{"name": "f", "description": "d", + "input_schema": {"type": "object"}}], + f"x_rand_{rng.randint(0, 999)}": 1, "top_p": 0.9} + + def _rand_response_payload(self, rng): + items = [] + for i in range(rng.randint(1, 5)): + if i % 2 == 0: + items.append({"type": "message", "role": "user", + "content": self._rand_text(rng)}) + else: + items.append({"type": "message", "role": "assistant", + "content": [{"type": "output_text", + "text": self._rand_text(rng)}]}) + if rng.random() < 0.5: + items.append({"type": "function_call", "name": "f", + "call_id": f"call_{rng.randint(0, 999)}", + "arguments": json.dumps( + {"v": rng.randint(0, 50)})}) + return {"model": "gpt-x", "input": items, + f"x_rand_{rng.randint(0, 999)}": "x"} + + # -- 性质断言 ----------------------------------------------------------- + def test_property_chat_to_anthropic(self): + rng = random.Random(self.SEED) + for case in range(120): + p = self._rand_chat_payload(rng) + d1 = Dropped() + req = ChatAdapter().to_ir(p, d1) + # 性质②:未知顶层字段必降级(chat._KNOWN_TOP 之外一律在案) + known = {"model", "messages", "tools", "max_tokens", + "max_completion_tokens", "temperature", "stream", "system"} + dropped_fields = {d["field"] for d in d1.items} + for k in p: + if k not in known: + self.assertIn(k, dropped_fields, + f"case {case}: 未知字段 {k} 被静默丢弃") + # 性质①:断点上限 + out = AnthropicAdapter().from_ir(req, Dropped(), ir.SessionContext()) + bps = _count_breakpoints(out) + self.assertTrue(3 <= bps <= 4, f"case {case}: 断点数 {bps} 越界") + # 性质③:工具参数守恒(v1.4 修复的参数丢失正是这条性质被抓出来的) + src = [json.loads(tc["function"]["arguments"]) + for m in p["messages"] for tc in m.get("tool_calls", []) or []] + got = [b["input"] for m in out["messages"] for b in m["content"] + if b.get("type") == "tool_use"] + self.assertEqual(src, got, f"case {case}: 工具参数跨协议变形") + + def test_property_anthropic_to_chat(self): + rng = random.Random(self.SEED + 1) + for case in range(120): + p = self._rand_anthropic_payload(rng) + d1 = Dropped() + req = AnthropicAdapter().to_ir(p, d1) + known = {"model", "max_tokens", "temperature", "stream", + "system", "messages", "tools", "thinking", "tool_choice"} + dropped_fields = {d["field"] for d in d1.items} + for k in p: + if k not in known: + self.assertIn(k, dropped_fields, + f"case {case}: 未知字段 {k} 被静默丢弃") + out = ChatAdapter().from_ir(req, Dropped()) + # 性质③:tool_use.input → arguments JSON 守恒 + src_inputs = [b["input"] for m in p["messages"] + for b in m["content"] if b.get("type") == "tool_use"] + got_args = [json.loads(tc["function"]["arguments"]) + for m in out["messages"] + for tc in m.get("tool_calls", []) or []] + self.assertEqual(src_inputs, got_args, + f"case {case}: Anthropic→Chat 参数变形") + + def test_property_anthropic_signature_roundtrip(self): + rng = random.Random(self.SEED + 2) + for case in range(80): + p = self._rand_anthropic_payload(rng) + req = AnthropicAdapter().to_ir(p, Dropped()) + out = AnthropicAdapter().from_ir(req, Dropped()) + # 性质④:signature 有序守恒(多轮思考链的硬约束) + src_sigs = [b["signature"] for m in p["messages"] + for b in m["content"] if b.get("type") == "thinking"] + got_sigs = [b.get("signature") for m in out["messages"] + for b in m["content"] if b.get("type") == "thinking"] + self.assertEqual(src_sigs, got_sigs, + f"case {case}: thinking signature 往返丢失") + + def test_property_response_to_anthropic(self): + rng = random.Random(self.SEED + 3) + for case in range(100): + p = self._rand_response_payload(rng) + d1 = Dropped() + req = ResponseAdapter().to_ir(p, d1) + dropped_fields = {d["field"] for d in d1.items} + for k in p: + if k not in ("model", "input"): + self.assertIn(k, dropped_fields, + f"case {case}: 未知字段 {k} 被静默丢弃") + out = AnthropicAdapter().from_ir(req, Dropped(), ir.SessionContext()) + src = [json.loads(it["arguments"]) for it in p["input"] + if it.get("type") == "function_call"] + got = [b["input"] for m in out["messages"] for b in m["content"] + if b.get("type") == "tool_use"] + self.assertEqual(src, got, f"case {case}: Responses→Anthropic 参数变形") + + def _count_breakpoints(payload: dict) -> int: n = 0 for t in payload.get("tools", []):