第1767篇:生产环境的实时监控大屏——AI解读Grafana指标并生成摘要报告
第1767篇:生产环境的实时监控大屏——AI解读Grafana指标并生成摘要报告
做运维的同学都知道,公司大屏上挂着各种Grafana面板是常态。请求量、错误率、CPU利用率、P99延迟……一大堆折线图。
平时没事,大屏就是装饰品。一旦出事,所有人盯着大屏,但要从一堆图里快速提炼出"现在到底发生了什么",还真不容易,特别是对非技术的管理人员。
我们后来做了一个AI解读层,定期把Grafana的指标数据抓下来,用LLM生成一份人话版的系统状态摘要。这篇讲实现细节。
为什么不直接接Grafana API
有人会问:Grafana不是有Webhook和Alert了吗,为什么要自己做一层?
原因是场景不同。Grafana的告警是事件驱动的,当某个阈值被触发时才发通知。我们想要的是一个定期的健康状态描述——"现在是上午10点,系统整体运行正常,支付服务的QPS比昨天同期高出15%,数据库连接池使用率维持在70%左右……"这样的日常播报。
另外,Grafana的告警只能描述单一指标的异常,无法做多指标的关联叙述。LLM可以把多个指标的变化综合起来,讲出一个有意义的故事。
整体架构
指标采集层
先定义要监控的关键指标集合,这个配置化很重要,不同服务的关键指标不同。
@Configuration
public class MonitoringConfig {
@Bean
public MonitoringSpec orderServiceSpec() {
return MonitoringSpec.builder()
.serviceName("order-service")
.metricSpecs(List.of(
MetricSpec.builder()
.name("qps")
.query("sum(rate(http_server_requests_seconds_count{job=\"order-service\"}[5m]))")
.unit("req/s")
.description("接口请求量")
.alertThreshold(2000.0)
.warningThreshold(1500.0)
.build(),
MetricSpec.builder()
.name("error_rate")
.query("sum(rate(http_server_requests_seconds_count{job=\"order-service\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=\"order-service\"}[5m]))")
.unit("%")
.description("5xx错误率")
.alertThreshold(0.05) // 5%触发告警
.warningThreshold(0.01) // 1%预警
.build(),
MetricSpec.builder()
.name("p99_latency")
.query("histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job=\"order-service\"}[5m])) by (le))")
.unit("ms")
.description("P99响应时间")
.alertThreshold(2.0) // 2秒
.warningThreshold(1.0) // 1秒
.build(),
MetricSpec.builder()
.name("db_pool_usage")
.query("hikaricp_connections_active{pool=\"order-service\"} / hikaricp_connections_max{pool=\"order-service\"}")
.unit("%")
.description("数据库连接池使用率")
.alertThreshold(0.90)
.warningThreshold(0.70)
.build(),
MetricSpec.builder()
.name("jvm_memory_usage")
.query("jvm_memory_used_bytes{job=\"order-service\",area=\"heap\"} / jvm_memory_max_bytes{job=\"order-service\",area=\"heap\"}")
.unit("%")
.description("JVM堆内存使用率")
.alertThreshold(0.85)
.warningThreshold(0.70)
.build()
))
.build();
}
}@Service
@Slf4j
public class MetricsCollector {
@Autowired
private PrometheusQueryService prometheusService;
@Data
@Builder
public static class MetricSnapshot {
private String metricName;
private String description;
private double currentValue;
private double value1hAgo;
private double value24hAgo;
private double value7dAgo; // 同期对比
private double maxLast1h;
private double minLast1h;
private double avgLast1h;
private String unit;
private MetricStatus status; // NORMAL/WARNING/ALERT
}
public List<MetricSnapshot> collectSnapshots(MonitoringSpec spec) {
List<MetricSnapshot> snapshots = new ArrayList<>();
Instant now = Instant.now();
for (MetricSpec metricSpec : spec.getMetricSpecs()) {
try {
MetricSnapshot snapshot = collectSingleMetric(metricSpec, now);
snapshots.add(snapshot);
} catch (Exception e) {
log.error("指标采集失败: metric={}", metricSpec.getName(), e);
}
}
return snapshots;
}
private MetricSnapshot collectSingleMetric(MetricSpec spec, Instant now) {
// 并行查询多个时间点的值
CompletableFuture<Double> currentFuture = CompletableFuture.supplyAsync(
() -> prometheusService.queryInstant(spec.getQuery(), now));
CompletableFuture<Double> hour1AgoFuture = CompletableFuture.supplyAsync(
() -> prometheusService.queryInstant(spec.getQuery(), now.minus(Duration.ofHours(1))));
CompletableFuture<Double> day1AgoFuture = CompletableFuture.supplyAsync(
() -> prometheusService.queryInstant(spec.getQuery(), now.minus(Duration.ofDays(1))));
CompletableFuture<Double> week1AgoFuture = CompletableFuture.supplyAsync(
() -> prometheusService.queryInstant(spec.getQuery(), now.minus(Duration.ofDays(7))));
// 查询最近1小时的范围数据,用于计算max/min/avg
CompletableFuture<List<DataPoint>> rangeFuture = CompletableFuture.supplyAsync(
() -> prometheusService.queryRange(
spec.getQuery(),
now.minus(Duration.ofHours(1)),
now,
Duration.ofMinutes(1)));
try {
double current = currentFuture.get(5, TimeUnit.SECONDS);
List<DataPoint> range = rangeFuture.get(5, TimeUnit.SECONDS);
DoubleSummaryStatistics stats = range.stream()
.mapToDouble(DataPoint::getValue)
.summaryStatistics();
MetricStatus status;
if (current >= spec.getAlertThreshold()) {
status = MetricStatus.ALERT;
} else if (current >= spec.getWarningThreshold()) {
status = MetricStatus.WARNING;
} else {
status = MetricStatus.NORMAL;
}
return MetricSnapshot.builder()
.metricName(spec.getName())
.description(spec.getDescription())
.currentValue(current)
.value1hAgo(getOrDefault(hour1AgoFuture, current))
.value24hAgo(getOrDefault(day1AgoFuture, current))
.value7dAgo(getOrDefault(week1AgoFuture, current))
.maxLast1h(stats.getMax())
.minLast1h(stats.getMin())
.avgLast1h(stats.getAverage())
.unit(spec.getUnit())
.status(status)
.build();
} catch (Exception e) {
throw new RuntimeException("指标查询失败: " + spec.getName(), e);
}
}
}数据处理:让LLM看得懂的输入
原始数字直接给LLM,效果不好。要做一步转换,让数字有意义。
@Component
public class MetricsNarrativeBuilder {
public String buildNarrative(String serviceName,
List<MetricSnapshot> snapshots,
ContextInfo context) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("## 服务:%s\n", serviceName));
sb.append(String.format("报告时间:%s\n\n",
context.getReportTime().atZone(ZoneId.of("Asia/Shanghai"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))));
// 先给出整体状态评估
long alertCount = snapshots.stream()
.filter(s -> s.getStatus() == MetricStatus.ALERT).count();
long warningCount = snapshots.stream()
.filter(s -> s.getStatus() == MetricStatus.WARNING).count();
if (alertCount > 0) {
sb.append(String.format("⚠️ 当前有 %d 个指标处于告警状态\n\n", alertCount));
} else if (warningCount > 0) {
sb.append(String.format("⚡ 当前有 %d 个指标处于预警状态\n\n", warningCount));
} else {
sb.append("✅ 所有指标正常\n\n");
}
// 逐指标描述
sb.append("## 各指标详情:\n\n");
for (MetricSnapshot snap : snapshots) {
sb.append(buildSingleMetricNarrative(snap, context));
sb.append("\n");
}
// 对比基准
sb.append("## 同期对比:\n\n");
sb.append(buildComparisonNarrative(snapshots, context));
return sb.toString();
}
private String buildSingleMetricNarrative(MetricSnapshot snap, ContextInfo ctx) {
StringBuilder sb = new StringBuilder();
String statusLabel = switch (snap.getStatus()) {
case ALERT -> "[告警]";
case WARNING -> "[预警]";
case NORMAL -> "[正常]";
};
sb.append(String.format("**%s %s**\n", statusLabel, snap.getDescription()));
sb.append(String.format("当前值: %.2f %s\n", snap.getCurrentValue(), snap.getUnit()));
// 描述趋势(与1小时前对比)
double change1h = snap.getCurrentValue() - snap.getValue1hAgo();
double changePercent1h = snap.getValue1hAgo() != 0 ?
change1h / snap.getValue1hAgo() * 100 : 0;
if (Math.abs(changePercent1h) > 5) {
String trend = changePercent1h > 0 ? "上升" : "下降";
sb.append(String.format("较1小时前%s %.1f%%(%s%.2f %s)\n",
trend, Math.abs(changePercent1h),
changePercent1h > 0 ? "+" : "",
change1h, snap.getUnit()));
} else {
sb.append("较1小时前基本持平\n");
}
// 描述1小时内的波动范围
sb.append(String.format("最近1小时区间: [%.2f, %.2f] %s, 均值: %.2f %s\n",
snap.getMinLast1h(), snap.getMaxLast1h(), snap.getUnit(),
snap.getAvgLast1h(), snap.getUnit()));
return sb.toString();
}
private String buildComparisonNarrative(List<MetricSnapshot> snapshots,
ContextInfo ctx) {
StringBuilder sb = new StringBuilder();
for (MetricSnapshot snap : snapshots) {
if (snap.getValue24hAgo() <= 0) continue;
double vsYesterday = (snap.getCurrentValue() - snap.getValue24hAgo()) /
snap.getValue24hAgo() * 100;
double vsLastWeek = snap.getValue7dAgo() > 0 ?
(snap.getCurrentValue() - snap.getValue7dAgo()) / snap.getValue7dAgo() * 100 : 0;
if (Math.abs(vsYesterday) > 10 || Math.abs(vsLastWeek) > 15) {
sb.append(String.format("- %s: 较昨日同期%s %.1f%%,较上周同期%s %.1f%%\n",
snap.getDescription(),
vsYesterday > 0 ? "高" : "低", Math.abs(vsYesterday),
vsLastWeek > 0 ? "高" : "低", Math.abs(vsLastWeek)));
}
}
if (sb.length() == 0) {
sb.append("- 各指标与历史同期基本持平,无显著差异\n");
}
return sb.toString();
}
}LLM摘要生成
@Service
@Slf4j
public class MonitoringSummaryGenerator {
@Autowired
private OpenAiService openAiService;
@Autowired
private MetricsNarrativeBuilder narrativeBuilder;
private static final String SYSTEM_PROMPT = """
你是一位技术运营助手,负责将系统监控数据转化为易于理解的状态报告。
报告要求:
1. 用简洁清晰的中文写作,技术人员和管理层都能看懂
2. 重点突出:先说结论,再说细节
3. 有异常的指标要重点描述,并给出可能的原因分析
4. 如果指标都正常,不要堆砌数字,一句话带过
5. 对比历史数据时,要说清楚是好是坏,不要只说数字
6. 篇幅控制在200字以内
报告格式:
【当前状态】一句话总结
【关注点】(仅在有异常时出现)
- 异常指标描述和分析
【今日亮点/关注项】
- 值得注意的趋势(好的或坏的)
【总结】一句话收尾
""";
public String generateSummary(String serviceName,
List<MetricSnapshot> snapshots,
ContextInfo context) {
String narrative = narrativeBuilder.buildNarrative(serviceName, snapshots, context);
ChatCompletionRequest request = ChatCompletionRequest.builder()
.model("gpt-4o-mini") // 这个场景用mini模型足够,成本更低
.messages(List.of(
new ChatMessage("system", SYSTEM_PROMPT),
new ChatMessage("user", "请为以下监控数据生成摘要报告:\n\n" + narrative)
))
.temperature(0.3) // 稍微提高温度,让摘要更自然流畅
.maxTokens(400)
.build();
try {
return openAiService.createChatCompletion(request)
.getChoices().get(0).getMessage().getContent();
} catch (Exception e) {
log.error("摘要生成失败,使用规则降级方案", e);
return generateFallbackSummary(snapshots);
}
}
private String generateFallbackSummary(List<MetricSnapshot> snapshots) {
long alertCount = snapshots.stream()
.filter(s -> s.getStatus() == MetricStatus.ALERT).count();
if (alertCount > 0) {
List<String> alertMetrics = snapshots.stream()
.filter(s -> s.getStatus() == MetricStatus.ALERT)
.map(MetricSnapshot::getDescription)
.collect(Collectors.toList());
return String.format("【当前状态】存在告警\n以下指标超过阈值:%s\n请及时处理。",
String.join("、", alertMetrics));
}
return "【当前状态】系统运行正常,所有指标在正常范围内。";
}
}播报调度与发布
@Component
@Slf4j
public class MonitoringReportScheduler {
@Autowired
private MetricsCollector collector;
@Autowired
private MonitoringSummaryGenerator generator;
@Autowired
private DingtalkNotificationService dingtalkService;
@Autowired
private GrafanaAnnotationService grafanaAnnotation;
@Autowired
private List<MonitoringSpec> monitoringSpecs;
// 每5分钟检查一次,有告警状态就立即播报
@Scheduled(fixedRate = 5 * 60 * 1000)
public void checkAndReport() {
for (MonitoringSpec spec : monitoringSpecs) {
try {
List<MetricSnapshot> snapshots = collector.collectSnapshots(spec);
boolean hasAlert = snapshots.stream()
.anyMatch(s -> s.getStatus() == MetricStatus.ALERT);
if (hasAlert) {
// 有告警立即播报
publishReport(spec, snapshots, ReportType.ALERT);
}
} catch (Exception e) {
log.error("监控检查失败: service={}", spec.getServiceName(), e);
}
}
}
// 每小时整点发送日常播报
@Scheduled(cron = "0 0 * * * *")
public void hourlyReport() {
for (MonitoringSpec spec : monitoringSpecs) {
try {
List<MetricSnapshot> snapshots = collector.collectSnapshots(spec);
publishReport(spec, snapshots, ReportType.ROUTINE);
} catch (Exception e) {
log.error("小时播报失败: service={}", spec.getServiceName(), e);
}
}
}
// 工作日早上9点发送日报
@Scheduled(cron = "0 0 9 * * MON-FRI")
public void morningReport() {
for (MonitoringSpec spec : monitoringSpecs) {
try {
List<MetricSnapshot> snapshots = collector.collectSnapshots(spec);
String summary = generator.generateSummary(
spec.getServiceName(), snapshots,
new ContextInfo(Instant.now(), ReportType.MORNING));
dingtalkService.sendMarkdown(
"📊 " + spec.getServiceName() + " 早间系统状态",
summary
);
} catch (Exception e) {
log.error("早报发送失败: service={}", spec.getServiceName(), e);
}
}
}
private void publishReport(MonitoringSpec spec,
List<MetricSnapshot> snapshots,
ReportType type) {
ContextInfo ctx = new ContextInfo(Instant.now(), type);
String summary = generator.generateSummary(spec.getServiceName(), snapshots, ctx);
String title = type == ReportType.ALERT ?
"🚨 " + spec.getServiceName() + " 告警状态播报" :
"📊 " + spec.getServiceName() + " 系统状态播报";
dingtalkService.sendMarkdown(title, summary);
// 同时在Grafana大屏上添加文字面板注解
grafanaAnnotation.addAnnotation(
spec.getServiceName(),
summary,
Instant.now()
);
log.info("播报已发送: service={}, type={}", spec.getServiceName(), type);
}
}Grafana大屏集成
Grafana支持通过API写入文字面板,让AI摘要直接显示在大屏上。
@Service
@Slf4j
public class GrafanaAnnotationService {
@Value("${grafana.base-url}")
private String grafanaBaseUrl;
@Value("${grafana.api-key}")
private String apiKey;
private final RestTemplate restTemplate;
public GrafanaAnnotationService() {
this.restTemplate = new RestTemplate();
}
// 写入Annotation(可以在所有面板上显示)
public void addAnnotation(String serviceName, String text, Instant timestamp) {
String url = grafanaBaseUrl + "/api/annotations";
Map<String, Object> body = new HashMap<>();
body.put("time", timestamp.toEpochMilli());
body.put("text", text);
body.put("tags", List.of("ai-summary", serviceName));
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
restTemplate.postForObject(url, entity, Map.class);
log.info("Grafana注解已添加: service={}", serviceName);
} catch (Exception e) {
log.error("Grafana注解添加失败", e);
}
}
// 更新文字面板(需要提前在Grafana创建一个Text面板)
public void updateTextPanel(int dashboardId, int panelId, String content) {
// 通过Grafana HTTP API更新面板内容
// 注意:Grafana的panel更新需要整个dashboard JSON,比较复杂
// 实际项目中建议用Grafana的Variable功能,从外部数据源拉取文字内容
// 更实用的方案:把AI摘要写入一个简单的HTTP接口,
// Grafana的Text面板通过iframe或data source读取
}
}一份实际播报的效果
这是某次早9点的播报,发到值班群:
📊 order-service 早间系统状态
【当前状态】系统运行稳定,所有核心指标正常。
【今日关注点】
- 今日早高峰(08:30-09:00)QPS较昨日同期高出约22%,接近历史峰值的78%,仍有余量
- 数据库连接池使用率在高峰期触达71%,略高于平日同期(约60%),建议关注后续走势
- P99延迟本周整体低于上周,说明上周优化的慢查询已有效果
【总结】服务健康,连接池使用率有小幅上升趋势,建议持续观察。这比一大堆折线图要有价值多了。值班工程师看一眼就知道需不需要关注。
关键踩坑
坑:播报过于频繁变成噪音
早期5分钟一次日常播报,一天下来近300条消息,大家直接屏蔽了群。
解决方案:日常情况下只在整点发送。有告警时才实时播报,但同一个告警最多每30分钟播报一次,避免持续告警的刷屏。
坑:LLM"过度解读"正常波动
有时候QPS有正常的5%波动,LLM会说"请求量有所上升,需要关注是否存在异常流量",吓得工程师赶紧去查,结果啥事没有。
解决方案:在System Prompt里明确说明"10%以内的波动属于正常,不需要特别指出",同时在数据输入中加入"正常波动范围"的标注。
这套系统最大的价值不是技术有多复杂,而是让监控数据真正被读懂。以前的大屏是给技术人员看的,有了AI解读层,连产品经理、运营同学在关键时刻也能看懂系统在发生什么。
