Agent 质量与生产化 · 可观测性
学习舱 / Agent 质量与生产化 / 可观测性

可观测性:Trace、Replay 与成本追踪

整理中 深度 · 应用 更新 2026-09-09

前置 Harness 概览

可观测性:Trace、Replay 与成本追踪

一句话定位:给运行中的 Agent 装上仪表盘——本文给出一套 Trace 字段清单(记什么)、一条 Replay 判据(怎么离线复现失败)、一笔 token 账(花多少)与四类监控指标(何时报警):①②③落到一个 50 行的迷你追踪器,④落到一份体检清单。前置:Harness 总览——五部件在那篇定义;本篇管的是它们运行起来之后「看得见看不见」。

先从一句「昨晚它失败了」说起

Agent 上线后每天跑几百个任务,绝大多数好好的。某个下午用户来问:「昨晚那个查询任务失败了,修一下。」你打开日志,从头翻到尾,只找到一行 ERROR: task failed——

哪个模型哪一版跑的?工具收到什么参数?报错原文是什么?这一单花了多少 token?全是空白。没有现场,就既不能复现、也不能归因、更不能验证修好了——只能改两行看起来可疑的代码,然后祈祷。

一条失败的任务,要留下什么才能被修好?

四件:①全链路 Trace(每步留痕,字段成规范)、②Replay(不跑模型复原现场)、③成本账(花多少钱)、④监控告警(何时报警)。四件合起来就是 Agent 的仪表盘——本篇逐个落地,动手环节是一个 50 行的迷你追踪器,把「记什么」的差距真跑给你看(结构见图 1)。

全景图:一次任务,四件仪表

可观测性四件套:一次任务,四路消费 Agent 执行 规划 · 检索 · 工具 子代理 · 人工确认 · 输出 每个环节都是一个事件 ① 全链路 Trace 事件流逐条落盘: 模型与版本 · 输入输出 token · 耗时 · 工具参数 字段成规范,才可横向比对 ② Replay 复盘 不跑模型,复原失败现场 产出:归因 + 回归素材 ③ 成本账 token × 单价 按任务 / 用户 / 天汇总 ④ 监控与告警 业务 / 模型 / Agent / 安全 四类指标,超线即喊 ④的告警里塞一条①的 Trace 链接 读法:Trace 是唯一的事实来源,②③④都是它的消费方——记不全,三路全瞎

图 1:可观测性四件套——Agent 执行产生事件流,①按字段规范记成 Trace,②Replay 复原现场、③成本账、④监控告警是同一份数据的三种消费

渐进拆解:给 Agent 配一套行车记录仪

类比:这套东西的车里都有。①Trace 是行车记录仪——全程录像,不挑日子;②Replay 是事故后调录像逐帧看——不用把车再开一遍;③成本账是油表与油耗记录——每次出行烧了多少,清清楚楚;④监控告警是仪表盘警示灯——油量低、水温高,灯亮的同时告诉你哪里不对。四个件共用一路录像,少装任何一个,出事的那天你都会重新想要不要装。

① 记什么:事件流与字段规范

Agent 的一次任务是一条事件流:规划、检索、工具调用、子代理委托、人工确认、最终输出——每个环节记成一条事件。字段命名已有一套公认口径:OpenTelemetry 的 GenAI 语义约定(2026 年仍在活跃演进)用统一命名记 LLM 调用——模型名 gen_ai.request.model、输入输出 token gen_ai.usage.input_tokens / output_tokens、结束原因 gen_ai.response.finish_reasons。

Trace 结构上是一个顶层 invoke_agent 跨度(span:一段有起止时间的记录,时间轴上的一块),套着每次模型调用的 chat 与每次工具调用的 execute_tool 子跨度。自己记 JSONL 没问题,字段语义对齐这套口径(示例用短名,接 OTel 时映射成 gen_ai.*),将来换观测平台数据不用重录。字段清单至少含——少记一样,②的 Replay 就瘸一样:

字段用途OTel 对应名
任务 id 与代码/提示词版本复现的是当时那一版—
模型与版本记下是哪个模型哪一版在跑gen_ai.request.model
每步输入与输出复原现场—
工具名 + 参数原文 + 结果原文定位失败步execute_tool 子跨度
token 与耗时进成本账与延迟指标gen_ai.usage.input_tokens / output_tokens
结束原因判断这次怎么收的尾gen_ai.response.finish_reasons

一个重要的默认值:内容默认不采集。OTel 的约定里,提示词正文与工具参数属于需要显式开启的内容捕获——默认只记模型名、token、耗时这些元数据。这与安全篇的脱敏是同一条纪律:上下文是出域数据,观测系统不该成为新的泄露面。折中做法是正文记哈希、参数记到失败步——够复现,不带隐私。

② Replay:不跑模型,复原现场

判据只有一条:拿 trace 文件离线重放,能定位失败步、能复原参数与报错原文,就算合格。注意与模型调用与容错篇的 VCR 录制回放(录像机式的录制回放)区分:VCR 录的是模型响应,用来让测试不必真调模型;本篇 Replay 消费的是执行事件流,用来复原一次失败任务的现场——同一份记录的两种消费,记的时候就要按「将来要被重放」来记。

