Chapter 02 · Mechanism

机制:一棵 trace 树是怎么长出来的

上一章装好了 trace / span 的词汇与三方边界;这一章钻进那棵树底下——它不是日志聚合,是 context propagation 造出来的。

本章你将建立的 schema

  • 那棵树靠 context propagation(traceparent 串 parent_span_id 过 reason→act→observe、异步/流式、多 agent 交接、MCP 边界)造出来;树不是日志聚合器的事后拼接,而是调用时写入的因果链。
  • token 与成本的逐 span 归因:每个 span 自记 gen_ai.usage.*;父 span 在关闭时汇总子 span 的数值,形成 trace 级总量;成本不由 OTel 规范定义,由厂商在 ingestion 时按 usage + 定价表计算。
  • 在三条 instrumentation 路(进程内 SDK/callback、OTel auto、代理网关)与标准层(OTel GenAI semconv / OpenInference / OpenLLMetry,emitter↔backend 分层非竞争)之间做出有依据的选择。

文档教读者"调 SDK 就能追踪 agent 了"——这是结论,不是机制。这一章回答的是:凭什么一次跨多个 LLM 调用、多个工具、甚至多个独立进程的运行,能被拼成一棵连贯的树?答案不是魔法,是一个 29 字节的 HTTP 头。每一节都按同一套问法解剖:通过什么机制达到目标、因此代价是什么、在什么条件下失效。

2.1Context propagation:树怎么被串起来

一棵 trace 树的形成,依赖于每次"调用下一步"时把当前 span 的身份作为 HTTP 头写入请求——这件事叫 context propagation,规范是 W3C Trace Context。

为什么需要它

agent 的一次运行横跨多个函数调用、多个服务、甚至多个进程。如果仅靠日志聚合(按时间戳对齐、靠 user_id 关联),面对并发、异步、重试时就会拼错或拼不上。Context propagation 把"当前 span 属于哪棵树、是树上的哪个节点"这条信息显式写进每个下游调用,因此树的结构在数据产生时就已经确定,不依赖事后的推断或聚合。

底层机制(比文档深一层)

W3C Trace Context 标准(RFC 2019)定义两个 HTTP 请求头:

  • traceparent:格式 version-trace_id-parent_span_id-flags,共 55 字节。其中 parent_span_id 是当前调用者的 span id——下游收到它、把它记为自己的 parent_span_id,树的父子关系就在这一步写入。trace_id 全程不变,保证整棵树共享一个根。
  • tracestate:可扩展的厂商键值对(如 Datadog 写入采样决策),不影响 trace 树结构。

agent 的 reason→act→observe 循环里,context propagation 在三处起作用:

  1. 同进程内的 LLM 调用:SDK 在开始 chat span 时,从 context storage(通常是线程/协程局部变量)读取当前的 traceparent,写入 HTTP 请求头。LLM API 返回时 span 关闭,并把 token 计数写入 attributes。
  2. 异步/流式工具调用:流式响应时,span 在收到第一个 token 时开启(记录 time_to_first_chunk),在流关闭时才结束——这意味着 span 的结束时刻和 HTTP 响应完成时刻对齐,而不是请求发出时刻。异步任务(如通过消息队列触发的工具)需要在消息 payload 里附带 traceparent 头,否则子任务的 span 找不到父节点,成为孤儿 span(这是第 3 章 §s34 陷阱 C 族的根源)。
  3. 多 agent 交接:orchestrator 在把子任务下发给子 agent 时,把当前的 traceparent 附在请求或消息里(HTTP 头、消息元数据,或 JSON payload 字段)。子 agent 解析它、用其中的 parent_span_id 作为自己根 span 的父,于是子 agent 的整棵子树挂进同一条 trace_id 下。从 backend 视角看,这次运行里两个 agent 的工作都在一棵树上,没有断裂。

对于 MCP 边界,MCP 协议本身不强制携带 traceparent,但 OTel GenAI semconv 建议在 MCP 调用的 attributes 里附 mcp.session.id 作为 session 级关联键,并在 HTTP transport 层走标准的 traceparent 头。当 MCP server 也被 instrumented 时,server 侧的 span 就能正确继承 client 侧的 trace context,实现跨 MCP 边界的连续树。未被 instrumented 的 MCP server 会产生一段"盲区"——调用发生了,但 server 侧的执行过程不在树上。

