LLMResponse 缺两个可观测字段:供应商 prompt cache 命中 token 数、API 实际返回的模型版本串 #3
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
背景
下游项目 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 写明的「库新增,只增不删,必带默认值」约定:在
_build_response中从result.raw取值填入。两者都用None表示「这个源没给」,与「给了但是 0」区分开——对下游而言这两种情况的处置不同:前者要在论文里声明该源不可
做缓存校正,后者是一次真实的零命中。
建议同时考虑:把
cache_hit的语义在 docstring 里写明是「PolyGateway 响应缓存」,避免与新字段混淆。改名会破坏兼容,注释成本更低。
影响面
纯增字段,带默认值,不改任何现有调用方。
middleware/cache.py:121的LLMResponse(**fields)走的是全量字段 dict,需确认缓存序列化格式随之更新。已在 v1.1.0 实现,两个字段的最终形态如下。
新增字段
位置在
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命中时只覆写时延类字段)。所以:漏掉
cache_hit = false会把回放行重复计数。这和 v1.0.3 里 cost 缺口的口径是同一类坑。另外按 issue 的建议,
cache_hit的语义已在 docstring 与 wiki 里写明是「PolyGateway 自身的响应缓存」——它和供应商 prompt cache 是两回事(前者压根没联网,后者联了只是对方省了算力)。名字没动,改名会破坏三项目的迁移兼容。超出 issue 范围的两项(经确认后加的)
遥测表
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 都不会发。PricingTable支持可选的缓存读取单价cached_input_per_1m。配了它,cost 按(prompt - cached) × input + cached × cached_input分段算,消除系统性高估;不配就退化为全额输入价——库不替你猜折扣率。旧价格表文件零改动。兼容性
纯增字段,不改任何现有调用方。旧格式的缓存条目(缺这两个键)照常可重建为
None、不会回源;缓存命中行的cost仍恒为0.0(未产生新调用),该短路排在任何单价换算之前。文档已同步:CHANGELOG 1.1.0、wiki 的
参考-公共API/参考-配置键/指南-遥测与成本。已随 v1.0.4 发布并合并到 main(
486809b,tagv1.0.4)。版本号取 patch 而非 minor:虽然加了字段、扩了遥测端口、动了表结构,但对下游是零改动——新字段带默认值,旧价格表与旧缓存条目照常工作,遥测表自动补列。
文档见 wiki 的 [参考-公共API]与 指南-遥测与成本(供应商 prompt cache 一节,含命中率 SQL 的口径)。