跳到主要内容

你的网关在 LLM 调用与工具执行之间丢失的 traceparent 请求头

· 阅读需 13 分钟
Tian Pan
Software Engineer

一名用户反馈 Agent 回答正确,但数据库从未更新。你打开可观测性工具,搜索用户端对话中标记的 trace ID,发现了一个清晰的树状结构——五次 LLM 调用,四次工具决策,一个最终回答。没有任何错误。接着你搜索负责数据库写入的工具服务,发现了另一个 trace,虽然墙钟时间窗口相同,但 trace ID 不同,根 span 不同,且没有关联回溯。你搜索网关日志。又发现了三个孤立追踪(orphan traces)。在聊天 UI 中看起来像是单次连贯交互的 Agent 运行,在你的追踪后端却分裂成了一片森林。

本应将这一切串联起来的请求头是 traceparent。它是一个 55 字节的 W3C 标准字符串,分布式系统中的每个 span 都用它来识别其父节点。然而,在大多数生产环境的 LLM Agent 技术栈中,它在用户请求与用户真正想要的副作用(side effect)之间,至少会被丢弃一次。

大多数团队发现这一点的方式都如出一辙:在一次事故中,Agent 的行为难以重构,因为他们预期中应当从头读到尾的追踪树,实际上是四个无法关联的独立树。修复方法并不复杂,但这种失败模式是结构性的——它在 LiteLLM、Bifrost、自定义 Node 网关、OpenAI Responses API 以及任何未显式提取入站追踪上下文的工具服务中都会以同样的方式出现。

链路在哪里断裂

W3C Trace Context 规范定义了两个请求头——traceparenttracestate——任何具备 HTTP 感知能力的系统都应该转发这些头信息以参与追踪。在同步 Web 应用中,链路很短,大多数打点库(instrumentation libraries)会自动处理。但在 Agent 循环中,链路比看起来要长,且大多数跳转并不是规范所假设的那种同步 Web 调用。

一次典型的 Agent 轮次至少涉及四个边界。编排器向 AI 网关发送聊天补全请求。网关将请求转发给模型供应商。模型返回工具调用指令。编排器将这些指令分发给一个或多个工具服务。每个工具服务本身可能还会调用其他内部服务。来自工具服务的响应返回给编排器,编排器再将其输入到下一次模型调用中。在单次用户轮次中,这样的过程可能会重复三到四次。

为了让追踪在整个循环中保持连贯,traceparent 请求头需要沿四个方向传输:编排器→网关、网关→供应商、编排器→工具以及工具→下游服务。在实践中,请求头通常在第二个和第三个边界发生泄漏。

网关边界泄漏是因为大多数 AI 网关将发往模型供应商的请求视为一个全新的连接,而不是入站追踪的延续。例如,Bifrost 虽然会提取入站 W3C 上下文并正确链接其内部 span,但在向 OpenAI 或 Anthropic 发起出站调用时,却不会将该上下文注入到新请求中。LiteLLM 默认运行在一个允许列表上,出于安全考虑会剥离大多数客户端请求头,而 traceparent 并不在默认转发列表中。Portkey 支持转发,但前提是你必须显式为该路由配置请求头转发。而公司内部编写的自定义 Node 或 Python 网关几乎从不传播它,因为编写转发逻辑的开发人员在考虑到 AuthorizationContent-Type 后就停止了。

工具分发边界泄漏的原因则不同。工具调用通常通过消息队列、无服务器调用或工作线程池进行分发。在 OpenTelemetry SDK 中,HTTP 上下文传播大多是自动的,但队列传播则不然——每个生产者都必须将上下文注入到消息头中,而每个消费者在启动其 span 之前都必须先将其提取出来。一旦任何一方遗漏,消费者的第一个 span 就会变成一个新的根节点。

为什么响应端让情况变得更糟

还有第二种更隐蔽的失败模式加剧了第一种:响应端(response leg)。W3C 规范主要关于请求侧的传播。大多数服务器框架不会在响应中回显 traceparent——规范并不要求这样做,OpenTelemetry 打点库也不依赖响应来携带它。

当 LLM 网关缓冲一个请求并将其转发给模型供应商,而返回的响应身份与编排器预期的不同时,这就成了一个问题。模型供应商的 request-id(Anthropic)或 x-request-id(OpenAI)会记录在响应中,但并没有 trace ID 能让你将编排器视角的请求与供应商视角的同个请求关联起来。Anthropic 和 OpenAI 并不参与你的追踪。

对于大多数团队来说这没问题——供应商是一个黑盒——但这改变了你思考追踪结构的方式。你的追踪应当将供应商调用视为一个叶子节点 span,由你的网关在响应到达时关闭。如果网关生成了父 span,它必须显式地生成并显式地完成。如果网关正在转发流式响应,父 span 需要保持开启直到流结束,而不是初始 HTTP 交互完成时。如果流式 Agent 响应的父 span 在 200 OK 时就关闭了,而实际的 token 又持续流式传输了 30 秒,那么这 30 秒内产生的每一个工具调用在追踪中都会显示为聊天补全的兄弟节点,而不是子节点。

异步工具调用与队列边界

OpenTelemetry GenAI 语义规范在过去一年中已经定型,定义了一套清晰的 span 词汇表:create_agentinvoke_agentinvoke_workflowexecute_tool。每一个 execute_tool span 都应该携带 gen_ai.tool.call.argumentsgen_ai.tool.call.result、耗时、重试次数和错误状态。Span 类型为 INTERNAL。这个约定非常明确。

规范无法强制执行的是当工具在不同进程中运行时父子 span 的链接。如果你的编排器通过 SQS 或 Kafka 主题调度 execute_tool,那么实际运行工具的消费者工作线程必须负责将上下文注入到队列消息中,并在另一端提取它。这种交接的两端都是应用程序代码。OpenTelemetry SDK 可以协助你完成这项工作,但它无法替你完成,因为队列 API 的接口过于多样,无法进行通用的自动插桩 (auto-instrumentation)。

这同样适用于线程池、asyncio 任务和 Lambda 调用。它们中的每一个都是一个上下文边界,OTel SDK 虽然提供了钩子,但这些钩子需要手动接入。如果你的工具服务运行在由 Celery 任务启动的工作进程中,而该任务又是由 Webhook 触发的,你就经历了三个跳步 (hops),除非有人编写了传播器 (propagator),否则其中任何一步都不会传播追踪上下文。其结果就是经典的智能体可观测性反模式:每个异步工具调用都会发出一个带有父 ID 的 span,但该 ID 在语料库中并不存在,因此该 span 在你的追踪后端变成了孤儿根节点 (orphan root)。

针对追踪存储的一个有用调试查询是:在五分钟窗口内统计每个 agent.session.id 的唯一根 span 数量。一个健康的智能体运行应该在每一轮对话 (turn) 中产生一个根 span。如果你看到每一轮产生四个或八个根 span,说明你至少有三个断裂的传播边界,仅凭追踪数据无法还原智能体的行为。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates