第1761篇:AIOps实践——用大模型分析告警日志的根因定位系统
第1761篇:AIOps实践——用大模型分析告警日志的根因定位系统
去年有个客户找到我,他们的运维团队每天处理几百条告警,三个人轮班盯着屏幕,眼睛都快瞎了,但真正有价值的故障定位往往要等到告警风暴已经过去才能复盘。我当时就想,这事儿大模型能做,而且能做好。
于是我们花了大概两个月,落地了一套基于大模型的根因定位系统。这篇文章就把整个过程和踩的坑都讲清楚。
为什么传统方案不够用
先说说老问题在哪里。
传统的告警处理基本是这样一个链路:监控系统产生告警 → 告警规则触发通知 → 运维工程师人工排查 → 找到根因 → 处理修复。
这套流程本身没问题,但有几个痛点:
第一,告警风暴时信噪比极低。 一旦某个核心服务挂了,下游几十个依赖服务的告警会在几分钟内同时爆出来。一个人看着几百条告警,根本不知道从哪里下手。
第二,上下文丢失。 告警信息本身只有指标名、阈值、触发时间,没有系统拓扑关系,没有最近的变更记录,没有历史相似故障的处理经验。工程师排查时还要到处找这些上下文。
第三,知识不可沉淀。 每次排查的过程基本靠工程师个人记忆,没有形成结构化的知识库。老员工一离职,经验就断了。
这三个问题叠在一起,就是所谓的"告警疲劳"。而大模型的强项恰好是:理解非结构化文本、关联多源上下文、从历史案例中类比推理。
系统整体架构
先看全局架构图:
核心设计思路是:把告警本身当成一个"触发点",然后围绕这个触发点,从多个数据源拉取上下文,最后把丰富的上下文一起送给大模型分析。
大模型不做数据查询,只做推理。数据查询的事交给各个专门的系统。
核心模块实现
1. 告警采集与标准化
不同监控系统的告警格式各不相同,第一步是标准化。
@Component
public class AlertNormalizer {
// 标准化的告警数据结构
@Data
@Builder
public static class NormalizedAlert {
private String alertId;
private String serviceName;
private String metricName;
private Double currentValue;
private Double threshold;
private Severity severity;
private Instant triggeredAt;
private Map<String, String> labels;
private String rawMessage;
}
public enum Severity {
CRITICAL, WARNING, INFO
}
// 处理Prometheus格式告警
public NormalizedAlert fromPrometheus(PrometheusAlert alert) {
return NormalizedAlert.builder()
.alertId(UUID.randomUUID().toString())
.serviceName(alert.getLabels().get("job"))
.metricName(alert.getLabels().get("alertname"))
.currentValue(parseValue(alert.getAnnotations().get("value")))
.severity(mapSeverity(alert.getLabels().get("severity")))
.triggeredAt(Instant.parse(alert.getStartsAt()))
.labels(alert.getLabels())
.rawMessage(alert.getAnnotations().get("description"))
.build();
}
private Double parseValue(String valueStr) {
if (valueStr == null) return null;
try {
return Double.parseDouble(valueStr);
} catch (NumberFormatException e) {
return null;
}
}
private Severity mapSeverity(String severity) {
if (severity == null) return Severity.INFO;
return switch (severity.toLowerCase()) {
case "critical", "p0", "p1" -> Severity.CRITICAL;
case "warning", "p2" -> Severity.WARNING;
default -> Severity.INFO;
};
}
}这里有个坑值得提一下:Prometheus的告警时间戳用的是RFC3339格式,但有些自建监控系统用的是毫秒时间戳,要分别处理,不要想当然地用同一个解析器。
2. 上下文富化器
这是整个系统最重要的模块。告警本身信息量太少,必须拼装足够多的上下文,大模型才能给出有价值的分析。
@Service
@Slf4j
public class ContextEnricher {
@Autowired
private CmdbService cmdbService;
@Autowired
private ChangeRecordService changeRecordService;
@Autowired
private ElasticsearchService esService;
@Autowired
private HistoricalIncidentService historyService;
@Data
public static class EnrichedContext {
private NormalizedAlert alert;
private ServiceTopology topology;
private List<ChangeRecord> recentChanges;
private List<LogEntry> relatedLogs;
private List<SimilarIncident> historicalCases;
private List<NormalizedAlert> correlatedAlerts;
}
public EnrichedContext enrich(NormalizedAlert alert) {
EnrichedContext ctx = new EnrichedContext();
ctx.setAlert(alert);
// 并行拉取上下文,减少总体耗时
CompletableFuture<ServiceTopology> topologyFuture =
CompletableFuture.supplyAsync(() ->
cmdbService.getTopology(alert.getServiceName()));
CompletableFuture<List<ChangeRecord>> changesFuture =
CompletableFuture.supplyAsync(() ->
changeRecordService.getRecentChanges(
alert.getServiceName(),
Duration.ofHours(24)));
CompletableFuture<List<LogEntry>> logsFuture =
CompletableFuture.supplyAsync(() ->
esService.searchLogs(
alert.getServiceName(),
alert.getTriggeredAt().minus(Duration.ofMinutes(10)),
alert.getTriggeredAt().plus(Duration.ofMinutes(5)),
100));
CompletableFuture<List<SimilarIncident>> historyFuture =
CompletableFuture.supplyAsync(() ->
historyService.findSimilar(alert, 5));
try {
ctx.setTopology(topologyFuture.get(5, TimeUnit.SECONDS));
ctx.setRecentChanges(changesFuture.get(5, TimeUnit.SECONDS));
ctx.setRelatedLogs(logsFuture.get(8, TimeUnit.SECONDS));
ctx.setHistoricalCases(historyFuture.get(5, TimeUnit.SECONDS));
} catch (TimeoutException e) {
log.warn("上下文富化超时,部分信息缺失: alertId={}", alert.getAlertId());
// 超时不影响主流程,用已获取到的部分上下文继续
} catch (Exception e) {
log.error("上下文富化失败", e);
}
return ctx;
}
}这里有几个工程上的细节:
并行拉取是必须的,串行会让响应时间叠加到不可接受的程度。每个数据源设置独立超时,单个数据源挂了不影响整体分析。
日志时间窗口我取的是告警前10分钟到后5分钟,前置窗口更大,因为根因往往在告警触发之前就已经有苗头了。
3. Prompt工程
这是让系统真正好用的关键。Prompt写不好,给再多上下文也白搭。
@Component
public class RootCauseAnalysisPromptBuilder {
private static final String SYSTEM_PROMPT = """
你是一位资深的SRE(网站可靠性工程师),具备丰富的分布式系统故障排查经验。
你的任务是根据提供的告警信息和相关上下文,进行根因定位分析。
分析要求:
1. 优先考虑最近的变更(发布、配置修改、数据库变更等)
2. 关注告警的时间顺序,找出"第一个出问题的服务"
3. 区分根因告警和级联告警
4. 给出置信度评估(高/中/低)
5. 提供具体的验证步骤
输出格式要求:JSON格式,字段包括:
- rootCause: 根因描述
- confidence: 置信度(high/medium/low)
- evidence: 支撑根因的关键证据列表
- cascadeAlerts: 判断为级联告警的告警ID列表
- verificationSteps: 验证步骤列表
- suggestedActions: 建议的处置动作列表
""";
public String buildPrompt(EnrichedContext ctx) {
StringBuilder sb = new StringBuilder();
// 主告警信息
sb.append("## 当前告警\n");
sb.append(formatAlert(ctx.getAlert())).append("\n\n");
// 服务拓扑
if (ctx.getTopology() != null) {
sb.append("## 服务依赖关系\n");
sb.append(formatTopology(ctx.getTopology())).append("\n\n");
}
// 最近变更(这个权重很重要)
if (!CollectionUtils.isEmpty(ctx.getRecentChanges())) {
sb.append("## 最近24小时内的变更记录\n");
ctx.getRecentChanges().forEach(change ->
sb.append(formatChange(change)).append("\n"));
sb.append("\n");
}
// 关键错误日志(控制数量,避免超token限制)
if (!CollectionUtils.isEmpty(ctx.getRelatedLogs())) {
sb.append("## 告警时间窗口内的关键日志(ERROR/WARN级别)\n");
ctx.getRelatedLogs().stream()
.filter(log -> log.getLevel().equals("ERROR") || log.getLevel().equals("WARN"))
.limit(20) // 限制日志条数,避免token超限
.forEach(log -> sb.append(formatLog(log)).append("\n"));
sb.append("\n");
}
// 历史相似故障
if (!CollectionUtils.isEmpty(ctx.getHistoricalCases())) {
sb.append("## 历史相似故障案例\n");
ctx.getHistoricalCases().forEach(incident ->
sb.append(formatIncident(incident)).append("\n"));
}
return sb.toString();
}
private String formatAlert(NormalizedAlert alert) {
return String.format("""
- 服务名: %s
- 告警指标: %s
- 当前值: %s
- 告警阈值: %s
- 严重程度: %s
- 触发时间: %s
- 告警描述: %s
""",
alert.getServiceName(),
alert.getMetricName(),
alert.getCurrentValue(),
alert.getThreshold(),
alert.getSeverity(),
alert.getTriggeredAt(),
alert.getRawMessage()
);
}
// 其他格式化方法省略...
}4. LLM调用层
我们用的是OpenAI兼容接口,封装了一层,方便切换不同的模型提供商。
@Service
@Slf4j
public class LLMAnalysisEngine {
@Autowired
private OpenAiService openAiService;
@Autowired
private RootCauseAnalysisPromptBuilder promptBuilder;
@Value("${aiops.llm.model:gpt-4o}")
private String model;
@Value("${aiops.llm.timeout-seconds:30}")
private int timeoutSeconds;
public RootCauseResult analyze(EnrichedContext context) {
String userPrompt = promptBuilder.buildPrompt(context);
ChatCompletionRequest request = ChatCompletionRequest.builder()
.model(model)
.messages(List.of(
new ChatMessage("system", promptBuilder.getSystemPrompt()),
new ChatMessage("user", userPrompt)
))
.temperature(0.1) // 低温度,确保输出稳定可解析
.responseFormat(new ResponseFormat("json_object")) // 强制JSON输出
.build();
try {
ChatCompletionResult result = openAiService.createChatCompletion(request);
String content = result.getChoices().get(0).getMessage().getContent();
log.info("LLM分析完成: alertId={}, tokens_used={}",
context.getAlert().getAlertId(),
result.getUsage().getTotalTokens());
return parseResult(content);
} catch (Exception e) {
log.error("LLM分析失败: alertId={}", context.getAlert().getAlertId(), e);
return RootCauseResult.failed("LLM分析服务不可用");
}
}
private RootCauseResult parseResult(String jsonContent) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(jsonContent);
return RootCauseResult.builder()
.rootCause(node.get("rootCause").asText())
.confidence(Confidence.valueOf(node.get("confidence").asText().toUpperCase()))
.evidence(parseStringList(node.get("evidence")))
.cascadeAlerts(parseStringList(node.get("cascadeAlerts")))
.verificationSteps(parseStringList(node.get("verificationSteps")))
.suggestedActions(parseStringList(node.get("suggestedActions")))
.build();
} catch (Exception e) {
log.error("LLM输出解析失败,原始内容: {}", jsonContent, e);
return RootCauseResult.failed("分析结果解析失败");
}
}
}温度设置成0.1这个细节很关键。我们要的是稳定、可解析的JSON输出,不需要创意。早期我设成0.7,结果输出时不时会有markdown代码块包裹,或者JSON格式不标准,解析老出错。
踩过的坑
坑1:Token超限导致分析失败
早期我们把日志直接全量塞进去,一次性往往有几千行日志,妥妥超token。
解决方案是分层截断:ERROR日志最多20条,WARN日志最多10条,INFO日志不传。同时对每条日志的长度做截断,超过500字符的日志截断并加"[已截断]"标记。
private String truncateLog(String logMessage, int maxLength) {
if (logMessage.length() <= maxLength) return logMessage;
return logMessage.substring(0, maxLength) + "...[已截断]";
}坑2:大模型对"最近变更"过度敏感
发现一个有趣的问题:只要最近有任何变更,大模型几乎100%会把根因指向变更,哪怕变更和告警毫无关系(比如前端样式修改导致后端数据库CPU告警)。
解决方案是在Prompt里加了一句:
"请注意:并非所有变更都与当前告警相关。请根据变更类型和告警指标的相关性做判断,避免无关联的推断。"
效果明显改善。
坑3:级联告警判断准确率不高
初版中大模型对级联告警的判断准确率大概只有70%左右,漏判比误判多。
后来增加了一个规则引擎前置处理:基于服务拓扑关系,如果上游服务已有CRITICAL告警,且下游服务在2分钟内出现告警,先打标记为"疑似级联",在Prompt里明确告诉大模型这个先验信息,准确率提升到88%。
坑4:历史故障知识库的质量问题
从各种运维文档里爬取的历史故障案例,质量参差不齐。有些案例描述太模糊("重启后恢复"),有些案例根本没有根因说明。
这些低质量案例送给大模型,反而会干扰判断。我们后来加了一个质量评分过滤,只有评分超过60分的历史案例才会被用于上下文。
效果评估
上线三个月后的数据:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 平均根因定位时间 | 47分钟 | 8分钟 |
| 告警处理人工介入率 | 100% | 34% |
| 根因定位准确率(人工复核) | - | 76% |
| 级联告警识别率 | - | 88% |
76%的根因定位准确率,在运维同事一开始的预期(50%就满足)的基础上高出很多,但也意味着还有24%的情况需要人工兜底。这24%基本集中在两类场景:硬件故障(大模型没有硬件层面的数据)和第三方服务问题(外部依赖日志拿不到)。
下一步计划
目前系统还有几个方向可以继续优化:
引入RAG增强知识库:把处理过的故障复盘报告向量化存储,用语义检索代替现在的关键词匹配来找相似故障。
加入指标时序分析:现在只看告警触发时刻的快照,后续要加时序趋势分析,"CPU在过去1小时内持续爬升"比"CPU超过80%"更有价值。
主动验证建议:大模型给出验证步骤后,自动执行一部分可安全自动化的验证命令,把结果反馈回来做二次分析。
AIOps这个方向真的在快速落地,不是PPT技术。但想做好,工程侧的数据管道、上下文质量、Prompt设计,每一块都要认真打磨,没有捷径。
