15 KiB
响应可观测字段扩展设计(Issue #3)
- 日期: 2026-07-31
- 来源: Gitea Issue #3(下游 dissect 审计需求)
- 状态: 已批准(2026-07-31,人类逐条确认 A2 / B1 / C1 / D1)
- 触发档位: 强制(变更
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 会失败。两侧的失败形态都是逐行 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:36、video-tree-trm5.md:36/51),端口的唯一实现者就是库内两个后端,完整签名的成本为零。漏改的兜底靠 §8 的键集合断言测试,不靠类型检查。
6. 行为审计(既有行为逐条标注)
| 既有行为 | 处置 |
|---|---|
LLMResponse 前 11 字段顺序即公共承诺 |
保留,新字段追加到尾部(structured_data 之后) |
缓存序列化 _serialize 用 asdict 后 pop 掉 structured_data、_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 点、无共享状态。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 构造零改动;旧价格表文件、旧缓存条目、旧遥测表均可继续工作 |
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 且 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。
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/ |
四处天然拦截点必须同步(漏改即红): 两个 _record_minimal 手写 18 键 dict(unit/test_telemetry.py:76 起、integration/test_postgres_telemetry.py:81-105)与两个 _EXPECTED_COLUMNS 列序断言(unit/test_telemetry.py:18-40、integration/test_postgres_telemetry.py:22-41);unit/test_ports.py:96 的全签名 fake 同步(它不会红,Protocol 的 isinstance 不校验签名);新增契约测试 |
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:56 |
该行内联注释是仓内唯一的价格表格式说明(无独立模板文件,config/prices.json 是未入库的本地文件),补 cached_input_per_1m 可选档 |
10. 审批记录
2026-07-31 人类逐条确认: A2(TransportResult 强类型字段)、B1(缓存命中原样回放 + 度量口径带 cache_hit = false)、C1(ModelPrice 可选缓存单价档)、D1(DDL 加列 + 初始化期幂等补列)。设计获批,进入 writing-plans。
版本号按 1.1.0 推进(纯增字段不破坏下游,但触及端口签名与表结构,minor 位比 patch 位更能提示下游);发版前若人类另有指示以指示为准。