拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手搓AI工程化流程:数据版本、实验管理与服务部署实战

从零手搓AI工程化流程:数据版本、实验管理与服务部署实战

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(本地模式)自带 UI,API 简单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.csv,DVC 会生成一个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.lr=0.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 python=3.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 一遍,能避免很多误操作。这个习惯帮我省下了不少因为路径写错而白跑的训练时间。

返回列表