跳到主要内容

你的智能体追踪在撒谎:LLM 智能体的基数、采样与 Span 层级结构

· 阅读需 13 分钟
Tian Pan
Software Engineer

你的链路追踪仪表盘显示 Agent 为了响应用户请求发起了 8 次调用。但实际上,它发起了 47 次。你的头部采样器(Head-based sampler)静默地丢弃了其中的大部分。你保留下来的那些调用在技术上是正确的,但在因果关系上毫无用处——它们是从被父级采样器丢弃的根节点中孤立出来的子 Span。

这并不是可视化层面的 Bug。它是将专为 10 个 Span 的 HTTP 扇出设计的分布式链路追踪基础设施,强行套用到每轮对话生成数百个 Span 的系统上的必然结果。默认的 OpenTelemetry 配置系统性地低估了 Agent 的工作量,而运行这些 Agent 的团队通常直到客户抱怨链路追踪视图中显示“不存在”的延迟时,才会察觉到问题。

Agent 的可观测性并不是微服务可观测性的加强版。它的数据形态不同、故障模式不同,成本曲线也完全不同。如果把它当作一个更复杂的 Web 后端来对待,结果就是你的链路追踪账单在一个季度内翻倍,而你的平均故障诊断时间(MTTD)不仅没有改善,反而变得更糟。

没人预警过你的基数计算难题

从单轮交互开始。传统的 REST 接口可能会扇出大约 10 个 Span:HTTP 处理程序、几个数据库查询、一个缓存读取、一个外部 API 调用。链路追踪工具就是围绕这种形态构建的。尾部采样处理器(Tail-sampling processor)的文档在示例中使用的是 10 个 Span 的链路。默认的存储配额也是基于此假设。Span 存储的定价也是据此校准的。

现在给一个 Agent 增加埋点。一个用于客户支持聊天机器人的合理 ReAct 循环,对于一条用户消息的处理可能如下:意图分类(1 次 LLM 调用)、工具选择(1 次 LLM 调用)、并行工具执行(3 个工具 Span)、工具结果验证(1 次 LLM 调用)、检索(1 次向量数据库查询,1 次重排序调用)、回答草稿(1 次 LLM 调用)、安全检查(1 次 LLM 调用)、后续查询的工具调用(1 次 LLM 调用加上 1 次 API 调用)、最终响应(1 次 LLM 调用)。这大约是 13 个操作。每个操作都会产生一个顶级 Span,再加上 HTTP、序列化和重试的子 Span。现实的计数是:每轮对话 30 到 60 个 Span。

将这个数字乘以一个 5 轮的对话。针对中型部署的公开估算描述了一个典型的轨迹:每天 50,000 条用户消息、200,000 次 LLM 调用、100 万个 Span、400 万个指标数据点、400 MB 日志。那些将 AI 工作负载直接套用到现有 Datadog、Honeycomb 或 New Relic 设置的团队,其可观测性账单增加了 40% 到 200% 不等,具体取决于存储时长和自定义指标。

10 到 50 倍的数量增长还不是最难的部分。难点在于每个 Span 的载荷(Payload)不同。在 OpenTelemetry 语义规范下,每个 gen_ai Span 都需要携带提示词(Prompts)、补全结果(Completions)、Token 计数、模型参数和工具参数。其中每一个属性都可能有数 KB 大小。而传统的 Span 只有几百字节。你不仅在为多出 10 到 50 倍的 Span 买单,你还在为更大的 Span 买单,而大多数链路追踪后端是按属性大小计费的。

为什么头部采样会静默损坏你的 Agent 追踪

头部采样(Head-based sampling)在根 Span 处就决定是否保留整条链路。它速度快、无状态,且成本可预测。它也是大多数 OTel SDK 的默认设置。对于传统服务来说,这没问题:丢失 90% 的健康链路是可以接受的,因为保留下来的 10% 具有代表性。

但对于 Agent 来说,头部采样是具有破坏性的。原因有二。

首先,单次 Agent 运行是一个依赖序列,而不是统计样本。你不想在一次运行中采样 10% 的 LLM 调用——你想要保留下来的那些运行中 100% 的调用,而丢弃掉的那些则一个都不留。任何部分的捕获都会产生一条对实际发生情况撒谎的链路。一个缺失了三个叶子节点的 Span 树不会告诉你 Agent 走了一段计划外的弯路;它只会向你展示一个从步骤 2 直接跳到步骤 7 且没有任何解释的 Agent。

