几年前我第一次把一个模型送上线的时候,最大的感慨不是“神经网络真厉害”,而是“这玩意怎么这么难伺候”。模型在笔记本上跑得好好的,到了服务器上就报错;实验记录全靠Notebook里手动翻;上线一周后没有监控,预测结果开始悄悄变差,没人能第一时间察觉。这些问题单拎出来每一个都不难,难的是把它们串成一条完整的、可复用、可交底的能力链。这几年带着不少从零起步的工程师一起做项目,也陆陆续续整理了一套自己的路径——就是标题里这个ai-engineering-from-scratch。它要表达的意思不是“从零写一个训练脚本”,而是把 AI 工程这件事完整地立起来:从环境、数据、实验、训练、部署到监控,每个环节都有章法,每个坑都有记录。
这篇文章我想把这条路拆开讲清楚。适合正在转向算法工程方向的后端开发者,也适合刚入门、打算把 AI 做成正经系统的同学,哪怕是带过一段时间 AI 项目但总觉得流程别扭的朋友,应该也能在里面找到几个能直接拿来用的思路。下文没有特别深的数学,核心是工程结构和实操习惯,目标就一个:让大家看完之后,能顺着这条线自己从零搭出一套靠谱的 AI 工程底座。
1. 先搞清楚一个事实:AI工程和调算法根本不是一回事
1.1 三种角色,别把自己定位错了
我见过太多人一开始就钻错方向。想搞“AI工程”,结果一上来就死磕 transformer 源码,或者在 Kaggle 上刷了两个月的精度排行榜。不是说这些没用,而是它们解决的完全是另一个问题。
业内常被混在一起的其实是三类人:算法研究员,负责发明或改进模型结构,拼的是论文和基准分数;数据分析师/数据科学家,负责从业务数据里找规律、做分析、提特征,拼的是对业务的理解和统计功底;AI 工程师,负责把算法真正变成线上系统里稳定运行的模块,拼的是工程化能力——数据能不能重复、训练能不能重现、模型能不能上线、上线后能不能感知异常。
ai-engineering-from-scratch关注的是第三类。这里最核心的分界线是:研究角色追求“上限”,工程角色追求的是“下限”。同一个模型,A 组在理想环境里跑出 0.91 的 AUC,B 组在肮脏数据、混乱版本、无监控的线上环境里跑出 0.84 还要保证不崩。后面的工作往往比前面的调参更难,也更值钱。
1.2 AI工程要解决的最基本四个问题
把这四个问题想明白,基本就理解了这个领域为什么存在:
- 可复现:三个月前的实验,我还能不能原样跑出来同样的结果?数据、代码、依赖、超参数缺一个,这个目标就完不成。
- 可协作:三个人同时在一个模型项目上干活,谁改了训练集、谁改了特征、谁动了超参数,能不能有迹可循地查清楚?
- 可上线:模型训练完不是终点,从
.ipynb变成线上服务,中间那条路像不像一个正规软件项目的发布流程? - 可观测:上线之后只能祈祷模型永远正常工作吗?特征分布变了谁发现?预测结果异常了怎么报警?
这四个问题,任何一个做不好,项目都会在某个阶段翻车。翻车的方式各不相同:可能是同事离职后没人敢动他的训练代码,可能是模型上线一周后悄悄劣化被业务方投诉,也可能是线上推理延迟暴涨导致服务超时。
1.3 先给自己做一个自测清单
行动之前,建议先做一张自测表。不用工具,就拿你现在手头的一个 AI 项目回答以下问题:
| 检查项 | 如果答不出来,说明缺口在哪 |
|---|---|
| 这个模型用的训练数据是哪天导出的? | 数据版本管理 |
| 当前模型的 AUC/准确率是谁跑的?怎么证明? | 实验记录与评估标准化 |
| 训练脚本依赖的 Python 包能一键恢复环境吗? | 依赖隔离与容器化 |
| 预处理、特征计算和线上推理用的是同一份代码吗? | 训练/推理一致性 |
| 如果模型效果下降了,你怎么知道? | 监控与告警 |
| 如果公司换一台 GPU 服务器,能半小时内重新跑出线上模型吗? | 环境可迁移与自动化 |
如果发现自己有超过一半的格子是空白的,那这篇文章正好就是你要的起点。
2. 环境与工具链:第一块基石怎么打最稳
2.1 从零起步的技术选型逻辑
很多人一上来就会问我:这些工具我应该全都要吗?要不要学 Kubernetes?要不要上 Feature Store?我的回答通常是:不要为了工具而工具,从最小的闭环开始,然后在痛点上加工具。
从零搭建的时候,我建议先定三个原则:
- 能用标准化的,不自己造。模型训练代码、Web 推理服务都有成熟的框架,别总想着从底层手写。
- 配置是一切复现的起点。任何超参数、路径、数据版本,原则上都走配置文件,不写死在代码里。
- 环境必须一次性构建成功。不管你在自己电脑上、GPU 服务器上还是同事的机器上,都应该有一条命令把环境从零复原。
2.2 Python 环境管理的真实建议
Python 是 AI 领域逃不开的语言,但 Python 的包管理是个著名的坑。我个人的经验是分两个阶段:
第一,本地快速开发阶段,直接用uv(一个用 Rust 写的包管理器)或conda创建隔离环境,按项目管理依赖,不要直接装在系统 Python 里。uv的好处是速度极快,而且能用锁文件锁定依赖版本。
第二,项目交付或部署阶段,必须升级为容器镜像。因为不管本地环境管理得再好,容器才是能跨越“开发机”和“生产机”之间差异的最小单位。我常用的组合是python:3.11-slim作为基础镜像,加上项目级requirements.txt或pyproject.toml来安装依赖。
2.3 GPU、CUDA、cuDNN 的版本对齐问题
这是从零起步的工程师最容易踩的第一个大坑。很多人用的显卡是 NVIDIA 的,安装 GPU 版本 PyTorch 之后,莫名其妙发现程序报CUDA error: no kernel image is available for execution on the device。这个问题绝大概率不是代码错了,而是驱动、CUDA runtime、PyTorch 编译版本、GPU 算力之间的兼容性出了问题。
我实践下来比较稳的组合参考如下(以 2025 年常见环境为例,具体小版本以各框架官方文档为准):
| 组件 | 推荐版本示例 | 说明 |
|---|---|---|
| NVIDIA 驱动 | 560.x 及以上 | 驱动版本决定 CUDA 版本上限 |
| CUDA Toolkit | 12.x(向下兼容 11.x 的多数算子) | 训练框架会带 runtime,不一定需要整包安装 |
| PyTorch | 2.x,对应 CUDA 12.x 的预编译版本 | 用官方--index-url安装,别用默认源 |
| Python | 3.10 或 3.11 | 太新的版本可能等不到所有算子编译适配 |
另外,很多人在服务器上管理 GPU 环境时会纠结:到底要不要单独装 CUDA Toolkit?我的经验是,如果完全用 PyTorch 这样的框架,通常不用单独安装完整 CUDA Toolkit,因为框架自带的 runtime 已经够用。真要装,也建议通过 conda 管理,而不是直接用系统包管理器乱装,否则很容易污染系统环境。
2.4 用 Docker 把环境固定下来
我的习惯是在项目的第一个星期就引入 Docker。原因很简单:让协作的人不用再回答“我的环境为什么跑不起来”这种问题。
一个最小可用的 AI 训练/推理环境,Dockerfile 大致长这样(实际项目不需要特别复杂):
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 # 这里可以改成 python:3.11-slim 用于纯 CPU 推理 RUN apt-get update && apt-get install -y --no-install-recommends \ python3.11 python3.11-venv git curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /workspace COPY requirements.txt . RUN python3.11 -m venv /opt/venv && \ /opt/venv/bin/pip install --upgrade pip && \ /opt/venv/bin/pip install -r requirements.txt ENV PATH="/opt/venv/bin:$PATH"这里有个实用细节想分享给大家:GRGPU 训练容器体积很大,如果只是做推理,尽量用 CPU 小镜像。把训练和推理的环境拆成两套,好处是部署快、攻击面小、资源占用低。训练服务器和推理服务本来就该是两套不同的资源池。
3. 从零搭建一条可复用的AI流水线:五件套
有了环境只是地基。真正让 AI 工程和“随便跑个脚本”拉开差距的,是下面这条流水线的设计。我一般称它为 AI 工程“五件套”。
3.1 数据版本化:模型可以回滚,数据不能
先提一个问题:模型上线后效果变差了,算法同学说“我什么都没改”。那你怎么办?如果发现不是代码问题,大概率是训练数据变了。所以,数据和代码一样,必须纳入版本管理。
传统思维是用 Git 管理数据,但数据文件往往很大,不适合塞进 Git 仓库。行业里的常规做法是用专门的工具,我常用的是DVC(Data Version Control)。它的工作方式其实很好理解:数据文件存储在本地或云对象存储(S3、OSS、NAS 等)里,DVC 只记录文件的元信息和哈希值,然后把元信息文件提交到 Git。这样大家都从 Git 拉代码,再执行一次dvc pull就能把对应版本的数据拉下来。
实际项目里,我习惯在数据目录下建立清晰的文件组织:
data/ ├── raw/ # 原始数据,只增不改 │ ├── 2025-01-01.parquet │ └── 2025-01-08.parquet ├── processed/ # 清洗后的数据,按版本输出 │ ├── v1.0.0.parquet │ └── v1.1.0.parquet ├── features/ # 特征数据,可按特征组合命名 │ └── feature_set_a.parquet └── .dvc/ # DVC 元信息目录关键习惯是:原始数据永远只增不改。哪怕发现原始数据里有脏数据,也一定要保留最原始的那份,只在其上派生出清洗后的新版本。这样任何分析都能追溯到源头,而不是等到出问题时才发现原始数据早被覆盖了。
3.2 实验管理:每个实验背后是一整套配方
跑实验的时候,只记录一个model_final_v2.ipynb是不够的。实验管理的本质是记录“配方”:数据文件哈希、代码 commit、环境依赖、超参数、评估指标、模型产物路径。缺任何一项,实验就等于没做。
我的做法比较朴素:训练脚本 + 配置文件 + MLflow 记录。
配置用 Hydra 或纯 YAML 维护。比如一个config.yaml:
data: raw_path: data/raw/2025-01-08.parquet processed_version: v1.1.0 test_split: 0.2 random_state: 42 model: name: gradient_boosting params: n_estimators: 500 learning_rate: 0.05 max_depth: 5 experiment: name: churn_prediction_v1训练代码里,用 MLflow 把这些信息自动记录起来:
import mlflow import yaml with open("config.yaml") as f: config = yaml.safe_load(f) mlflow.set_experiment(config["experiment"]["name"]) with mlflow.start_run(): # 记录超参数 mlflow.log_params(config["model"]["params"]) # 记录指标,比如 AUC、F1 mlflow.log_metric("auc", val_auc) # 记录模型 mlflow.sklearn.log_model(model, "model") # 记录数据版本信息,可以是一个自定义字符串 mlflow.log_param("data_version", config["data"]["processed_version"])这样三个月后想回溯,每一条实验记录都能告诉你:用了哪份数据、什么样的参数、产出了什么效果、模型文件在哪。这个稳定性带来的价值,在项目协作时体现得最明显。
3.3 特征工程独立成模块
很多人写 AI 项目,习惯把特征处理和训练代码搅在一起,训练脚本里一手 pandas 清洗一手拉模型。这种做法在单机演示没问题,但一旦要上线,就会出现最经典的问题:训练时的特征处理和线上推理时的特征处理不一致。比如训练时你用的是df.fillna(0),线上你却忘了处理缺失值;训练时你做了标准化,线上却没有存那组均值和方差。
更稳的做法是把“特征计算”封装成一个独立模块,训练和推理都复用它。推荐的组织结构:
src/ ├── features/ │ ├── builders.py # 从原始数据构造特征的逻辑 │ └── schema.py # 特征名、特征类型定义 ├── models/ │ ├── baseline.py │ └── train.py ├── serving/ │ ├── api.py # 推理服务接口 │ └── preprocess.py # 线上请求进来时的特征转换 └── configs/ └── config.yaml哪怕是一个很小的项目,也值得把特征逻辑抽出来。因为线上服务最怕的不是模型不准,而是模型用的输入和训练时完全不一致,这种错误不是精度问题,是事故级别的问题。
3.4 训练脚本的工程化结构
一个训练脚本要满足“别人也能跑、以后也能跑”的要求,至少要包含五个部分:
- 读配置:超参数、路径统一从配置读入;
- 数据加载:从原始数据处理到训练/验证集;
- 训练循环:模型拟合、早停、交叉验证;
- 评估:在独立测试集上输出完整指标;
- 保存产物:模型文件、预处理器、指标 JSON。
这里有一个我踩过的坑想特别提醒:随机种子。不管做线性模型还是深度模型,都要在脚本开头固定所有随机源(Pythonrandom、NumPy、PyTorch/框架的 seed),否则两个环境各跑一次,结果不一样,谁都说不清是不是代码改动导致的。这不是形式主义,这是工程判断的问题。
3.5 模型评估与注册:为上线准备好“身份”
训练完成后,模型不能只是磁盘上一个.pkl或.pt文件。要让它成为一个可上线的“候选”,至少要完成两件事:一是在一个固定的测试集上记录完整评估报告,二是给模型一个版本号。
MLflow 的 Model Registry 是一个不错的选择。可以给模型打上Staging、Production、Archived这样的阶段标签。上线不是把文件复制过去就行,应该是:从注册中心拉取指定版本 → 验证产物完整性 → 部署 → 在监控看板上标记当前线上版本。这个过程一旦固化下来,整个团队的协作节奏都会顺畅很多。
4. 一次完整的端到端实践:从原始表格到在线API
前面讲了这么多框架和概念,现在来一个能直接运行的骨架。这一节我带大家把一个最简单的回归任务,做成一条完整的链路:原始数据 → 训练 → 导出模型 → FastAPI 在线推理 → Docker 部署。
整个过程不需要 GPU,在普通开发机上就能跑通。虽然它很简单,但完整体现了训练和推理分离、特征一致、服务化部署这三个工程要点。你可以把它当成自己的第一个模板,以后换成真实数据和真实模型,改改接口就行。
4.1 场景定义
为了不让示例复杂化,我们就做一个虚拟的业务:根据 5 个数值特征(比如用户行为统计、金额、频次等),预测一个连续指标(比如 LTV 用户生命周期价值)。数据集用随机数生成——真实项目里替换成业务数据即可。
4.2 训练侧:产出模型产物
训练脚本的核心流程是:生成数据 → 分割样本 → 训练梯度提升回归模型 → 转成 ONNX 格式 → 保存。
# train.py import numpy as np from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error from skl2onnx import to_onnx # 1. 用随机数据模拟业务样本 rng = np.random.default_rng(42) X = rng.random((2000, 5)).astype(np.float32) y = ( X @ np.array([1.5, -2.0, 0.3, 4.0, -1.0], dtype=np.float32) + rng.normal(0, 0.1, 2000) ) # 2. 切分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # 3. 训练模型 model = GradientBoostingRegressor( n_estimators=200, learning_rate=0.05, max_depth=4, random_state=42 ) model.fit(X_train, y_train) # 4. 评估模型 pred = model.predict(X_test) print(f"MSE: {mean_squared_error(y_test, pred):.4f}") # 5. 导出 ONNX,供线上推理使用 initial_input = {"X": X_train[:1]} onnx_model = to_onnx(model, initial_input, target_opset=17) with open("model.onnx", "wb") as f: f.write(onnx_model.SerializeToString())这里导出 ONNX 而不是直接存 sklearn 的.pkl,是有意识的设计选择。ONNX 是跨平台的标准格式,不依赖 Python 里的特定库版本,推理时用 ONNX Runtime 就可以,部署环境和训练环境完全脱钩。这是训练/推理分离的关键一步。
4.3 推理侧:FastAPI 接住请求
线上服务的职责很纯粹:接收请求 → 特征转换成模型输入格式 → 调用 ONNX Runtime 推理 → 返回结果。
# main.py import numpy as np import onnxruntime as ort from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"]) class Payload(BaseModel): features: list[float] @app.post("/predict") def predict(payload: Payload): if len(payload.features) != 5: return {"error": "expected 5 features"} x = np.array(payload.features, dtype=np.float32).reshape(1, -1) output = sess.run(None, {"X": x})[0] return {"prediction": float(output[0][0])}这个服务在本地启动后,可以直接用 curl 验证:
curl -X POST http://127.0.0.1:8000/predict \ -H "Content-Type: application/json" \ -d '{"features": [0.5, 1.2, 0.3, 2.5, -0.4]}'如果一切正常,你会收到一个{"prediction": ...}的 JSON 响应。这里的核心是:特征顺序必须和训练时完全一致。真实项目里,建议在推理代码里记录特征顺序的 schema,并在请求进来时做校验,不要把这种一致性寄托在“大家记性好”上。
4.4 Docker 部署:一条命令启动服务
最后把推理服务打成镜像,让它在任何机器上都能一条命令启动。
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY model.onnx main.py . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]requirements.txt只需三行:
fastapi uvicorn onnxruntime构建并运行:
docker build -t ai-demo-serving . docker run -p 8000:8000 ai-demo-serving到这里,一个最小闭环就跑通了。前面讲的数据版本化、实验管理、监控,实际都是在真实项目里围绕这个闭环逐渐丰满的。先把骨架立起来,再往里面填血肉,这是我从零搭过多个系统后最深的体会。
4.5 跑通之后的验证清单
骨架跑通,不代表系统工程化就算完成了。我通常还会让团队按这张清单自查一遍:
- 删掉整个 Python 虚拟环境,只用 Docker 镜像能否从零启动服务?
- 如果新增 10 个特征,从改特征模块到重新部署,需要动哪几个文件?
- 线上服务的模型版本,能不能在注册中心一一对应到训练实验?
- 压测 1000 个并发请求时,响应延迟和内存占用是否在预期范围内?
如果这些问题都要想很久,说明系统还只是“能跑”,距离“工程化”还有差距。
5. 实测中踩过的真实坑位:工程化学费记录
下面这部分想写成一份踩坑笔记。这些都是我在实际项目中遇到过、也帮别人排过的问题,几乎每个都曾在不同团队里反复上演。
5.1 数据泄露:交叉验证写错的经典案例
有一次团队训练用户流失预测模型,离线 AUC 高达 0.98,业务方高兴坏了。上线后效果惨不忍睹。查了两个星期,最后发现问题出在交叉验证代码:数据做归一化时,用了全量数据的均值和方差,而不是只从训练集上 fit。这就造成验证集的信息偷偷漏进了训练过程,等于考试时把答案放在了草稿纸里。
正确的做法是:
from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression # 关键点:scaler 只 fit 训练部分,验证/测试部分只能 transform pipeline = Pipeline([ ("scaler", StandardScaler()), ("model", LogisticRegression()), ]) pipeline.fit(X_train, y_train) # scaler 和 model 都只在训练折上 fit y_pred = pipeline.predict(X_test)这个坑之所以常见,是因为很多人把特征处理当成“全局准备工作”,习惯在整个数据集上先算一遍再切分。任何利用全局统计信息做预处理的方式,都要反复检查数据泄露的可能。
5.2 训练和推理的特征不一致
还有个更隐蔽的问题:训练时的预处理是一套代码,线上服务里为了“图方便”又写了一套。比如训练时对日期字段做了“年初至今天数”转换,线上请求进来的日期却没有对应转换;训练时把空字符串填成 0,线上却直接传空值。这类问题不会报错,模型不会崩,但预测结果从根上就是错的,且极难定位。
解决思路前面提过:把特征处理逻辑做成唯一模块,训练时和推理时都 import 同一份代码。真实系统里哪怕做不到完全动态加载,至少要把特征转换函数做成纯函数,不依赖全局状态,这样两边调用结果天然一致。
5.3 实验复现失败:随机种子和依赖版本
还有一次,团队有人想在原来的模型基础上加一个新特征,于是他把旧代码从 Git 仓库里检出之后重新训练。结果发现指标比记录的好了一截,他以为特征有效,开心地准备上线。我让他先去查三件事:Python 包的版本是否和旧实验一致、随机种子是否固定、训练数据哈希是否相同。
查完发现,数据文件已经被另一个人重新生成了,虽然内容看起来“差不多”,但实际分布已经变了一点。这个问题如果不发现,后面的评估对比全部失真。
所以我在每个实验的 MLflow 记录里,除了超参数,一定会额外记录三项:数据文件哈希、依赖锁定文件哈希、Git commit ID。这三项齐全,才算一个可复现的实验。
5.4 上线后预测开始漂移:监控怎么做
很多人有个误解:模型上线校验完效果就完事了。其实真正的考验才刚开始。业务环境的数据分布是实时变化的,用户习惯会变、突发活动会变、外部环境也会变。模型是静态的,世界是动态的。
我常用的监控方式是双管齐下:
- 第一层,输入特征分布监控:比如每日统计每个特征的平均值、标准差、分位数,用 PSI(Population Stability Index)对比训练时的分布。PSI 超过阈值(一般 0.25 以上算剧烈漂移)就触发告警。
- 第二层,业务指标兜底:如果模型对应的是转化率、留存率等业务可观测指标,直接统计线上真实结果和模型预测结果之间的偏差趋势。
监控不一定要上很重的平台,我推荐可以先从 Evidently 这样的开源库起步,每天跑一个定时脚本,输出报告并发送告警。等规模大了再考虑接入监控平台。
5.5 并发与延迟:推理性能的常见短板
最后一个坑来自线上服务本身。很多人训练时用的是 PyTorch 模型,部署时直接把整个 PyTorch 框架加载进来做推理,结果内存占用巨大、并发能力差。实际上,推理阶段根本不需要训练框架的全部能力。把模型转换为 ONNX 并用 ONNX Runtime 推理,是最简单的性能优化手段——体积更小、启动更快、CPU 上部署也很稳。
另外,如果服务面对较高并发,要记得使用异步框架(如 FastAPI)的时候,推理部分如果是 CPU 密集型,仍然会阻塞事件循环。这时候建议把在线推理放到独立进程/独立线程池里去跑,或者用专门的推理框架。这个细节不处理,压测一上来服务就会大面积超时。
我自己在压测一个小模型时,单请求未优化前是 15ms,切换到 ONNX Runtime 后降到 3ms,再做一版批量处理,整体吞吐提升了近十倍。优化的顺序永远是:先转标准格式,再考虑批处理,还没有必要就暂时不去动架构。
最后再分享一个小技巧
从一个“能跑的脚本”到一个“能交底的系统”,我个人体会最深的不是某个工具的使用,而是尽早把端到端链路跑通。哪怕第一版模型效果很差、数据很脏、服务很简陋,也一定要先让“数据 → 训练 → 部署 → 推理 → 监控”整个闭环转起来。因为只有闭环存在,后面每一步的改进都能被观测到,你才知道自己改的东西有没有用。
从零开始的话,我建议按这样的时间线去推进:第一周把端到端骨架跑通(就参考第 4 节这个模板);第二周把数据版本化和实验管理补上,让实验可复现;第三四周再把监控和服务化完善。这样四周下来,你手里的就不是一个模型,而是一条产线。这条路我走过好几次了,是真正能把 AI 项目从“科研状态”拉到“工程状态”的捷径。