Agent 可观测性的基石:OpenTelemetry 学习与实践
英文标题:Agent 可观测性基石:OpenTelemetry 学习与实践
单次 LLM 调用可用平铺日志描述,但具备多轮推理、工具调用和跨服务依赖的 Agent,需要通过 Trace 的树状结构呈现层级、因果与耗时。OpenTelemetry(OTel)以 Trace、Metric、Log 三类信号统一记录和关联运行数据,其中 Span、Attributes、上下文传播及 Collector 是实现 Agent 可观测性的关键机制。文章给出智能客服 Agent 的完整埋点示例,说明如何使用 OTel 语义约定记录模型、Token、工具调用和业务属性。实测显示,一次包含 14 个 Span、端到端耗时 771.8ms 的 Agent Trial 中,LLM 调用仅占 120.1ms、约 15.6%,说明 Trace 能帮助开发者避免将性能问题错误归因于模型。
46.5 KB
32.2 KB
16.3 KB
57.9 KB
11.5 KB
14.5 KB
11.0 KB
51.9 KB
7.8 KB
19.4 KB
12.7 KB
15.4 KB
23.0 KB
13.8 KB
27.7 KB
5.6 KB
17.0 KB
37.9 KB
中文
一、背景
单次 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"):
with tracer.start_as_current_span("llm"):
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"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
**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__)
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("会话开始")
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)
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
English / 原文
一、背景
单次 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"):
with tracer.start_as_current_span("llm"):
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"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
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__)
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("会话开始")
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)
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 从「调用一个模型」变成「自主完成一项任务」,理解它怎么跑,就成了可观测性的核心问题。