跳到主要内容

在智能体交接处中断的分布式链路追踪

· 阅读需 12 分钟
Tian Pan
Software Engineer

你打开一个失败运行的追踪(trace)。Span 树非常漂亮:用户请求、规划者 Agent 的推理、三次工具调用、Token 计数、延迟,所有这些都整齐地嵌套在一起。然后规划者交接给一个专家 Agent —— 追踪到此结束。并不是出现了错误 Span。它只是停止了。接下来的内容是来自专家 Agent 的另一个、无根的追踪,它从思考的中途开始,没有父级,没有可见的输入,也与导致它的请求没有任何联系。

Bug 就存在于那个间隙中。一直以来都是如此。交接是一个 Agent 的假设与另一个 Agent 的理解相遇的地方,也是你的追踪无法跟随的唯一地方。

这不是日志记录的问题。你的 Agent 可能在两端都正确地发出了 Span。问题在于追踪上下文(trace context)—— 将 Span 缝合成一个故事的线程 ID —— 没能在从调用者到被调用者的跳转中幸存下来。你技术栈中的每个 HTTP 客户端和 gRPC 存根都会免费传播该上下文。但你的 Agent 交接没有这样做,因为没有人告诉它去这样做。

为什么 Agent 交接会破坏 HTTP 从未破坏的东西

分布式追踪之所以奏效,是因为一个默认的契约。当一个服务进行出站调用时,它会将当前的追踪上下文 注入 到请求中 —— 对于 HTTP,这就是携带追踪 ID、父 Span ID 和采样标志的 W3C traceparent 请求头。当接收服务处理请求时,它会 提取 该上下文,并使其自身的 Span 成为父级的子级。提取、注入、重复。追踪 ID 在每一次跳转中保持不变,Span 树从而自行组装。

你从未编写过这些代码。OpenTelemetry 的自动插桩(auto-instrumentation)库包装了标准的 HTTP 和 gRPC 客户端,因此 traceparent 请求头会在你完全无感的情况下随每个请求一起传输。这就是为什么经过六个微服务的请求能产生一个连贯的追踪:服务之间的边界是一个已知的、经过插桩的关卡。

Agent 交接也是两个执行单元之间的边界。但它看起来不像 HTTP 调用。它看起来像以下几种情况之一:

  • 在同一个进程内调用子 Agent 的 run() 方法。
  • 将消息放入队列,供工作 Agent 稍后处理。
  • 在线程池或异步事件循环上生成一个新任务。
  • 将负载(payload)发送到另一个确实使用 HTTP 的 Agent 服务 —— 但交接内容携带在 JSON 正文中,而不是请求头里。

这些路径中没有一个是经过插桩的 HTTP 客户端。自动插桩无处挂钩。OpenTelemetry 中的追踪上下文存在于上下文本地存储中 —— 线程本地或异步上下文等效项 —— 一旦执行进入新线程、新任务或通过无人插桩的通道进入新进程,该存储就是空的。接收方的 Agent 启动了一个 Span,发现在作用域内没有父级,于是默默地成为了一个新的追踪根节点。

这就是整个 Bug 所在。交接被视为一种 负载 —— 这里是任务,这里是下一个 Agent 完成工作所需的上下文 —— 而它同时也是一个 载体。它必须同时携带任务和追踪上下文,就像 HTTP 请求既携带正文又携带 traceparent 请求头一样。团队痴迷于对负载进行插桩,却完全忘记了载体。

孤立追踪是故障处理中最昂贵的间隙

让我们来看一个真实的事故。用户报告 Agent 删除了错误的记录。你调取追踪记录。规划者 Agent 看起来没问题 —— 它正确地决定将删除请求路由到数据库专家。追踪在交接处结束了。

现在你去寻找专家的追踪记录。你有一个大致的时间戳,所以你扫描那个时间段内专家服务的追踪……发现了四十个,因为系统很忙。哪一个对应这个用户?你无法辨别,因为那个能告诉你答案的东西 —— 共享追踪 ID —— 恰恰被弄丢了。你开始根据时间戳和记录 ID 进行关联,手动重建追踪系统本该自动提供给你的父子链接。

这是做这项工作最糟糕的时机。你正处于事故处理中,数据在变动,而交接负载 —— 专家收到的精确输入 —— 可能根本没有记录在任何地方,因为大家都以为追踪系统已经捕获了它。在多 Agent 系统的复盘中,最有用的问题是“下游 Agent 到底收到了什么?”。孤立的追踪无法回答这个问题。

成本还会累加。子 Agent 协作失败 —— Agent A 向 Agent B 发送了不完整或微妙错误的上下文 —— 是最常见的多 Agent Bug 之一,而且它们 从 B 的角度看是不可见的。调试专家 Agent 的团队看到一个合理的输入和一个输出错误,得出的结论是专家 Agent 坏了。他们调整专家 Agent 的 Prompt。真正的根源其实在更上游:规划者总结了请求并丢掉了一个限定词。如果没有跨越交接的追踪,你就无法看到输入其实早已被污染。你调试了错误的 Agent,发布了一个毫无作用的修复程序,然后 Bug 再次出现。

一个完整的追踪能将一小时的关联工作转化为一次点击:展开交接 Span,查看专家收到的负载,将其与用户的需求进行对比。诊断结果就在那里。而断开的追踪则将其变成了考古工作。

让交付成为载体,而不仅仅是负载

修复方法与 HTTP 自动获得的“提取与注入”规范相同——在框架未进行插桩的边界处手动应用。模式不会随传输方式而改变;改变的只是你放置上下文的槽位。

