第2029篇:LLM服务的灰度发布——模型版本平滑切换实战
2026/4/30大约 7 分钟
第2029篇:LLM服务的灰度发布——模型版本平滑切换实战
适读人群:负责LLM服务运维和发布的工程师 | 阅读时长:约17分钟 | 核心价值:掌握LLM模型版本的灰度发布策略,避免新版本上线导致的服务质量波动
我们更新过一次模型,直接全量切换,上线当天接到了大量用户投诉。
不是因为新模型差——实验室评测新模型确实好。问题是新模型的输出格式有些地方发生了变化,下游的解析逻辑没有对应调整,导致了一批数据处理错误。
这个教训让我们彻底改变了模型发布方式:所有模型更新必须先灰度,渐进式放量。
模型灰度发布的挑战
普通代码灰度发布的标准方式是:先发布新版本到一小部分机器,观察错误率和性能指标,确认没问题后全量发布。
模型灰度有几个额外的挑战:
解决这些挑战需要:量化的质量指标、用户满意度信号、以及足够的流量时间窗口。
灰度路由策略
/**
* 模型版本的灰度路由
* 支持按用户比例、按用户属性、按请求特征灰度
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ModelVersionRouter {
@Qualifier("stableModelClient")
private final ChatClient stableModel; // 当前稳定版本(v1)
@Qualifier("canaryModelClient")
private final ChatClient canaryModel; // 灰度版本(v2)
private final ModelGrayConfig grayConfig;
private final ModelQualityTracker qualityTracker;
private final MeterRegistry meterRegistry;
/**
* 路由到合适的模型版本
*/
public ModelResponse route(String userId, ChatRequest request) {
ModelVersion selectedVersion = selectVersion(userId, request);
long start = System.currentTimeMillis();
String response;
boolean success = true;
try {
ChatClient client = selectedVersion == ModelVersion.CANARY
? canaryModel : stableModel;
response = client.prompt()
.system(request.getSystemPrompt())
.user(request.getUserMessage())
.call()
.content();
} catch (Exception e) {
success = false;
log.error("模型{}调用失败: {}", selectedVersion, e.getMessage());
// 灰度版本失败时,自动回退到稳定版本
if (selectedVersion == ModelVersion.CANARY) {
log.warn("灰度版本失败,自动回退到稳定版本");
selectedVersion = ModelVersion.STABLE;
response = stableModel.prompt()
.system(request.getSystemPrompt())
.user(request.getUserMessage())
.call()
.content();
} else {
throw new LlmServiceException("模型服务异常", e);
}
}
long latency = System.currentTimeMillis() - start;
// 记录指标(用于灰度评估)
meterRegistry.counter("model.calls",
"version", selectedVersion.name(),
"success", String.valueOf(success)).increment();
meterRegistry.timer("model.latency", "version", selectedVersion.name())
.record(latency, TimeUnit.MILLISECONDS);
return ModelResponse.builder()
.content(response)
.modelVersion(selectedVersion)
.latencyMs(latency)
.build();
}
/**
* 版本选择逻辑:支持多种灰度策略
*/
private ModelVersion selectVersion(String userId, ChatRequest request) {
// 策略1:手动指定(用于测试)
if (request.getHeaders().containsKey("X-Force-Model-Version")) {
String forced = request.getHeaders().get("X-Force-Model-Version");
return ModelVersion.valueOf(forced.toUpperCase());
}
// 策略2:内部用户先行(员工、内测用户)
if (grayConfig.isInternalUser(userId)) {
return ModelVersion.CANARY;
}
// 策略3:按灰度比例随机
double canaryRatio = grayConfig.getCanaryRatio(); // 0.0到1.0
if (canaryRatio > 0) {
// 用userId的hash保证同一用户总是去同一版本(避免体验不一致)
int userHash = Math.abs(userId.hashCode()) % 100;
if (userHash < canaryRatio * 100) {
return ModelVersion.CANARY;
}
}
return ModelVersion.STABLE;
}
}灰度质量评估
灰度期间最重要的工作是持续评估新版本的质量:
/**
* 模型灰度质量跟踪
* 自动对比两个版本的输出质量
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ModelGrayQualityTracker {
private final ModelResponseRepository responseRepo;
private final ChatClient judgeModel; // 用于自动评估
private final AlertService alertService;
/**
* Shadow模式:对某些请求,同时调用两个版本,对比输出
* 但只返回稳定版本的结果给用户(不影响用户体验)
*/
public ModelComparisonResult shadowTest(ChatRequest request) {
// 并发调用两个版本
CompletableFuture<String> stableFuture = CompletableFuture.supplyAsync(
() -> stableModel.prompt().user(request.getUserMessage()).call().content());
CompletableFuture<String> canaryFuture = CompletableFuture.supplyAsync(
() -> canaryModel.prompt().user(request.getUserMessage()).call().content());
String stableResponse = stableFuture.join();
String canaryResponse = canaryFuture.join();
// 异步评估质量差异(不影响用户响应时间)
CompletableFuture.runAsync(() -> evaluateAndRecord(request, stableResponse, canaryResponse));
// 返回稳定版本的结果给用户
return ModelComparisonResult.of(stableResponse, canaryResponse);
}
private void evaluateAndRecord(
ChatRequest request, String stable, String canary) {
// 用LLM-as-Judge对比两个版本
String judgePrompt = """
请对比以下两个AI回答的质量,判断哪个更好(A/B/EQUAL):
问题:%s
回答A(稳定版本):%s
回答B(候选版本):%s
判断标准:准确性、完整性、格式规范性
只输出A、B或EQUAL:
""".formatted(request.getUserMessage(), stable, canary);
try {
String judgment = judgeModel.prompt()
.user(judgePrompt).call().content().trim().toUpperCase();
ComparisonOutcome outcome = switch (judgment) {
case "A" -> ComparisonOutcome.STABLE_BETTER;
case "B" -> ComparisonOutcome.CANARY_BETTER;
default -> ComparisonOutcome.EQUAL;
};
responseRepo.saveComparison(ModelComparison.builder()
.requestId(request.getRequestId())
.stableResponse(stable)
.canaryResponse(canary)
.outcome(outcome)
.timestamp(LocalDateTime.now())
.build());
} catch (Exception e) {
log.warn("质量评估失败: {}", e.getMessage());
}
}
/**
* 生成灰度评估报告(每小时执行一次)
*/
@Scheduled(cron = "0 0 * * * *")
public void generateGrayReport() {
LocalDateTime since = LocalDateTime.now().minusHours(1);
List<ModelComparison> comparisons = responseRepo.findSince(since);
if (comparisons.isEmpty()) return;
long canaryBetter = comparisons.stream()
.filter(c -> c.getOutcome() == ComparisonOutcome.CANARY_BETTER).count();
long stableBetter = comparisons.stream()
.filter(c -> c.getOutcome() == ComparisonOutcome.STABLE_BETTER).count();
long equal = comparisons.size() - canaryBetter - stableBetter;
double canaryWinRate = (double) canaryBetter / comparisons.size();
log.info("灰度质量报告 - 最近1小时: 共{}次对比,候选版本胜出{:.1f}%,稳定版本胜出{:.1f}%,平局{:.1f}%",
comparisons.size(), canaryWinRate * 100,
(double) stableBetter / comparisons.size() * 100,
(double) equal / comparisons.size() * 100);
// 告警:如果候选版本在超过20%的对比中输给稳定版本,需要关注
if ((double) stableBetter / comparisons.size() > 0.20) {
alertService.warn(String.format(
"灰度版本质量告警:在%.1f%%的对比中候选版本不如稳定版本,建议暂停灰度",
(double) stableBetter / comparisons.size() * 100));
}
}
}灰度比例的自动调整
/**
* 基于质量指标自动调整灰度比例
* 指标好 → 自动放量;指标差 → 自动回退
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AutoGrayScaler {
private final ModelGrayConfig grayConfig;
private final ModelGrayQualityTracker qualityTracker;
private final AlertService alertService;
private static final double[] GRAY_STAGES = {0.01, 0.05, 0.10, 0.25, 0.50, 1.0};
private int currentStageIndex = 0;
/**
* 定期评估是否应该推进或回滚灰度
*/
@Scheduled(cron = "0 */30 * * * *") // 每30分钟评估一次
public void evaluateAndAdjust() {
GrayMetrics metrics = collectMetrics();
GrayDecision decision = makeDecision(metrics);
switch (decision) {
case ADVANCE -> {
if (currentStageIndex < GRAY_STAGES.length - 1) {
currentStageIndex++;
double newRatio = GRAY_STAGES[currentStageIndex];
grayConfig.setCanaryRatio(newRatio);
log.info("灰度进阶:比例提升至{}%", (int)(newRatio * 100));
alertService.info(String.format(
"模型灰度自动放量至%d%%,各项指标正常", (int)(newRatio * 100)));
} else {
// 已经是100%,可以正式全量切换
alertService.info("灰度完成,候选版本已全量切换,可以关闭灰度模式");
}
}
case HOLD -> {
log.info("灰度保持不变:当前{}%,等待更多数据",
(int)(grayConfig.getCanaryRatio() * 100));
}
case ROLLBACK -> {
currentStageIndex = 0;
grayConfig.setCanaryRatio(0.0);
log.error("灰度回滚!候选版本质量不达标,已恢复稳定版本");
alertService.critical("模型灰度紧急回滚:候选版本错误率或质量不达标");
}
}
}
private GrayDecision makeDecision(GrayMetrics metrics) {
// 回滚条件:任何一个指标严重恶化
if (metrics.getCanaryErrorRate() > metrics.getStableErrorRate() * 2 ||
metrics.getCanaryErrorRate() > 0.05) {
return GrayDecision.ROLLBACK;
}
if (metrics.getCanaryP99LatencyMs() > metrics.getStableP99LatencyMs() * 1.5) {
return GrayDecision.ROLLBACK;
}
if (metrics.getCanaryQualityScore() < metrics.getStableQualityScore() * 0.95) {
return GrayDecision.ROLLBACK;
}
// 推进条件:指标稳定且样本量足够
if (metrics.getCanarySampleCount() >= getMinSamplesForStage(currentStageIndex) &&
metrics.getCanaryQualityScore() >= metrics.getStableQualityScore() * 0.98) {
return GrayDecision.ADVANCE;
}
return GrayDecision.HOLD;
}
private int getMinSamplesForStage(int stageIndex) {
// 越往后需要越多的样本量才能推进
return switch (stageIndex) {
case 0 -> 50; // 1%阶段,50次请求就够
case 1 -> 200; // 5%阶段
case 2 -> 500; // 10%阶段
default -> 2000; // 更高阶段需要更多数据
};
}
}发布清单
模型发布应该有完整的发布清单,不能随意上线:
/**
* 模型发布前检查清单
* 每次发布必须执行,不允许跳过
*/
@Service
@RequiredArgsConstructor
public class ModelReleaseChecklist {
private final ModelEvaluationPipeline evaluationPipeline;
private final ModelVersionManager versionManager;
/**
* 执行完整的发布前检查
* 任何一项失败,阻止发布
*/
public ReleaseCheckResult performChecks(String modelPath, String baselineRunId) {
List<CheckResult> results = new ArrayList<>();
// 1. 基础功能测试
results.add(checkBasicFunctionality(modelPath));
// 2. 性能测试(不能比基线慢超过20%)
results.add(checkPerformance(modelPath, baselineRunId));
// 3. 质量评估(核心任务不能退化超过5%)
results.add(checkQuality(modelPath, baselineRunId));
// 4. 输出格式兼容性(确保下游系统能解析)
results.add(checkOutputFormat(modelPath));
// 5. 安全性检查(不能输出明显有害内容)
results.add(checkSafetyBehavior(modelPath));
boolean allPassed = results.stream().allMatch(CheckResult::isPassed);
return ReleaseCheckResult.builder()
.allPassed(allPassed)
.checks(results)
.recommendation(allPassed ? "允许发布" : "不允许发布,请修复失败项目后重试")
.build();
}
private CheckResult checkOutputFormat(String modelPath) {
// 测试关键的输出格式是否和基线一致
// 比如:JSON输出的字段是否变化,markdown格式是否异常
// ...
return CheckResult.passed("输出格式检查通过");
}
}灰度发布不只是技术问题,更是风险管理问题。它的核心价值是:在发现问题时,受影响的用户数量可控,有时间和空间进行修复和回滚,而不是全量故障再紧急处理。
