Files
PolyGateway/research-wiki/designs/2026-07-31-response-observability-fields-design.md
T

15 KiB
Raw Blame History

响应可观测字段扩展设计(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 只吸收 usagedone 两键(_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 = Nonemodel_reported: str | None = None;解析逻辑留在 openai_compat.py;RetryMW 直接搬运 报文格式知识不出 transports/,middleware 只做搬运,符合 P7(middleware 只依赖端口、不懂具体报文);两字段带默认值,monkey_ocr 的 OCR 结果类型不受影响
A3 middleware 解析 raw RetryMW 内写 OpenAI 嵌套路径解析 把 provider 报文格式知识放进 middleware 层,新增非 OpenAI 兼容 transport 时会分叉;违反分层,否决

选 A2TransportResult 是库内部流转类型(非三项目消费面),但仍按「新增必带默认值」处理,使 openai_compat 之外的构造点零改动;全库该类型仅 2 处构造(openai_compat.py:354/436)。

解析纪律(P5 一切外部输入校验后使用):cached_tokensmodel 均来自网关响应,类型不可信。取值走一个防御 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 会失败。两侧的失败形态都是逐行 warning 丢弃(SQLite sqlite.py:93;PG postgres.py:127——_failed 结构性标志只在 _ensure_ready 建池/建表失败时置位,与写入路径无关),即每一次调用的遥测行都丢,却不会有任何一次硬失败提示,与「遥测必录」相悖。

方案 做法 权衡
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 的核心诉求。

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:36video-tree-trm5.md:36/51),端口的唯一实现者就是库内两个后端,完整签名的成本为零。漏改的兜底靠 §8 的键集合断言测试,不靠类型检查。

6. 行为审计(既有行为逐条标注)

既有行为 处置
LLMResponse 前 11 字段顺序即公共承诺 保留,新字段追加到尾部(structured_data 之后)
缓存序列化 _serializeasdict 后 pop 掉 structured_data_rehydrate_RESPONSE_FIELDS 过滤 保留。新字段自动进出;旧缓存条目缺这两键时,LLMResponse(**fields) 靠默认值构造成功(向后兼容已验证)
cache_hit 语义 = PolyGateway 自身响应缓存 保留,仅补 docstring 消歧
TelemetryEmitter 单一 _record helper(遥测必录铁律:禁止复制参数列表) 保留,新字段只在 _record 增两个参数,三个 emit_* 入口各传一次
失败尝试 / 终态失败行记 usage_source="unavailable" 保留,两个新字段在这些路径记 None
pricing.cost() 是唯一换算点(注释语) 修正:实际有 TelemetryEmitterembedding.py:419 两个调用点,顺带订正该 docstring(限于一行注释,不做结构重构)
OCR / embedding 各自构造 LLMResponse 保留,两字段取默认 None(该路径无供应商 cache 概念)

7. 非功能维度

维度 结论
并发与取消 纯数据字段,无新增 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 NOTHINGcall_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 采集;⑥ 顶层无 modelNone
retry(unit) _build_response 透传两字段;失败尝试路径不受影响
cache(unit) ① 新字段随序列化往返;② 旧格式缓存条目(缺这两键)仍能 rehydrate;③ 命中回放值符合 B1
pricing(unit) ① 配缓存档 + 命中 → 成本低于全额;② 未配该档 → 与现状逐位相等;③ cached > prompt → 夹取且不为负;④ 三参旧调用签名仍可用(embedding 调用形态);⑤ 价格表含负缓存单价 → ValueError
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,被 _recordexcept Exception 吞成 warning → 静默丢遥测。这是本变更最危险的失败形态,而现有 8 个 **fields 形态的 fake 一个都拦不住

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;初始化期幂等补列
tests/ 两处手写 18 键 dict 的 helper 必须同步(unit/test_telemetry.py:78-98integration/test_postgres_telemetry.py:105——漏改即红,是唯一天然拦截点);unit/test_ports.py:96 的全签名 fake 同步;新增契约测试
research-wiki/ARCHITECTURE.md §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述
「18 字段冻结」的其余措辞点 ports.py:248pricing.py:6(币种说明里引用了该数字)、telemetry/sqlite.py:87tests/unit/test_telemetry.py:1
Wiki 站 + CHANGELOG.md docs-convention.md §2 清单同步(公共行为变更,版本 bump 不得裸发)
.env.example:56 该行内联注释是仓内唯一的价格表格式说明(无独立模板文件,config/prices.json 是未入库的本地文件),补 cached_input_per_1m 可选档

10. 待人类确认

  1. 本设计整体是否批准进入 writing-plans
  2. 决策 B(缓存命中原样回放 + 度量口径带 cache_hit = false)是否认可——这是唯一一处「语义可争论」的选择。
  3. 版本号定为 1.1.0(纯增字段但触及端口签名与表结构)是否合适。