From 005a90ca1953e41d1a81a9f8d3ac88bfe221b343 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 04:35:51 -0400 Subject: [PATCH] docs: fold the independent review findings into the design --- ...31-response-observability-fields-design.md | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/research-wiki/designs/2026-07-31-response-observability-fields-design.md b/research-wiki/designs/2026-07-31-response-observability-fields-design.md index 0e1ff99..3503e42 100644 --- a/research-wiki/designs/2026-07-31-response-observability-fields-design.md +++ b/research-wiki/designs/2026-07-31-response-observability-fields-design.md @@ -73,7 +73,7 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 ## 5. 决策 D:遥测表扩列的落地方式 -人类确认「现在不存在必须保留的生产库」。但两个后端的 DDL 都是 `CREATE TABLE IF NOT EXISTS`,**已存在的开发库/下游库不会自动获得新列**,INSERT 会失败:SQLite 每行 warning 降级、Postgres 置结构性降级标志后全量短路——遥测静默丢失,与「遥测必录」相悖。 +人类确认「现在不存在必须保留的生产库」。但两个后端的 DDL 都是 `CREATE TABLE IF NOT EXISTS`,**已存在的开发库/下游库不会自动获得新列**,INSERT 会失败。两侧的失败形态都是**逐行 warning 丢弃**(SQLite `sqlite.py:93`;PG `postgres.py:127`——`_failed` 结构性标志只在 `_ensure_ready` 建池/建表失败时置位,与写入路径无关),即每一次调用的遥测行都丢,却不会有任何一次硬失败提示,与「遥测必录」相悖。 | 方案 | 做法 | 权衡 | |---|---|---| @@ -83,14 +83,23 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 **选 D1**。列类型:SQLite `cached_prompt_tokens INTEGER` / `model_reported TEXT`;PG `INTEGER` / `TEXT`。两列均可空(NULL = 该源未上报),不设 NOT NULL 与默认值——0 与 NULL 的区分正是本 issue 的核心诉求。 -端口 `TelemetryRecorder.record_llm_call` 由 18 字段扩为 20 字段(关键字参数),`ports.py:248` 的「18 字段冻结」注释与 ARCHITECTURE 相应表述同步更新。新增参数在 Protocol 上**不设默认值**——端口是库对实现者的完整契约,冻结签名的价值在于两个后端与测试 fake 必须同步,静默少写一列比编译期报错更坏。 +**D1 的实现纪律(必须钉进计划,否则补列会把降级放大成永久失能)**: + +| 约束 | 原因 | +|---|---| +| SQLite 的 ALTER 必须用**独立 try**,且置于 `self._conn = conn` **之后** | `__init__` 现有 try 的最后一句才是 `self._conn = conn`(`sqlite.py:74-84`);ALTER 抛异常会让 `_conn` 停在 `None`,`record_llm_call` 首行即 return —— 整个 recorder 永久 no-op,比逐行丢弃严重得多 | +| `duplicate column name` 视为成功吞掉 | `PRAGMA table_info` 探测 + ALTER 是 TOCTOU:多 worker 共用同一 db 文件时后到者必然撞上 | +| 不得为补列加宽 `except` | `sqlite.py:93` 只捕 `(OSError, sqlite3.Error)`,取消是天然穿透的;PG 侧的 `except asyncio.CancelledError: raise` 必须留在最前 | +| PG 用原生 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 无 TOCTOU;落在既有 `_init_lock` 保护的 `_ensure_ready` 内 | + +端口 `TelemetryRecorder.record_llm_call` 由 18 字段扩为 20 字段(关键字参数),`ports.py:248` 的「18 字段冻结」注释与 ARCHITECTURE 相应表述同步更新。新增参数在 Protocol 上**不设默认值**——依据不是「漏改会报错」(本仓无 mypy,`make lint` 只有 ruff + import-linter,8 个测试 fake 全是 `**fields`,漏改根本不会自动红),而是**库外不存在第三方实现者**:三项目迁移文档明确删除各自的 TelemetryRecorder Protocol 与实现(`migrations/govdoc-saas.md:36`、`video-tree-trm5.md:36/51`),端口的唯一实现者就是库内两个后端,完整签名的成本为零。漏改的兜底靠 §8 的键集合断言测试,不靠类型检查。 ## 6. 行为审计(既有行为逐条标注) | 既有行为 | 处置 | |---|---| | `LLMResponse` 前 11 字段顺序即公共承诺 | **保留**,新字段追加到尾部(`structured_data` 之后) | -| 缓存序列化 `_serialize` 用 `asdict` 全量、`_rehydrate` 按 `_RESPONSE_FIELDS` 过滤 | **保留**。新字段自动进出;旧缓存条目缺这两键时,`LLMResponse(**fields)` 靠默认值构造成功(向后兼容已验证) | +| 缓存序列化 `_serialize` 用 `asdict` 后 pop 掉 `structured_data`、`_rehydrate` 按 `_RESPONSE_FIELDS` 过滤 | **保留**。新字段自动进出;旧缓存条目缺这两键时,`LLMResponse(**fields)` 靠默认值构造成功(向后兼容已验证) | | `cache_hit` 语义 = PolyGateway 自身响应缓存 | **保留**,仅补 docstring 消歧 | | `TelemetryEmitter` 单一 `_record` helper(遥测必录铁律:禁止复制参数列表) | **保留**,新字段只在 `_record` 增两个参数,三个 `emit_*` 入口各传一次 | | 失败尝试 / 终态失败行记 `usage_source="unavailable"` | **保留**,两个新字段在这些路径记 `None` | @@ -101,8 +110,8 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 | 维度 | 结论 | |---|---| -| 并发与取消 | 纯数据字段,无新增 await 点、无共享状态。SQLite 补列在既有 `threading.Lock` + 初始化路径内;PG 补列在既有 `_init_lock` 保护的 `_ensure_ready` 内,并发首调用不会重复 ALTER。取消穿透路径不变:两个 recorder 的 `except asyncio.CancelledError: raise` 保持在最前 | -| 降级方向 | 遥测属「静默降级」侧:补列失败 → warning 并沿用既有降级(SQLite 逐行丢弃 / PG 结构性短路),绝不冒泡到调用方。解析失败 → 字段记 `None`,不影响响应返回 | +| 并发与取消 | 纯数据字段,无新增 await 点、无共享状态。PG 补列在既有 `_init_lock` 保护的 `_ensure_ready` 内,并发首调用不会重复 ALTER;SQLite 的 `__init__` **不持** `_lock`(它只保护 `_write`/`close`),跨进程共库靠上面 D1 纪律里的「duplicate column 视为成功」兜底。取消穿透不变:PG 两处 `except asyncio.CancelledError: raise` 保持在最前,SQLite 侧只捕 `(OSError, sqlite3.Error)` 故天然穿透 | +| 降级方向 | 遥测属「静默降级」侧:补列失败 → warning 并沿用既有逐行丢弃,绝不冒泡到调用方,也绝不让 recorder 整体失能(见 D1 纪律)。解析失败 → 字段记 `None`,不影响响应返回 | | 幂等与重复 | 补列幂等(PG `IF NOT EXISTS`;SQLite 先探测)。写入幂等性不变(`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING` 按 `call_id`) | | 持久化与原子性 | 单行 INSERT 原子性不变;新增两列不参与主键与冲突判定。缓存 JSON 是整值覆写,无部分写入 | | 向后兼容 | 下游三项目 + dissect:纯增字段带默认值,逐字段传参的 fake 构造零改动;旧价格表文件、旧缓存条目、旧遥测表均可继续工作 | @@ -118,8 +127,8 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 | retry(unit) | `_build_response` 透传两字段;失败尝试路径不受影响 | | cache(unit) | ① 新字段随序列化往返;② **旧格式**缓存条目(缺这两键)仍能 rehydrate;③ 命中回放值符合 B1 | | pricing(unit) | ① 配缓存档 + 命中 → 成本低于全额;② 未配该档 → 与现状逐位相等;③ `cached > prompt` → 夹取且不为负;④ 三参旧调用签名仍可用(embedding 调用形态);⑤ 价格表含负缓存单价 → `ValueError` | -| telemetry(integration) | ① 20 字段写入 SQLite/PG 成功并可读回;② **旧表**(18 列)在初始化后自动补列并写入成功;③ 补列失败时降级为 warning 不冒泡 | -| 契约 | 测试用的 fake recorder 同步到 20 字段(端口无默认值 → 漏改即报错) | +| telemetry(integration) | ① 20 字段写入 SQLite/PG 成功并可读回;② **旧表**(18 列)在初始化后自动补列并写入成功;③ 补列失败时降级为 warning 且 recorder 仍能工作(SQLite `_conn` 不得因此为 None) | +| 契约(**新增,不可省**) | 断言 `TelemetryEmitter` 传给 recorder 的实参键集合 == 两个后端的 `_COLUMNS`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端的 try **之外**(`sqlite.py:90` / `postgres.py:121`),emitter 漏传新字段会抛 `KeyError`,被 `_record` 的 `except Exception` 吞成 warning → **静默丢遥测**。这是本变更最危险的失败形态,而现有 8 个 `**fields` 形态的 fake 一个都拦不住 | > integration 层的 Redis/PG 测试遵守既有纪律:共享后端严禁并跑,`conda run -n PolyGateway --no-capture-output`。 @@ -134,9 +143,11 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 | `src/polygateway/pricing.py` | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验;订正唯一换算点注释 | | `src/polygateway/ports.py` | `TelemetryRecorder` 18 → 20 字段 | | `src/polygateway/telemetry/{sqlite,postgres}.py` | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 | +| `tests/` | 两处手写 18 键 dict 的 helper 必须同步(`unit/test_telemetry.py:78-98`、`integration/test_postgres_telemetry.py:105`——漏改即红,是唯一天然拦截点);`unit/test_ports.py:96` 的全签名 fake 同步;新增契约测试 | | `research-wiki/ARCHITECTURE.md` | §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述 | +| 「18 字段冻结」的其余措辞点 | `ports.py:248`、`pricing.py:6`(币种说明里引用了该数字)、`telemetry/sqlite.py:87`、`tests/unit/test_telemetry.py:1` | | Wiki 站 + `CHANGELOG.md` | 按 `docs-convention.md` §2 清单同步(公共行为变更,版本 bump 不得裸发) | -| `.env.example` / 价格表模板 | 补 `cached_input_per_1m` 示例与说明 | +| `.env.example:56` | 该行内联注释是仓内**唯一**的价格表格式说明(无独立模板文件,`config/prices.json` 是未入库的本地文件),补 `cached_input_per_1m` 可选档 | ## 10. 待人类确认