一、先搞清楚:监控(Monitoring)和可观测性(Observability)不是一回事

很多人把"监控"和"可观测性"当同义词,但给 AI Agent 做追踪时,这俩驱动的设计完全不同:

对比项Monitoring 监控Observability 可观测性
回答的问题已知的:还活着吗?CPU 高吗?未知的:它为什么这次绕了三圈才答?
设计时机事前知道要看什么,做好仪表盘事后能对已记录数据提出当初没预料的新问题
表现形态阈值、告警、红绿灯可钻取、可还原、可对比单次请求全过程

记住这句总纲:可观测性 = 为"将来会被追问的问题"提前留下证据。

为什么 Agent 比普通服务更需要它?因为它有四重黑箱:

  1. 非确定性:同样的输入,LLM 每次可能走不同路径,难复现;
  2. 多步因果链:一次回答是"想 → 调工具 → 看结果 → 再想",错在哪一环肉眼看不见;
  3. 隐性成本:token 在悄悄烧钱,子 Agent 还会放大;
  4. 涌现性故障:死循环、调错工具、参数幻觉,都是过程问题。

下面用开源 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;跨进程(微服务、子进程)用 W3C traceparent header 把 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.modelgen_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.namegen_ai.usage.input_tokens/output_tokensgen_ai.response.finish_reasons
  • 自有概念(标准没覆盖的,如 fail_open、判定理由)走私有命名空间 milu.*
  • ID 用 W3C Trace Context,天然可跨系统对接;
  • 每行带 schema 版本号,将来迁移有依据。

八、存储设计:append-only + 索引明细分离

trace 是只增的洪流,存储设计决定系统能不能长期跑。五条可复用套路:

  1. Append-only 抗崩溃:span 一结束就 append 一行 JSONL,进程崩了已写的行还在。
  2. 读时跳过坏行:某行写一半挂了,读取跳过这行而非整文件报废。
  3. 按实体分区:trace 索引按 runs/{safe_user_id}.jsonl 分文件,多用户只读自己那份。
  4. 轮转 + 保留上限:运行索引超 RUNS_INDEX_MAX_LINES(5000)就原子重写丢最旧;trace 明细按 retention_days 过期清理。任何"永久追加"的文件都必须有天花板。
  5. 索引与明细分离(最值得学):一次运行落两处——轻量索引 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(甚至任何后端服务)设计监测,照这十问走一遍:

  1. 列问题:将来必须能回答哪些追问?(行为/性能/成本/可靠性/可比较/审计)
  2. 画树:把执行画成"工作单元的嵌套树",这些就是 span 候选。
  3. 定粒度:逐个判 span / 属性 / 事件 / 丢掉;检查基数纪律。
  4. 补盲窗、正归因:哪段"有耗时无信号"?哪段时间被记错了主?
  5. 对标准:用 OTel GenAI 词汇映属性,ID 用 W3C,每行带 schema 版本。
  6. 定横切策略:fail-silent、关闭零开销、内容捕获档位、隐私、传播。
  7. 定存储:append-only + 跳坏行 + 分区 + 轮转 + 索引/明细分离。
  8. 建消费层:列表 / 瀑布 / 聚合(p50/p95)/ 对比 / 成本。
  9. 可验证:写测试守住不变量——结构正确、并发隔离、fail-silent、关闭时零产物。
  10. 迭代:每当线上出现"这个问题我答不上来",就是一处埋点缺口,补上即进化。

小结

给 AI Agent 做可观测性,最难的不是技术,是那份"倒着想"的纪律——先列出"将来会被追问的问题",再回头决定埋什么。完整实现见 stephonGAO/milusrc/milu/observability/

如果你在调 Agent 时遇到过"想复盘却发现什么都没记"的情况,欢迎在评论区说说你最想回答却答不上来的那个问题,我看看能不能在下一篇里展开。觉得有用记得点赞收藏,方便回头查 👍

更多推荐