反事实日志:通过今天的充足记录,在明年的模型上重放昨天的流量
每个 LLM 团队最终都会收到主管发来的同一封邮件:“Anthropic 发布了新的 Sonnet。用我们的流量跑一下测试,周五前告诉我是否应该切换。”团队打开生产环境的追踪(trace)存储,调取上个月的请求,并针对新模型排队运行——但在运行三小时后,有人发现工具调用环节的差异评分看起来非常离谱。答案是:没有人以原始形式捕捉工具的响应。追踪记录忠实地记录了模型的“回复”,并存储了每个工具返回内容的一行摘要。回放这些请求并不能回放旧模型实际看到的内容;它回放的是一段被严重压缩的投射。迁移评估并不是在衡量新模型,而是在衡量新模型如何与一个不同的现实对话。
这就是我想讨论的失败模式。大多数生产环境的 LLM 日志都是“以输出为导向”的:它们能很好地回答“模型说了什么?”,但只能模糊地回答“模型看到了什么?”。这种不对称性在你需要针对新模型回放历史数据之前是隐形的——到那时,它就成了整个问题的关键,因为日志记录与实际发 送内容之间的差距,正是真实评估与虚假评估之间的差距。
称之为反事实日志(counterfactual logging):今天就捕捉那些你明天询问“如果用另一个模型处理这个完全相同的请求,它会做什么?”时所需的输入。标准不是“我们记录了请求”,而是“我们可以针对不同的模型重新执行该请求,并确信结果是有意义的”。
你记录的内容与模型看到的内容之间的不对称性
LLM 应用的默认观测立场将模型调用视为 HTTP 请求:记录输入、记录输出,或许还有耗时。这对于你想了解模型输出了什么的“事后分析”很有效。但当你想要重新运行请求时,这种方式就会崩塌,因为生产环境中的模型调用并不是真正的一次性输入。它们是更深层组合的可见表面。
一个真实的生产环境调用是模型所见输入的一个堆栈,其中大部分是在请求时构建然后被丢弃的:
- 特定版本的 Prompt 模板,带有特定修订版本的系统指令、角色定义、格式规则和 few-shot 示例。模板每周都会变化。一小时前上线的版本可能已经不在你的仓库中了。
- 插入到该模板中的变量,来自用户状态、检索到的记忆、A/B 测试分配标志、区域设置等。模板是菜谱;渲染后的 Prompt 是菜肴,而菜肴才是模型真正吃掉的东西。
- 检索出的片段(chunks),来自向量数据库或 BM25 索引,加上决 定哪些片段符合条件的权限过滤器。片段有内容,内容有历史——如果文档被编辑过,昨天的第 427 号片段就不是今天的第 427 号片段。
- 工具调用结果,在缝合回对话之前通常经过处理。一个
search_orders调用可能返回了一个 12 行的表格,由于 Token 预算限制,系统将其转换为四行摘要。 - 采样参数——温度(temperature)、top_p、停止序列、max_tokens、JSON 模式标志、结构化输出模式——这些通常在代码中默认为缺省值,且从未序列化到追踪记录中。
- 模型标识符和供应商快照,包括托管模型的日期后缀(供应商会悄悄地对其模型字符串进行版本控制)以及分词器(tokenizer)修订版本,分词器本身就是一个隐藏输入,决定了“Prompt”到底意味着什么。
OpenTelemetry GenAI 语义规范为其中大部分属性拼写出了命名空间——gen_ai.request.model、gen_ai.request.temperature、gen_ai.prompt.*、gen_ai.completion.*——这确实是一个进步。规范已经存在;供应商正开始遵循它们;你可以直接拿来使用。但规范描述的是插槽,而不是你放入其中的内容。如果你的仪表化(instrumentation)记录了一个 JSON 序列化的模板变量包,而模板本身是从不同的提交(commit)热加载的,那么插槽虽然被填满了,但回放依然毫无意义。其核心纪律在于捕捉完全解析后的状态,而不是在属性写入时恰好可用的各个组件。
