From 8a824e20002a26aecf7d505bc87b5b50780b4bd0 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 04:22:51 -0400 Subject: [PATCH] docs: design the response observability fields for issue 3 --- ...31-response-observability-fields-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 research-wiki/designs/2026-07-31-response-observability-fields-design.md 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 new file mode 100644 index 0000000..0e1ff99 --- /dev/null +++ b/research-wiki/designs/2026-07-31-response-observability-fields-design.md @@ -0,0 +1,145 @@ +# 响应可观测字段扩展设计(Issue #3) + +- **日期**: 2026-07-31 +- **来源**: Gitea Issue #3(下游 dissect 审计需求) +- **状态**: 待人类审批 +- **触发档位**: 强制(变更 `types.py` 公共类型 + `ports.py` 端口签名 + 遥测持久化 schema) + +## 1. 目标与非目标 + +| 项 | 内容 | +|---|---| +| 目标 1 | `LLMResponse` 暴露供应商侧 prompt cache 命中的输入 token 数 | +| 目标 2 | `LLMResponse` 暴露 API 响应体实际返回的模型版本串 | +| 目标 3 | 两字段同步落 `llm_calls` 遥测表(端口 18 → 20 字段) | +| 目标 4 | `PricingTable` 支持可选的缓存读取单价,消除 cost 高估 | +| 非目标 1 | 不改 `EmbeddingResponse` / OCR 响应——embedding 与 OCR 无 prompt cache 语义,且 issue 未提;`cost()` 新增参数带默认值,embedding 调用点(`embedding.py:419`)零改动 | +| 非目标 2 | 不改 `cache_hit` 字段名/类型(破兼容),只在 docstring 消歧 | +| 非目标 3 | 不为 `reasoning_tokens` 等其他 usage 细项开口(YAGNI,无下游需求) | + +### 1.1 Issue 前提的一处修正 + +Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核查结果: + +| 数据 | 实际所在 | 结论 | +|---|---|---| +| `usage.prompt_tokens_details.cached_tokens` | `raw={"usage": ...}`(流式 `openai_compat.py:362`、非流式 `:444`) | ✅ 已在 raw 内 | +| 响应体顶层 `model` | **不在**。非流式 raw 只放 `body["usage"]`;流式 sink 只吸收 `usage` 与 `done` 两键(`_sse_delta`,`:44-47`),chunk 的 `model` 从未收集 | ❌ 需改 transport 采集 | + +故本变更**不是纯字段暴露**,必须同时改 `transports/`。这决定了下面决策 A 的必要性。 + +## 2. 决策 A:字段的采集与传递路径 + +| 方案 | 做法 | 权衡 | +|---|---|---| +| A1 raw 约定键 | transport 往 `raw` 里塞 `{"model": ...}`;RetryMW 读 `raw.get("model")` 与 `raw["usage"]["prompt_tokens_details"]["cached_tokens"]` | 改动最小;但 `raw: dict[str, Any]` 变成隐式契约,键名靠约定;且 middleware 要懂 OpenAI 报文嵌套结构 | +| A2 TransportResult 强类型字段(**推荐**) | `TransportResult` 追加 `cached_prompt_tokens: int \| None = None`、`model_reported: str \| None = None`;解析逻辑留在 `openai_compat.py`;RetryMW 直接搬运 | 报文格式知识不出 `transports/`,middleware 只做搬运,符合 P7(middleware 只依赖端口、不懂具体报文);两字段带默认值,`monkey_ocr` 的 OCR 结果类型不受影响 | +| A3 middleware 解析 raw | RetryMW 内写 OpenAI 嵌套路径解析 | 把 provider 报文格式知识放进 middleware 层,新增非 OpenAI 兼容 transport 时会分叉;违反分层,否决 | + +**选 A2**。`TransportResult` 是库内部流转类型(非三项目消费面),但仍按「新增必带默认值」处理,使 `openai_compat` 之外的构造点零改动;全库该类型仅 2 处构造(`openai_compat.py:354/436`)。 + +解析纪律(P5 一切外部输入校验后使用):`cached_tokens` 与 `model` 均来自网关响应,类型不可信。取值走一个防御 helper——非 `int`/非正、非 `str`/空串一律归 `None`,不抛异常(可观测字段缺失绝不能打断主路径)。 + +## 3. 决策 B:缓存命中回放时两字段取什么值 + +| 方案 | LLMResponse 层 | 遥测层 | 权衡 | +|---|---|---|---| +| B1 原样回放(**推荐**) | 随缓存 JSON 回放原值 | 照记回放值 | 与既有口径一致——`CacheMW._rehydrate`(`cache.py:113-119`)只覆写与本次调用相关的时序字段(`latency_ms`/`ttft_ms`/`max_inter_token_ms`/`call_id`/`cache_hit`),`model`/`provider`/`prompt_tokens` 全部回放。新字段与它们同类(溯源 + 用量),按同一规则处理 | +| B2 命中时置 None | 覆写为 None | NULL | 语义上「本次未打供应商,无供应商侧事实」也成立,但与同层的 `prompt_tokens` 回放行为不一致,下游要记两套规则 | +| B3 混合 | `model_reported` 回放、`cached_prompt_tokens` 置 None | 同左 | 最难解释,否决 | + +**选 B1**,并写入文档一条度量口径约束(与 `cost` 缺口口径同款教训,ARCHITECTURE §5.1): + +> 统计供应商缓存命中率必须写 `WHERE cache_hit = false`——缓存命中行的 `cached_prompt_tokens` 是历史回放值,计入会重复计数。 + +`cost` 不受影响:遥测层 `cache_hit=True` 分支仍短路为 `0.0`,早于任何单价换算。 + +## 4. 决策 C:缓存读取单价(人类已选「增加可选档」) + +| 方案 | 做法 | 权衡 | +|---|---|---| +| C1 ModelPrice 可选第三档(**推荐**) | `cached_input_per_1m: float \| None = None`;`cost()` 增可选参 `cached_prompt_tokens: int \| None = None` | 价格表旧文件零改动仍可加载;`embedding.py:419` 的三参调用零改动 | +| C2 cost() 收 LLMResponse | 换算函数直接吃响应对象 | `pricing.py` 会反向依赖 `types.py` 且难以单测纯函数,否决 | + +换算规则与退化路径: + +| 条件 | 计价方式 | +|---|---| +| 配了 `cached_input_per_1m` 且本次 `cached_prompt_tokens` 为正 | `(prompt - cached) × input + cached × cached_input` | +| 未配该档,或本次 `cached_prompt_tokens` 为 None/0 | 全额按 `input` 计(现状行为,不变) | +| `cached > prompt`(网关口径异常) | 按 `cached = prompt` 夹取并记一次 warning;不抛异常、不产生负成本 | + +**不猜折扣率**:未配置缓存档时绝不按「五分之一」之类经验值折算(P5 严禁默认值掩盖)。`from_file` 的 fail-loud 校验对新档同样适用:出现该键但非数或为负 → `ValueError`。 + +## 5. 决策 D:遥测表扩列的落地方式 + +人类确认「现在不存在必须保留的生产库」。但两个后端的 DDL 都是 `CREATE TABLE IF NOT EXISTS`,**已存在的开发库/下游库不会自动获得新列**,INSERT 会失败:SQLite 每行 warning 降级、Postgres 置结构性降级标志后全量短路——遥测静默丢失,与「遥测必录」相悖。 + +| 方案 | 做法 | 权衡 | +|---|---|---| +| D1 初始化期幂等补列(**推荐**) | DDL 加新列;初始化时按需 `ALTER TABLE ADD COLUMN`——PG 用原生 `IF NOT EXISTS`,SQLite 先查 `PRAGMA table_info` 再按需 ALTER | 旧库自动升列,新库无副作用;两处各约 5 行;补列失败沿用现有降级策略(warning,不冒泡) | +| D2 只改 DDL,文档写「删表重建」 | 零代码 | 已建表的开发机/下游踩坑后只看到降级 warning,排查成本高;违反防御性 | +| D3 引入迁移框架(alembic) | 正规版本化迁移 | 新增依赖,与「依赖极简」铁律冲突,规模严重不匹配,否决 | + +**选 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 必须同步,静默少写一列比编译期报错更坏。 + +## 6. 行为审计(既有行为逐条标注) + +| 既有行为 | 处置 | +|---|---| +| `LLMResponse` 前 11 字段顺序即公共承诺 | **保留**,新字段追加到尾部(`structured_data` 之后) | +| 缓存序列化 `_serialize` 用 `asdict` 全量、`_rehydrate` 按 `_RESPONSE_FIELDS` 过滤 | **保留**。新字段自动进出;旧缓存条目缺这两键时,`LLMResponse(**fields)` 靠默认值构造成功(向后兼容已验证) | +| `cache_hit` 语义 = PolyGateway 自身响应缓存 | **保留**,仅补 docstring 消歧 | +| `TelemetryEmitter` 单一 `_record` helper(遥测必录铁律:禁止复制参数列表) | **保留**,新字段只在 `_record` 增两个参数,三个 `emit_*` 入口各传一次 | +| 失败尝试 / 终态失败行记 `usage_source="unavailable"` | **保留**,两个新字段在这些路径记 `None` | +| `pricing.cost()` 是唯一换算点(注释语)| **修正**:实际有 `TelemetryEmitter` 与 `embedding.py:419` 两个调用点,顺带订正该 docstring(限于一行注释,不做结构重构) | +| OCR / embedding 各自构造 `LLMResponse` | **保留**,两字段取默认 `None`(该路径无供应商 cache 概念) | + +## 7. 非功能维度 + +| 维度 | 结论 | +|---|---| +| 并发与取消 | 纯数据字段,无新增 await 点、无共享状态。SQLite 补列在既有 `threading.Lock` + 初始化路径内;PG 补列在既有 `_init_lock` 保护的 `_ensure_ready` 内,并发首调用不会重复 ALTER。取消穿透路径不变:两个 recorder 的 `except asyncio.CancelledError: raise` 保持在最前 | +| 降级方向 | 遥测属「静默降级」侧:补列失败 → warning 并沿用既有降级(SQLite 逐行丢弃 / PG 结构性短路),绝不冒泡到调用方。解析失败 → 字段记 `None`,不影响响应返回 | +| 幂等与重复 | 补列幂等(PG `IF NOT EXISTS`;SQLite 先探测)。写入幂等性不变(`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING` 按 `call_id`) | +| 持久化与原子性 | 单行 INSERT 原子性不变;新增两列不参与主键与冲突判定。缓存 JSON 是整值覆写,无部分写入 | +| 向后兼容 | 下游三项目 + dissect:纯增字段带默认值,逐字段传参的 fake 构造零改动;旧价格表文件、旧缓存条目、旧遥测表均可继续工作 | + +## 8. 错误处理与测试策略 + +错误分类:本变更**不新增任何错误路径**。网关报文里这两项缺失或类型异常 → 记 `None`,不归入四分类(它们不是失败,是「该源没给」)。价格表配置错误仍走装配期 `ValueError`(fail-loud,不属运行时四分类)。 + +| 层 | 测试(先失败后通过) | +|---|---| +| types(unit) | 新字段默认值为 `None`;字段顺序不变(前 11 位置构造仍成立) | +| transports(unit) | 用真实网关响应二次构造样本:① 流式含 `prompt_tokens_details.cached_tokens` → 解析出正整数;② 非流式同上;③ 无该键 → `None`;④ 值为 `"abc"`/负数 → `None` 不抛;⑤ 流式 chunk 的 `model` 被 sink 采集;⑥ 顶层无 `model` → `None` | +| retry(unit) | `_build_response` 透传两字段;失败尝试路径不受影响 | +| cache(unit) | ① 新字段随序列化往返;② **旧格式**缓存条目(缺这两键)仍能 rehydrate;③ 命中回放值符合 B1 | +| pricing(unit) | ① 配缓存档 + 命中 → 成本低于全额;② 未配该档 → 与现状逐位相等;③ `cached > prompt` → 夹取且不为负;④ 三参旧调用签名仍可用(embedding 调用形态);⑤ 价格表含负缓存单价 → `ValueError` | +| telemetry(integration) | ① 20 字段写入 SQLite/PG 成功并可读回;② **旧表**(18 列)在初始化后自动补列并写入成功;③ 补列失败时降级为 warning 不冒泡 | +| 契约 | 测试用的 fake recorder 同步到 20 字段(端口无默认值 → 漏改即报错) | + +> integration 层的 Redis/PG 测试遵守既有纪律:共享后端严禁并跑,`conda run -n PolyGateway --no-capture-output`。 + +## 9. 影响面清单 + +| 文件 | 改动 | +|---|---| +| `src/polygateway/types.py` | `LLMResponse` +2 字段;`TransportResult` +2 字段;`cache_hit` docstring 消歧 | +| `src/polygateway/transports/openai_compat.py` | sink 采集 `model`;两处 `TransportResult` 构造填新字段;新增防御解析 helper | +| `src/polygateway/middleware/retry.py` | `_build_response` 透传 2 字段 | +| `src/polygateway/middleware/telemetry.py` | `_record` + 三个 `emit_*` 各透传 2 字段;cost 换算传入 `cached_prompt_tokens` | +| `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;初始化期幂等补列 | +| `research-wiki/ARCHITECTURE.md` | §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述 | +| Wiki 站 + `CHANGELOG.md` | 按 `docs-convention.md` §2 清单同步(公共行为变更,版本 bump 不得裸发) | +| `.env.example` / 价格表模板 | 补 `cached_input_per_1m` 示例与说明 | + +## 10. 待人类确认 + +1. 本设计整体是否批准进入 `writing-plans`。 +2. 决策 B(缓存命中原样回放 + 度量口径带 `cache_hit = false`)是否认可——这是唯一一处「语义可争论」的选择。 +3. 版本号定为 `1.1.0`(纯增字段但触及端口签名与表结构)是否合适。