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 个 1min | P1 | 查失败 run 的 error.type,看是否集中在一个工具 |
| P95 延迟翻倍 | p95_ms > 基线 × 2 | 连续 3 个 1min | P2 | 按 span 分解延迟,定位模型还是检索变慢 |
| 单 run 成本超预算 | cost_per_run > $0.05 | 连续 5 个 5min | P2 | 看 token 用量分布,是否上下文爆炸或重试风暴 |
| 某工具错误率飙升 | tool_error_rate > 0.10 | 连续 3 个 1min | P1 | 查该工具下游状态码,可能第三方挂了 |
| 队列积压 | queue_backlog > 100 | 连续 2 个 1min | P2 | 看 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 + 1h | 14.4x | 立刻叫醒,正在快速烧预算 |
| 慢烧 | 6h + 3d | 6x | 开工单,慢性消耗 |
快烧捕捉突发故障(几分钟内烧掉大量预算),慢烧捕捉「每天掉一点」的慢性问题——后者最容易被忽略,却常常是服务慢慢变差的主因。两档并存,才不会「小漏不管、大漏才醒」。
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 手册里该有的内容):
确认影响面:看板 ① 健康度,确认是全站还是单租户。若是单租户,先看是不是该租户流量异常。
锁定时间窗:记下告警时间窗(如
03:00~03:05),所有后续查询都限定在这段时间。按工具下钻:看板 ④ 错误 Top N,确认
fetch_url错误率 24%,远高于其它工具 → 根因高度集中在fetch_url。捞失败 run:从 13.1 的结构化日志里按时间窗与工具名过滤:
jq -c 'select(.event=="tool.call" and .gen_ai.tool.name=="fetch_url" and .ok==false)' \ researchcopilot.log | head定位到单次 run:从上面捞出的行里取一个
run_id,用trace_id到追踪后端查那次 run 的完整 span 树,看fetch_url到底失败在什么状态码。看错误类型:如果全是
429(限流),是下游第三方限流,处置是退避重试 + 降级;如果全是503,是下游挂了,处置是切备用源 + 熔断。止血与复盘:先扩容/降级止血,恢复后写复盘——这次是告警先发现还是用户投诉先到?如果是后者,说明规则有缺口。
这份脚本的价值在于:把「凭感觉排查」变成「照单执行」。半夜被叫醒的人不需要创造力,需要一个清单。它也倒逼 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 / ok | 13.1 结构化日志 |
| 定位单次 run | span 的 trace_id / run_id | 13.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 延迟剖析:模型/检索/工具各占多少 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。