《AI Agent 工程实战》13.2 指标与看板:延迟/成功率/成本/工具错误

把 13.1 的 span 数据聚合成四类指标:延迟分位(P50/P95/P99)、成功率、每 run 成本、按工具名分组的错误率,讲清平均值为何被长尾骗、直方图与摘要的差别、以及 run_id 当标签会炸掉时序库的基数问题;给出四块看板布局。真跑 200 次模拟 run,打印分位表与错误工具 Top 榜。

13.2 指标与看板:延迟/成功率/成本/工具错误

13.1 把每次 run 的细节都记下来了:span 树、结构化日志、token 用量。但细节太多等于没有细节——你没法盯着十万条 span 看出「服务是不是变差了」。人脑需要一个更高的视角:把海量单次记录压缩成几条随时间变化的曲线,一眼看出趋势。这就是指标(metrics)要做的事。

本节把 ResearchCopilot 推进到「有看板」:把 13.1 的 span 数据聚合成延迟分位、成功率、每 run 成本、工具错误率四类指标,讲清为什么平均值会骗人、指标基数为什么会炸库,并用 200 次模拟 run 真跑出分位表与错误工具 Top 榜。

13.2.1 从 span 到指标:聚合是降维

span 和指标是两种粒度的同一份数据。span 是「这一次运行发生了什么」,指标是「最近这段时间整体怎么样」。前者不可聚合、不可画图,后者可以:

手段粒度保留什么回答
span / trace单次运行完整细节这次为什么慢
日志单个事件逐条事件此刻发生了什么
指标时间窗聚合分位 / 计数 / 求和最近变好还是变差

聚合的代价是丢细节:P95 延迟 9 秒,你并不知道是哪 5% 的 run 拖的。所以正确姿势是指标先报警、再下钻到 trace——指标负责「发现异常」,trace 负责「解释异常」。这也是 13.3 告警的底座。

从 span 到指标有三类基本运算,覆盖了本节全部四类指标:

运算输入产出本节用在
计数事件流count运行量、失败数、工具调用数
比率两个计数a / (a+b)成功率、工具错误率
分布一批数值分位 / 均值 / 极值延迟、成本

计数与比率是可加的——两个窗口的成功数直接相加仍是成功数。分布不可加——两个窗口的 P95 相加毫无意义。这个区别决定了指标的存储方式,也是 13.2.3 要展开的。

本节选的四类指标,恰好覆盖「快、稳、省、准」四个方向,缺一不可:

指标方向少了它会怎样
延迟分位快用户嫌慢,你却不知道慢到哪
成功率稳服务在报错,看板一片绿
每 run 成本省账单月底爆炸才发现
工具错误率准知道「变差」但定位不到是哪个工具

这四类也是后面 13.3 告警规则表覆盖的全部对象——指标看板是「常态观察」,告警是「异常打断」,两者吃的是同一份数据。

13.2.2 延迟分位:为什么平均值会骗人

延迟绝不能用平均值衡量。Agent 一次 run 的耗时是典型的长尾分布:大多数请求几秒内返回,少数请求因为多跳检索、模型重试、工具超时拖到几十秒。平均值会被这些长尾往上拉,拉到一个「没有任何一次真实运行会落在那里」的虚假中心。

分位(percentile)才是正确工具。P50 是中位数(一半请求比它快),P95 是「100 次里有 5 次比它慢」,P99 更极端。一个不依赖库的实现:

import math

def percentile(values, p):
    xs = sorted(values)
    k = (len(xs) - 1) * p / 100.0
    lo, hi = math.floor(k), math.ceil(k)
    return xs[lo] if lo == hi else xs[lo] * (hi - k) + xs[hi] * (k - lo)

这个实现用的是线性插值:当 k 落在两个样本之间时,按距离加权。不同库(Prometheus 的 histogram_quantile、numpy 的 percentile)插值口径略有差异,所以同一个 P95 在不同系统里可能差几毫秒——跨系统对数时要知道这个前提,别拿两个口径不同的数硬比。