进程内交付(调用子智能体)。 这是最简单的情况,也是团队最容易出错的情况,因为它看起来并不像一个边界。如果子智能体在同一个异步上下文的同一个线程上运行,OpenTelemetry 的上下文会自然传播——前提是你将子智能体的执行包装在子 Span 中,并且没有启动新的根 Span。这里的故障模式是:子智能体在父级上下文被清除后调用了 tracer.start_as_current_span,或者框架在智能体之间故意重置了上下文。将子智能体的调用设为一个以该智能体命名的显式子 Span——GenAI 语义规范正是为此定义了 invoke_agent {agent.name}——这样调用树就能保持完整。

跨线程或异步交付(线程池、任务派生)。 上下文本地存储不会跨越线程或任务边界。你必须在跳跃之前捕获当前上下文,并在新的执行单元内部重新附加它。大多数 OpenTelemetry SDK 都为此提供了助手工具——例如包装执行器,或将上下文绑定到派生的协程。规则是:如果你在智能体工作周围编写了 executor.submit 或派生了一个脱离的任务,你就已经跨越了边界,你有义务进行显式上下文捕获。

队列或消息总线交付(工作智能体稍后领取)。 这里的边界是完全异步的,完全没有共享内存。将追踪上下文注入到消息本身中——例如消息元数据中的 traceparent 字段——就像 HTTP 将其放入 Header 中一样。工作节点在出队时提取它。一个微妙之处在于:父子 Span 关系意味着父级正在等待子级,这对于“发完即忘”的队列工作来说是不成立的。对于解耦的异步工作,请使用 Span 链接 (Span link) 而不是父子边缘。链接表示“这次运行是由那次运行引起的”,而不声称父级阻塞等待它——这是事件驱动交付的正确语义,之所以支持这种方式,正是因为当没有等待发生时,父子模型是错误的。

跨服务交付(通过 HTTP 调用独立智能体)。 如果交付已经通过 HTTP 进行,讽刺的是,你可能因为手动构建请求而禁用了免费的传播。智能体负载放在 JSON 正文中,请求是用原始客户端构建的,却没有人添加 traceparent Header。要么通过已插桩的 HTTP 客户端路由调用,要么自己将上下文注入到 Header 中。传输层支持它;只是你的代码跳过了这一步。

在这四种情况下,规范是完全相同的:在每次交付时,询问什么承载了追踪上下文,并将其放在那里。 无论是正文还是 Header,Header 还是消息字段,线程本地还是显式捕获——位置在变,但义务不变。

对子调用进行插桩,而不仅仅是智能体

缝合交付是必要的,但还不够。一个跨越了智能体边界但将智能体内部的所有内容扁平化为一个 Span 的追踪,只能算插桩了一半。在智能体故障中,代价高昂的问题通常在更深一层:哪个工具调用失败了,模型在选择该工具之前看到了什么,它经历了多少次重试循环。

将每一层建模为自己的 Span,嵌套在拥有它的智能体之下。GenAI 语义规范为你提供了词汇——invoke_agent 用于智能体运行,execute_tool 用于工具调用,chat 用于 LLM 请求——每个都带有模型身份、Token 计数和工具名称的标准属性。一个格式良好的智能体追踪从上到下依次为:用户请求 → 规划智能体 (Planner Agent) → 交付 Span → 专家智能体 (Specialist Agent) → 它的工具调用 → 每个工具自己的 LLM 子调用 → 最终响应。每个节点都有输入、输出、延迟和成本。交付只是该树中许多 Span 的其中之一,而不是树倒下的断崖。

当工具调用本身触发另一个智能体时(随着工具包装整个子智能体,这种情况越来越普遍),该嵌套调用需要与顶级交付相同的上下文承载处理。边界在递归,规范也应随之递归。

除了调试之外,正确处理这些问题还有真正的回报。完整的追踪也是你的成本账本和评估数据。Token 计数会向上汇总到树中,因此你可以看到单个用户请求如何分发到四个智能体中的九个 LLM 调用,并了解其具体成本。失败运行中的干净追踪是一个现成的评估案例:智能体按顺序看到的每个输入都是可重放的。断开的追踪则什么都不是——它只是一个无法完整回答任何问题的碎片。

一份真正有意义的复盘报告

破碎的追踪与完整的追踪之间的区别,就是两份复盘报告之间的区别。

使用破碎的追踪,复盘报告在原本应该是原因的地方留有一个空洞。你写道“专家智能体删除了错误的记录”以及“规划智能体似乎路由正确”,而在这两句话之间则是无能为力的耸肩。你无法展示专家智能体接收到了什么,因此你无法证明是规划智能体的交付出错了,还是专家智能体的理解出错了。改进措施含糊不清——“在交付周围添加更多日志”——这等于承认你之前是在盲目飞行。

使用完整的追踪,复盘报告就像是在 Span 树中漫步。规划智能体接收到了用户的请求,其中带有限定条件“排除存档记录”。交付 Span 显示规划智能体给专家的摘要中遗漏了该子句。专家智能体收到了一个无限制的删除指令并忠实地执行了它。根因不是专家智能体——而是交付时的有损总结,修复方案很明确:传递结构化约束,而不是散文式摘要。你在几分钟内就找到了原因,因为追踪从未中断。

这就是将追踪上下文视为每次交付的“一等公民”的全部理由。智能体系统正变得越来越层次化,而不是扁平化——规划者调用专家,专家调用包装了更多智能体的工具。每一个边界都是追踪可能断裂的地方,每一次断裂都是一次需要你手动调试的事故。对载体进行插桩,而不仅仅是负载。让交付像 HTTP 请求携带 Header 一样携带 Trace ID。这样,下次在两个智能体的缝隙间发生故障时,追踪将随之而至——而 Bug 将无处遁形。

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