AI Agent 可观测性怎么做?从 0 设计一套 Span 树追踪系统(OpenTelemetry 对齐 + p50/p95 + 成本核算)
目录
一、先搞清楚:监控(Monitoring)和可观测性(Observability)不是一回事
很多人把"监控"和"可观测性"当同义词,但给 AI Agent 做追踪时,这俩驱动的设计完全不同:
| 对比项 | Monitoring 监控 | Observability 可观测性 |
|---|---|---|
| 回答的问题 | 已知的:还活着吗?CPU 高吗? | 未知的:它为什么这次绕了三圈才答? |
| 设计时机 | 事前知道要看什么,做好仪表盘 | 事后能对已记录数据提出当初没预料的新问题 |
| 表现形态 | 阈值、告警、红绿灯 | 可钻取、可还原、可对比单次请求全过程 |
记住这句总纲:可观测性 = 为"将来会被追问的问题"提前留下证据。
为什么 Agent 比普通服务更需要它?因为它有四重黑箱:
- 非确定性:同样的输入,LLM 每次可能走不同路径,难复现;
- 多步因果链:一次回答是"想 → 调工具 → 看结果 → 再想",错在哪一环肉眼看不见;
- 隐性成本:token 在悄悄烧钱,子 Agent 还会放大;
- 涌现性故障:死循环、调错工具、参数幻觉,都是过程问题。
下面用开源 Agent 框架 milu(GitHub:stephonGAO/milu)的真实实现,一步步教你把这套系统设计出来。讲的是通用方法,换成你自己的 Agent 一样适用。
二、第一步:从"必须能回答的问题"倒推该埋什么
这是整个教程最关键的方法论:不要从"我能记什么"开始,要从"我将来会被追问什么"开始。
Agent 系统逃不掉的追问归成 6 类,这就是你的需求清单:
| 类别 | 典型追问 | 要埋什么 |
|---|---|---|
| 行为/正确性 | 为什么调这个工具?模型当时看到了什么?为什么停? | 工具名、参数、finish_reason、最终消息 |
| 性能 | 时间花哪了?首字多久(TTFT)? | 每段耗时、TTFT、按类别拆分时间 |
| 成本 | 烧了多少 token?折多少钱?含子 Agent 吗? | usage 增量、按 generation 求和、单价表 |
| 可靠性 | 工具报错没?安全判定拦了啥?兜底触发没? | 状态码、judge 裁决+理由、fail_open 标记 |
| 可比较 | 这次比上次更快/更贵/更好吗? | 可聚合的运行索引(每次一行) |
| 可追溯 | 给定这条输出,还原完整因果链 | trace_id 串起的整棵树 + 时间戳 |
实操建议:动手写代码前,先把这张表落到你自己的项目上。后面所有埋点决策都回头对照它——能回答某类追问的才埋,回答不了任何追问的删掉。
三、核心模型:一次 Agent 运行 = 一棵 Span 树
这是整套系统的地基。先记两个概念:
- Span(跨度):一个有起止时间的"工作单元",带名字、种类(kind)、起止时间、属性、点事件、父指针(parent_id)。
- Trace(追踪):共享同一个
trace_id的所有 span,靠parent_id串成一棵树。
为什么用树?因为 Agent 的执行结构本身就是树:
invoke_agent ← 根:整次 run()
├─ chat {model} (turn 1) ← 一次 LLM 调用(generation)
├─ execute_tool: web_search ← 一个工具
├─ guardrail: judge ← 安全判定
├─ blocked_on_user ← 等人审批
├─ chat {model} (turn 2)
│ └─ execute_tool: 子agent调用
│ └─ invoke_agent ← 子 Agent 整棵树嵌在这里
└─ compact ← 上下文压缩
关键技巧:用 ContextVar 自动传播父子关系,不要手动穿参
新手最容易犯的错,是给每个函数都加一个 parent_span 参数往下传——函数签名很快就被污染,子调用一深就漏记。正确做法是用上下文传播。milu 的实现:
# tracer.py
from contextvars import ContextVar
_current_tracer: ContextVar = ContextVar("milu_current_tracer", default=None)
_current_span: ContextVar = ContextVar("milu_current_span", default=None)
def start(self, name: str, kind: str) -> Span:
parent = _current_span.get() # 自动认当前活动 span 当父
return Span(
trace_id=self.trace_id,
span_id=new_span_id(),
parent_id=parent.span_id if isinstance(parent, Span) else None,
name=name, kind=kind,
start_time=time.time(),
start_monotonic=time.monotonic(),
)
进入一个 span 就把它设为"当前",内部新建的 span 自动认它当父。子 Agent 沿 asyncio 任务树继承父 tracer,于是子 Agent 的整棵树零代码就挂到父的 execute_tool 下。
进程内用
ContextVar;跨进程(微服务、子进程)用 W3Ctraceparentheader 把 trace_id/parent 传过去——同一个思路。
Span 的 kind 一定要分类
milu 把 span 分成 agent / generation / tool / guardrail / confirmation / compaction 六类。分类是为了能按类别聚合:所有 generation 的 token 求和就是成本,所有 tool 的耗时求和就是工具时间,瀑布图按 kind 上色。没有分类,聚合无从下手。
四、埋点粒度怎么选:Span / 属性 / 事件 / 不记
知道了在哪埋,还要知道埋成什么。一套能直接套的判断口诀:
- 建一个 Span:当它是有意义时长、你将来想独立测量或对比的工作单元,尤其可能有子级或会独立失败时。例:一次 LLM 调用、一次工具执行、一次 judge 判定、整次 run。
- 记一个属性(Attribute):当它是某个 span 的性质或度量。例:
gen_ai.request.model、gen_ai.usage.input_tokens、工具名、工具参数。注意 TTFT 做成属性(gen_ai.response.time_to_first_chunk),不是事件——它是个度量值,不需要在时间轴单独定位。 - 记一个事件(Event):当它是 span 内部某一时刻发生、本身无时长、但你想要时间戳的事。例:“重试了一次”、“首个 chunk 到了”。
- 什么都不记:当它高频、低信息、可派生。最典型的反例:给每个流式 chunk 都建 span——会被淹没。记"首 chunk 时刻 + 总 chunk 数"即可。
口诀:有时长且想独立看 → span;描述这段工作的值 → 属性;某一刻的点 → 事件;高频可派生 → 丢掉。
还要守一条基数(cardinality)纪律:别把无界唯一值(如 user_id)当作指标的聚合维度,否则指标爆炸。user_id 可以进 span 用于过滤,但 p50/p95 这类聚合不要拿它当分组键。
五、两个容易被忽略的坑:盲窗与时间归因
这两条是从"能用"到"好用"的分水岭,也是 milu 真实修过的点。
坑 1:盲窗(silent window)—— 在干活却没信号
Agent 有两段静默:正文输出结束、工具参数还在流式生成的那几秒;以及 judge 阻塞式判定的那段时间。这两段前端收不到任何事件,看起来像卡死。解决办法是补状态信号事件:
# events.py
@dataclass(frozen=True)
class ToolCallPreparing(AgentEvent): # 覆盖"参数还在生成"的静默窗
tool_name: str
@dataclass(frozen=True)
class SafetyCheckStart(AgentEvent): # 覆盖"安全判定阻塞调用"的静默窗
tool_names: tuple[str, ...]
自检方法:问自己"系统有没有哪段时间在干活、却什么都没发出来?"——凡是"有耗时无信号"的缝隙,都该补埋点。
坑 2:时间归因(time attribution)—— 别把时间记错主
一个需要人工确认的工具,如果把"等了用户 5 分钟"算进它的执行时间,它会显得慢得离谱,你会误判成工具性能问题。所以要把"等人审批"的时间从工具耗时里剥离。milu 的 RunReport 因此把时间拆成四份:
# report.py(节选)
llm_time_ms: float # 模型时间
tool_time_ms: float # 工具时间
judge_time_ms: float # 安全判定时间
wait_time_ms: float # 人工审批等待时间
机器时间和人等待时间必须分开。 时间分解错了,所有性能结论都会被带偏。
六、工程纪律:fail-silent 与关闭零开销
埋点是横切关注点,会渗进每个角落,所以纪律要比业务代码更严。
1)fail-silent:监测出错,业务无感。
# tracer.py —— finish()
for sink in self.sinks:
try:
sink.emit(span)
except Exception as e:
logger.warning("trace sink 写入失败(已忽略): %s", e)
宁可丢一条 trace,也不能因为记日志把 agent.run() 搞崩。
2)关闭时零开销: 库默认 Agent(trace=False) 走 NOOP_TRACER,所有埋点调用变空操作。
class _NoopTracer:
enabled = False
def start(self, name, kind): return NOOP_SPAN
def finish(self, span, status=None, error_type=None): pass
3)内容捕获策略可调: prompt 可能极大且含 PII。milu 给 full / truncated / none 三档,默认 truncated 截断到 2000 字符,把隐私、体积、可用性的平衡权交给部署方。
capture_content: str = "truncated" # full | truncated | none
max_content_chars: int = 2000
七、对齐 OpenTelemetry GenAI 语义与 W3C Trace Context
就算你像 milu 一样自研 JSONL 落盘,也强烈建议属性命名对齐标准。原因很实在:今天叫 gen_ai.usage.input_tokens(OTel GenAI semantic conventions 标准名),将来要导出到 Langfuse / Phoenix / Jaeger 这类后端时几乎零映射;当初随手叫 tokens_in,就得写翻译层、还容易翻错。
具体做法(可当模板):
# span.py
SCHEMA_VERSION = 1 # 每行带 schema 版本,格式演进后老数据仍可正确解读
def new_trace_id() -> str: return secrets.token_hex(16) # W3C:32 hex
def new_span_id() -> str: return secrets.token_hex(8) # W3C:16 hex
- 属性名对齐 OTel GenAI:
gen_ai.provider.name、gen_ai.usage.input_tokens/output_tokens、gen_ai.response.finish_reasons; - 自有概念(标准没覆盖的,如 fail_open、判定理由)走私有命名空间
milu.*; - ID 用 W3C Trace Context,天然可跨系统对接;
- 每行带 schema 版本号,将来迁移有依据。
八、存储设计:append-only + 索引明细分离
trace 是只增的洪流,存储设计决定系统能不能长期跑。五条可复用套路:
- Append-only 抗崩溃:span 一结束就 append 一行 JSONL,进程崩了已写的行还在。
- 读时跳过坏行:某行写一半挂了,读取跳过这行而非整文件报废。
- 按实体分区:trace 索引按
runs/{safe_user_id}.jsonl分文件,多用户只读自己那份。 - 轮转 + 保留上限:运行索引超
RUNS_INDEX_MAX_LINES(5000)就原子重写丢最旧;trace 明细按retention_days过期清理。任何"永久追加"的文件都必须有天花板。 - 索引与明细分离(最值得学):一次运行落两处——轻量索引
runs/{user}.jsonl(每次运行一行汇总),重型明细{session}/trace.jsonl(整棵 span 树)。先扫索引找到目标,再只打开那一个明细文件钻取:
milu trace list # 读索引,行少、便宜
milu trace show <id> # 才读明细,钻取那一棵 span 树
把"目录"和"内容"分开,是所有大规模可观测系统的通用骨架。
九、读懂数据:p50/p95 与 token 成本估算
埋点是原料,能据此判断才是目的。
为什么看 p50/p95,不看平均值?
milu trace stats 把最近几百次运行聚合,算出 token/成本总和以及耗时 p50/p95。看一组数据就懂为什么不能用平均值(10 次运行,已排序,单位秒):
1, 1, 1, 1, 1, 1, 1, 1, 1, 100
| 指标 | 值 | 含义 |
|---|---|---|
| 平均值 | 10.9 秒 | 被极端值带偏,像"普遍很慢" |
| p50(中位数) | 1 秒 | 典型体验其实 1 秒 |
| p95 | ~1 秒 | 绝大多数都很快 |
| 最大值 | 100 秒 | 但有一次惨案 |
那"最倒霉的 5%"叫长尾,恰恰是用户会抱怨、会流失的部分。专业做法是两个一起看:p50 = 大多数人的真实体验,p95 = 最倒霉少数人有多惨。
token 成本估算:双轨制 + 宁可显示未知
token 用 provider 返回的真实 usage,单价查表估算;查不到价格返回 None,绝不瞎算:
# pricing.py
def estimate_cost(model, input_tokens, output_tokens, override=None):
entry = resolve_price(model, override) # 精确命中优先,再最长前缀匹配
if entry is None:
return None # 查不到价:标"未配置价格"
cost = (input_tokens / 1e6 * entry["input"]
+ output_tokens / 1e6 * entry["output"])
return round(cost, 6), entry.get("currency", "CNY")
(上面为可读性做了简化,省去容错分支,完整实现见源码。)两条铁律:宁可显示"未知",不可编造数字;token 求和必须含子 Agent,否则成本被严重低估。
十、可复用的 10 步设计 Checklist
从零给任何 Agent(甚至任何后端服务)设计监测,照这十问走一遍:
- 列问题:将来必须能回答哪些追问?(行为/性能/成本/可靠性/可比较/审计)
- 画树:把执行画成"工作单元的嵌套树",这些就是 span 候选。
- 定粒度:逐个判 span / 属性 / 事件 / 丢掉;检查基数纪律。
- 补盲窗、正归因:哪段"有耗时无信号"?哪段时间被记错了主?
- 对标准:用 OTel GenAI 词汇映属性,ID 用 W3C,每行带 schema 版本。
- 定横切策略:fail-silent、关闭零开销、内容捕获档位、隐私、传播。
- 定存储:append-only + 跳坏行 + 分区 + 轮转 + 索引/明细分离。
- 建消费层:列表 / 瀑布 / 聚合(p50/p95)/ 对比 / 成本。
- 可验证:写测试守住不变量——结构正确、并发隔离、fail-silent、关闭时零产物。
- 迭代:每当线上出现"这个问题我答不上来",就是一处埋点缺口,补上即进化。
小结
给 AI Agent 做可观测性,最难的不是技术,是那份"倒着想"的纪律——先列出"将来会被追问的问题",再回头决定埋什么。完整实现见 stephonGAO/milu 的 src/milu/observability/。
如果你在调 Agent 时遇到过"想复盘却发现什么都没记"的情况,欢迎在评论区说说你最想回答却答不上来的那个问题,我看看能不能在下一篇里展开。觉得有用记得点赞收藏,方便回头查 👍
更多推荐
所有评论(0)