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