可观测性:追踪一条任务的代价
本文总结把 Agent、模型、检索和工具串成一条 trace,再看延迟、成本和失败类型,找出慢在哪、贵在哪、错在哪。
一条任务是一棵调用树
员工说“帮我把 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 数各用什么属性名。采用时要固定所用约定的版本,并留意哪些字段还不稳定。本文的字段名不是什么永久标准。
成本按实际调用记账
一个任务的成本,就是它触发的每一次模型调用和工具调用的费用加起来,失败的、重试的也算:
| 符号 | 含义 | 单位 |
|---|---|---|
| 这一个任务的总成本 | 元 | |
| 第 j 次调用,模型调用和工具调用都编号 | ||
| 这个任务的调用总数,包括失败和重试产生的调用 | 次 | |
| 、 | 第 j 次调用的输入、输出 token 数;工具调用这两项为 0 | token |
| 、 | 每个输入、输出 token 的单价 | 元/token |
| 第 j 次调用的工具费,比如外部发票验真接口按次收费;模型调用这一项为 0 | 元 |
账单通常按“每百万 token 多少元”标价,代入前要先除以一百万。假设(示意)输入 2 元/百万 token、输出 8 元/百万 token,那么 ,。
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 定律
系统稳定运行、统计口径一致时,在途任务数、到达速度和逗留时间之间有个简单关系:
- :系统里平均同时有多少个任务,单位“个”。
- :读作 lambda,长期平均的到达率。系统稳定时它等于完成率,单位“个/秒”。
- :一个任务从进入到离开平均待多久,包括排队时间,单位“秒”。
手算:每秒来 2 个报销任务,每个平均耗时 5 秒,,平均有 10 个任务在途。如果 query_invoices 从 200 ms 变慢到 2 s,每个任务多等 1.8 秒, 变成 6.8 秒,。假如工具服务的连接池只有 10 个并发,这时请求就开始排队,排队又让 继续变长。
这个式子说的是长期平均。它给不出硬件容量,也不是峰值并发的上限。如果到达率超过系统处理能力,队列会一直变长,系统根本不稳定,套用公式也就没有意义。限流、排队、突发流量要靠压测来验证。
一张够用的仪表盘
- 任务结果:成功、失败、拒绝、用户取消分开统计。员工问“帮我报同事的发票”被拒,这是正确的拒绝,不能算进失败率。
- 失败分类:检索、模型、工具、权限、超时、预算。示意:本周 37 个失败,其中 21 个是
query_invoices超时,优先处理哪里一眼就能看出来。 - 资源:每任务的 token 和费用、工具调用次数、上下文长度。上下文长度突然变长,常常是检索塞进了整份《差旅费管理办法》。
- 分位延迟:按任务类型和模型版本分组,避免平均数掩盖长尾。
- 版本对比:用相同的 case_id 对照回归,每个点都能跳转到脱敏后的轨迹,评测方法见 Agent 评测:从测试集到发布门槛。
练习一:把假工具的响应时间从 200 ms 改成 2 s,看端到端延迟涨了多少、关键路径上是哪个 span 变长。如果这个工具和检索并行而且检索更慢,端到端可能涨得比 1.8 秒少。练习二:让第一次模型调用失败并重试,核对这个任务的成本是不是恰好多了一次调用的费用,没有漏记也没有重复记。页面里的数字都是演示用的,不是本站的运行指标。重试本身怎么设计,见 可靠执行:状态、幂等与恢复。