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 兼容 |
| 切流量到旧副本 | 秒–分钟 | 需要副本常驻或快速拉起 |
回滚前提:
- 旧版本制品还在(镜像/权重不被清理);
- 旧版本的特征 spec 与当前在线特征兼容;
- 数据库/缓存里的模型相关数据(如 embedding、索引)可回滚或双版本共存;
- 回滚后监控能确认「错误率真的降了」,而不是流量切错。
⚠️ 模型回滚常常不是「一个模型」的事:如果新模型依赖新的向量索引、新的特征、新的缓存 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 天;核心模型保留更久。保留成本主要是镜像/权重存储,相比事故代价很低。清理前要确认没有流量、没有回滚需求、没有审计要求。