其次,有趣的事件往往是罕见的。响应缓慢、幻觉导致的工具调用、推理循环、成本激增——这些才是你需要的链路,而它们恰恰是你在根 Span 处无法识别的。当采样器看到第一个 LLM 调用时,它无法预知这个 Agent 随后会再进行 45 次调用。应用在双峰延迟分布上的 1% 头部采样器会保留大量的快速链路,而几乎保留不到慢速链路,因为慢速链路始终是少数。

还有一个更隐蔽的失效模式:只有当你的埋点非常规范时,采样才会丢弃整个执行过程而非单个调用。在跨多个框架的 Agent 代码中——例如在 OpenAI SDK 之上运行 LangGraph,再在之上运行自定义工具路由器——上下文传播(Context propagation)经常会断裂。每个框架都会开启自己的链路,因为没有人传递父级上下文。你的采样器会将每个片段视为一个独立的根 Span 并做出独立决策。最终你可能在视图中看到一个保留下来的片段和四个被丢弃的片段,于是你看到的“链路”只是一个孤立的、断连的子树。

设计能在数据保留压力下存活的 span 层级结构

假设你必须丢弃大部分 span。层级结构的设计目标应该是:即使丢弃了部分数据,留下的 span 仍能回答你实际关心的那些问题。

根 span 应该是 Agent 的运行(agent run),而不是 HTTP 请求。这是许多团队将 gen_ai 规范强加到现有服务时最常犯的错误:他们让 Web 处理器作为根 span,导致 Agent 的轮次(turn)变成了原本就有四层深(来自中间件)的树结构中的一个深层节点。应该让 Agent 运行拥有自己的 trace 边界。如果需要关联,可以通过 span link(span 链接)向上连接到 HTTP 请求,但要让 Agent 拥有根 span。

在根 span 之下,在进入细粒度操作之前,请使用三个粗粒度层级:

  1. Turn(回合) — 一个用户消息以及为回答它所做的所有工作。
  2. Step(步骤) — 计划/执行循环(意图 → 计划 → 执行 → 观察)的一次迭代。
  3. Action(动作) — 一个 step 内部的一次 LLM 调用、工具调用、检索或验证。

常见的陷阱是因为每个 step 通常只包含一个 action 就跳过 step 层级。千万不要跳过它。Step span 能让你无需遍历每个叶节点就能回答“Agent 是否陷入了循环?”。Step span 将规划器的自然语言计划作为属性携带,这是当 trace 看起来异常时最有用的上下文信息。如果没有它,你看到的只是一连串没有叙事逻辑的 LLM 调用列表。

Action 层级的工具调用和 LLM 调用应遵循 gen_ai.* 语义约定 —— gen_ai.operation.namegen_ai.request.modelgen_ai.usage.input_tokensgen_ai.usage.output_tokens。这些约定的核心属性在 2025 年中期已趋于稳定,且大多数供应商现在都支持自动埋点。其价值不仅在于可视化,更在于你可以通过一个跨所有 Agent 运行的查询来回答“哪个模型的 p99 工具调用延迟正在退化?”,而无需为每个框架设置自定义属性。

还有一个层级规则:绝不要让重试循环在与主调用相同的层级产生兄弟 span。将重试封装在一个 retry span 中,其子项是各个具体的尝试。否则,来自 LLM 供应商的一波瞬时 429 错误会将一个 action 变成五个并列的 span,导致你的“每回合 LLM 调用次数”指标变得不可靠。

针对 Agent 的尾部采样:保留那些“古怪”的请求

一旦你的层级结构合理了,接下来的问题就是丢弃什么。尾部采样(Tail-based sampling)—— 即在完整的 trace 到达收集器(collector)后再做决定 —— 是唯一适用于 Agent 的采样方式,因为你关心的信号通常只在结束时才出现。