类比 · 带边界声明

Context propagation 像电话转接时报一句"这通电话来自 A"——每次转接都把来源标明,接听方记录下来,事后可以还原整条转接链。边界:电话里的"来自 A"可以伪造;traceparent 同样可以被恶意客户端伪造(把自己的 span 挂进别人的 trace),这在多租户 SaaS 场景是真实的安全面——backend 需要按 auth context 隔离 trace,而不是信任 trace_id 本身。

差旅助理演示:两个 agent,一棵树

假设差旅助理 agent(orchestrator)把订票请求里的 book_flight 工具调用,转交给一个独立部署的"预订子 agent"处理。从 trace 结构的角度:

  1. Orchestrator 开启 invoke_agent span(根,trace_id = T1, span_id = S0)。
  2. 它决策后开启 chat span(parent=S0, span_id=S1)调用 LLM,LLM 回复"调 book_flight",span S1 关闭。
  3. Orchestrator 开启 execute_tool: book_flight span(parent=S1, span_id=S2),并在 HTTP 请求里附 traceparent: ...-T1-S2-01。
  4. 预订子 agent 收到请求,读取 traceparent,开启自己的 invoke_agent span(trace_id=T1, parent=S2, span_id=S3)。子 agent 内部再产生若干 chat / execute_tool span,都挂在 S3 下。
  5. 子 agent 完成后,所有 span 导出到同一个 backend,按 trace_id=T1 查询,拿到完整的一棵树——orchestrator 和子 agent 的工作都在同一视图里,延迟、token、错误可以在树上整体分析。

关键点:两个 agent 是不同进程、可能是不同语言、甚至不同团队部署的——它们能在一棵树上,唯一的纽带是那个 HTTP 头。头没传,树就断。

trace_id = T1(全程不变) Orchestrator 进程 invoke_agent span_id=S0 parent=∅ trace_id=T1 chat S1 parent=S0 execute_tool S2 parent=S0 traceparent T1-S2-01 进程边界 预订子 agent 进程 invoke_agent S3 parent=S2 trace_id=T1 chat S4 parent=S3 execute_tool S5 parent=S3 backend 查 trace_id=T1 → 整棵树 traceparent 未传 → S3 孤儿,树断裂
图 2.1差旅助理 orchestrator 与预订子 agent 通过 traceparent 头共享同一 trace_id = T1,形成跨进程的一棵完整树。注意:两个进程里的 span 能连在一起,唯一的机制是那个 HTTP 头——头没传,子 agent 的 span 就找不到父节点,成为孤儿,整段执行在树上消失。
想一想

差旅助理在订票时,LLM 决定先并发调用 search_flights(机票 API)和 retrieve_policy(RAG 检索),两个工具调用同时在飞。这时 context propagation 怎么保证两个 execute_tool span 都挂在正确的父节点下,而不是互相干扰?

展开答案(先停 10 秒再点)

每个 execute_tool span 在创建时,从当前调用点的 context storage(通常是协程的局部变量,Python 里是 contextvars.ContextVar)读取当前 span 的 id 作为自己的 parent_span_id。只要两个工具调用都在同一个"决策 span"(某个 chat span)的执行上下文里创建,它们就都以该 chat span 为父,互不干扰。

失效条件:如果工具调用被扔进一个新线程或新进程,而没有把当前 context 复制过去(比如用裸 threading.Thread 而非 OTel 的 context-propagating wrapper),新线程就会看到空的 context,导致 span 没有父节点——又是孤儿问题。

2.2归因与上卷:token 和成本怎么汇总到 trace 级

每个 span 自记 billable token;父 span 在关闭时把子 span 的数值加起来;成本不由 OTel 规范定义,由厂商在 span 落库时按 token 数 × 定价表算出。

为什么需要它

一次 agent 运行里可能有 5 轮 LLM 调用,分散在不同的 chat span 里。如果只看单个 span 的 token,无法回答"这次请求总共烧了多少钱";如果只看 trace 级汇总,无法定位是哪个 LLM 调用是成本大户。归因(per-span 记录)+ 上卷(父汇子)这两步合在一起,才让"整体视图"和"根因定位"同时成立。

