第1762篇:智能运维中的异常检测——时序数据与LLM的结合
第1762篇:智能运维中的异常检测——时序数据与LLM的结合
做了几年运维相关的AI项目之后,我发现有个问题是大家都遇到但很少被认真讨论的:时序异常检测和大语言模型,这两个东西各有各的强项,但单独用都有明显的短板。
时序算法能算出"这个点是统计意义上的异常",但它解释不了为什么。大模型能解释,但它看不懂原始的时序数据。
把两者结合起来,才是真正有工程价值的方案。这篇文章就讲这个。
先说时序异常检测的现状
传统的时序异常检测方案,大概分这几类:
基于统计的方法:3-Sigma、Z-Score。简单、快,但对非正态分布的指标不适用,而且对季节性模式(比如每天业务高峰期的流量涌入)会产生大量误报。
基于机器学习的方法:Isolation Forest、LSTM自编码器、Prophet。精度更高,但训练成本大,而且每个指标都要单独调参,运维团队几百个指标,维护成本极高。
基于规则的方法:就是现在大多数监控系统的告警规则,CPU > 80%这种。直观,但全靠人工维护,指标一多就维护不过来。
每种方法都有其适用场景,但有一个共同的问题:只能告诉你"异常了",无法解释"为什么异常,背后代表什么问题"。
这就是LLM能补足的地方。
整体设计思路
关键思路:
- 时序算法负责发现异常,输出的是数值结论(异常分数、异常类型)
- LLM负责解释异常,输入的是结构化的特征描述,而不是原始时序数据
- 中间有一个特征描述生成器,把时序算法的输出翻译成LLM能理解的自然语言描述
核心实现
1. 时序预处理
首先要做的是去除已知的周期性模式,避免把"业务高峰"误判为异常。
@Component
public class TimeSeriesPreprocessor {
@Data
public static class ProcessedTimeSeries {
private String metricName;
private List<DataPoint> original;
private List<DataPoint> detrended; // 去趋势
private List<DataPoint> deseasonalized; // 去季节性
private double meanValue;
private double stdDev;
private String timeZone;
}
@Data
@AllArgsConstructor
public static class DataPoint {
private Instant timestamp;
private double value;
}
// 业务日历:哪些时间段是"正常高峰期"
@Autowired
private BusinessCalendarService calendarService;
public ProcessedTimeSeries process(String metricName,
List<DataPoint> rawData) {
ProcessedTimeSeries result = new ProcessedTimeSeries();
result.setMetricName(metricName);
result.setOriginal(rawData);
// 1. 计算滑动基线(用过去7天同时段的均值作为基线)
List<DataPoint> baseline = calculateWeeklyBaseline(metricName, rawData);
// 2. 去季节性:当前值 - 基线值
List<DataPoint> deseasonalized = new ArrayList<>();
for (int i = 0; i < rawData.size(); i++) {
DataPoint current = rawData.get(i);
DataPoint base = baseline.get(i);
deseasonalized.add(new DataPoint(
current.getTimestamp(),
current.getValue() - base.getValue()
));
}
result.setDeseasonalized(deseasonalized);
// 3. 统计特征
DoubleSummaryStatistics stats = deseasonalized.stream()
.mapToDouble(DataPoint::getValue)
.summaryStatistics();
result.setMeanValue(stats.getAverage());
result.setStdDev(calculateStdDev(deseasonalized));
return result;
}
private double calculateStdDev(List<DataPoint> data) {
double mean = data.stream()
.mapToDouble(DataPoint::getValue)
.average()
.orElse(0.0);
double variance = data.stream()
.mapToDouble(dp -> Math.pow(dp.getValue() - mean, 2))
.average()
.orElse(0.0);
return Math.sqrt(variance);
}
private List<DataPoint> calculateWeeklyBaseline(String metricName,
List<DataPoint> current) {
// 从历史数据库中查询过去4周同时段的数据,取中位数
// 实际实现中从Prometheus的历史数据API查询
// 这里省略具体实现
return List.of(); // placeholder
}
}2. 多模型异常检测层
我们用了三个算法并行检测,投票决策,降低单一算法的误报率。
@Service
@Slf4j
public class AnomalyDetectionEngine {
@Data
@Builder
public static class AnomalyResult {
private boolean isAnomaly;
private double anomalyScore; // 0-1,越高越异常
private AnomalyType type; // SPIKE/DROP/TREND_CHANGE/PATTERN_BREAK
private double magnitude; // 偏离正常值的倍数
private Instant anomalyStart;
private Instant anomalyEnd;
private String detectorName;
}
public enum AnomalyType {
SPIKE, // 突刺
DROP, // 骤降
TREND_CHANGE, // 趋势改变
PATTERN_BREAK // 模式打破
}
// Z-Score检测器
private AnomalyResult detectWithZScore(ProcessedTimeSeries ts,
DataPoint point) {
double zScore = Math.abs(
(point.getValue() - ts.getMeanValue()) /
(ts.getStdDev() + 1e-9) // 避免除零
);
boolean isAnomaly = zScore > 3.0;
AnomalyType type = point.getValue() > ts.getMeanValue() ?
AnomalyType.SPIKE : AnomalyType.DROP;
return AnomalyResult.builder()
.isAnomaly(isAnomaly)
.anomalyScore(Math.min(zScore / 5.0, 1.0))
.type(type)
.magnitude(zScore)
.detectorName("ZScore")
.build();
}
// IQR检测器(对非正态分布更鲁棒)
private AnomalyResult detectWithIQR(ProcessedTimeSeries ts,
DataPoint point) {
List<Double> values = ts.getDeseasonalized().stream()
.mapToDouble(DataPoint::getValue)
.boxed()
.sorted()
.collect(Collectors.toList());
int n = values.size();
double q1 = values.get(n / 4);
double q3 = values.get(3 * n / 4);
double iqr = q3 - q1;
double lowerFence = q1 - 1.5 * iqr;
double upperFence = q3 + 1.5 * iqr;
boolean isAnomaly = point.getValue() < lowerFence ||
point.getValue() > upperFence;
double score = isAnomaly ?
Math.min(Math.abs(point.getValue() - ts.getMeanValue()) / (iqr + 1e-9) / 3.0, 1.0) :
0.0;
return AnomalyResult.builder()
.isAnomaly(isAnomaly)
.anomalyScore(score)
.type(point.getValue() > upperFence ? AnomalyType.SPIKE : AnomalyType.DROP)
.detectorName("IQR")
.build();
}
// 变化点检测(发现趋势突变)
private AnomalyResult detectTrendChange(ProcessedTimeSeries ts,
int currentIndex) {
if (currentIndex < 10) {
return AnomalyResult.builder().isAnomaly(false).build();
}
List<DataPoint> data = ts.getDeseasonalized();
// 计算前后窗口的均值差异
int windowSize = Math.min(10, currentIndex);
double beforeMean = data.subList(currentIndex - windowSize, currentIndex)
.stream()
.mapToDouble(DataPoint::getValue)
.average()
.orElse(0.0);
double currentValue = data.get(currentIndex).getValue();
double changeMagnitude = Math.abs(currentValue - beforeMean) /
(ts.getStdDev() + 1e-9);
boolean isAnomaly = changeMagnitude > 2.5;
return AnomalyResult.builder()
.isAnomaly(isAnomaly)
.anomalyScore(Math.min(changeMagnitude / 5.0, 1.0))
.type(AnomalyType.TREND_CHANGE)
.magnitude(changeMagnitude)
.detectorName("TrendChange")
.build();
}
// 投票融合
public AnomalyResult detect(ProcessedTimeSeries ts, int pointIndex) {
DataPoint point = ts.getDeseasonalized().get(pointIndex);
List<AnomalyResult> results = List.of(
detectWithZScore(ts, point),
detectWithIQR(ts, point),
detectTrendChange(ts, pointIndex)
);
long anomalyVotes = results.stream()
.filter(AnomalyResult::isAnomaly)
.count();
// 至少2个算法认为是异常才判定为异常(多数投票)
if (anomalyVotes >= 2) {
double avgScore = results.stream()
.mapToDouble(AnomalyResult::getAnomalyScore)
.average()
.orElse(0.0);
AnomalyType dominantType = results.stream()
.filter(AnomalyResult::isAnomaly)
.map(AnomalyResult::getType)
.findFirst()
.orElse(AnomalyType.SPIKE);
return AnomalyResult.builder()
.isAnomaly(true)
.anomalyScore(avgScore)
.type(dominantType)
.detectorName("Ensemble")
.build();
}
return AnomalyResult.builder()
.isAnomaly(false)
.anomalyScore(0.0)
.build();
}
}3. 特征描述生成器——关键中间层
这个组件是整个系统的灵魂所在。它把时序算法的数值输出,转换成LLM能理解的自然语言描述。
@Component
public class AnomalyFeatureDescriber {
public String describe(ProcessedTimeSeries ts,
AnomalyResult anomaly,
MetricMetadata metadata) {
StringBuilder sb = new StringBuilder();
// 1. 指标基本信息
sb.append(String.format("指标: %s\n", ts.getMetricName()));
sb.append(String.format("业务含义: %s\n", metadata.getDescription()));
sb.append(String.format("所属服务: %s\n", metadata.getServiceName()));
// 2. 异常特征描述
sb.append("\n异常特征:\n");
sb.append(String.format("- 异常类型: %s\n", describeType(anomaly.getType())));
sb.append(String.format("- 异常程度: %.1f倍标准差(%s)\n",
anomaly.getMagnitude(),
classifyMagnitude(anomaly.getMagnitude())));
// 3. 当前值与基线对比
if (anomaly.getAnomalyStart() != null) {
sb.append(String.format("- 异常开始时间: %s\n", anomaly.getAnomalyStart()));
sb.append(String.format("- 持续时长: %s\n",
formatDuration(anomaly.getAnomalyStart(), anomaly.getAnomalyEnd())));
}
// 4. 趋势描述
sb.append("\n趋势描述:\n");
sb.append(describeTrend(ts));
// 5. 时间上下文
sb.append("\n时间上下文:\n");
sb.append(describeTimeContext(anomaly.getAnomalyStart(), metadata));
return sb.toString();
}
private String describeType(AnomalyType type) {
return switch (type) {
case SPIKE -> "数值突刺(短时间内急剧升高后恢复)";
case DROP -> "数值骤降(短时间内急剧下降)";
case TREND_CHANGE -> "趋势突变(持续性变化,方向改变)";
case PATTERN_BREAK -> "模式打破(不符合历史规律的异常模式)";
};
}
private String classifyMagnitude(double magnitude) {
if (magnitude > 5) return "极严重异常";
if (magnitude > 3) return "严重异常";
if (magnitude > 2) return "中等异常";
return "轻微异常";
}
private String describeTrend(ProcessedTimeSeries ts) {
List<DataPoint> recent = ts.getDeseasonalized();
if (recent.size() < 5) return "数据点不足,无法判断趋势\n";
// 取最近20个点做线性回归判断趋势
int n = Math.min(20, recent.size());
List<DataPoint> window = recent.subList(recent.size() - n, recent.size());
double slope = calculateSlope(window);
if (Math.abs(slope) < ts.getStdDev() * 0.1) {
return "- 近期趋势: 相对平稳\n";
} else if (slope > 0) {
return String.format("- 近期趋势: 持续上升(斜率: %.4f/分钟)\n", slope);
} else {
return String.format("- 近期趋势: 持续下降(斜率: %.4f/分钟)\n", slope);
}
}
private double calculateSlope(List<DataPoint> points) {
// 简单线性回归
int n = points.size();
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
for (int i = 0; i < n; i++) {
sumX += i;
sumY += points.get(i).getValue();
sumXY += i * points.get(i).getValue();
sumXX += i * i;
}
return (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX + 1e-9);
}
private String describeTimeContext(Instant anomalyTime, MetricMetadata metadata) {
if (anomalyTime == null) return "";
ZonedDateTime zdt = anomalyTime.atZone(ZoneId.of("Asia/Shanghai"));
int hour = zdt.getHour();
DayOfWeek dow = zdt.getDayOfWeek();
StringBuilder ctx = new StringBuilder();
if (hour >= 9 && hour <= 11 || hour >= 14 && hour <= 16) {
ctx.append("- 发生在业务高峰时段\n");
} else if (hour >= 0 && hour <= 6) {
ctx.append("- 发生在业务低谷时段(深夜/凌晨),此时异常更显著\n");
}
if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) {
ctx.append("- 发生在周末,通常流量较低,此时出现异常需要重点关注\n");
}
return ctx.toString();
}
private String formatDuration(Instant start, Instant end) {
if (end == null) return "持续中";
long minutes = Duration.between(start, end).toMinutes();
if (minutes < 60) return minutes + "分钟";
return (minutes / 60) + "小时" + (minutes % 60) + "分钟";
}
}4. LLM语义解释引擎
有了特征描述,就可以让LLM给出人类可读的解释了。
@Service
@Slf4j
public class AnomalyExplainEngine {
@Autowired
private OpenAiService openAiService;
private static final String SYSTEM_PROMPT = """
你是一位资深的SRE工程师,擅长分析运维指标异常。
我会给你一个或多个指标的异常特征描述,请你:
1. 分析可能的根本原因(从技术角度分类:代码问题/配置问题/流量问题/依赖问题/基础设施问题)
2. 评估业务影响程度(高/中/低)
3. 给出3-5个排查方向,按优先级排序
4. 如果多个指标同时异常,分析它们之间的关联关系
请用JSON格式输出,字段:
- summary: 一句话摘要(50字以内)
- possibleCauses: 可能原因列表(每条包含category和description)
- businessImpact: 业务影响评估(level + description)
- investigationGuide: 排查指引(按优先级排序的步骤)
- correlationAnalysis: 多指标关联分析(如果输入包含多个异常指标)
""";
public AnomalyExplanation explain(List<String> featureDescriptions,
List<AnomalyResult> anomalies) {
String userMessage = buildUserMessage(featureDescriptions, anomalies);
ChatCompletionRequest request = ChatCompletionRequest.builder()
.model("gpt-4o")
.messages(List.of(
new ChatMessage("system", SYSTEM_PROMPT),
new ChatMessage("user", userMessage)
))
.temperature(0.1)
.responseFormat(new ResponseFormat("json_object"))
.maxTokens(1500)
.build();
try {
String response = openAiService.createChatCompletion(request)
.getChoices().get(0).getMessage().getContent();
return parseExplanation(response);
} catch (Exception e) {
log.error("LLM解释失败", e);
return AnomalyExplanation.defaultFallback();
}
}
private String buildUserMessage(List<String> descriptions,
List<AnomalyResult> anomalies) {
StringBuilder sb = new StringBuilder();
if (descriptions.size() == 1) {
sb.append("以下是一个异常指标的详细特征:\n\n");
sb.append(descriptions.get(0));
} else {
sb.append(String.format("以下%d个指标同时出现异常,请分析它们的关联关系:\n\n",
descriptions.size()));
for (int i = 0; i < descriptions.size(); i++) {
sb.append(String.format("### 异常指标 %d\n", i + 1));
sb.append(descriptions.get(i));
sb.append("\n");
}
}
return sb.toString();
}
}踩坑实录
坑1:时序数据对齐问题
不同指标的采集间隔不同:有的1分钟一个点,有的5分钟一个点,有的30秒一个点。送给LLM做关联分析之前,必须先做时间对齐,否则"同时异常"的判断会完全乱掉。
我们用的方案是统一插值到1分钟粒度,缺失值用前值填充(forward fill),而不是线性插值,因为指标往往有阶梯状特性,线性插值会引入不真实的中间值。
坑2:LLM对"持续上升"过度解读
指标持续上升不一定是问题,可能只是业务增长。早期LLM老是把"过去一周内存使用量持续增长"解读为内存泄漏。
解决方案是在特征描述里加入增速比较:
- 当前增速: 2.3MB/小时
- 历史同期增速: 2.1MB/小时(接近正常水平)这样LLM就能区分"正常的缓慢增长"和"异常的加速增长"了。
坑3:单一指标异常 vs 多指标关联
让LLM同时分析太多指标(超过5个),输出质量明显下降,容易出现泛泛而谈的分析。
最终方案是:先用服务拓扑对异常指标做分组,同一服务的指标一组,最多分析一组(3-5个指标),分析完一组再分析下一组,分析结果最后汇总。
实际运行效果
接入了一个有200+监控指标的核心服务后,统计了两个月的数据:
- 误报率从原来规则告警的38%降低到11%
- 平均每次异常解释生成时间:4.2秒
- 工程师对"LLM解释有帮助"的评分:4.1/5.0
最出乎意料的效果是:有两次数据库慢查询的早期信号(数据库连接池等待时间从50ms缓慢爬升到200ms),在还没有触发任何告警规则之前,系统就检测出了趋势变化并给出了预判,帮助DBA提前介入处理,避免了一次可能的服务中断。
这种"预测性"的价值,是纯规则告警永远做不到的。
