Notebook到生产环境的机器学习交付实战:四层解耦与12步上线清单

Notebook到生产环境的机器学习交付实战:四层解耦与12步上线清单 1. 项目概述这不是一次模型训练而是一场工程交付“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被太多人轻描淡写、却让无数团队在临门一脚时彻底卡死的真相Notebook 是思考的草稿纸Production 是交付的合同书。它不讲怎么调参、不教怎么画 loss 曲线它直指那个没人愿意多说但每天都在吞噬工程师时间的核心问题当你在 Jupyter 里跑通了 accuracy 92.3% 的模型下一步该把这串代码交给谁用什么方式交交过去之后它会不会在凌晨三点因为一条脏数据崩掉而你手机没响、告警没触发、业务方已经打电话来问“为什么推荐页全黑了”我做过 7 个从零到上线的机器学习服务其中 4 个在模型准确率达标后花了比训练周期长 2.3 倍的时间才真正稳定跑进生产环境。Part 4 这个编号很关键——它不是入门篇不是原理篇而是压轴的“交付实战篇”。它默认你已掌握模型开发Part 1、特征工程落地Part 2、模型监控基线Part 3现在要解决的是如何让一个“能跑”的模型变成一个“敢签 SLA”的服务。核心关键词“Notebook to Production”背后实际覆盖三个不可妥协的硬性要求可复现性Reproducibility——今天在你本地跑的结果和三个月后运维同事在 k8s 集群里拉起的镜像结果必须完全一致可观测性Observability——不是只看 CPU 和内存而是要实时知道特征分布是否漂移、预测置信度是否集体下滑、某类样本的延迟是否异常升高可演进性Maintainability——当业务方下周突然要求增加“用户最近 30 分钟行为加权”你能不能在不重启服务、不影响线上流量的前提下完成热更新这三个词就是 Part 4 的全部分量。它适合两类人一类是刚把模型跑通、正对着部署文档发愁的算法工程师另一类是被算法同学反复喊“再给我两天就能上线”、但已经等了三周的后端或 SRE 同事。这篇文章就是给你们共同写的交接清单。2. 整体设计思路为什么放弃“一键部署”选择“分层解耦”很多团队在 Part 4 阶段会本能地走向两个极端要么用 MLflow 或 Kubeflow 搞一套“全自动流水线”结果半年过去 pipeline 跑得比模型还复杂出了问题连日志都找不到在哪要么干脆手写 Flask API Gunicorn模型 load 一次、全局变量存着美其名曰“轻量”实则成了线上最脆弱的单点故障。这两种方案本质上都错在试图用“一个工具”解决“三层矛盾”开发态与运行态的矛盾、模型逻辑与基础设施的矛盾、快速迭代与系统稳定的矛盾。我们最终采用的方案是“四层解耦架构”它不是炫技而是从血泪教训里长出来的第一层Notebook → Script可执行脚本化不是简单把 .ipynb 导出为 .py而是重构整个代码结构把数据加载、预处理、模型加载、推理封装成独立函数每个函数有明确输入输出契约例如def predict(user_id: str, item_ids: List[str]) - Dict[str, float]并强制添加类型注解和 docstring。我试过直接导出的脚本里面混着plt.show()、df.head()、%timeit这类调试代码上线前漏删一行服务就卡死在 matplotlib 后端初始化上。这一层的目标只有一个让模型代码脱离 Jupyter 环境后仍能通过python model_inference.py --user_id123 --item_ids456,789这种命令行方式干净运行。第二层Script → Container容器标准化用 Dockerfile 显式声明所有依赖Python 版本、PyTorch 版本、CUDA 版本、甚至pip install的源地址国内必须指定清华源否则 CI/CD 流水线会因网络超时失败。关键细节在于模型权重文件不打包进镜像而是通过挂载 volume 或对象存储 URL 加载。原因很现实——一个 BERT 微调模型权重动辄 1.2GB每次模型微调都重打镜像镜像仓库会迅速膨胀到 TB 级且版本回滚成本极高。我们约定镜像只含代码和轻量依赖模型权重存 OSS启动时由容器内脚本下载到/model/weights/目录。这样同一镜像可服务多个模型版本只需改一个环境变量MODEL_VERSIONv2.1.3。第三层Container → Service服务化抽象不直接暴露容器端口而是通过标准 Web API 封装。我们坚持使用 FastAPI而非 Flask核心理由有三一是自动生成 OpenAPI 文档前端和测试同学不用再猜接口格式二是原生支持异步当模型推理涉及外部 HTTP 调用如查用户画像服务时不会阻塞整个 worker三是依赖注入机制让单元测试极其简单——你可以 mock 掉get_model()函数完全不加载真实模型就能测接口逻辑。这里有个反直觉但极重要的经验API 的输入输出 Schema 必须比模型本身更严格。比如模型能接受空字符串作为 user_id但 API 层必须校验user_id: str Field(..., min_length1)把脏数据拦截在网关外避免模型内部做防御性编程。第四层Service → Platform平台级治理这一层才是 Part 4 的真正分水岭。它不关心模型长什么样只关心“这个服务该怎么管”。我们接入公司统一的 Service Mesh基于 Istio所有流量走 sidecar自动获得熔断、限流、链路追踪能力日志统一采集到 Loki用 LogQL 查“{servicerecsys-model} |~ OOM”指标上报 Prometheus关键指标包括model_inference_latency_seconds_bucketP95 延迟、model_prediction_count_total每分钟预测数、feature_drift_score特征漂移分数。这一层的意义在于当算法同学说“模型效果变差了”运维同学不再需要登录服务器翻日志而是直接打开 Grafana 看漂移热力图5 分钟定位到是“用户城市字段缺失率从 0.2% 飙升至 18%”。这个四层设计本质是把“模型交付”拆解为四个可独立验证、可并行推进、可分别追责的环节。开发同学专注第一、二层SRE 同学把控第三、四层双方在 API Schema 和 SLA 指标上对齐而不是在部署脚本里互相改参数。它牺牲了一点“一键”的爽感换来了 90% 的线上问题可归因、可复现、可回滚。3. 核心细节解析从 Notebook 到容器的 7 个致命陷阱与避坑指南把 Notebook 代码搬进生产容器表面看只是jupyter nbconvert --to python再docker build的事但实际踩过的坑足够写一本《生产环境生存手册》。以下是我在 7 个项目中反复验证、必须逐条检查的 7 个核心细节每一个都曾导致线上服务不可用超过 2 小时。3.1 陷阱一随机种子的“伪确定性”现象本地 Notebook 里设置torch.manual_seed(42)模型预测结果完全一致但容器里每次启动结果都不同。原因PyTorch 的随机性受多个因素影响manual_seed只控制 torch不控制 numpy、python 内置 random、CUDA 的 cuDNN 卷积算法。更隐蔽的是cuDNN 在不同 GPU 架构如 V100 vs A100上即使 seed 相同也可能因底层优化路径不同产生微小差异。解决方案必须显式冻结所有随机源。我们在inference.py开头强制写入import torch import numpy as np import random import os def set_deterministic(seed: int 42): torch.manual_seed(seed) np.random.seed(seed) random.seed(seed) os.environ[PYTHONHASHSEED] str(seed) # 关键禁用 cuDNN 的非确定性算法 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False提示torch.backends.cudnn.benchmark False这行常被忽略。开启 benchmark 会让 cuDNN 在首次运行时耗时搜索最优卷积算法但该算法选择可能受输入 shape 影响导致相同 seed 下不同 batch size 产生不同结果。生产环境必须关闭。3.2 陷阱二绝对路径的“本地幻觉”现象Notebook 里pd.read_csv(/data/raw/user_features.csv)本地跑得好好的容器里报FileNotFoundError。原因Notebook 的工作目录是/home/jovyan/work而容器启动时默认工作目录是/app且/data目录根本不存在。更糟的是有些同学会把路径写成../data/...在本地相对路径能 work但容器里目录结构完全不同。解决方案所有路径必须动态生成且基于__file__定位。我们定义统一的资源管理器from pathlib import Path class ResourceManager: def __init__(self): self.root Path(__file__).parent.parent # 指向项目根目录 def get_data_path(self, *parts) - Path: return self.root / data / raw / Path(*parts) def get_model_path(self) - Path: return self.root / models / os.getenv(MODEL_VERSION, v1.0.0) # 使用时 rm ResourceManager() df pd.read_csv(rm.get_data_path(user_features.csv))注意Path(*parts)支持传入user, features.csv自动拼接为user/features.csv避免手动拼接/的跨平台问题。3.3 陷阱三模型加载的“内存黑洞”现象容器启动后内存占用飙升至 8GB远超模型权重文件大小仅 1.2GB且持续不释放。原因PyTorch 默认将模型加载到 CPU 内存然后.to(device)时才拷贝到 GPU。如果模型很大CPU 内存会先 hold 住完整副本再复制一份到 GPU瞬间双倍内存占用。更危险的是如果代码里写了model torch.load(model.pth)而没有指定map_location在无 GPU 的 CI 环境里会直接报错。解决方案加载时即指定设备且用torch.load(..., map_locationdevice)。我们封装加载函数def load_model(model_path: str, device: torch.device) - nn.Module: # 先加载到 CPU避免 GPU 内存不足时崩溃 state_dict torch.load(model_path, map_locationcpu) model MyModel() # 实例化空模型 model.load_state_dict(state_dict) return model.to(device) # 启动时 device torch.device(cuda if torch.cuda.is_available() else cpu) model load_model(/model/weights/best.pth, device)实测心得对于 500MB 的模型此方法可降低启动峰值内存 40% 以上。且map_locationcpu保证了在 CI 测试阶段也能顺利加载。3.4 陷阱四日志的“静默死亡”现象服务运行中无任何报错但请求全部超时docker logs却空空如也。原因Jupyter 默认的日志级别是INFO且输出到 stdout但容器化后如果没配置日志处理器print()和logging.info()可能被缓冲、被截断或直接丢弃。更常见的是模型推理函数里try...except捕获了异常但只pass错误被彻底吞掉。解决方案强制重定向所有日志到标准输出并设置明确级别。我们在main.py中初始化import logging import sys # 清除所有已有 handler for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) # 强制输出到 stdout格式化为 JSON便于日志系统解析 logging.basicConfig( levellogging.INFO, format{time: %(asctime)s, level: %(levelname)s, message: %(message)s}, handlers[logging.StreamHandler(sys.stdout)] ) logger logging.getLogger(__name__)关键技巧format用 JSON 字符串Loki 或 ELK 可直接解析为结构化字段level: ERROR这样的字段让告警规则编写变得极其简单。3.5 陷阱五依赖版本的“蝴蝶效应”现象本地pip list显示scikit-learn1.2.2容器里pip list却是1.3.0导致OneHotEncoder行为不一致。原因requirements.txt如果只写scikit-learn不写版本pip install会安装最新版更隐蔽的是某些包如xgboost的 wheel 包会隐式依赖特定版本的numpy而numpy的 patch 版本升级如1.24.2→1.24.3可能引发 ABI 不兼容。解决方案必须使用pip freeze requirements.txt生成锁定文件并在 Dockerfile 中用pip install --no-cache-dir -r requirements.txt。但还不够我们额外增加校验步骤# Dockerfile 片段 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt \ # 校验关键包版本是否精确匹配 pip list --outdated | grep -q scikit-learn\|torch\|numpy \ echo ERROR: Outdated packages detected! exit 1 || true经验--no-cache-dir防止 pip 缓存污染镜像层|| true是为了不因 grep 无匹配而中断构建grep 找不到时返回 1。3.6 陷阱六环境变量的“隐形依赖”现象Notebook 里os.getenv(MODEL_PATH, /local/model.pth)本地正常容器里因未传MODEL_PATH环境变量降级到/local/...路径报错。原因Notebook 开发时习惯性用os.getenv加默认值但生产环境必须显式传入不能依赖默认值。默认值在本地是便利在生产是隐患。解决方案所有关键环境变量必须强制存在缺失则启动失败。我们用 Pydantic BaseSettings 做强校验from pydantic import BaseSettings class Settings(BaseSettings): MODEL_VERSION: str MODEL_BUCKET: str # OSS bucket 名 REDIS_URL: str # 必须提供否则 Settings() 初始化时抛 ValidationError class Config: env_file .env settings Settings()提示.env文件只用于本地开发CI/CD 中通过平台 Secret 注入环境变量BaseSettings会自动从环境读取无需修改代码。3.7 陷阱七健康检查的“假阳性”现象Kubernetes 的 liveness probe 返回 200但服务实际已卡死无法处理新请求。原因probe 接口只检查return {status: ok}没验证模型是否真能推理。当模型加载失败但服务进程仍在probe 依然成功。解决方案liveness probe 必须包含轻量级推理验证。我们新增/healthz/live接口app.get(/healthz/live) def live_check(): # 用预设的最小样本做一次快速推理超时 1 秒则失败 try: result model.predict([test_user], [test_item]) if not isinstance(result, dict) or len(result) 0: raise ValueError(Empty prediction result) return {status: ok, latency_ms: int((time.time() - start) * 1000)} except Exception as e: logger.error(fLive check failed: {e}) raise HTTPException(status_code503, detailModel inference failed)实操心得样本必须是预存的、不触发外部依赖的如不查数据库且 timeout 设为 1000ms确保 probe 自身不成为瓶颈。我们用curl -I http://localhost:8000/healthz/live在容器启动后 5 秒内自动触发失败则立即重启。4. 实操全流程从本地 Notebook 到 Kubernetes 集群的 12 步交付清单以下是我们团队标准化的交付流程每一步都有明确负责人、验收标准和失败回滚方案。它不是理论流程而是贴着键盘敲出来的操作清单。整个过程平均耗时 3.2 人日最快记录是 1.5 人日前提前期已做好四层解耦设计。4.1 Step 1Notebook 清洁与契约定义负责人算法工程师操作删除所有%matplotlib inline、%debug、%%time等 magic command将所有df.head()、print(model)替换为logger.info(fData shape: {df.shape})定义清晰的InputSchema和OutputSchemaPydantic 模型。验收标准运行python -m py_compile model_inference.py无语法错误mypy model_inference.py类型检查通过。失败回滚若类型检查失败退回修改类型注解不进入下一步。4.2 Step 2脚本化重构负责人算法工程师操作将 notebook 拆分为preprocess.py特征工程、model.py模型定义、inference.py推理入口inference.py必须有if __name__ __main__:块支持命令行调用。验收标准在本地虚拟环境中执行python inference.py --user_id123 --item_ids456,789输出 JSON 结果且与 notebook 最终输出一致用jsondiff工具比对。失败回滚若输出不一致用git bisect定位哪次重构引入偏差。4.3 Step 3Dockerfile 编写与本地构建负责人算法工程师 DevOps操作编写Dockerfile基础镜像选nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04COPY仅复制必要文件inference.py,requirements.txt,pyproject.tomlCMD [uvicorn, main:app, --host, 0.0.0.0:8000]。验收标准docker build -t recsys-model:v1 .成功docker run --rm -p 8000:8000 recsys-model:v1启动后curl http://localhost:8000/healthz/live返回 200。失败回滚构建失败则检查requirements.txt版本冲突用pipdeptree分析依赖树。4.4 Step 4模型权重分离与 OSS 上传负责人算法工程师操作将best.pth上传至 OSS bucketml-models/recsys/v1.0.0/在容器启动脚本entrypoint.sh中添加aws s3 cp s3://ml-models/recsys/${MODEL_VERSION}/best.pth /model/weights/。验收标准容器启动日志中出现Downloaded model weights from s3://ml-models/...ls -lh /model/weights/显示文件存在且大小正确。失败回滚OSS 权限错误则检查 IAM Role 策略下载超时则增加--retries 5参数。4.5 Step 5FastAPI 接口开发与单元测试负责人后端工程师操作编写main.py定义/predictPOST 接口输入为PredictRequest输出为PredictResponse用pytest写测试mockmodel.predict()验证输入校验、异常处理。验收标准pytest tests/test_api.py -v全部通过openapi.json自动生成/docs页面可交互测试。失败回滚测试失败则修复 schema 或异常处理逻辑不提交代码。4.6 Step 6CI/CD 流水线配置负责人DevOps操作在 GitLab CI 中配置.gitlab-ci.yml包含test运行 pytest、builddocker build push、scantrivy 扫描镜像漏洞阶段build阶段 tag 为git commit short hash。验收标准Push 代码后流水线 10 分钟内完成镜像推送到 Harbortag 为a1b2c3dtrivy image recsys-model:a1b2c3d报告无 CRITICAL 漏洞。失败回滚扫描失败则升级基础镜像或修复漏洞包不发布。4.7 Step 7Kubernetes Deployment 编写负责人SRE操作编写deployment.yamlreplicas: 2resources.requests设为cpu: 1, memory: 4GilivenessProbe指向/healthz/liveinitialDelaySeconds: 30env从 Secret 读取MODEL_VERSION。验收标准kubectl apply -f deployment.yaml后kubectl get pods显示2/2 Runningkubectl logs pod-name有模型加载成功日志。失败回滚Pod CrashLoopBackOff 则kubectl describe pod查事件常见原因是 Secret 未创建或 OSS 权限不足。4.8 Step 8Service 与 Ingress 配置负责人SRE操作编写service.yamlClusterIPingress.yaml路由到/api/predictIngress annotation 添加nginx.ingress.kubernetes.io/proxy-body-size: 10m支持大请求体。验收标准curl -H Host: recsys.example.com http://ingress-ip/api/predict返回 200kubectl get ingress显示 ADDRESS 已分配。失败回滚Ingress 503 错误则检查 Service selector 是否匹配 Pod label。4.9 Step 9Prometheus 指标埋点负责人SRE 算法工程师操作在inference.py中集成prometheus_client定义model_inference_latency_secondsHistogram每次predict()调用后histogram.observe(latency)在 FastAPI 中添加/metrics路由。验收标准curl http://pod-ip:8000/metrics返回文本格式指标Grafana 中rate(model_inference_latency_seconds_count[5m])有数据。失败回滚指标无数据则检查Histogram的labelnames是否与查询语句一致。4.10 Step 10特征漂移监控接入负责人算法工程师操作在preprocess.py中对每个数值特征计算ks_2samp与基准分布的 p-value当 p-value 0.01 时logger.warning(fFeature drift detected: {feature_name}, p{p_value})该日志被 Loki 采集告警规则匹配|~ Feature drift。验收标准模拟脏数据如将age全设为 0触发告警Grafana 中count_over_time({jobrecsys-model} |~ Feature drift[1h]) 0。失败回滚告警不触发则检查日志级别是否为WARNINGLoki 查询语法是否正确。4.11 Step 11灰度发布与流量切分负责人SRE操作用 Istio VirtualService 配置 5% 流量到新版本新版本 Deployment 添加 labelversion: v2旧版本 labelversion: v1。验收标准kubectl get virtualservice recsys-vs -o yaml显示weight: 5New Relic 中 v2 版本的 error rate 0.1%latency P95 200ms。失败回滚若 error rate 0.5%立即kubectl edit virtualservice将 weight 改为 010 分钟内回滚。4.12 Step 12SLA 文档签署与交接负责人算法工程师 SRE 产品经理操作填写《ML 服务 SLA 协议》明确P95 延迟 ≤ 300ms、可用性 ≥ 99.95%、模型效果衰减告警阈值AUC 下降 0.02 触发、紧急回滚 SOP3 分钟内完成。验收标准三方签字确认协议存入 Confluence链接嵌入服务 Dashboard。失败回滚若 SLA 条款无法达成则暂停上线重新评估模型或基础设施。这个 12 步清单每一步都对应一个真实的工单编号和故障案例。它不追求“快”而追求“稳”——因为在线上一次失败的部署代价远高于多花一天做足准备。5. 常见问题与排查技巧实录那些凌晨三点教会我的事Part 4 的交付90% 的时间花在解决“意料之外但情理之中”的问题上。以下是我在凌晨三点、咖啡凉透时从日志、监控、火焰图里亲手挖出的 5 个高频问题附带可直接复用的排查命令和修复方案。它们不是教科书答案而是血的教训。5.1 问题P95 延迟突增 300%但 CPU 和内存一切正常现象Grafana 中model_inference_latency_seconds_bucket{le0.3}突降至 40%而container_cpu_usage_seconds_total和container_memory_usage_bytes平稳。排查思路延迟高但资源不涨大概率是 I/O 等待或锁竞争。首先排除网络——kubectl exec -it pod -- sh -c curl -w time_namelookup:%{time_namelookup}\ntime_connect:%{time_connect}\ntime_starttransfer:%{time_starttransfer}\n -o /dev/null -s http://redis:6379测 Redis 连接各阶段耗时。定位发现time_starttransfer高达 1.2s说明 Redis 响应慢。进一步kubectl exec -it redis-pod -- redis-cli --latency显示平均延迟 800ms。根因Redis 配置maxmemory-policy为noeviction内存满后拒绝写入但我们的代码没处理redis.exceptions.ConnectionError陷入无限重试。修复Redis 配置改为allkeys-lru代码中增加重试上限retry(stopstop_after_attempt(3))监控增加redis_connected_clients指标告警。独家技巧在 FastAPI middleware 中记录time.time()到request.state.start_time在响应后计算time.time() - request.state.start_time这样能精准区分是模型推理慢还是上游依赖慢。5.2 问题模型预测结果每天凌晨 4 点批量异常现象model_prediction_count_total{resulterror}每天 04:00 出现尖峰持续 15 分钟错误日志为ValueError: Input contains NaN。排查思路NaN 不是模型问题是输入数据问题。查数据管道调度——发现特征工程任务每天 03:55 调度但有时因资源争抢延迟到 04:02 才完成而模型服务在 04:00 就开始读取新特征表此时表是空的或部分写入。定位kubectl logs feature-job-pod --since1h | grep Finished writing确认完成时间kubectl exec -it model-pod -- ls -lt /data/features/查文件修改时间。根因特征表更新缺乏原子性模型服务读到了半成品。修复特征任务完成时touch /data/features/.ready_v20240501模型服务启动时检查.ready文件存在才加载增加监控count(count by (job) (count_over_time({jobfeature-job} |~ Finished writing[1d]))) 1告警。独家技巧用fuser -v /data/features/在模型 pod 中检查文件是否被其他进程占用可快速判断是否正在写入。5.3 问题GPU 显存缓慢泄漏72 小时后 OOM Kill现象nvidia-smi显示Used显存从 2.1GB 每小时增长 50MB3 天后达 8GB触发 OOM。排查思路PyTorch 显存泄漏经典套路——tensor 未 detach、grad 未清空、cache 未释放。用torch.cuda.memory_summary()在每次推理后打印。定位发现allocated bytes稳定但reserved bytes持续增长指向 CUDA cache。进一步torch.cuda.empty_cache()后reserved不下降说明是底层驱动或库问题。根因使用的transformers4.28.0存在已知 bug在pipeline模式下缓存未释放。升级到4.35.0修复。修复pip install transformers4.35.0在Dockerfile中固定版本增加监控nvidia_gpu_duty_cycle 95% 持续 5 分钟告警。独家技巧用nvidia-smi --query-compute-appspid,used_memory --formatcsv,noheader,nounits实时抓取每个进程显存占用比nvidia-smi交互式界面更精准。5.4 问题Kubernetes Horizontal Pod AutoscalerHPA不扩缩现象kubectl get hpa显示TARGETS为unknown/80%REPLICAS始终为 2。排查思路HPA 依赖 Metrics Server先查其状态kubectl get apiservice v1beta1.metrics.k8s.io应为True。定位kubectl get apiservice v1beta1.metrics.k8s.io -o yaml发现status.conditions中reason: FailedDiscoveryCheck。进一步kubectl logs -n kube-system $(kubectl get pods -n kube-system | grep metrics-server | awk {print $1})显示x509: certificate signed by unknown authority。根因Metrics Server TLS 证书过期或自签名证书未被 API Server 信任。修复kubectl delete apiservice v1beta1.metrics.k8s.io重新部署 Metrics Server官方 Helm chart 会自动生成新证书等待 2 分钟kubectl get hpa显示TARGETS正常。独家技巧用kubectl top pods测试 Metrics Server 是否工作若返回error: Metrics not available则证明未就绪。5.5 问题模型效果突然下降但所有监控指标绿灯现象业务方反馈推荐点击率下降 15%但model_inference_latency、model_prediction_count、feature_drift_score全部正常。排查思路效果下降 ≠ 模型问题可能是数据、特征、业务逻辑问题。先查特征分布——用pandas-profiling对线上请求样本做 EDA。定位发现user_age字段的mean从 32.1 降到 28.4但drift_score未报警因为 KS 检验对均值偏移不敏感。进一步查数据源发现上游用户表增加了“Z 世代”标签但特征工程 SQL 未适配age字段被错误赋值为NULL而模型用0填充导致大量年轻用户被误判为 0 岁。根因特征工程逻辑未随上游数据 schema 变更而更新且填充策略不合理。修复SQL 中增加COALESCE(age, FLOOR(AVG(age) OVER()))监控增加 count(count_over_time({jobrecsys-model} |~ Filled