一、背景
单次 LLM 调用:这种形态下,链路线性,日志即全景,打一行日志就记录「调用了什么模型、花了多久、返回了什么」就结束了:
用户提问 ──→ 调用一次模型 ──→ 返回答案
而 Agent 具备多轮推理、工具嵌套及强因果依赖等特征,执行过程呈树状拓扑且耗时跨度大,扁平日志无法承载层级耗时统计与因果依赖关系的表达。
- 从 1 次调用变成几十次:日志行数变多,人眼看不过来;
- 从毫秒变成分钟:耗时本身成了需要定位的问题;
- 从线性变成有因果:第 3 轮依赖第 2 轮的工具结果,平铺日志表达不了这种关系。
日志记录的是「发生了什么」,而 Agent 的执行还有一层结构:做了什么、按什么顺序做、时间花在哪一步。
OpenTelemetry(简称 OTel):它用一套标准的数据模型,把一次执行记录成一棵有层级、有因果、带耗时的树(Trace / Span),并统一描述、采集和传输。
Agent 的执行结构,本身就是一棵树:
Agent 关心的「层级 + 因果 + 耗时」,正好是 span 树的三个能力:有父子层级、有调用因果、有起止耗时。
二、核心概念
2.1 OTel 的定位与核心数据
OTel 有点像 HTTP 协议:HTTP 定义了请求/响应长什么样、怎么传,但它不是浏览器、也不是服务器;OTel 定义了运行数据长什么样、怎么传。
OTel 记录的最小单元叫 span:
{
"name": "claude",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"parent_id": "1c987a38b1408550",
"start_time": "2026-09-21T03:15:55.631Z",
"end_time": "2026-09-21T03:15:56.736Z",
"attributes": { "gen_ai.request.model": "claude" },
"events": [ { "name": "first-token" } ],
"status": "UNSET",
"kind": "CLIENT"
}
- name:这个 span 在干什么(显示在瀑布图上);
- trace_id:属于哪一次完整调用(整棵树共享);
- span_id:自身编号;parent_id:父 span 的编号;
- start_time / end_time:时间区间,相减就是耗时;
- attributes:附加的结构化信息;events:瞬时事件;
- status:UNSET / OK / ERROR;kind:CLIENT / SERVER 等,后端靠它画拓扑图。
两个关键点:
- span 是时间区间,不是时间点。 这是它和日志最根本的区别。
- parent_id 是拼出一棵树的唯一依据。 trace = 一次完整调用产生的所有 span 拼成的树:
trace_id 表示属于哪棵树,把散落的 span 归拢成一个 trace;span_id 标识自身;parent_span_id 指向父节点,是拼树的唯一依据。后端只做两件事:
按 trace_id 分组 → 按 parent_id 建树
判断根节点很简单:看它有没有父节点,没有父节点的就是根。
2.2 上下关系确定
在同一个程序里,OTel 会自动把新 span 接到当前的父节点上:
with tracer.start_as_current_span("turn-0"): # 当前是 turn-0
with tracer.start_as_current_span("llm"): # llm 自动成为 turn-0 的子节点
pass
跨进程就不一样:两个程序互相看不见,各记各的。比如 Agent 要查订单,它自己查不了,得让订单服务去查。
在 OTel 里,这两个值叫 trace_id 和 span_id,通常放在 HTTP 请求头里,SDK 会自动带上和读取。
跨进程时如果没有这个传递,两边的记录就永远对不上,只会得到两棵互不相干的树。
2.3 三大信号:Trace、Metric、Log
OTel 只定义三类数据,叫信号:
它们通过 trace_id 关联,形成从粗到细的下钻链:
Trace 和 Metric 的成本差异很直接:
100 万个请求不可能每个都存一棵完整的树;metrics 只存聚合数字,100 万个请求也只是几个数。
2.4 组件与链路:从 SDK 到 Collector
OTel 分两个包:opentelemetry-api 只有接口;opentelemetry-sdk 给应用依赖。
如果 openai 库内部埋点、依赖的是 api:应用装了 SDK,埋点生效;没装,API 调用变成空操作,什么都不发生,也不报错。这样库可以零成本内置埋点。
五个核心组件:
对应代码:
resource = Resource.create({"service.name": "my-agent"}) # ← Resource
provider = TracerProvider(resource=resource) # ← Provider
provider.add_span_processor(BatchSpanProcessor(exporter)) # ← Processor + Exporter
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__) # ← Tracer
OTLP 和 Collector。 OTLP 是传输协议。Collector 是独立进程,生产环境常用:
它做四件事:接收(统一入口)、处理(采样、脱敏、富化)、路由(一份数据发多个后端)、缓冲。改策略不用改应用——把采样率从 10% 调到 5%,改 Collector 配置即可,不用重新部署 Agent。
完整架构:
三、解决的问题
3.1 日志与 Trace 的分工
- 出错看堆栈、看某个变量的值 → 日志;
- 看某一步花了多久、时间花在哪一步 → trace;
- 在几十条运行里找最慢的、统计工具失败率、对比两个模型 → trace。
日志回答「发生了什么」,trace 回答「为什么慢、谁拖累了谁」。两者都真实存在,不是二选一。
OTel 不取代现有日志。挂一个 handler,业务代码不用改,日志同时进两个地方,OTel 那份自动带上 trace_id:
一条带 trace_id 的日志,把散落的文本变成树上的一个锚点,可以从日志直接跳到对应的 trace。
3.2 从日志到执行树
同样是记录一次运行,日志是平铺文本,trace 是有层级的树:
日志记录发生了什么,但很难表达这些事情之间的关系:哪个 LLM 调用属于哪一轮、哪个 Tool 是哪次决策触发的、整个 Agent 在哪里耗时。
一个排障例子:
① Metric 发现异常:P99 从 80ms 涨到 210ms,慢在模型调用
② Trace 定位到具体会话:turn-1 里 tool:query_order 被调了 3 次,每次都 1.5s+
③ Log 找到根因(从 trace_id 一键跳过来):
ERROR query_order 超时(第 3 次),放弃
→ 不是模型慢,是订单服务接口超时,模型被迫反复重试
3.3 Attributes:记录属性信息
span 自带的信息只有四样:
name → 这是什么操作(只能有 1 个值)
start / end → 什么时候、多久
parent_id → 谁在谁里面
attributes → 其余一切
name 必须低基数(它是聚合主键),只能承担分类。实际要记录的还包括模型、token 数、轮次、输入输出、失败原因,这些放在 attributes 里。
同一棵 span 树跑两遍(A 不 set 属性、B set 属性),结构、名字、耗时完全一样,差别在能回答的问题:
- 哪一步最慢:两者都能答(这是唯一只靠时间就能答的);
- token花费、上下文第几轮开始涨、哪个工具最容易失败:只有 B 能答。
只看 span 结构,能分析的只有耗时;要分析业务,就得靠 attributes。
属性之间有依赖,位置也有要求:要画 token 增长曲线,turn.index 和 gen_ai.usage.input_tokens 必须同时存在,少一个就只剩三个孤立数字,排不出顺序;属性都记了但挂错 span,分析也用不了。
Trace 决定能不能还原过程,Attributes 决定能不能分析过程。
四、编码与实测
4.1 字段规范
核心是用行业约定的名字,后端靠字段名自动渲染面板——用 model_name 而不是 gen_ai.request.model,后端的面板、成本计算、LLM 视图都会失效。
LLM / Agent 场景已有标准(OTel 语义约定):
- 模型 span:gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.server.time_to_first_token;
- 工具 span:tool.name、tool.arguments;
- 输入输出:input.value / output.value
自定义业务字段加自己的前缀,比如 cs.intent、cs.prompt_version,避免和规范撞名。
是 span 还是 attribute,用两步判断:
第一步:这一步有自己的耗时吗? 是 → 开 span
第二步:这是某一步的产出/结果吗? 是 → 挂 attribute
有耗时的步骤用 span,步骤的产出用 attribute。比如智能客服前置三步——意图识别、query 改写、槽位抽取——各自都耗时,所以各是一个 span;各自的产出(意图、槽位)是 attribute:
with tracer.start_as_current_span("cs:intent") as s:
intent = classify_intent(text) # ← 耗时发生在这一步
s.set_attribute("cs.intent", intent.name) # ← 产出挂属性
如果压成一个 span + 三个属性,就丢掉了「哪一步最慢」,而调优时需要这个信息。
4.2 完整示例:一个智能客服 Agent
初始化(一个程序只做一次):
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
resource = Resource.create({"service.name": "my-agent-runner"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces"))
)
trace.set_tracer_provider(provider) # ← 全进程只能调一次
tracer = trace.get_tracer(__name__)
把前面的规则串起来,规范字段和业务自定义字段混用:
def handle_session(user_text: str) -> str:
# 根 span:一次会话的边界。业务属性走自己的 cs. 命名空间
with tracer.start_as_current_span("agent:customer-service") as agent:
agent.set_attribute("openinference.span.kind", "AGENT")
agent.set_attribute("cs.tenant_id", "t-001")
agent.set_attribute("cs.prompt_version", "v3.2")
# user.id / session.id 是高基数 → 放进日志,不塞属性
# 前置 pipeline:每一步都有耗时 → 各自一个 span;产出挂属性
with tracer.start_as_current_span("cs:intent") as s:
intent = classify_intent(user_text)
s.set_attribute("cs.intent", intent.name)
with tracer.start_as_current_span("cs:slot_filling") as s:
slots = fill_slots(user_text)
s.set_attribute("cs.slots", json.dumps(slots))
# 多轮生成:规范字段挂在模型 span 上
for i in range(MAX_TURNS):
with tracer.start_as_current_span(f"turn-{i}") as turn:
turn.set_attribute("turn.index", i)
with tracer.start_as_current_span("claude") as llm:
reply = call_model(user_text, slots)
llm.set_attribute("gen_ai.request.model", "claude")
llm.set_attribute("gen_ai.usage.input_tokens", reply.usage.input)
llm.set_attribute("gen_ai.server.time_to_first_token", reply.ttft_ms)
llm.set_attribute("llm.cost.usd", reply.cost_usd)
llm.set_attribute("output.value", reply.text[:10240]) # 截断
for call in reply.tool_calls:
with tracer.start_as_current_span(f"tool:{call.name}") as tool:
tool.set_attribute("tool.name", call.name)
tool.set_attribute("status", "ok" if execute(call).ok else "error")
生成的树:
每个字段对应一个具体问题:
- 这次会话花了多少token:gen_ai.usage.* + llm.cost.usd
- token 花在哪一轮:上面 + turn.index(必须同层)
- 前置三步谁最慢:三个 cs:* span 的时长本身
- 用户意图分布:cs.intent;哪个工具最容易挂:tool.name + status
with tracer.start_as_current_span("turn-0") as turn: 这行和 with open() 结构一致:
括号里的字符串 → 是"名字",进后端,别人看 → 有约束(低基数)
as 后面的变量 → 是"把手",只在代码里用 → 随便取,零语义
with → 退出时自动结束 span
三个细节:
- with 就是 span 的开始和结束。不需要自己调 end(),也不要漏掉结束——不 end() 的 span 永远不会被导出,而且不报错。
- as 后面的名字随便取,它只是个 Python 局部变量;括号里的字符串才是 span 的名字。树长什么样完全由代码缩进决定。
4.3 三信号协同:Metric 和 Log 怎么配合
真实系统里三条信号一起用:同一个事件,写三遍,各写各的。
tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)
logger = logging.getLogger(__name__)
# instruments:模块级各建一次,别放在函数里
tokens = meter.create_counter("gen_ai.client.token.usage", unit="{token}")
latency = meter.create_histogram("gen_ai.client.operation.duration", unit="s")
with tracer.start_as_current_span("agent:customer-service") as agent:
logger.info("会话开始") # ③ log —— 自动带上 trace_id
with tracer.start_as_current_span(MODEL) as span:
t0 = time.monotonic()
reply = call_model(user_text)
dt = time.monotonic() - t0
span.set_attribute("gen_ai.usage.input_tokens", reply.usage.input)
# ② metric —— 同一个事件换个记法:从"这一次"变成"一个数字"
lbl = {"gen_ai.request.model": MODEL}
tokens.add(reply.usage.input + reply.usage.output, lbl)
latency.record(dt, lbl)
跑完之后手上同时有三样东西,靠 trace_id 缝合:trace 是一棵树,metric 是几条数字,log 是几行带 trace_id 的文本。
4.4 实测:一次 Agent Trial
链路是完整的:SDK 埋点 → OTLP/HTTP → Jaeger 进程 → Jaeger 的 HTTP API → 取回来出图。中间隔着一个真实的后端进程,验证的是端到端链路,不是内存里的对象。
结果:service.name = agent-eval,14 个 span,端到端 771.8ms。
其中 LLM 调用总共 120.1ms,占比约 15.6%:
这次实验里 LLM 不是主要耗时来源。没有 Trace,容易先怀疑模型;有 Trace 可以先确认时间花在哪。
4.5 能回答的问题与模型对比
常用查询模式(都是按某个字段聚合):
- 哪一步最慢 → 按 span name 聚合耗时排序;
- 哪个工具最容易失败 → 按 tool.name 分组统计 status = ERROR;
- token 花在哪 → 按 turn.index 排开 input_tokens 看增长曲线。
对比(对评测场景尤其有用):
最终分数 轮数 总 token 工具调用
模型 A 0.85 3 2.1k 2
模型 B 0.85 12 11.4k 9
看起来一样 差 4 倍 差 5 倍 差 4.5 倍
分数一样,成本差 5 倍,这个差异日志里看不出来,span 树里能直接看到。形状本身也是信息:
4.6 后端选型与落地节奏
后端选型:Jaeger 入门、看瀑布图,开箱即用,适合起步;MLflow 适合实验对比;Langfuse 是 LLM 专用面板,字段约定不同,要验证渲染效果;自建 Collector + 存储等量上来再说。
OTel 的失败大多是「数据没到」而不是「程序崩了」,需要先有一个能看数据的地方,否则问题出在哪都不好判断。
分阶段落地:
① 先让一条轨迹能看 ← 最重要,成本低② 再批量导入历史数据③ 再定字段规范④ 最后建对比工作流、上生产(加 Collector、配采样、接多后端)
OTel 的价值在第一次跑的时候不明显:只看到一棵树,会像「好看点的日志」。价值出现在有第二个版本可以对比的时候。所以先跑通、先有数据,字段可以之后再补。
五、总结
OpenTelemetry 把一次 Agent 执行记录成一棵有层级、有因果、带属性的树:
- Trace / Span 还原「做了什么、什么顺序、花了多久」;
- Attributes 记录「用的什么、token花了多少、为什么失败」;
- Metric / Log 分别回答「整体怎么样」和「那一瞬发生了什么」,靠 trace_id 串起来。
一次 771.8ms 的 Agent Trial:LLM 只占 120.1ms(15.6%),慢的不一定是模型。
当 Agent 从「调用一个模型」变成「自主完成一项任务」,理解它怎么跑,就成了可观测性的核心问题。