Replay 的三份产出:

  • 现场:参数与报错——图 2 右侧就是从一份 trace 复原的全部现场;
  • 修复验证:改完代码,把旧 trace 的输入重放一遍看结果,归因手法在评估篇展开;
  • 回归素材:失败案例攒起来,就是评估篇里 golden set——带标准答案的回归基准题集——的毛坯。

③ 成本账:token 是按个收费的

成本追踪的位置在调用层统一拦截——模型调用出口记 token 与耗时,工具调用出口记耗时与结果,不散落在业务代码里(在调用层统一拦截是通行做法)。账要能按三个维度汇:单任务(这一单花了多少)、按用户或租户(谁在烧钱)、按天(趋势)。单价换算与原始 token 分开记——「8620 token」是原始值,「$0.12」是按某日牌价的折算,两条账各挂各的出处,模型一降价历史账不改。

④ 监控告警:四类指标,超线即喊

业界通行做法把指标归四类,每类给两三个代表:

指标类代表指标异常信号
业务任务成功率、人工接管率成功率跌破基线、接管率抬升
模型延迟(p95)、错误与限流率延迟抬升或错误率跳高,常在换模型或供应商后最先现形
Agent步数分布、工具失败率平均步数突增(任务拆解变差或死循环抬头)
安全拦截次数、注入命中数突增可能意味着被盯上(接安全篇的审计留痕)

告警阈值怎么定才不是拍脑袋?两条纪律:先观测后定线——上线先跑一两周拿分布,告警线设在 p95(把数值从小到大排,95% 位置的那个值,代表「最慢 / 最贵的那批」)之外而不是平均值上,平均值会被长尾淹没;告警必须带现场——每条告警塞一条 Trace 链接,点开就是失败步的参数与报错,不能让值班的人再从零找起。

一条失败任务的 Trace:三步三个事件 事件 1 · plan(模型 m-main@2026-08) 「查 Q3 销售额」→ 决定调 run_sql · 812+46 token · 1430 ms 事件 2 · tool run_sql —— ok=false 参数原文 {"sql": "SELECT sum(amount) FROM q3_report"} 报错原文:错误:关系 q3_report 不存在 · 120 ms 事件 3 · finish status=failed · 88 token · 900 ms 离线 Replay 读到什么 失败步:第 2 步 run_sql 复现参数:SQL 原文 现场报错:表不存在 token 账:812 输入 / 134 输出 读法:三个事件三条横条,失败的那条红着——参数与报错原文都在,现场就还在

图 2:一条失败任务的 Trace 事件流——每步带 token 与耗时,失败步记参数与报错原文;右侧是 Replay 离线读出的全部现场

动手:50 行的迷你追踪器

第一步:记两版 Trace。 同一次失败任务,完整版与省钱版各记一份,保存为 tracer.py:

import json

# —— 追记器:每个事件落一行 JSONL(字段语义对齐 OTel 的 gen_ai 口径,示例用短名)——
def save(path, events):
    with open(path, "w") as f:
        for e in events:
            f.write(json.dumps(e, ensure_ascii=False) + "\n")

# 场景 A:完整字段版——版本、每步输入输出、token、耗时、工具参数与报错原文全记
run_a = [
    {"step": 1, "type": "plan", "model": "m-main@2026-08", "input": "帮我查 Q3 销售额",
     "output": "调 run_sql 查 q3_report", "in_tokens": 812, "out_tokens": 46, "ms": 1430},
    {"step": 2, "type": "tool", "tool": "run_sql",
     "args": {"sql": "SELECT sum(amount) FROM q3_report"},
     "result": "错误:关系 q3_report 不存在", "ok": False, "ms": 120},
    {"step": 3, "type": "finish", "status": "failed", "reason": "工具报错直接收尾",
     "in_tokens": 0, "out_tokens": 88, "ms": 900},
]

# 场景 B:省着记的版本——只记了步骤名与一句摘要
run_b = [
    {"step": 1, "type": "plan", "summary": "查 Q3 销售额"},
    {"step": 2, "type": "tool", "summary": "查询失败"},
    {"step": 3, "type": "finish", "summary": "失败收尾"},
]

save("trace_full.jsonl", run_a)
save("trace_slim.jsonl", run_b)

# —— 离线 Replay:不跑模型,逐行重放 + 汇账 + 定位失败 ——
def replay(path):
    cost_in = cost_out = 0
    fail = None
    for line in open(path):
        e = json.loads(line)
        cost_in += e.get("in_tokens", 0)
        cost_out += e.get("out_tokens", 0)
        if e.get("ok") is False:
            fail = e
    print(f"== {path} ==")
    if fail:
        print(f"失败步:第 {fail['step']} 步 {fail.get('tool')}")
        print(f"复现参数:{fail.get('args')}")
        print(f"现场报错:{fail.get('result')}")
    else:
        print("连失败步都定位不到——事件没记 ok 标记,参数与报错更是没记")
    print(f"token 账:输入 {cost_in} + 输出 {cost_out}\n")