一个行之有效的 Agent 工作负载尾部采样策略包含四个保留原则和一个限流原则:

  • 保留所有错误。 任何状态为 error 的 span,任何超时的工具 span,任何触发内容过滤器的 LLM span。这类量级天然较低。
  • 保留所有离群值。 尾部采样处理器支持延迟和 span 计数策略。保留延迟超过 p99 的 trace,以及 span 数量超过预期 p95 每回合计数的 trace。推理循环和上下文溢出恢复通常属于这一类。
  • 保留所有高成本 trace。 Agent 的成本具有肥尾效应:极少数的运行消耗了大部分的 token 预算。设置一个 gen_ai.usage.total_tokens 阈值策略,确保你不会错过那个消耗了 3 美元、下周得向 CFO 解释的用户回合。
  • 保留所有低评分(low-eval)trace。 如果你运行在线评估器,请将得分作为 trace 属性附加上,并保留任何低于信任阈值的记录。这是事后研究“模型表现异常”唯一可靠的方法。
  • 对健康的 trace 进行限流。 对其余所有内容采用 1–5% 的概率采样策略,这能为你提供足够的基准流量来计算正常路径的 SLO,而不会导致存储膨胀。

尾部采样的成本是真实存在的。收集器必须在内存中缓冲 trace 直到决策窗口关闭,而 Agent 的决策窗口需要以数十秒计,因为 Agent 运行很慢。请规划一个拥有足够 RAM 的尾部采样收集器层,以容纳一个决策窗口内的全量 trace,并将其部署为专用池 —— 不要与接入层收集器混部。在流量高峰期因 OOM(内存溢出)失去尾部采样器,会导致你的后端在三十分钟内悄无声息地退回到 100% 全量采样。

将载荷从 span 中移出

最后一个手段是大多数团队最后才想到、但其实应该最先采用的:大部分成本花在属性(attributes)上,而不是 span 的数量上。一个包含三十个带有完整 Prompt 和 Completion 的 span 的 trace 很容易达到 1MB。而同样的 trace,如果将 Prompt 移至对象存储并按 ID 引用,则只有 20KB。

按照操作复杂程度排序,有三种模式:

在 span 处进行截断。gen_ai.promptgen_ai.completion 属性长度设置硬性上限 —— 每个 2KB 已经很慷慨了。如果你需要完整文本,请在根 span 上每个 trace 记录一次,而不是在每个子 span 上都记录。这不需要新的基础设施,通常能减少 60–80% 的 span 存储账单。

将载荷转移到事件(Events)。 在大多数后端,OpenTelemetry 日志和事件的存储成本比 span 属性低,因为它们不被索引。将完整的 Prompt 作为日志事件发出,并通过 trace ID 与 span 关联。查看器会将它们重新缝合在一起。

将载荷转移到 Blob 存储。 将 Prompt/Completion 的主体写入 S3、R2 或以 trace ID 为键的专用存储中,span 仅附带对象 URL。对于有合规保留要求的团队,这是正确的模型 —— 你可以为 trace 设置较短的生命周期策略(用于查询的热数据),而为载荷设置较长的策略(用于审计的温数据),并且你可以单独对载荷加密,而无需对每个 span 重新加密。

无论你选择哪种模式,都不要再把 gen_ai.prompt 当作一个自由格式的属性。它是一个伪装成元数据的数据载荷,而每个追踪后端都会按元数据的价格向你收费。

当你正确实现这一点时带来的改变

在生产环境中运行 AI 系统,本质上是一个可见性问题。Bug 是随机的,根本原因是涌现的,而调试一个循环了十八次而非三次的智能体 (agent),唯一的方法就是查看全部十八次循环。一个只向你展示其中四次循环的追踪查看器 (trace viewer) 算不上工具——而是一种负担,因为它构建了一个会让你的团队信以为真并据此行动的假象。

投资于“智能体感知追踪” (agent-aware tracing) 的团队往往会趋向于同一个由三部分组成的答案:匹配智能体实际控制流的层级结构、优先考虑异常而非覆盖范围的尾部采样,以及将查询用的元数据与你偶尔需要阅读的文本分开的有效负载处理。这一切都不算新奇。但它要求你停止像在 2018 年那样配置你的追踪系统。

更大的转变在于哲学层面。分布式追踪曾经是一个优化工具——你只有在需要追查 p99 延迟退化时才会开启它。而对于智能体,追踪就是你工程团队的产品界面。它是你了解系统做了什么、为什么这么做,以及明天是否还能信任它的途径。像对待重要事情一样去配置它,因为它确实很重要。

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