← 回到知识图谱
实践持续完善

可观测性:追踪一条任务的代价

本文总结把 Agent、模型、检索和工具串成一条 trace,再看延迟、成本和失败类型,找出慢在哪、贵在哪、错在哪。

创建 2026-10-31更新 2026-11-02阅读约 12 分钟
Agent工程实践扩展学习

一条任务是一棵调用树

员工说“帮我把 INV-102 这张打车发票报了”。报销 Agent 处理这句话时,大致会走下面这些步骤,每一步记成一个 span,整棵树共用一个 trace_id:

task / trace_id
├─ context.assemble
├─ retrieval.search
│  ├─ keyword.search
│  └─ vector.search
├─ model.generate
├─ tool.execute / action_id / idempotency_key
└─ result.verify

context.assemble 拼出员工信息(P6、上海)和对话历史;retrieval.search 下面两路并行,一路关键词检索,一路向量检索,找《差旅费管理办法》第 4.2 条;model.generate 决定调用哪个工具;tool.execute 实际调用 create_reimbursement_ticket;result.verify 回查工单是否真的建好。

每个 span 至少记开始和结束时间、状态、属于哪个任务、相关的版本号。模型调用另外记 token 用量、首 token 时间和停止原因;工具调用记工具名、参数摘要、超时设置和这是第几次重试。下面是一个工具 span 的示意:

{
  "trace_id": "t-7f31",
  "span_id": "s-05",
  "parent_span_id": "s-01",
  "name": "tool.execute",
  "tool": "create_reimbursement_ticket",
  "action_id": "act-3",
  "idempotency_key": "t-7f31:act-3",
  "args_digest": {"invoice_id": "INV-102", "amount": 118.5, "over_limit": 38.5},
  "attempt": 1,
  "timeout_ms": 10000,
  "start": "2026-11-02T10:00:02.310+08:00",
  "end": "2026-11-02T10:00:02.720+08:00",
  "status": "ok",
  "result": {"ticket_id": "R-55821"},
  "versions": {"prompt": "p-12", "tool_schema": "ts-4", "policy_index": "2026-10-30"}
}

出了问题,比如员工投诉“我报了一次怎么有两张单”,按 trace_id 拉出这棵树,看 tool.execute 下面是不是有两个 attempt、两个 attempt 的 idempotency_key 是否相同,基本就能定位是 Agent 重复调用,还是服务端没去重。

参数摘要里只放排查需要的字段。完整的输入输出可能有员工姓名、银行卡号、发票图片,默认要脱敏、抽样存储,并设保留期限和查看权限。

这里用的是一个最小的自定义字段集。OpenTelemetry 的 GenAI 语义约定 给出了跨系统的命名参考,比如模型名、输入输出 token 数各用什么属性名。采用时要固定所用约定的版本,并留意哪些字段还不稳定。本文的字段名不是什么永久标准。

成本按实际调用记账

一个任务的成本,就是它触发的每一次模型调用和工具调用的费用加起来,失败的、重试的也算:

Ctask=∑j=1m(nj,inpj,in+nj,outpj,out+cj,tool)C_{task}=\sum_{j=1}^{m}(n_{j,in}p_{j,in}+n_{j,out}p_{j,out}+c_{j,tool})
符号 含义 单位
CtaskC_{task} 这一个任务的总成本 元
jj 第 j 次调用,模型调用和工具调用都编号
mm 这个任务的调用总数,包括失败和重试产生的调用 次
nj,inn_{j,in}、nj,outn_{j,out} 第 j 次调用的输入、输出 token 数;工具调用这两项为 0 token
pj,inp_{j,in}、pj,outp_{j,out} 每个输入、输出 token 的单价 元/token
cj,toolc_{j,tool} 第 j 次调用的工具费,比如外部发票验真接口按次收费;模型调用这一项为 0 元

