告警不是心疼 token,是根因没找对:把 400 条告警压成 1 条根因的归因管道实战
前两天大促凌晨三点,群里突然炸了。Prometheus 弹了 216 条,业务埋点弹了 128 条,日志监控弹了 62 条,一共 406 条告警在四分钟内全部甩进值班群。三个值班同学在屏幕前刷屏,每个人都看着自己那一路,每条告警下面都跟着"谁处理?"六个字。
47 分钟后,根因落定:一个下游 Redis 集群主从切换,导致调用方连接池被打满,连锁反应让七个中间服务都开始超时。那四十分钟里,没有任何一条告警直接指向这个 Redis 集群,大部分告警本身都没错------问题在于它们互相独立地报警,没有人告诉值班同学这 406 条告警之间是什么关系。
事后我把这套流水线搭起来了:告警先去重聚合,按服务拓扑做扩散传播收敛,用时间窗里的相关性打分排出候选根因,最后才让大模型用自然语言解释并给出止血建议。核心判断只有一条:大模型是最后一公里的解释器,不是定位器。定位靠拓扑与时序,大模型只负责把结构化结论翻译成人话和 runbook 步骤。一次事件一次调用,token 消耗被压到极低,而不是每条告警喂一次。
一、为什么"每条告警喂一次大模型"是错的
这事我们也试过,而且是先试的。当时的直觉很简单:告警里有指标名、阈值、发生时间,把每条告警连上下文一起喂给大模型,让它告诉我们根因是什么。结果是三个问题叠在一起。
第一是成本。400 条告警,每条带上指标标签、最近十分钟的时间序列、相关日志片段,轻轻松松每条 2 到 4 千个 token,一次事件就要喂几十万 token,按当时模型价格算,一次大促告警风暴可能烧掉几百块,而且几乎全浪费在重复信息上。
第二是幻觉。大模型最擅长的是"把已有信息复述得更漂亮",不是"从四百条没有因果关系的碎片里找出隐藏的因果链"。它会顺着最显眼的告警编故事:比如 CPU 高的那台机器其实是被拖累的受害者,大模型反而把它当成根因,值班同学按它的建议去重启机器,问题好不了。
第三是上下文塞不下。400 条告警连同标签和时间窗,远超单次对话的合理窗口,即使塞得下,信噪比也极低。最致命的是:告警本身没有因果信息。一条告警只能说"这个指标超阈值了",它不能告诉你这个指标是谁导致的,也不能告诉你它会不会波及下游。因果关系不在告警里,在拓扑和时间里。
所以我们换了一条路:规则先收敛,拓扑再传播,时序再打分,大模型只在最后解释。下面是几种做法的对比,数据是我们自己的生产环境统计,非官方基准。
| 方案 | token 成本 | 定位准确率 | 可解释性 | 实现复杂度 | 误报率 |
|---|---|---|---|---|---|
| 逐条喂 LLM | 极高 | 低,易被显眼症状误导 | 高,但解释的是错的 | 低 | 高 |
| 规则收敛 | 极低 | 中,只能压重复 | 低,只告诉数量少了 | 低 | 中 |
| 拓扑传播 | 极低 | 中上,能找上游 | 中,靠图结构 | 中 | 中 |
| 拓扑+时序+LLM 解释 | 极低(一次事件一次调用) | 高,根因 Top1 命中 | 高,结构化事实+人话解释 | 中高 | 低 |
二、整体架构
我把这条管道画了一张图,后面所有代码都挂在这上面。
图里最容易被忽略的是 D 到 H 之间那段:大模型输入的不是原始告警堆,而是已经收敛出来的结构化事实------哪几个服务受影响、传播分最高的是谁、时间戳早晚怎么排。它只做最后一百米。
三、告警归一化与指纹
三路告警长得不一样。Prometheus 给的是 alertname、instance、severity 和一堆标签;业务埋点是自己埋的事件,有 service、metric、value;日志监控是全文检索出来的错误计数,带 pod 和 namespace。我们要做的第一件事,是把它们映射到同一个 schema:
java
public record AlertEvent(
String service,
String instance,
String metric,
String severity,
long startedAt,
Map<String, String> labels,
String source // prometheus / biz-metric / log-monitor
) {}
字段解释:service 是服务名,instance 是实例,metric 是指标名,severity 是严重度,startedAt 是开始时间戳(毫秒),labels 是原始标签透传,source 记录告警来自哪一路,方便出问题时回溯原始数据。
三路源的映射关系不一致的地方比想象中多,我们踩了两轮才把表定下来:
| 统一字段 | Prometheus | 业务埋点 | 日志监控 |
|---|---|---|---|
| service | 由 job 标签映射 | 事件自带的 service | 从 pod 名前缀推断 |
| instance | instance 标签 | 上报的 host | namespace/pod |
| metric | alertname | 事件类型 metric | 错误关键词聚类后的类别 |
| severity | severity 标签 | 阈值等级自报 | 按错误率分级 |
| startedAt | startsAt | 首次触发时间 | 首次命中时间 |
最容易出错的是最后一行 startedAt。Prometheus 给的是告警开始时间,业务埋点给的是"我这一分钟统计到异常"的时间,日志监控给的是"第一次命中关键词"的时间,三个含义并不相同。我们最终统一成"该异常现象最早被观察到的时间",日志那一列因为采集延迟通常比实际晚十几秒,这个偏差在时序打分里会被吃掉一部分分,是已知损耗。
指纹怎么算?同一个故障会产生大量重复告警,比如 Redis 连接池打满,每个调用方都会报,而且每个调用方会连续报好几个周期。我们不能让这几十条告警各自走一遍后续流程,否则拓扑传播会被重复计数严重扭曲。
我们的做法是:用 service + metric 类型 + 时间窗桶 作为指纹。时间窗桶我们取两分钟,同指纹的告警做滚动计数,只在第一次达到阈值时推送一次入管道,后续同窗内的重复告警只累计计数不再推送。代码大致长这样:
java
public class FingerprintStore {
private final LoadingCache<String, AtomicLong> counts = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
public boolean shouldPush(AlertEvent event) {
String fp = event.service() + ":" + event.metric() + ":" + bucket(event.startedAt());
AtomicLong c = counts.get(fp, k -> new AtomicLong());
long n = c.incrementAndGet();
return n == 1; // 同指纹同窗只推第一条
}
private long bucket(long ts) {
return ts / (2 * 60 * 1000);
}
}
两分钟这个窗是拍脑袋定的,上线后调过一次。刚开始设成一分钟,结果一个抖动的指标在两分钟内跨窗,同一个故障被切成两次推送,传播分被砍半,根因排不到 Top3。后来改成两分钟,收敛稳定了,也没有明显延迟。
四、拓扑传播收敛:根因一定在上游
这是整条管道的核心,也是值班同学最需要的能力。直觉很简单:如果 A 调用 B,B 报错了,那 A 大概率是被 B 拖累的受害者,真正的问题更可能在 B 或者 B 的更上游。根因在拓扑上游,症状在下游。
我们维护一张服务依赖图,节点是服务,边是调用关系,方向是"被调用方"指向"调用方"(反向传播方向)。一条告警落在某个节点上,沿调用边向上游传播,每经过一边分数衰减,上游节点累计得分。得分最高的上游节点,就是候选根因。
比如下游服务 order-api 报 Redis 连接池满,拓扑上 order-api 被 gateway 调用,gateway 被 web 调用。告警从 order-api 出发,向 gateway 传播一分,向 web 传播衰减后的分。如果 order-api 本身得分也高,它自己也可能是根因;最终得分排序,Top1 就是我们要重点看的。
这里有个大坑:扇出节点。公共网关、公共中间件会被大量下游节点的传播分汇总到一起,如果不处理,网关永远得分最高,每次故障都指向它,完全没用。必须按入度做归一化:一个节点收到的传播分,除以它的入度(有多少个下游在往它传分)。这样公共网关虽然收到很多传播,但分母也大,不会虚高。
拓扑传播的 Java 代码(Spring Boot 3 风格):
java
@Component
public class TopologyPropagation {
private final TopologyStore topology; // service -> List<downstream>
private static final double DECAY = 0.6; // 每经过一边保留 60%
public List<ScoredNode> propagate(AlertEvent event) {
String start = event.service();
Map<String, Double> scores = new HashMap<>();
Deque<String> queue = new ArrayDeque<>();
queue.offer(start);
scores.put(start, 1.0);
while (!queue.isEmpty()) {
String cur = queue.poll();
double curScore = scores.get(cur);
List<String> upstreams = topology.upstreams(cur);
if (upstreams.isEmpty()) {
continue;
}
// 入度归一化:上游节点收到的分要除以它的入度,避免公共网关虚高
for (String up : upstreams) {
double inDegree = topology.inDegree(up);
double add = curScore * DECAY / Math.max(inDegree, 1);
scores.merge(up, add, Double::sum);
queue.offer(up);
}
}
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.map(e -> new ScoredNode(e.getKey(), e.getValue()))
.toList();
}
}
这段代码有两处值得注意。一是 DECAY 取 0.6,意味着传播三边后分数只剩原来的 21%,远端上游不会因为路径长就不停累分。二是 BFS 没有访问标记,因为拓扑图理论上是有向无环图,但万一配置中心出错出现环,这里会死循环。上线前我们加了最大传播深度 6 的保护,代码里为了清晰没写出来,实际部署时必须带。
入度归一化到底救回了什么,举个我们的真实例子。公共网关 api-gw 下游挂了 38 个服务,某次 order-api 报连接池满,不归一化时 api-gw 收到 1×0.6=0.6 分,看着不高;但只要有五个服务同时报警,它就能累到 3.0,把真正的 redis-cluster-a(得分 0.6)压下去。归一化后 api-gw 每次只能拿到 0.6÷38≈0.016,五条加起来不到 0.08,永远排不到前面。这个改动上线当天就把根因 Top1 命中率从 51% 拉到 70% 左右,是整条管道里性价比最高的一行代码。但也要注意分母的取法:inDegree 必须用拓扑图里的真实入度,而不是运行时观测到的活跃连接数,后者会随流量抖动,导致同一个故障在不同时间点算出不同的分,值班同学看到的结果不可复现就很麻烦。
上游节点累计得分最高者为候选根因,但只靠传播分不够。比如网关得分高可能是因为它下游多,入度归一化缓解了但没根除;这时候需要时序信息来校验。
五、时序相关性:谁先发生谁更可疑
根因的时间戳通常早于下游症状。如果 Redis 在 03:00:00 主从切换,调用方在 03:00:02 开始报连接池满,下游 API 在 03:00:05 开始报超时,这个先后顺序本身就是线索。我们用一个简单的时间偏移加权:越早发生的告警,在同一拓扑分支里得分越高;再配合上下游一致性校验------上游候选的开始时间必须早于或等于下游症状的开始时间,否则直接降分。
下面是可运行的 Java 打分代码,接上一节的拓扑传播结果,对候选根因做时序校验和排序:
java
@Component
public class TemporalScorer {
private static final long MAX_LAG_MS = 5 * 60 * 1000; // 只看五分钟内的偏移
private static final double PENALTY_PER_SECOND = 0.02; // 每晚一秒扣 2%
public List<ScoredNode> score(List<ScoredNode> topoCandidates,
Map<String, Long> serviceStartTimes) {
// 同一事件内最早的那条告警时间,作为"零时刻"
long earliest = serviceStartTimes.values().stream()
.filter(Objects::nonNull)
.min(Long::compareTo)
.orElse(System.currentTimeMillis());
return topoCandidates.stream()
.map(c -> {
long start = serviceStartTimes.getOrDefault(c.service(), earliest);
long lagMs = Math.max(0, start - earliest); // 越晚发生偏移越大
double temporal;
if (lagMs > MAX_LAG_MS) {
temporal = 0.1; // 超出窗口只降分,不完全剔除
} else {
temporal = Math.max(0.1, 1.0 - lagMs / 1000.0 * PENALTY_PER_SECOND);
}
return new ScoredNode(c.service(), c.score() * temporal);
})
.sorted(Comparator.comparingDouble(ScoredNode::score).reversed())
.limit(3)
.toList();
}
}
serviceStartTimes 来自归一化阶段的 startedAt,同一服务多条告警取最早时间戳。零时刻取整个事件里最早的那条告警,lagMs 是候选比它晚多少毫秒,PENALTY_PER_SECOND 取 0.02,意味着晚发生两分钟的候选得分大约打一折,晚发生五分钟几乎被淘汰。这个阈值不是理论推出来的,是上线后跑了几十次真实故障反复调出来的:刚开始不惩罚时间,结果一个昨晚已经恢复的慢查询告警,因为拓扑位置靠上,一直排在根因候选第一,值班同学被带沟里去了。
整个时序校验的判断顺序我画成了一张图,值班同学看这张图就能理解为啥某些告警会被降权:
图里两个判定分支都写成了矩形,这是刻意的。掘金的 Mermaid 渲染器对带引号的菱形标签兼容性很差,写成 T{"是否上游"} 大概率渲染成一片空白。我用矩形加文字"判定 是否上游"绕过去,视觉上粗糙一点,但至少图能出来。这个坑我第二篇踩过,所以这次直接避开。
D 分支和 F 分支的处理方式不一样,这也有讲究。不在上游路径上的高分公司,往往是因为入度归一化没压干净,直接降分到症状层,只做记录不进 Top3;而晚于下游的高分节点,是被打上"疑似并发干扰"的标签,仍然保留在 Top3 里,因为并发故障在真实环境里很常见,我们不能一刀切掉。这两种降权力度不同,是因为前者我基本能确定它不是根因,后者只是"时间上不像",不能排除。
时序打分有一个隐藏前提:时钟要同步。我们的服务都走 NTP,机器间时钟偏差控制在一秒以内,否则"谁先发生"这条线索本身就不可信。如果你的环境时钟漂移大,先把 NTP 修好,再上这套管道。
六、抑制与降噪
在拓扑传播和时序打分之前,先做一轮抑制。抑制规则的核心思想是:父告警在场时,子告警静默。比如 Redis 连接数打满是父告警,调用方的超时告警是子告警,父在场时子告警不入管道,避免重复干扰值班同学。
抑制规则用 YAML 配置,放在配置中心,运维可以热更新:
yaml
suppression:
rules:
- parent: "redis_connection_pool_full"
child: "api_timeout"
matchLabels:
service: "order-api"
- parent: "redis_master_slave_switch"
child: "redis_connection_pool_full"
matchAll: true
maintenanceWindows:
- name: "周二凌晨发布窗口"
cron: "0 0 * * 2"
durationMinutes: 60
services: ["order-api", "payment-api"]
抑制有个致命风险:把真根因吞了。如果父告警本身就是根因,抑制子告警没问题;但如果配置错了,把一个并发发生的独立故障当成子告警抑制,根因就丢了。我们加了一条硬约束:抑制只在"父告警严重度 ≥ 子告警"时生效。比如父告警是警告级,子告警是致命级,不抑制,因为致命级子告警本身可能就是另一个独立根因。
维护窗口和已知故障白名单也在这里处理。发布窗口期内的告警不入管道,只写日志不入群;已知故障(比如某个第三方支付接口每天定时维护)直接白名单放行,不再触发后续流程。这些规则上线初期我们调得太紧,有一次把一个真正的数据库连接泄漏当成"已知故障"静默了,半夜没人接,早上业务方投诉才发现。现在白名单里的每一项都要带到期日,过期自动失效,不再永久静默。
七、大模型那一段到底该干什么
到这里,管道已经给出了结构化结论:根因候选 Top3、每个候选的传播分和时序分、受影响的下游服务列表、开始时间。大模型的任务不是"从四百条告警里找根因",而是把这份已经收敛的结构化事实翻译成人话,并给出三条止血动作和需要人工确认的点。
输入只包含:候选根因、拓扑路径、传播分、时序分、受影响服务、已采取的抑制规则。输出是 JSON,方便直接渲染到群消息或工单里,不再依赖人工去读大模型的自然语言再手工记工单。
有一个细节决定了 token 能压到这么低:我们不把原始告警文本塞进去,而是塞"事实摘要"。比如原来是一条 ALERTS{alertname="redis_connection_pool_full", instance="10.2.7.31:6379", severity="critical"} 这样带着标签和样板的字符串,压缩成 redis-cluster-a / connection_pool_used=98% / 03:00:00 一行。四百条告警经过归一化、去重、传播、打分,最后进 Prompt 的事实通常不超过十五行,一千个 token 以内,模型的注意力全花在真正重要的几件事上。这也是我们后来能接受"一次事件一次调用"这个成本结构的根本原因:不是省调用费,是省掉了噪声。
Prompt 模板:
diff
你是可观测性告警归因助手。你只根据输入中的结构化事实得出结论,禁止编造任何未在输入中出现的服务、指标或时间。
输入:
- 候选根因:{{rootCauses}}
- 传播路径:{{paths}}
- 时序分:{{temporalScores}}
- 受影响下游:{{downstreams}}
- 抑制规则:{{suppressions}}
输出 JSON 字段:
- rootCauseHypothesis: 根因假设,只引用输入中出现的服务和指标
- confidence: 置信度 0-1
- stopBleedingActions: 三条止血动作,每条包含命令、目标服务、预期效果
- needsManualConfirmation: 需要人工确认的点,如"需确认 Redis 是否真的发生主从切换"
- doNotMention: 禁止在回复中出现的服务(输入未提及的任何服务)
只输出 JSON,不要自然语言废话。
最关键的是那条"不许编造"的约束:只允许引用输入里出现过的服务和指标。我们上线初期没加这条,大模型有一次把"Redis 连接池满"解释成"MySQL 连接泄漏",值班同学按 MySQL 方向去排查,浪费了十分钟。加上约束和 JSON Schema 校验后,这类彻底跑偏的现象没再出现。
JSON Schema 校验代码(Python):
python
import jsonschema
SCHEMA = {
"type": "object",
"required": ["rootCauseHypothesis", "confidence", "stopBleedingActions",
"needsManualConfirmation"],
"properties": {
"rootCauseHypothesis": {"type": "string"},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"stopBleedingActions": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"required": ["action", "target", "expectedEffect"],
"properties": {
"action": {"type": "string"},
"target": {"type": "string"},
"expectedEffect": {"type": "string"}
}
}
},
"needsManualConfirmation": {"type": "string"}
},
"additionalProperties": False
}
def validate_llm_output(text):
data = json.loads(text)
jsonschema.validate(data, SCHEMA)
# 额外的不许编造约束:假设里不能出现输入白名单之外的服务
allowed = {"order-api", "gateway", "redis-cluster-a"}
import re
mentioned = set(re.findall(r'[a-z-]+-api|redis-cluster-\w+', data["rootCauseHypothesis"]))
if not mentioned <= allowed:
raise ValueError(f"LLM 提及了未授权的服务:{mentioned - allowed}")
return data
校验不通过的输出直接丢弃,回退到纯结构化结论发群,不让大模型的自然语言直接干扰值班同学。一次事件一次调用,输入只有几百个 token,每次事件 token 消耗在十几个以内,远低于逐条喂 LLM 的几十万 token。
八、效果数据
上线后跑了一个多月,下面是我们自己的生产环境统计,样本 N=32 次告警风暴事件,非官方基准,只供参考:
| 指标 | 治理前 | 治理后 |
|---|---|---|
| 单次事件告警条数 | 400+ | 1~3(根因 Top3) |
| 根因 Top1 命中率 | 未统计,值班凭经验 | 78% |
| 平均定位时长 | 47 分钟 | 9 分钟 |
| LLM 调用次数 | 每条告警一次,每次事件几百次 | 每次事件一次 |
| 每次事件 token 数 | 几十万 | < 1000 |
| 值班打扰次数 | 每次风暴 3 人全员刷屏 | 1 人接单,其余静默 |
根因 Top1 命中率 78%,剩下的 22% 里,根因在 Top2 或 Top3,大模型解释的时候会把三个候选都列出来,值班同学按顺序排查。平均定位时长从 47 分钟降到 9 分钟,其中大部分时间花在"确认根因后执行止血动作"上,而不是"猜哪里出了问题"。
再补充几个上线后才想到的实施细节。滚动计数状态我们放在 Redis 里,每个指纹对应一个计数器,过期时间设成十分钟,防止指纹空间无限膨胀。跨服务拓扑合并是另一个容易忽略的点:同一个故障在多个服务上都产生告警时,每条告警独立做传播,最后要把同一根因收到的分加总,否则一个根因对应五条症状告警,分被摊在五个地方,反而排不过一个只产生一条告警的干扰节点。最后是降级策略:拓扑图如果暂时不可用(配置中心抖动),管道自动退化成纯时序打分,只按开始时间排序,不做传播;大模型如果不可用,直接发结构化结论,不强依赖。这些兜底没在架构图里画出来,但它们是系统能跑稳的原因。
九、踩过的坑
这套管道上线初期踩过不少坑,记四个最典型的。
第一,拓扑图不准导致传播方向错。 配置中心漏了中间件依赖:order-api 通过一个内部 RPC 框架调用 inventory-service,拓扑图里这条边缺失。一次 inventory-service 故障,传播分没法从它往上游传,得分最高的反而是它的下游 order-api,值班同学先去重启订单服务,白忙一场。后来我们强制要求新服务上线必须同时提交拓扑边,配置中心变更审计里加了"缺失依赖边"自动检测。
第二,抑制规则把根因吞了。 有次 Redis 主从切换和数据库慢查询几乎同时发生,抑制规则把"数据库慢查询"配成了"Redis 连接池满"的子告警,结果数据库那边的根因告警被静默,值班群里只剩 Redis 相关告警,误以为 Redis 是唯一问题。数据库慢查询持续了二十分钟才被发现。现在抑制规则上线前必须过"父子严重度"校验,且严重度相等时不抑制,宁可多报不要漏报。
第三,大模型把"Redis"说成"MySQL"。 还是那个主从切换事件,大模型在解释时凭空提到了 MySQL 连接泄漏,值班同学按 MySQL 方向去看监控,浪费了十分钟。加上"不许编造"约束和 JSON Schema 校验后,这个问题没再出现,但这事让我们认清:大模型在告警场景里不能当定位器,只能当解释器。
第四,时间窗取太短把慢故障漏掉。 刚开始两分钟的去重窗对一个逐渐攀升的内存泄漏故障不够,同一个服务在三个窗里分三次推送,传播分被切碎。后来窗口放宽到两分钟,并且对同一服务的不同指标做了跨指标合并,慢故障才能收敛成一个根因候选。
十、上线清单
如果你要在自己环境里跑这套流水线,下面是检查清单:
- 三路告警源都能归一化到同一个 schema,
service和startedAt是必填。 - 服务拓扑图里的边是完整的,尤其是中间件和数据库依赖,缺失的边会导致传播方向错。
- 拓扑传播加了最大深度保护(建议 6 层),防止配置错误导致的环形传播死循环。
- 入度归一化已做,公共网关不会虚高得分。
- 时钟同步做好了,NTP 偏差在一秒以内。
- 抑制规则只在"父告警严重度 ≥ 子告警"时生效,白名单项带到期日。
- 大模型输入只包含收敛后的结构化事实,带"不许编造"约束和 JSON Schema 校验。
- 每次事件只调用一次大模型,校验失败直接回退到纯结构化结论。
最后抛个问题给正在做可观测性的朋友:你的服务拓扑图,是靠自动发现(比如链路追踪生成)出来的,还是手工维护的?自动发现省人力但边容易漏,手工维护准但改起来慢。我们现在是配置中心手工申报 + 链路追踪补全,效果还可以,但不够自动化。欢迎在评论区交流。
写这篇的时候,群里又弹了三条告警,都是已经抑制的子告警,什么事都没发生。管道跑稳以后的日子,就是这样平静。