Skip to content

필사 모드: AI 平台模型注册表与 A/B 部署流水线设计 2026

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
AI 平台模型注册表与 A/B 部署流水线设计 2026

概述

比起训练模型,把训练好的模型安全地部署到生产环境更难。训练代码一旦写好就可以反复执行,但部署每一次面对的条件都不一样。与旧版本的兼容性、随流量增长而来的资源分配、新模型的性能验证、故障时的即时回滚——这一切都必须交给自动化流水线来处理。

本文将围绕两个核心组件展开。

  1. 模型注册表:对训练好的模型 artifact 进行集中版本管理,并配合元数据提供晋升(promotion)工作流的系统
  2. 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 RegistryW&B RegistrySageMaker Model RegistryVertex AI Model Registry
部署方式开源(自托管)或 DatabricksSaaS 或自托管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 flavorArtifact 版本管理JumpStart 模型 + 自有模型Model Garden + 自有模型
CI/CD 集成REST API + Python SDKPython SDK + webhookCodePipeline 原生集成 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 / GCSKServe 原生支持,成本低廉需要另行实现版本管理大多数生产环境
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,但所有流量仍然只流向旧模型。

原因与解决

  1. 确认 KServe 版本:在 v1beta1 之前的版本中,需要分别定义 canary spec 和 default spec。用 kubectl get inferenceservice -o yaml 确认实际生效的 spec。

  2. 确认 Knative Revision 状态:确认新模型的 Knative Revision 是否处于 Ready 状态。

    • 执行 kubectl get revision -n ml-serving
    • 确认 READY 列是否为 True
    • 若为 False,用 kubectl describe revision <revision-name> 排查原因
  3. 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 或版本管理,使其无法被覆盖。

参考资料

현재 단락 (1/703)

比起训练模型,把训练好的模型安全地部署到生产环境更难。训练代码一旦写好就可以反复执行,但部署每一次面对的条件都不一样。与旧版本的兼容性、随流量增长而来的资源分配、新模型的性能验证、故障时的即时回滚——...

작성 글자: 0원문 글자: 24,178작성 단락: 0/703