ML工程师的CI/CD实战指南:构建可验证、可回滚的模型交付流水线 1. 这不是给 DevOps 工程师看的 CI/CD而是给 ML 工程师写的“模型上线生存指南”你手里的模型在 Jupyter 里跑得飞起AUC 0.92F1 0.88老板拍着桌子说“赶紧上生产”——结果你把 notebook 复制粘贴进 Flask改了三遍requirements.txt本地能跑测试环境报ModuleNotFoundError: No module named xgboost线上服务启动后一调用就CUDA out of memory日志里全是NaN loss和Tensor shape mismatch。这不是玄学是典型的ML 模型交付断层数据科学家产出的是“可复现的实验”而生产系统需要的是“可验证、可回滚、可监控、可伸缩的软件制品”。CI/CD 不是给代码加个自动打包按钮它是把机器学习从“实验室手工作坊”推进“现代工程流水线”的唯一桥梁。本文讲的就是怎么让一个刚跑通 baseline 的 PyTorch 模型变成每天自动训练、自动评估、自动部署、自动告警、出问题 5 分钟内回滚到上一版的稳定服务。核心关键词ML CI/CD、模型版本控制、训练流水线、推理服务化、数据漂移检测、模型可观测性。适合三类人刚从 Kaggle 转战工业界的算法同学别再手动 scp 模型文件了、带 ML 团队但被上线周期拖垮的 Tech Lead你团队的平均上线周期是不是超过 7 天、以及正在设计 MLOps 平台的平台工程师别只盯着 Kubeflow UI先想清楚 pipeline 的原子性怎么定义。这不是理论推演是我带过的 4 个跨行业项目金融风控、电商推荐、医疗影像辅助诊断、工业设备预测性维护踩出来的路——每一步都标好了坑位和填坑工具。2. 为什么传统 CI/CD 流水线在 ML 场景下会直接崩盘2.1 根本矛盾软件工程的确定性 vs 机器学习的随机性标准 CI/CD 的基石是“确定性”同一份代码 同一份依赖 同一份配置 同一份二进制产物。但 ML 流水线里输入数据是活的模型参数是概率的评估指标是波动的。举个最简单的例子你在train.py里写了torch.manual_seed(42)看起来很稳。但如果你的数据加载用了DataLoader(shuffleTrue)而你的训练集是动态拉取的比如从 Hive 表按时间分区读那么今天拉的 100 万条样本和明天拉的 100 万条哪怕时间窗口只差 1 小时用户行为分布可能已发生偏移。这时候seed42保证的只是“同一批数据下的可复现”而不是“同业务逻辑下的可复现”。我见过最惨的一次是某电商推荐模型在预发环境 A/B 测试时线上流量打进来后 CTR 突降 15%排查三天才发现预发环境用的是离线导出的静态样本快照而线上实时特征服务因网络抖动延迟了 2 秒导致特征向量里大量字段为 null模型直接输出了默认值。传统 CI/CD 的单元测试根本覆盖不了这种“数据-特征-模型”三级耦合故障。提示ML 流水线的第一道防线不是写更多 test_* 函数而是强制所有数据输入必须带版本哈希如md5sum /data/train_20240501.parquet和时间戳范围如start_ts2024-04-25T00:00:00Z, end_ts2024-05-01T00:00:00Z并在 pipeline 开头做校验。任何不满足hashexpected_hash ts_in_range的数据包直接 fail fast不进入训练环节。2.2 构建产物不再是单一二进制而是多模态资产包传统 CI 编译出一个app.jar或service.bin部署时解压即用。ML 的“构建产物”至少包含五类资产代码资产训练脚本、预处理函数、模型定义PyTorch Module / TF SavedModel数据资产训练集、验证集、测试集的原始数据快照Parquet/CSV模型资产序列化后的权重文件.pt,.h5,saved_model.pb 模型元信息输入 shape、输出 label map、训练框架版本特征资产特征工程 pipeline如sklearn.Pipeline保存的preprocessor.pkl 特征统计量均值、方差、分位数等评估资产测试集上的详细指标报告JSON、关键样本预测截图PNG、混淆矩阵热力图HTML这五类资产必须原子性绑定。我曾遇到一个案例运维同学只更新了模型权重文件.pt忘了同步更新preprocessor.pkl结果线上服务用新模型跑老预处理器输入维度从 128 变成 64直接触发 PyTorch 的size mismatch异常。后来我们强制要求所有资产必须打包进一个ML Model Artifact格式为标准 tarball结构如下model_artifact_v2.3.1/ ├── code/ │ ├── train.py │ ├── inference.py │ └── requirements.txt ├── data/ │ ├── train_md5.txt # 记录训练数据哈希 │ └── test_sample.parquet # 测试集小样本用于 smoke test ├── model/ │ ├── weights.pt │ └── metadata.json # {framework: pytorch, version: 2.0.1, input_shape: [1, 128], labels: [cat, dog]} ├── features/ │ ├── preprocessor.pkl │ └── stats.json # {age_mean: 35.2, income_std: 12500} └── eval/ ├── metrics.json # {accuracy: 0.892, f1_macro: 0.871, drift_score: 0.032} └── sample_predictions.html每次 pipeline 成功运行只生成一个model_artifact_v2.3.1.tar.gz部署脚本只认这个包解压后校验所有子文件 checksum缺一不可。2.3 验证阶段不能只靠“test pass”必须引入领域感知的守门人标准 CI 的make test是跑单元测试和集成测试。ML 流水线的验证Validation阶段必须插入三层守门人第一层技术守门人Technical Gate检查模型是否能加载、能否对标准输入如torch.randn(1, 128)完成前向传播、输出 shape 是否符合metadata.json声明。这是防止“模型文件损坏”或“框架版本不兼容”的底线。第二层业务守门人Business Gate在固定测试集上运行检查核心业务指标是否达标。例如风控模型要求KS 0.4且bad_rate_at_top10% 0.15推荐模型要求NDCG10 0.65。注意这里用的是离线测试集不是线上实时数据确保验证环境纯净。第三层稳定性守门人Stability Gate这是最容易被忽略的一层。对比当前模型与基线模型通常是上一版生产模型在同一测试集上的表现差异。设定硬性阈值|current_f1 - baseline_f1| 0.01且|current_latency_p95 - baseline_latency_p95| 50ms。如果新模型 F1 提升 0.005 但 P95 延迟增加 200ms它就不该过闸——业务不能为微小精度提升牺牲用户体验。我们曾用这个规则拦截了一个“过拟合测试集”的模型它在测试集上 F1 达到 0.912比基线高 0.02但在 1000 条真实线上请求的影子流量中F1 仅 0.843且 30% 请求超时。这就是稳定性守门人的价值。3. 实操从零搭建一条端到端 ML CI/CD 流水线基于 GitHub Actions Docker FastAPI3.1 整体架构设计为什么选 GitHub Actions 而不是 Jenkins 或 GitLab CI很多人第一反应是“Jenkins 更强大”。但 ML 流水线的核心诉求不是“支持 100 种插件”而是快速迭代、环境隔离、资源弹性、与代码仓库深度绑定。GitHub Actions 天然满足代码即配置所有 pipeline 定义写在.github/workflows/ml_pipeline.yml随代码一起 review、一起 merge避免 Jenkins job 配置散落在 Web UI 里。环境即服务每个 job 自动分配干净的 Ubuntu runner预装 Python 3.9/3.10dockerd已启动无需自己维护 slave 节点池。资源弹性训练任务CPU/GPU可指定runs-on: ubuntu-latestCPU或runs-on: [self-hosted, gpu]自建 GPU runner测试和部署用轻量级 CPU runner 即可成本可控。生态友好actions/checkout,actions/setup-python,docker/build-push-action等官方 action 成熟稳定社区维护。我们不用 Jenkins 的另一个现实原因是数据科学家更习惯 GitHub。让他们去学 Jenkins Pipeline Syntax不如直接教他们写 YAML。下面这张表对比了关键能力能力项GitHub ActionsJenkins配置版本化✅.yml文件随代码库管理❌ Job 配置在 Web UI需额外插件导出GPU runner 支持✅ 可自建ubuntu-22.04-gpurunner预装 CUDA 11.8 nvidia-docker⚠️ 需手动配置 slave 节点驱动版本易冲突密钥管理✅secrets.GITHUB_TOKEN自动注入支持加密 secrets⚠️ 需安装 Credentials Binding 插件配置繁琐Docker 构建缓存✅docker/build-push-action支持cache-from/cache-to⚠️ 需配合 Docker-in-Docker (DinD) 或 registry cache复杂度高ML 特定 action✅ 社区有mlflow-actions,seldon-core-actions等❌ 无原生 ML 支持全靠自研 plugin所以我们的选择很明确GitHub Actions 作为 orchestration 层Docker 作为环境封装层FastAPI 作为服务暴露层。整条流水线分为四个阶段Trigger → Build Test → Train Evaluate → Deploy。3.2 第一阶段Trigger —— 什么事件真正该触发 ML 流水线很多团队一上来就设置on: push to main结果每次git commit -m fix typo都触发耗时 30 分钟的训练。这是对算力的巨大浪费。真正的触发策略必须分层代码变更Code Changeon: push to src/或on: pull_request to main触发Build Test阶段编译代码、运行单元测试、检查代码风格不触发训练。因为修改utils.py不该重训模型。数据变更Data Changeon: workflow_dispatch手动触发或监听外部事件如 AWS S3 新增s3://my-bucket/data/train_20240501.parquet我们用一个轻量级 Lambda 函数监听 S3 事件当新数据文件上传时自动调用 GitHub API 触发 workflow并传入DATA_VERSION20240501参数。这是最常用的训练触发方式。配置变更Config Changeon: push to config/修改超参配置文件config/hyperparams.yaml时触发训练。我们要求所有超参必须集中管理禁止硬编码在train.py里。定时触发Scheduleon: schedule: cron: 0 2 * * 1每周一凌晨 2 点用于定期 retrain应对数据缓慢漂移。但必须加守门人只有当drift_score 0.05由独立 drift detection job 计算时才执行训练否则跳过。注意所有触发事件必须携带上下文参数。GitHub Actions 的workflow_dispatch可定义 inputon: workflow_dispatch: inputs: data_version: description: S3 data version tag (e.g., 20240501) required: true default: latest model_type: description: Model type to train (e.g., xgboost, pytorch) required: true default: pytorch这样同一个 workflow 可以服务多个模型类型避免重复建设。3.3 第二阶段Build Test —— 为训练准备可信赖的代码基线这一步的目标是证明当前代码分支具备执行训练的能力且不会因代码缺陷导致训练失败或结果错误。它包含三个并行 jobJob A: Code Lint Type Check- name: Run mypy type check run: | pip install mypy mypy src/ --ignore-missing-imports --disallow-untyped-defs - name: Run black formatting check run: | pip install black black --check --diff src/为什么必须做ML 代码里大量使用pandas.DataFrame和numpy.ndarray类型模糊极易引发隐式转换错误。例如df[col].mean()返回float64但下游模型期望float32不加类型检查训练时可能 silent fail。Job B: Unit Test with Mock Data- name: Run unit tests run: | pip install pytest pytest-cov pytest tests/ --covsrc/ --cov-reportxml - name: Upload coverage to Codecov uses: codecov/codecov-actionv3关键技巧所有测试用例必须使用mock 数据而非真实数据。我们在tests/conftest.py中定义import pandas as pd import numpy as np pytest.fixture def mock_train_data(): return pd.DataFrame({ feature_a: np.random.normal(0, 1, 1000), feature_b: np.random.randint(0, 10, 1000), label: np.random.choice([0, 1], 1000, p[0.7, 0.3]) })这样测试速度极快 1 秒且完全隔离数据依赖。覆盖率目标设为 80%重点覆盖数据预处理函数src/preprocess.py和模型加载逻辑src/model.py。Job C: Docker Image Build Scan- name: Set up Docker Buildx uses: docker/setup-buildx-actionv2 - name: Build and push uses: docker/build-push-actionv4 with: context: . push: false tags: ${{ github.sha }} cache-from: typegha cache-to: typegha,modemax - name: Scan image for vulnerabilities uses: anchore/scan-actionv4 with: image: ${{ github.sha }} fail-on: high这里的关键是build cache。ML 镜像通常很大基础镜像nvidia/cuda:11.8.0-devel-ubuntu22.04就 3GB每次从头 build 耗时 15 分钟。我们利用 GitHub Actions 的typeghacache将pip install的 layer 缓存下来。实测首次 build 18 分钟后续 build 仅 2.3 分钟。扫描用 Anchore设置fail-on: high拦截已知高危漏洞如CVE-2023-45803避免带毒镜像进入训练环节。3.4 第三阶段Train Evaluate —— 流水线的心脏如何让它既快又稳这是最耗时、最易失败的阶段。我们拆成两个 jobTrain和Evaluate失败时可单独重试避免重复训练。Job D: Train —— GPU 训练的稳定执行策略我们自建了 2 台g4dn.xlarge4 vCPU, 16GB RAM, 1x T4 GPU作为 GitHub Actions self-hosted runner。关键配置在runner/config.sh# 禁用 NVIDIA persistence mode避免 GPU 内存泄漏 sudo nvidia-smi -dm 0 # 设置 GPU 为 exclusive mode防止多 job 争抢 sudo nvidia-smi -c 3 # 配置 Docker 使用 nvidia-container-runtime echo {runtimes:{nvidia:{path:nvidia-container-runtime,runtimeArgs:[]}}} | sudo tee /etc/docker/daemon.json sudo systemctl restart docker训练 job 的核心 YAML- name: Train model uses: ./.github/actions/train-model with: data_version: ${{ inputs.data_version }} model_type: ${{ inputs.model_type }} gpu_count: 1这个自定义 action (./github/actions/train-model/action.yml) 封装了所有 GPU 训练细节拉取最新数据到 runner 本地aws s3 cp s3://my-bucket/data/train_${{ data_version }}.parquet ./data/校验数据哈希md5sum ./data/train_${{ data_version }}.parquet | grep -q expected_hash启动 Docker 容器挂载 GPU 和数据卷docker run \ --gpus device${{ gpu_count }} \ --rm \ -v $(pwd)/data:/workspace/data \ -v $(pwd)/models:/workspace/models \ -e DATA_VERSION${{ data_version }} \ -e MODEL_TYPE${{ model_type }} \ ${{ runner_image }} \ python src/train.py捕获训练日志并上传到 artifactupload-artifact保存models/model_${{ data_version }}.pt和logs/train_${{ data_version }}.log实操心得GPU 训练最大的敌人是 OOMOut of Memory。我们强制要求所有train.py必须实现梯度裁剪gradient clipping和混合精度训练AMPfrom torch.cuda.amp import autocast, GradScaler scaler GradScaler() for batch in dataloader: optimizer.zero_grad() with autocast(): # 自动选择 float16/fp32 loss model(batch) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) # 关键 scaler.step(optimizer) scaler.update()实测开启 AMP 后T4 显存占用降低 35%batch size 可从 32 提升到 64训练速度加快 1.8 倍。Job E: Evaluate —— 自动生成可交付的 Model ArtifactEvaluatejob 接收Trainjob 产出的模型文件执行三重验证并打包技术验证加载模型用 mock 输入测试前向传播业务验证在固定测试集上计算指标写入eval/metrics.json稳定性验证调用mlflow.evaluate()对比基线模型生成eval/stability_report.html最终打包命令tar -czf model_artifact_v${{ inputs.data_version }}.tar.gz \ --transform s/^/model_artifact_v${{ inputs.data_version }}\// \ code/ data/ model/ features/ eval/这个 tarball 就是交付给部署环节的唯一制品。我们把它作为 workflow artifact 上传供后续 job 下载。3.5 第四阶段Deploy —— 从 Artifact 到线上服务的最后 1 公里部署不是kubectl apply -f service.yaml就完事。它必须包含灰度发布、健康检查、自动回滚三要素。Step 1: 构建推理服务镜像我们用 FastAPI 写轻量级推理服务# src/inference.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel import torch import joblib app FastAPI() class PredictRequest(BaseModel): features: list[float] app.on_event(startup) async def load_model(): global model, preprocessor model torch.jit.load(/app/models/weights.pt) # TorchScript 模型启动快 preprocessor joblib.load(/app/features/preprocessor.pkl) app.post(/predict) async def predict(request: PredictRequest): try: X torch.tensor([request.features]).float() X_proc preprocessor.transform(X.numpy()) # 注意preprocessor 必须支持 batch with torch.no_grad(): y_pred model(torch.tensor(X_proc).float()) return {prediction: y_pred.argmax().item()} except Exception as e: raise HTTPException(status_code500, detailstr(e))Dockerfile 极简FROM pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime COPY model_artifact_v20240501/ /app/ COPY src/inference.py /app/ CMD [uvicorn, inference:app, --host, 0.0.0.0:8000, --port, 8000]构建时model_artifact_v20240501/目录直接 COPY 进镜像确保服务与模型强绑定。Step 2: Kubernetes 部署与金丝雀发布我们用 Argo Rollouts 实现金丝雀发布。部署 manifest (k8s/deployment.yaml) 关键字段apiVersion: argoproj.io/v1alpha1 kind: Rollout metadata: name: ml-model-service spec: replicas: 10 strategy: canary: steps: - setWeight: 10 # 先切 10% 流量 - pause: {duration: 600} # 等待 10 分钟 - setWeight: 50 # 再切 50% - analysis: templates: - templateName: success-rate args: - name: service value: ml-model-service配套的 AnalysisTemplate (k8s/analysis.yaml) 定义成功标准apiVersion: argoproj.io/v1alpha1 kind: AnalysisTemplate metadata: name: success-rate spec: args: - name: service metrics: - name: http-success-rate interval: 30s count: 10 successCondition: result[0].successRate 0.99 provider: prometheus: address: http://prometheus-server.monitoring.svc.cluster.local:9090 query: | sum(rate(http_request_total{service{{args.service}}, status!~5.*}[5m])) / sum(rate(http_request_total{service{{args.service}}}[5m]))如果 10 次采样中任意一次成功率 99%Argo Rollouts 自动暂停并回滚。Step 3: 自动化回滚机制回滚不是人工kubectl rollout undo。我们在 deployment 的 annotation 中标记 artifact 版本annotations: artifact.version: model_artifact_v20240501.tar.gz artifact.hash: a1b2c3d4...当监控系统如 Prometheus Alertmanager检测到http_request_total{status~5.*} 100持续 5 分钟自动触发回滚 workflow- name: Find last stable artifact run: | # 查询 Argo CD 或自建 DB找到上一个通过 stability gate 的 artifact LAST_STABLE$(curl -s https://api.my-mlops.com/artifacts?statusstablelimit1 | jq -r .[0].version) echo LAST_STABLE$LAST_STABLE $GITHUB_ENV - name: Deploy last stable run: | kubectl set image deployment/ml-model-service \ appmy-registry/ml-model-service:$LAST_STABLE整个过程无人值守从告警到回滚完成平均耗时 4.2 分钟。4. 避坑指南那些文档里绝不会写的血泪教训4.1 数据版本与代码版本的耦合陷阱初学者常犯的错误把数据版本硬编码在代码里如train.py中写死data_path /data/train_v1。这导致无法复现历史实验git checkout commit_abc后代码指向train_v1但train_v1文件可能已被清理。流水线无法并行两个 PR 同时触发训练都写train_v1互相覆盖。正确做法数据版本必须作为 pipeline 的 runtime 参数传递且代码中绝不出现具体路径。我们定义统一的数据访问接口# src/data_loader.py import os from typing import Optional def get_train_data(version: Optional[str] None) - pd.DataFrame: if version is None: version os.getenv(DATA_VERSION, latest) # 从环境变量读取 path fs3://my-bucket/data/train_{version}.parquet return pd.read_parquet(path) # train.py 中调用 if __name__ __main__: data get_train_data() # 自动读取 pipeline 传入的 DATA_VERSION这样git blame查到的永远是接口定义而不是某个具体的路径字符串。4.2 模型序列化的“伪跨平台”幻觉很多人以为torch.save(model, model.pt)保存的模型是跨 Python 版本的。大错特错PyTorch 1.12 保存的模型在 PyTorch 2.0 加载时可能报AttributeError: dict object has no attribute _metadata。更隐蔽的是joblib.dump(preprocessor, preprocessor.pkl)用 Python 3.9 保存的 pickle在 Python 3.10 加载时可能因_codecs模块变化而失败。终极解决方案只用 TorchScript 或 ONNX。我们强制要求PyTorch 模型必须torch.jit.script(model)或torch.jit.trace(model, example_input)导出为.ptTorchScript 格式Scikit-learn 预处理器必须用skl2onnx转为 ONNX 格式from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType initial_type [(float_input, FloatTensorType([None, 128]))] onnx_model convert_sklearn(preprocessor, initial_typesinitial_type) with open(preprocessor.onnx, wb) as f: f.write(onnx_model.SerializeToString())TorchScript 和 ONNX 是框架无关的中间表示只要推理引擎LibTorch, ONNX Runtime版本兼容就能加载。我们线上服务统一用 ONNX Runtime因为它启动快 100ms、内存占用低比 PyTorch 小 40%、支持硬件加速CUDA, TensorRT。4.3 “绿色构建”不等于“可部署模型”的认知偏差CI 流水线显示All jobs passed ✅不代表模型可以安全上线。我们吃过最大的亏是训练 job 成功评估 job 显示F10.892 baseline 0.885但部署后发现P95 latency1200ms基线是 200ms。原因评估 job 用的是 CPU 推理而线上服务启用了 CUDA但模型没做model.to(cuda)导致第一次请求时触发隐式 CUDA 初始化耗时 1 秒。补救措施在 Evaluate 阶段必须模拟线上环境。我们新增一个smoke-testjob- name: Smoke test on GPU runs-on: [self-hosted, gpu] steps: - uses: actions/checkoutv4 - name: Download model artifact uses: actions/download-artifactv3 with: name: model_artifact - name: Run GPU smoke test run: | docker run \ --gpus all \ -v $(pwd)/model_artifact:/workspace/model \ nvidia/cuda:11.8.0-runtime-ubuntu22.04 \ bash -c cd /workspace/model python -c \ import torch, time; model torch.jit.load(model/weights.pt); model model.to(cuda); x torch.randn(1, 128).to(cuda); start time.time(); with torch.no_grad(): y model(x); print(fGPU warmup latency: {time.time()-start:.3f}s); \ 这个 job 专门测 GPU 初始化延迟阈值设为 0.5s。不通过直接 fail pipeline。4.4 监控盲区只看 P95 延迟却忽略长尾请求的“幽灵错误”线上监控 dashboard 上latency_p95210ms一切正常。但业务方反馈“偶尔有请求超时”。抓取日志发现latency_p9998500ms8.5 秒。排查根源是特征服务在高峰期返回了部分 null 字段模型推理时torch.cat()操作遇到None触发了异常处理路径走了慢速 fallback 逻辑。解决方案在服务中注入“可观测性探针”。我们在 FastAPI 的 middleware 中添加app.middleware(http) async def log_request_metrics(request: Request, call_next): start_time time.time() try: response await call_next(request) process_time time.time() - start_time # 记录细粒度指标 if process_time 1.0: # 1s 为长尾 logger.warning(fLong tail request: {process_time:.3f}s, path{request.url.path}) return response except Exception as e: process_time time.time() - start_time logger.error(fRequest failed: {process_time:.3f}s, error{str(e)}) raise同时Prometheus exporter 暴露http_request_duration_seconds_bucket按le1.0、le5.0、le10.0分桶。告警规则设为rate(http_request_duration_seconds_count{le1.0}[5m]) / rate(http_request_duration_seconds_count[5m]) 0.95即 5 分钟内超过 5% 的请求耗时 1 秒立即告警。5. 常见问题速查表与调试现场记录问题现象根本原因排查步骤解决方案实测耗时训练 job 随机 OOMPyTorch DataLoader 的num_workers0与 fork 内存泄漏1.nvidia-smi查看 GPU 内存增长曲线2.ps aux | grep train.py看进程数3. 检查dataloader是否启用pin_memoryTrue改用num_workers0单进程或num_workers4persistent_workersTruepin_memoryFalse20 分钟评估 job 指标波动大测试集未 shuffle模型在固定顺序上过拟合1.head -n 10 test_labels.csv查看标签分布2.python -c import numpy as np; print(np.std(np.random.choice([0,1],1000)))对比在get_test_data()中强制df.sample(frac1, random_state42).reset_index(dropTrue)5 分钟部署后服务 503Kubernetes Service 的 selector 与 Pod label 不匹配1.kubectl get pods -l appml-model-service2.kubectl get svc ml-model-service -o yaml | grep selector -A 53. 对比 label 键值统一约定 label 名为app.kubernetes.io/name: ml-model-service所有 deployment 和 service 严格遵守8 分钟模型预测结果全为 0特征预处理器的fit()和transform()未分离用训练集统计量 transform 测试集1.cat features/stats.json查看均值/方差2.python -c import joblib; pjoblib.load(preprocessor.pkl); print(p.named_steps[scaler].mean_)