LLMResponse 缺两个可观测字段:供应商 prompt cache 命中 token 数、API 实际返回的模型版本串 #3

Closed
opened 2026-07-31 16:07:29 +08:00 by iomgaa · 2 comments
Owner

背景

下游项目 dissect(agent 自我进化的受控实验)需要把每次 LLM 调用落成一行审计记录,
其中两列拿不到值。两者的数据都已经存在于 TransportResult.raw 里,只是没有透传到
LLMResponse,所以这是一个纯粹的字段暴露问题,不涉及调用链改造。

缺口一:供应商侧 prompt cache 的命中 token 数

LLMResponse 现有 cache_hit: bool(types.py:26),但它由 middleware/cache.py
PolyGateway 自己的响应缓存命中时置位;middleware/retry.py:428_build_response
里对真实调用一律写死 cache_hit=False。这跟供应商侧的 prompt cache 是两件不同的事:
后者指同一段提示词前缀被服务端复用、按更低单价计费(MiniMax 的缓存读取价约为输入价的
五分之一),它在真实调用里天天发生,而现在的 cache_hit 永远看不见它。

对 dissect 的影响是实质性的:该项目要对比不同实验条件的边际成本,而其中一部分条件会
逐次改变提示词内容、必然降低缓存命中率。如果不能把「缓存命中率差异带来的成本」与
「实验条件本身带来的成本」分开,成本对比就会把缓存策略的伪影当成条件效应。用布尔量
无法做这个校正,需要的是命中的 token 数。

OpenAI 兼容格式把它放在 usage.prompt_tokens_details.cached_tokens

缺口二:API 实际返回的模型版本串

_build_responsemodel=source.model(retry.py:421),即 .env 里配置的别名,
而不是响应体里的 model 字段。两者在供应商把别名指向新权重时会分叉,而这正是 dissect
需要防的情况——它的一个底座模型已被第三方标记为 deprecated,实验快照必须固化调用当时
真正跑的那个版本,否则复现性无从谈起。

建议改法

LLMResponse 尾部追加两个字段,符合 types.py:28 写明的「库新增,只增不删,必带默认值」约定:

cached_prompt_tokens: int | None = None   # 供应商 prompt cache 命中的输入 token 数;None = 该源未上报
model_reported: str | None = None         # API 响应体里的 model 字段;None = 未上报

_build_response 中从 result.raw 取值填入。两者都用 None 表示「这个源没给」,
与「给了但是 0」区分开——对下游而言这两种情况的处置不同:前者要在论文里声明该源不可
做缓存校正,后者是一次真实的零命中。

建议同时考虑:把 cache_hit 的语义在 docstring 里写明是「PolyGateway 响应缓存」,
避免与新字段混淆。改名会破坏兼容,注释成本更低。

影响面

纯增字段,带默认值,不改任何现有调用方。middleware/cache.py:121
LLMResponse(**fields) 走的是全量字段 dict,需确认缓存序列化格式随之更新。

## 背景 下游项目 dissect(agent 自我进化的受控实验)需要把每次 LLM 调用落成一行审计记录, 其中两列拿不到值。两者的数据都已经存在于 `TransportResult.raw` 里,只是没有透传到 `LLMResponse`,所以这是一个纯粹的字段暴露问题,不涉及调用链改造。 ## 缺口一:供应商侧 prompt cache 的命中 token 数 `LLMResponse` 现有 `cache_hit: bool`(types.py:26),但它由 `middleware/cache.py` 在 **PolyGateway 自己的响应缓存**命中时置位;`middleware/retry.py:428` 的 `_build_response` 里对真实调用一律写死 `cache_hit=False`。这跟**供应商侧的 prompt cache** 是两件不同的事: 后者指同一段提示词前缀被服务端复用、按更低单价计费(MiniMax 的缓存读取价约为输入价的 五分之一),它在真实调用里天天发生,而现在的 `cache_hit` 永远看不见它。 对 dissect 的影响是实质性的:该项目要对比不同实验条件的边际成本,而其中一部分条件会 逐次改变提示词内容、必然降低缓存命中率。如果不能把「缓存命中率差异带来的成本」与 「实验条件本身带来的成本」分开,成本对比就会把缓存策略的伪影当成条件效应。用布尔量 无法做这个校正,需要的是命中的 token 数。 OpenAI 兼容格式把它放在 `usage.prompt_tokens_details.cached_tokens`。 ## 缺口二:API 实际返回的模型版本串 `_build_response` 用 `model=source.model`(retry.py:421),即 `.env` 里配置的别名, 而不是响应体里的 `model` 字段。两者在供应商把别名指向新权重时会分叉,而这正是 dissect 需要防的情况——它的一个底座模型已被第三方标记为 deprecated,实验快照必须固化调用当时 真正跑的那个版本,否则复现性无从谈起。 ## 建议改法 在 `LLMResponse` 尾部追加两个字段,符合 types.py:28 写明的「库新增,只增不删,必带默认值」约定: ```python cached_prompt_tokens: int | None = None # 供应商 prompt cache 命中的输入 token 数;None = 该源未上报 model_reported: str | None = None # API 响应体里的 model 字段;None = 未上报 ``` 在 `_build_response` 中从 `result.raw` 取值填入。两者都用 `None` 表示「这个源没给」, 与「给了但是 0」区分开——对下游而言这两种情况的处置不同:前者要在论文里声明该源不可 做缓存校正,后者是一次真实的零命中。 建议同时考虑:把 `cache_hit` 的语义在 docstring 里写明是「PolyGateway 响应缓存」, 避免与新字段混淆。改名会破坏兼容,注释成本更低。 ## 影响面 纯增字段,带默认值,不改任何现有调用方。`middleware/cache.py:121` 的 `LLMResponse(**fields)` 走的是全量字段 dict,需确认缓存序列化格式随之更新。
Author
Owner

