Agent 可观测性基石:OpenTelemetry 学习与实践

0 阅读11分钟

一、背景

单次 LLM 调用:这种形态下,链路线性,日志即全景,打一行日志就记录「调用了什么模型、花了多久、返回了什么」就结束了:

用户提问 ──→ 调用一次模型 ──→ 返回答案

而 Agent 具备多轮推理、工具嵌套及强因果依赖等特征,执行过程呈树状拓扑且耗时跨度大,扁平日志无法承载层级耗时统计与因果依赖关系的表达。

image

  • 从 1 次调用变成几十次:日志行数变多,人眼看不过来;
  • 从毫秒变成分钟:耗时本身成了需要定位的问题;
  • 从线性变成有因果:第 3 轮依赖第 2 轮的工具结果,平铺日志表达不了这种关系。

日志记录的是「发生了什么」,而 Agent 的执行还有一层结构:做了什么、按什么顺序做、时间花在哪一步。

OpenTelemetry(简称 OTel):它用一套标准的数据模型,把一次执行记录成一棵有层级、有因果、带耗时的树(Trace / Span),并统一描述、采集和传输。

Agent 的执行结构,本身就是一棵树:

image

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 等,后端靠它画拓扑图。

两个关键点:

  1. span 是时间区间,不是时间点。 这是它和日志最根本的区别。
  2. parent_id 是拼出一棵树的唯一依据。 trace = 一次完整调用产生的所有 span 拼成的树:

image

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 要查订单,它自己查不了,得让订单服务去查。

image

在 OTel 里,这两个值叫 trace_id 和 span_id,通常放在 HTTP 请求头里,SDK 会自动带上和读取。

跨进程时如果没有这个传递,两边的记录就永远对不上,只会得到两棵互不相干的树。

2.3 三大信号:Trace、Metric、Log

OTel 只定义三类数据,叫信号:

image

它们通过 trace_id 关联,形成从粗到细的下钻链:

image

Trace 和 Metric 的成本差异很直接:

image

100 万个请求不可能每个都存一棵完整的树;metrics 只存聚合数字,100 万个请求也只是几个数。

2.4 组件与链路:从 SDK 到 Collector

OTel 分两个包:opentelemetry-api 只有接口;opentelemetry-sdk 给应用依赖。

如果 openai 库内部埋点、依赖的是 api:应用装了 SDK,埋点生效;没装,API 调用变成空操作,什么都不发生,也不报错。这样库可以零成本内置埋点。

五个核心组件:

image

对应代码:

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 是独立进程,生产环境常用:

image

它做四件事:接收(统一入口)、处理(采样、脱敏、富化)、路由(一份数据发多个后端)、缓冲。改策略不用改应用——把采样率从 10% 调到 5%,改 Collector 配置即可,不用重新部署 Agent。

完整架构:

image


三、解决的问题

3.1 日志与 Trace 的分工

  • 出错看堆栈、看某个变量的值 → 日志;
  • 看某一步花了多久、时间花在哪一步 → trace;
  • 在几十条运行里找最慢的、统计工具失败率、对比两个模型 → trace。

日志回答「发生了什么」,trace 回答「为什么慢、谁拖累了谁」。两者都真实存在,不是二选一。

OTel 不取代现有日志。挂一个 handler,业务代码不用改,日志同时进两个地方,OTel 那份自动带上 trace_id:

image

一条带 trace_id 的日志,把散落的文本变成树上的一个锚点,可以从日志直接跳到对应的 trace。

3.2 从日志到执行树

同样是记录一次运行,日志是平铺文本,trace 是有层级的树:

image

日志记录发生了什么,但很难表达这些事情之间的关系:哪个 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")

生成的树:

image

每个字段对应一个具体问题:

  • 这次会话花了多少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

三个细节:

  1. with 就是 span 的开始和结束。不需要自己调 end(),也不要漏掉结束——不 end() 的 span 永远不会被导出,而且不报错。
  2. as 后面的名字随便取,它只是个 Python 局部变量;括号里的字符串才是 span 的名字。树长什么样完全由代码缩进决定。

4.3 三信号协同:Metric 和 Log 怎么配合

真实系统里三条信号一起用:同一个事件,写三遍,各写各的。

image

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。

image

其中 LLM 调用总共 120.1ms,占比约 15.6%:

image

这次实验里 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 树里能直接看到。形状本身也是信息:

image

4.6 后端选型与落地节奏

后端选型:Jaeger 入门、看瀑布图,开箱即用,适合起步;MLflow 适合实验对比;Langfuse 是 LLM 专用面板,字段约定不同,要验证渲染效果;自建 Collector + 存储等量上来再说。

OTel 的失败大多是「数据没到」而不是「程序崩了」,需要先有一个能看数据的地方,否则问题出在哪都不好判断。

分阶段落地:

① 先让一条轨迹能看   ← 最重要,成本低② 再批量导入历史数据③ 再定字段规范④ 最后建对比工作流、上生产(加 Collector、配采样、接多后端)

OTel 的价值在第一次跑的时候不明显:只看到一棵树,会像「好看点的日志」。价值出现在有第二个版本可以对比的时候。所以先跑通、先有数据,字段可以之后再补。


五、总结

image

OpenTelemetry 把一次 Agent 执行记录成一棵有层级、有因果、带属性的树:

  • Trace / Span 还原「做了什么、什么顺序、花了多久」;
  • Attributes 记录「用的什么、token花了多少、为什么失败」;
  • Metric / Log 分别回答「整体怎么样」和「那一瞬发生了什么」,靠 trace_id 串起来。

一次 771.8ms 的 Agent Trial:LLM 只占 120.1ms(15.6%),慢的不一定是模型。

当 Agent 从「调用一个模型」变成「自主完成一项任务」,理解它怎么跑,就成了可观测性的核心问题。

转自:mp.weixin.qq.com/s?__biz=MzI…