账单通常按“每百万 token 多少元”标价,代入前要先除以一百万。假设(示意)输入 2 元/百万 token、输出 8 元/百万 token,那么 pin=2×10−6p_{in}=2\times10^{-6},pout=8×10−6p_{out}=8\times10^{-6}。

INV-102 这个任务共 4 次调用,其中第 2 次模型调用超时后重试了(示意数字):

j 调用 输入 token 输出 token 计算 费用(元)
1 模型:决定先查发票 3000 200 3000×2e-6 + 200×8e-6 0.0076
2 模型:超时,按已生成部分计费 3500 150 3500×2e-6 + 150×8e-6 0.0082
3 模型:重试,生成工单参数 4200 300 4200×2e-6 + 300×8e-6 0.0108
4 工具:发票验真接口 0 0 每次 0.01 0.0100
合计 0.0366

如果只统计成功的那次模型调用,会漏掉第 2 行的 0.0082 元,占这个任务成本的两成多。反过来,如果重试时把第 2 次的 span 又上报了一遍,就会重复计费。按 span_id 去重可以避免这种情况。超时的调用到底计不计费、计多少,要看服务商的规则,以账单为准。

缓存也要单独拆开算。很多服务对命中缓存的输入 token 按更低的价格收费。设第 3 次调用的 4200 个输入 token 里有 3000 个命中缓存,缓存价是 0.5 元/百万 token(示意):3000 × 0.5e-6 = 0.0015,加上未命中的 1200 × 2e-6 = 0.0024,输入部分是 0.0039 元,原来按全价算是 0.0084 元。不拆开的话,成本会被高估一倍多。价格要取自己账单的快照,本页不预设任何实时价格。

只看每次请求的成本还不够,要再看每个成功任务的平均成本:一批任务的总成本除以成功的任务数。示意两套方案各跑 100 个任务:

方案 每任务成本 成功数 总成本 每个成功任务成本
A:小模型 0.030 60 3.0 3.0 / 60 = 0.050
B:大模型 0.040 90 4.0 4.0 / 90 ≈ 0.044

A 每次请求更便宜,但每拿到一个有效结果反而更贵,失败的 40 个任务后面还要人工处理。成功数为 0 时这个指标没有定义,要单独报“本批全部失败”。

延迟与并发

关键路径

串行的步骤,时间可以直接相加;并行的分支,只取最慢的那条。用上面那棵树算一遍(示意):

span 耗时 说明
context.assemble 50 ms
keyword.search 120 ms 与向量检索并行
vector.search 300 ms 与关键词检索并行
融合排序 20 ms 等两路都返回后
model.generate 1800 ms 其中首 token 600 ms
tool.execute 400 ms
result.verify 80 ms

把所有 span 直接加起来是 50 + 120 + 300 + 20 + 1800 + 400 + 80 = 2770 ms,这个数没有意义。两路检索并行,只算慢的 300 ms,端到端是 50 + 300 + 20 + 1800 + 400 + 80 = 2650 ms。这时把关键词检索从 120 ms 优化到 60 ms,端到端一点都不会变,因为它不在关键路径上。要变快,得优化向量检索或模型生成。

首 token 时间是 600 ms,用户很快就看到字开始出来了,但任务要到 2650 ms 才真正完成。首 token 变快只说明“开始回应”变快了,任务完成时间要看端到端延迟。端到端延迟、排队时间、首 token 时间、工具耗时要分别记。

为什么看 P95 而不看平均

示意:某天抽 20 个报销任务,端到端耗时(秒)从小到大排好:

1.6、1.8、1.9、2.0、2.1、2.1、2.2、2.3、2.3、2.4、2.5、2.5、2.6、2.7、2.8、2.9、3.1、3.4、9.0、12.0

  • 平均值:总和 64.2 秒,除以 20 得 3.21 秒。
  • P50(中位数):一半请求比它快。按最近秩法取第 ⌈0.5 × 20⌉ = 10 个,2.4 秒。⌈·⌉ 是向上取整。
  • P95:95% 的请求不超过它。取第 ⌈0.95 × 20⌉ = 19 个,9.0 秒。