底层机制(比文档深一层)

OTel GenAI semconv 规定 LLM span(kind = chat)在 span 关闭时写入以下 attributes(v1.41.0,2025-04):

  • gen_ai.usage.input_tokens:本次调用的输入 token 数,是 API 响应里的 billable count(不是估算)。
  • gen_ai.usage.output_tokens:输出 token 数。
  • gen_ai.usage.reasoning.output_tokens:o1 / R1 类模型的思维链 token(v1.41 新增,也是 billable 的,但许多看板会漏掉它)。
  • gen_ai.usage.cache_read.input_tokens / cache_creation.input_tokens:Anthropic / OpenAI 的 prompt cache 对应的 token 数,通常定价更低——要分开记才能算对成本。

父 span(如 invoke_agent)通常不直接调用 LLM,它的 token 数是子 span 的汇总。实现方式有两种:① SDK 在父 span 关闭时自动遍历子 span attributes 求和(OpenInference / Phoenix 这条路);② backend 在 ingestion 时做聚合(LangSmith / Langfuse 这条路,backend 存储结构化 span 树后,查询 API 提供 trace 级聚合视图)。两种方式的结果相同,区别是聚合发生在 SDK 侧还是 backend 侧。

成本为什么不在 OTel 规范里

这是最容易让工程师感到困惑的地方。OTel GenAI semconv(截至 2026-06)没有 gen_ai.cost.* 属性——规范只记 token 数,不记钱。原因是:token 数在 API 响应里是确定的,而同样 token 数的价格在不同账户(有无企业折扣)、不同时间(厂商调价)、不同模型(同厂商不同版本)下都可以不同。把定价逻辑写进规范会让规范追着每次厂商调价跑,所以留给厂商在 ingestion 时算。

实际上,可观测性 backend 在接收到 span 时,会用以下公式在服务端实时计算:

cost-ingestion-pseudo pseudo
# backend ingestion 时执行,不在 SDK 侧
if span.attributes.get("gen_ai.usage.input_tokens"):
    input_tok  = span.attributes["gen_ai.usage.input_tokens"]
    output_tok = span.attributes["gen_ai.usage.output_tokens"]
    model      = span.attributes["gen_ai.request.model"]
    # 用内置的 tokenizer + pricing table 算
    cost = price_table[model]["input"] * input_tok / 1_000_000 \
         + price_table[model]["output"] * output_tok / 1_000_000
    span.derived["cost_usd"] = cost  # 写回,可查询但不在 OTel 协议里
# 若 span 没有 usage(未 instrumented),则用 tokenizer 估算

代价:当厂商没返回 usage(旧 API 或代理拦截后 usage 字段丢失)时,backend 只能用 tokenizer 估算 input,output 则无法在调用前预知,只能在收到响应后重新 tokenize——这层估算有 ±5% 左右的误差,且不计 cache 折扣,导致成本多算。这也是为什么"看 LangSmith 的成本数字和实际账单有出入"——两者用的是同一 token 数据,但定价快照时间点不同,或折扣信息没有同步。

invoke_agent(根) 汇总: input=2450 output=810 tokens chat #1 retrieve_policy 后 input=820 out=210 chat #2 search_flights 后 input=980 out=390 chat #3 book_flight 后 input=650 out=210 父 span 关闭时汇总子 span token Backend · Ingestion 时 token × price_table[model] → cost_usd(OTel 协议不含,backend 算) cache_read / reasoning token 分开计价,漏掉 = 成本多算
图 2.2差旅助理三轮 LLM 调用的 token 在各自 chat span 里归因,父 invoke_agent span 汇总后送 backend,ingestion 时乘以定价表得出 cost_usd。注意:cost_usd 不是 OTel 协议字段,是 backend 的派生字段——这意味着不同 backend 的成本数字可以因定价快照时间点不同而出现差异,不代表 token 数据有误。

2.3Instrumentation 三条路:各自看得见什么

给 agent 加遥测代码有三条路——进程内 SDK/callback、OTel auto-instrumentation、代理网关——它们对代码的侵入程度不同,因此能看见的东西也不同。

为什么需要它

