第1768篇:事故复盘的AI辅助——自动生成Postmortem草稿的工程方案
第1768篇:事故复盘的AI辅助——自动生成Postmortem草稿的工程方案
每次事故之后,Postmortem(故障复盘报告)是必做的一项工作。但实际上,这件事在很多团队里做得很马虎。
为什么?因为写Postmortem很耗时,而且事故刚处理完,工程师身心俱疲,最不想做的就是再花两三个小时把整个过程梳理成文字。结果要么拖很久才写,细节已经忘了;要么写得很潦草,下次同类问题发生时参考价值有限。
我们用AI来自动生成Postmortem的初稿,工程师只需要在此基础上补充和修正,时间从平均3小时缩短到40分钟。
Postmortem的标准结构
在讲实现之前,先梳理一份好的Postmortem应该包含什么,这决定了我们要采集哪些数据。
一份完整的Postmortem通常包含:
- 事故摘要:一句话概括事故(影响+时长+规模)
- 影响范围:受影响的服务、用户量、业务损失
- 时间线:完整的事件发生顺序(告警触发→排查→定位→处理→恢复)
- 根本原因:为什么发生这个问题
- 触发因素:直接导致事故发生的那个操作或事件
- 检测过程:如何发现的这个问题
- 处理过程:具体做了哪些操作来修复
- 经验教训:如果重来,哪些环节可以做得更好
- 改进行动项:具体的改进措施,有明确负责人和截止日期
其中时间线和根本原因是最难写的,因为需要从多个数据源还原完整过程。这正是AI最擅长的。
数据采集层
要生成Postmortem,需要整合多个数据源。
@Service
@Slf4j
public class IncidentDataCollector {
@Autowired
private AlertManagerService alertService;
@Autowired
private ElasticsearchService esService;
@Autowired
private PrometheusService prometheusService;
@Autowired
private DeploymentService deploymentService;
@Autowired
private IMMessageService imService;
@Data
@Builder
public static class IncidentData {
private String incidentId;
private Instant startTime;
private Instant endTime;
private String affectedService;
private List<AlertRecord> alerts; // 告警记录
private List<LogEntry> errorLogs; // 错误日志
private MetricTimeline metricTimeline; // 指标时序数据
private List<DeployRecord> recentDeploys; // 最近变更
private List<ChatMessage> chatHistory; // 群聊记录
private List<RunbookExecution> runbookExecs; // Runbook执行历史
}
public IncidentData collect(String incidentId, String serviceName,
Instant startTime, Instant endTime) {
log.info("开始采集事故数据: incidentId={}, service={}", incidentId, serviceName);
// 时间窗口:事故前30分钟到事故后15分钟
Instant collectStart = startTime.minus(Duration.ofMinutes(30));
Instant collectEnd = endTime.plus(Duration.ofMinutes(15));
// 并行采集
CompletableFuture<List<AlertRecord>> alertsFuture =
CompletableFuture.supplyAsync(() ->
alertService.getAlerts(serviceName, collectStart, collectEnd));
CompletableFuture<List<LogEntry>> logsFuture =
CompletableFuture.supplyAsync(() ->
esService.searchErrorLogs(serviceName, collectStart, collectEnd, 500));
CompletableFuture<MetricTimeline> metricsFuture =
CompletableFuture.supplyAsync(() ->
collectMetricTimeline(serviceName, collectStart, collectEnd));
CompletableFuture<List<DeployRecord>> deploysFuture =
CompletableFuture.supplyAsync(() ->
deploymentService.getDeployments(serviceName,
startTime.minus(Duration.ofHours(24)), endTime));
CompletableFuture<List<ChatMessage>> chatFuture =
CompletableFuture.supplyAsync(() ->
imService.getIncidentChannel(incidentId, collectStart, collectEnd));
try {
return IncidentData.builder()
.incidentId(incidentId)
.startTime(startTime)
.endTime(endTime)
.affectedService(serviceName)
.alerts(alertsFuture.get(10, TimeUnit.SECONDS))
.errorLogs(logsFuture.get(15, TimeUnit.SECONDS))
.metricTimeline(metricsFuture.get(10, TimeUnit.SECONDS))
.recentDeploys(deploysFuture.get(5, TimeUnit.SECONDS))
.chatHistory(chatFuture.get(5, TimeUnit.SECONDS))
.build();
} catch (Exception e) {
log.error("数据采集部分失败: incidentId={}", incidentId, e);
// 返回已采集到的数据,不因部分失败而终止
return buildPartialData(incidentId, serviceName, startTime, endTime,
alertsFuture, logsFuture, metricsFuture, deploysFuture, chatFuture);
}
}
private MetricTimeline collectMetricTimeline(String serviceName,
Instant start, Instant end) {
// 采集核心指标的时序数据,用于绘制事故期间的指标变化
Map<String, List<DataPoint>> metrics = new HashMap<>();
List<String> coreMetrics = List.of(
String.format("sum(rate(http_server_requests_seconds_count{job=\"%s\"}[1m]))", serviceName),
String.format("sum(rate(http_server_requests_seconds_count{job=\"%s\",status=~\"5..\"}[1m])) / sum(rate(http_server_requests_seconds_count{job=\"%s\"}[1m]))", serviceName, serviceName),
String.format("histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job=\"%s\"}[1m])) by (le))", serviceName)
);
List<String> metricNames = List.of("qps", "error_rate", "p99_latency");
for (int i = 0; i < coreMetrics.size(); i++) {
try {
List<DataPoint> data = prometheusService.queryRange(
coreMetrics.get(i), start, end, Duration.ofMinutes(1));
metrics.put(metricNames.get(i), data);
} catch (Exception e) {
log.warn("指标采集失败: {}", metricNames.get(i));
}
}
return new MetricTimeline(metrics);
}
}时间线重建器
从多数据源重建完整时间线,是最核心的挑战。
@Service
public class IncidentTimelineBuilder {
@Data
@AllArgsConstructor
public static class TimelineEvent {
private Instant timestamp;
private EventType type;
private String source;
private String description;
private Severity severity;
}
public enum EventType {
ALERT_FIRED, // 告警触发
ALERT_RESOLVED, // 告警恢复
METRIC_ANOMALY, // 指标异常
LOG_ERROR, // 错误日志
DEPLOYMENT, // 发布变更
MANUAL_ACTION, // 工程师手动操作
SYSTEM_RECOVERY // 系统恢复
}
public List<TimelineEvent> build(IncidentData data) {
List<TimelineEvent> events = new ArrayList<>();
// 1. 告警事件
for (AlertRecord alert : data.getAlerts()) {
events.add(new TimelineEvent(
alert.getFiredAt(),
EventType.ALERT_FIRED,
"AlertManager",
String.format("告警触发: [%s] %s - %s",
alert.getSeverity(), alert.getName(), alert.getMessage()),
alert.getSeverity()
));
if (alert.getResolvedAt() != null) {
events.add(new TimelineEvent(
alert.getResolvedAt(),
EventType.ALERT_RESOLVED,
"AlertManager",
String.format("告警恢复: [%s] %s",
alert.getSeverity(), alert.getName()),
Severity.INFO
));
}
}
// 2. 最近变更
for (DeployRecord deploy : data.getRecentDeploys()) {
events.add(new TimelineEvent(
deploy.getDeployedAt(),
EventType.DEPLOYMENT,
"DeploySystem",
String.format("发布变更: %s v%s (by %s), 变更内容: %s",
deploy.getServiceName(), deploy.getVersion(),
deploy.getOperator(), deploy.getChangeSummary()),
Severity.INFO
));
}
// 3. 关键错误日志(避免太多,只取最具代表性的)
List<LogEntry> keyLogs = extractKeyLogs(data.getErrorLogs());
for (LogEntry log : keyLogs) {
events.add(new TimelineEvent(
log.getTimestamp(),
EventType.LOG_ERROR,
"AppLog",
String.format("[%s] %s: %s",
log.getLevel(), log.getLogger(),
truncate(log.getMessage(), 200)),
Severity.ERROR
));
}
// 4. 指标异常时间点
List<TimelineEvent> metricEvents = extractMetricAnomalies(data.getMetricTimeline());
events.addAll(metricEvents);
// 5. 值班群聊中的关键操作记录
List<TimelineEvent> chatEvents = extractChatActions(data.getChatHistory());
events.addAll(chatEvents);
// 按时间排序
events.sort(Comparator.comparing(TimelineEvent::getTimestamp));
return events;
}
private List<LogEntry> extractKeyLogs(List<LogEntry> allLogs) {
// 策略:只保留独特的错误类型(去重),以及每5分钟窗口内最早出现的错误
Map<String, LogEntry> firstOccurrence = new LinkedHashMap<>();
for (LogEntry log : allLogs) {
// 用日志的前100个字符作为去重key(去掉时间戳和行号等变化部分)
String key = log.getLogger() + ":" + truncate(log.getMessage(), 100);
firstOccurrence.putIfAbsent(key, log);
}
// 最多返回15条关键日志
return new ArrayList<>(firstOccurrence.values())
.subList(0, Math.min(15, firstOccurrence.size()));
}
private List<TimelineEvent> extractMetricAnomalies(MetricTimeline timeline) {
List<TimelineEvent> events = new ArrayList<>();
for (Map.Entry<String, List<DataPoint>> entry : timeline.getMetrics().entrySet()) {
String metricName = entry.getKey();
List<DataPoint> points = entry.getValue();
// 找到指标开始急剧变化的时间点
DataPoint firstAnomaly = findFirstAnomalyPoint(points, metricName);
if (firstAnomaly != null) {
events.add(new TimelineEvent(
firstAnomaly.getTimestamp(),
EventType.METRIC_ANOMALY,
"Prometheus",
String.format("指标异常:%s 开始出现明显变化 (值: %.2f)",
metricName, firstAnomaly.getValue()),
Severity.WARNING
));
}
}
return events;
}
private DataPoint findFirstAnomalyPoint(List<DataPoint> points, String metricName) {
if (points.size() < 10) return null;
// 计算前半段的均值和标准差作为基线
int baselineEnd = points.size() / 3;
DoubleSummaryStatistics baseline = points.subList(0, baselineEnd)
.stream().mapToDouble(DataPoint::getValue).summaryStatistics();
double mean = baseline.getAverage();
double variance = points.subList(0, baselineEnd).stream()
.mapToDouble(p -> Math.pow(p.getValue() - mean, 2))
.average().orElse(0.0);
double stdDev = Math.sqrt(variance);
// 找第一个超过3倍标准差的点
return points.subList(baselineEnd, points.size()).stream()
.filter(p -> Math.abs(p.getValue() - mean) > 3 * stdDev)
.findFirst()
.orElse(null);
}
private List<TimelineEvent> extractChatActions(List<ChatMessage> messages) {
// 从群聊中提取工程师的操作记录
// 通常工程师会发类似 "重启了xxx服务"、"回滚到上一版本" 的消息
return messages.stream()
.filter(msg -> containsActionKeyword(msg.getContent()))
.map(msg -> new TimelineEvent(
msg.getTimestamp(),
EventType.MANUAL_ACTION,
"IM:" + msg.getSender(),
msg.getContent(),
Severity.INFO
))
.collect(Collectors.toList());
}
private boolean containsActionKeyword(String content) {
List<String> actionKeywords = List.of(
"重启", "回滚", "扩容", "下线", "切换", "恢复",
"修改配置", "发布", "执行", "操作", "rollback"
);
String lower = content.toLowerCase();
return actionKeywords.stream().anyMatch(lower::contains);
}
}LLM Postmortem生成器
有了结构化的时间线和上下文,让LLM写出第一版草稿。
@Service
@Slf4j
public class PostmortemGenerator {
@Autowired
private OpenAiService openAiService;
@Autowired
private IncidentTimelineBuilder timelineBuilder;
private static final String SYSTEM_PROMPT = """
你是一位有10年经验的SRE工程师,擅长撰写高质量的事故复盘报告。
你的任务是根据提供的事故数据,生成一份规范的Postmortem草稿。
写作原则:
1. 事实优先:基于提供的数据,不要凭空推断
2. 无指责原则:用系统思维而非个人归咎("监控告警未能及时发现"而非"某人没有及时处理")
3. 根因要深挖:不要停留在表面现象,要追问"为什么"直到找到系统性原因
4. 改进项要具体:不是"加强监控",而是"在xxx指标上增加阈值为xxx的告警"
输出格式:Markdown格式的完整报告
""";
public String generateDraft(IncidentData data) {
List<TimelineEvent> timeline = timelineBuilder.build(data);
String timelineText = formatTimeline(timeline);
String contextText = buildContextText(data);
String userMessage = String.format("""
请根据以下事故数据,生成Postmortem草稿:
## 基本信息
- 事故ID: %s
- 受影响服务: %s
- 事故开始时间: %s
- 事故结束时间: %s
- 事故持续时长: %s
## 完整时间线
%s
## 补充上下文
%s
请按照标准Postmortem格式生成报告,包含:
1. 事故摘要(一段话,100字内)
2. 影响范围(基于数据推断)
3. 详细时间线(基于上面的时间线数据)
4. 根本原因分析
5. 触发因素
6. 检测与响应过程分析
7. 经验教训(3-5条)
8. 改进行动项(SMART格式:具体、可衡量、有负责人、有截止日期)
""",
data.getIncidentId(),
data.getAffectedService(),
formatTime(data.getStartTime()),
formatTime(data.getEndTime()),
formatDuration(data.getStartTime(), data.getEndTime()),
timelineText,
contextText
);
ChatCompletionRequest request = ChatCompletionRequest.builder()
.model("gpt-4o")
.messages(List.of(
new ChatMessage("system", SYSTEM_PROMPT),
new ChatMessage("user", userMessage)
))
.temperature(0.2)
.maxTokens(3000)
.build();
try {
String draft = openAiService.createChatCompletion(request)
.getChoices().get(0).getMessage().getContent();
log.info("Postmortem草稿生成完成: incidentId={}", data.getIncidentId());
return draft;
} catch (Exception e) {
log.error("Postmortem生成失败", e);
return generateFallbackTemplate(data, timeline);
}
}
private String formatTimeline(List<TimelineEvent> events) {
StringBuilder sb = new StringBuilder();
for (TimelineEvent event : events) {
String time = formatTime(event.getTimestamp());
sb.append(String.format("- [%s] **%s** (来源: %s)\n %s\n",
time, event.getType().name(), event.getSource(),
event.getDescription()));
}
return sb.toString();
}
private String buildContextText(IncidentData data) {
StringBuilder sb = new StringBuilder();
// 指标峰值
if (data.getMetricTimeline() != null) {
sb.append("### 事故期间指标峰值\n");
data.getMetricTimeline().getMetrics().forEach((metric, points) -> {
OptionalDouble max = points.stream()
.mapToDouble(DataPoint::getValue).max();
if (max.isPresent()) {
sb.append(String.format("- %s 峰值: %.2f\n", metric, max.getAsDouble()));
}
});
}
// 告警统计
if (!data.getAlerts().isEmpty()) {
sb.append("\n### 告警统计\n");
Map<Severity, Long> alertCounts = data.getAlerts().stream()
.collect(Collectors.groupingBy(AlertRecord::getSeverity, Collectors.counting()));
alertCounts.forEach((sev, count) ->
sb.append(String.format("- %s: %d条\n", sev, count)));
}
return sb.toString();
}
private String generateFallbackTemplate(IncidentData data,
List<TimelineEvent> timeline) {
// LLM失败时的降级模板
return String.format("""
# 事故复盘报告 - %s
**待填写:** AI生成失败,请手动填写此报告。
## 1. 事故摘要
> 请在此填写事故的一句话描述
## 2. 时间线
%s
## 3. 根本原因
> 请在此填写根本原因
## 4. 改进行动项
> 请在此填写具体的改进措施
""",
data.getIncidentId(),
formatTimeline(timeline)
);
}
}与Confluence集成
生成的草稿自动创建为Confluence页面,并通知相关工程师来完善。
@Service
@Slf4j
public class PostmortemPublisher {
@Autowired
private ConfluenceApiClient confluenceClient;
@Autowired
private DingtalkNotificationService dingtalkService;
public String publish(String incidentId, String draftContent,
String affectedService) {
// 在Confluence创建草稿页面
String pageTitle = String.format("[草稿] 事故复盘 - %s - %s",
affectedService,
LocalDate.now().format(DateTimeFormatter.ISO_DATE));
String pageUrl = confluenceClient.createPage(
"运维手册", // space key
"事故复盘记录", // parent page title
pageTitle,
convertToConfluenceFormat(draftContent)
);
// 通知相关人员
String notification = String.format("""
## 📋 Postmortem草稿已生成
**事故ID:** %s
**受影响服务:** %s
**草稿地址:** %s
AI已根据事故数据生成初稿,请相关工程师:
1. 核实时间线的准确性
2. 补充根本原因的细节
3. 确认改进行动项的负责人和截止日期
请在**48小时内**完成复盘报告。
""", incidentId, affectedService, pageUrl);
dingtalkService.sendToGroup("ops-postmortem", notification);
log.info("Postmortem草稿已发布: incidentId={}, url={}", incidentId, pageUrl);
return pageUrl;
}
private String convertToConfluenceFormat(String markdown) {
// Confluence使用自己的Storage Format(类似HTML)
// 这里做简单的Markdown到Confluence格式转换
return markdown
.replaceAll("^# (.+)$", "<h1>$1</h1>")
.replaceAll("^## (.+)$", "<h2>$1</h2>")
.replaceAll("^### (.+)$", "<h3>$1</h3>")
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
.replaceAll("^- (.+)$", "<li>$1</li>")
.replace("\n\n", "<p/>");
}
}生成效果对比
以一次真实的Redis OOM事故为例,以前手写的Postmortem和AI生成草稿的对比:
手写版(事后2天):
Redis内存打满了,重启后恢复。原因是大Key没有清理。下次注意定期清理。
AI生成版(事后30分钟):
时间线精确到分钟,从早上8:47第一条WARN日志出现,到9:23触发CRITICAL告警,到9:31工程师手动扩容,到9:48彻底恢复,每个节点都有对应的数据来源。
根本原因分析了三层:表面原因(大Key占用)→ 中间原因(缺少大Key告警)→ 根本原因(Key的生命周期管理规范缺失)。
改进行动项5条,每条都有负责人和截止日期。
工程师只用15分钟补充了两处自己记得的细节,就发布了。
踩坑
坑1:群聊记录里的"干货"很少
事故处理过程中,工程师在群里发的内容往往是简短的"看一下"、"在处理"、"好了",对时间线重建帮助不大。真正有价值的操作记录其实在Runbook执行记录和操作日志里,而不是群聊。
坑2:LLM对根本原因的分析流于表面
如果没有足够的上下文,LLM倾向于给出"配置不当"、"资源不足"这类宽泛的根因,没有价值。
解决方案:在Prompt里明确要求"至少分析到第三层原因(Why-Why-Why)",并提供具体的例子。
坑3:改进行动项无法落地
AI生成的改进项常见"加强监控"、"完善规范"这类无法量化的条目。
解决方案:在Prompt里要求"每个行动项必须符合SMART原则,并且给出具体到配置哪个监控、写哪份文档的说明"。
Postmortem这件事,如果做得好,是团队学习和系统改进的最重要机制之一。AI能帮我们降低写的门槛,让更多事故有机会被真正复盘,而不是在疲惫中被遗忘。
