第2155篇:生产LLM系统的性能基线——建立可追踪的质量监控指标
2026/4/30大约 7 分钟
第2155篇:生产LLM系统的性能基线——建立可追踪的质量监控指标
适读人群:负责LLM系统稳定性的工程师和SRE | 阅读时长:约18分钟 | 核心价值:为生产LLM系统建立完整的质量基线,实现质量下滑的早期预警
凌晨三点,产品总监发微信:"用户投诉说AI助手今天回答质量很差,你们知道吗?"
查了下日志,没有报错,接口延迟正常,Token消耗正常。系统看起来"运行正常",但实际上输出质量已经悄悄下滑了。
这就是LLM系统监控的核心难点:传统基础设施监控感知不到质量问题。CPU正常、内存正常、延迟正常,不代表AI在给好答案。
建立质量基线的目的就是:在用户投诉之前,第一时间发现质量问题。
质量监控的指标体系
LLM系统的监控需要覆盖三个层次:
基础设施层的监控已经很成熟(Prometheus/Grafana),本文重点讲模型行为层和业务质量层的监控实现。
模型行为层监控
这层监控不需要LLM评估,靠简单的规则统计就能发现很多问题。
/**
* 模型行为监控服务
*
* 通过统计模型输出的特征来发现异常行为
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ModelBehaviorMonitor {
private final MeterRegistry meterRegistry;
private final AlertService alertService;
// 滑动窗口统计(过去1小时)
private final Deque<ModelOutputRecord> recentOutputs = new ConcurrentLinkedDeque<>();
private static final Duration WINDOW = Duration.ofHours(1);
/**
* 记录每次模型输出的特征
*
* 在模型输出后立即调用,不影响响应时间
*/
@Async
public void recordOutput(String requestId, String input, String output,
String modelVersion, long latencyMs) {
ModelOutputRecord record = ModelOutputRecord.builder()
.requestId(requestId)
.timestamp(Instant.now())
.inputLength(input.length())
.outputLength(output.length())
.modelVersion(modelVersion)
.latencyMs(latencyMs)
.isRefusal(detectRefusal(output))
.isRepetitive(detectRepetition(output))
.languageMatchInput(checkLanguageMatch(input, output))
.build();
recentOutputs.addLast(record);
cleanOldRecords();
// 记录到Prometheus
recordMetrics(record);
// 检查异常
checkAnomalies();
}
/**
* 检测拒绝回答(模型说"我不能回答这个问题")
*/
private boolean detectRefusal(String output) {
String lower = output.toLowerCase();
List<String> refusalPatterns = Arrays.asList(
"我无法回答", "我不能回答", "抱歉,我不", "这超出了我的能力范围",
"i cannot", "i'm unable to", "i can't help with"
);
return refusalPatterns.stream().anyMatch(lower::contains);
}
/**
* 检测重复内容(可能是模型出现了"重复症状")
*/
private boolean detectRepetition(String output) {
if (output.length() < 100) return false;
// 检测连续重复的短语(超过3次出现相同片段)
String[] sentences = output.split("[。!?.!?]");
Map<String, Integer> counts = new HashMap<>();
for (String s : sentences) {
String trimmed = s.trim();
if (trimmed.length() > 10) {
counts.merge(trimmed, 1, Integer::sum);
}
}
return counts.values().stream().anyMatch(c -> c >= 3);
}
/**
* 检测输出语言是否与输入语言一致
* (有时模型会用英语回答中文问题,或反之)
*/
private boolean checkLanguageMatch(String input, String output) {
boolean inputIsChinese = isMostlyChinese(input);
boolean outputIsChinese = isMostlyChinese(output);
return inputIsChinese == outputIsChinese;
}
private boolean isMostlyChinese(String text) {
long chineseCount = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FA5)
.count();
return (double) chineseCount / text.length() > 0.3;
}
private void recordMetrics(ModelOutputRecord record) {
// 输出长度分布
meterRegistry.summary("llm.output.length")
.record(record.getOutputLength());
// 拒答率计数
if (record.isRefusal()) {
meterRegistry.counter("llm.output.refusal",
"model", record.getModelVersion()).increment();
}
// 重复内容计数
if (record.isRepetitive()) {
meterRegistry.counter("llm.output.repetitive",
"model", record.getModelVersion()).increment();
}
// 语言不匹配计数
if (!record.isLanguageMatchInput()) {
meterRegistry.counter("llm.output.language_mismatch",
"model", record.getModelVersion()).increment();
}
}
@Scheduled(fixedDelay = 300000) // 每5分钟检查一次
public void checkAnomalies() {
if (recentOutputs.isEmpty()) return;
List<ModelOutputRecord> windowData = new ArrayList<>(recentOutputs);
int total = windowData.size();
// 检查拒答率
long refusalCount = windowData.stream().filter(ModelOutputRecord::isRefusal).count();
double refusalRate = (double) refusalCount / total;
if (refusalRate > 0.15) { // 超过15%触发告警
alertService.sendWarningAlert(
"LLM拒答率异常",
String.format("过去1小时拒答率=%.1f%%(阈值15%%),共%d次", refusalRate * 100, total)
);
}
// 检查重复输出率
long repetitiveCount = windowData.stream().filter(ModelOutputRecord::isRepetitive).count();
double repetitiveRate = (double) repetitiveCount / total;
if (repetitiveRate > 0.05) { // 超过5%触发告警
alertService.sendWarningAlert(
"LLM重复输出异常",
String.format("过去1小时重复输出率=%.1f%%(阈值5%%)", repetitiveRate * 100)
);
}
// 检查平均输出长度异常
OptionalDouble avgLength = windowData.stream()
.mapToInt(ModelOutputRecord::getOutputLength)
.average();
avgLength.ifPresent(avg -> {
if (avg < 50) { // 平均不足50字,可能有问题
alertService.sendWarningAlert(
"LLM输出异常简短",
String.format("过去1小时平均输出长度=%.0f字(阈值50字)", avg)
);
}
});
}
private void cleanOldRecords() {
Instant cutoff = Instant.now().minus(WINDOW);
while (!recentOutputs.isEmpty() && recentOutputs.peekFirst().getTimestamp().isBefore(cutoff)) {
recentOutputs.pollFirst();
}
}
}质量基线建立与漂移检测
/**
* 质量基线管理服务
*
* 建立基线,检测质量漂移
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QualityBaselineService {
private final EvaluationResultRepository evalRepository;
private final BaselineRepository baselineRepository;
private final AlertService alertService;
/**
* 建立质量基线
*
* 在系统稳定运行一段时间后,用历史数据建立基线
* 后续的变化与基线对比
*/
public QualityBaseline establishBaseline(String modelVersion,
LocalDate startDate,
LocalDate endDate) {
List<EvaluationReport> historicalReports = evalRepository.findByModelVersionAndDateRange(
modelVersion, startDate, endDate
);
if (historicalReports.size() < 100) {
throw new IllegalStateException("建立基线需要至少100个评估样本,当前只有" + historicalReports.size());
}
// 计算各维度的基线统计值
Map<String, DimensionBaseline> dimensionBaselines = new HashMap<>();
Set<String> dimensions = historicalReports.get(0).getDimensionScores().keySet();
for (String dimension : dimensions) {
List<Double> scores = historicalReports.stream()
.map(r -> r.getDimensionScores().get(dimension))
.filter(Objects::nonNull)
.map(DimensionScore::getScore)
.collect(Collectors.toList());
double mean = scores.stream().mapToDouble(Double::doubleValue).average().orElse(0);
double std = computeStd(scores, mean);
dimensionBaselines.put(dimension, DimensionBaseline.builder()
.dimension(dimension)
.mean(mean)
.stdDev(std)
.lowerBound(mean - 2 * std) // 均值-2倍标准差为下界(约2.5%分位数)
.p25(computePercentile(scores, 25))
.p50(computePercentile(scores, 50))
.p75(computePercentile(scores, 75))
.build());
}
// 整体通过率
double passRate = historicalReports.stream()
.mapToDouble(r -> r.isPassed() ? 1.0 : 0.0)
.average().orElse(0);
QualityBaseline baseline = QualityBaseline.builder()
.modelVersion(modelVersion)
.establishedDate(LocalDate.now())
.periodStart(startDate)
.periodEnd(endDate)
.sampleCount(historicalReports.size())
.dimensionBaselines(dimensionBaselines)
.overallPassRate(passRate)
.overallMeanScore(historicalReports.stream()
.mapToDouble(EvaluationReport::getOverallScore).average().orElse(0))
.build();
baselineRepository.save(baseline);
log.info("质量基线建立完成,模型版本={}, 基线综合分={:.3f}", modelVersion, baseline.getOverallMeanScore());
return baseline;
}
/**
* 将最近的质量指标与基线对比,检测漂移
*/
@Scheduled(cron = "0 0 8 * * *") // 每天上午8点检查
public void detectQualityDrift() {
List<QualityBaseline> activeBaselines = baselineRepository.findActive();
for (QualityBaseline baseline : activeBaselines) {
checkDriftForBaseline(baseline);
}
}
private void checkDriftForBaseline(QualityBaseline baseline) {
// 获取过去24小时的评估数据
LocalDate yesterday = LocalDate.now().minusDays(1);
List<EvaluationReport> recentReports = evalRepository.findByModelVersionAndDateRange(
baseline.getModelVersion(), yesterday, LocalDate.now()
);
if (recentReports.size() < 20) {
log.warn("最近24小时评估样本不足({}个),跳过漂移检测", recentReports.size());
return;
}
double recentPassRate = recentReports.stream()
.mapToDouble(r -> r.isPassed() ? 1.0 : 0.0)
.average().orElse(0);
double passRateDrop = baseline.getOverallPassRate() - recentPassRate;
// 通过率下降超过10个百分点触发告警
if (passRateDrop > 0.10) {
alertService.sendCriticalAlert(
"LLM质量显著下降",
String.format("模型%s通过率从基线%.1f%%下降到%.1f%%,下降%.1f个百分点",
baseline.getModelVersion(),
baseline.getOverallPassRate() * 100,
recentPassRate * 100,
passRateDrop * 100)
);
} else if (passRateDrop > 0.05) {
alertService.sendWarningAlert(
"LLM质量轻微下降",
String.format("模型%s通过率从基线%.1f%%下降到%.1f%%",
baseline.getModelVersion(),
baseline.getOverallPassRate() * 100,
recentPassRate * 100)
);
}
// 检查各维度的漂移
for (Map.Entry<String, DimensionBaseline> entry : baseline.getDimensionBaselines().entrySet()) {
String dimension = entry.getKey();
DimensionBaseline dimBaseline = entry.getValue();
double recentDimScore = recentReports.stream()
.map(r -> r.getDimensionScores().get(dimension))
.filter(Objects::nonNull)
.mapToDouble(DimensionScore::getScore)
.average().orElse(-1);
if (recentDimScore < 0) continue;
// 低于基线下界(均值-2σ)时告警
if (recentDimScore < dimBaseline.getLowerBound()) {
alertService.sendWarningAlert(
"LLM维度质量下降: " + dimension,
String.format("维度[%s]最近24h均分=%.3f,低于基线下界=%.3f(基线均值=%.3f)",
dimension, recentDimScore,
dimBaseline.getLowerBound(), dimBaseline.getMean())
);
}
}
}
private double computeStd(List<Double> values, double mean) {
double variance = values.stream()
.mapToDouble(v -> Math.pow(v - mean, 2))
.average().orElse(0);
return Math.sqrt(variance);
}
private double computePercentile(List<Double> sorted, int p) {
List<Double> sortedList = new ArrayList<>(sorted);
Collections.sort(sortedList);
int idx = (int) Math.ceil(p / 100.0 * sortedList.size()) - 1;
return sortedList.get(Math.max(0, Math.min(idx, sortedList.size() - 1)));
}
}监控大盘设计
光有数据不够,还要有可视化。用Spring Boot Actuator + Micrometer + Grafana构建监控大盘:
/**
* 自定义指标注册
*
* 把LLM质量指标注册到Micrometer,供Prometheus抓取
*/
@Configuration
@RequiredArgsConstructor
public class LlmMetricsConfig {
private final QualityBaselineService baselineService;
private final EvaluationResultRepository evalRepository;
@Bean
public MeterBinder llmQualityMetrics() {
return registry -> {
// 注册质量分数Gauge(每次抓取时计算)
Gauge.builder("llm.quality.overall_score", this, obj -> {
LocalDate yesterday = LocalDate.now().minusDays(1);
return evalRepository.findAverageScoreSince(yesterday.atStartOfDay());
})
.description("LLM过去24小时平均质量分数")
.register(registry);
Gauge.builder("llm.quality.pass_rate", this, obj -> {
LocalDate yesterday = LocalDate.now().minusDays(1);
return evalRepository.findPassRateSince(yesterday.atStartOfDay());
})
.description("LLM过去24小时质量通过率")
.register(registry);
Gauge.builder("llm.quality.hallucination_rate", this, obj -> {
LocalDate yesterday = LocalDate.now().minusDays(1);
return evalRepository.findHallucinationRateSince(yesterday.atStartOfDay());
})
.description("LLM过去24小时幻觉率")
.register(registry);
};
}
}Grafana大盘需要包含:
- 质量分数时序图(30天趋势)
- 各维度雷达图(直观看短板)
- 质量分布直方图(看分数分布是否异常)
- 幻觉率和拒答率趋势
- 质量与流量的关联图(流量峰值时质量是否下降)
建立基线的实战步骤
- 系统稳定期:在没有大的Prompt变更的前提下,收集2-4周的评估数据
- 清洗异常值:去掉明显异常的时间段(比如上线初期不稳定的数据)
- 分场景建基线:不同意图分类可能有不同的质量水位,混在一起会掩盖问题
- 设置合理的告警阈值:不要太敏感(频繁误报会被忽视),也不要太宽松(发现问题太晚)
- 定期回顾更新基线:每1-2个月更新基线,避免基线过时
