Agent Trace 与 OTLP:让 AI Agent 的黑箱变成调用链

33 min

上个月同事跑来求助:他让 Agent 修一个测试,Agent 很自信地回复”已经修好了,测试全部通过”。实际上它把测试的断言删掉了——测试确实通过了,因为什么都没测。

问题在哪里出的?Agent 读了错误日志,调了模型,改了文件,跑了测试——每一步看起来都正常。但中间有一个瞬间,模型把”测试失败”理解成了”测试太严格”,于是决定”放宽断言”而不是”修复代码”。等同事发现时,错误已经被覆写,没有任何记录能回溯 Agent 当时到底看到了什么、怎么做的判断。

这就是 Agent 调试最痛苦的地方。一次简单的任务,内部可能跑了十几次模型调用和工具调用。如果只看最后的回复,永远不知道中间哪一步拐错了弯。Agent 的执行过程需要被记录下来,变成一棵可复盘、可检索的调用树。 这就是 Trace。

写这篇文章时,OpenTelemetry 针对 LLM 场景的 Gen AI 语义约定 还在快速迭代,但 trace/span 的基础模型已经稳定。LangSmith、Arize Phoenix、Honeycomb、Datadog 等平台也都在围绕 Agent 可观测性做各自的探索。本文从问题出发,先讲清楚 Trace 和 OTLP 是什么、为什么重要,再落到三个真实场景里说明到底怎么用。

一、Trace:把 Agent 的每一步都记下来

1.1 日志和 Trace,差别在哪

Agent 的普通日志大概长这样:

read file: error.log
call model: success
edit file: src/foo.ts
run command: pnpm test
call model: success

这不是没用,但缺了最关键的两样东西。第一是结构——不知道哪些动作属于同一批,不知道谁先谁后,不知道模型第二次调用是因为失败了重试还是正常往下走。第二是上下文——模型调用前看到了什么输入?工具调用用了什么参数?失败时抛了什么错误?

Trace 换个思路:把一次完整的 Agent 任务建立成一棵执行树。 每个动作是一个 span,span 知道自己的父节点、花了多久、有没有失败、带了什么关键信息。

flowchart TB
    Run["agent.run<br/>修复测试失败"] --> Plan["agent.plan<br/>决定先读日志"]
    Run --> Read["tool.call<br/>read_file(error.log)"]
    Run --> LLM1["llm.call<br/>分析失败原因"]
    Run --> Edit["tool.call<br/>edit_file(src/foo.ts)"]
    Run --> Test["tool.call<br/>pnpm test"]
    Test --> LLM2["llm.call<br/>根据测试结果修正"]
    Run --> Final["agent.finalize<br/>生成回复"]

有了这棵树,就能回答之前回答不了的问题:测试失败后的那一次 LLM 调用到底做了什么决定?工具调用如果失败了,是文件不存在还是传错了参数?整棵树里最慢的一步花在哪里?

1.2 Span 怎么拆

把 Agent 动作拆成 span 时,通用的几类:

Agent 动作Span 名称记录什么属性
一次完整任务agent.run任务类型、session ID
任务规划agent.plan规划步数、规划来源
模型调用llm.callprovider、模型名、输入 token 数、输出 token 数
工具调用tool.call工具名、成功/失败、错误码
检索资料retrieval.query来源、top_k、命中数
子 Agent 委托agent.delegate子 Agent 名、委托原因

一个很容易踩的坑:把文件路径、用户输入甚至 prompt 内容直接写成 span name。比如 tool.call read_file src/content/agent-trace.md——这会导致每次调用的 span name 不一样,后端根本没法聚合分析。

正确的做法是 span name 保持稳定,变化的信息放 attributes:

✅ span name: tool.call
   attributes: { tool.name: "read_file", file.extension: "md" }

❌ span name: tool.call read_file /home/user/project/src/foo.ts

Span name 稳定,才能回答”在所有任务里,read_file 这个工具的平均耗时是多少”。名字一变,连聚合都做不了。Agent 场景里用户输入天然发散,高基数问题比传统后端严重得多,这个原则尤其重要。

1.3 Trace ID、Span ID 和多轮对话的关系

这三个概念很容易搅在一起,但它们的分工其实很清楚。

