1. 从零搭建AI工程能力:这个项目到底在解决什么问题
第一次看到ai-engineering-from-scratch这个标题,我脑子里蹦出来的第一个念头是:又一个“从入门到放弃”的教程仓库?但翻了一圈之后发现,它想做的事情其实比“教你调包”要硬核得多——它试图回答一个被很多人忽略的问题:当一个AI模型离开Jupyter Notebook,进入真实的生产环境时,中间到底缺了哪些东西?
这个项目本质上是一套面向开发者的AI工程化实践路线图。它不是教你如何训练一个SOTA模型,而是教你如何把一个能跑的demo,变成一套能扛住流量、能持续迭代、能被团队协作维护的系统。适合谁看?如果你已经会用PyTorch或TensorFlow跑通几个demo,但一到部署、监控、版本管理就抓瞎,那这个项目就是为你准备的。如果你是完全零基础的小白,也不用慌,它从最基础的环境搭建讲起,只是节奏会比较快,需要你边看边动手。
我之所以对这个标题感兴趣,是因为过去几年我见过太多团队在AI工程化上踩坑。模型在实验室里准确率95%,上线之后掉到70%都不到;数据管道今天能跑明天就崩;模型版本管理靠文件名区分,最后谁也说不清线上跑的是哪个版本。这些问题不是算法问题,是工程问题。而ai-engineering-from-scratch这个项目,恰恰是冲着这些工程问题去的。
它的核心价值在于:把AI工程化拆解成可学习、可复现、可落地的模块,从数据版本控制、实验追踪、模型打包、服务部署,到监控告警、CI/CD流水线,每一个环节都有对应的工具选型和实操方案。接下来我会按照这个项目的逻辑主线,把每个环节的核心思路、工具选型理由、实操要点和我自己踩过的坑,逐一拆开来讲。
2. 核心模块拆解与工具选型逻辑
2.1 为什么AI工程化不能直接套用传统软件工程
很多人第一反应是:AI工程不就是软件工程加个模型吗?用Git管代码,用Docker打包,用K8s部署,不就完了?我一开始也这么想,直到被现实反复教育。
传统软件工程的核心假设是:代码即逻辑,逻辑确定则行为确定。你写一个排序函数,输入相同,输出永远相同。但AI系统不一样,它的行为由三部分共同决定:代码、数据、模型权重。代码可以版本控制,数据呢?模型权重呢?这三个东西任意一个变了,系统行为就变了。更麻烦的是,数据是持续变化的,模型是需要重新训练的,这就导致AI系统的“版本”概念比传统软件复杂得多。
ai-engineering-from-scratch在开篇就强调了这一点:AI工程化的第一原则是“可复现”。什么叫可复现?给定一个时间点,你能精确还原出当时线上跑的是哪份代码、哪份数据、哪个模型权重、哪套配置参数。听起来简单,做起来极难。我见过一个团队,模型效果突然下降,排查了三天才发现是上游数据源某个字段的格式悄悄变了,而他们没有任何数据版本记录,只能靠翻聊天记录去猜。
所以这个项目的工具选型逻辑,始终围绕“可复现”这个核心目标展开。下面这张表是我根据项目内容整理的,对比了传统软件工程和AI工程化在关键环节上的差异:
| 维度 | 传统软件工程 | AI工程化 |
|---|---|---|
| 版本控制对象 | 代码 | 代码 + 数据 + 模型权重 + 配置 |
| 测试重点 | 功能正确性 | 功能正确性 + 数据质量 + 模型效果 |
| 部署单元 | 服务二进制/容器 | 服务 + 模型文件 + 特征管道 |
| 监控重点 | 延迟、错误率、资源 | 上述全部 + 数据漂移 + 模型衰减 |
| 迭代周期 | 按需发布 | 数据更新触发 + 模型重训触发 |
这张表不是学术分类,是我在实际项目中总结出来的。每次我觉得“这次应该没问题了”,就会有一个新的维度冒出来打脸。比如数据漂移,传统软件根本不存在这个概念,但在AI系统里,它是导致线上效果衰减的头号杀手。
2.2 数据版本控制:DVC为什么比Git LFS更合适
项目里第一个实操模块是数据版本控制。很多人第一反应是用Git LFS,毕竟Git用熟了。但ai-engineering-from-scratch明确推荐了DVC(Data Version Control),理由很实在:Git LFS管的是大文件,DVC管的是数据管道。
这两者的区别在哪?Git LFS本质上还是Git,它只是把大文件存在别的地方,版本历史还是Git那套。但数据版本控制的需求不止于此:你需要知道这份数据是从哪个源、经过哪些处理步骤、用什么参数生成的。DVC的做法是,它不直接存数据,而是存一个.dvc文件,里面记录了数据的哈希值和生成它的命令。你dvc repro一下,它就能根据依赖关系重新生成数据。
我实测下来的感受是,DVC的学习曲线比Git LFS陡,但一旦跑通,收益巨大。举个例子:你有一个数据清洗脚本,处理100万条数据要跑2小时。某天你改了清洗逻辑,想对比新旧数据训练出来的模型效果。用Git LFS,你得手动存两份数据,占双倍空间。用DVC,你只需要切换一下版本,它自动判断哪些步骤需要重跑,哪些可以复用缓存。我试过一个场景,改了最后一步的过滤阈值,DVC只重跑了最后一步,前面90%的耗时都省了。
注意:DVC的缓存目录默认在项目根目录的
.dvc/cache,这个目录会随着版本增多越来越大。建议在项目初期就配置好远程存储(比如S3兼容的对象存储),把缓存推上去,本地只保留当前版本。我踩过的坑是本地磁盘被缓存撑爆,清理的时候不小心把正在用的缓存删了,导致整个管道重新跑了一遍。
2.3 实验追踪:MLflow的定位与轻量替代方案
实验追踪是AI工程化里最容易被低估的环节。很多人觉得,我拿个Excel记一下不就行了?我一开始也这么干,直到实验数量超过50个,Excel彻底失控。ai-engineering-from-scratch推荐的是MLflow,但同时也提到了Weights & Biases和Neptune作为备选。
MLflow的核心优势是开源、可自托管、与框架无关。它主要解决四个问题:参数记录、指标记录、模型存储、模型注册。你可以把它理解成一个“实验的Git”,每次跑实验,它自动记录你用了什么超参数、跑了什么指标、产出了什么模型。最实用的是它的模型注册功能,你可以给模型打上“Staging”“Production”这样的标签,部署的时候直接按标签拉取,不用再靠文件名去猜。
但MLflow也不是没有缺点。它的UI比较朴素,多人协作时的权限管理也比较弱。如果你的团队规模在5人以下,MLflow足够用。如果超过10人,或者需要更精细的权限控制和更漂亮的可视化,可以考虑Weights & Biases。不过W&B是商业产品,免费版有额度限制。我个人的建议是:先用MLflow跑通流程,等真正遇到瓶颈了再考虑迁移。过早引入商业工具,反而会增加学习成本和迁移成本。
这里有一个我实际用过的MLflow最小配置,可以直接抄:
import mlflow import mlflow.sklearn mlflow.set_tracking_uri("http://localhost:5000") mlflow.set_experiment("my-first-experiment") with mlflow.start_run(): mlflow.log_param("learning_rate", 0.01) mlflow.log_param("max_depth", 5) # 训练代码... mlflow.log_metric("accuracy", 0.95) mlflow.log_metric("f1_score", 0.93) mlflow.sklearn.log_model(model, "model")跑完mlflow ui,打开浏览器就能看到所有实验的对比。我特别喜欢它的平行坐标图,能直观看出哪些参数组合效果最好。
2.4 模型打包与服务化:为什么选FastAPI而不是Flask
模型训练完,下一步是把它变成API。项目里对比了Flask、FastAPI和TorchServe,最终推荐FastAPI。理由有三点:原生异步支持、自动生成API文档、Pydantic数据校验。
Flask是同步框架,虽然也能用,但在高并发场景下性能不如FastAPI。TorchServe是专门为PyTorch模型设计的,功能很全,但如果你用的不是PyTorch,或者模型逻辑比较复杂(比如需要预处理和后处理),TorchServe的定制成本反而更高。FastAPI则是一个通用Web框架,你可以把模型推理逻辑当成普通的业务逻辑来写,灵活度最高。
我实测过一个场景:同一个模型,分别用Flask和FastAPI部署,在100并发下的表现。FastAPI的P99延迟比Flask低了约30%,而且CPU占用更稳定。当然,这个数据不是绝对的,跟模型复杂度和硬件都有关系。但FastAPI的异步特性在处理IO密集型任务(比如调用外部特征服务)时优势明显。
项目里给出的FastAPI最小示例是这样的:
from fastapi import FastAPI from pydantic import BaseModel import joblib app = FastAPI() model = joblib.load("model.pkl") class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): prediction: float model_version: str @app.post("/predict", response_model=PredictResponse) async def predict(request: PredictRequest): prediction = model.predict([request.features])[0] return PredictResponse(prediction=prediction, model_version="v1.0")这个示例虽然简单,但包含了几个关键设计:请求体校验、响应体结构化、模型版本标识。特别是模型版本标识,很多人会忽略,但它在排查问题时极其重要。线上出问题了,你第一件事就是确认当前跑的是哪个版本。
3. 实操流程:从零搭建一条完整的AI工程流水线
3.1 环境准备与项目初始化
项目的第一步是环境准备。ai-engineering-from-scratch推荐用Conda管理Python环境,用Poetry管理依赖。我一开始觉得这俩功能重叠,后来发现分工不同:Conda管的是Python版本和系统级依赖(比如CUDA),Poetry管的是项目级的Python包依赖。两者配合使用,能避免很多“在我机器上能跑”的问题。
具体操作步骤:
- 安装Miniconda(比完整版Anaconda轻量,没有预装一堆用不上的包)
- 创建环境:
conda create -n ai-eng python=3.10 - 激活环境:
conda activate ai-eng - 安装Poetry:
pip install poetry - 初始化项目:
poetry init,按提示填写项目信息 - 添加依赖:
poetry add fastapi uvicorn scikit-learn mlflow dvc
这里有个细节:Poetry默认会创建虚拟环境,但如果你已经在Conda环境里,它会复用当前环境。我建议在poetry config里设置virtualenvs.create false,避免环境嵌套带来的混乱。
项目结构建议这样组织:
ai-engineering-from-scratch/ ├── data/ │ ├── raw/ │ └── processed/ ├── src/ │ ├── data/ │ │ └── make_dataset.py │ ├── features/ │ │ └── build_features.py │ ├── models/ │ │ ├── train.py │ │ └── predict.py │ └── api/ │ └── main.py ├── tests/ ├── dvc.yaml ├── pyproject.toml └── README.md这个结构不是随便定的。data/目录被DVC管理,src/目录被Git管理,dvc.yaml定义数据管道,pyproject.toml定义依赖。每个目录的职责清晰,新人接手时能快速定位。
3.2 数据管道搭建:DVC实操详解
数据管道是AI工程化的地基。ai-engineering-from-scratch用DVC来定义管道,核心是dvc.yaml文件。我拿一个实际项目举例,假设你要做一个房价预测模型,数据管道分三步:下载数据、清洗数据、生成特征。
dvc.yaml这样写:
stages: download: cmd: python src/data/download.py deps: - src/data/download.py outs: - data/raw/housing.csv clean: cmd: python src/data/clean.py deps: - src/data/clean.py - data/raw/housing.csv outs: - data/processed/housing_clean.csv features: cmd: python src/features/build.py deps: - src/features/build.py - data/processed/housing_clean.csv outs: - data/processed/housing_features.csv这个配置的关键在于deps和outs的声明。DVC会根据这些声明自动构建依赖图。当你运行dvc repro时,它会检查每个阶段的依赖是否变化,只重跑受影响的部分。我实测过一个场景:改了clean.py里的一个过滤条件,DVC只重跑了clean和features两个阶段,download阶段直接复用缓存,省了十几分钟。
实操心得:
dvc repro默认只重跑变化的部分,但如果你改了dvc.yaml本身(比如加了新阶段),需要先运行dvc stage add或者手动更新依赖图。我踩过的坑是改了dvc.yaml之后直接dvc repro,结果DVC没识别到新阶段,白跑了一遍。
3.3 模型训练与实验追踪集成
数据管道跑通之后,下一步是训练模型并记录实验。项目里把MLflow集成到了训练脚本中,每次训练自动记录参数、指标和模型。我按照这个思路写了一个训练脚本,核心逻辑如下:
import mlflow import pandas as pd from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, r2_score mlflow.set_tracking_uri("http://localhost:5000") mlflow.set_experiment("housing-price") df = pd.read_csv("data/processed/housing_features.csv") X = df.drop("price", axis=1) y = df["price"] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) with mlflow.start_run(): n_estimators = 100 max_depth = 10 mlflow.log_param("n_estimators", n_estimators) mlflow.log_param("max_depth", max_depth) model = RandomForestRegressor(n_estimators=n_estimators, max_depth=max_depth) model.fit(X_train, y_train) y_pred = model.predict(X_test) mse = mean_squared_error(y_test, y_pred) r2 = r2_score(y_test, y_pred) mlflow.log_metric("mse", mse) mlflow.log_metric("r2", r2) mlflow.sklearn.log_model(model, "model")这个脚本跑一次,MLflow就会记录一次实验。跑个十几次之后,打开MLflow UI,你能直观看到不同参数组合的效果对比。我特别喜欢它的“Compare”功能,可以勾选多个实验,并排看参数和指标差异。
这里有个细节:MLflow的模型存储路径默认是本地文件系统。如果你在容器里跑训练,容器一销毁,模型就没了。建议在mlflow.set_tracking_uri里配置远程存储,或者用mlflow.sklearn.log_model的registered_model_name参数把模型注册到模型仓库。
3.4 API服务开发与模型加载
模型训练好之后,下一步是把它变成API。项目里用FastAPI搭建了一个简单的预测服务。我按照这个思路,结合自己的经验,写了一个更完整的版本:
from fastapi import FastAPI, HTTPException from pydantic import BaseModel import mlflow import numpy as np app = FastAPI(title="Housing Price Predictor") model = None model_version = "unknown" @app.on_event("startup") async def load_model(): global model, model_version model_uri = "models:/housing-price/Production" model = mlflow.sklearn.load_model(model_uri) model_version = model_uri.split("/")[-1] class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): prediction: float model_version: str @app.post("/predict", response_model=PredictResponse) async def predict(request: PredictRequest): if model is None: raise HTTPException(status_code=503, detail="Model not loaded") try: features = np.array(request.features).reshape(1, -1) prediction = model.predict(features)[0] return PredictResponse(prediction=float(prediction), model_version=model_version) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/health") async def health(): return {"status": "ok", "model_version": model_version}这个版本比项目里的示例多了几个关键设计:启动时加载模型、健康检查接口、异常处理。启动时加载模型是为了避免每次请求都去拉模型,健康检查接口是为了配合K8s的存活探针,异常处理是为了避免模型报错时直接把堆栈暴露给客户端。
注意:
@app.on_event("startup")在FastAPI的新版本里已经被标记为废弃,推荐用lifespan事件。不过目前还能用,如果你的FastAPI版本比较新,建议改成lifespan写法。
3.5 容器化与部署配置
API写好了,下一步是打包成容器。项目里给出了一个Dockerfile模板,我根据自己的经验做了优化:
FROM python:3.10-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml poetry.lock ./ RUN pip install poetry && \ poetry config virtualenvs.create false && \ poetry install --no-dev --no-interaction --no-ansi COPY src/ ./src/ COPY models/ ./models/ EXPOSE 8000 CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]这个Dockerfile有几个优化点:用slim基础镜像减小体积、分层复制利用缓存、只安装生产依赖。我实测下来,这个镜像大概300MB左右,比直接用完整版Python镜像小了将近一半。
部署的时候,项目推荐用K8s,但如果你只是小规模使用,Docker Compose也够用。我个人的建议是:先用Docker Compose跑通,等真正需要弹性伸缩了再上K8s。过早引入K8s,运维成本会吃掉你大部分精力。
4. 常见问题与排查技巧实录
4.1 数据管道常见报错与解决
DVC用起来很爽,但报错的时候也挺让人头疼。我整理了几个高频问题和解决方法:
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
ERROR: failed to reproduce 'xxx': missing dependencies | 依赖文件不存在或路径写错 | 检查dvc.yaml里的deps路径,确保文件存在 |
ERROR: output 'xxx' is already tracked by DVC | 输出文件已经被DVC管理 | 先dvc remove再重新dvc add |
ERROR: unexpected error - [Errno 2] No such file or directory | 缓存目录被清理或远程存储未配置 | 运行dvc pull拉取缓存 |
WARNING: cache 'xxx' not found | 本地缓存缺失 | 运行dvc fetch或dvc pull |
我踩过最坑的一个问题是:在dvc.yaml里写了相对路径,但在不同目录下运行dvc repro,相对路径的基准目录不一样,导致找不到文件。建议统一在项目根目录运行DVC命令,路径也统一用相对于根目录的写法。
4.2 MLflow实验记录丢失的排查思路
MLflow用久了,偶尔会遇到实验记录丢失的情况。我遇到过两次,一次是本地文件系统满了,一次是数据库连接断了。排查思路如下:
- 先看MLflow服务是否正常:
curl http://localhost:5000/health - 再看后端存储是否可写:如果是文件存储,检查磁盘空间;如果是数据库,检查连接
- 最后看实验是否被误删:MLflow的删除是软删除,可以通过API恢复
实操心得:MLflow的默认文件存储路径是
./mlruns,这个目录会随着实验增多越来越大。建议定期清理不再需要的实验,或者配置数据库后端。我现在的做法是,每个季度归档一次旧实验,把mlruns目录打包存到对象存储,本地只保留最近三个月的。
4.3 模型服务性能瓶颈的定位方法
API上线之后,最怕的就是性能瓶颈。我总结了一套定位方法,按顺序排查:
- 先看模型推理耗时:在代码里加时间戳,记录
model.predict的耗时。如果推理本身就慢,那优化方向是模型压缩或换更快的模型。 - 再看预处理耗时:特征工程往往比推理还耗时。如果预处理慢,考虑把预处理逻辑移到训练管道里,线上只做轻量转换。
- 最后看网络和序列化耗时:请求体太大、JSON序列化慢,都会影响延迟。可以用
orjson替代标准库的json,序列化速度能快好几倍。
我实测过一个场景:一个模型推理只要10ms,但API响应要200ms。排查后发现是特征预处理里有一个循环,每次请求都要遍历一个列表做归一化。把归一化参数提前算好存下来,响应时间直接降到30ms。
4.4 模型版本管理与回滚策略
模型版本管理是AI工程化里最容易出事的环节。我见过太多团队靠文件名区分版本,最后谁也说不清线上跑的是哪个。ai-engineering-from-scratch推荐用MLflow的模型注册功能,给模型打标签,部署时按标签拉取。
我的做法是:每次训练完,把模型注册到MLflow,打上Staging标签。经过验证后,再改成Production标签。部署服务启动时,从Production标签拉取模型。回滚的时候,只需要把旧版本的标签改回Production,重启服务即可。
这个流程的关键是:标签切换要快,服务重启要快。如果服务启动要几分钟,回滚就失去了意义。所以我在设计API服务时,会把模型加载做成懒加载,启动时只加载模型元数据,第一次请求时才真正加载模型权重。这样服务重启只要几秒钟,回滚速度大大提升。
5. 我在这条路上踩过的坑和总结的经验
5.1 不要过早追求“完美架构”
我刚开始做AI工程化的时候,总想一步到位:DVC、MLflow、FastAPI、K8s、Prometheus全套上齐。结果花了两个月搭架子,真正训练模型的时间不到一周。后来我学乖了,先用最简方案跑通全流程,再逐步替换瓶颈环节。
最简方案是什么?数据用文件夹管理,实验用Excel记录,模型用pickle保存,服务用Flask写个接口,部署用nohup跑在服务器上。这套方案虽然土,但能让你在一天之内跑通“数据到服务”的全流程。跑通之后,你才知道瓶颈在哪,再针对性地引入工具。
5.2 监控比训练更重要
很多人把精力全花在训练上,觉得模型效果好就万事大吉。但线上系统出问题,往往不是模型本身的问题,而是数据的问题。我经历过一次线上事故:模型效果突然下降,排查后发现是上游数据源某个字段的编码格式变了,导致特征提取出错。如果当时有数据质量监控,这个问题在数据进入管道时就能被发现。
所以我现在做AI工程化,监控的优先级高于训练。至少要监控三个东西:输入数据的分布、模型输出的分布、推理延迟。输入数据分布变了,说明上游有问题;输出分布变了,说明模型可能失效了;推理延迟变了,说明系统有瓶颈。
5.3 文档和测试是给自己留的后路
AI项目的人员流动往往比传统软件项目更频繁。一个人走了,他训练的模型、搭的管道、写的脚本,如果没文档没测试,接手的人基本要从头再来。我现在的习惯是:每个数据管道脚本都要有对应的测试,每个模型都要有模型卡片,每个API都要有接口文档。
模型卡片是什么?就是一份说明文档,记录这个模型是用什么数据训练的、效果指标是多少、适用场景是什么、有什么已知缺陷。听起来很形式主义,但真正出问题的时候,这份文档能帮你快速定位原因。我见过一个团队,模型效果下降后排查了两天,最后发现是训练数据里混入了测试集的数据,导致指标虚高。如果当时有模型卡片记录数据来源,这个问题五分钟就能发现。
5.4 小步快跑,持续迭代
AI工程化不是一次性的项目,而是一个持续迭代的过程。不要想着一次把所有事情做完,而是每次解决一个痛点,每次改进一个环节。今天把数据版本控制加上,明天把实验追踪加上,后天把监控加上。每次改动都小,风险可控,出了问题也容易回滚。
我现在的节奏是:每两周做一次工程化改进,每次只改一个环节。改完之后跑一周,确认稳定了再改下一个。这样虽然慢,但稳。AI工程化最怕的就是大跃进,一次性改太多,出了问题根本不知道是哪个改动导致的。
最后分享一个我最近在用的技巧:给每个模型训练任务加一个“数据指纹”。具体做法是,在训练脚本里计算训练数据的哈希值,把这个哈希值记录到MLflow的参数里。这样当模型效果出问题时,你可以快速对比当前数据和训练数据的哈希值,判断是不是数据变了。这个技巧帮我省了好几次排查时间,强烈推荐你试试。