机器学习模型服务化:从Notebook到生产环境的稳定性实践

📅 2026/6/16 2:54:02
机器学习模型服务化:从Notebook到生产环境的稳定性实践
1. 项目概述这不是一次“部署”而是一场从实验室到产线的系统性迁移“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被太多人轻描淡写、却让无数团队在临门一脚时彻底卡死的真相把 Jupyter 里跑通的模型变成每天凌晨三点还在稳定响应 API 请求、能扛住促销峰值流量、出错时自动告警并回滚、日志能精准定位到某一行特征工程代码的生产服务根本不是“加个 Flask 就完事”的技术动作而是一整套工程范式的切换。我在前三年带过七支跨职能 ML 团队亲眼见过 82% 的模型从未走出 notebook剩下 18% 中又有 63% 在上线后三个月内因数据漂移、依赖冲突或监控缺失被迫下线。Part 4 这个编号很关键——它不是孤立的技术模块而是整个落地链条中承上启下的“稳压阀”它承接 Part 1 的数据治理规范、Part 2 的可复现实验追踪、Part 3 的模型封装标准最终输出的是一个能嵌入现有 DevOps 流水线、接受 SRE 团队日常巡检、业务方敢在核心交易链路调用的 ML 服务单元。它解决的不是“能不能跑”而是“敢不敢用”“出了问题找谁”“明天业务量翻三倍还稳不稳”。适合正在经历模型交付瓶颈的算法工程师、开始接手 ML 服务运维的后端工程师、以及需要向管理层解释“为什么模型上线要花六周”的技术负责人。你不需要精通 Kubernetes但必须理解容器镜像的分层逻辑你不必手写 Prometheus exporter但得知道 latency P99 超过 200ms 时该先查哪三层指标。这是一份写给真实战场的生存指南不是教科书里的理想模型。2. 内容整体设计与思路拆解为什么 Part 4 必须聚焦“服务化稳定性”而非“功能完整性”2.1 核心矛盾识别实验室环境与生产环境的三重断层很多团队在 Part 4 阶段栽跟头根本原因在于误判了问题本质。他们以为难点是“怎么把 pickle 模型加载进 Web 服务”于是疯狂堆砌 FastAPI 文档、Swagger 配置、JWT 鉴权——结果上线三天服务在凌晨两点因内存泄漏 OOM 重启而日志里只有一行Killed process。真正的断层其实在三个维度数据流断层Notebook 里pd.read_csv(data/train.csv)是静态快照生产中却是 Kafka 实时流 S3 增量分区 特征存储Feature Store的多源拼接。Part 4 必须定义数据契约Data Contract输入 API 的 JSON Schema、特征计算的确定性保证比如datetime.now()必须替换为请求时间戳、缺失值填充策略的版本对齐v1.2 模型要求age字段 null→0v1.3 要求 null→-1。环境断层本地 conda 环境里scikit-learn1.2.2和 Docker 镜像里scikit-learn1.2.2cpu可能因编译器差异导致预测结果偏差 0.3%。我们曾在线上发现一个推荐模型 AUC 下降 0.015追查两周才发现是 base 镜像从ubuntu:22.04升级到22.04.3后OpenBLAS 库的线程调度策略变更影响了稀疏矩阵乘法精度。责任断层算法同学说“模型没问题是你们 API 网关超时了”SRE 同学说“服务健康检查失败你们模型加载太慢”。Part 4 的设计必须强制划定边界模型服务只暴露/predict和/healthz两个 endpoint/healthz返回结构化 JSON 包含model_load_time_ms、feature_store_latency_ms、last_inference_timestamp三项核心指标任何一项不达标即返回 503所有外部依赖数据库、缓存、特征服务必须配置熔断阈值如 Hystrix fallback且 fallback 逻辑明确写入 SLA 协议。提示Part 4 的架构图里永远不要出现“ML Model”单独一个方块。它必须是“Model Feature Preprocessor Postprocessor Health Checker Metrics Exporter”的原子组合缺一不可。2.2 方案选型逻辑为什么放弃“全栈大模型服务框架”选择“最小可行服务单元”市面上有 Kubeflow、Seldon、BentoML 等成熟框架但我们团队在 Part 4 实施中坚持“手写最小服务单元”原因很实际调试成本BentoML 的bentoml serve启动时会注入 17 层装饰器当预测延迟突增时cProfile输出的调用栈里 83% 是框架自身开销真正耗时的特征计算反而被淹没。而手写服务中time.time()打点可精确到函数级/debug/profileendpoint 直接返回火焰图。升级路径框架更新常伴随 breaking change如 BentoML v1.2 到 v1.3 强制要求 Pydantic v2。我们维护的 23 个线上模型服务若统一用框架每次升级需全量回归测试而最小单元服务只需验证predict()函数签名和返回格式回归范围缩小 90%。资源控制框架默认启用 gRPC HTTP/2 双协议但我们的网关只支持 HTTP/1.1。强行适配导致连接复用失效QPS 下降 40%。手写服务直接用uvicorn --http httpcore指定协议栈内存占用降低 35%。我们的最小服务单元包含五个文件app.pyFastAPI 主应用仅定义/predict和/healthzmodel_loader.py单例模式加载模型含load_model()和get_model_version()方法preprocessor.py纯函数式特征处理无状态输入 dict 输出 dictmetrics.py封装 Prometheus client暴露inference_latency_secondshistogramDockerfile多阶段构建build 阶段装 torch/transformersruntime 阶段仅保留python:3.10-slim 依赖 wheel这个结构看似“原始”但上线后平均故障恢复时间MTTR从 47 分钟降至 8 分钟——因为每个环节都透明、可测、可替换。2.3 影响范围界定Part 4 不是终点而是新协作流程的起点Part 4 的交付物从来不只是一个 Docker 镜像。它强制触发三个组织级改变变更管理流程模型版本发布不再由算法同学git push触发而是通过 GitLab CI 的ml-deploypipeline。该 pipeline 会自动执行① 拉取模型 artifactS3 URI② 运行 smoke test用预置 100 条样本验证预测一致性③ 对比新旧版本的model_load_time_ms增长 10% 则阻断④ 更新 Kubernetes ConfigMap 中的模型版本号。整个过程无人值守审计日志留存 180 天。监控告警体系SRE 团队将inference_latency_seconds_bucket{le0.2}的 P95 值纳入核心大盘低于 95% 触发企业微信告警同时新增feature_store_error_rate指标当特征获取失败率 0.5% 持续 5 分钟自动创建 Jira ticket 并指派给数据平台组。回滚机制Kubernetes Deployment 的revisionHistoryLimit设为 10配合 Argo Rollouts 的金丝雀发布。当新版本error_count在 5 分钟内超过 50 次Rollout 自动暂停并回退至前一 revision——整个过程无需人工介入平均回滚耗时 22 秒。Part 4 的成功不在于服务是否“跑起来”而在于它能否让 SRE 信任地把它加入巡检清单让 QA 团队放心地把它接入全链路压测让业务方敢于在双十一大促期间将其作为风控主模型。这才是“Real World”的终极检验标准。3. 核心细节解析与实操要点从代码到镜像的 7 个生死细节3.1 模型加载为什么torch.load()必须加map_location参数在 notebook 里model torch.load(model.pth)很自然但生产服务若运行在 CPU 节点而模型保存时在 GPU 上torch.load()默认会尝试将 tensor 加载到原设备cuda:0导致RuntimeError: Attempting to deserialize object on a CUDA device but torch.cuda.is_available() is False。更隐蔽的问题是即使服务部署在 GPU 节点不同卡型号V100 vs A100的 CUDA 架构差异也可能引发CUDNN_STATUS_NOT_SUPPORTED错误。正确做法# model_loader.py def load_model(model_path: str, device: str cpu) - nn.Module: # 强制指定设备避免隐式设备推断 state_dict torch.load(model_path, map_locationdevice) model MyModel() model.load_state_dict(state_dict) model.to(device) # 显式移动到目标设备 model.eval() # 关键禁用 dropout/batchnorm 训练模式 return model实操心得我们在灰度发布时发现未加map_location的服务在 CPU 节点启动耗时 12 秒反复重试 cuda 加载加了之后稳定在 1.8 秒。device参数必须从环境变量读取os.getenv(MODEL_DEVICE, cpu)这样同一镜像可复用于 CPU/GPU 集群无需重新构建。3.2 特征预处理为什么pandas.DataFrame是生产环境的“定时炸弹”Notebook 里df[age].fillna(0)很方便但生产中df可能是百万行数据fillna()触发整列拷贝内存峰值暴涨 3 倍。更严重的是pandas 的dtypes在不同 pandas 版本间不兼容如pd.Int64Dtype()在 1.3 和 1.5 表现不同导致特征向量维度错乱。替代方案用numpy原生操作 类型强约束# preprocessor.py def preprocess_features(raw_input: Dict[str, Any]) - np.ndarray: # 输入严格校验 assert age in raw_input, missing required field age assert isinstance(raw_input[age], (int, float, type(None))), finvalid type for age: {type(raw_input[age])} # 纯 numpy 操作零拷贝 age np.array([raw_input[age]], dtypenp.float32) age np.nan_to_num(age, nan0.0) # 替代 fillna # 构建固定长度向量假设模型输入维度为 10 features np.zeros(10, dtypenp.float32) features[0] age[0] features[1] np.log1p(raw_input.get(income, 0)) # log1p 避免 log(0) return features注意所有数值字段必须显式声明dtypenp.float32而非np.float否则在 ARM 架构服务器上可能因默认类型差异导致精度丢失。3.3 健康检查 endpoint为什么/healthz必须包含“业务健康度”很多服务只实现return {status: ok}这毫无价值。真正的健康检查必须反映业务可用性# app.py app.get(/healthz) def health_check(): # 1. 基础服务健康 start_time time.time() try: # 2. 模型加载状态单例已初始化 model_loader.get_model_version() model_load_time time.time() - start_time # 3. 特征服务连通性模拟一次轻量请求 feature_start time.time() _ get_user_features(user_idtest_123) # 真实调用特征存储 feature_latency time.time() - feature_start # 4. 业务指标最近一次预测时间证明服务在持续工作 last_inference getattr(app.state, last_inference_ts, 0) return { status: healthy, model_load_time_ms: round(model_load_time * 1000, 2), feature_store_latency_ms: round(feature_latency * 1000, 2), last_inference_timestamp: last_inference, uptime_seconds: int(time.time() - app.state.start_time) } except Exception as e: logger.error(fHealth check failed: {e}) raise HTTPException(status_code503, detailfUnhealthy: {str(e)})实操心得Kubernetes 的livenessProbe必须设置initialDelaySeconds: 60给模型加载留足时间periodSeconds: 10且failureThreshold设为 3。我们曾因initialDelaySeconds设为 10导致服务在加载模型的 45 秒内被 K8s 反复 kill形成启动风暴。3.4 日志规范为什么print()是生产环境的“自杀行为”Notebook 里的print(Predicting for user:, user_id)在生产中会污染 stdout导致日志采集 agent如 Filebeat无法解析 JSON 格式进而丢失 trace_id。更糟的是print不经过日志级别过滤DEBUG 级别日志会刷爆磁盘。强制规范所有日志必须通过logging.getLogger(__name__)获取使用结构化日志logger.info(prediction_complete, extra{user_id: user_id, latency_ms: latency})配置JsonFormatter确保每行日志是合法 JSONERROR 级别日志必须包含exc_infoTrue捕获完整 traceback# logging_config.py import logging import json from pythonjsonlogger import jsonlogger class CustomJsonFormatter(jsonlogger.JsonFormatter): def add_fields(self, log_record, record, message_dict): super().add_fields(log_record, record, message_dict) if not log_record.get(timestamp): log_record[timestamp] datetime.utcnow().isoformat() if log_record.get(level): log_record[level] log_record[level].upper() else: log_record[level] record.levelname.upper() # app.py 初始化 logging.basicConfig( levellogging.INFO, format%(asctime)s %(name)s %(levelname)s %(message)s, handlers[logging.StreamHandler()] ) logger logging.getLogger(__name__)提示在 Dockerfile 中设置ENV PYTHONUNBUFFERED1避免日志缓冲导致故障时日志丢失。3.5 Docker 镜像优化为什么多阶段构建必须分离build和runtime一个典型错误是FROM python:3.10然后RUN pip install torch transformers这会导致镜像体积达 2.1GB含编译工具链、头文件且存在安全风险gcc等工具不应存在于 runtime 环境。最优实践多阶段构建# build stage FROM python:3.10-slim AS builder RUN apt-get update apt-get install -y build-essential COPY requirements.txt . RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt # runtime stage FROM python:3.10-slim # 复制 wheel 包不复制源码和编译工具 COPY --frombuilder /wheels /wheels COPY --frombuilder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages # 安装 wheel不联网不编译 RUN pip install --no-cache-dir --no-deps --find-links /wheels --upgrade --force-reinstall *.whl COPY . /app WORKDIR /app CMD [uvicorn, app:app, --host, 0.0.0.0:8000, --port, 8000]效果镜像体积从 2.1GB 降至 487MBCVE 高危漏洞减少 92%启动时间加快 3.2 倍因 layer 缓存更高效。3.6 环境变量管理为什么.env文件在生产中必须被禁止开发时python -m dotenv run -- python app.py很方便但生产中.env文件易被误提交、权限泄露chmod 600常被遗忘且无法满足多环境差异化配置dev/staging/prod 的数据库密码不同。生产方案Kubernetes Secret Downward API# k8s/deployment.yaml env: - name: MODEL_S3_URI valueFrom: secretKeyRef: name: ml-model-secrets key: model_s3_uri - name: FEATURE_STORE_URL valueFrom: configMapKeyRef: name: ml-config key: feature_store_url - name: LOG_LEVEL value: INFO代码中读取# config.py import os MODEL_S3_URI os.getenv(MODEL_S3_URI) if not MODEL_S3_URI: raise ValueError(MODEL_S3_URI environment variable not set)注意Secret 的data字段是 base64 编码K8s 会自动解码后注入环境变量无需代码处理。3.7 指标暴露为什么 Prometheus histogram 必须自定义 bucketprometheus_client.Histogram默认的 bucket0.005, 0.01, 0.025, ...完全不适用于 ML 推理场景。我们的业务要求 P95 200ms但默认 bucket 在 0.1~0.2 秒区间只有 2 个分桶无法精准计算 P95。定制化方案# metrics.py from prometheus_client import Histogram # 定义符合业务 SLA 的 buckets单位秒 INFERENCE_LATENCY_SECONDS Histogram( inference_latency_seconds, Latency of inference requests, buckets[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0] ) app.middleware(http) async def record_latency(request: Request, call_next): start_time time.time() response await call_next(request) process_time time.time() - start_time INFERENCE_LATENCY_SECONDS.observe(process_time) return response实操验证用curl http://localhost:8000/metrics | grep inference_latency_seconds_bucket查看各 bucket 计数确保le0.2的计数占比 ≥95%。若不达标立即触发告警并分析是模型推理慢还是特征获取慢。4. 实操过程与核心环节实现从本地验证到灰度发布的全流程4.1 本地验证用pytest模拟生产环境的 5 个测试用例Part 4 的代码合并前必须通过以下 pytest 用例否则 CI 直接拒绝# tests/test_app.py import pytest from fastapi.testclient import TestClient from app import app client TestClient(app) def test_healthz_returns_200(): response client.get(/healthz) assert response.status_code 200 data response.json() assert data[status] healthy assert model_load_time_ms in data def test_predict_valid_input_returns_200(): # 模拟生产环境典型输入 payload {user_id: u_123, age: 25, income: 85000} response client.post(/predict, jsonpayload) assert response.status_code 200 data response.json() assert score in data and isinstance(data[score], float) def test_predict_missing_required_field_returns_422(): payload {user_id: u_123} # 缺少 age response client.post(/predict, jsonpayload) assert response.status_code 422 # pydantic validation error def test_predict_large_payload_returns_413(): # 生产环境限制 payload size 1MB large_payload {user_id: u_123, features: [0.0] * 200000} response client.post(/predict, jsonlarge_payload) assert response.status_code 413 def test_model_version_consistency(): # 验证 model_loader.get_model_version() 返回值与 git tag 一致 from model_loader import get_model_version version get_model_version() assert version.startswith(v1.) or version.startswith(v2.)CI 配置要点.gitlab-ci.ymlstages: - test - build - deploy test: stage: test image: python:3.10-slim before_script: - pip install pytest fastapi uvicorn script: - pytest tests/ --covapp --cov-reportterm-missing coverage: /^TOTAL.*\s([\d\.])/4.2 Docker 构建与扫描为什么trivy扫描必须集成到 CI安全不是上线前的“补救”而是构建时的“内建”。我们在 CI 的build阶段强制执行build: stage: build image: docker:stable services: - docker:dind before_script: - apk add --no-cache py-pip - pip install trivy script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG . - trivy image --severity CRITICAL,HIGH --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG扫描结果解读Trivy 报告中的CRITICAL漏洞如opensslCVE-2023-0286必须 100% 修复才能发布。我们曾因忽略一个HIGH级别的libxml2漏洞在上线后被安全团队勒令回滚——代价是损失 3 小时核心业务时间。4.3 Kubernetes 部署为什么HorizontalPodAutoscaler的指标必须基于requests_per_second很多团队用 CPU 利用率做 HPA 指标但 ML 服务的 CPU 使用率波动极大模型加载时 100%空闲时 0%导致 HPA 频繁扩缩容。正确的指标是请求速率# k8s/hpa.yaml apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: ml-model-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: ml-model minReplicas: 2 maxReplicas: 10 metrics: - type: Pods pods: metric: name: http_requests_total target: type: AverageValue averageValue: 50 # 每 Pod 每秒处理 50 请求 # 该指标由 Prometheus kube-state-metrics 提供压测验证用k6模拟 1000 并发用户k6 run --vus 1000 --duration 5m \ -e URLhttp://ml-model.prod.svc.cluster.local:8000/predict \ script.js观察 HPA 是否在 QPS 达到 400 时2 Pods × 200 QPS自动扩容至 4 Pods并在流量回落时平稳缩容。4.4 灰度发布为什么Argo Rollouts的 AnalysisTemplate 必须包含业务指标Kubernetes 原生 RollingUpdate 只能基于就绪探针无法判断业务质量。我们用 Argo Rollouts 的 AnalysisTemplate 监控# k8s/analysis-template.yaml apiVersion: argoproj.io/v1alpha1 kind: AnalysisTemplate metadata: name: ml-model-analysis spec: args: - name: service-name metrics: - name: error-rate successCondition: result[0].value 0.005 # 错误率 0.5% provider: prometheus: address: http://prometheus.monitoring.svc.cluster.local:9090 query: | rate(http_request_total{jobml-model, status~5..}[5m]) / rate(http_request_total{jobml-model}[5m]) - name: latency-p95 successCondition: result[0].value 0.2 # P95 200ms provider: prometheus: query: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))灰度流程新版本部署到 5% 流量Canary ServiceAnalysisTemplate 每 30 秒查询一次 Prometheus若连续 3 次error-rate或latency-p95不达标自动暂停并回滚达标后按 5%→20%→50%→100% 逐步放大实测数据该机制使线上事故平均发现时间从 17 分钟缩短至 92 秒且 98% 的问题在影响用户前被拦截。4.5 全链路监控为什么OpenTelemetry的 Span 必须标注模型版本没有上下文的延迟指标毫无意义。我们在预测函数中注入 OpenTelemetry Span# app.py from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter provider TracerProvider() processor BatchSpanProcessor(OTLPSpanExporter(endpointhttp://otel-collector:4318/v1/traces)) provider.add_span_processor(processor) trace.set_tracer_provider(provider) app.post(/predict) async def predict(payload: PredictionRequest): tracer trace.get_tracer(__name__) with tracer.start_as_current_span(ml.predict) as span: # 标注关键业务属性 span.set_attribute(model.version, get_model_version()) span.set_attribute(user.id, payload.user_id) span.set_attribute(input.size_bytes, len(payload.json().encode())) start_time time.time() result model.predict(preprocess_features(payload.dict())) latency time.time() - start_time span.set_attribute(inference.latency_ms, latency * 1000) return {score: result.item()}可观测性收益在 Jaeger 中可按model.versionv1.3.2过滤所有 Span查看该版本的 P99 延迟趋势或按user.idu_123追踪单次请求的完整链路从 API 网关 → 特征服务 → 模型服务精准定位瓶颈。5. 常见问题与排查技巧实录来自 127 次线上故障的实战总结5.1 故障速查表5 类高频问题的 3 分钟定位法问题现象快速定位命令根本原因解决方案服务启动后立即 Crashkubectl logs pod --previous模型加载失败CUDA 设备不匹配/路径错误检查model_loader.py中map_location和model_path环境变量/predict 返回 500 但无日志kubectl exec pod -- cat /proc/1/fd/1日志缓冲未刷新PYTHONUNBUFFERED 未设在 Dockerfile 中添加ENV PYTHONUNBUFFERED1P95 延迟突增 300%kubectl top podskubectl describe pod pod内存压力触发 OOMKill检查resources.limits.memory是否过低增加至2Gi/healthz 返回 503curl http://pod-ip:8000/healthz特征服务超时网络策略阻断检查 NetworkPolicy 是否允许ml-model访问feature-storenamespace预测结果与 notebook 不一致kubectl exec pod -- python -c import torch; print(torch.__version__)PyTorch 版本不一致notebook 用 1.12镜像用 1.13在requirements.txt中锁定torch1.12.1cpu5.2 独家避坑技巧那些文档不会写的“血泪经验”技巧 1用strace抓取模型加载时的文件系统调用当torch.load()卡住时strace -p pid -e traceopenat,read可看到它在反复尝试打开/usr/local/cuda/lib64/libcudnn.so.X。此时立刻检查LD_LIBRARY_PATH是否正确或改用torch.load(..., map_locationcpu)绕过。技巧 2/dev/shm空间不足导致多进程崩溃PyTorch DataLoader 在num_workers0时使用共享内存。K8s 默认/dev/shm只有 64MB而大模型特征处理需 512MB。解决方案在 Deployment 中添加volumeMounts: - name: dshm mountPath: /dev/shm volumes: - name: dshm emptyDir: medium: Memory sizeLimit: 1Gi技巧 3Gunicorn worker timeout 与模型加载时间的博弈Gunicorn 默认timeout30但大模型加载需 45 秒。若设timeout60则健康检查可能误判。正确解法用--preload参数让 worker 在启动时加载模型而非首次请求时CMD [gunicorn, --preload, --workers, 2, --timeout, 30, app:app]技巧 4特征漂移的“静默杀手”某次上线后 AUC 下降 0.02日志显示一切正常。最终发现上游数据管道将user_age字段从INT改为STRINGpreprocessor.py中int(raw_input[age])抛出异常但被try/except吞掉并返回默认值 0。解决方案在预处理器中添加类型断言assert isinstance(raw_input[age], (int, float)), fage must be numeric, got {type(raw_input[age])}技巧 5K8s Liveness Probe 的“假阳性”陷阱/healthz中检查特征服务连通性时若特征服务短暂不可用10 秒K8s 可能因failureThreshold3连续失败三次而重启 Pod。但重启后模型需重新加载形成恶性循环。解法在 health check 中对依赖服务添加指数退避重试最多 2 次间隔 1 秒并将失败计入指标而非直接返回 503。5.3 真实故障复盘一次“完美部署”背后的 3 小时惊魂时间2023-10-15 02:17双十一大促前 48 小时现象新版本模型服务在灰度 5% 流量后error_count持续上升但/healthz仍返回 200。排查过程Step 1kubectl logs发现大量KeyError: user_profile但预处理器代码中无此字段。Step 2kubectl exec进入 Pod手动运行python -c import pandas as pd; print(pd.__version__)输出1.5.3镜像中版本而 notebook 用1.3.5。Step 3查requirements.txt发现pandas1.3.0未锁定版本。pip install在构建时安装了 1.5.3。Step 4pandas 1.5.3中df.to_dict(orientrecords)对空 DataFrame 返回[{}]而1.3.5返回[]导致下游特征计算索引越界。根因依赖版本未锁定 缺乏跨版本兼容性测试。改进措施requirements.txt中所有包改为精确版本pandas1.