12 KiB
响应可观测字段扩展设计(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. 待人类确认
- 本设计整体是否批准进入
writing-plans。 - 决策 B(缓存命中原样回放 + 度量口径带
cache_hit = false)是否认可——这是唯一一处「语义可争论」的选择。 - 版本号定为
1.1.0(纯增字段但触及端口签名与表结构)是否合适。