分位之外还有一条经验:报告 P95/P99,而不是只报平均。SLO 通常也按分位定(比如「P95 延迟 < 3s」),因为平均值达标不代表体验达标——100 个用户里 5 个卡了两分钟,平均可能依然「很好看」。

13.2.3 分位怎么算出来:直方图与摘要

上一节的 percentile() 需要拿到全部原始样本才能算。但线上每秒几十万次 run,不可能把每条耗时都存下来。时序库用两种结构近似分位:

结构原理优点缺点
直方图(histogram)把数值落进预设的桶计数可跨实例 / 跨窗口合并桶边界外精度损失
摘要(summary)流式估计分位(如 t-digest)内存小、单实例准不可合并

关键差别在「能不能合并」。直方图存的是「每个桶有多少个样本」,两个实例的直方图逐桶相加仍是一个合法直方图,所以能聚合成全局 P95;摘要存的是估计器状态,两台机器的摘要无法相加,多副本聚合就失真。

对 ResearchCopilot 这种多 worker 并发的服务,这个差别是决定性的:run 分散在 N 个 worker 上跑,全局 P95 必须能合并。所以延迟与成本都用直方图,而不是摘要。本节的模拟聚合为了讲清概念直接拿了全量样本,真实系统里这一步会换成「各 worker 上报直方图 → 后端按桶合并 → 再算分位」。

13.2.4 成功率与成本

成功率是最简单的健康指标,但要定义清楚「什么算成功」。本节口径:一次 run 的所有工具调用都没返回 {"error": ...}、且模型正常收敛(finish_reasons 为 end_turn),才算成功。含糊的定义(比如「没抛异常就算成功」)会把「模型幻觉出一个不存在的工具但没崩」也算成成功,掩盖真问题。

每 run 成本由 token 用量算出来:

cost = in_tok / 1e6 * 3.0 + out_tok / 1e6 * 15.0   # 输入 $3/M、输出 $15/M

注意输入输出单价不同,而且差好几倍——输出 token 通常贵得多。所以成本不能只看总 token 数,得分开算。真实环境里还要叠上缓存命中折扣(第 6 章的语义缓存)和小模型路由(14.2),本节先按基准价算,把口径立住。

成本是 Agent 服务特有的指标——它既可能因为上下文爆炸(第 6 章没裁剪好)飙升,也可能因为重试风暴飙升,两者在成本曲线上长得一模一样,必须结合成功率一起看:

成功率成本判读
跌涨重试风暴:失败后反复重跑
平涨上下文变长:prompt 或历史膨胀
跌平纯功能故障:工具或模型报错
平跌缓存或小模型路由生效了

这张 2×2 表比任何单条曲线都值钱——它把「两个指标一起看」变成了可执行的判断。

13.2.5 工具错误率:按工具名分组

整体成功率是个「混合物」,它会掩盖单点故障。一个工具彻底挂掉,如果只占全部调用的 10%,整体成功率可能只掉几个点,看板看着「还行」,但那个工具已经废了。所以工具错误率必须按 gen_ai.tool.name 分组:

from collections import defaultdict

agg = defaultdict(lambda: [0, 0])       # tool_name -> [调用数, 失败数]
for r in runs:
    for c in r["calls"]:
        agg[c["name"]][0] += 1
        agg[c["name"]][1] += 0 if c["ok"] else 1
for name, (tot, err) in sorted(agg.items(), key=lambda kv: -(kv[1][1] / kv[1][0])):
    print("%-18s %6d %8d   %6.2f%%" % (name, tot, err, err / tot * 100))

按工具分组后,fetch_url 的错误率飙升会独立地跳出来,不被其它健康工具稀释——这是「从整体指标下钻到局部」的标准动作。sorted 的 key 用错误率降序,排在第一行的就是最该查的那个工具。

进一步还可以按 error.type 再分一层(超时 / 4xx / 5xx / 参数错),但那是 trace 的活——指标层只要回答「哪个工具在冒烟」,细因留给下钻。

13.2.6 指标基数:别把 run_id 当标签

时序库(Prometheus / VictoriaMetrics)把「指标名 + 标签组合」当作一条独立的时间序列。标签值每多一个不同取值,就多一条序列。把 run_id 或 workspace_id 当标签,是最经典的基数(cardinality)事故:

