64.4 模型版本与回滚

一句话

模型版本不是「文件名加 v2」,而是不可变制品 + 可追溯元数据 + 原子别名切换:回滚要能在秒级完成,且不需要重新训练、重新打包、重新配置。

模型注册表的最小模型

对象 作用 关键约束
Model 逻辑模型(churn、ctr、rerank) 名字稳定,跨版本不变
Version 一次训练产出的不可变制品 只增不改;同版本内容 hash 固定
Stage/Alias stable / canary / archived 指针可原子切换,指向某个 Version
Run 训练实验(数据、超参、指标) 与 Version 多对一/一对一
Artifact 权重、签名、特征 spec、镜像 内容寻址,可校验

🔑 回滚的对象是 Alias,不是「重新部署一个旧版本」。只有别名切换是 O(1),重新构建/重新加载都是 O(分钟),故障时来不及。

版本里必须带什么

元数据 回答的问题 缺失后果
模型 hash 这是不是同一个制品 无法确认线上跑的是哪份权重
训练数据快照 用哪些数据训的 无法复现/审计
特征 spec/版本 输入需要哪些特征、什么时间语义 新特征配旧模型,直接报错或效果崩
代码 commit 预处理/训练代码版本 无法定位逻辑回归
离线指标 AUC/NDCG/校准 无法比较版本
签名(输入/输出) 服务该收什么、返回什么 schema 不匹配,线上 500
资源需求 GPU/显存/批大小 调度失败或 OOM

⚠️ 只版本化权重、不版本化特征 spec,是最常见的「回滚后仍然错」:旧模型配上了新特征顺序/新语义,回滚了模型但没回滚特征。

代码:不可变版本、金丝雀、原子回滚

package main

import (
	"fmt"
	"math/rand"
)

type Model struct {
	Version   string
	Hash      string
	ErrorRate float64
}

type Registry struct {
	Models map[string]Model
	Stable string
	Canary string
}

func (r *Registry) pick(canaryPct int, rr *rand.Rand) Model {
	if r.Canary != "" && rr.Intn(100) < canaryPct {
		return r.Models[r.Canary]
	}
	return r.Models[r.Stable]
}

func serve(r *Registry, n, canaryPct int, rr *rand.Rand) (errors int) {
	for i := 0; i < n; i++ {
		m := r.pick(canaryPct, rr)
		if rr.Float64() < m.ErrorRate {
			errors++
		}
	}
	return
}

func main() {
	reg := &Registry{Models: map[string]Model{
		"v1": {"v1", "sha256:1a2b", 0.030},
		"v2": {"v2", "sha256:9f0c", 0.010},
		"v3": {"v3", "sha256:77de", 0.200}, // 坏模型
	}, Stable: "v2"}

	fmt.Println("=== 模型注册表(不可变制品 + 别名)===")
	for _, v := range []string{"v1", "v2", "v3"} {
		m := reg.Models[v]
		fmt.Printf("  %s hash=%s error=%.1f%%\n", m.Version, m.Hash, m.ErrorRate*100)
	}

	r := rand.New(rand.NewSource(42))
	reg.Canary = "v3"
	e1 := serve(reg, 2000, 10, r)
	fmt.Printf("\n金丝雀 10%%:%d 次请求,错误 %d(%.2f%%)\n", 2000, e1, float64(e1)/2000*100)

	// 错误地全量发布 v3
	reg.Stable = "v3"
	reg.Canary = ""
	e2 := serve(reg, 2000, 0, r)
	fmt.Printf("全量 v3:%d 次请求,错误 %d(%.2f%%)——超过阈值,触发回滚\n", 2000, e2, float64(e2)/2000*100)

	// 回滚:只切别名,不重新构建
	reg.Stable = "v2"
	e3 := serve(reg, 500, 0, r)
	fmt.Printf("回滚到 v2:%d 次请求,错误 %d(%.2f%%);回滚动作 = 切一次别名(O(1))\n",
		500, e3, float64(e3)/500*100)

	fmt.Println("\n=== 回滚判据 ===")
	fmt.Println("1) 制品不可变:v1/v2/v3 各自 hash 固定,回滚不重新打包")
	fmt.Println("2) 别名稳定:stable -> v2 是原子指针切换,已建立连接无需重建")
	fmt.Println("3) 指标触发:5xx/超时/业务指标超过阈值,自动或人工切回")
	fmt.Println("4) 数据/代码版本一起记:模型 hash + 特征版本 + 训练数据快照")
}

