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,会有什么问题?

▸ 答案:无法归因。三个变量同时变,指标变化不知道来自哪个;如果方向相反还会互相抵消。实验设计应一次只改一个主变量;特征与模型变更分开实验,或至少做因子设计/分层分析。否则「赢了」也不知道为什么赢,无法复制。

关联