
- 概述
- 模型注册表核心功能
- MLflow Model Registry 实现
- 模型版本管理策略
- A/B 部署流水线设计
- KServe 金丝雀部署
- 流量拆分与路由
- 监控与自动回滚
- 模型晋升流水线自动化
- 故障排查
- 运维清单
- 失败案例
- 参考资料
概述
比起训练模型,把训练好的模型安全地部署到生产环境更难。训练代码一旦写好就可以反复执行,但部署每一次面对的条件都不一样。与旧版本的兼容性、随流量增长而来的资源分配、新模型的性能验证、故障时的即时回滚——这一切都必须交给自动化流水线来处理。
本文将围绕两个核心组件展开。
- 模型注册表:对训练好的模型 artifact 进行集中版本管理,并配合元数据提供晋升(promotion)工作流的系统
- A/B 部署流水线:同时提供新旧两个模型的服务、拆分流量,并基于实时指标决定晋升或回滚的自动化流水线
本文以 2026 年 3 月为基准,反映 MLflow 2.19+、KServe 0.14+、W&B Registry、SageMaker Model Registry 的最新功能。与此前文章讨论过的 Kubeflow Pipeline 编排、Feature Store 同步区分开范围,本文聚焦于模型注册表的内部设计和部署策略的具体实现。
模型注册表核心功能
模型注册表不是单纯的文件存储。生产级 ML 系统中,模型注册表应当提供的核心功能如下。
必备功能清单
| 功能 | 说明 | 缺失时产生的问题 |
|---|---|---|
| 版本管理 | 按顺序管理同一模型的多个版本 | 无法追踪生产环境中运行的是哪个模型 |
| 元数据打标 | 关联训练数据、超参数、评估指标 | 模型无法复现,调试时无法追溯原因 |
| Alias / Stage 管理 | 标示 champion、challenger、archived 等状态 | 自动化部署时无法判断该部署哪个版本 |
| Lineage 追踪 | 可反向追溯训练 Run、数据集、代码版本 | 无法应对合规审计(audit) |
| 访问控制 | 按团队/角色分离读/写/晋升权限 | 生产模型被误覆盖的事故 |
| Artifact 存储集成 | 支持 S3、GCS、Azure Blob 等外部存储 | 无法管理大体量模型(LLM) |
| API / SDK | 以编程方式完成注册、查询、晋升 | 无法与 CI/CD 流水线集成 |
主流模型注册表方案对比
| 项目 | MLflow Model Registry | W&B Registry | SageMaker Model Registry | Vertex AI Model Registry |
|---|---|---|---|---|
| 部署方式 | 开源(自托管)或 Databricks | SaaS 或自托管 | AWS 托管 | GCP 托管 |
| 版本管理 | 自动递增编号 + Alias | 基于 Collection + 版本链接 | 基于 ModelPackageGroup | 自动版本编号 |
| Stage 管理 | Alias(@champion、@challenger) | 基于标签 | Approval 状态(Approved/Rejected/Pending) | 基于标签 |
| Lineage | 关联 MLflow Run | 关联 W&B Run | 自动捕获 Training Job + S3 URI | 关联 Pipeline Run |
| 跨团队共享 | 需要设置权限 | Organization 内共享 | 跨账户资源策略 | 基于 IAM |
| LLM 支持 | MLflow 2.x transformers flavor | Artifact 版本管理 | JumpStart 模型 + 自有模型 | Model Garden + 自有模型 |
| CI/CD 集成 | REST API + Python SDK | Python SDK + webhook | CodePipeline 原生 | 集成 Cloud Build |
| 费用 | 免费(仅基础设施成本) | 免费层 + 付费 | 按用量计费 | 按用量计费 |
MLflow Model Registry 实现
截至 2026 年,MLflow Model Registry 是使用最广泛的开源模型注册表。MLflow 3.x 中引入的基于 Alias 的模型管理取代了此前的 Stage(Staging/Production/Archived)方式,提供了可以给同一个模型版本分配多个 Alias 的灵活工作流。
模型注册与 Alias 设置
"""
MLflow Model Registry:模型注册、版本管理、Alias 设置
以 MLflow 2.19+ 为基准
"""
import mlflow
from mlflow.tracking import MlflowClient
# 连接 MLflow Tracking Server
mlflow.set_tracking_uri("http://mlflow.internal.example.com:5000")
client = MlflowClient()
# 1. 训练运行与模型日志记录
with mlflow.start_run(run_name="fraud-detection-v3") as run:
# 记录训练指标
mlflow.log_params({
"model_type": "xgboost",
"n_estimators": 500,
"max_depth": 8,
"learning_rate": 0.05,
"training_data_version": "2026-03-01",
"feature_count": 47,
})
mlflow.log_metrics({
"auc_roc": 0.9847,
"precision_at_95_recall": 0.912,
"f1_score": 0.943,
"latency_p99_ms": 12.3,
})
# 一次性完成模型 artifact 日志记录 + 注册表注册
model_info = mlflow.xgboost.log_model(
xgb_model=trained_model,
artifact_path="model",
registered_model_name="fraud-detection",
input_example=sample_input,
signature=mlflow.models.infer_signature(X_test, y_pred),
)
print(f"Model URI: {model_info.model_uri}")
# 输出:models:/fraud-detection/3
# 2. 为已注册的模型版本分配 Alias
# 将新模型指定为 challenger
client.set_registered_model_alias(
name="fraud-detection",
alias="challenger",
version=3,
)
# 3. 验证完成后晋升为 champion
def promote_to_champion(model_name: str, version: int):
"""将模型晋升为 champion,并记录此前 champion 的信息。"""
# 确认当前 champion
try:
current_champion = client.get_model_version_by_alias(
name=model_name, alias="champion"
)
# 为此前的 champion 添加 archived 标签
client.set_model_version_tag(
name=model_name,
version=current_champion.version,
key="previously_champion",
value="true",
)
print(f"Previous champion: v{current_champion.version}")
except mlflow.exceptions.MlflowException:
print("No existing champion found")
# 指定新的 champion
client.set_registered_model_alias(
name=model_name, alias="champion", version=version
)
# 移除 challenger alias
client.delete_registered_model_alias(
name=model_name, alias="challenger"
)
print(f"Promoted v{version} to champion")
promote_to_champion("fraud-detection", version=3)
模型加载与服务集成
"""
基于 Alias 加载模型并用于服务的模式。
在部署流水线中自动 resolve champion 模型的 URI。
"""
import mlflow
# 加载 champion 模型(基于 Alias)
champion_model = mlflow.pyfunc.load_model("models:/fraud-detection@champion")
# 加载特定版本(用于调试/对比)
v2_model = mlflow.pyfunc.load_model("models:/fraud-detection/2")
# 将模型 URI 转换为 KServe storageUri 的工具函数
def resolve_model_storage_uri(model_name: str, alias: str) -> str:
"""从 MLflow Alias 中提取实际的 artifact storage URI。"""
client = MlflowClient()
mv = client.get_model_version_by_alias(name=model_name, alias=alias)
run = client.get_run(mv.run_id)
artifact_uri = run.info.artifact_uri
# 例:s3://ml-artifacts/12/abc123/artifacts/model
return f"{artifact_uri}/model"
storage_uri = resolve_model_storage_uri("fraud-detection", "champion")
print(f"KServe storageUri: {storage_uri}")
# 输出:s3://ml-artifacts/12/abc123/artifacts/model
模型版本管理策略
模型版本管理不只是简单地递增数字。为了在生产环境中保证安全的模型替换,需要一套系统化的策略。
版本管理原则
1. 保证不变性(Immutability):一旦注册的模型版本 artifact,绝不修改。需要修改时就注册新版本。一旦破坏这条原则,就会出现"昨天还好好的模型突然给出不同结果"这种幽灵般的 bug。
2. 强制要求必备元数据:定义模型注册时必须包含的元数据,并进行自动校验。
"""
在模型注册时校验必备元数据的守门器(gatekeeper)。
插入到 CI/CD 流水线的注册环节,
防止不完整的模型进入注册表。
"""
from dataclasses import dataclass
from typing import Optional
REQUIRED_TAGS = [
"training_data_version",
"feature_schema_hash",
"evaluation_dataset",
"code_commit_sha",
"owner_team",
]
REQUIRED_METRICS = [
"auc_roc",
"latency_p99_ms",
]
@dataclass
class RegistrationValidation:
is_valid: bool
missing_tags: list[str]
missing_metrics: list[str]
message: str
def validate_before_registration(
run_id: str,
min_auc: float = 0.95,
max_latency_ms: float = 50.0,
) -> RegistrationValidation:
"""在模型注册前校验必备条件。"""
client = MlflowClient()
run = client.get_run(run_id)
# 校验必备标签
existing_tags = set(run.data.tags.keys())
missing_tags = [t for t in REQUIRED_TAGS if t not in existing_tags]
# 校验必备指标
existing_metrics = set(run.data.metrics.keys())
missing_metrics = [m for m in REQUIRED_METRICS if m not in existing_metrics]
# 校验性能基线
threshold_failures = []
if "auc_roc" in existing_metrics:
auc = run.data.metrics["auc_roc"]
if auc < min_auc:
threshold_failures.append(
f"auc_roc={auc:.4f} < min={min_auc}"
)
if "latency_p99_ms" in existing_metrics:
latency = run.data.metrics["latency_p99_ms"]
if latency > max_latency_ms:
threshold_failures.append(
f"latency_p99_ms={latency:.1f} > max={max_latency_ms}"
)
is_valid = (
len(missing_tags) == 0
and len(missing_metrics) == 0
and len(threshold_failures) == 0
)
messages = []
if missing_tags:
messages.append(f"Missing tags: {missing_tags}")
if missing_metrics:
messages.append(f"Missing metrics: {missing_metrics}")
if threshold_failures:
messages.append(f"Threshold failures: {threshold_failures}")
return RegistrationValidation(
is_valid=is_valid,
missing_tags=missing_tags,
missing_metrics=missing_metrics,
message=" | ".join(messages) if messages else "All checks passed",
)
3. 基于 Alias 的部署契约:部署流水线引用的不是模型版本号,而是 Alias。@champion 是当前的生产模型,@challenger 是 A/B 测试对象模型,@shadow 是仅在影子模式下执行推理的模型。
模型 Artifact 存储选项对比
| 存储 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| S3 / GCS | KServe 原生支持,成本低廉 | 需要另行实现版本管理 | 大多数生产环境 |
| MinIO | 兼容 S3 API,可本地部署 | 需自行运维 | 本地部署 / 气隙(air-gap)环境 |
| MLflow Artifact Store | 与注册表自动集成 | 对大体量 LLM 可能不太合适 | 以 MLflow 为中心的技术栈 |
| OCI Registry | 与容器镜像采用相同方式管理 | 相较模型专用工具,元数据较为欠缺 | Kubernetes 原生环境 |
| HuggingFace Hub | 支持 LLM 生态,基于 Git LFS | 私有托管有成本 | LLM / 基础模型(Foundation Model)管理 |
A/B 部署流水线设计
A/B 部署不只是简单地拆分流量,而是为了给"新模型是否优于现有模型?"这个问题找到具有统计显著性的答案而做的实验设计。
部署策略对比
| 策略 | 流量拆分 | 同时运行的版本数 | 回滚速度 | 主要用途 |
|---|---|---|---|---|
| 金丝雀(Canary) | 渐进式提升(2% -> 10% -> 50% -> 100%) | 2 个 | 即时(流量切换为 0%) | 安全的渐进式发布 |
| 蓝绿(Blue-Green) | 整体切换(0% -> 100%) | 2 个(保持待命状态) | 即时(切换 DNS/路由) | 快速整体切换与即时回滚 |
| A/B 测试 | 固定比例(例如 50/50) | 2 个以上 | 实验结束后切换 | 基于业务指标的对比 |
| 影子(Shadow) | 100% 复制(不返回响应) | 2 个 | 不适用(不对用户暴露) | 验证新模型、测量延迟 |
| 多臂老虎机(MAB) | 动态调整(基于性能) | 2 个以上 | 自动(性能下降时降低比例) | 探索-利用优化 |
流水线整体流程
生产环境 A/B 部署流水线的整体流程整理如下。
1. 模型注册(MLflow Registry)
└─ 通过必备元数据校验
└─ 分配 @challenger alias
2. 部署前校验(Pre-deployment Validation)
└─ 用离线评估数据集确认准确率
└─ 推理延迟基准测试(p50、p95、p99)
└─ 校验输入输出 schema 兼容性
3. 启动金丝雀部署
└─ 更新 KServe InferenceService
└─ 设置 canaryTrafficPercent: 5
└─ 启用监控看板
4. 渐进式提升流量
└─ 5% -> 10% -> 25% -> 50% -> 100%
└─ 每个阶段自动对比指标
└─ 检测到异常时自动回滚
5. 晋升或回滚
└─ 成功:切换 @champion alias,旧版本设为 @archived
└─ 失败:canaryTrafficPercent: 0,记录原因分析标签
KServe 金丝雀部署
KServe 已于 2025 年 11 月晋升为 CNCF Incubating 项目,通过 InferenceService CRD 提供声明式的金丝雀部署。从 v1beta1 开始,无需单独定义 canary spec,仅凭 canaryTrafficPercent 字段即可完成金丝雀发布。
InferenceService 金丝雀部署 YAML
# kserve-canary-deploy.yaml
# KServe InferenceService:金丝雀部署配置
# 给新模型分配 10% 的流量,并逐步提升。
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: fraud-detection
namespace: ml-serving
annotations:
# 最小副本数设置(防止冷启动)
autoscaling.knative.dev/min-scale: '2'
# 部署元数据
mlflow.model.version: '3'
mlflow.model.alias: 'challenger'
deploy.pipeline.run-id: 'deploy-20260304-001'
spec:
predictor:
# 金丝雀流量比例(0-100)
canaryTrafficPercent: 10
# 新模型版本(金丝雀)
model:
modelFormat:
name: xgboost
runtime: kserve-xgbserver
storageUri: 's3://ml-artifacts/fraud-detection/v3/model'
resources:
requests:
cpu: '2'
memory: '4Gi'
limits:
cpu: '4'
memory: '8Gi'
# Autoscaler 配置
scaleTarget: 10
scaleMetric: concurrency
# 流量路由配置(可选)
transformer:
containers:
- name: feature-enricher
image: registry.example.com/ml/feature-enricher:v2.1
resources:
requests:
cpu: '1'
memory: '2Gi'
流量渐进式提升脚本
"""
分阶段提升 KServe 金丝雀流量的自动化脚本。
每个阶段都会检查核心指标,一旦未达标准就立即回滚。
"""
import time
import subprocess
import json
from dataclasses import dataclass
@dataclass
class CanaryStage:
traffic_percent: int
observation_minutes: int
max_error_rate: float
max_latency_p99_ms: float
CANARY_STAGES = [
CanaryStage(traffic_percent=5, observation_minutes=10, max_error_rate=0.01, max_latency_p99_ms=50),
CanaryStage(traffic_percent=10, observation_minutes=15, max_error_rate=0.01, max_latency_p99_ms=50),
CanaryStage(traffic_percent=25, observation_minutes=20, max_error_rate=0.005, max_latency_p99_ms=45),
CanaryStage(traffic_percent=50, observation_minutes=30, max_error_rate=0.005, max_latency_p99_ms=45),
CanaryStage(traffic_percent=100, observation_minutes=0, max_error_rate=0.005, max_latency_p99_ms=45),
]
def update_canary_traffic(
isvc_name: str,
namespace: str,
traffic_percent: int,
) -> bool:
"""更新 KServe InferenceService 的 canaryTrafficPercent。"""
patch = {
"spec": {
"predictor": {
"canaryTrafficPercent": traffic_percent
}
}
}
cmd = [
"kubectl", "patch", "inferenceservice", isvc_name,
"-n", namespace,
"--type=merge",
f"-p={json.dumps(patch)}",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Failed to patch: {result.stderr}")
return False
print(f"Updated canaryTrafficPercent to {traffic_percent}%")
return True
def check_canary_health(
isvc_name: str,
namespace: str,
stage: CanaryStage,
prometheus_url: str = "http://prometheus.monitoring:9090",
) -> bool:
"""从 Prometheus 中检查金丝雀指标。"""
import requests
# 检查错误率
error_query = (
f'sum(rate(revision_request_count'
f'{{service_name="{isvc_name}",response_code!="200",'
f'namespace="{namespace}"}}[5m])) / '
f'sum(rate(revision_request_count'
f'{{service_name="{isvc_name}",'
f'namespace="{namespace}"}}[5m]))'
)
resp = requests.get(
f"{prometheus_url}/api/v1/query",
params={"query": error_query},
)
data = resp.json()
if data["data"]["result"]:
error_rate = float(data["data"]["result"][0]["value"][1])
if error_rate > stage.max_error_rate:
print(f"Error rate {error_rate:.4f} exceeds {stage.max_error_rate}")
return False
# 检查 p99 延迟
latency_query = (
f'histogram_quantile(0.99, '
f'sum(rate(revision_request_latencies_bucket'
f'{{service_name="{isvc_name}",'
f'namespace="{namespace}"}}[5m])) by (le))'
)
resp = requests.get(
f"{prometheus_url}/api/v1/query",
params={"query": latency_query},
)
data = resp.json()
if data["data"]["result"]:
latency_ms = float(data["data"]["result"][0]["value"][1])
if latency_ms > stage.max_latency_p99_ms:
print(f"p99 latency {latency_ms:.1f}ms exceeds {stage.max_latency_p99_ms}ms")
return False
return True
def run_canary_rollout(isvc_name: str, namespace: str):
"""按阶段执行金丝雀发布。"""
for i, stage in enumerate(CANARY_STAGES):
print(f"\n--- Stage {i+1}/{len(CANARY_STAGES)}: "
f"{stage.traffic_percent}% traffic ---")
if not update_canary_traffic(isvc_name, namespace, stage.traffic_percent):
print("ABORT: Failed to update traffic")
rollback(isvc_name, namespace)
return False
if stage.observation_minutes > 0:
print(f"Observing for {stage.observation_minutes} minutes...")
# 在观察期内定期检查指标
checks = stage.observation_minutes // 2 # 每 2 分钟检查一次
for check in range(checks):
time.sleep(120) # 等待 2 分钟
if not check_canary_health(isvc_name, namespace, stage):
print(f"ROLLBACK: Health check failed at stage {i+1}")
rollback(isvc_name, namespace)
return False
print(f" Check {check+1}/{checks}: OK")
print("\nCanary rollout completed successfully!")
return True
def rollback(isvc_name: str, namespace: str):
"""将金丝雀流量设置为 0%,立即回滚。"""
update_canary_traffic(isvc_name, namespace, 0)
print("Rolled back to previous stable version")
流量拆分与路由
除了金丝雀部署之外,还需要多种流量拆分模式。本节讨论仅向特定用户群体开放新模型、按地区提供不同模型等场景。
基于 Istio VirtualService 的 A/B 流量路由
KServe 在内部使用 Istio 或 Kourier 来路由流量。若需要更精细的 A/B 路由,可以直接配置 Istio VirtualService。
# istio-ab-routing.yaml
# 基于请求头的 A/B 流量路由
# 根据 x-model-variant 请求头路由到不同的模型版本。
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: fraud-detection-ab
namespace: ml-serving
spec:
hosts:
- fraud-detection.ml-serving.svc.cluster.local
http:
# 规则 1:明确请求 variant B -> 新模型
- match:
- headers:
x-model-variant:
exact: 'B'
route:
- destination:
host: fraud-detection-predictor.ml-serving.svc.cluster.local
port:
number: 80
headers:
request:
set:
x-model-version: 'v3'
weight: 100
# 规则 2:特定用户分组 -> 新模型(基于 Cookie)
- match:
- headers:
cookie:
regex: '.*ab_group=treatment.*'
route:
- destination:
host: fraud-detection-predictor.ml-serving.svc.cluster.local
port:
number: 80
weight: 100
# 规则 3:默认流量 -> 按权重拆分
- route:
- destination:
host: fraud-detection-predictor.ml-serving.svc.cluster.local
subset: stable
weight: 90
- destination:
host: fraud-detection-predictor.ml-serving.svc.cluster.local
subset: canary
weight: 10
---
# 用 DestinationRule 定义 subset
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: fraud-detection-subsets
namespace: ml-serving
spec:
host: fraud-detection-predictor.ml-serving.svc.cluster.local
subsets:
- name: stable
labels:
model-version: 'v2'
- name: canary
labels:
model-version: 'v3'
流量路由决策标准
在 A/B 测试中,按什么标准拆分流量是实验设计的核心。下面这些标准通常会组合使用。
| 拆分标准 | 实现方式 | 优点 | 注意事项 |
|---|---|---|---|
| 随机比例 | 基于 Istio weight | 实现简单,能保证统计随机性 | 同一用户每次请求可能会遇到不同的模型 |
| 用户 ID 哈希 | 基于请求头/Cookie 路由 | 为同一用户提供一致的体验 | 可能存在哈希分布偏斜 |
| 地区/国家 | 基于 GeoIP 请求头 | 可验证不同地区的模型性能差异 | 各地区流量可能不均衡 |
| 设备类型 | 解析 User-Agent | 可验证按设备的延迟优化 | 依赖解析准确度 |
| 特性开关(Feature Flag) | 集成 LaunchDarkly/Unleash | 可实时更改拆分比例 | 增加外部依赖 |
监控与自动回滚
A/B 部署的成败取决于监控。部署之后要实时追踪模型的表现,一旦检测到异常迹象,就必须在无需人工介入的情况下自动回滚。
需要监控的核心指标
基础设施指标:
- 推理延迟(p50、p95、p99)
- 每秒请求数(RPS)
- 错误率(HTTP 5xx 比例)
- GPU/CPU 使用率
- 内存使用量
模型指标:
- 预测分布变化(PSI:Population Stability Index)
- 输入特征漂移(KL Divergence、Kolmogorov-Smirnov)
- 业务 KPI(转化率、点击率、流失率)
- 模型置信度分布变化
Prometheus + Grafana 监控脚本
"""
A/B 部署监控与自动回滚控制器。
从 Prometheus 采集指标,一旦违反标准就触发自动回滚。
"""
import time
import requests
import logging
from dataclasses import dataclass, field
from enum import Enum
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ab-monitor")
class RollbackReason(Enum):
ERROR_RATE = "error_rate_exceeded"
LATENCY = "latency_exceeded"
PREDICTION_DRIFT = "prediction_distribution_drift"
BUSINESS_KPI = "business_kpi_degradation"
@dataclass
class MonitorConfig:
prometheus_url: str = "http://prometheus.monitoring:9090"
isvc_name: str = "fraud-detection"
namespace: str = "ml-serving"
check_interval_sec: int = 60
# 阈值设置
max_error_rate: float = 0.01
max_latency_p99_ms: float = 50.0
max_psi: float = 0.2 # PSI > 0.2 视为有意义的分布变化
# 连续失败次数标准
consecutive_failures_to_rollback: int = 3
# 告警设置
slack_webhook: str = ""
pagerduty_key: str = ""
@dataclass
class MonitorState:
consecutive_failures: int = 0
total_checks: int = 0
failure_reasons: list = field(default_factory=list)
def query_prometheus(config: MonitorConfig, query: str) -> float | None:
"""执行 Prometheus 查询并返回单个值。"""
try:
resp = requests.get(
f"{config.prometheus_url}/api/v1/query",
params={"query": query},
timeout=10,
)
data = resp.json()
if data["status"] == "success" and data["data"]["result"]:
return float(data["data"]["result"][0]["value"][1])
except Exception as e:
logger.error(f"Prometheus query failed: {e}")
return None
def check_model_health(config: MonitorConfig) -> tuple[bool, list[RollbackReason]]:
"""综合检查模型的健康状态。"""
reasons = []
# 1. 检查错误率
error_rate = query_prometheus(config, (
f'sum(rate(revision_request_count'
f'{{service_name="{config.isvc_name}",'
f'response_code=~"5..",namespace="{config.namespace}"}}[5m]))'
f' / sum(rate(revision_request_count'
f'{{service_name="{config.isvc_name}",'
f'namespace="{config.namespace}"}}[5m]))'
))
if error_rate is not None and error_rate > config.max_error_rate:
logger.warning(f"Error rate: {error_rate:.4f} > {config.max_error_rate}")
reasons.append(RollbackReason.ERROR_RATE)
# 2. 检查 p99 延迟
latency = query_prometheus(config, (
f'histogram_quantile(0.99, sum(rate('
f'revision_request_latencies_bucket'
f'{{service_name="{config.isvc_name}",'
f'namespace="{config.namespace}"}}[5m])) by (le))'
))
if latency is not None and latency > config.max_latency_p99_ms:
logger.warning(f"p99 latency: {latency:.1f}ms > {config.max_latency_p99_ms}ms")
reasons.append(RollbackReason.LATENCY)
# 3. 检查预测分布变化(PSI)
psi = query_prometheus(config, (
f'ml_prediction_psi'
f'{{model="{config.isvc_name}",namespace="{config.namespace}"}}'
))
if psi is not None and psi > config.max_psi:
logger.warning(f"PSI: {psi:.4f} > {config.max_psi}")
reasons.append(RollbackReason.PREDICTION_DRIFT)
is_healthy = len(reasons) == 0
return is_healthy, reasons
def send_alert(config: MonitorConfig, message: str):
"""向 Slack 和 PagerDuty 发送告警。"""
if config.slack_webhook:
requests.post(config.slack_webhook, json={
"text": f":rotating_light: [AB Deploy Monitor] {message}",
"channel": "#ml-alerts",
})
logger.info(f"Alert: {message}")
def trigger_rollback(config: MonitorConfig, reasons: list[RollbackReason]):
"""执行自动回滚。"""
import subprocess, json
reason_str = ", ".join([r.value for r in reasons])
logger.critical(f"Triggering rollback. Reasons: {reason_str}")
# 将 KServe canaryTrafficPercent 修补为 0
patch = json.dumps({
"spec": {"predictor": {"canaryTrafficPercent": 0}}
})
subprocess.run([
"kubectl", "patch", "inferenceservice", config.isvc_name,
"-n", config.namespace,
"--type=merge", f"-p={patch}",
], check=True)
send_alert(config, f"Auto-rollback executed for {config.isvc_name}. Reasons: {reason_str}")
def run_monitor(config: MonitorConfig):
"""执行监控循环。"""
state = MonitorState()
logger.info(f"Starting AB deploy monitor for {config.isvc_name}")
while True:
is_healthy, reasons = check_model_health(config)
state.total_checks += 1
if is_healthy:
state.consecutive_failures = 0
state.failure_reasons.clear()
if state.total_checks % 10 == 0:
logger.info(f"Check #{state.total_checks}: Healthy")
else:
state.consecutive_failures += 1
state.failure_reasons.extend(reasons)
logger.warning(
f"Check #{state.total_checks}: Unhealthy "
f"({state.consecutive_failures}/{config.consecutive_failures_to_rollback})"
)
if state.consecutive_failures >= config.consecutive_failures_to_rollback:
trigger_rollback(config, reasons)
break
time.sleep(config.check_interval_sec)
# 执行示例
if __name__ == "__main__":
config = MonitorConfig(
isvc_name="fraud-detection",
namespace="ml-serving",
slack_webhook="https://hooks.slack.com/services/xxx/yyy/zzz",
)
run_monitor(config)
模型晋升流水线自动化
金丝雀部署成功之后,需要一条把新模型正式晋升为 champion 的流水线。这一过程要一次性处理好 MLflow 注册表更新、KServe 配置清理和告警发送。
"""
模型晋升流水线:金丝雀部署成功后自动切换为 champion。
处理 MLflow Alias 更新 + KServe 金丝雀清理 + 告警。
"""
import mlflow
from mlflow.tracking import MlflowClient
import subprocess
import json
from datetime import datetime
def promote_model_pipeline(
model_name: str,
new_version: int,
isvc_name: str,
namespace: str,
ab_test_results: dict | None = None,
):
"""
模型晋升的整体流水线。
Args:
model_name: MLflow 中注册的模型名称
new_version: 要晋升的模型版本号
isvc_name: KServe InferenceService 名称
namespace: Kubernetes 命名空间
ab_test_results: A/B 测试结果指标字典
"""
client = MlflowClient()
timestamp = datetime.utcnow().isoformat()
# Step 1:备份当前 champion 信息
try:
old_champion = client.get_model_version_by_alias(
name=model_name, alias="champion"
)
old_version = int(old_champion.version)
print(f"Current champion: v{old_version}")
# 为此前的 champion 记录元数据
client.set_model_version_tag(
name=model_name, version=old_version,
key="demoted_at", value=timestamp,
)
client.set_model_version_tag(
name=model_name, version=old_version,
key="demoted_by", value=f"v{new_version}",
)
# 将此前的 champion 移动为 archived
client.set_registered_model_alias(
name=model_name, alias="archived", version=old_version,
)
except mlflow.exceptions.MlflowException:
old_version = None
print("No existing champion found")
# Step 2:将新模型晋升为 champion
client.set_registered_model_alias(
name=model_name, alias="champion", version=new_version,
)
# 移除 challenger alias
try:
client.delete_registered_model_alias(
name=model_name, alias="challenger",
)
except mlflow.exceptions.MlflowException:
pass
# 记录晋升元数据
client.set_model_version_tag(
name=model_name, version=new_version,
key="promoted_to_champion_at", value=timestamp,
)
if ab_test_results:
for k, v in ab_test_results.items():
client.set_model_version_tag(
name=model_name, version=new_version,
key=f"ab_result_{k}", value=str(v),
)
# Step 3:在 KServe 中清理金丝雀配置
# 移除 canaryTrafficPercent,让新模型接收 100% 流量
patch = json.dumps({
"spec": {
"predictor": {
"canaryTrafficPercent": 100
}
}
})
subprocess.run([
"kubectl", "patch", "inferenceservice", isvc_name,
"-n", namespace,
"--type=merge", f"-p={patch}",
], check=True)
print(f"Promotion complete: {model_name} v{new_version} is now champion")
return {
"model_name": model_name,
"new_champion_version": new_version,
"old_champion_version": old_version,
"promoted_at": timestamp,
}
# 执行示例
result = promote_model_pipeline(
model_name="fraud-detection",
new_version=3,
isvc_name="fraud-detection",
namespace="ml-serving",
ab_test_results={
"auc_improvement": 0.012,
"latency_p99_delta_ms": -2.1,
"error_rate_delta": -0.003,
"test_duration_hours": 24,
"traffic_percentage": 50,
},
)
故障排查
问题 1:金丝雀部署后流量没有被拆分
症状:已经设置了 canaryTrafficPercent,但所有流量仍然只流向旧模型。
原因与解决:
-
确认 KServe 版本:在 v1beta1 之前的版本中,需要分别定义 canary spec 和 default spec。用
kubectl get inferenceservice -o yaml确认实际生效的 spec。 -
确认 Knative Revision 状态:确认新模型的 Knative Revision 是否处于 Ready 状态。
- 执行
kubectl get revision -n ml-serving - 确认
READY列是否为True - 若为
False,用kubectl describe revision <revision-name>排查原因
- 执行
-
storageUri访问权限:如果对新模型的 S3/GCS 路径没有访问权限,Revision 就会失败,流量也不会被拆分。确认 ServiceAccount 是否绑定了合适的 IAM 角色。
问题 2:更新 MLflow Alias 后服务的模型没有改变
症状:在 MLflow 中已经把 @champion alias 改到了新版本,但 KServe 仍然在提供旧模型的服务。
原因:MLflow alias 的变更只会更新注册表的元数据。KServe InferenceService 的 storageUri 不会自动跟着改变。
解决:晋升流水线中必须把 MLflow alias 变更和 KServe InferenceService 补丁绑在一起执行。像上面的 promote_model_pipeline 函数那样,把两个操作当作一个事务捆绑起来。
问题 3:金丝雀模型的延迟异常偏高
症状:明明是同一个模型,金丝雀 Revision 的 p99 延迟却比 stable Revision 高出 2-3 倍。
原因:
- 金丝雀 Revision 的 Pod 数量较少,冷启动频繁发生。
- 金丝雀流量偏少时,autoscaler 会缩容,下一次请求又触发冷启动,如此形成恶性循环。
解决:把 autoscaling.knative.dev/min-scale 注解至少设置为 1,让始终有一个 Pod 保持 warm 状态。
问题 4:A/B 测试结果缺乏统计显著性
症状:已经进行了 2 周的 A/B 测试,但两个模型之间的性能差异在统计上并不显著。
原因:流量不够,或者效应量(effect size)太小,未能达到所需的样本数。
解决:在测试开始前先计算所需的最小样本数。常见做法如下。
- 基于预期效应量(delta)、显著性水平(alpha = 0.05)、检验力(power = 0.8)算出所需样本数
- 对照日流量,提前确定最短测试周期
- 流量不足时,提高金丝雀比例或延长测试周期
运维清单
部署前(Pre-deployment)
- 确认模型注册时的必备元数据(训练数据版本、特征 schema 哈希、代码提交 SHA)是否通过校验
- 确认离线评估数据集上的表现是否与现有 champion 持平或更优
- 确认输入 schema 与输出 schema 是否与当前生产 API 契约兼容
- 确认推理延迟基准测试是否落在 SLA 之内(以 p99 为准)
- 确认模型 artifact 大小与内存需求(设置资源请求/限制)
部署中(During deployment)
- 确认金丝雀流量比例是否设为预期值
- 确认新 Revision 的 Pod 是否正常启动(Ready 状态)
- 在 Prometheus/Grafana 看板上实时确认错误率、延迟、RPS
- 确认自动回滚监控是否正常运行
- 确认旧 Revision 是否维持着足够的资源(防止被缩容)
部署后(Post-deployment)
- 把 A/B 测试结果记录为 MLflow 模型版本标签
- 把 champion alias 切换到新模型,并将此前的 champion 移到 archived
- 逐步回收旧 Revision 的资源(不立即删除,保留 1-2 天)
- 在团队频道分享部署结果(模型版本、性能变化、测试周期)
- 把这次部署中可以改进的地方记录到复盘文档中
失败案例
案例 1:Alias 同步遗漏导致的模型不一致
某个团队把 MLflow Model Registry 的 @champion alias 更新到了 v5,但 KServe InferenceService 的 storageUri 仍然指向 v4。MLflow 看板上显示 v5 是 champion,而实际提供服务的却是 v4,这种状态持续了 2 周。问题是在 A/B 测试结果分析中被发现的——"champion 模型"的性能数值,与 MLflow 中记录的 v5 离线评估结果对不上。
教训:必须把 MLflow alias 变更和 KServe 部署绑到同一条流水线里。两个操作中只要有一个失败,就需要能把整体回滚的事务逻辑。
案例 2:冷启动导致的金丝雀误判
把新模型以 5% 金丝雀比例部署之后,自动监控检测到 p99 延迟超标,于是执行了回滚。分析原因后发现,模型性能本身并没有问题——是金丝雀 Revision 流量偏低,导致 autoscaler 把 Pod 缩容到 0,之后每来一个请求就会触发一次冷启动。这个团队用的是一个模型加载要花 8 秒的大模型,正是这段冷启动延迟把 p99 拉高,导致了误判。
教训:金丝雀部署时把 min-scale 至少设为 1,以防止冷启动。另外,还应在自动回滚判定中加入过滤冷启动请求的逻辑(例如:Pod 启动后 30 秒以内的请求从延迟计算中剔除)。
案例 3:未经统计显著性检验就晋升的模型
进行了 72 小时的 A/B 测试后,确认新模型的转化率高出 0.3%,于是将其晋升为 champion。然而晋升 2 周之后,转化率反而比之前更低了。分析原因发现,72 小时的数据样本量不足以显著验证 0.3% 的差异,观测到的差异只是单纯的统计噪声。考虑到该服务的日请求量和预期效应量,本应至少需要 10 天以上的测试周期。
教训:在 A/B 测试开始前进行 power analysis,测算所需的最小样本数和测试周期。以 p-value 0.05 和检验力 0.8 为基准计算最短周期,在这段时间结束之前不做晋升/回滚的判断。
案例 4:没有模型注册表而导致的事故
一家早期创业公司没有使用模型注册表,而是把 S3 路径直接硬编码进 KServe 的 storageUri 里部署。某天,一名工程师改动了训练流水线的输出路径,导致同一个 S3 路径被一个新的(未经验证的)模型 artifact 覆盖了。KServe Pod 重启后自动加载了这个未经验证的模型,接下来 4 个小时里持续返回异常的推理结果。
教训:模型 artifact 的不变性(immutability)是模型注册表最基本的功能。必须使用通过注册表的间接引用,而不是直接引用 S3 路径,并为已注册的 artifact 启用 S3 Object Lock 或版本管理,使其无法被覆盖。
参考资料
- MLflow Model Registry 官方文档 - MLflow Model Registry 基于 Alias 的工作流、API 参考、最佳实践指南
- KServe Canary Rollout Strategy - KServe InferenceService 金丝雀部署策略官方文档
- W&B Registry - Weights & Biases 的模型与数据集注册表、治理功能说明
- SageMaker Model Registry - AWS SageMaker 基于 ModelPackageGroup 的模型注册、审批工作流、跨账户共享
- Neptune.ai - Model Deployment Strategies - 金丝雀、蓝绿、A/B、影子部署策略的对比分析与选型指南
- KServe GitHub Repository - KServe 源代码、示例 manifest、issue 追踪
- CNCF Blog - KServe Incubating Project - KServe 晋升为 CNCF Incubating 项目的公告(2025 年 11 月)
현재 단락 (1/703)
比起训练模型,把训练好的模型安全地部署到生产环境更难。训练代码一旦写好就可以反复执行,但部署每一次面对的条件都不一样。与旧版本的兼容性、随流量增长而来的资源分配、新模型的性能验证、故障时的即时回滚——...