模型服务 A/B 测试:流量分桶要同时看延迟和业务指标
模型服务 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(nA+nB) 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 才是最终决策依据。