《AI Agent 高级编程》10.1 统一可观测:三框架 trace 归一 OTel

pydantic-ai 原生就吐 OTel span,smolagents 与 minagent 却只有各自的内部轨迹。本节在装了 opentelemetry-sdk 1.44.0 的环境里真跑:把三种来源的 trace 归一到一张中间表示,再统一 emit 成 GenAI 语义约定的 OTel span 树,并给出三框架 span 字段到 OTel 属性的逐项映射表。

10.1 统一可观测:三框架 trace 归一 OTel

本卷前半程把同一件事在 pydantic-ai、Claude Agent SDK、OpenAI Agents SDK、smolagents 里各写了一遍,第 4.3 节还同题对照过分数与成本。但对照的前提是你能同时看见三个框架内部发生了什么——而现实是:pydantic-ai 一开箱就往 OpenTelemetry 里吐 span,smolagents 只把轨迹留在 agent.memory.steps 里,卷一自研的 minagent 则只有 RunResult.steps 那个三元组列表。三套数据结构互不相认,仪表盘上就是三堆孤岛。

本节把「可观测」从「每个框架各看各的」推进到「一张统一的 span 树」:在真的装了 opentelemetry-sdk 1.44.0 的环境里,把三种来源的 trace 归一到同一张中间表示,再统一 emit 成符合 GenAI 语义约定的 OTel span,并给出逐字段映射表。

10.1.1 先厘清一个环境事实:本卷有真 SDK

卷一 15 章可观测性 那个环境里,装的是 opentelemetry-api 却没有 opentelemetry-sdk,于是 get_tracer() 只能拿到一个 no-op 的 ProxyTracer——代码能跑,但 span 全被丢弃,什么都看不见。本卷的环境不一样:/tmp/agentbook/venv3 里 opentelemetry-sdk 是 1.44.0,与 opentelemetry-api 同版本。先核一遍:

/tmp/agentbook/venv3/bin/python -c \
  "import importlib.metadata as m; print(m.version('opentelemetry-sdk'))"
# 1.44.0

这个差别很关键:有 SDK,我们才能用 TracerProvider + InMemorySpanExporter 把 span 真的收下来看;没有 SDK,TracerProvider() 都不会存在,只能得到空结果。本节所有「真跑输出」都建立在 SDK 在场这个事实上。

10.1.2 pydantic-ai:原生就吐 OTel span

先说好消息——pydantic-ai 内置了 OTel 埋点,你不用写一行 instrument 代码。只要用 InstrumentedModel 包住模型、并把 tracer_provider 传进 InstrumentationSettings,它就会按 GenAI 语义约定吐 span:

"""pydantic-ai 原生 OTel:InstrumentedModel + InMemorySpanExporter。"""
import asyncio
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic_ai.models.instrumented import InstrumentedModel, InstrumentationSettings

exp = InMemorySpanExporter()
tp = TracerProvider()
tp.add_span_processor(SimpleSpanProcessor(exp))          # 本地 exporter,不连任何远端

def add(a: int, b: int) -> int:
    "Add two numbers"
    return a + b

agent = Agent(
    InstrumentedModel(TestModel(), InstrumentationSettings(tracer_provider=tp)),
    tools=[add], system_prompt="use tools",
)

asyncio.run(agent.run("what is 2+3"))
for s in exp.get_finished_spans():
    print(s.name, "|", s.attributes.get("gen_ai.operation.name"))

本机实测(pydantic-ai 2.55.0):

chat test                | chat
execute_tool add         | execute_tool
chat test                | chat
invoke_agent agent       | invoke_agent

一次 run 产出一棵三层 span 树:根是 invoke_agent,下面是两次 chat(第一轮要工具、第二轮给答案),中间夹一次 execute_tool。它们的属性已经用了 gen_ai.* 命名,例如根 span 上有 gen_ai.agent.name、gen_ai.aggregated_usage.input_tokens,chat span 上有 gen_ai.request.model、gen_ai.usage.input_tokens。

注意 SimpleSpanProcessor 是同步导出:span 一结束立刻写进 exporter,适合测试;生产里要换成 BatchSpanProcessor 批量异步导出,否则导出开销会拖慢主路径。

10.1.3 smolagents 与 minagent:只有各自的内部轨迹

另外两个就没这么好命了。smolagents 1.26.0 没有原生 OTel,它把执行轨迹留在 agent.memory.steps 里,每步是一个 ActionStep / TaskStep:

from smolagents import ToolCallingAgent, tool as smtool, Model
from smolagents.models import ChatMessage, ChatMessageToolCall, ChatMessageToolCallFunction

@smtool
def add(a: int, b: int) -> int:
    """Add two numbers.
    Args:
        a: first
        b: second
    """
    return a + b

