64.3 模型服务与 A/B
一句话
模型上线不是「发一个新版本」,而是用真实流量换信息:shadow 验证不伤人,canary 验证系统指标,A/B 验证业务指标;三者回答不同问题,不能互相替代。
三种发布方式,三个问题
| 方式 | 流量 | 是否返回新模型结果 | 回答什么问题 | 风险 |
|---|---|---|---|---|
| Shadow | 复制 100% 或抽样 | 否,只记录 | 新模型分数分布、延迟、错误率、与旧模型差异 | 零用户风险,但消耗双份算力 |
| Canary | 1%–10% | 是 | 新版本在真实流量下系统指标是否正常(错误率、P99、资源) | 小范围用户风险 |
| A/B | 50/50 或按实验设计 | 是 | 新模型是否提升业务指标(CTR、CVR、留存) | 需要足够样本与成熟标签 |
🔑 顺序通常是 shadow → canary → A/B → 全量。跳过 shadow,会把 train-serving skew 直接带上 canary;跳过 canary,会把系统故障带进 A/B;跳过 A/B,会把「离线涨点」当成「业务变好」。
模型服务要看的四层指标
| 层 | 指标 | 作用 | 常见阈值 |
|---|---|---|---|
| 系统 | QPS、P99、错误率、GPU 利用率、队列长度 | 服务是否活着 | P99 < SLO,错误率 < 0.5% |
| 模型 | 分数分布、特征缺失率、预测漂移、置信度 | 模型行为是否异常 | PSI < 0.1,缺失率差 < 1% |
| 业务 | CTR、CVR、GMV、停留、留存 | 是否真的有用 | 按实验设计 |
| 护栏 | 投诉率、退款率、内容安全、延迟 | 是否有负向副作用 | 任一越界即停 |
⚠️ 业务指标变好不一定能上线:如果投诉率/错误率/延迟同时恶化,净收益可能为负。护栏指标要有「一票否决权」,而不是等业务指标显著后再补看。
实验设计的五个坑
| 坑 | 表现 | 处理 |
|---|---|---|
| 随机化单元错 | 按请求随机,同一用户体验不一致 | 按用户/会话随机;跨设备要统一 ID |
| SRM | A/B 流量不是 50/50 | 每天校验分流比例,偏差 > 1% 报警 |
| 标签延迟 | 转化 7 天后才回传,第 1 天就下结论 | 等标签成熟或建模延迟分布 |
| 新奇效应 | 新模型前 3 天涨、之后回落 | 至少跑 1–2 个完整周期 |
| 偷看 | 每天看 p 值,看到显著就停 | 固定样本/序贯检验(见 63.5 推荐效果评估与 A/B) |
样本量怎么算、NDCG 与 A/B 的关系在 63.5 推荐效果评估与 A/B 已有完整推导;这里只补「模型服务」特有的两件事:标签延迟与护栏否决。
代码:延迟反馈会让早期结论完全反过来
真实 CVR A=10%、B=12%;但 B 的转化有 60% 发生在第 6 天(新奇/长决策周期),A 的转化均匀分布。第 1 天看,B 反而更差;第 7 天看,B 才变好。
package main
import (
"fmt"
"math"
"math/rand"
)
type U struct {
arm int // 0=A, 1=B
conv bool
delay int
err bool
}
func pvalue(xA, nA, xB, nB int) (float64, float64) {
pA := float64(xA) / float64(nA)
pB := float64(xB) / float64(nB)
se := math.Sqrt(pA*(1-pA)/float64(nA) + pB*(1-pB)/float64(nB))
if se == 0 {
return 0, 0
}
z := (pB - pA) / se
p := math.Erfc(math.Abs(z) / math.Sqrt2)
return (pB - pA) / pA, p
}
func main() {
r := rand.New(rand.NewSource(42))
const n = 5000
var users []U
for arm := 0; arm < 2; arm++ {
for i := 0; i < n; i++ {
u := U{arm: arm}
p := 0.10
if arm == 1 {
p = 0.12
}
u.conv = r.Float64() < p
// A:转化延迟均匀 0..6 天;B:60% 的转化发生在第 6 天(延迟反馈)
u.delay = r.Intn(7)
if arm == 1 && u.conv && r.Float64() < 0.60 {
u.delay = 6
}
u.err = r.Float64() < map[int]float64{0: 0.001, 1: 0.005}[arm]
users = append(users, u)
}
}
fmt.Println("=== 同一批实验,不同观察窗口(真实 CVR:A=10%,B=12%)===")
fmt.Printf("%-8s %-10s %-10s %-10s %-10s %s\n", "day", "A CVR", "B CVR", "lift", "p", "结论")
for _, day := range []int{1, 3, 7} {
xA, xB := 0, 0
for _, u := range users {
if u.conv && u.delay < day {
if u.arm == 0 {
xA++
} else {
xB++
}
}
}
lift, p := pvalue(xA, n, xB, n)
verdict := "不显著/不可下结论"
if p < 0.05 {
if lift > 0 {
verdict = "显著正向"
} else {
verdict = "显著负向(延迟偏差)"
}
}
fmt.Printf("%-8d %-10.4f %-10.4f %-10.2f %-10.4f %s\n",
day, float64(xA)/n, float64(xB)/n, lift, p, verdict)
}
errA, errB := 0, 0
for _, u := range users {
if u.err {
if u.arm == 0 {
errA++
} else {
errB++
}
}
}
fmt.Printf("\n护栏:错误率 A=%.2f%% B=%.2f%%(B 超过 0.3%% 阈值,需回滚或限流)\n",
float64(errA)/n*100, float64(errB)/n*100)
fmt.Println("\n结论:延迟转化的指标在第 1/3 天会低估 B;护栏指标(错误率)可能在业务指标变好之前先报警。")
}
本机实测(macOS,Apple M1,Go 1.27.1):
=== 同一批实验,不同观察窗口(真实 CVR:A=10%,B=12%)===
day A CVR B CVR lift p 结论
1 0.0152 0.0066 -0.57 0.0000 显著负向(延迟偏差)
3 0.0426 0.0230 -0.46 0.0000 显著负向(延迟偏差)
7 0.1000 0.1174 0.17 0.0052 显著正向
护栏:错误率 A=0.14% B=0.50%(B 超过 0.3% 阈值,需回滚或限流)
结论:延迟转化的指标在第 1/3 天会低估 B;护栏指标(错误率)可能在业务指标变好之前先报警。
读数:
| 观察窗口 | A CVR | B CVR | lift | 结论 | 真实情况 |
|---|---|---|---|---|---|
| 第 1 天 | 1.52% | 0.66% | −57% | 显著负向 | 只是 B 的转化还没回来 |
| 第 3 天 | 4.26% | 2.30% | −46% | 显著负向 | 仍有 60% 的 B 转化未到 |
| 第 7 天 | 10.00% | 11.74% | +17% | 显著正向 | 接近真实 +20% |
🔑 这张表是「A/B 第 1 天就宣布失败」的标准事故:显著性只能说明「当前观察到的差异不是随机」,不能说明「最终差异」。标签延迟会让早期差异系统性偏向「转化慢」的一侧。
反馈闭环:没有日志就没有下一次训练
| 要记什么 | 为什么 | 存在哪 |
|---|---|---|
| 请求上下文(user/session/device) | 复现、归因、去重 | 日志/数据湖 |
| 线上特征值 + feature_ts | 排查 train-serving skew | 特征日志 |
| 模型版本 + 分数/候选集 | 归因到版本 | 预测日志 |
| 曝光/点击/转化 + 时间 | 训练标签、延迟分析 | 行为日志 |
| 护栏指标 | 一票否决 | 监控/实验平台 |
⚠️ 只记「预测结果」不记「特征值」,出问题时无法判断是模型变了还是特征变了;只记「点击」不记「曝光」,CTR 的分母就错了。日志 schema 要和训练样本的 schema 对齐,否则反馈闭环断在最后一步。
生产落地:指名 → 可抄 → 可验 → 可退
指名:模型服务用 KServe / Seldon / Triton;流量切分与开关用 Istio / Envoy 或特性开关平台(LaunchDarkly、Statsig);实验平台可以自建,也可以直接用 Statsig/Amplitude。
可抄(Istio 按 header 切 5% 到 canary,未验证:本机未安装 Istio):
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: model-router
spec:
hosts: ["model.example.com"]
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: model
subset: v2
- route:
- destination:
host: model
subset: v1
weight: 95
- destination:
host: model
subset: v2
weight: 5
可验:本节程序输出第 1/3/7 天的 CVR 与 p 值;生产上每天校验分流比例(SRM)、标签成熟度、护栏指标,任一异常停止实验。可退:实验开关一键回滚到 control;模型服务保留 stable 版本;回滚以「切流量/切别名」完成,不重新构建(见 64.4 模型版本与回滚)。
反模式
| 反模式 | 后果 |
|---|---|
| 第 1 天看 CVR 就宣布成败 | 标签延迟导致结论反转(本节 −57% → +17%) |
| 按请求随机分流 | 同一用户体验不一致,指标被污染 |
| 不做 SRM 校验 | 分流偏了,实验结论不可信 |
| 只看业务指标,不看护栏 | 错误率/投诉率恶化被忽略 |
| 实验期间改模型/改特征 | 多个变量混在一起,无法归因 |
| 用训练集指标代替线上 A/B | 离线涨点、线上不涨 |
| 没有曝光日志 | CTR 分母错,标签不可用 |
| 一次开很多实验 | 互相干扰, Simpson 悖论 |
自检
Q1 Shadow、Canary、A/B 各回答什么问题?为什么不能互相替代?
▸ 答案:Shadow 回答「新模型在真实流量上会输出什么、延迟/错误如何」,不返回给用户,零风险;Canary 回答「小部分真实用户走新模型时系统指标是否正常」,有真实风险;A/B 回答「新模型是否提升业务指标」,需要足够样本与成熟标签。Shadow 发现不了业务收益,Canary 发现不了长期业务效果,A/B 发现不了 train-serving skew(因为不对比分数分布)。
Q2 为什么「第 1 天显著负向」不能直接判 B 失败?
▸ 答案:因为转化标签有延迟。本节 B 的 60% 转化发生在第 6 天,第 1 天只观察到早期转化,B 的 CVR 被系统性低估;此时 p 值显著只说明「当前观察到的差异不是随机」,不说明「最终差异」。要等标签成熟,或按延迟分布做校正/序贯检验,而不是看单日 p 值。
Q3 护栏指标为什么要有「一票否决权」?
▸ 答案:业务指标变好可能伴随错误率、投诉率、退款率、延迟恶化,净收益可能为负;护栏指标是「不能变差」的底线。实验平台应在护栏越界时自动停止/回滚,而不是等业务指标显著后再人工看。否则一次实验可能用短期 GMV 换长期用户信任。
Q4 随机化单元为什么通常选用户/会话,而不是请求?
▸ 答案:按请求随机会让同一用户在不同请求看到不同模型,体验不一致,且用户级指标(留存、复购)被污染;用户/会话随机能保证「同一用户在同一实验里只属于一组」。代价是需要稳定的用户 ID、跨设备打通,且样本量按用户而非请求算。
Q5 标签延迟怎么处理?
▸ 答案:三种做法:① 等标签成熟(按业务最长转化周期,通常 7/14/30 天);② 建模延迟分布,对早期观察做逆概率加权/生存分析校正;③ 用短期代理指标(点击、加购)做在线监控,但最终决策仍用成熟标签。不能把「未成熟标签」当「未转化」。
Q6 反馈闭环最少要记录哪几类日志?
▸ 答案:请求上下文(用户/会话/设备)、线上特征值与时间、模型版本与分数/候选集、曝光/点击/转化及时间、护栏指标。缺特征日志无法排查 skew;缺曝光日志 CTR 分母错;缺版本日志无法归因到模型;缺时间戳无法处理延迟。
Q7 一次实验同时改模型、特征、UI,会有什么问题?
▸ 答案:无法归因。三个变量同时变,指标变化不知道来自哪个;如果方向相反还会互相抵消。实验设计应一次只改一个主变量;特征与模型变更分开实验,或至少做因子设计/分层分析。否则「赢了」也不知道为什么赢,无法复制。