已在 v1.1.0 实现,两个字段的最终形态如下。

新增字段

cached_prompt_tokens: int | None = None   # 供应商 prompt cache 命中的输入 token 数
model_reported: str | None = None         # API 响应体里的 model 字段

位置在 LLMResponse 尾部(structured_data 之后),带默认值,逐字段传参的 fake 构造零改动。

None0 是两回事,按 issue 的要求区分开了:None = 该源不上报这个数(下游据此声明本源不可做缓存成本校正),0 = 该源上报了一次真实零命中。网关报文一律不可信,形态异常(负数、字符串、boolprompt_tokens_details 非 dict)一律归 None 且绝不抛异常——可观测字段缺失不得打断调用。

与 issue 描述的一处出入

issue 说「两者的数据都已经存在于 TransportResult.raw 里」——第一个是,第二个不是。raw 只放 {"usage": ...};流式解析的 sink 当时只吸收 usage[DONE] 两样东西,响应体顶层的 model 从来没被捡起来过。所以这不是纯字段暴露,transports/openai_compat.py 也改了:流式取首个有效model(首帧报空串不会锁死后续真实值),非流式取 body 顶层。

报文格式的解析留在 transport 层,TransportResult 上加了同名的两个强类型字段,middleware 只做搬运——没有让调度层去认某一家的报文嵌套结构。

缓存命中行的口径(请注意)

cache_hit=True 的行里,这两个字段是原样回放的历史值,跟 modelprompt_tokens 同一规则(CacheMW 命中时只覆写时延类字段)。所以:

-- 统计供应商缓存命中率,WHERE 条件不可省
SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0)
FROM llm_calls
WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;

漏掉 cache_hit = false 会把回放行重复计数。这和 v1.0.3 里 cost 缺口的口径是同一类坑。

另外按 issue 的建议,cache_hit 的语义已在 docstring 与 wiki 里写明是「PolyGateway 自身的响应缓存」——它和供应商 prompt cache 是两回事(前者压根没联网,后者联了只是对方省了算力)。名字没动,改名会破坏三项目的迁移兼容。

超出 issue 范围的两项(经确认后加的)

  1. 遥测表 llm_calls 同步加了这两列,TelemetryRecorder 端口由 18 字段扩为 20。两个后端在初始化期对已存在的旧表幂等补列——CREATE TABLE IF NOT EXISTS 不会给旧表加列,不补的话每行写入都被逐行 warning 丢掉、遥测静默全失。无需手工迁移,历史行的新列为 NULL。

    两条纪律值得说明,因为它们直接影响升级体验:补列失败只降级为逐行丢弃,不会让 recorder 整体失能(应用账号若只有 INSERT 权限,ALTER TABLE 的 ownership 检查会早于 IF NOT EXISTS 的存在性判断而失败,置位失能就意味着升级后遥测全灭);补列先用 SELECT 探测缺列,只在真缺列时才 ALTER——ADD COLUMN IF NOT EXISTS 即便列已存在也会先取 ACCESS EXCLUSIVE 锁(实测会被一个开着的读事务阻塞),而遥测是内联 await,让每个进程的首次写入都去锁共享审计表等于用记录基础设施拖垮业务调用。稳态下一条 ALTER 都不会发。

  2. PricingTable 支持可选的缓存读取单价 cached_input_per_1m。配了它,cost 按 (prompt - cached) × input + cached × cached_input 分段算,消除系统性高估;不配就退化为全额输入价——库不替你猜折扣率。旧价格表文件零改动。

{"MiniMax-M3": {"input_per_1m": 2.1, "output_per_1m": 8.4, "cached_input_per_1m": 0.42}}

兼容性

纯增字段,不改任何现有调用方。旧格式的缓存条目(缺这两个键)照常可重建为 None、不会回源;缓存命中行的 cost 仍恒为 0.0(未产生新调用),该短路排在任何单价换算之前。

文档已同步:CHANGELOG 1.1.0、wiki 的 参考-公共API / 参考-配置键 / 指南-遥测与成本