class SFFake(Model):                      # 脚本化假模型,零凭据确定性
    def __init__(self):
        super().__init__(); self.n = 0
    def generate(self, messages, stop_sequences=None, response_format=None,
                 tools_to_call_from=None, **kwargs):
        self.n += 1
        if self.n == 1:
            tc = ChatMessageToolCall(id="call_1", type="function",
                function=ChatMessageToolCallFunction(name="add", arguments={"a": 2, "b": 3}))
            return ChatMessage(role="assistant", content="", tool_calls=[tc])
        tc = ChatMessageToolCall(id="call_2", type="function",
            function=ChatMessageToolCallFunction(name="final_answer", arguments={"answer": "5"}))
        return ChatMessage(role="assistant", content="", tool_calls=[tc])

agent = ToolCallingAgent(tools=[add], model=SFFake(), max_steps=4)
agent.run("2+3?")
for st in agent.memory.steps:
    print(type(st).__name__, getattr(st, "step_number", None), getattr(st, "tool_calls", None))

实测输出(已略去 rich 控制台输出):

TaskStep None None
ActionStep 1 [ToolCall(name='add', arguments={'a': 2, 'b': 3}, id='call_1')]
ActionStep 2 [ToolCall(name='final_answer', arguments={'answer': '5'}, id='call_2')]

ActionStep 的字段实测为 step_number / tool_calls / timing / token_usage / model_output_message / observations,其中 tool_calls 是 ToolCall(name, arguments, id),token_usage 是 TokenUsage(input_tokens, output_tokens, total_tokens)。这些字段和 OTel 的 gen_ai.* 一一对得上,只是名字不同——这正是要归一的理由。

minagent 那边更朴素:卷一的 RunResult.steps 是 list[Step],每个 Step 只有 index / kind / detail 三个字段,kind 取 "tool_call" | "final" | "error";token 用量得去 ModelResponse.usage 里找。三个框架,三种轨迹形状,见下表:

框架原生轨迹载体有无原生 OTel工具调用的表示
pydantic-aiOTel span(InstrumentedModel)有execute_tool span + gen_ai.tool.* 属性
smolagentsagent.memory.steps(ActionStep)无ActionStep.tool_calls: list[ToolCall]
minagentRunResult.steps(Step)无Step(kind="tool_call", detail=...)

10.1.4 归一:一张中间表示 + 一次 emit

归一的思路是先归到中间表示,再统一 emit,而不是给每个框架各写一套导出。中间表示只有三个字段——名字、属性、子节点:

"""统一中间表示 + 一次 emit,把任意框架的轨迹落成 OTel span 树。"""
from dataclasses import dataclass, field
from typing import Any

@dataclass
class NormSpan:
    name: str
    attrs: dict[str, Any] = field(default_factory=dict)
    children: list["NormSpan"] = field(default_factory=list)

def emit(root: NormSpan, tracer) -> None:
    """把 NormSpan 树递归落成 OTel span,父子关系由嵌套结构决定。"""
    def rec(n: NormSpan) -> None:
        with tracer.start_as_current_span(n.name) as sp:
            for k, v in n.attrs.items():
                sp.set_attribute(k, v)
            for c in n.children:
                rec(c)
    rec(root)

三个来源各写一个 reader,把原生轨迹翻译成 NormSpan:

  • pydantic-ai reader:它的轨迹本来就是 OTel span,直接读 InMemorySpanExporter 里的 ReadableSpan,按 gen_ai.operation.name 分派成 chat / execute_tool 子节点。
  • smolagents reader:遍历 agent.memory.steps,每个 ActionStep.tool_calls 生成一个 execute_tool 子节点。
  • minagent reader:遍历 RunResult.steps,kind == "tool_call" 的生成 execute_tool,token 从 ModelResponse.usage 取。

两个「需要翻译」的 reader 长这样:

def read_pydantic_ai(spans) -> NormSpan:
    """pydantic-ai 的 span 已是 OTel,按 operation 分派即可。"""
    root = NormSpan("invoke_agent agent",
                    {"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": "agent"})
    for s in spans:
        a = dict(s.attributes)
        op = a.get("gen_ai.operation.name")
        if op == "chat":
            root.children.append(NormSpan("chat " + str(a.get("gen_ai.request.model")), {
                "gen_ai.operation.name": "chat",
                "gen_ai.request.model": a.get("gen_ai.request.model"),
                "gen_ai.usage.input_tokens": a.get("gen_ai.usage.input_tokens"),
                "gen_ai.usage.output_tokens": a.get("gen_ai.usage.output_tokens")}))
        elif op == "execute_tool":
            root.children.append(NormSpan("execute_tool " + str(a.get("gen_ai.tool.name")), {
                "gen_ai.operation.name": "execute_tool",
                "gen_ai.tool.name": a.get("gen_ai.tool.name"),
                "gen_ai.tool.call.id": a.get("gen_ai.tool.call.id")}))
    return root


def read_smolagents(agent) -> NormSpan:
    """smolagents 只有 memory.steps,逐 ActionStep 翻译。"""
    root = NormSpan("invoke_agent smolagent",
                    {"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": "smolagent"})
    root.children.append(NormSpan("chat fake",
                                  {"gen_ai.operation.name": "chat", "gen_ai.request.model": "fake"}))
    for st in agent.memory.steps:
        for tc in (getattr(st, "tool_calls", None) or []):
            root.children.append(NormSpan("execute_tool " + tc.name, {
                "gen_ai.operation.name": "execute_tool",
                "gen_ai.tool.name": tc.name,
                "gen_ai.tool.call.id": tc.id}))
    return root

