跳到主要内容

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

· 阅读需 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)——智能体步骤编号、父跨度、重试次数、分支标识符——这些解释了这是多步运行中的哪一次调用。智能体的第三次规划器调用与第一次调用并不是同一个问题,你无法仅从请求体中重构这一点。

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

存储争议每个季度都会卷土重来

可重现日志(Replayable logs)的大小大约是仅输出日志(output-only logs)的 10 倍。当有人第一次核算年度可观测性账单时,这就会演变成一场争论。这场争论的形式每次都如出一辙:平台团队想要保留它们;FinOps 审核人员指出,90% 的流量是没人会再读的请求中的完整检索负载;最后的妥协通常是“我们会进行采样”或“我们会截断超过 4 KB 的字段”。

这种妥协是失败的。1% 的采样意味着迁移评估只能针对 1% 的历史流量运行,这也就意味着你无法按租户、按查询类型、按语言或任何足以让用户提交工单的重要维度进行切片分析。截断长字段意味着那些有意义的请求——即那些带有大型检索上下文、大型工具输出、长历史记录的请求——恰恰是那些你无法重现的请求。存储成本的节省是实实在在的,但代价是由于缺乏数据支持,导致本该进行的迁移未能发生。

最终能赢的论据应该是将其定位为针对下一次迁移的保险,而不是当下的一个功能。可重现日志的成本由冷存储支付,那里的字节成本很低。而没有这些日志的代价,则是要花费几个季度的工程时间重新埋点,并在做出任何决策之前等待新流量的累积。一个如果不重新埋点并等待两周就无法回答“新模型上周的表现是否会更好”的团队,等于是在告诉领导层,他们无法在两周内完成模型评估。一旦这种情况确立,供应商切换就会停滞,GPT-5 被搁置在一旁未被评估,接着就会有人发现,那些在早期就做好了埋点的竞争对手交付速度更快。

确实有一些实际策略可以控制账单:

  • 分层保留(Tier the retention):高分辨率、全保真的可重现日志保留 30 天,然后对于长尾数据进行采样和内容寻址(仅保留块哈希值,通过独立的冷归档文件解引用)。
  • 激进地去重(Deduplicate aggressively):提示词模板、系统指令、检索块和工具模式(tool schemas)在不同请求之间具有极高的重复性。“哈希并存储一次”的方法很简单,通常能将数据量再降低一个数量级。
  • 写入时压缩(Compress at write time):对 JSON 负载使用 zstd 压缩通常是个好主意。许多追踪(trace)的熵分布非常适合字典压缩;针对你的流量样本训练一个字典,压缩率会更高。
  • 动静分离(Hot/cold split):保持可重现内容的索引(span ID、请求 ID、内容哈希、标签)处于热状态且可查询。根据需要从冷存储中按需加载(Hydrate)实际的重现负载。你不需要实时在数 TB 的提示词文本中进行 grep;你需要的是通过标签找到 5,000 个请求,并批量提取它们的内容。

这些技术都不神秘。它们是常规的数据工程手段。它们也是“每次预算审查都要花费三个月工程时间去辩解的可重现日志库”与“成本与 APM 账单大致相当的日志库”之间的区别。

隐私足迹变得越来越重,你必须为此做好计划

仅输出日志已经引发了合规方面的担忧;可重现日志则进一步放大了这些担忧。从设计上讲,你现在要保留用户输入的全文、可能包含内部信息的渲染后的系统提示词、检索到的文档内容(可能包含用户今天有权访问但明天没有的数据),以及可能包含电子邮件、地址、内部 ID 和其他受监管字段的工具调用结果。

错误的做法是将其视为一个可以通过在流水线中随意添加脱敏操作来解决的独立问题。正确的理解是,可重现日志从捕获的那一刻起就是一个受监管的数据集,而使其安全与使其有用的原则是一致的:即一个清楚其包含内容的模式(Schema)。

几种能通过审计的模式:

  • 字段级的敏感性分类,写入模式并在写入时强制执行。每个 span 属性和事件字段都带有一个类别——piiconfidentialinternalpublic。存储层级、加密态势、保留期限和访问控制都源自该类别。这与成熟数据平台中已有的数据分类制度如出一辙,只是应用到了新的数据集上。
  • 针对需要保留在重现中但不可读取的字段,使用确定性、不可逆的令牌化(Tokenization)。使用基于每个租户的盐值(salt)对用户的电子邮件进行哈希处理;重现工具仍然会在渲染后的提示词中插入一个稳定的令牌,分析师无法读取该电子邮件,且这种令牌化在数据主体权利请求(DSR)中也能幸存,因为原始数据从未被存储。
  • 匹配数据主体实际权利的每租户保留窗口,而不是为了方便而设置的最长间隔。如果你为欧盟提供服务,你处理个人数据的重现窗口将受限于你的 DPIA(数据保护影响评估)范围以及你在隐私声明中的承诺。重现工具应当拒绝加载任何超过边界的数据。
  • 重现路径上的访问日志,与用于分析读取的访问日志分开。针对新模型重现一年前的请求是一项特权操作,而不是常规查询,它应该留下与数据库导出相当的审计线索。
  • 从捕获点到重现路径的归档数据流。我不断看到的合规失败模式是:平台工程师在一次冲刺中添加了重现工具,而数据分类团队在九个月后审计员询问时才知晓。将数据流纳入设计评审,而不是作为道歉的一部分。

坦率地说,可重现日志比仅输出日志更有价值,也更危险,而答案是对这两个方面都进行投入,而不是假装只有其中一半是事实。

将回放作为一等工程界面

这件事在现在比两年前更重要的原因在于,模型发布的节奏已经大幅压缩。一个基于前沿模型构建产品的团队,现在大约每季度都需要评估一个新的候选模型,而且提出这种要求的不再仅仅是模型团队——他们是产品负责人、财务和安全团队,都在从不同维度询问:“新模型对我们的业务流量来说是否更好、更便宜、更安全?”回放路径是这三个问题的共同答案,而只有当日志在设计之初就考虑到了这一点时,这条路径才会存在。

如果构建得当,回放界面是一个非平台工程师无需提交工单即可使用的工具:一个 CLI 或 Notebook,它接受类似“过去 30 天内,planner 执行步数超过 2 步的 X 组别所有追踪记录”的查询,以及一个类似“温度为 0.3 的 Claude Sonnet 4.7”的目标,并发分发请求,最后生成一个包含成本、延迟和质量评分的侧边对比差异报告。如果构建得很糟,它就只是某个为了某次迁移而写的一次性脚本,除作者外没人能运行,因为一半的输入数据都是临时抓取的。

值得在架构层面区分的是两个极易混淆的概念:我们记录了模型输出了什么(成本低,对事故复盘有用,是大多数团队的做法)以及我们记录了模型看到了什么(成本高,但对那种能带来十倍存储成本回报的迁移工作极其有用,是少数团队的做法)。请审慎选择。后者能让你把模型迁移视作一项常规的工程决策,而非一个跨季度的庞大项目;而让你在需要它的那一天就能用上的唯一方法,就是提前决定对其进行捕获。

References:Let's stay in touch and Follow me for more thoughts and updates