AI 应用的灰度发布——新模型上线不能像改个按钮那样简单
AI 应用的灰度发布——新模型上线不能像改个按钮那样简单
去年年中,我们把智能客服的底层模型从 GPT-3.5 升级到 GPT-4o,整个过程搞了将近三周。
不是技术难度有多高,是我们被打了个措手不及。
第一天全量切换,下午两点开始陆续有客服主管反馈:"AI 回答变长了,客户说话唠嗦。"当天晚上用户投诉率上升了 12%。紧急回滚,好不容易稳住。
事后复盘,问题出在我们把 AI 模型升级当成了普通功能升级来处理——写个配置、改个 URL、部署上去完事。对于一个按钮的颜色变化,这没问题;但对于 AI 模型,这种做法是在赌博。
这篇文章,我来说清楚 AI 功能灰度发布为什么和普通灰度不一样,以及我们后来怎么重新设计整套发布流程的。
普通灰度发布,你以为你懂了
传统的灰度发布逻辑很简单:把 5% 的流量导向新版本,观察报错率、响应时间、业务转化率,没问题就逐步扩大比例。
这套逻辑背后有个隐含前提:新旧版本的"好坏"是可以客观衡量的,而且响应速度很快。
接口报 500 了,马上知道出问题了。响应时间从 50ms 变成了 500ms,监控马上告警。这些都是硬指标,冷冰冰但清晰。
AI 模型升级的问题在于,它带来的变化很多时候不是"对错",而是"好坏"——而好坏是主观的,评估成本极高。
GPT-4o 的回答更长更详细,是好是坏?取决于场景。技术支持场景下,详细是好事;简单问答场景下,啰嗦就是坏事。客服主管觉得不好,但某些用户可能觉得更专业了。
更要命的是,这种"坏"的信号出现有延迟。用户不会当场投诉,可能过几天才反映,或者干脆默默流失。等你发现指标异常,影响面已经不小了。
这就是我说的 AI 灰度的核心难题:不确定性更高,评估成本更大,信号延迟更长。
AI 灰度的三个特殊挑战
在我们踩过坑之后,我总结了三个传统灰度方案处理不好的地方。
第一个:评估指标不统一
普通功能灰度看的是工程指标:错误率、延迟、可用性。这些指标自动采集、实时可见。
AI 功能还需要看业务指标:用户满意度、任务完成率、二次追问率(用户问了 AI 之后又来问人工,说明 AI 没答好)、回答被采纳率。这些指标有的需要用户主动反馈,有的要靠后续行为推算,很难做到实时。
第二个:行为差异不可预测
不同模型对同一个 Prompt 的反应可能差很多。你以为只是"换个更好的模型",实际上新模型可能:
- 对某类 Prompt 更敏感,触发更多安全过滤
- 对格式要求理解不一致,导致结构化输出解析失败
- 语气风格发生变化,某些用户群体不适应
- Token 消耗量变化,成本超预算
这些都不是提前能完全预测的,必须用真实流量来验证。
第三个:回滚不像想象那么简单
普通功能回滚:切回旧代码,部署,完事。
AI 功能回滚:切回旧模型,但已经被新模型"污染"的对话上下文怎么处理?多轮对话中途换模型,前后文理解会不会出问题?对于有状态的对话场景,回滚的边界在哪?
这些问题不想清楚,回滚操作本身可能引入新的问题。
我们重新设计的灰度方案
经历了那次翻车之后,我们花了两周重新设计了一套 AI 功能的灰度发布流程。核心思路是:基于用户分组的灰度 + 多维指标对比 + 自动回滚触发器。
用户分组策略
我们放弃了随机流量切分,改为基于用户分组的灰度。
原因很简单:随机切分会导致同一个用户在不同会话中遇到不同的模型,体验割裂;而且一旦出问题,我们很难判断是"这批用户特殊"还是"模型本身有问题"。
用户分组策略:
- 种子用户组(0.5%):公司内部用户 + 愿意参与内测的 VIP 用户,这批人容错率高,出问题能快速反馈
- 早期采用者组(5%):活跃度高、历史评分好的用户,相对包容新东西
- 普通用户扩展组(20% → 50% → 100%):分阶段扩大
每个阶段之间设置观察窗口,最短 24 小时,最长 7 天,取决于业务场景的反馈周期。
指标体系设计
我们把指标分为三层:
L1 工程指标(实时监控)
- API 错误率
- P99 响应延迟
- Token 消耗量变化(超过基线 30% 自动告警)
- 流式响应中断率
L2 行为指标(近实时,5 分钟粒度)
- 用户二次追问率
- 会话平均轮次变化
- 提前退出率(用户没等 AI 回答完就关掉)
- 人工坐席转接率
L3 满意度指标(离线,日粒度)
- 用户主动评分
- NPS 变化
- 用户留存率变化
L1 指标异常立即告警,L2 指标连续异常触发预警,L3 指标用于最终评估决策。
自动回滚触发器
这是我们吃亏之后加的机制。以前出问题靠人发现、人判断、人操作,链路太长。现在我们设置了自动回滚触发器:
触发条件(满足任意一个):
- API 错误率 > 5%(持续 5 分钟)
- P99 延迟 > 10s(持续 10 分钟)
- Token 消耗量 > 基线 50%(持续 30 分钟)
- 二次追问率 > 基线 40%(持续 1 小时)触发后自动把该用户组的流量切回旧模型,同时发送告警通知。
代码实现:灰度路由 + 指标对比
说了那么多原理,来看具体实现。我们的 AI 服务是 Spring Boot 应用,下面是核心代码。
用户分组路由
@Component
public class AiModelRouter {
@Autowired
private GrayScaleConfigService grayScaleConfigService;
@Autowired
private UserGroupService userGroupService;
/**
* 根据用户ID决定使用哪个模型
*/
public ModelConfig routeModel(String userId, String featureKey) {
GrayScaleConfig config = grayScaleConfigService.getConfig(featureKey);
if (!config.isEnabled()) {
return config.getBaselineModel();
}
UserGroup userGroup = userGroupService.getUserGroup(userId);
// 检查用户是否在灰度组内
if (isInGrayGroup(userId, userGroup, config)) {
return config.getCandidateModel();
}
return config.getBaselineModel();
}
private boolean isInGrayGroup(String userId, UserGroup userGroup, GrayScaleConfig config) {
// 白名单用户(种子用户)直接进入
if (config.getWhitelistUsers().contains(userId)) {
return true;
}
// 按用户组判断
if (config.getEnabledGroups().contains(userGroup.getGroupName())) {
return true;
}
// 按比例判断(哈希取模,保证同一用户稳定分组)
int hashValue = Math.abs(userId.hashCode()) % 100;
return hashValue < config.getGrayPercent();
}
}@Data
public class GrayScaleConfig {
private String featureKey;
private boolean enabled;
private ModelConfig baselineModel; // 线上已有模型
private ModelConfig candidateModel; // 待验证新模型
private int grayPercent; // 灰度比例 0-100
private List<String> enabledGroups; // 开启的用户组
private Set<String> whitelistUsers; // 白名单用户
private AutoRollbackConfig rollbackConfig; // 自动回滚配置
}AOP 指标采集
@Aspect
@Component
@Slf4j
public class AiCallMetricsAspect {
@Autowired
private MetricsCollector metricsCollector;
@Around("@annotation(aiGrayScaleCall)")
public Object collectMetrics(ProceedingJoinPoint joinPoint, AiGrayScaleCall aiGrayScaleCall) throws Throwable {
String featureKey = aiGrayScaleCall.featureKey();
// 从上下文获取当前使用的模型版本
ModelVersion modelVersion = GrayScaleContext.getCurrentModelVersion();
long startTime = System.currentTimeMillis();
boolean success = false;
String errorType = null;
try {
Object result = joinPoint.proceed();
success = true;
return result;
} catch (Exception e) {
errorType = e.getClass().getSimpleName();
throw e;
} finally {
long duration = System.currentTimeMillis() - startTime;
// 采集调用指标
metricsCollector.record(AiCallMetrics.builder()
.featureKey(featureKey)
.modelVersion(modelVersion.getVersion())
.isGrayVersion(modelVersion.isGray())
.duration(duration)
.success(success)
.errorType(errorType)
.tokenCount(GrayScaleContext.getTokenCount())
.timestamp(System.currentTimeMillis())
.build());
}
}
}指标对比服务
@Service
public class GrayScaleComparisonService {
@Autowired
private MetricsRepository metricsRepository;
@Autowired
private AlertService alertService;
/**
* 对比灰度版本和基线版本的指标
*/
public ComparisonReport compare(String featureKey, TimeRange timeRange) {
ModelMetrics baseline = metricsRepository.getMetrics(featureKey, "baseline", timeRange);
ModelMetrics candidate = metricsRepository.getMetrics(featureKey, "candidate", timeRange);
ComparisonReport report = new ComparisonReport();
report.setFeatureKey(featureKey);
report.setTimeRange(timeRange);
// 错误率对比
double errorRateDiff = candidate.getErrorRate() - baseline.getErrorRate();
report.addMetricComparison("error_rate", baseline.getErrorRate(),
candidate.getErrorRate(), errorRateDiff);
// 延迟对比
double p99Diff = candidate.getP99Latency() - baseline.getP99Latency();
report.addMetricComparison("p99_latency", baseline.getP99Latency(),
candidate.getP99Latency(), p99Diff);
// Token 消耗对比
double tokenDiff = (candidate.getAvgTokenCount() - baseline.getAvgTokenCount())
/ baseline.getAvgTokenCount();
report.addMetricComparison("token_count", baseline.getAvgTokenCount(),
candidate.getAvgTokenCount(), tokenDiff);
// 二次追问率对比
double followUpDiff = candidate.getFollowUpRate() - baseline.getFollowUpRate();
report.addMetricComparison("follow_up_rate", baseline.getFollowUpRate(),
candidate.getFollowUpRate(), followUpDiff);
// 综合判断
report.setRecommendation(makeRecommendation(report));
return report;
}
private Recommendation makeRecommendation(ComparisonReport report) {
// 错误率增加超过 2%,建议回滚
if (report.getMetricDiff("error_rate") > 0.02) {
return Recommendation.ROLLBACK;
}
// P99 延迟增加超过 50%,建议暂停
if (report.getMetricDiff("p99_latency") > 0.5) {
return Recommendation.PAUSE;
}
// Token 消耗增加超过 40%,需要人工审核
if (report.getMetricDiff("token_count") > 0.4) {
return Recommendation.MANUAL_REVIEW;
}
// 所有指标都好,建议推进
if (report.allMetricsImproved()) {
return Recommendation.PROCEED;
}
return Recommendation.HOLD;
}
}自动回滚触发器
@Component
@Slf4j
public class AutoRollbackTrigger {
@Autowired
private GrayScaleConfigService grayScaleConfigService;
@Autowired
private MetricsRepository metricsRepository;
@Autowired
private AlertService alertService;
@Scheduled(fixedDelay = 60000) // 每分钟检查一次
public void checkAndRollback() {
List<GrayScaleConfig> activeConfigs = grayScaleConfigService.getActiveConfigs();
for (GrayScaleConfig config : activeConfigs) {
AutoRollbackConfig rollbackConfig = config.getRollbackConfig();
if (!rollbackConfig.isEnabled()) {
continue;
}
RollbackCheckResult result = checkRollbackCondition(config, rollbackConfig);
if (result.shouldRollback()) {
log.warn("Auto rollback triggered for feature: {}, reason: {}",
config.getFeatureKey(), result.getReason());
executeRollback(config);
alertService.sendRollbackAlert(config.getFeatureKey(), result.getReason());
}
}
}
private RollbackCheckResult checkRollbackCondition(GrayScaleConfig config,
AutoRollbackConfig rollbackConfig) {
TimeRange checkWindow = TimeRange.lastMinutes(rollbackConfig.getCheckWindowMinutes());
ModelMetrics candidateMetrics = metricsRepository.getMetrics(
config.getFeatureKey(), "candidate", checkWindow);
// 检查错误率
if (candidateMetrics.getErrorRate() > rollbackConfig.getMaxErrorRate()) {
return RollbackCheckResult.shouldRollback(
String.format("Error rate %.2f%% exceeds threshold %.2f%%",
candidateMetrics.getErrorRate() * 100,
rollbackConfig.getMaxErrorRate() * 100));
}
// 检查 P99 延迟
if (candidateMetrics.getP99Latency() > rollbackConfig.getMaxP99Latency()) {
return RollbackCheckResult.shouldRollback(
String.format("P99 latency %dms exceeds threshold %dms",
candidateMetrics.getP99Latency(),
rollbackConfig.getMaxP99Latency()));
}
// 检查 Token 消耗
ModelMetrics baselineMetrics = metricsRepository.getMetrics(
config.getFeatureKey(), "baseline", checkWindow);
double tokenGrowthRate = (double)(candidateMetrics.getAvgTokenCount()
- baselineMetrics.getAvgTokenCount())
/ baselineMetrics.getAvgTokenCount();
if (tokenGrowthRate > rollbackConfig.getMaxTokenGrowthRate()) {
return RollbackCheckResult.shouldRollback(
String.format("Token growth rate %.1f%% exceeds threshold %.1f%%",
tokenGrowthRate * 100,
rollbackConfig.getMaxTokenGrowthRate() * 100));
}
return RollbackCheckResult.noRollback();
}
private void executeRollback(GrayScaleConfig config) {
// 将灰度比例设为 0,白名单清空
config.setGrayPercent(0);
config.getWhitelistUsers().clear();
config.getEnabledGroups().clear();
grayScaleConfigService.updateConfig(config);
log.info("Rollback executed for feature: {}", config.getFeatureKey());
}
}灰度发布决策流程
一些实践中的细节
对话状态的处理
对于多轮对话场景,我们的策略是:一旦一个会话开始使用某个模型版本,该会话全程使用同一个版本,不会中途切换。会话开始时路由,结果绑定到会话 ID。
这样做的好处是避免上下文错乱,坏处是灰度比例变化对已有会话不立即生效,有一定延迟。我们认为这个延迟是可以接受的。
Prompt 的兼容性测试
在进入灰度之前,我们会对所有现有 Prompt 做一轮离线评估:用历史对话数据分别跑新旧模型,对比输出。这一步能提前发现格式解析问题、语气突变等问题,减少线上风险。
成本监控前置
新模型通常意味着更高的 Token 消耗,尤其是从 GPT-3.5 升到 GPT-4 这类跨代升级。我们在灰度阶段就会严格监控 Token 消耗增量,避免等到全量上线后才发现成本翻倍。
不要迷信"更好的模型一定效果更好"
这是我血泪教训。GPT-4o 在通用 benchmark 上确实远超 GPT-3.5,但在我们的客服场景里,有些 Prompt 在 GPT-3.5 上经过大量调优,换到 GPT-4o 之后反而效果变差了。模型升级需要配套 Prompt 升级,这是个系统工程,不是换个模型名字就搞定的事。
最后说几句
AI 模型升级的灰度发布,本质上是个不确定性管理问题。你没办法在上线前 100% 预知新模型的行为,但你可以通过有序的灰度策略,把风险控制在可接受的范围内,用真实用户数据来驱动决策。
那次翻车之后,我们建立了这套流程,此后又做了三次模型升级,每一次都平稳落地。不是因为我们变聪明了,是因为我们把流程搭好了,不用靠"人厉害"来保证质量。
