
1. 为什么我要从零手搓一套AI工程化流程第一次看到ai-engineering-from-scratch这个标题我脑子里蹦出来的不是某个具体项目而是一种状态手上有模型、有数据、有想法但把它们串成一条能跑、能复现、能交付的流水线时处处卡壳。模型在 notebook 里跑得挺好一换机器就崩数据版本对不上训练结果没法复现推理服务上线后延迟忽高忽低排查半天发现是预处理拖了后腿。这些问题不是调参能解决的它们属于工程化的范畴。所谓 AI 工程化说白了就是把“能跑一次的代码”变成“能稳定跑一万次、别人也能接手跑”的系统。它覆盖数据管道、实验管理、模型训练、评估、打包、部署、监控这一整条链路。ai-engineering-from-scratch这个主题的价值就在于它不依赖某个大厂的重型平台而是从最朴素的工具出发一层层把这条链路搭起来让你真正理解每个环节在干什么、为什么这么干。这套东西适合谁如果你是会写 Python、懂一点机器学习、但每次把模型交给别人或部署到线上就心里没底的人那这篇内容就是写给你的。我会按我自己实际搭过的一套流程来讲从目录结构怎么定、数据怎么管、实验怎么记到服务怎么起、监控看什么每一步都给出可抄的配置和踩过的坑。全程不依赖任何需要特殊网络环境才能用的工具全部是公开、可本地运行的方案。2. 整体架构设计与技术选型思路2.1 从“脚本思维”切换到“流水线思维”大多数人起步时都是一个train.py加一个predict.py数据路径写死超参数写在代码里模型存成model_final_v2_real.pkl。这种模式在个人探索阶段没问题但一旦要对比不同实验、要回滚、要多人协作就会迅速失控。我踩过最典型的一个坑两周后想复现一个效果不错的模型结果发现当时用的数据是手动改过几行的版本代码里也没记录直接白干。流水线思维的核心是把每个环节变成有明确输入输出的独立阶段阶段之间通过约定好的产物artifact连接。数据阶段产出带版本号的数据集训练阶段消费数据集产出模型和指标评估阶段消费模型产出报告部署阶段消费模型产出服务。每个产物都可追溯每个阶段都可单独重跑。这样做的直接好处是出问题时你能快速定位是哪个阶段变了而不是对着一坨脚本干瞪眼。2.2 工具选型够用、可替换、不绑架我在选型上有一条硬原则每个环节的工具都要能被替换掉不能出现“换掉它整个流程就瘫了”的情况。基于这个原则我的选择是这样的。环节选用工具选它的理由可替换方案环境与依赖conda requirements.txt隔离干净锁版本方便venv pip-tools数据版本DVC和 Git 配合好大文件不进仓库LakeFS、纯文件哈希实验记录MLflow本地模式自带 UIAPI 简单TensorBoard 手写日志配置管理Hydra / YAML分层配置命令行可覆盖纯 YAML argparse模型打包ONNX / TorchScript脱离训练框架依赖直接 pickle不推荐服务FastAPI Uvicorn轻、异步、文档自动生成Flask、BentoML监控Prometheus Grafana生态成熟指标标准自建日志统计这里重点说几个选型背后的考量。为什么数据用 DVC 而不是直接塞 Git因为 Git 对大文件的支持很差一个几百 MB 的数据集提交几次仓库就废了。DVC 的做法是仓库里只存一个指向实际文件的元数据指针真正的数据放在本地或对象存储版本切换时它帮你把对应文件拉回来。为什么实验记录用 MLflow 本地模式因为它不需要起服务器mlflow.log_param和mlflow.log_metric直接写本地文件想看结果时mlflow ui起个页面就行零运维成本。注意选型阶段最忌讳“一步到位上重型平台”。我见过太多团队一上来就搭一套复杂的调度系统结果业务还没跑通光维护平台就耗掉大半精力。先用最轻的工具把流程跑通等真的遇到瓶颈再升级这个顺序不能反。2.3 目录结构让新人三分钟看懂目录结构是工程化的门面。一个清晰的仓库结构能让接手的人三分钟知道东西在哪。我用的结构是这样的ai-engineering-from-scratch/ ├── configs/ # 所有配置按环境分 │ ├── base.yaml │ ├── dev.yaml │ └── prod.yaml ├── data/ │ ├── raw/ # 原始数据只读 │ ├── interim/ # 中间产物 │ └── processed/ # 训练用数据 ├── src/ │ ├── data/ # 数据加载与清洗 │ ├── features/ # 特征工程 │ ├── models/ # 模型定义 │ ├── train.py │ ├── evaluate.py │ └── serve.py ├── tests/ # 单元测试 ├── notebooks/ # 探索用不参与生产 ├── models/ # 训练产物 ├── reports/ # 评估报告 ├── dvc.yaml ├── requirements.txt └── README.md关键点是data/raw设为只读任何清洗都往interim和processed写。这样原始数据永远干净出问题可以随时从头再来。notebooks目录明确标注不参与生产避免有人把探索代码直接搬上线。configs按环境分层base.yaml放通用配置dev.yaml和prod.yaml只覆盖差异项避免配置重复。3. 核心环节拆解与实操要点3.1 数据管道可复现是第一要务数据管道的目标只有一个给定一个版本号能精确还原出当时训练用的数据。我用 DVC 来管这件事流程是这样的。先把原始数据放进data/raw然后执行dvc add data/raw/dataset.csvDVC 会生成一个dataset.csv.dvc文件这个文件很小可以进 Git。真正的数据文件被 DVC 缓存起来。当数据更新时重新dvc add版本号就变了Git 里记录的是新版本的指针。数据清洗和特征工程我写成独立的脚本放在src/data和src/features每个脚本的输入输出路径都从配置读不写死。这样同一个脚本在不同环境跑只要配置不同产出的数据就落到不同位置。清洗脚本里我会强制加一步数据校验比如检查缺失率、检查类别分布是否异常校验不通过直接报错退出而不是让脏数据流到下游。# src/data/validate.py 的核心逻辑 import pandas as pd def validate(df, config): missing_rate df.isnull().mean() bad_cols missing_rate[missing_rate config[max_missing_rate]] if len(bad_cols) 0: raise ValueError(f缺失率超标: {bad_cols.to_dict()}) for col, expected in config[expected_categories].items(): actual set(df[col].unique()) if not actual.issubset(set(expected)): raise ValueError(f{col} 出现未知类别: {actual - set(expected)}) return True实操心得数据校验这一步千万别省。我遇到过训练集里混进测试集样本的情况模型指标虚高得离谱上线后直接打脸。后来加了样本 ID 去重校验这类问题再没出现过。3.2 实验管理让每次训练都有据可查实验管理的核心是回答三个问题这次训练用了什么配置、产出了什么指标、模型存在哪。我用 MLflow 来记录。每次训练开始时创建一个 run把超参数、数据版本、代码 commit 都 log 进去训练过程中 log 指标结束时把模型作为 artifact 存起来。import mlflow def train(config): mlflow.set_experiment(config[experiment_name]) with mlflow.start_run(): mlflow.log_params(config[hyperparams]) mlflow.log_param(data_version, config[data_version]) mlflow.log_param(git_commit, get_git_commit()) model, metrics run_training(config) mlflow.log_metrics(metrics) mlflow.sklearn.log_model(model, model)这里有个细节值得说git_commit一定要记。有一次我发现两个 run 指标差异很大但配置完全一样最后靠 commit 号定位到是中间改了一行特征处理逻辑忘了提交说明。没有 commit 记录这种问题根本查不出来。配置管理我用 Hydra它支持配置继承和命令行覆盖。base.yaml定义默认值dev.yaml覆盖开发环境特有的项运行时用python train.py --config-name dev就能加载对应配置。命令行还能临时覆盖比如python train.py model.lr0.001方便快速试参。3.3 模型打包别让训练框架绑架部署模型打包最常见的错误是直接把训练时的 pickle 文件丢给服务端。问题是 pickle 依赖训练时的类定义和库版本服务端环境稍有不同就加载失败。我的做法是导出成 ONNX 或 TorchScript这两种格式只依赖运行时不依赖训练代码。以 PyTorch 为例导出 TorchScript 的代码很简单import torch model.eval() example_input torch.randn(1, config[input_dim]) traced torch.jit.trace(model, example_input) traced.save(models/model.pt)导出后一定要做一致性校验用同一批输入分别跑原模型和导出模型对比输出差异。差异超过阈值就说明导出有问题通常是模型里有不支持的操作。我遇到过自定义激活函数导出后结果不对的情况后来把它拆成基础算子组合才解决。注意导出模型时要把预处理逻辑一起考虑进去。如果预处理在 Python 里做服务端也得复制一份容易不一致。更好的做法是把能放进模型的计算都放进模型让模型接收原始输入输出最终结果。3.4 服务部署延迟和稳定性的平衡服务用 FastAPI 起核心是把模型加载放在启动时而不是每次请求时。模型加载一次常驻内存请求进来直接推理。下面是一个最小可用的服务骨架from fastapi import FastAPI import torch app FastAPI() model None app.on_event(startup) def load_model(): global model model torch.jit.load(models/model.pt) model.eval() app.post(/predict) def predict(payload: dict): tensor preprocess(payload) with torch.no_grad(): output model(tensor) return {result: postprocess(output)}启动命令用uvicorn serve:app --host 0.0.0.0 --port 8000 --workers 2。workers 数量根据 CPU 核数和模型大小调一般设成核数的一半到核数之间。设太多反而因为进程切换导致延迟上升。服务上线前必须做压测。我用locust或简单的并发脚本测 QPS 和 P99 延迟。有一次压测发现 P99 延迟是平均延迟的十倍排查发现是某个请求触发了内存回收后来通过预热和限制单请求数据量解决。4. 常见问题与排查技巧实录4.1 训练结果无法复现怎么办这是最高频的问题。排查顺序我总结成一张表排查项检查方法常见原因随机种子检查是否设置并固定忘了设 seed或只设了部分库的 seed数据版本对比 DVC 版本号数据被手动改过依赖版本对比 requirements 锁文件库升级导致行为变化硬件差异对比 GPU 型号和驱动浮点运算顺序不同代码版本对比 git commit有未提交的本地修改随机种子要设全Python 的random、NumPy 的np.random、框架自己的 seed 都要设。GPU 上的非确定性操作可以通过设置环境变量强制确定性但会牺牲一点性能训练阶段建议开启推理阶段可以关掉。4.2 服务延迟突然升高怎么查延迟问题我一般按这个顺序查先看是不是流量突增对比 QPS 和延迟曲线再看是不是某个特定输入导致的抽样慢请求的输入特征然后看资源CPU、内存、GPU 利用率最后看依赖是不是下游服务或数据库慢了。有一次线上延迟从 50ms 涨到 500ms查了半天发现是日志级别被改成了 DEBUG每个请求写大量日志拖慢了 IO。这种问题不看资源监控根本发现不了。所以监控里一定要包含磁盘 IO 和日志量指标。4.3 模型效果线上衰减怎么定位线上效果衰减通常有两个原因数据分布变了或者评估口径不一致。数据分布变化可以用 PSI群体稳定性指标来监控定期对比线上输入分布和训练分布。评估口径不一致更隐蔽比如线下用 AUC线上用点击率两者本来就不完全对应。我的做法是在服务里加一个采样日志记录输入特征和预测结果定期用离线评估脚本跑一遍和线上指标对比。如果离线指标正常但线上指标降了那问题多半在业务逻辑或评估口径不在模型本身。实操心得监控指标不要贪多先盯住四个请求量、延迟、错误率、输入分布偏移。这四个能覆盖大部分线上问题。等这套跑顺了再逐步加细。5. 从零搭建的完整实操流程5.1 环境初始化与依赖锁定第一步是把环境搭干净。我用 conda 建一个独立环境Python 版本选 3.10兼容性好。建完环境后装依赖装完立刻用pip freeze requirements.txt锁版本。这一步的关键是所有依赖都锁死包括间接依赖避免下次装出不同版本。conda create -n aieng python3.10 -y conda activate aieng pip install -r requirements.txt pip freeze requirements.lock之后所有环境都用requirements.lock装保证一致。如果某个库需要特定版本在requirements.txt里写死不要用。5.2 数据接入与版本登记把原始数据放进data/raw执行dvc init初始化 DVC然后dvc add data/raw。DVC 会生成.dvc文件把它和.gitignore一起提交。之后每次数据更新重新dvc add并提交新的.dvc文件版本就记录下来了。切换版本用dvc checkout配合git checkout切到对应的.dvc文件即可。数据接入后先跑一遍校验脚本确认数据质量。校验配置写在configs/base.yaml里包括缺失率阈值、类别白名单等。校验不通过就停下来修数据不要带着问题往下走。5.3 训练与评估的自动化串联训练和评估我串成一个 DVC pipeline写在dvc.yaml里。DVC 会根据依赖关系判断哪些阶段需要重跑数据没变就不重跑训练省时间。stages: train: cmd: python src/train.py --config-name base deps: - src/train.py - data/processed outs: - models/model.pt metrics: - reports/train_metrics.json: cache: false evaluate: cmd: python src/evaluate.py --config-name base deps: - models/model.pt - data/processed metrics: - reports/eval_metrics.json: cache: false跑dvc repro就会自动按依赖顺序执行。改了训练代码只有 train 和 evaluate 重跑只改了评估代码只有 evaluate 重跑。这个机制在迭代阶段特别省事。5.4 服务上线与灰度验证服务上线不要一次性全量。我的做法是先起一个新版本服务用一小部分流量验证对比新旧版本的延迟和错误率。验证通过再逐步放大流量。灰度期间重点看错误率和 P99 延迟这两个指标异常就立刻回滚。回滚要能做到一键所以模型文件和服务代码都要版本化。模型文件用 MLflow 的 run id 标识服务代码用 git tag 标识。回滚时把服务指向旧版本的模型和代码即可。这套机制搭好后上线心理压力小很多因为知道出问题能快速退回去。6. 我在这套流程里踩过的坑和总结的经验搭这套流程前后花了大概两个月中间踩的坑比预想的多。最大的一个教训是不要追求一步到位。我一开始就想把数据版本、实验管理、自动化测试、CI/CD 全搭上结果每个都搭了一半哪个都不好用。后来砍掉一半先把数据版本和实验管理做扎实其他等真正需要再加反而推进得快。第二个教训是配置管理要早做。我早期把超参数写在代码里改一次参数要改代码、提交、再跑效率极低。换成 Hydra 之后改参数就是改 YAML 或命令行覆盖实验迭代速度明显提升。配置和代码分离这件事越早做收益越大。第三个是监控要从第一天就有。我一开始觉得服务刚上线没多少流量监控以后再说结果第一次出问题全靠用户反馈才知道。后来补上 Prometheus 指标问题能在发生前就发现苗头。监控不是锦上添花是必需品。最后分享一个我一直在用的小技巧给每个阶段脚本加一个--dry-run参数只打印将要执行的操作和输入输出路径不真正执行。在改流程或排查路径问题时先 dry-run 一遍能避免很多误操作。这个习惯帮我省下了不少因为路径写错而白跑的训练时间。