标签取值数量级后果
status2(ok / error)安全
gen_ai.tool.name个位数安全
gen_ai.request.model个位数安全
workspace_id数百到数万危险,序列数爆炸
run_id每次 run 都不同灾难,写爆时序库

算一笔账:run_agent_duration_ms 这个指标,如果带 workspace_id(假设 5000 个租户)× gen_ai.tool.name(8 个)× status(2 个),就是 80000 条序列;再叠上 run_id 这种「每次都不一样」的标签,序列数永不收敛,内存和索引直接撑爆。这就是「基数爆炸」——它不会立刻报错,而是慢性地把时序库拖垮。

规则很简单:标签只放「取值有限、可枚举」的维度;高基数的东西(run_id / workspace_id)只进 span 与日志,不进指标标签。真要按租户看,就在日志或 trace 里过滤,或者用「每租户单独的聚合作业」落成一张低基数的结果表,而不是塞进时序标签。

13.2.7 聚合放在哪里:在线埋点 vs 离线批算

指标可以在两个地方算,各有取舍:

方式怎么算优点代价
在线埋点应用内直接 counter.inc() / histogram.observe()实时、口径统一侵入业务代码、要接 SDK
离线批算从 span / 日志批处理聚合零侵入、可重算有延迟、要一套批处理

本卷的处境很特殊:13.1 已经确认本机只有 OTel API、没有 SDK,在线埋点这条路暂时走不通。所以本节的真实路径是离线批算——把 13.1 导出的 span 当作数据源,跑一个聚合作业产出指标快照。这条路线的额外好处是可重算:口径改了(比如成功率定义变严),历史数据可以按新口径重跑一遍,而在线埋点的历史数据改不了。

真实系统里通常是两者并存:关键的计数器与直方图在线埋点(要实时告警),复杂的派生指标离线批算(要灵活)。本节先把离线这条打通,13.3 的告警求值器就吃它产出的指标快照。

13.2.8 真跑:200 次模拟 run 的聚合

用固定随机种子造 200 次模拟 run 的 span 数据(每次 run 含 2~5 个工具调用,失败 run 更慢——模拟重试),聚合成四类指标。真跑(Python 3.14.6):

样本: 200 次 run

[延迟] 平均值 3823 ms | P50 2668 | P90 7183 | P95 9000 | P99 28079 | max 32569
      平均值比 P50 高 1156 ms(43%),被长尾拉高了

[成功率] 83.0%  (166/200 成功, 34 失败)

[成本] 总 $5.16 | 每 run 均值 $0.0258 | P50 $0.0255 | P95 $0.0376 | 最贵 $0.0417

[工具错误率] 工具                调用数   失败数   错误率
             fetch_url             156       31    19.87%
             search_corpus         161        2     1.24%
             query_pg              157        1     0.64%
             summarize             163        1     0.61%

[失败率 Top3 workspace] ws_01=26% ws_03=23% ws_00=20%

四个结论,逐个读:

  • 平均值 3823 ms,比 P50(2668 ms)高出 43%。这就是「平均值骗人」的实证:没有任何一次 run 的典型耗时是 3823 ms,它是被 P99(28079 ms)那批长尾拉上去的。只看平均会以为「差不多三秒多」,实际一半请求在两秒七以内就返回了,而最慢的 1% 卡了近 28 秒。
  • 成功率 83.0%,34 次失败。这个数单独看还行,但要结合下面的工具错误率才知道根因。
  • 成本均值 $0.0258、P95 $0.0376,方差不大——说明这一批里没有上下文爆炸,成本是健康的。
  • fetch_url 错误率 19.87%,是其它工具的 20 倍。整体成功率从 ~99% 掉到 83%,几乎全是 fetch_url 一个工具拖的。这就是按工具分组的意义:整体指标只会告诉你「变差了」,分组指标直接告诉你「是 fetch_url 挂了」。

顺带看最后一行 ws_01=26%:失败率最高的租户比最低的高出十几个点。但这不能直接当成「租户问题」——样本只有 200 次、分摊到 6 个租户后每户几十次,26% 和 20% 的差别很可能只是小样本波动。小样本上的比率差异要谨慎归因,这也是 13.3 告警要设「连续 N 个窗口」门槛的原因之一。