已在 v1.1.0 实现,两个字段的最终形态如下。 ## 新增字段 ```python cached_prompt_tokens: int | None = None # 供应商 prompt cache 命中的输入 token 数 model_reported: str | None = None # API 响应体里的 model 字段 ``` 位置在 `LLMResponse` 尾部(`structured_data` 之后),带默认值,逐字段传参的 fake 构造零改动。 **`None` 与 `0` 是两回事**,按 issue 的要求区分开了:`None` = 该源不上报这个数(下游据此声明本源不可做缓存成本校正),`0` = 该源上报了一次真实零命中。网关报文一律不可信,形态异常(负数、字符串、`bool`、`prompt_tokens_details` 非 dict)一律归 `None` 且绝不抛异常——可观测字段缺失不得打断调用。 ## 与 issue 描述的一处出入 issue 说「两者的数据都已经存在于 `TransportResult.raw` 里」——第一个是,第二个不是。`raw` 只放 `{"usage": ...}`;流式解析的 sink 当时只吸收 `usage` 和 `[DONE]` 两样东西,响应体顶层的 `model` 从来没被捡起来过。所以这不是纯字段暴露,`transports/openai_compat.py` 也改了:流式取**首个有效**的 `model`(首帧报空串不会锁死后续真实值),非流式取 body 顶层。 报文格式的解析留在 transport 层,`TransportResult` 上加了同名的两个强类型字段,middleware 只做搬运——没有让调度层去认某一家的报文嵌套结构。 ## 缓存命中行的口径(请注意) `cache_hit=True` 的行里,这两个字段是**原样回放**的历史值,跟 `model`、`prompt_tokens` 同一规则(`CacheMW` 命中时只覆写时延类字段)。所以: ```sql -- 统计供应商缓存命中率,WHERE 条件不可省 SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0) FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; ``` 漏掉 `cache_hit = false` 会把回放行重复计数。这和 v1.0.3 里 cost 缺口的口径是同一类坑。 另外按 issue 的建议,`cache_hit` 的语义已在 docstring 与 wiki 里写明是「**PolyGateway 自身的响应缓存**」——它和供应商 prompt cache 是两回事(前者压根没联网,后者联了只是对方省了算力)。名字没动,改名会破坏三项目的迁移兼容。 ## 超出 issue 范围的两项(经确认后加的) 1. **遥测表 `llm_calls` 同步加了这两列**,`TelemetryRecorder` 端口由 18 字段扩为 20。两个后端在初始化期对**已存在的旧表幂等补列**——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补的话每行写入都被逐行 warning 丢掉、遥测静默全失。**无需手工迁移**,历史行的新列为 NULL。 两条纪律值得说明,因为它们直接影响升级体验:**补列失败只降级为逐行丢弃,不会让 recorder 整体失能**(应用账号若只有 INSERT 权限,`ALTER TABLE` 的 ownership 检查会早于 `IF NOT EXISTS` 的存在性判断而失败,置位失能就意味着升级后遥测全灭);**补列先用 `SELECT` 探测缺列,只在真缺列时才 ALTER**——`ADD COLUMN IF NOT EXISTS` 即便列已存在也会先取 ACCESS EXCLUSIVE 锁(实测会被一个开着的读事务阻塞),而遥测是内联 await,让每个进程的首次写入都去锁共享审计表等于用记录基础设施拖垮业务调用。稳态下一条 ALTER 都不会发。 2. **`PricingTable` 支持可选的缓存读取单价** `cached_input_per_1m`。配了它,cost 按 `(prompt - cached) × input + cached × cached_input` 分段算,消除系统性高估;**不配就退化为全额输入价**——库不替你猜折扣率。旧价格表文件零改动。 ```json {"MiniMax-M3": {"input_per_1m": 2.1, "output_per_1m": 8.4, "cached_input_per_1m": 0.42}} ``` ## 兼容性 纯增字段,不改任何现有调用方。旧格式的缓存条目(缺这两个键)照常可重建为 `None`、不会回源;缓存命中行的 `cost` 仍恒为 `0.0`(未产生新调用),该短路排在任何单价换算之前。 文档已同步:CHANGELOG 1.1.0、wiki 的 `参考-公共API` / `参考-配置键` / `指南-遥测与成本`。
Author
Owner

已随 v1.0.4 发布并合并到 main(486809b,tag v1.0.4)。

版本号取 patch 而非 minor:虽然加了字段、扩了遥测端口、动了表结构,但对下游是零改动——新字段带默认值,旧价格表与旧缓存条目照常工作,遥测表自动补列。

pip install --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
    --extra-index-url https://pypi.org/simple/ "polygateway[redis,postgres,structured]==1.0.*"

文档见 wiki 的 [参考-公共API]指南-遥测与成本(供应商 prompt cache 一节,含命中率 SQL 的口径)。

已随 **v1.0.4** 发布并合并到 main(`486809b`,tag `v1.0.4`)。 版本号取 patch 而非 minor:虽然加了字段、扩了遥测端口、动了表结构,但对下游是**零改动**——新字段带默认值,旧价格表与旧缓存条目照常工作,遥测表自动补列。 ```bash pip install --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ --extra-index-url https://pypi.org/simple/ "polygateway[redis,postgres,structured]==1.0.*" ``` 文档见 wiki 的 [[参考-公共API]](两个字段的定义)与 [[指南-遥测与成本]](供应商 prompt cache 一节,含命中率 SQL 的口径)。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#3