第1634篇:如何给AI应用做蓝绿发布——零停机切换模型版本的工程实践
第1634篇:如何给AI应用做蓝绿发布——零停机切换模型版本的工程实践
去年我们团队遇到一件很尴尬的事:用户反馈AI回答质量变差了,我们排查了半天发现是模型提供商偷偷升级了底层模型版本,接口没变但行为变了。那次之后,我们开始认真思考一个问题:模型版本管理应该掌握在我们自己手里,而不是被动依赖上游。
这篇文章聊的就是怎么在AI应用里实现蓝绿发布——在不停机的情况下安全地切换模型版本。
为什么AI应用的发布比普通应用更难
普通的蓝绿发布,主要解决的是代码变更的安全切换问题。但AI应用还有几个额外的复杂性:
1. 模型行为难以量化
代码改了好不好,跑一下测试就知道。但模型换了版本之后,"效果好不好"很难有一个客观的标准,通常需要人工评估或者AB测试积累足够的样本。
2. 有状态的对话历史
很多AI应用维护了多轮对话的历史。如果请求切换了,新模型和旧模型对同一段历史消息的理解可能不同,可能导致对话连贯性中断。
3. Prompt兼容性
同一个Prompt,在不同模型上的表现可能差异很大。对GPT-4精心调优的Prompt,换到Claude上效果可能截然不同。模型版本升级时,Prompt也需要一起评估。
4. 响应格式变化
如果你依赖模型输出特定格式(比如JSON),版本升级后格式可能出现微妙变化,导致下游解析出错。
这些复杂性加在一起,让AI应用的蓝绿发布需要比普通应用更精细的设计。
整体架构设计
我们的蓝绿发布架构长这样:
核心思路:用一个可控的流量分配器,根据运营策略把流量分发到不同的模型版本,同时收集两个版本的表现数据用于评估。
核心组件实现
1. 模型版本注册中心
首先需要一个地方来管理当前有哪些版本以及它们的状态:
@Data
@Builder
public class ModelVersion {
private String versionId; // 版本唯一ID
private String modelName; // 模型名称
private String promptTemplateId; // 关联的Prompt模板版本
private String environment; // blue / green
private double trafficWeight; // 0.0 - 1.0
private VersionStatus status; // ACTIVE / INACTIVE / DRAINING
private LocalDateTime deployedAt;
private Map<String, String> metadata;
public enum VersionStatus {
ACTIVE, // 正在接收流量
INACTIVE, // 已注册但不接收流量
DRAINING // 正在排空(等待进行中的请求完成)
}
}
@Service
@Slf4j
public class ModelVersionRegistry {
// 使用Redis存储,支持多实例同步
private final RedisTemplate<String, ModelVersion> redisTemplate;
private static final String VERSION_KEY = "ai:model:versions";
public void registerVersion(ModelVersion version) {
redisTemplate.opsForHash().put(VERSION_KEY, version.getVersionId(), version);
log.info("注册模型版本: {}, 环境: {}, 权重: {}",
version.getVersionId(), version.getEnvironment(), version.getTrafficWeight());
}
public List<ModelVersion> getActiveVersions() {
Map<Object, Object> allVersions = redisTemplate.opsForHash().entries(VERSION_KEY);
return allVersions.values().stream()
.map(v -> (ModelVersion) v)
.filter(v -> v.getStatus() == ModelVersion.VersionStatus.ACTIVE)
.collect(Collectors.toList());
}
public void updateTrafficWeight(String versionId, double weight) {
ModelVersion version = (ModelVersion) redisTemplate.opsForHash()
.get(VERSION_KEY, versionId);
if (version != null) {
version.setTrafficWeight(weight);
redisTemplate.opsForHash().put(VERSION_KEY, versionId, version);
log.info("更新版本 {} 的流量权重为 {}", versionId, weight);
}
}
public void drainVersion(String versionId) {
ModelVersion version = (ModelVersion) redisTemplate.opsForHash()
.get(VERSION_KEY, versionId);
if (version != null) {
version.setStatus(ModelVersion.VersionStatus.DRAINING);
redisTemplate.opsForHash().put(VERSION_KEY, versionId, version);
}
}
}2. 流量分配器
@Component
@Slf4j
public class ModelVersionRouter {
private final ModelVersionRegistry registry;
private final ThreadLocalRandom random = ThreadLocalRandom.current();
public ModelVersionRouter(ModelVersionRegistry registry) {
this.registry = registry;
}
/**
* 为给定请求选择模型版本
* 支持基于用户ID的粘性路由(同一用户始终路由到同一版本)
*/
public ModelVersion route(String requestId, String userId) {
List<ModelVersion> activeVersions = registry.getActiveVersions();
if (activeVersions.isEmpty()) {
throw new IllegalStateException("没有可用的模型版本");
}
if (activeVersions.size() == 1) {
return activeVersions.get(0);
}
// 如果有userId,做粘性路由
if (userId != null && !userId.isEmpty()) {
return stickyRoute(userId, activeVersions);
}
// 否则按权重随机路由
return weightedRoute(activeVersions);
}
private ModelVersion stickyRoute(String userId, List<ModelVersion> versions) {
// 用userId的哈希值决定路由,确保同一用户始终到同一版本
int hash = Math.abs(userId.hashCode());
// 计算累计权重
double totalWeight = versions.stream()
.mapToDouble(ModelVersion::getTrafficWeight)
.sum();
double targetPoint = (hash % 1000) / 1000.0 * totalWeight;
double cumulative = 0.0;
for (ModelVersion version : versions) {
cumulative += version.getTrafficWeight();
if (targetPoint <= cumulative) {
return version;
}
}
return versions.get(versions.size() - 1);
}
private ModelVersion weightedRoute(List<ModelVersion> versions) {
double totalWeight = versions.stream()
.mapToDouble(ModelVersion::getTrafficWeight)
.sum();
double randomPoint = random.nextDouble() * totalWeight;
double cumulative = 0.0;
for (ModelVersion version : versions) {
cumulative += version.getTrafficWeight();
if (randomPoint <= cumulative) {
return version;
}
}
return versions.get(versions.size() - 1);
}
}3. 版本感知的ChatClient工厂
@Service
@Slf4j
public class VersionAwareChatClientFactory {
// 每个版本对应一个ChatClient实例
private final Map<String, ChatClient> versionClients = new ConcurrentHashMap<>();
private final ChatClientBuilder chatClientBuilder;
private final PromptTemplateRepository templateRepository;
public VersionAwareChatClientFactory(
ChatClient.Builder chatClientBuilder,
PromptTemplateRepository templateRepository) {
this.chatClientBuilder = chatClientBuilder;
this.templateRepository = templateRepository;
}
public ChatClient getClientForVersion(ModelVersion version) {
return versionClients.computeIfAbsent(version.getVersionId(), id -> {
log.info("初始化版本 {} 的ChatClient", id);
return buildClientForVersion(version);
});
}
private ChatClient buildClientForVersion(ModelVersion version) {
// 加载该版本对应的Prompt模板
PromptTemplate template = templateRepository
.findById(version.getPromptTemplateId())
.orElseThrow(() -> new IllegalStateException(
"找不到Prompt模板: " + version.getPromptTemplateId()));
return chatClientBuilder
.defaultSystem(template.getSystemPrompt())
.defaultOptions(ChatOptions.builder()
.model(version.getModelName())
.build())
.build();
}
public void invalidateVersion(String versionId) {
versionClients.remove(versionId);
log.info("清除版本 {} 的ChatClient缓存", versionId);
}
}4. 主调用入口——把所有东西串起来
@Service
@Slf4j
public class BlueGreenAIChatService {
private final ModelVersionRouter router;
private final VersionAwareChatClientFactory clientFactory;
private final ResponseEvaluator evaluator;
private final MetricsCollector metricsCollector;
public BlueGreenAIChatService(
ModelVersionRouter router,
VersionAwareChatClientFactory clientFactory,
ResponseEvaluator evaluator,
MetricsCollector metricsCollector) {
this.router = router;
this.clientFactory = clientFactory;
this.evaluator = evaluator;
this.metricsCollector = metricsCollector;
}
public AIResponse chat(AIRequest request) {
// 路由决策
ModelVersion selectedVersion = router.route(
request.getRequestId(),
request.getUserId()
);
log.debug("请求 {} 路由到版本 {}, 环境: {}",
request.getRequestId(), selectedVersion.getVersionId(),
selectedVersion.getEnvironment());
ChatClient client = clientFactory.getClientForVersion(selectedVersion);
long startTime = System.currentTimeMillis();
String responseContent;
boolean success = true;
try {
responseContent = client.prompt()
.user(request.getUserInput())
.advisors(a -> a.param("userId", request.getUserId()))
.call()
.content();
} catch (Exception e) {
success = false;
metricsCollector.recordError(selectedVersion.getVersionId(), e);
throw e;
} finally {
long latency = System.currentTimeMillis() - startTime;
metricsCollector.recordLatency(selectedVersion.getVersionId(), latency);
metricsCollector.recordCall(selectedVersion.getVersionId(), success);
}
AIResponse response = AIResponse.builder()
.content(responseContent)
.versionId(selectedVersion.getVersionId())
.environment(selectedVersion.getEnvironment())
.build();
// 异步评估(不阻塞返回)
CompletableFuture.runAsync(() ->
evaluator.evaluateAsync(request, response, selectedVersion));
return response;
}
}渐进式切量的实现
真正的蓝绿发布不是一刀切,而是渐进式地把流量从旧版本迁移到新版本:
@Service
@Slf4j
public class BlueGreenSwitchService {
private final ModelVersionRegistry registry;
private final VersionAwareChatClientFactory clientFactory;
private final MetricsCollector metricsCollector;
// 渐进式切量:5% → 10% → 25% → 50% → 100%
private static final double[] TRAFFIC_STAGES = {0.05, 0.10, 0.25, 0.50, 1.0};
/**
* 启动蓝绿切换流程
* @param blueVersionId 当前稳定版本
* @param greenVersionId 新版本
*/
public CompletableFuture<SwitchResult> startSwitch(
String blueVersionId, String greenVersionId) {
log.info("开始蓝绿切换: {} -> {}", blueVersionId, greenVersionId);
return CompletableFuture.supplyAsync(() -> {
for (double stage : TRAFFIC_STAGES) {
log.info("调整绿色环境流量至 {}%", (int)(stage * 100));
// 调整流量
registry.updateTrafficWeight(greenVersionId, stage);
registry.updateTrafficWeight(blueVersionId, 1.0 - stage);
// 等待数据稳定
try {
Thread.sleep(5 * 60 * 1000); // 每个阶段等5分钟
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return SwitchResult.aborted("等待被中断");
}
// 检查新版本健康状况
VersionMetrics greenMetrics = metricsCollector
.getMetrics(greenVersionId, Duration.ofMinutes(5));
if (!isHealthy(greenMetrics, stage)) {
log.error("新版本健康检查失败,触发回滚!错误率: {}",
greenMetrics.getErrorRate());
rollback(blueVersionId, greenVersionId);
return SwitchResult.failed("错误率超阈值,已自动回滚");
}
log.info("阶段 {}% 检查通过,错误率: {}, P99延迟: {}ms",
(int)(stage * 100),
greenMetrics.getErrorRate(),
greenMetrics.getP99Latency());
}
// 切换完成,停用蓝色版本
registry.drainVersion(blueVersionId);
log.info("蓝绿切换完成,新版本 {} 已接管全部流量", greenVersionId);
return SwitchResult.success("切换完成");
});
}
/**
* 回滚到蓝色版本
*/
public void rollback(String blueVersionId, String greenVersionId) {
log.warn("执行回滚: {} -> {}", greenVersionId, blueVersionId);
registry.updateTrafficWeight(blueVersionId, 1.0);
registry.updateTrafficWeight(greenVersionId, 0.0);
registry.drainVersion(greenVersionId);
clientFactory.invalidateVersion(greenVersionId);
}
private boolean isHealthy(VersionMetrics metrics, double trafficRatio) {
// 错误率不超过1%
if (metrics.getErrorRate() > 0.01) return false;
// P99延迟不超过5秒
if (metrics.getP99Latency() > 5000) return false;
// 样本量足够(流量越大要求越多样本)
long minSamples = (long)(trafficRatio * 100);
if (metrics.getTotalCalls() < minSamples) return false;
return true;
}
}自动化效果评估
切换过程中怎么判断新版本"效果更好"?这需要一套评估机制:
@Service
@Slf4j
public class ResponseEvaluator {
private final ChatClient evaluatorClient; // 专门用于评估的ChatClient
private final EvaluationResultRepository repository;
private static final String EVALUATION_PROMPT = """
你是一个专业的AI响应质量评估员。
用户问题:{user_input}
版本A的回答:{response_a}
版本B的回答:{response_b}
请从以下维度对两个回答进行评估(各维度满分10分):
1. 准确性:回答是否正确、事实是否无误
2. 完整性:是否回答了用户的全部问题
3. 简洁性:是否简洁明了,没有冗余
4. 有用性:对用户是否真正有帮助
请以JSON格式返回评估结果:
{
"versionA": {"accuracy": 8, "completeness": 7, "conciseness": 9, "usefulness": 8},
"versionB": {"accuracy": 9, "completeness": 8, "conciseness": 8, "usefulness": 9},
"winner": "B",
"reason": "版本B的准确性和有用性更高"
}
""";
@Async
public void evaluateAsync(AIRequest request, AIResponse response,
ModelVersion selectedVersion) {
// 只对50%的请求做评估,避免开销过大
if (Math.random() > 0.5) return;
try {
// 用另一个版本也跑一遍,用于对比
// 这里是简化版,实际上需要找到对应的另一个版本
EvaluationResult result = EvaluationResult.builder()
.requestId(request.getRequestId())
.versionId(selectedVersion.getVersionId())
.userInput(request.getUserInput())
.response(response.getContent())
.evaluatedAt(LocalDateTime.now())
.build();
repository.save(result);
} catch (Exception e) {
log.warn("评估失败,不影响主流程: {}", e.getMessage());
}
}
}运维管理API
最后,要有一套管理API让运维人员可以手动干预:
@RestController
@RequestMapping("/admin/ai/versions")
@Slf4j
public class ModelVersionController {
private final ModelVersionRegistry registry;
private final BlueGreenSwitchService switchService;
@GetMapping
public List<ModelVersion> listVersions() {
return registry.getActiveVersions();
}
@PostMapping("/{versionId}/weight")
public ResponseEntity<String> adjustWeight(
@PathVariable String versionId,
@RequestParam double weight) {
if (weight < 0 || weight > 1) {
return ResponseEntity.badRequest().body("权重必须在0-1之间");
}
registry.updateTrafficWeight(versionId, weight);
return ResponseEntity.ok("流量权重已更新为 " + weight);
}
@PostMapping("/switch")
public ResponseEntity<String> startSwitch(
@RequestParam String fromVersion,
@RequestParam String toVersion) {
switchService.startSwitch(fromVersion, toVersion)
.thenAccept(result -> log.info("切换完成: {}", result.getMessage()));
return ResponseEntity.accepted().body("切换流程已启动,可通过监控查看进度");
}
@PostMapping("/rollback")
public ResponseEntity<String> rollback(
@RequestParam String stableVersion,
@RequestParam String rollbackVersion) {
switchService.rollback(stableVersion, rollbackVersion);
return ResponseEntity.ok("回滚完成");
}
}我们踩过的坑
最后说几个踩过的坑:
坑1:对话历史的版本绑定
我们最开始没有做会话粘性路由,结果同一个对话的不同轮次被路由到了不同版本,新版本看不懂旧版本的上下文,对话出现了奇怪的"失忆"现象。后来我们改成了基于sessionId的粘性路由,一个对话的所有请求固定到同一个版本。
坑2:评估结果的滞后性
新版本刚上的时候样本量很少,评估结果不稳定。有次新版本第一个请求刚好出了错,触发了自动回滚,但其实只是偶发问题。后来我们加了"最低样本量"的检查,样本量不足时不触发自动回滚。
坑3:Prompt和模型必须版本联动
这是最容易被忽视的。Prompt模板和模型版本必须一起管理,不能单独更新其中一个。我们现在强制要求每个ModelVersion都必须关联一个PromptTemplateVersion,两者一起发布、一起回滚。
整套方案实现下来,我们现在可以做到:
- 新模型版本灰度发布,10分钟内完成全量切换
- 发现问题3分钟内回滚
- 模型升级对用户完全无感知
