让 LLM 链路追踪真正可排障
作者复盘一次追踪数据齐全却无法定位回归的事故,指出提示词版本缺失、跨度命名混乱、敏感查询明文记录和大字段堆积等问题。有效追踪应能快速关联模型、提示词、检索、工具调用、质量指标与用户群体。
完整中文译文
我们的客服 Agent 在德国企业用户群体中出现了一次回归。这个特定用户群的服务质量有所下降,我打开 trace 存储,以为几分钟内就能找到问题。我们已经接入了 tracing,而且还是我亲自搭建的。
然而,我看到的只是一个包含 28 个 span 的扁平列表。没有一个 span 记录 prompt 版本。同一个服务里的模型调用 span 竟然有三种不同的名称,因为不同的库采用了不同的命名方式。检索 span 直接以纯文本保存了用户的原始查询。还有一个 span 塞进了四 KB 的大块数据,里面装着完整的 prompt 正文。
四十五分钟过去了,我仍然没有找到这次回归的原因。这个应用确实接入了 tracing,但这种 tracing 对解决问题毫无帮助。
正是这次事故促使我彻底重写了 tracing 方式。下面这些,才是凌晨两点当你盯着 trace、手边没有代码,只能观察其结构时,真正能让 trace 发挥作用的东西。
“好的 trace”究竟意味着什么
先暂时忘掉 schema。好的 trace 应该能让你在几秒钟内回答以下问题:
这个用户看到的是哪个 prompt 版本?
哪一次检索最慢?
哪个工具调用失败了?
哪个步骤的质量评分下降了?
这次回归影响的是哪个用户群体?
答案是由哪个模型生成的?
这些正是事故期间你会提出的问题。你的 trace 要么具备能够回答它们的结构,要么没有。“它显示模型被调用了,却没有说明调用的是哪个版本”,与完全没有答案并无区别。我的 trace 里充斥着这种无效信息。
好的 trace 是一棵树,而不是扁平列表
这是我犯下的第一个真正意义上的错误。trace 应该是一棵能够反映实际执行过程的树:
用户请求是根节点。
每个有意义的步骤都是它下面的子节点:planner、每一次检索、每一次模型调用、每一次工具调用、guardrail,以及 evaluator。
工具调用应嵌套在触发它的步骤之下,这样你才能看清因果关系。
扁平的 span 列表并不是 trace。它只是一个硬生生钉上 span id 的日志文件,而且会把你真正需要找到的那个决策点埋没其中。当我改用正确的树状结构后,“究竟是哪个步骤出了问题”便从一场寻宝游戏变成了一眼就能看清的事情。
如果只修一件事,就把 prompt 版本放到每次模型调用上
这是价值最高的一项改动,所以应该最先完成。
每个模型调用 span 都应该携带三个 tag:使用了哪个 prompt、具体是哪个版本,以及在进行 A/B 测试时属于哪个变体。缺少这些信息,你根本无法判断回归是否来自某次 prompt 发布,因为 trace 中没有任何内容能把糟糕的输出与特定版本关联起来。有了这些信息,你只需按版本筛选 trace 存储,导致问题的那次发布就会立刻浮现出来。
德国用户群体的回归正是一次 prompt 变更引起的。我之所以花了 45 分钟才找到原因,恰恰是因为 trace 中没有任何信息说明每位用户拿到的是哪个版本。
把质量评分附加到 span 上,而不只是记录延迟
这是大多数人都会跳过的一项。大多数 trace 只跟踪延迟和错误,而这些指标只能发现基础设施问题。它们完全无法告诉你答案的质量究竟如何。
因此,我现在会对输出运行轻量级质量检查,并将评分直接附加到 span 上:groundedness、faithfulness,或者任何对这条处理路径真正重要的指标。然后通过告警监控每条路径、每个 prompt 版本的评分滚动平均值。延迟告警可以发现服务器崩溃,评分告警则能发现模型在所有延迟图表仍然一片绿色时悄悄变差。过去,第二类问题常常能在我眼皮底下溜走整整一周。
将成本拆分为 reasoning token 和 cache token
如果把所有 token 数量合并成一个数字,你的成本仪表盘就会欺骗你。
reasoning-model token 和 cached token 的行为模式截然不同,把它们混在一起,会掩盖真正推动账单变化的因素。一次 reasoning model 升级,可能在可见答案完全不变的情况下,让每次查询的成本翻倍。如果 span 上分别记录了这些 token,你在问题发生当天就能发现;如果它们全被混在一起,你只能等到账单寄来时才会察觉。另外,应在每次调用发生时立即计算其成本,这样后续的价格变化就不会扰乱历史数据。
在 collector 端脱敏,而不是在 client 端脱敏
trace 特别容易吞进个人数据:用户的原始问题、完整的工具参数,乃至整个 prompt。对于任何受监管的业务,这些数据都不能以原始形式进入 trace 存储。
真正有效的模式是:在 collector,也就是 span 前往存储时必经的那一层完成数据清理,而不能只在应用中处理。使用一致的替换规则,让同一个邮箱地址或姓名始终变成同一个占位符。这样,你仍然可以在 trace 中追踪同一个用户,却永远不需要存储其真实身份。还要把脱敏规则与 tracing 代码放在同一个代码仓库中,并像其他代码一样接受审查。
保持 span 名称稳定
span 名称是所有仪表盘和告警对数据进行分组的依据。如果从一个版本升级到下一个版本时重命名了 span,就会悄无声息地破坏所有依赖旧名称的图表。
因此,应该尽早选定一种命名约定,比如简单的 component-dot-operation,保持小写和稳定,并把重命名视为一项 breaking change,同时更新相关仪表盘。还要留意那些会在升级时悄悄重命名 span 的框架库。固定依赖版本,并在升级时检查 span 名称。
采样尾部,而不是头部
为了节省成本而随机保留百分之一的 trace,听起来很合理,却会悄悄违背 tracing 的根本目的。你搭建 tracing 想要捕获的故障本来就很少见,随机采样会把其中绝大多数直接丢掉。
应该保留真正值得关注的 trace:
所有包含错误的 trace。
所有质量评分较低的 trace。
所有异常缓慢或异常昂贵的 trace。
来自 canary 或实验用户群体的所有 trace。
再从其余平淡、健康的 trace 中随机保留一小部分。
这样一来,事故期间你真正需要查看的那些 trace,才会被保存下来。
糟糕的 trace,一眼看上去是什么样子
如果你的 trace 存在以下任何一种问题,它就会在关键时刻让你失望:
只有一个巨大的 span,所有东西都硬塞在里面。
span 名称随版本变化。
任何地方都没有 prompt 版本。
用户原始输入被直接粘贴到 attribute 中。
所有 token 成本都被揉成一个数字。
Agent 运行过程本应是一棵树,呈现出来的却是一个扁平列表。
棘手之处在于,糟糕的 trace 乍看之下完全正常。直到三周后的凌晨两点,当你成为那个不得不阅读它的人时,它才会让你失望。
我不断重新认识到的教训是:“我们接入了 tracing”和“我们确实能够通过 trace 调试问题”是两种完全不同的状态,而我把它们混为一谈了好几个月。修正树状结构、记录 prompt 版本,以及把质量评分附加到 span 上,就填补了其中的大部分差距。
如果你想把准确的 attribute 名称和 span 结构复制到自己的系统中,这篇文章已经把它们全部列了出来。
如果你曾通过 trace 调试过 LLM 事故,我很想知道,哪个 attribute 最让你庆幸自己当初记录了它。对我来说,毫无疑问是 prompt 版本,其他信息根本无法与之相提并论。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。