13.2.9 看板布局:四块面板

一个 Agent 服务的看板,按「从宏观到微观、从症状到根因」排四块:

面板展示什么一眼要看出的
① 健康度成功率、运行量、失败数(折线 + 当前值)服务整体是否正常
② 延迟P50 / P95 / P99 三条线 + SLO 阈值线尾延迟是否突破 SLO
③ 成本每 run 成本、总成本、token 用量趋势钱有没有异常烧
④ 错误 Top N按 gen_ai.tool.name 的错误率排行是哪个工具在冒烟

四块面板的排查动线是固定的:①看到成功率跌 → ②确认不是整体延迟拖垮的 → ④定位到具体工具 → 拿着时间窗回 13.1 的 trace 里捞那次 run。① 是哨兵,④ 是根因,②③ 负责排除干扰项。

布局上有个原则:症状面板在上、根因面板在下。人从看板顶部往下扫,第一眼该看到「服务好不好」(①),而不是一屏密密麻麻的分位细节。把根因藏在第二屏,是为了强制先判断影响面、再决定要不要深挖——避免一有风吹草动就扎进细节。

每块面板还要配一个对照基线,否则「P95 9 秒」这行数字没有意义——是比昨天好还是坏?常见做法是把「过去 7 天同时段」的曲线用浅色叠在当前曲线下面,异常时两条线一劈叉,肉眼立刻能发现。本节 13.3 的告警阈值也建立在同样的基线思路上(p95_ms > 基线 × 2)。

13.2.10 指标口径是契约

指标名和口径一旦上线,下游(看板、告警、成本报表)就全按它写死了。改口径比改代码危险得多,因为它不会报错,只会让曲线悄悄变形:

改动后果
成功率定义从「无 error」改成「含 end_turn」历史曲线出现断崖,告警误判
延迟单位从 ms 改成 s阈值全部失效,形同虚设
指标名重命名看板空白、告警静默失效
标签增减时序库序列数突变,历史不可比

所以口径要写进代码注释与文档;真要改,走评审、并保留新旧两套指标并行一段时间,确认下游都切过去了再下掉旧的。这和 API 的版本兼容是同一类问题——指标也是一种对外接口。

13.2.11 小结与验收

本节把 13.1 的原始 span 压成了能看、能画、能告警的四类指标:

  • 延迟用分位而非平均,实证平均值被长尾拉高 43%。
  • 成功率定义要精确到「工具无 error 且模型正常收敛」。
  • 成本按 token 算,需与成功率联合判读根因。
  • 工具错误率必须按工具名分组,才能从整体下钻到单点。
  • 基数:run_id / workspace_id 绝不进指标标签。

验收清单:

  • 延迟看板报 P50/P95/P99,不只报平均
  • 成功率定义明确写进代码,不含糊
  • 工具错误率按 gen_ai.tool.name 分组,能排出 Top N
  • 指标标签里没有 run_id 这类高基数维度
  • 每块面板都有明确的下钻目标(trace 或日志)

真实环境 / 真实模型下会怎样:本节数据是固定种子的模拟值,长尾形状是人为设定的。真实系统的延迟分布更极端——P99 可能是 P50 的二十倍以上,而且会随上游模型服务的负载剧烈抖动;真实成本也远不止本节这个量级,一次多跳研究可能烧掉几美分。真实看板还会遇到本节没展开的两个问题:其一是聚合窗口的选择——5 分钟窗口灵敏但抖,1 小时窗口稳但迟钝,通常要同时放粗细两档;其二是指标与 trace 的关联——看板上的一个毛刺要能一键跳到那个时间窗的 trace 列表,这依赖 13.1 里 run_id 与时间戳的稳定关联。此外,真实的成本指标往往要按 workspace_id 拆,但如前所述这不能进时序标签,得落成单独的低基数汇总表。

阅读导航:上一节:13.1 结构化日志与追踪:OTel GenAI 语义约定埋点 · 下一节:13.3 告警与 on-call:从 trace 到告警演练 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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