两个 reader 的形状几乎对称——因为它们做的是同一件事:把「框架私有的轨迹」映射成「OTel 的属性」。minagent 的 reader 与 smolagents 的只差数据来源(RunResult.steps vs memory.steps)与一个 detail 字符串的解析,逻辑完全同构。

归一后统一 emit,实测打印出的 span 树:

=== 统一 emit 后,InMemorySpanExporter 里的 span 树 ===
- invoke_agent agent
  - chat test
  - execute_tool add
  - chat test
- invoke_agent smolagent
  - chat fake
  - execute_tool add
  - execute_tool final_answer
- invoke_agent minagent
  - chat fake
  - execute_tool add
span 总数: 11

三个框架、一棵树、一套属性名。 现在把这份 trace 接进任意 OTel 后端(Jaeger / Tempo / 云厂商)都不需要改业务代码——归一发生在 emit 之前,后端只认 span 结构和 gen_ai.* 属性。

生产里还要补两件事。其一是采样:全量采集 span 的成本会随 QPS 线性上涨,通常用 TracerProvider(sampler=...) 做头部采样,或者对「失败的 run」「超预算的 run」做尾部采样——只留你要排查的那批。其二是敏感内容:gen_ai.input.messages / gen_ai.output.messages 会把提示词和回答原文写进 span,pydantic-ai 允许用 InstrumentationSettings(include_content=False) 关掉。归一的时候,统一决定哪些属性进 span、哪些只留在本地,比让每个框架各采各的要安全得多。

10.1.5 三框架 span 字段 → OTel GenAI 语义约定映射表

归一的落脚点是「属性名对齐」。下表把三个框架的原生字段与 OTel GenAI 语义约定逐项对上,— 表示该框架原生不提供、需要补算:

OTel 属性(GenAI 语义约定)pydantic-aismolagentsminagent
gen_ai.operation.name原生 gen_ai.operation.name由 step 类型推导由 Step.kind 推导
gen_ai.agent.namegen_ai.agent.nameagent.name运行时固定名
gen_ai.request.modelgen_ai.request.modelmodel.model_idmodel.name
gen_ai.usage.input_tokensgen_ai.usage.input_tokensActionStep.token_usage.input_tokensModelResponse.usage["input_tokens"]
gen_ai.usage.output_tokensgen_ai.usage.output_tokensActionStep.token_usage.output_tokensModelResponse.usage["output_tokens"]
gen_ai.tool.namegen_ai.tool.nameToolCall.name解析 Step.detail
gen_ai.tool.call.idgen_ai.tool.call.idToolCall.idToolCall.id
gen_ai.tool.call.argumentsgen_ai.tool.call.argumentsToolCall.argumentsToolCall.arguments
span 父子层级框架自动建按 step_number 顺序重建按 Step.index 重建

pydantic-ai 那一列几乎是「零翻译」,因为它本来就照 GenAI 约定埋点;smolagents 与 minagent 那两列才是归一工作的真正成本所在——尤其 gen_ai.tool.name,minagent 的 Step.detail 里 name 和 arguments 是拼成一个字符串的("add({'a': 2}) -> 5"),得自己切出来。

还有一个实测细节值得记:pydantic-ai 的 invoke_agent 根 span 用的是聚合用量 gen_ai.aggregated_usage.*,而 chat 子 span 用的是单次用量 gen_ai.usage.*。归一时不要混用——根看总量、子看单次,这样才能算出「哪一次模型调用最贵」。

10.1.6 这套框架替你做了什么、你又让渡了什么

pydantic-ai 替你做的:把 OTel 埋点做进了框架内部——span 命名、gen_ai.* 属性、父子层级、token 用量,全自动;你只要传一个 tracer_provider。你让渡的:属性集由框架定义,它写 gen_ai.input.messages 就写,你想少采点敏感内容只能靠 InstrumentationSettings(include_content=False) 这类开关,粒度有限。

smolagents / minagent 的对称面:它们不替你埋点,但把轨迹数据完整暴露在你手里(memory.steps / RunResult.steps),你想怎么归就怎么归,零锁定。代价是归一这层代码要你自己写、自己维护——每接一个新框架,就得补一个 reader。

一条务实的分工建议:让框架管「产生轨迹」,让归一层管「对齐属性」。归一层(NormSpan + emit)只依赖 OTel 一个标准,框架换了、加了,只要补 reader;而 OTel 后端永远只认一棵规范的 span 树。这样可观测性就与「你用了哪个框架」解耦了——这正是下一节 10.2 成本与延迟治理 的输入:只有 trace 归一了,成本与延迟才能按同一个口径统计。

想先回看框架埋点的源码位置,见 5.2 读 PydanticAI ;OTel 采集与后端的通用做法见 可观测性专题 。

阅读导航:上一节:9.3 何时该训、何时调提示:成本收益决策清单 · 下一节:10.2 成本与延迟治理:路由/缓存/并发 + 评测兜底 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距