1. 项目概述当机器学习模型开始“自动交货”你有没有遇到过这样的场景算法工程师在本地 Jupyter Notebook 里调通了一个新模型准确率提升了 0.8%兴奋地把代码和权重文件打包发给后端同事三天后运维同学告诉你“模型 API 响应延迟翻倍CPU 占用持续 95%我们刚把它从生产环境摘掉了。”——不是模型不香是它压根没经过“出厂质检”不是团队不努力是模型交付还停留在“U 盘拷贝人工部署”的手工业时代。Integrating CI/CD Pipelines to Machine Learning Applications这个标题说的不是给机器学习加个“自动化流水线”这么轻巧。它本质是在解决一个系统性断层数据科学侧的快速迭代能力与软件工程侧的可靠交付能力之间那条宽得能跑卡车的鸿沟。它把模型训练、验证、打包、测试、部署、监控这一整套原本靠人肉串联、靠微信群对齐、靠运气上线的流程变成一条可版本化、可回滚、可审计、可度量的工业级产线。核心关键词——CI/CD、Machine Learning、Pipelines、Integration——每一个都指向一个真实痛点CI持续集成解决的是“代码数据模型参数”三者如何同步校验的问题CD持续交付/部署解决的是“这个模型到底能不能上生产、该不该上、上完会不会崩”的决策自动化问题而 Integration则是把 MLOps 工具链如 MLflow、DVC、Kubeflow、基础设施Kubernetes、Docker、监控体系Prometheus、Grafana和传统 DevOps 工具GitLab CI、GitHub Actions、Jenkins真正拧成一股绳而不是堆砌一堆炫酷但互不认路的“孤岛工具”。适合谁看如果你是算法工程师常被问“模型什么时候能上线”却苦于无法控制部署节奏如果你是后端或 SRE 工程师总在深夜收到告警发现是某个未经充分验证的模型版本导致服务雪崩如果你是技术负责人正为模型迭代周期长达 2 周、线上故障平均恢复时间MTTR超过 40 分钟而焦虑——那么这篇内容就是为你写的。它不讲虚的概念只拆解真实产线中每一道工序的设计逻辑、踩过的坑、以及为什么非得这么干。接下来我会带你从零搭建一条能跑通的 ML-CI/CD 流水线所有配置、脚本、判断逻辑都来自我们团队在金融风控、电商推荐、IoT 设备预测三个业务线中实打实跑了一年半的产线经验。2. 整体设计思路为什么不能直接照搬软件 CI/CD很多人第一反应是“不就是把 Jenkins 里的 Java 构建脚本换成python train.py吗”——这是最危险的误解。我亲眼见过一个团队花三个月把 GitHub Actions 流程跑通结果上线后发现模型在 CI 环境里 AUC 是 0.92到了生产环境降到了 0.73另一个团队实现了全自动部署但每次发布后都要手动登录服务器把模型文件从/tmp拷到/opt/model因为没人告诉他们 Docker 镜像里根本没挂载模型存储卷。这些不是操作失误而是设计思路上的根本错位。2.1 机器学习交付的四大不可忽视特性要设计一条靠谱的 ML-CI/CD 流水线必须先承认并接纳这四个软件工程里几乎不存在的“麻烦特性”数据依赖强且不可复现软件编译依赖的是源码和依赖库版本而模型训练依赖的是数据快照 数据处理逻辑 随机种子。今天用pandas.read_csv(data.csv)读取的数据明天可能因上游 ETL 任务延迟实际读到的是昨天的脏数据。CI 环境里用train_20240501.parquet训练CD 环境里如果没严格锁定这个文件哈希就可能用train_20240502.parquet含新引入的异常样本去部署。这不是 bug是默认行为。模型非确定性即使数据、代码、超参完全一致GPU 的浮点运算顺序、cuDNN 的优化策略、甚至 PyTorch 版本小更新都可能导致模型权重微小差异。这种差异在离线评估中可能无感但在高并发、低延迟场景下会放大成服务响应时间抖动。因此ML-CI/CD 的“构建产物”不能只是.py文件而必须是带完整环境描述、数据指纹、模型权重哈希的可验证包。验证维度多维且昂贵软件测试有单元测试、集成测试、E2E 测试但模型验证还要加三道硬门槛数据漂移检测新数据分布是否显著偏离训练集用 KS 检验、PSI 指标模型性能衰减在新数据上AUC、F1、RMSE 是否跌破基线需预留 holdout 数据集服务稳定性验证模型 API 在 100 QPS 下 P95 延迟是否 200ms内存泄漏是否 1MB/h需压测脚本这些验证耗时动辄 10–30 分钟远超普通单元测试的秒级必须设计合理的触发策略和并行机制。部署即状态变更回滚成本高部署一个 Java 服务回滚就是切回上一个 Docker 镜像但部署一个模型往往意味着更新在线推理服务的模型权重文件清空 Redis 中缓存的旧模型特征重跑批处理任务以生成新模型的预测结果通知下游 BI 系统刷新报表口径。这是一个跨系统、跨团队的状态协同任何一环失败回滚就不是“一键”而是“一场战役”。2.2 我们最终采用的分阶段流水线架构基于以上认知我们放弃了“一套脚本打天下”的幻想转而设计了四阶段、双门禁的流水线架构。它不是为了炫技而是为了在速度与安全之间找到那个可落地的平衡点阶段触发条件核心任务产出物门禁规则Stage 0代码与数据准入Pre-PR开发者本地git commit后IDE 插件自动触发1. 代码风格检查Black Flake82. 数据 Schema 校验对比schema.yaml与train.csv字段3. 小样本快速训练10% 数据1 epoch验证代码可运行通过/失败标记附带数据字段变更报告任意一项失败禁止提交 PRStage 1模型构建与离线验证CIPR 创建或更新时由 GitHub Actions 触发1. 克隆 PR 分支 锁定数据版本DVC pulldvc.yaml指定的 hash2. 全量训练 保存模型MLflow log_model3. 离线指标计算AUC/F1 on holdout set4. 数据漂移分析PSI 0.1 则告警MLflow Experiment Run ID、模型 URI、指标 JSONAUC 必须 ≥ 基线 -0.005且 PSI 0.25 才允许进入 Stage 2Stage 2服务化与集成测试CD-PrepareStage 1 成功后手动点击 “Deploy to Staging”1. 构建推理服务 Docker 镜像含模型权重、依赖、健康检查端点2. 推送镜像至私有 Harbor3. 在 Kubernetes Staging Namespace 部署服务4. 运行集成测试调用 API验证输入输出格式、HTTP 状态码可部署的 Docker 镜像、Staging Service URL集成测试 100% 通过且镜像扫描无 CRITICAL 漏洞Stage 3灰度发布与生产就绪CD-ReleaseStage 2 成功后由值班 SRE 手动审批触发1. 将 Staging 服务流量 5% 切至新版本Istio VirtualService2. 实时监控 15 分钟错误率、延迟 P95、资源使用率3. 若全部达标自动全量切换否则自动回滚并告警生产环境新版本服务、完整的灰度报告错误率 0.1%、P95 延迟 ≤ 基线 1.2 倍、CPU 使用率波动 ±15%这个设计的核心逻辑是把“能不能跑通”交给机器Stage 0 1把“值不值得上线”交给数据Stage 1 门禁把“会不会崩”交给环境Stage 2最后把“敢不敢全量”交给人和实时指标Stage 3。它牺牲了一点“全自动”的理想主义换来了线上事故率下降 76% 的现实收益。下面我们就深入每个阶段看看那些关键环节到底是怎么实现的。3. 核心细节解析从数据锁死到灰度决策的实操要点光有架构图是没用的真正的挑战永远藏在细节里。比如“锁定数据版本”听起来简单但如果你用git add data/train.csv文件体积超 2GB 就会让 Git 直接崩溃再比如“灰度监控 15 分钟”看似明确但监控什么指标、阈值怎么设、告警发给谁这些决定着整条流水线是救命稻草还是定时炸弹。以下是我们踩过坑、验证过、现在每天都在跑的实操要点。3.1 Stage 0让数据和代码在提交前就“对齐”很多团队跳过 Stage 0认为“开发者自己负责数据质量”。但现实是一个新人在本地改了preprocess.py里的一行归一化逻辑忘了更新schema.yaml结果 PR 合并后整个训练流水线跑出一堆 NaN。Stage 0 的价值就是把这类低级错误挡在代码仓库门外。我们强制要求所有数据文件CSV、Parquet、JSONL必须由 DVCData Version Control管理。DVC 不是替代 Git而是作为 Git 的扩展它把大文件存到远程存储如 S3Git 仓库里只保留一个很小的.dvc元数据文件里面记录了该数据文件的 SHA256 哈希、路径和远程地址。这样git commit时Git 只处理 KB 级的元数据速度飞快。但 DVC 默认不校验数据 Schema。我们的解决方案是在pre-commit钩子中加入自定义脚本# .pre-commit-config.yaml - repo: local hooks: - id: validate-data-schema name: Validate Data Schema against DVC-tracked files entry: python scripts/validate_schema.py language: system types: [text] # 匹配 .dvc 文件 pass_filenames: falsevalidate_schema.py的核心逻辑是解析当前目录下所有.dvc文件提取deps字段即数据文件路径对每个数据文件用pandas.read_parquet(path, nrows100)读取前 100 行获取列名和 dtype与项目根目录下的schema.yaml由数据工程师统一维护比对新增列→ 允许但需在schema.yaml中显式标注is_new: true删除列→ 禁止除非schema.yaml中该列标记为deprecated: true且已存在 7 天dtype 变更如int64→float64→ 发出 WARNING但不阻断提交留给 Stage 1 的离线验证深挖。提示这个脚本必须极快我们实测单个 Parquet 文件校验控制在 300ms 内。如果用read_csv全量读取一次校验就要 2 分钟开发者绝对会绕过它。所以我们只读 schema不读数据内容。3.2 Stage 1模型构建中的“三重锁”机制Stage 1 是整条流水线的“心脏”它决定了哪个模型有资格进入后续环节。我们称之为“三重锁”数据锁、代码锁、环境锁。缺一不可。数据锁通过 DVC 实现。在 CI 脚本中我们不写dvc pull而是写dvc pull --rev ${{ github.head_ref }}。这意味着只有当 PR 分支的 HEAD 提交中.dvc文件被明确修改即数据版本被开发者主动升级才会拉取新数据。如果.dvc文件没变就复用上一次成功的缓存。这避免了“数据静默更新”导致的模型不可复现。代码锁我们要求所有训练脚本必须接受--config参数指向一个 YAML 配置文件如config/staging.yaml。这个文件里明确写了data: train_path: s3://my-bucket/data/train_v2.dvc # DVC 文件路径 holdout_path: s3://my-bucket/data/holdout_v1.dvc model: name: xgboost params: n_estimators: 100 max_depth: 6 seed: 42 # 全局随机种子确保可复现CI 脚本启动训练时固定传入--config config/ci.yaml而ci.yaml是一个只读的、由 MLOps 团队维护的文件它强制覆盖了所有可能影响结果的变量。开发者可以改staging.yaml但改不了ci.yaml。环境锁我们不用requirements.txt而是用conda-lock生成conda-lock.yml。这个文件精确到每个包的 build string如numpy-1.24.3-py39h1a8460c_0彻底杜绝了pip install时因网络波动拉取到不同二进制包导致的环境差异。CI 环境启动时第一行命令就是conda-lock install conda-lock.yml -p $CONDA_PREFIX。注意MLflow 的log_model并不自动记录 conda 环境。我们必须在训练脚本末尾手动追加import mlflow from mlflow.models.signature import infer_signature # ... 训练完成后 signature infer_signature(X_test, model.predict(X_test)) mlflow.sklearn.log_model( sk_modelmodel, artifact_pathmodel, signaturesignature, input_exampleX_test.iloc[:3], # 记录输入样例供后续 API 文档生成 registered_model_namefraud-detector # 强制注册到中心模型库 ) # 关键手动记录 conda 环境 mlflow.log_artifact(conda-lock.yml, env)3.3 Stage 2构建“可验证”的推理服务镜像很多团队的“模型服务化”就是写个 Flask APIpickle.load()模型然后docker build -t my-model .。这在 Stage 2 是灾难性的。因为这个镜像里没有健康检查端点/healthzK8s 无法判断容器是否真活没有就绪探针/readyz新实例启动后立刻接收流量导致请求失败模型文件是COPY进去的一旦模型更新就得重新构建整个镜像浪费存储和时间。我们的解决方案是镜像只包含运行时环境和推理框架模型权重作为外部配置注入。具体做法基础镜像分层我们维护一个ml-inference-base:1.0镜像里面预装了Python 3.9、PyTorch 2.0、XGBoost 2.0.3uvicorn、fastapi、prometheus-client一个通用的inference_server.py它支持从环境变量MODEL_URI加载模型支持mlflow://,s3://,file:///healthz和/readyz端点/readyz会检查MODEL_URI是否可访问、模型是否加载成功。构建阶段只做一件事在 Stage 2 的 CI 脚本中我们只构建这个基础镜像的 tag并推送到 Harbor# Stage 2 CI 脚本片段 export IMAGE_TAGstaging-${GITHUB_RUN_ID} docker build -t harbor.example.com/ml-inference-base:${IMAGE_TAG} . docker push harbor.example.com/ml-inference-base:${IMAGE_TAG}部署时才绑定模型K8s Deployment 的env字段中动态注入MODEL_URI# k8s/deployment-staging.yaml env: - name: MODEL_URI value: mlflow://https://mlflow.example.com?run_id{{ .Values.mlflowRunId }}这样同一个ml-inference-base:1.0镜像可以服务 100 个不同模型只需改一个环境变量。镜像构建频率从“每次模型更新”降到“每月一次基础环境升级”CI 时间从 12 分钟缩短到 90 秒。3.4 Stage 3灰度发布的“黄金 15 分钟”监控清单Stage 3 是最后一道防线也是最容易被“拍脑袋”决定的环节。我们把“15 分钟”拆解成 5 个必监控、3 个建议监控的硬性指标并全部接入 Prometheus Grafana自动生成灰度报告。必监控5 项任一超标即自动回滚指标查询 PromQL阈值为什么关键API 错误率rate(http_request_total{jobml-api, status~5..}[5m]) / rate(http_request_total{jobml-api}[5m]) 0.001 (0.1%)5xx 错误代表服务崩溃不是模型不准是工程故障P95 延迟histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{jobml-api}[5m])) by (le))≤ 基线值 × 1.2模型复杂度提升必然增加延迟但翻倍说明有严重瓶颈如未启用 ONNX RuntimeCPU 使用率100 - (avg by(instance) (irate(node_cpu_seconds_total{modeidle}[5m])) * 100)波动 ±15%突然飙升说明模型有内存泄漏或无限循环GPU 显存占用nvidia_smi_duty_cycle{device0} / nvidia_smi_memory_total_bytes{device0} 90%GPU OOM 会导致服务直接 kill必须严防特征加载成功率rate(feature_load_failure_total{jobml-api}[5m]) 0模型需要的特征在 Redis 或 Hive 中缺失是数据管道断裂的信号建议监控3 项用于人工决策模型预测分布偏移histogram_quantile(0.5, rate(model_output_histogram_bucket[5m]))对比基线中位数偏移 10% 需关注输入数据长度分布histogram_quantile(0.99, rate(input_length_histogram_bucket[5m]))防止恶意长文本攻击下游调用方错误率rate(downstream_api_failure_total{upstreamml-api}[5m])确认问题是否真的出在模型侧。灰度报告自动生成逻辑CI 脚本在istioctl apply -f istio/virtualservice-canary.yaml后启动一个watchdog容器它每 30 秒查询一次上述 5 个 PromQL持续 15 分钟。如果全部达标执行istioctl apply -f istio/virtualservice-prod.yaml全量切换否则执行istioctl apply -f istio/virtualservice-rollback.yaml切回旧版并发送企业微信告警附带详细指标截图。实操心得我们最初把阈值设得太“理想”比如要求 P95 延迟 ≤ 基线结果灰度总是失败。后来分析发现新模型用了更复杂的特征交叉首次请求确实慢冷启动但后续请求很快。于是我们把监控窗口从“首分钟”改为“第 5–15 分钟”并增加了“冷启动延迟”专项监控。这个调整让灰度通过率从 42% 提升到 91%。4. 实操过程从零搭建一条可运行的 ML-CI/CD 流水线纸上谈兵终觉浅。下面我将用一个极简但真实的案例——一个用于预测用户次日付费概率的 XGBoost 模型——带你一步步搭建一条可立即运行的 ML-CI/CD 流水线。所有代码、配置、命令均来自我们生产环境的最小可行版本MVP删减了公司内部认证等非核心逻辑你可以直接复制粘贴使用。4.1 环境准备5 分钟初始化你的本地沙箱我们假设你有一台 Linux 或 macOS 机器Windows 用户请用 WSL2已安装 Docker、Python 3.9、Git。无需云服务所有组件均可本地运行。安装核心工具链# 安装 DVC数据版本控制 pip install dvc[s3] # 如果用 S3加 s3本地用 dvc[gs] 或 dvc[azure] # 安装 MLflow模型生命周期管理 pip install mlflow2.10.1 # 安装 MinIO本地对象存储替代 S3 brew install minio/stable/minio # macOS # 或下载二进制https://min.io/download minio server /data # 启动 MinIO访问 http://localhost:9000默认账号 minioadmin:minioadmin初始化项目结构mkdir ml-cicd-demo cd ml-cicd-demo git init dvc init # 初始化 MLflow 后端存储用本地 SQLite生产环境请换 PostgreSQL mlflow db upgrade sqlite:///mlruns.db mlflow ui --backend-store-uri sqlite:///mlruns.db --default-artifact-root ./mlartifacts 创建最小数据集与训练脚本# data/generate_sample.py import pandas as pd import numpy as np np.random.seed(42) n_samples 10000 df pd.DataFrame({ age: np.random.randint(18, 80, n_samples), income: np.random.normal(50000, 15000, n_samples), last_login_days: np.random.exponential(5, n_samples), is_premium: np.random.choice([0, 1], n_samples, p[0.7, 0.3]), label: np.random.binomial(1, 0.1 0.02*df[income]/10000 - 0.01*df[last_login_days], n_samples) }) df.to_parquet(data/train.parquet, indexFalse) df.sample(frac0.2).to_parquet(data/holdout.parquet, indexFalse) print(Sample data generated.)运行python data/generate_sample.py生成两个 Parquet 文件。4.2 Stage 0配置 Pre-Commit 钩子拦截低级错误创建schema.yaml# schema.yaml version: 1 fields: - name: age type: integer nullable: false - name: income type: float nullable: false - name: last_login_days type: float nullable: false - name: is_premium type: integer nullable: false - name: label type: integer nullable: false编写scripts/validate_schema.py# scripts/validate_schema.py import sys import yaml import pandas as pd from pathlib import Path def main(): # 读取 schema with open(schema.yaml) as f: schema yaml.safe_load(f) # 查找所有 .dvc 文件 dvc_files list(Path(.).rglob(*.dvc)) if not dvc_files: print(No .dvc files found. Skipping schema validation.) return 0 for dvc_file in dvc_files: try: # 解析 .dvc 文件获取 deps数据路径 with open(dvc_file) as f: dvc_content yaml.safe_load(f) data_path dvc_content.get(deps, [{}])[0].get(path, ) if not data_path or not Path(data_path).exists(): continue # 读取数据前几行获取 schema if data_path.endswith(.parquet): df_sample pd.read_parquet(data_path, nrows100) else: df_sample pd.read_csv(data_path, nrows100) # 比对字段 for field in schema[fields]: if field[name] not in df_sample.columns: print(fERROR: Field {field[name]} missing in {data_path}) return 1 actual_dtype str(df_sample[field[name]].dtype) expected_type field[type] if expected_type integer and int not in actual_dtype: print(fERROR: Field {field[name]} expected integer, got {actual_dtype}) return 1 if expected_type float and float not in actual_dtype: print(fERROR: Field {field[name]} expected float, got {actual_dtype}) return 1 except Exception as e: print(fERROR validating {dvc_file}: {e}) return 1 print(Schema validation passed.) return 0 if __name__ __main__: sys.exit(main())安装 pre-commit 钩子pip install pre-commit pre-commit install # 此时每次 git commit 都会自动运行 validate_schema.py4.3 Stage 1编写 GitHub Actions CI 脚本完成模型构建与验证创建.github/workflows/ml-ci.ymlname: ML CI Pipeline on: pull_request: branches: [main] jobs: validate-and-train: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 with: fetch-depth: 0 # 必须DVC 需要完整 Git 历史 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.9 - name: Install DVC and dependencies run: | pip install dvc[s3] mlflow scikit-learn xgboost pandas numpy - name: Configure DVC remote (local for demo) run: | dvc remote add -d myremote local \ dvc remote modify myremote url ./.dvc/cache \ dvc remote default myremote - name: Pull data (DVC) run: dvc pull - name: Train model and log to MLflow run: | python train.py \ --train-path data/train.parquet \ --holdout-path data/holdout.parquet \ --output-dir models/ \ --mlflow-tracking-uri http://localhost:5000 - name: Upload MLflow artifacts uses: actions/upload-artifactv3 with: name: mlflow-run-id path: mlflow-run-id.txt # train.py 会生成此文件train.py的核心逻辑简化版# train.py import argparse import pandas as pd import xgboost as xgb from sklearn.metrics import roc_auc_score import mlflow import mlflow.xgboost def main(): parser argparse.ArgumentParser() parser.add_argument(--train-path) parser.add_argument(--holdout-path) parser.add_argument(--output-dir) parser.add_argument(--mlflow-tracking-uri, defaulthttp://localhost:5000) args parser.parse_args() # 设置 MLflow mlflow.set_tracking_uri(args.mlflow_tracking_uri) mlflow.set_experiment(fraud-demo) with mlflow.start_run(): # 记录参数 mlflow.log_param(train_path, args.train_path) mlflow.log_param(holdout_path, args.holdout_path) # 加载数据 train_df pd.read_parquet(args.train_path) holdout_df pd.read_parquet(args.holdout_path) X_train, y_train train_df.drop(label, axis1), train_df[label] X_holdout, y_holdout holdout_df.drop(label, axis1), holdout_df[label] # 训练 model xgb.XGBClassifier(n_estimators50, max_depth3, random_state42) model.fit(X_train, y_train) # 评估 y_pred_proba model.predict_proba(X_holdout)[:, 1] auc roc_auc_score(y_holdout, y_pred_proba) mlflow.log_metric(auc, auc) # 保存模型 mlflow.xgboost.log_model(model, model) # 保存 run_id 供后续步骤使用 with open(mlflow-run-id.txt, w) as f: f.write(mlflow.active_run().info.run_id) if __name__ __main__: main()提示这个 CI 脚本在 GitHub 上运行时mlflow.active_run().info.run_id会被写入mlflow-run-id.txt后续 Stage 2 的 CD 脚本会读取它来构建MODEL_URI。这就是 Stage 1 和 Stage 2 的关键纽带。4.4 Stage 2 3Kubernetes 部署与 Istio 灰度本地 Minikube 演示由于完整 K8s 集群搭建复杂我们用 Minikube 演示核心逻辑启动 Minikube 并启用 Istiominikube start --cpus4 --memory8192 minikube addons enable istio-provisioner minikube addons enable istio部署基础推理服务Stage 2# 使用我们预先构建好的基础镜像演示用 kubectl create namespace ml-staging kubectl apply -f k8s/inference-deployment-staging.yaml # inference-deployment-staging.yaml 包含 Deployment、Service、VirtualService触发灰度Stage 3# 修改 VirtualService将 5% 流量导向新版本 sed -i s/weight: 100/weight: 5/g k8s/istio/virtualservice-canary.yaml sed -i s/weight: 0/weight: 95/g k8s/istio/virtualservice-canary.yaml kubectl apply -f k8s/istio/virtualservice-canary.yaml此时访问http://$(minikube ip):30080/predictNodePort 服务95% 请求走旧版5% 走新版。watchdog脚本会自动监控 15 分钟决定是否全量。这条流水线从git commit到kubectl apply全程无人值守所有决策基于数据和指标。它不是银弹但它是把机器学习从“艺术”推向“工程”的最坚实一步。5. 常见问题与排查技巧实录那些文档里不会写的坑再完美的设计在真实世界里也会撞墙。以下是我们在过去一年中被问得最多、也最痛的 7 个问题以及我们摸索出的、真正管用的排查技巧。它们不是理论而是凌晨三点在 Slack 里敲出来的血泪总结。5.1 问题 1CI 环境里模型 AUC 是 0.85Staging 环境里降到 0.72但数据、代码、参数完全一样表象离线评估完美线上服务一跑就崩。排查路径首先查特征工程CI 环境用的是pandas 1.5.3Staging 镜像里是pandas 2.0.1。pd.cut