trace_id 标识一次完整的 Agent 任务。 比如”帮我修复 src/auth.ts 的登录超时 bug”——从用户发出这条消息,到 Agent 读文件、调模型、改代码、跑测试、给出最终回复,整个过程共享同一个 trace_id。这条 trace 里所有的 span,trace_id 都一样。

span_id 标识 trace 内部的每一个具体操作。 同一条 trace 里,“读错误日志”是一个 span,“第一次 LLM 调用”是另一个 span,“运行测试”是又一个 span——它们 span_id 各不相同,但 trace_id 相同。

parent_span_id 把 span 串成树。 每个 span 记录自己是谁的子节点。“分析失败原因”这个 span 的 parent 是”agent.run”,“运行测试”的 parent 也是”agent.run”。后端根据 parent_span_id 把整棵执行树还原出来。

flowchart TB
    subgraph "trace_id: abc123 | 任务:修复登录超时"
        Root["span_id: 001 (根)<br/>agent.run<br/>parent: 无"] --> Plan["span_id: 002<br/>agent.plan<br/>parent: 001"]
        Root --> LLM1["span_id: 003<br/>llm.call: 分析错误<br/>parent: 001"]
        Root --> Tool1["span_id: 004<br/>tool.call: read_file<br/>parent: 001"]
        Root --> LLM2["span_id: 005<br/>llm.call: 生成修复方案<br/>parent: 001"]
        Root --> Tool2["span_id: 006<br/>tool.call: run_tests<br/>parent: 001"]
        Tool2 --> LLM3["span_id: 007<br/>llm.call: 测试失败,修正<br/>parent: 006"]
    end

多轮对话怎么处理? 这是最容易搞错的地方。一次多轮对话可能包含多次 Agent 任务——第一轮问”这个仓库的结构是什么”,第二轮说”帮我修一个 bug”,第三轮说”刚才的修复再加个测试”。每一轮是一个独立的任务,应该各有一个独立的 trace_id。它们之间的关联不靠 trace_id 共享,而是靠一个额外的 session_idconversation_id 属性。

flowchart LR
    subgraph "session_id: sess_42"
        subgraph "trace_id: t_001"
            T1["第 1 轮<br/>'仓库结构是什么'<br/>agent.run → tool.call → llm.call"]
        end
        subgraph "trace_id: t_002"
            T2["第 2 轮<br/>'帮我修一个 bug'<br/>agent.run → plan → tool → llm → tool → llm"]
        end
        subgraph "trace_id: t_003"
            T3["第 3 轮<br/>'刚才的修复再加个测试'<br/>agent.run → llm.call → tool.call"]
        end
    end

这个设计有两个好处。第一,每轮任务的 span 树独立、清晰——第二轮修 bug 的 trace 不会和第一轮问结构的 trace 缠在一起。第二,按 session_id 可以串联整段对话——想知道”这个用户在对话里总共调了多少次模型”,跨 trace 按 session_id 聚合就行。

反过来,如果把三轮对话都塞进同一个 trace_id,这棵 span 树会变成一个巨大的扁平结构——三轮的 agent.run span 挤在一起,看不清谁属于哪一轮。trace 的边界应该和”一次独立的 Agent 任务”对齐,而不是和”一整段对话”对齐。

二、OTLP:把 Trace 送出去的标准管道

2.1 有 OTLP 和没有 OTLP 的区别

Trace 在应用里创建了,但总要送到某个地方——本地文件、远端平台、自建的 Collector。OTLP 解决的就是这个”送出去”的问题。

OTLP(OpenTelemetry Protocol)是一套标准的遥测数据传输协议,规范文档 详细定义了 trace、metric、log 三种数据的编码和传输方式。它要解决的局面其实很简单——让应用只输出一种格式,让后端只接收一种格式,中间的传输、路由、存储由基础设施处理。

没有 OTLP 的时候:

flowchart LR
    Agent["Agent 应用"] -->|"SDK A 私有格式"| BackendA["后端 A"]
    Agent2["另一个 Agent 应用"] -->|"SDK B 私有格式"| BackendB["后端 B"]

每个可观测性平台有自己的数据格式。换后端就要换 SDK,换 SDK 就要改埋点代码。Agent 框架迭代快,半年可能换两轮——如果 trace 代码跟着动,基本跟不住。

