Agent Trace 与 OTLP:让 AI Agent 的黑箱变成调用链
上个月同事跑来求助:他让 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.call | provider、模型名、输入 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.tsSpan 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_id 或 conversation_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,直接没了。
所以落地时有三件事比较关键:
- 进程退出前主动 flush。不要让 Agent 进程说退就退——退出前显式调用 SDK 的 shutdown,等 exporter 把缓冲区里的最后一批数据发完。
- 开发阶段先验证链路。 不要一上来就接远端后端。先在本地跑一个 Collector,用它的控制台输出确认 trace 数据确实从应用发出去了、格式是对的、属性是完整的。
- 生产环境不要把 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.call、function.invoke 还是 action——名字不统一,跨团队查询和聚合就没法做。
OpenTelemetry 的 语义约定(Semantic Conventions) 定义了常见操作的 span 命名和属性规范。LLM 场景的 Gen AI 语义约定 还在 experimental 阶段,但几条基本原则已经明确:
- Span name 稳定:
llm.call,不是llm.call.gpt-5.5.fix-bug。变化的信息全放 attributes。 - 属性用命名空间前缀:
llm.model、tool.name、agent.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"]
问题一目了然:LLM2 和 LLM3 的 token_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_code | 4.2s | 0 | 文章不涉及代码库时不需要 |
| query_docs | 2.1s | 0 | 代码已自文档化时不需要 |
| generate_outline | 3.3s | 8000 | 总是需要 |
| generate_sections | 18.7s | 12000 | 总是需要 |
| review_and_edit | 7.8s | 6000 | 失败率仅 1%,可降频 |
三个优化动作:
跳过不必要的步骤:给 Skill 加了前置判断——用户请求不涉及代码库时,跳过
read_source_code和query_docs。这类任务的耗时从 ~36 秒降到 ~30 秒。审校采样化:
review_and_edit失败率仅 1%,从”每次都跑”改为”采样 30% 的任务跑审校,前面步骤失败或用户要求高质量时触发”。总 token 消耗降低约 20%,用户感知质量不变。大纲生成精简:
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.call、tool.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 的 ChatOpenAI、tool 装饰器、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-api 和 opentelemetry-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 resultOTLP 输出后,后端可以接 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 做展示。