replay("trace_full.jsonl")
replay("trace_slim.jsonl")

实跑输出(已验证,python3 tracer.py):

== trace_full.jsonl ==
失败步:第 2 步 run_sql
复现参数:{'sql': 'SELECT sum(amount) FROM q3_report'}
现场报错:错误:关系 q3_report 不存在
token 账:输入 812 + 输出 134

== trace_slim.jsonl ==
连失败步都定位不到——事件没记 ok 标记,参数与报错更是没记
token 账:输入 0 + 输出 0

对着输出读:同一桩失败,完整版 Replay 出全部现场——失败步、SQL 原文、报错原文、token 账(812 输入 + 134 输出,与两条事件的分账吻合);省钱版连失败步都得靠人翻摘要才找得到,参数与报错没记,修无可修。这就是「记什么」的答案:不是越多越好,是 Replay 时你会回头找的一样都不能少。接真系统时把 JSONL 换成 OpenTelemetry 的导出器即可——字段语义本文已对齐(示例用短名,接 OTel 时映射成 gen_ai.*),chat / execute_tool 的跨度结构也是现成的。

第二步:给你的 Agent 做一次观测体检,五个动作:

  • 列事件:对照任务生命周期(规划/检索/工具/子代理/确认/输出),缺记哪环节;
  • 对字段:拿 Replay 判据自问——只凭现在记的字段,昨晚的失败能复现吗;
  • 查成本:token 与耗时是否在调用层统一拦截,能按任务/用户/天汇;
  • 数指标:四类指标各挑两三个,基线跑起来了吗;
  • 演一遍告警:随手造一条失败,告警响不响、点开有没有 Trace 链接。

边界与取舍

全记与隐私、成本,三选二要想清楚。 内容全记,Replay 能力最强,但上下文出域与存储成本都涨;只记元数据,账面便宜,失败步却可能复现不了。业界的默认折中(OTel 同款)是元数据常开、内容按需开,再按失败或慢任务做采样——正常任务只记账,出问题的任务记全文。采样开关的判断线与安全篇一致:数据值多少钱,捕获就开多细。

最后的坏味道预警:如果日志里只有 ERROR 一行,是①没装;如果告警天天响但没人点开看,是④的阈值拍脑袋加告警不带现场——两条的解法都在本文。而「告警之后按 Runbook 走哪几步」的收尾,在生产化里展开。

自测

Trace 要记什么字段,才能离线 Replay 一条失败任务?

Replay 时会回头找的一样都不能少:任务 id 与代码/提示词版本(复现的是当时那一版)、模型与版本、每步的输入与输出原文、工具名加参数原文加结果原文(尤其失败步的报错原文)、token 与耗时、结束原因。判据是「不跑模型、不靠运气」:拿 trace 文件离线重放,能定位失败步、复原参数与报错。字段名建议对齐 OpenTelemetry 的 gen_ai 口径(gen_ai.request.model / usage tokens / finish_reasons),换观测平台不用重录。

成本异常告警的阈值怎么定才不是拍脑袋?

两条纪律:一是先观测后定线——上线先跑一两周拿真实分布,告警线设在 p95(数值从小到大排,95% 位置的那个值)之外而不是平均值,平均会被长尾淹没;二是告警必须带现场——每条告警塞一条 Trace 链接,点开就是失败步与账目。成本特有的线另设两条:按天与按用户的预算上限(突发烧钱单独告警),与质量线分开管。成本本身在调用层统一拦截记原始 token,折算美元单独挂当日牌价,模型降价不回改历史账。

本篇的 Replay 与模型调用与容错篇的 VCR 录制回放有什么区别?

消费对象不同:VCR 录的是模型响应本身,回放是为了让测试与开发不必真调模型(省成本、确定性);本篇 Replay 消费的是执行事件流(含工具参数、报错原文、版本号),是为了复原一次失败任务的现场(归因、修复验证、攒回归素材)。记的时候就要按「将来要被重放」设计字段——两者可以共享同一份底层数据,但字段取舍不同。

参考与延伸

  • Inside the LLM Call – GenAI Observability with OpenTelemetry(OpenTelemetry 官方博客,2026):gen_ai.* 字段、invoke_agent / chat / execute_tool 跨度结构与内容捕获默认关闭的官方口径
  • 延伸阅读:失败任务的归因与评估(Trace Analysis、13 类失败归因)在 Agent Eval 里展开——Replay 攒下的失败案例正是那里的 golden set 毛坯
本节点大纲与自测题(5 个子点 · 3 问)

大纲

  • Agent Trace 全链路:请求/规划/检索/工具/子 Agent/人工确认/输出
  • 模型与工具调用日志规范(字段清单)
  • Replay 机制:失败任务复现、修复验证、回归素材
  • Token 成本与执行耗时拦截记录
  • 监控四类指标与异常告警:业务/模型/Agent/安全

自测——答不上来就是缺口

  • Trace 要记什么字段,才能离线 Replay 一条失败任务?
  • 成本异常告警的阈值怎么定才不是拍脑袋?
  • 本篇的 Replay 与模型调用与容错篇的 VCR 录制回放有什么区别?