本机实测(macOS,Apple M1,Go 1.27.1):

=== 模型注册表(不可变制品 + 别名)===
  v1 hash=sha256:1a2b error=3.0%
  v2 hash=sha256:9f0c error=1.0%
  v3 hash=sha256:77de error=20.0%

金丝雀 10%:2000 次请求,错误 51(2.55%)
全量 v3:2000 次请求,错误 425(21.25%)——超过阈值,触发回滚
回滚到 v2:500 次请求,错误 4(0.80%);回滚动作 = 切一次别名(O(1))

=== 回滚判据 ===
1) 制品不可变:v1/v2/v3 各自 hash 固定,回滚不重新打包
2) 别名稳定:stable -> v2 是原子指针切换,已建立连接无需重建
3) 指标触发:5xx/超时/业务指标超过阈值,自动或人工切回
4) 数据/代码版本一起记:模型 hash + 特征版本 + 训练数据快照

读数:

阶段 请求 错误 说明
金丝雀 10% 2000 51(2.55%) 坏模型只影响 10%,但错误率已明显高于 stable
全量 v3 2000 425(21.25%) 错误率超阈值,触发回滚
回滚 v2 500 4(0.80%) 只切 alias,秒级恢复,无重新构建

🔑 金丝雀的价值不是「少错」,而是在影响面小的时候收集足够信号。如果 10% 流量的错误率已经从 1% 涨到 2.5%,就不该继续放量;等到 100% 才发现,回滚再快也已经有大量用户受损。

回滚不是「再发一次」

做法 耗时 风险
重新训练旧版本 分钟–小时 数据/环境已变,未必能复现
重新构建镜像 分钟 依赖漂移,可能构建失败
切别名到旧 Version 秒级 需要旧制品仍保留、schema 兼容
切流量到旧副本 秒–分钟 需要副本常驻或快速拉起

回滚前提:

  1. 旧版本制品还在(镜像/权重不被清理);
  2. 旧版本的特征 spec 与当前在线特征兼容;
  3. 数据库/缓存里的模型相关数据(如 embedding、索引)可回滚或双版本共存;
  4. 回滚后监控能确认「错误率真的降了」,而不是流量切错。

⚠️ 模型回滚常常不是「一个模型」的事:如果新模型依赖新的向量索引、新的特征、新的缓存 key,回滚模型但不回滚这些依赖,旧模型会读到不兼容的数据。回滚清单要把「模型 + 特征 + 索引 + 配置」作为一个版本单元。

版本策略:语义化版本不够,要「可比较」

变更 版本号 是否兼容 回滚难度
权重更新、同结构重训 1.2.0 → 1.2.1 输入输出不变 低
特征新增(可缺省) 1.3.0 服务需兼容缺省 中
输入 schema 变化 2.0.0 不兼容,需双写/适配 高
预处理逻辑变化 必须升 minor/major 可能改变分数分布 中–高
只改阈值/后处理 配置版本,不算模型版本 可回滚配置 低

🔑 版本号的意义是「让回滚有边界」:patch 可以无脑回;minor 要检查特征缺省;major 要准备双版本/双写。不要把所有变更都塞进 v2。

生产落地:指名 → 可抄 → 可验 → 可退

指名:模型注册表用 MLflow Model Registry(开源、与训练打通);服务层用 KServe / Seldon 的 Canary/Revision;云上用 SageMaker Model Registry / Vertex AI Model Registry。