平均值 3.21 秒,看着还行;可是这 20 个人里有 2 个等了 9 秒以上。最慢的两个往往是同一个原因,比如 query_invoices 偶尔超时后重试。平均值会把这种长尾摊平。分位数要按任务类型和模型版本分组看。不同监控系统计算分位数的方法不一样,有的按桶插值,有的按最近秩,跨系统对比前要先确认口径。20 个样本算 P95 本身也很不稳,这里只是演示算法。

错误预算

把延迟目标写成 SLO,例如“一周内 95% 的报销任务在 10 秒内完成”。允许超时的那 5% 就是错误预算。一周 2000 个任务,预算是 2000 × 5% = 100 个慢任务。

到周三晚上(一周 7 天过去 3 天),已经出现 70 个慢任务,用掉了 70% 的预算。时间只过去 3/7 ≈ 43%,预算却用掉 70%,消耗速度是正常速度的 0.70 / 0.43 ≈ 1.63 倍,这个比值常叫 burn rate。按这个速度,大概第 4.3 天预算就会用完(7 ÷ 1.63 ≈ 4.3)。这时应该先停下新功能发布,去查长尾。

Little 定律

系统稳定运行、统计口径一致时,在途任务数、到达速度和逗留时间之间有个简单关系:

L=λWL=\lambda W
  • LL:系统里平均同时有多少个任务,单位“个”。
  • λ\lambda:读作 lambda,长期平均的到达率。系统稳定时它等于完成率,单位“个/秒”。
  • WW:一个任务从进入到离开平均待多久,包括排队时间,单位“秒”。

手算:每秒来 2 个报销任务,每个平均耗时 5 秒,L=2×5=10L = 2 \times 5 = 10,平均有 10 个任务在途。如果 query_invoices 从 200 ms 变慢到 2 s,每个任务多等 1.8 秒,WW 变成 6.8 秒,L=2×6.8=13.6L = 2 \times 6.8 = 13.6。假如工具服务的连接池只有 10 个并发,这时请求就开始排队,排队又让 WW 继续变长。

这个式子说的是长期平均。它给不出硬件容量,也不是峰值并发的上限。如果到达率超过系统处理能力,队列会一直变长,系统根本不稳定,套用公式也就没有意义。限流、排队、突发流量要靠压测来验证。

一张够用的仪表盘

  1. 任务结果:成功、失败、拒绝、用户取消分开统计。员工问“帮我报同事的发票”被拒,这是正确的拒绝,不能算进失败率。
  2. 失败分类:检索、模型、工具、权限、超时、预算。示意:本周 37 个失败,其中 21 个是 query_invoices 超时,优先处理哪里一眼就能看出来。
  3. 资源:每任务的 token 和费用、工具调用次数、上下文长度。上下文长度突然变长,常常是检索塞进了整份《差旅费管理办法》。
  4. 分位延迟:按任务类型和模型版本分组,避免平均数掩盖长尾。
  5. 版本对比:用相同的 case_id 对照回归,每个点都能跳转到脱敏后的轨迹,评测方法见 Agent 评测:从测试集到发布门槛。

练习一:把假工具的响应时间从 200 ms 改成 2 s,看端到端延迟涨了多少、关键路径上是哪个 span 变长。如果这个工具和检索并行而且检索更慢,端到端可能涨得比 1.8 秒少。练习二:让第一次模型调用失败并重试,核对这个任务的成本是不是恰好多了一次调用的费用,没有漏记也没有重复记。页面里的数字都是演示用的,不是本站的运行指标。重试本身怎么设计,见 可靠执行:状态、幂等与恢复。

这是一篇可以继续生长的笔记。沿着关系继续阅读 ↗