有了 OTLP 之后:

flowchart LR
    Agent["Agent 应用"] -->|OTLP| Collector["Collector / Backend"]
    Collector -->|OTLP| Jaeger
    Collector -->|OTLP| Tempo
    Collector -->|OTLP| Phoenix
    Collector -->|OTLP| Datadog

应用只输出 OTLP,后端只消费 OTLP,中间自由组合。这个解耦在 Agent 领域的价值比传统后端更大——传统后端的框架和技术栈相对稳定,Agent 这边框架、模型、工具协议都还在快速变化,可观测性这层稳定下来,上面怎么换都不影响。

2.2 数据在路上丢了怎么办

OTLP 有一个容易踩的坑:它只管”我交给你的那一跳”,不管”从起点到终点的全程”。

这不是设计缺陷,而是有意为之。OTLP 的定位是传输协议——它定义的是”两个节点之间数据怎么编码、怎么发、怎么确认”,不定义”数据最终存在哪里、怎么保证不丢”。如果 OTLP 要求端到端确认,就要等 Trace 后端写盘之后才给 Agent 回 ACK——那整条链路会变成同步等待,Agent 每发一个 span 都要堵在那儿等几百毫秒,应用性能直接崩掉。

所以它把可靠性拆给了每一跳各自负责。Agent 负责把数据交给本地 Collector 并收到确认,至于 Collector 之后怎么转发、怎么落盘、怎么容灾,那是 Collector 和基础设施的事。这个分工和 TCP 的思路类似——TCP 只保证数据从 A 机器的网卡到了 B 机器的网卡,不管 B 机器的应用程序有没有 crash。

打个比方。寄一个包裹,经过三个中转站才到家。快递公司保证的是:每个中转站在收到包裹后会给上一个站回一个”收到了”的确认。但如果第三个中转站收到包裹后着火了——前两个站不会知道,寄件人也不会知道,因为每个站只对”上一跳”负责。

OTLP 的数据传输就是这个模型:

flowchart LR
    App["Agent 应用<br/>(寄件人)"] -->|"第 1 跳 ✅"| Local["本地 Collector<br/>(中转站 1)"]
    Local -->|"第 2 跳 ✅"| Gateway["网关 Collector<br/>(中转站 2)"]
    Gateway -->|"第 3 跳 ❌"| Backend["Trace 后端<br/>(收件人)"]

Agent 应用把 trace 数据发给本地 Collector,收到了确认——此时应用以为”发出去了”。但实际上数据可能卡在网关 Collector 的队列里,可能在网络断开时被丢弃,可能在第三跳失败后永久丢失。OTLP 承诺的是每一跳的送达,不是全程的送达。

这对 Agent 意味着什么?Agent 经常在临时环境里运行——本地笔记本、CI 容器、短生命周期的任务 Pod。任务跑完进程就退出,如果数据还在队列里没 flush,直接没了。

所以落地时有三件事比较关键:

  1. 进程退出前主动 flush。不要让 Agent 进程说退就退——退出前显式调用 SDK 的 shutdown,等 exporter 把缓冲区里的最后一批数据发完。
  2. 开发阶段先验证链路。 不要一上来就接远端后端。先在本地跑一个 Collector,用它的控制台输出确认 trace 数据确实从应用发出去了、格式是对的、属性是完整的。
  3. 生产环境不要把 Collector 和应用放在同一个进程里。 Collector 应该独立部署,有自己的生命周期和容错机制。Agent 实例来了又走,Collector 一直站在那里收数据。

OTLP 的重发机制也会带来一个具体问题:重复 span。

过程是这样的:Agent 应用把一批 span 发给 Collector,Collector 收到后回一个确认。但如果网络在确认返回的路上断了——Agent 没收到确认,不知道 Collector 到底收到了没有。为了不丢数据,Agent 唯一的合理选择是把同一批 span 再发一次。Collector 收到后,这批 span 已经在库里了,于是出现了重复。

每条 span 在创建时都有一个全局唯一的 span ID。利用这个 ID,存储端在写入前去重——“这个 span ID 已经存在,跳过”。去重逻辑很简单,但没有 span ID 就做不了。

2.3 语义约定:让 Span 可以跨团队共享

OTLP 解决了传输格式,但还有一个问题:不同团队写的 Agent,同一个操作可能叫不同的名字。管工具调用叫 tool.callfunction.invoke 还是 action——名字不统一,跨团队查询和聚合就没法做。

OpenTelemetry 的 语义约定(Semantic Conventions) 定义了常见操作的 span 命名和属性规范。LLM 场景的 Gen AI 语义约定 还在 experimental 阶段,但几条基本原则已经明确:

  • Span name 稳定llm.call,不是 llm.call.gpt-5.5.fix-bug。变化的信息全放 attributes。
  • 属性用命名空间前缀llm.modeltool.nameagent.task.type。用点分隔层级,后端按前缀聚合。
  • 错误用 span status 表达,不在 span name 里加后缀。
  • 敏感内容默认不入 span:用户输入、prompt 正文、工具返回不存为 attribute,需要时走独立的、可开关的记录通道。

这些原则的价值在日常工作中才体现出来。比如排查”所有 Agent 任务里,哪个工具最容易失败”,需要的是稳定的 tool.name 属性,而不是翻几千条不一样的名字。

三、记录策略:该记什么、不该记什么

Agent Trace 最容易在接入初期失控——完整 prompt、完整回复、工具参数、检索文本、文件内容,什么都想打进 span。保守一点更可持续。

必须记录

类型原因
模型名称和 provider低基数,按模型聚合成本、延迟、成功率
token 数Agent 成本分析的核心指标
工具名低基数,按工具维度做聚合
工具成功/失败最直接的故障信号
错误码和错误类型区分超时、权限、参数错误

默认不存

类型原因
完整 prompt可能含业务数据、密钥、PII
完整工具返回同上,体积还大
用户原始输入脱敏后记摘要
文件路径放 attribute 不放 span name

Agent 经常接触真实代码、文档和用户数据。Trace 是调试工具,不应该变成泄露面。如果确实需要记录 prompt 做深度调试,用显式开关控制,只在本地或受控环境打开。生产环境更适合记 hash、长度、token 估算值——有了 hash,至少能判断”这次和上次用的是不是同一个 prompt”,排查 prompt 漂移问题够用了。

四、实践案例:Trace 怎么帮 Agent 变好

概念讲完了,这一章用三个场景说明 Trace 的实际价值。它们不是”出了故障去翻 trace”的故事,而是”trace 提供了数据,推动了设计和行为的改进”。

4.1 案例一:找到 token 消耗的黑洞

场景:一个代码审查 Agent,输入 PR 链接,读取 diff,分析代码质量,给出修改建议。运行几周后,用户反馈”变慢了,质量也不如以前”。

没有 trace 时,只能看到最终回复变差了,但不知道是哪一步出了问题。

接入 trace 后,一次典型任务的执行树:

flowchart TB
    Run["agent.run<br/>代码审查 PR #342"] --> Read["tool.call: read_pr_diff<br/>耗时: 1.2s"]
    Run --> LLM1["llm.call: 分析 diff<br/>token_in: 8400<br/>token_out: 1200<br/>耗时: 8.5s"]
    Run --> Search["tool.call: search_related_code<br/>耗时: 2.1s"]
    Run --> LLM2["llm.call: 结合上下文审查<br/>token_in: 32000 ⚠️<br/>token_out: 800<br/>耗时: 18.3s"]
    Run --> LLM3["llm.call: 生成修改建议<br/>token_in: 28000 ⚠️<br/>token_out: 1500<br/>耗时: 14.2s"]

问题一目了然:LLM2LLM3token_in 分别暴涨到 32000 和 28000,远超 LLM1 的 8400。追踪到 search_related_code——它把搜索结果(相关文件的完整内容)原封不动地追加到了后续 LLM 调用的上下文中,没有截断、没有摘要。

优化:给 search_related_code 加了结果截断(最多返回 5 个片段,每个不超过 500 字符),传给 LLM 之前加了一层摘要。两轮调整后,同类任务平均 token 消耗从 ~70000 降到 ~20000,LLM2 的延迟从 18 秒降到 5 秒。

这里 Trace 的价值不是”事后翻旧账”,而是精确指出了浪费发生在哪一步。没有分步的 token 属性记录,只能模糊地感觉”好像变慢了”;有了分步数据,一眼就知道哪个 span 是黑洞。

4.2 案例二:发现 Agent 在无效循环

场景:一个研究助手 Agent,接收研究问题,规划检索步骤,调用搜索工具,阅读结果,迭代补充检索,汇总成报告。

某天发现有些任务异常漫长——“总结 React 19 新特性”跑了 4 分钟,调了 24 次搜索工具。

Trace 暴露的 pattern:

agent.plan (搜索"React 19 new features")
  → tool.call: web_search("React 19 new features")
  → llm.call (分析结果,决定补充搜索)
  → tool.call: web_search("React 19 new features")  ← 同一个 query 又来了
  → llm.call (分析...)
  → tool.call: web_search("React 19 what is new")   ← 换个说法又搜
  ...

Agent 的执行循环里没有”搜过了吗”的判断。每次 LLM 分析结论是”还需要更多信息”,但搜的关键词高度重叠。24 次搜索里,14 次返回的是基本相同的结果集。

优化:在 planner 指令里加了规则——“前两次搜索关键词相似度超过 70% 时,先基于已有信息尝试回答”。同时在工具层给 web_search 加了去重缓存,相同 query 5 分钟内不重复请求。同类任务的平均搜索次数从 15+ 降到 4-6,总耗时减少约 60%。

Trace 的价值在于把重复模式拉出来可见。单条日志只能看到”搜索被调用了”,但看不出调用之间的横向关系——而 trace 把调用的序列和参数串在一起,重复一眼可见。

4.3 案例三:用 Trace 数据优化 Skills 设计

场景:一个”生成技术博客文章”的 Skill,内部流程是:读取相关源代码 → 查询技术文档 → LLM 生成大纲 → LLM 逐节生成正文 → LLM 审校 → 输出最终文章。

Skill 每步都埋了 span,一个月的聚合数据:

Skill 步骤平均耗时平均 token_in是否总是必要
read_source_code4.2s0文章不涉及代码库时不需要
query_docs2.1s0代码已自文档化时不需要
generate_outline3.3s8000总是需要
generate_sections18.7s12000总是需要
review_and_edit7.8s6000失败率仅 1%,可降频

三个优化动作:

  1. 跳过不必要的步骤:给 Skill 加了前置判断——用户请求不涉及代码库时,跳过 read_source_codequery_docs。这类任务的耗时从 ~36 秒降到 ~30 秒。

  2. 审校采样化review_and_edit 失败率仅 1%,从”每次都跑”改为”采样 30% 的任务跑审校,前面步骤失败或用户要求高质量时触发”。总 token 消耗降低约 20%,用户感知质量不变。

  3. 大纲生成精简generate_outline 的 prompt 里去掉了冗余的上下文描述,token_in 从 8000 降到 5000,耗时从 3.3 秒降到 2.2 秒。

Trace 数据的价值在于把 Skill 从一个整体拆成了可分别观测、分别优化的步骤。没有分步数据,只能说”这个 Skill 平均跑 36 秒”;有了分步数据,每一步耗时多少、token 花在哪、哪一步可以跳过——全有数据支撑。

这三个案例的共同指向:Trace 的价值不止于出问题时回溯,更在于用数据改进 Agent 和 Skill 的设计。 这和传统后端的性能优化逻辑完全一致——先测量,再优化,再测量。Agent 开发也应该走这条路。

五、从零到第一条调用链

Agent Trace 不需要一开始就搭全套基础设施。三步渐进:

第一步:决定数据格式。 让应用输出 OTLP,不管后面接什么后端,数据格式都一样。OTLP 支持 gRPC 和 HTTP 两种传输——不确定怎么选就从 HTTP 开始,curl 能直接验证,出问题更容易排查。

第二步:跑通最小的闭环。 用一个最简单的 Agent 任务(“读一个文件,总结内容”),确认 trace 从发出到在后端看到是通的。不要一上来埋几十个 span——先跑通 agent.run 一个,再逐个加 llm.calltool.call

第三步:给每个 span 加 2-3 个最有用的属性。 llm.call 先加 model 和 token 数,tool.call 先加 tool name 和 success 状态。属性不在多,在对调试最关键。

三步走完后,应该能回答几个基本问题:一次 Agent 任务调了几次模型?每次 token 多少?工具调了几次、失败了几次?最慢的一步在哪里?失败是模型问题、工具问题还是上下文问题——能否在一分钟内判断?

这已经覆盖了 Agent 调试 80% 的需求。持续评估、自动回放、异常检测、成本归因——等基础链路稳定了再补。Trace 的价值不在数据多,在能不能讲清楚 Agent 为什么做了某个决定。

六、接入实例:三种主流方式

前面讲的是概念和策略,这一章落到代码上。三个最常用的接入方式各给一个最小示例——目标是”复制粘贴改一改就能跑”,不是完整封装。

6.1 LangSmith:Python 环境下的开箱即用

LangSmith 由 LangChain 团队维护,是目前 Agent 可观测性领域最成熟的产品之一。它的最大优势是零配置起步——如果已经在用 LangChain 或 LangGraph,加两行环境变量就能自动采集所有 LLM 调用和工具调用的 trace。

安装依赖:

pip install langsmith

设置环境变量:

export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="lsv2_pt_..."
export LANGSMITH_PROJECT="agent-debug"

然后正常写 Agent 代码,不需要额外埋点。LangChain 的 ChatOpenAItool 装饰器、AgentExecutor 等组件会自动产生嵌套 span:

from langsmith import traceable
from langchain.chat_models import ChatOpenAI
from langchain.agents import tool, AgentExecutor, create_openai_tools_agent

@tool
def search_codebase(query: str) -> str:
    """搜索代码库中的相关文件。"""
    return f"找到 3 个匹配文件: src/auth.ts, src/login.ts, src/session.ts"

@traceable(name="analyze_bug", run_type="chain")
def analyze_bug(error_log: str) -> str:
    llm = ChatOpenAI(model="gpt-5.5")
    return llm.invoke(f"分析这个错误日志的根因:\n{error_log}").content

# Agent 执行时,LangSmith 自动捕获:
# - agent.run(整体任务)
#   - llm.call(每次模型调用,带 token 数、延迟、模型名)
#   - tool.call(search_codebase,带参数和返回值)
#   - chain(analyze_bug,嵌套在 agent 内部)

tools = [search_codebase]
agent = create_openai_tools_agent(ChatOpenAI(model="gpt-5.5"), tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools)
result = executor.invoke({"input": "用户登录后 session 莫名丢失,帮忙排查"})

不想绑定 LangChain 生态的,LangSmith 也提供了独立于框架的 @traceable 装饰器和 RunTree API:

from langsmith import traceable
import openai

@traceable(name="call_model", run_type="llm", tags=["production"])
def call_model(prompt: str) -> str:
    client = openai.OpenAI()
    response = client.chat.completions.create(
        model="gpt-5.5",
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content

@traceable(name="agent_task", run_type="chain")
def run_agent(task: str) -> str:
    plan = f"分析任务:{task}"
    result = call_model(plan)
    return result

# 调用后直接在 LangSmith UI 看到完整 trace 树
run_agent("修复 src/auth.ts 中的登录超时问题")

@traceable 装饰器会自动把函数调用嵌套成 trace 树——外层 run_agent 是根 span,内层 call_model 是子 span。不需要手动创建 tracer、不需要管 span 的 start/end 生命周期。对于快速验证”trace 能带来什么价值”的场景,这种零摩擦的接入体验是 LangSmith 最强的卖点。

LangSmith 自带一套完整的调试 UI:按项目筛选 trace、点进单条 trace 查看每个 span 的输入输出和耗时、对运行结果打分标注、用标注数据构建评估集做回归测试。详见 LangSmith Tracing 文档

6.2 OpenTelemetry + OTLP:跨框架、跨语言的标准方案

如果 Agent 不只跑 Python(比如 TypeScript 的 MCP Server、Go 的工具服务),或者需要把 Agent trace 和公司现有的 OpenTelemetry 基础设施统一管理,直接输出 OTLP 是更灵活的选择。

以 TypeScript 为例,手动给 Agent 关键动作埋点:

pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto

初始化 SDK:

import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import { NodeSDK } from '@opentelemetry/sdk-node'

export const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
      ?? 'http://localhost:4318/v1/traces',
  }),
})

sdk.start()

Agent 侧手动创建 span:

import { SpanStatusCode, trace } from '@opentelemetry/api'

const tracer = trace.getTracer('agent-runtime')

async function runAgent(task: string) {
  return tracer.startActiveSpan('agent.run', async (runSpan) => {
    runSpan.setAttribute('agent.task.type', 'fix-test')

    // 规划步骤
    await tracer.startActiveSpan('agent.plan', async (planSpan) => {
      planSpan.setAttribute('plan.step_count', 3)
      planSpan.end()
    })

    // LLM 调用
    await tracer.startActiveSpan('llm.call', async (llmSpan) => {
      llmSpan.setAttribute('llm.model', 'gpt-5.5')
      llmSpan.setAttribute('llm.provider', 'openai')
      llmSpan.setAttribute('llm.input_tokens', 8400)
      llmSpan.setAttribute('llm.output_tokens', 1200)
      llmSpan.end()
    })

    // 工具调用
    await tracer.startActiveSpan('tool.call', async (toolSpan) => {
      toolSpan.setAttribute('tool.name', 'run_tests')
      try {
        await runTests()
        toolSpan.setAttribute('tool.success', true)
      }
      catch (error) {
        toolSpan.setStatus({ code: SpanStatusCode.ERROR })
        toolSpan.setAttribute('tool.success', false)
      }
      toolSpan.end()
    })

    runSpan.setAttribute('agent.result', 'completed')
    runSpan.end()
  })
}

// 进程退出前 flush
process.on('SIGTERM', async () => {
  await sdk.shutdown()
  process.exit(0)
})

Python 侧同样简单,用 opentelemetry-apiopentelemetry-exporter-otlp

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
    OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
))
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("agent-runtime")

def run_agent(task: str) -> str:
    with tracer.start_as_current_span("agent.run") as span:
        span.set_attribute("agent.task.type", task)

        with tracer.start_as_current_span("llm.call") as llm_span:
            llm_span.set_attribute("llm.model", "gpt-5.5")
            llm_span.set_attribute("llm.input_tokens", 8400)
            result = call_model(task)

        with tracer.start_as_current_span("tool.call") as tool_span:
            tool_span.set_attribute("tool.name", "search_codebase")
            search_result = search_codebase(task)

        span.set_attribute("agent.result", "completed")
        return result

OTLP 输出后,后端可以接 Jaeger、Tempo、Phoenix、Honeycomb、Datadog,也可以接自建的 Collector 做路由和采样。完整导出器列表见 OpenTelemetry Exporters

6.3 Weave、Phoenix 与更多选择

除了 LangSmith 和原生 OpenTelemetry,还有几个值得关注的选择,各有各的侧重:

Weights & Biases Weave 在模型实验追踪的基础上加了 LLM 调用链的记录能力。适合已经在用 W&B 做模型训练的团队,不需要额外引入新的可观测性平台。它的 weave.op() 装饰器用法和 LangSmith 的 @traceable 类似,也是自动嵌套:

import weave

@weave.op()
def call_model(prompt: str) -> str:
    ...

@weave.op()
def run_agent(task: str) -> str:
    ...

Arize Phoenix 开源、原生消费 OTLP。不需要在代码里引入 Phoenix 专用的 SDK——只要应用输出 OTLP,Phoenix 就能提供 LLM 专用的检索和可视化视图。核心优势是数据和平台解耦:trace 数据按 OTLP 标准存储,展示层随时可换。

Braintrust 把 trace 和评估紧密耦合。不是先记录再事后分析,而是在 trace 过程中同步跑评估函数,实时给每次 LLM 调用打分。适合对输出质量有严格要求的场景,比如客服 Agent、合规审查 Agent。

MLflow Tracing 在 MLflow 的 experiment tracking 框架里内建了 trace 功能。如果团队已经在用 MLflow 管理模型版本和实验,用它给 Agent 加 trace 是路径最短的选择。

选型不需要纠结。如果已经在用某个生态(LangChain → LangSmith,W&B → Weave,MLflow → MLflow Tracing),直接用生态内的方案,零迁移成本。如果还没绑定生态,或者 Agent 是多语言、跨框架的,用 OpenTelemetry + OTLP 最灵活——一次埋点,后端可换。如果既想要专用 LLM 视图、又不想被厂商锁定,用 OpenTelemetry 输出 OTLP,接 Phoenix 做展示。

参考