跳到主要内容

反事实日志:通过今天的充足记录,在明年的模型上重放昨天的流量

· 阅读需 14 分钟
Tian Pan
Software Engineer

每个 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.modelgen_ai.request.temperaturegen_ai.prompt.*gen_ai.completion.*——这确实是一个进步。规范已经存在;供应商正开始遵循它们;你可以直接拿来使用。但规范描述的是插槽,而不是你放入其中的内容。如果你的仪表化(instrumentation)记录了一个 JSON 序列化的模板变量包,而模板本身是从不同的提交(commit)热加载的,那么插槽虽然被填满了,但回放依然毫无意义。其核心纪律在于捕捉完全解析后的状态,而不是在属性写入时恰好可用的各个组件。

可回放性拥有一种特定的模式(Schema)

让追踪记录具备可回放性的不是工具。这是一个模式决策:每一个代表模型调用的跨度(span)都要配对一份足以在任何暴露相同接口的模型上重构该调用的输入快照。

一个可行的模式,在 SDK 层(而非应用层)编写一次并强制执行,大致如下所示:

  • Prompt 模板 ID 和版本哈希——不是友好名称,而是渲染时模板内容的内容哈希。名称会被重复使用;哈希不会。
  • 完整渲染后的 Prompt,逐字节记录,正如模型接收到的那样。这是核心字段。存储模板加变量的工具看起来能帮你节省空间,在模板被编辑、渲染逻辑改变、导致两个月后无法确定性地重现渲染结果之前,它们确实是对的。
  • 原始形式的工具调用结果,在任何系统端的摘要或截断之前。如果你的工具返回了 50 KB 的 JSON,就记录这 50 KB。如果你的系统随后在重新喂给模型之前将其压缩成了 2 KB 的摘要,也记录下来——但原始数据是不可商榷的,因为你评估的下一个模型可能有更长的上下文窗口,并且需要原始载荷。
  • 作为内容寻址指针的检索集——片段 ID,加上每个片段的内容哈希,加上嵌入模型版本,加上索引修订版本。在这一步,你不再记录文本,而是开始记录不可变的引用。SafJan 关于向量索引版本控制的文章从合规角度提出了同样的观点:没有版本化内容哈希的向量索引就是一个没有一致性模型的缓存。
  • 完整的采样配置,包括 SDK 默默应用的默认值。没有什么比模型在今天以 1.0 的温度调用更让回放发生偏移的了,只因为你忘了显式传递一个零,而上个月的请求使用了 0.2,因为当时的框架有不同的默认值。
  • 带有完整版本锁定的模型标识符,包括供应商暴露的日期后缀,以及如果你的技术栈分别锁定它们的话,还包括分词器修订版本。“我们使用 Claude”并不是锁定。“带分词器版本 2026-02-14 的 claude-sonnet-4-6”才是。
  • 系统状态(Harness state)——智能体步骤编号、父跨度、重试次数、分支标识符——这些解释了这是多步运行中的哪一次调用。智能体的第三次规划器调用与第一次调用并不是同一个问题,你无法仅从请求体中重构这一点。

注意名单上没有的内容:模型的响应。响应对于分析很有趣,但它不是你回放所需的“输入”的一部分。混淆这两者是团队认为自己拥有可回放日志但实际上并没有的最常见原因。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates