《AI Agent 工程实战》13.3 告警与 on-call:从 trace 到告警演练

告警要少而准:给出覆盖成功率、P95 延迟、单 run 成本、工具错误率、队列积压的规则表,讲 SLO 与错误预算(99% 成功率每月可消耗多少失败)与连续窗口门槛为何能压噪声。真跑一个极简告警求值器,喂正常与劣化两段序列,打印触发判定;末尾给从告警定位到具体 run 与工具的演练脚本。

13.3 告警与 on-call:从 trace 到告警演练

13.2 的看板能告诉你「现在怎么样」,但看板是被动的——没人盯着屏幕时,服务挂了三小时你也未必知道。告警要做的是主动打断:在指标越过红线时,把正在睡觉的 on-call 叫醒。但叫醒人是有代价的,叫错了代价更大:一个总在误报的告警,会让团队逐渐学会「看到就忽略」,等真正的事故来临时,没人再当回事。

本节把 ResearchCopilot 推进到「可 on-call」:给出覆盖五类异常的告警规则表,讲清 SLO 与错误预算,用一个极简告警求值器真跑正常与劣化两段序列,并写一段从告警下钻到具体 run 与工具的演练脚本。

13.3.1 告警要少而准:信噪比与告警疲劳

告警的核心指标是信噪比:发出的告警里,真正需要人介入的比例。信噪比低会引发告警疲劳——on-call 被无关告警反复吵醒后,会开始「无脑确认」,真正的 P0 混在一堆噪声里被漏掉。历史上很多重大事故的复盘里都有这一条:「告警其实响了,但没人看」。

压低噪声有三招,缺一不可:

手段做法压掉什么噪声
门槛连续 N 个窗口都超标才报单点抖动、瞬时毛刺
分级按影响面分 P1/P2/P3把所有事都当 P1 的焦虑
归并同一根因的多条告警合并成一条雪崩时的告警风暴

反过来,该报不报同样是事故。一条经验是:每一个告警都必须对应一个明确的动作——收到它之后要做什么,写不出动作的告警就是噪声,直接删掉。下面规则表里的「处置」一列,就是逼自己把动作写清楚。

一个具体的反例:曾经有团队给「每次 run 失败」都发一条告警。上线第一周还正常,等到某天模型服务抖动、失败率飙到 30%,on-call 手机在十分钟里收到两千条告警——真正的根因(模型服务故障)被淹没在告警洪水里。正确做法是把「单次失败」升级为「成功率跌破阈值」这种聚合指标告警:它不会因为一次失败就响,只有整体变差才响。告警的对象是「趋势」,不是「单点事件」——单点事件属于日志和 trace,不属于告警。

13.3.2 告警规则表

ResearchCopilot 的五条核心告警,覆盖 13.2 的四类指标加队列积压:

现象指标 / 阈值窗口级别处置动作
成功率跌破success_rate < 0.95连续 3 个 1minP1查失败 run 的 error.type,看是否集中在一个工具
P95 延迟翻倍p95_ms > 基线 × 2连续 3 个 1minP2按 span 分解延迟,定位模型还是检索变慢
单 run 成本超预算cost_per_run > $0.05连续 5 个 5minP2看 token 用量分布,是否上下文爆炸或重试风暴
某工具错误率飙升tool_error_rate > 0.10连续 3 个 1minP1查该工具下游状态码,可能第三方挂了
队列积压queue_backlog > 100连续 2 个 1minP2看 worker 是否卡死或副本不足,先扩容止血

几个设计点值得说明:

  • P95 用「相对基线翻倍」而不是绝对值。写死 > 5000ms 会在业务量变化、模型换代后频繁失效;用「相对过去 7 天同时段的基线」则能自适应。这与 13.2 看板的对照基线是同一思路。
  • 不同指标的窗口不同。延迟和错误要灵敏(1min),成本要迟钝(5min)——成本是累积量,短窗口抖动大,容易被误报。
  • 队列积压窗口最短(2 个)。积压意味着请求在排队,用户体验已经在恶化,宁可早报。

13.3.3 SLO 与错误预算

SLO(服务等级目标)是对用户承诺的可用性,比如「99% 的 run 成功」。它天然蕴含一个错误预算:既然允许 1% 失败,那每个月就有 月运行量 × 1% 次失败是「预算内」的,不算违约。

错误预算把「能不能发布」从主观争论变成算术:预算没用完,可以继续发;预算烧光了,就该冻结发布、先修稳定性。它还能算燃烧率(burn rate)——当前失败率相对 SLO 的倍数:

燃烧率 = (1 - 实际成功率) / (1 - SLO)

燃烧率 1x 表示正好按预算节奏烧,30 天用完;13x 表示按当前速度两天多就烧光一个月预算,必须立刻处理。用一段真实算术说明:

SLO=99%  月运行量=360,000  每月失败预算=3,600 次
  正常 99.4%: 燃烧率 0.6x  → 按此速度 50.0 天耗尽一个月预算
  劣化 87.0%: 燃烧率 13.0x  → 按此速度 2.3 天耗尽一个月预算

注意正常时段燃烧率 0.6x < 1,说明失败比预算允许的还少,是在「攒预算」;劣化时段 13x,两天多就烧穿。燃烧率是比成功率更直观的告警信号,因为它已经换算成了「还剩多少时间」。

真实系统通常用两档燃烧率同时告警,兼顾「快」与「慢」:

档位观测窗口阈值用途
快烧5min + 1h14.4x立刻叫醒,正在快速烧预算
慢烧6h + 3d6x开工单,慢性消耗

快烧捕捉突发故障(几分钟内烧掉大量预算),慢烧捕捉「每天掉一点」的慢性问题——后者最容易被忽略,却常常是服务慢慢变差的主因。两档并存,才不会「小漏不管、大漏才醒」。

13.3.4 为什么规则要「连续 N 个窗口」

规则表里每条都有「连续 N 个窗口」。这个门槛是抗抖动的关键:一次网络抖动、一批慢查询、一次 GC,都可能让某个 1 分钟窗口短暂超标,但如果下一个窗口恢复正常,就不该叫人起床。

用状态机理解:每个窗口算一次「是否超标」,只有连续 N 次超标才进入触发态。这等价于一个「需要连续 N 个 True」的过滤器。N 越大越稳但越迟钝——P1 用 3、成本用 5,就是在「别漏报」和「别误报」之间按严重级别取的不同平衡点。

一个具体例子:假设阈值是 p95 > 5000ms,某分钟恰好有一批慢查询让 P95 冲到 5100ms,下一分钟就回到 4800ms。若规则是「单窗口超标即报」,这就是一次误报;加上「连续 3 窗口」门槛后,只要这批慢查询没持续,就不会惊动任何人。代价是:如果真的是持续劣化,触发会晚 2 个窗口——这正是「灵敏」与「稳定」的取舍,没有两全,只能按严重级别分配。

13.3.5 真跑:极简告警求值器

把规则写成数据,求值器逐窗口扫描、统计连续超标数:

def is_bad(snap, rule, baseline):
    v = snap[rule["metric"]]
    if rule["op"] == "<":
        return v < rule["threshold"]
    if rule["op"] == ">":
        return v > rule["threshold"]
    if rule["op"] == ">x":                       # 相对基线的倍数
        return v > baseline[rule["metric"]] * rule["threshold"]
    raise ValueError(rule["op"])

def evaluate(series, baseline, rules):
    out = []
    for rule in rules:
        streak, fired = 0, None
        for i, snap in enumerate(series):
            streak = streak + 1 if is_bad(snap, rule, baseline) else 0
            if streak >= rule["for_windows"]:
                fired = i
                break
        out.append((rule, fired, streak))
    return out

喂两段各 6 个窗口的序列:A 段全程正常,B 段从第 3 个窗口开始劣化。真跑(Python 3.14.6):

=== 场景 A:正常时段 ===
  [未触发] success_rate_low           最长连续 0 个窗口 (阈值 <0.95, 需 3)
  [未触发] p95_latency_doubled        最长连续 0 个窗口 (阈值 >x2.0, 需 3)
  [未触发] cost_per_run_over_budget   最长连续 0 个窗口 (阈值 >0.05, 需 5)
  [未触发] tool_error_spike           最长连续 0 个窗口 (阈值 >0.1, 需 3)
  [未触发] queue_backlog              最长连续 0 个窗口 (阈值 >100, 需 2)

=== 场景 B:劣化时段(第 3 窗口起) ===
  [触发!] P1 success_rate_low  @窗口4 连续3个  success_rate=0.865
          处置: 查失败 run 的 error.type,看是否集中在一个工具
  [触发!] P2 p95_latency_doubled  @窗口4 连续3个  p95_ms=10200
          处置: 按 span 分解延迟,定位是模型还是检索变慢
  [未触发] cost_per_run_over_budget   最长连续 4 个窗口 (阈值 >0.05, 需 5)
  [触发!] P1 tool_error_spike  @窗口4 连续3个  fetch_url_error_rate=0.24
          处置: 查 fetch_url 下游状态码,可能第三方挂了
  [触发!] P2 queue_backlog  @窗口3 连续2个  queue_backlog=240
          处置: 看 worker 是否卡死或副本不足,先扩容止血

=== SLO / 错误预算 ===
SLO=99%  月运行量=360,000  每月失败预算=3,600 次
  正常 99.4%: 燃烧率 0.6x  → 按此速度 50.0 天耗尽一个月预算
  劣化 87.0%: 燃烧率 13.0x  → 按此速度 2.3 天耗尽一个月预算

读这份输出,有三个只有真跑才能发现的细节:

  • A 段五条全部「最长连续 0」,一条没触发——正常时段零误报,这正是信噪比的底线。
  • B 段 queue_backlog 在窗口 3 就触发,比其它四条早一个窗口。因为它的门槛只需 2 个窗口,而积压在窗口 2 就已经 130 了。窗口数越短越灵敏,代价是更容易被瞬时抖动触发,所以它只给了 P2。
  • cost_per_run 没有触发——它在 B 段连续超标了 4 个窗口(窗口 2~5),但门槛是 5。这是故意的教学设计:如果 B 段再多一个劣化窗口,它就会触发。这说明门槛设高了会漏报,成本这条规则的 5 窗口是「宁可慢一点」的取舍;反过来,如果某次真实事故里成本是最早的信号,就该把它的窗口调短。

13.3.6 告警内容要自带上下文

告警发出来时,如果只写「成功率低」,on-call 还得自己去看板翻。好的告警内容自带下钻入口:

字段内容作用
现象success_rate=0.865 < 0.95一眼知道越了什么线
时间窗2026-10-05 03:00~03:05定位到具体时段
级别P1决定要不要半夜叫人
影响面受影响 workspace 数 / run 数判断是不是全局
下钻链接看板面板 + trace 查询 URL一键跳到现场
处置动作规则里写好的那句话不用现想第一步做什么

最后一行「处置动作」最容易被忽略,却最省时间——凌晨三点被叫醒的人,最需要的是「第一步先做什么」,而不是从零推理。

一条这样的告警发出来长这样(Slack / PagerDuty 载荷的简化版):

{
  "severity": "P1",
  "summary": "success_rate=0.865 < 0.95(窗口 03:00~03:05)",
  "impact": "受影响 workspace 3 个 / 失败 run 27 次",
  "dashboard": "https://grafana.example/d/researchcopilot",
  "trace_query": "https://tempo.example/traces?service=researchcopilot&from=03:00&to=03:05",
  "runbook": "1) 看工具错误 Top N  2) 定位 run_id  3) 看下游状态码"
}

dashboard 与 trace_query 两个链接是一键下钻入口:点 dashboard 看全局,点 trace_query 直接跳到那一批失败的 span。这两个链接把 13.1 的 trace 和 13.2 的看板缝在了告警里,收到告警的人不用在三个系统之间手输查询条件。

13.3.7 演练脚本:从告警到具体那次 run

收到「P1 成功率跌破 + fetch_url 错误率飙升」后,按下面的脚本逐步排查(这是 on-call 手册里该有的内容):

  1. 确认影响面:看板 ① 健康度,确认是全站还是单租户。若是单租户,先看是不是该租户流量异常。

  2. 锁定时间窗:记下告警时间窗(如 03:00~03:05),所有后续查询都限定在这段时间。

  3. 按工具下钻:看板 ④ 错误 Top N,确认 fetch_url 错误率 24%,远高于其它工具 → 根因高度集中在 fetch_url。

  4. 捞失败 run:从 13.1 的结构化日志里按时间窗与工具名过滤:

    jq -c 'select(.event=="tool.call" and .gen_ai.tool.name=="fetch_url" and .ok==false)' \
       researchcopilot.log | head
    
  5. 定位到单次 run:从上面捞出的行里取一个 run_id,用 trace_id 到追踪后端查那次 run 的完整 span 树,看 fetch_url 到底失败在什么状态码。

  6. 看错误类型:如果全是 429(限流),是下游第三方限流,处置是退避重试 + 降级;如果全是 503,是下游挂了,处置是切备用源 + 熔断。

  7. 止血与复盘:先扩容/降级止血,恢复后写复盘——这次是告警先发现还是用户投诉先到?如果是后者,说明规则有缺口。

这份脚本的价值在于:把「凭感觉排查」变成「照单执行」。半夜被叫醒的人不需要创造力,需要一个清单。它也倒逼 13.1 的埋点必须齐全——脚本第 4 步依赖日志有 gen_ai.tool.name 和 ok,第 5 步依赖 span 有 trace_id;埋点缺一个,脚本就断一环。

把每一步和它依赖的数据源对齐,能清楚看到「观测体系」是怎么被 on-call 消费的:

步骤依赖的数据来自哪一节
确认影响面成功率 / 运行量指标13.2 看板 ①
锁定时间窗指标时间戳13.2
按工具下钻gen_ai.tool.name 分组错误率13.2 看板 ④
捞失败 run日志的 gen_ai.tool.name / ok13.1 结构化日志
定位单次 runspan 的 trace_id / run_id13.1 span 树
看错误类型span 的 error.type 属性13.1 埋点

这张表反过来就是埋点验收单:演练脚本里任何一步取不到数据,就说明对应的埋点缺了。用 on-call 脚本倒推埋点清单,比凭空想「该埋什么」可靠得多。

演练不能只在事故时才走一遍。定期的故障演练(第 18.2 节)会把这份脚本拿出来真跑:人为注入一次 fetch_url 故障,看告警是否按时触发、on-call 能否按脚本在十分钟内定位到根因。演练暴露的缺口——某个链接失效、某条日志字段缺失、某条规则阈值太钝——都要在演练后立刻补上,而不是等真事故来验证。

13.3.8 小结与验收

本节把 ResearchCopilot 从「有看板」推进到「可 on-call」:

  • 信噪比是告警的生命线,靠门槛、分级、归并三招压噪声。
  • 规则表覆盖成功率、延迟、成本、工具错误率、队列积压五类,每条都有明确动作。
  • SLO 与错误预算把「能不能发布」变成算术;燃烧率 13x 意味着两天多烧穿月预算。
  • 连续 N 窗口门槛是抗抖动的核心,窗口数按严重级别取值。
  • 演练脚本把排查从感觉变成清单,且每一步都依赖前面章节的埋点。

验收清单:

  • 每条告警规则都写了「处置动作」,写不出动作的已删除
  • P95 用相对基线而非绝对值,避免模型换代后失效
  • 关键告警有「连续 N 窗口」门槛,不因单点抖动误报
  • SLO 与错误预算有明确数字,燃烧率可计算
  • on-call 手册里有一份从告警下钻到具体 run 的演练脚本

真实环境 / 真实模型下会怎样:本节的指标序列是手工构造的六个窗口,形状干净、边界清晰。真实告警面对的是高频、多维度、互相耦合的信号:一次下游故障会同时打高错误率、延迟、队列积压,如果三条规则各自独立触发,on-call 手机一次会收到十几条告警——这正是「归并」要解决的(按时间窗与根因把相关告警合成一条)。真实系统还有本节没展开的两个工程点:其一是告警的闭环——每次告警都该被记录为「真阳性/假阳性」,定期复盘假阳性率高的规则并调参或删除;其二是值班轮换与升级链——P1 多久没人响应就升级到二线,避免告警发出却无人接。此外,真实环境里「基线」本身会随业务增长漂移,需要用移动窗口动态更新,而不是写死一个常量。

阅读导航:上一节:13.2 指标与看板:延迟/成功率/成本/工具错误 · 下一节:14.1 延迟剖析:模型/检索/工具各占多少 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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