
模型服务 A/B 测试流量分桶要同时看延迟和业务指标一、只比准确率是不够的团队的推荐模型服务做了一次架构升级。从 PyTorch 迁移到 TensorRT-LLM推理延迟从 180ms 降到了 45ms。看实验室数据两个模型的准确率几乎一样——旧版 0.891新版 0.893。差距不到 0.3%测试同学直接给了通过。上线后第二天运营数据出来了一组反直觉的数字新版模型服务的转化率下降了 4.7%。排查发现虽然新版模型在评分数据集上表现一致但实际场景中用户的行为分布和离线评估集有显著差异。新版模型对长尾物品的推荐更激进但用户对不熟悉物品的点击意愿更低。只比较准确率就做 A/B 结论等于用实验室的尺子量生产环境的水深。基础设施不需要漂亮话它需要涵盖延迟、吞吐和业务指标的三维评估。二、模型 A/B 测试的三维评估框架模型服务的 A/B 测试比传统软件复杂得多。它不仅涉及服务层指标延迟、吞吐还涉及模型质量指标准确率、召回率和业务指标转化率、留存率。flowchart TD I[用户请求] -- H[流量分桶] H --|70%| A[模型A 生产版] H --|30%| B[模型B 候选版] A -- M1[服务指标br/延迟/吞吐/错误率] A -- M2[模型指标br/准确率/召回率] A -- M3[业务指标br/转化率/留存率] B -- N1[服务指标br/延迟/吞吐/错误率] B -- N2[模型指标br/准确率/召回率] B -- N3[业务指标br/转化率/留存率] M1 -- C{对比分析} N1 -- C M2 -- C N2 -- C M3 -- C N3 -- C C --|达标| D[全量切换到B] C --|不达标| E[回滚到A]A/B 测试的核心设计要素分层分桶。将流量按用户维度如 user_id 哈希稳定分桶确保同一个用户始终命中同一个版本。分桶比例通常从 5% 起步逐步扩大到 50%。指标体系的层次。第一层是服务指标延迟 P99、吞吐 QPS、错误率必须持平或更优第二层是模型指标准确率等需要离线评估一致第三层才是业务指标转化率等这是最终的裁判。统计显著性。A/B 对比不是简单的数值谁大谁对。需要计算 p-value 和置信区间确认差异是统计显著的不是随机波动。样本量不够时即使指标有差异也不可信。三、流量分桶与指标采集实现package abtest import ( context crypto/md5 encoding/binary fmt sync time github.com/prometheus/client_golang/prometheus ) // ModelVersion 模型版本标识 type ModelVersion string const ( ModelA ModelVersion v3.2.1 // 生产版 ModelB ModelVersion v4.0.0 // 候选版 ) // TrafficRouter 流量分桶路由器 type TrafficRouter struct { mu sync.RWMutex config *ABConfig experimentID string } // ABConfig A/B 测试配置 type ABConfig struct { Enabled bool ExperimentB float64 // 候选版流量比例如 0.3 表示 30% HashSalt string // 哈希盐值用于防止分桶规律被推测 } // Route 根据用户 ID 决定使用哪个模型版本 func (r *TrafficRouter) Route(userID string) ModelVersion { r.mu.RLock() defer r.mu.RUnlock() if !r.config.Enabled { return ModelA // 默认使用生产版 } bucket : hashUserID(userID, r.config.HashSalt) % 100 threshold : int(r.config.ExperimentB * 100) if bucket threshold { return ModelB } return ModelA } // hashUserID 稳定哈希保证同一用户始终命中同一分桶 func hashUserID(userID, salt string) int { h : md5.Sum([]byte(userID salt)) // 取前 4 字节转为 uint32再取模 100 return int(binary.BigEndian.Uint32(h[:4])) } // UpdateConfig 在线更新分桶比例无需重启服务 func (r *TrafficRouter) UpdateConfig(cfg *ABConfig) { r.mu.Lock() defer r.mu.Unlock() r.config cfg } // Prometheus 指标定义 var ( // 服务层指标 modelLatency prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: model_inference_latency_ms, Buckets: []float64{10, 25, 50, 100, 200, 500, 1000, 2000}, }, []string{model_version}, ) modelErrors prometheus.NewCounterVec( prometheus.CounterOpts{ Name: model_inference_errors_total, }, []string{model_version, error_type}, ) // 业务层指标 modelConversionRate prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: model_conversion_rate, }, []string{model_version}, ) modelRequests prometheus.NewCounterVec( prometheus.CounterOpts{ Name: model_inference_requests_total, }, []string{model_version}, ) ) // InferenceClient 模型推理客户端集成 A/B 路由和指标采集 type InferenceClient struct { router *TrafficRouter engineA ModelEngine engineB ModelEngine } type ModelEngine interface { Infer(ctx context.Context, input []byte) ([]byte, error) } // InferWithAB 执行带 A/B 分桶的推理 func (c *InferenceClient) InferWithAB(ctx context.Context, userID string, input []byte) ([]byte, error) { version : c.router.Route(userID) // 记录请求量 modelRequests.WithLabelValues(string(version)).Inc() start : time.Now() var output []byte var err error switch version { case ModelA: output, err c.engineA.Infer(ctx, input) case ModelB: output, err c.engineB.Infer(ctx, input) } // 记录延迟按模型版本分组 latency : float64(time.Since(start).Milliseconds()) modelLatency.WithLabelValues(string(version)).Observe(latency) // 记录错误 if err ! nil { modelErrors.WithLabelValues(string(version), classifyError(err)).Inc() return nil, fmt.Errorf(infer with model %s: %w, version, err) } return output, nil } func classifyError(err error) string { if err nil { return none } errStr : err.Error() switch { case contains(errStr, timeout): return timeout case contains(errStr, oom), contains(errStr, out of memory): return oom default: return unknown } } // ABReport A/B 测试报告 type ABReport struct { ModelA struct { Requests int64 LatencyP50, LatencyP99 float64 ErrorRate float64 } ModelB struct { Requests int64 LatencyP50, LatencyP99 float64 ErrorRate float64 } } // CalculateStatisticalSignificance 计算样本量是否足够 // 使用简化版的两样本 z-test func CalculateStatisticalSignificance(nA, nB int, rateA, rateB float64) (float64, bool) { if nA 100 || nB 100 { return 0, false // 样本量不足 } pPool : (rateA*float64(nA) rateB*float64(nB)) / float64(nAnB) se : pPool * (1 - pPool) * (1/float64(nA) 1/float64(nB)) if se 0 { return 0, false } z : (rateB - rateA) / se return z, z 1.96 || z -1.96 // 95% 置信水平 }关键设计决策分层指标采集。服务指标延迟、错误率在推理客户端内直接采集业务指标转化率通过 Prometheus Gauge 接收业务系统的上报。稳定哈希分桶。同一个 user_id 始终命中相同版本避免用户在 AB 之间反复横跳导致体验不一致。分桶比例热更新。通过UpdateConfig方法在线调整比例无需重启服务。灰度过程可以逐步从 5% 到 30% 再到 100%。统计显著性校验。简化版的 z-test 判断差异是否显著。样本量小于 100 时直接忽略避免小样本误导决策。四、A/B 测试的工程陷阱冷启动偏差。新模型刚上线时GPU 缓存未预热延迟可能比稳态高 20%-50%。A/B 测试的前 10 分钟数据不应纳入结论等 GPU 缓存和连接池稳定后再采集。用户行为的延迟反馈。推荐模型的转化事件可能在展示后数小时甚至数天发生。A/B 测试窗口必须覆盖事件的完整发生周期。对于电商推荐建议窗口至少 72 小时。离线在线不一致。离线评估集再大也不可能覆盖所有线上分布。离线 Recall 提升 3% 但线上转化不变甚至下降是常见的真实结果。离线指标是必要的辅助但不能替代线上 A/B。多实验正交性。同时跑多个 A/B 实验时需要保证实验之间正交不同实验不污染同一批用户。通常用多层分桶解决——在 user_id 哈希中加入 experiment_id 作为盐值。GPU 资源的对称性。A/B 测试必须在相同硬件配置上运行。如果 Model B 在 A100 上跑、Model A 在 T4 上跑延迟差异来自硬件而不是模型。部署时必须确保两组 Pod 的节点亲和性一致。放量策略。从 5% → 15% → 30% → 50% → 100% 逐步释放。每个阶段观察至少 2 小时的 P99 延迟和错误率确认没有雪崩风险再进入下一阶段。五、总结模型 A/B 测试不能只看准确率。需要从服务指标延迟、错误率、模型指标准确率和业务指标转化率三个维度综合评估。核心实践稳定哈希分桶保证同用户同版本分桶比例支持热更新实现渐进放量GPU 预热 10 分钟后再采集指标统计显著性校验避免小样本误判多层分桶保证多实验正交放量阶段每 2 小时确认无异常。离线评估是入场券线上 A/B 才是最终决策依据。