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-ai | OTel span(InstrumentedModel) | 有 | execute_tool span + gen_ai.tool.* 属性 |
| smolagents | agent.memory.steps(ActionStep) | 无 | ActionStep.tool_calls: list[ToolCall] |
| minagent | RunResult.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-ai | smolagents | minagent |
|---|---|---|---|
gen_ai.operation.name | 原生 gen_ai.operation.name | 由 step 类型推导 | 由 Step.kind 推导 |
gen_ai.agent.name | gen_ai.agent.name | agent.name | 运行时固定名 |
gen_ai.request.model | gen_ai.request.model | model.model_id | model.name |
gen_ai.usage.input_tokens | gen_ai.usage.input_tokens | ActionStep.token_usage.input_tokens | ModelResponse.usage["input_tokens"] |
gen_ai.usage.output_tokens | gen_ai.usage.output_tokens | ActionStep.token_usage.output_tokens | ModelResponse.usage["output_tokens"] |
gen_ai.tool.name | gen_ai.tool.name | ToolCall.name | 解析 Step.detail |
gen_ai.tool.call.id | gen_ai.tool.call.id | ToolCall.id | ToolCall.id |
gen_ai.tool.call.arguments | gen_ai.tool.call.arguments | ToolCall.arguments | ToolCall.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 成本与延迟治理:路由/缓存/并发 + 评测兜底 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。