可抄(MLflow 2.x,未验证:本机未安装 MLflow):

# 注册模型并打别名
mlflow models register -m runs:/<run-id>/model -n churn --await-registration
mlflow models set-alias -n churn -v 3 -a canary
mlflow models set-alias -n churn -v 2 -a stable

# 回滚:把 stable 指回 v2
mlflow models set-alias -n churn -v 2 -a stable

可验:本节程序输出金丝雀/全量/回滚的错误率;生产上用「回滚演练」验证:随机把一个 canary 版本标成 stable,观察 5 分钟内错误率是否恢复、流量是否切回。可退:保留最近 N 个版本制品;别名切换是回滚开关;特征/索引与模型同版本单元;回滚后跑一次 smoke test + 关键指标对比。

反模式

反模式 后果
模型文件用 model_final_v2_new.pt 命名 无法追溯、无法比较、回滚靠人记
覆盖旧版本制品 回滚时没有旧文件可用
只回滚模型、不回滚特征/索引 旧模型读新数据,仍然错
金丝雀只看错误率、不看业务指标 系统正常但效果变差
全量发布前不做 shadow train-serving skew 直接上线
回滚后不验证 以为切回去了,实际流量/缓存没生效
模型版本与特征版本不绑定 复现时不知道用哪版特征
旧镜像/权重清理太激进 出事时无法快速回滚

自检

Q1 模型注册表里 Model、Version、Alias 分别是什么?为什么回滚切 Alias 而不是重建 Version?

▸ 答案:Model 是逻辑模型名;Version 是一次训练产出的不可变制品(权重+元数据);Alias 是指向某个 Version 的指针(stable/canary)。回滚切 Alias 是 O(1) 原子操作,秒级生效;重建 Version 要重新打包/加载,分钟级,且可能因依赖漂移无法复现。

Q2 一个可回滚的模型制品至少要包含哪些元数据?

▸ 答案:模型 hash、训练数据快照、特征 spec/版本、代码 commit、离线指标、输入输出签名、资源需求、镜像 digest。缺数据/特征版本无法复现;缺签名无法保证服务兼容;缺资源需求可能调度失败。

Q3 为什么「金丝雀 10% 错误率 2.55%」就应该停,而不是等 100% 再看?

▸ 答案:10% 流量已经提供了足够信号:stable 错误率 1%,canary 2.55%,相对恶化 155%,且样本 2000 已有统计意义。继续放量只会扩大影响面;金丝雀的目的就是「用小影响面换决策信息」,不是「等确认坏透了再回滚」。

Q4 回滚模型就够了吗?哪些依赖要一起回滚?

▸ 答案:不够。模型依赖特征 spec、向量索引、缓存 key、预处理配置、后处理阈值。新模型可能写入新索引/新缓存,旧模型读不懂;回滚清单要把「模型 + 特征 + 索引 + 配置」作为一个版本单元,并验证回滚后 smoke test 与关键指标。

Q5 语义化版本在模型上为什么不够?

▸ 答案:模型变更不只是代码 API 兼容,还包括分数分布、特征依赖、预处理逻辑。1.2.1 可能只是重训但分布变了;1.3.0 可能新增特征导致旧服务不兼容。版本号要配合「变更类型 + 兼容性检查 + 回滚边界」,不能只看数字。

Q6 回滚后怎么确认真的恢复了?

▸ 答案:看三件事:① 流量是否真的切到旧版本(按版本维度的 QPS/错误率);② 关键指标是否回到基线(错误率、P99、业务指标);③ 依赖是否一致(特征版本、索引版本、缓存 key)。只看「发布成功」不等于「回滚生效」。

Q7 旧版本制品保留多久?

▸ 答案:至少覆盖「最长故障发现 + 回滚 + 审计」窗口,常见 30–90 天;核心模型保留更久。保留成本主要是镜像/权重存储,相比事故代价很低。清理前要确认没有流量、没有回滚需求、没有审计要求。

关联