不同团队、不同栈、不同约束下,"怎么把遥测加进去"有本质区别。理解三条路各自的可见性边界,才能在"加了 SDK 但看不到想要的数据"时知道是哪条路的结构性限制,而不是配置问题。

底层机制(比文档深一层)

① 进程内 SDK / callback(代码侵入型)
以 LangChain 为例:框架在每次 LLM 调用前后触发 on_llm_start / on_llm_end 回调,SDK(如 LangSmith 的 tracing SDK)在这两个钩子里创建/关闭 span,并从回调参数里取 prompt、completion、token 数。可见范围:所有框架内部的状态——token 数、prompt 内容、工具名称、中间推理步骤、用户自定义的 metadata。看不见:进程外的调用(如子 agent 在另一进程里)、框架没有暴露回调的内部操作(如某些自定义 chain 不触发标准回调)。代价:代码与框架/SDK 深度耦合;换框架时要重写 instrumentation;如果 SDK 有 bug(如 OpenLLMetry 的 issue #3515:on_llm_end 回调里遗留旧的 gen_ai.prompt 属性名而非 v1.38 弃用后的 gen_ai.input.messages),需要等 SDK 修复才能修正数据。

② OTel auto-instrumentation(声明式)
OpenLLMetry(Traceloop)和 OpenInference(Arize)这类 emitter 库的工作方式:在进程启动时 monkey-patch 目标库(如 openai.ChatCompletion.create),在 patch 里插入 span 的开启/关闭逻辑,通过 OTel SDK 导出到任意 OTLP 兼容 backend。可见范围与①基本相同——因为同样在进程内,能拿到 prompt、token、工具参数。额外优点:通过 OTLP 导出,backend 可以自由切换;一行 traceloop.init() 就覆盖多个框架。看不见:与①相同的跨进程盲区;另外 monkey-patch 在框架升级后可能失效,需要 emitter 库同步更新(这是 OpenLLMetry 捐给 OTel 停滞期间的实际痛点——两套属性名并存,用 gen_ai.prompt 还是 gen_ai.input.messages,不同版本的 emitter 给出不同答案)。

③ 代理网关(HTTP 路径拦截型)
在 LLM API 调用的 HTTP 路径上架一层反向代理(如 Helicone,或自建 LiteLLM proxy),拦截请求/响应并记录。可见范围:HTTP 请求/响应本身——模型名、请求体(prompt)、响应体(completion)、latency、HTTP 状态码。结构性看不见:进程内的推理状态——agent 在这次调用前做了什么决策、选了哪个工具、工具返回了什么、检索召回了哪些文档。代理只看到进出 LLM API 的 HTTP 流量,进程内的一切对它是黑盒。因此,代理网关适合"成本与 latency 监控",不适合"调试 agent 决策链"。代价:额外一跳的网络延迟(通常 1–5ms);网关本身成为单点;Helicone 2026-03 被 Mintlify 收购转维护模式,新项目应评估 LiteLLM proxy 或云厂商 AI gateway 作替代。

新兴第四条:eBPF 进程外追踪(AgentSight,2025)
通过 Linux eBPF 在内核层钩住进程的 TLS socket,在不修改应用代码的情况下抓取 LLM API 的 HTTP/2 流量。优点:防篡改(应用层代码无法关掉它),对已部署的旧代码零侵入。同样看不见进程内推理状态——能看到的和代理网关类似,是 HTTP 层的 I/O,不是 agent 决策链。目前(2026-06)仍是研究原型,生产可用性未知,仅应了解这条路的存在和原理。

三条 instrumentation 路对比
维度 进程内 SDK/callback OTel auto-instrument 代理网关
代码侵入 高(需引入 SDK、配置回调) 低(一行 init) 零(改网络路由)
能看进程内状态 是(token、prompt、工具参数、推理步骤) 是(同上) 否(仅 HTTP 层 I/O)
backend 可换 取决于 SDK 是否输出 OTLP 是(OTLP 原生) 否(锁定网关产品)
失效模式 框架升级破坏回调;SDK bug 产生错误属性名 monkey-patch 失效;两套属性名并存 网关故障 = 全量不可用;看不见决策链
适合场景 需要完整决策链可见性 多框架 + 灵活换 backend 仅要成本/latency 数字,零代码改动
Agent App 进程 LLM 调用逻辑 chain / callback ① SDK/callback 看见推理状态 ② auto-instrument (monkey-patch) 看见推理状态 · OTLP 原生 OTLP OTLP Collector LangSmith Langfuse Phoenix / 自选 ③ 代理网关(HTTP 路径) 仅 HTTP I/O · 进程内不可见 HTTP to LLM API LLM API (OpenAI/Anthropic) 网关自记 cost/latency → 专属 backend(锁定) 锁定风险:在 emitter(①②),backend 可自由换;在网关(③),换网关即换 backend
图 2.3三条路相对 app 的位置,以及 emitter→OTLP→backend 分层架构。注意:进程内 SDK 和 auto-instrument(①②)都走 OTLP,backend 可以自由选择;代理网关(③)把 HTTP 数据记进自己的 backend,换网关就换了整套数据存储——锁定风险在路③,不在路①②。

2.4标准层:emitter ↔ backend,分层非竞争

OTel GenAI semconv、OpenInference、OpenLLMetry 不是三个竞争方案,而是同一技术栈的三层——emitter 库负责产生 span,OTLP 是传输协议,backend 负责存储与展示;锁定风险在 backend 层,不在 emitter 层。

为什么需要它

工程师在比较"用 LangSmith 还是用 OpenInference"时,常常混淆了层次:LangSmith 是 backend,OpenInference 是 emitter 规范,两者不在同一层。理清分层,才能做出有依据的选型,而不是跟着厂商的营销材料走。

底层机制(比文档深一层)

三层的定义与职责:

  • OTel GenAI semconv(gen_ai.*):OpenTelemetry 社区维护的语义约定,规定 LLM span 应该用哪些 attribute 名、值的格式是什么。状态是 Development(2025 年前叫 Experimental),意味着属性名仍在变动(v1.38 弃用 gen_ai.prompt,v1.41 拆 invoke_agent)。这一层规定"叫什么名字"。
  • OpenInference(Arize 维护,openinference.*):OTel 的超集——它在 gen_ai.* 之上额外定义了 LLM / CHAIN / TOOL / RETRIEVER / RERANKER / AGENT / GUARDRAIL / EVALUATOR / PROMPT 等 span kind,以及检索文档、eval 结果等字段。openinference.* 属性可以和 gen_ai.* 共存在同一 span 里。backend 侧的 Phoenix 原生消费 OpenInference,但它也能通过 normalization 层接收纯 OTel GenAI span。
  • OpenLLMetry(Traceloop 维护):一个 emitter 实现库(Py/TS/Go/Ruby),用 OTel SDK 的 API 产生符合 OTel GenAI semconv 的 span,然后通过 OTLP 导出到任意 backend。Traceloop 于 2025-02 提交将 OpenLLMetry 捐给 OTel 社区的 PR,截至 2026-06 仍未 merge(14+ 个月停滞),两套约定仍并存,实务上靠 backend 的 normalization 层映射。
类比 · 带边界声明

这个分层像 MCP 和 LSP 解决 M×N 问题的思路:M 个 agent/框架 × N 个 backend,不加中间层就是 M×N 个集成;加了 OTLP 这层标准传输,变成 M 个 emitter + N 个 backend,只需各自对齐协议。边界:MCP 和 LSP 的 M×N 问题由协议直接解决;OTel GenAI 的问题在于语义(attribute 名)还未收敛——OpenInference 和 OTel GenAI 仍是两套命名,normalization 层只是 workaround,不是真正的 M×N 消除。

为什么捐赠停滞是实务问题

OpenLLMetry 捐赠停滞意味着:OpenLLMetry 在 Traceloop 仓库里用一套属性名产出 span,OTel 社区的 GenAI semconv 在演进时用另一套属性名,两者之间没有官方 mapping。工程师面对的现实是:同一个 trace,如果用 OpenLLMetry 0.28.x 产出,某些字段还是 gen_ai.prompt(v1.38 前的旧名);如果用更新的 emitter,字段是 gen_ai.input.messages。backend 需要同时处理两种 schema 才能正确聚合历史数据——这正是 OpenLLMetry issue #3515 的背景。因此判断锁定风险的正确视角是:emitter 可以相对自由地换,但 backend 决定了你的历史 trace 数据存在哪、用什么 schema 查,这才是真正的换出成本所在。

想一想

一个团队在用 LangChain + OpenLLMetry 把 trace 导入 Langfuse,现在考虑迁移到 Phoenix。他们问:"emitter 要换吗?"——回答这个问题前,需要先区分哪两件事?

展开答案(先停 10 秒再点)

需要区分 emitter(OpenLLMetry,负责在进程里产生 span 并通过 OTLP 导出)和 backend(Langfuse → Phoenix,负责接收、存储、展示 span)。

迁移到 Phoenix 是换 backend,不是换 emitter。OpenLLMetry 通过 OTLP 导出,只需把 OTLP endpoint 从 Langfuse 换成 Phoenix 即可,emitter 代码不用动。需要评估的是:Phoenix 的 schema 和 Langfuse 的 schema 是否有差异(OpenInference vs OTel GenAI),历史数据能否平滑迁移,以及 Phoenix 的 normalization 层对 OpenLLMetry 产出的 gen_ai.prompt 旧属性名有没有兼容处理。

§本章 self-check

先合上教程把答案写下来,再展开对照——直接展开等于把这节当再读一遍。

  1. W3C traceparent 头里有四个字段:version、trace_id、parent_span_id、flags。当差旅助理 orchestrator 把任务交给预订子 agent 时,子 agent 收到这个头,用其中哪个字段的值做什么操作,才能让两个 agent 的 span 落在同一棵树上?
  2. OTel GenAI semconv 规定了 gen_ai.usage.input_tokens,但没有 gen_ai.cost.*。说出两个具体原因,解释为什么把成本定义进规范在工程上行不通。
  3. 代理网关(Helicone 类)和进程内 SDK 相比,有一个结构性的"看不见"——不是配置问题、而是架构决定的。描述这个盲区,并给出一个具体场景,说明这个盲区在实务里会造成什么后果。
  4. 一个工程师断言"OpenInference 和 OTel GenAI 是竞争关系,二者必须择一"。这个说法哪里错了?用分层视角重新描述它们的关系。
答案(先做完再展开)
  1. 子 agent 用 parent_span_id 的值作为自己根 span 的 parent_span_id,同时把 trace_id 原样复制到自己根 span 的 trace_id。这两步一起做,子 agent 的根 span 就挂进了 orchestrator 发起的那棵树(共享 trace_id),并且父子关系指向 orchestrator 里发起这次交接的那个 span。缺一步都会导致树断裂。
  2. 两个原因:① 定价因账户而异——同样 token 数,有企业折扣的账户和 Pay-as-you-go 账户价格不同,规范无法把这个变量固化;② 定价随时间变化——厂商会调价(OpenAI 历史上多次降价),如果规范里写了价格,规范就要跟着每次调价更新,把规范委员会变成了定价跟踪器。此外还有第三个:cache/reasoning token 的折扣比例各厂商不同,统一定义不现实。
  3. 代理网关只能看到 HTTP 层的 I/O(请求体、响应体、status code、latency),看不到进程内部的推理状态。具体场景:agent 在调用 LLM 之前,先做了一次 RAG 检索召回了 3 份文档,然后把这 3 份文档拼进 prompt 再调 LLM。网关看到的只是那次 LLM 调用的请求/响应,检索结果拼进 prompt 这件事对网关不可见。如果 agent 输出错误,调试者在网关日志里看不出"是哪份检索文档带偏了 LLM"——这正是代理网关适合"成本监控"但不适合"决策链调试"的原因。
  4. 这句话混淆了层次。OpenInference 是 OTel 的超集,不是竞争者:openinference.* 属性附加在同一个 span 上,和 gen_ai.* 共存,而不是替换它。它们的关系是"OTel GenAI 规定基础 LLM span 字段 → OpenInference 在此之上补充检索/eval/agent kind 等额外字段"。真正的选择不是"选哪套 semconv",而是"选哪个 backend"(Langfuse 偏向 OTel GenAI,Phoenix 偏向 OpenInference),以及"选哪个 emitter"(OpenLLMetry 产出 OTel GenAI,OpenInference 的 Python SDK 产出 OpenInference)——这两个选择是独立的。