1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
很多人第一次接触AI工程,脑子里想的都是“我装个PyTorch,找个开源模型,跑个demo出来就完事了”。我刚开始也是这么想的,直到真正接手一个要上线的项目,才发现从“能跑通”到“能扛住”之间,隔着一整套工程体系。ai-engineering-from-scratch这个标题,说的不是从零训练一个大模型,而是从零把AI工程化的能力搭起来——数据怎么管、实验怎么追踪、模型怎么部署、线上怎么监控,这一整套东西,才是AI工程师真正每天要面对的事。
这篇文章适合谁看?如果你已经会写Python、跑过几个notebook、对机器学习有基本概念,但一到“要把模型放到生产环境”就发怵,那这篇就是写给你的。如果你是完全零基础,也没关系,我会把每个环节为什么这么做讲清楚,你跟着走一遍,至少能建立起完整的工程认知。我不会只给你一堆工具名字,而是把选型逻辑、踩坑经验、参数计算都摊开讲,让你看完能直接动手搭一套属于自己的AI工程骨架。
先说一个我踩过的坑。早期我做项目,数据、代码、模型权重全塞在一个文件夹里,实验记录靠Excel,部署靠手动scp。结果就是:三个月后我自己都复现不出当时最好的那个模型,因为忘了当时用的是哪版数据、哪个超参。这不是笑话,是很多小团队的真实写照。ai-engineering-from-scratch的核心价值,就是让你从第一天起就避开这些坑,用工程化的方式管理AI项目的全生命周期。
2. 整体架构设计:先想清楚数据怎么流,再动手写代码
2.1 为什么架构设计要放在写代码之前
我见过太多人一上来就pip install,然后开始写训练脚本,写到一半发现数据格式不对,又回头改数据加载,改完发现实验没法对比,又加日志,加完日志发现部署时依赖冲突。这种“边写边补”的方式,在小demo里没问题,但一旦项目稍微复杂一点,就会变成一团乱麻。
正确的做法是,先画一张数据流图。不用很复杂,就在纸上画:原始数据从哪来、经过哪些清洗步骤、存到哪里、训练时怎么读、模型产出存哪里、推理时怎么加载、线上请求怎么进来、结果怎么返回、监控指标怎么收集。这张图想清楚了,后面写代码就是填空。
我自己的习惯是用一个config.yaml把所有路径、参数、版本号都管起来,代码里不出现任何硬编码的路径。这样做的好处是,换环境只需要改配置文件,不用动代码。比如数据路径、模型保存路径、日志路径、数据库连接,全部走配置。这个习惯看起来小,但能省掉后面无数麻烦。
2.2 目录结构怎么定,才能让三个月后的自己看得懂
我推荐一个经过多个项目验证的目录结构,你可以直接抄:
project/ ├── configs/ # 配置文件 │ ├── base.yaml │ └── exp001.yaml ├── data/ # 数据目录(不提交到git) │ ├── raw/ │ ├── processed/ │ └── interim/ ├── src/ # 源代码 │ ├── data/ # 数据加载与清洗 │ ├── features/ # 特征工程 │ ├── models/ # 模型定义 │ ├── training/ # 训练逻辑 │ ├── evaluation/ # 评估逻辑 │ └── serving/ # 推理服务 ├── experiments/ # 实验记录 ├── notebooks/ # 探索性分析 ├── tests/ # 单元测试 ├── requirements.txt └── README.md这个结构的关键在于职责分离。src/data只管读数据和清洗,src/models只管模型结构,src/training只管训练循环。这样当你需要换模型时,只改models,数据管道不动;换数据源时,只改data,模型不动。这种解耦是工程化的基础。
注意:
data/和experiments/一定要加到.gitignore里。数据文件大,实验产出多,提交到git会让仓库爆炸。但configs/必须提交,因为配置是代码的一部分。
2.3 工具选型:别追新,选社区活跃且你能hold住的
AI工程领域工具更新极快,今天火的东西明天可能就没人维护了。我的选型原则是:社区活跃、文档齐全、有商业公司或大机构背书、上手成本低。基于这个原则,我推荐一套经过验证的组合:
| 环节 | 推荐工具 | 选它的理由 |
|---|---|---|
| 实验追踪 | MLflow | 轻量、可本地部署、API简单 |
| 数据版本 | DVC | 和git无缝集成、支持多种存储后端 |
| 配置管理 | Hydra | 支持配置组合、命令行覆盖 |
| 模型服务 | FastAPI + ONNX Runtime | 轻量、高性能、易容器化 |
| 监控 | Prometheus + Grafana | 事实标准、生态完善 |
这套组合的好处是,每个工具都只解决一个问题,不搞大而全。你可以一个一个引入,不用一次性全上。比如先上MLflow做实验追踪,等数据版本问题出现了再上DVC。渐进式引入,学习成本低,团队接受度高。
3. 数据管道:AI工程里最脏最累但最重要的部分
3.1 数据版本管理,为什么git不够用
git管代码很好,但管数据不行。原因很简单:数据文件太大,而且经常变。你不可能每次改一条数据就commit一次,仓库会爆炸。但如果不做版本管理,你就无法复现实验——这是AI工程最致命的问题。
DVC的思路是:数据文件本身不进git,git只存一个很小的.dvc文件,里面记录了数据文件的哈希值和存储位置。数据文件存在本地磁盘或对象存储里。这样你切换git分支时,dvc checkout就能把对应版本的数据拉出来。
具体操作:
# 初始化 git init dvc init # 添加数据目录 dvc add data/raw # 这会生成 data/raw.dvc 文件,把它提交到git git add data/raw.dvc .gitignore git commit -m "add raw data" # 切换版本时 git checkout <commit> dvc checkout实测下来,这套流程在团队协作里非常稳。每个人拉代码后跑一下dvc pull,数据就同步了。不用再问“你那份数据是哪版的”。
3.2 数据清洗的工程化写法
数据清洗代码最容易写成一次性脚本,跑完就扔。但工程化的写法是:把清洗逻辑拆成一个个纯函数,每个函数只做一件事,输入输出都是DataFrame。这样你可以单独测试每个函数,也可以灵活组合。
# src/data/clean.py import pandas as pd def drop_duplicates(df: pd.DataFrame, subset: list) -> pd.DataFrame: """去重,保留第一条""" return df.drop_duplicates(subset=subset, keep='first') def fill_missing(df: pd.DataFrame, col: str, strategy: str = 'median') -> pd.DataFrame: """填充缺失值""" if strategy == 'median': df[col] = df[col].fillna(df[col].median()) elif strategy == 'mean': df[col] = df[col].fillna(df[col].mean()) elif strategy == 'zero': df[col] = df[col].fillna(0) return df def clip_outliers(df: pd.DataFrame, col: str, lower_q: float = 0.01, upper_q: float = 0.99) -> pd.DataFrame: """按分位数截断异常值""" lower = df[col].quantile(lower_q) upper = df[col].quantile(upper_q) df[col] = df[col].clip(lower, upper) return df这种写法的好处是,每个函数都可以单独写单元测试。比如测试clip_outliers,你造一个含极端值的小DataFrame,跑一下,断言结果在分位数范围内。测试通过了,这个函数就可以放心用。
实操心得:清洗函数一定要幂等。也就是说,同一个数据跑两遍清洗,结果应该一样。如果做不到幂等,说明你的函数有副作用,比如依赖了外部状态。这种函数在管道里会出大问题。
3.3 特征工程的版本控制
特征工程比数据清洗更复杂,因为特征会随着业务理解不断迭代。今天你觉得某个特征有用,明天可能发现它泄露了标签。所以特征也需要版本控制。
我的做法是,把特征计算逻辑写成独立的模块,每个特征一个函数,函数上标注版本号和依赖的数据版本。然后用一个features.yaml记录当前使用的特征列表和版本。
# configs/features.yaml features: - name: user_age version: v1 source: data/processed/users.parquet - name: order_count_7d version: v2 source: data/processed/orders.parquet params: window_days: 7这样当特征出问题时,你可以快速定位是哪个特征、哪个版本、依赖哪份数据。排查效率比翻代码高十倍。
4. 实验管理与模型训练:让每次实验都可追溯
4.1 MLflow实战:从手动记录到自动追踪
没有实验追踪的时候,我的记录方式是:在notebook里跑完,把准确率抄到Excel里,模型文件存成model_final_v2_real_final.pkl。这种方式的结局就是,一周后完全不知道哪个文件对应哪次实验。
MLflow解决的就是这个问题。你只需要在训练脚本里加几行代码:
import mlflow import mlflow.sklearn mlflow.set_experiment("my_project") with mlflow.start_run(run_name="exp001"): # 记录参数 mlflow.log_param("learning_rate", 0.01) mlflow.log_param("n_estimators", 100) # 训练模型 model = train_model(X_train, y_train) # 记录指标 mlflow.log_metric("accuracy", 0.92) mlflow.log_metric("f1", 0.89) # 保存模型 mlflow.sklearn.log_model(model, "model") # 保存特征重要性图 mlflow.log_artifact("feature_importance.png")跑完之后,打开MLflow UI,所有实验一目了然。你可以按指标排序,找到最好的那次,直接下载对应的模型文件。更重要的是,每个实验的参数、指标、模型、图表都绑在一起,复现的时候直接看记录就行。
注意:MLflow的tracking server最好单独部署,不要用本地文件存储。团队协作时,大家连同一个server,实验记录才能共享。本地文件存储只适合个人项目。
4.2 超参数搜索的工程化做法
超参数搜索最容易犯的错是:在notebook里写个for循环,跑一晚上,第二天看结果。这种做法的问题是,如果中间断了,前面的结果全丢;而且没法并行,效率低。
工程化的做法是用Optuna或Ray Tune,配合MLflow记录。Optuna的用法很简单:
import optuna def objective(trial): lr = trial.suggest_float("lr", 1e-4, 1e-1, log=True) n_estimators = trial.suggest_int("n_estimators", 50, 500) with mlflow.start_run(nested=True): mlflow.log_params({"lr": lr, "n_estimators": n_estimators}) model = train_model(X_train, y_train, lr=lr, n_estimators=n_estimators) acc = evaluate(model, X_val, y_val) mlflow.log_metric("accuracy", acc) return acc study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=100)Optuna会自动记录每次试验的参数和结果,支持剪枝(提前终止表现差的试验),还支持并行。配合MLflow,每次试验的细节都能追溯。
4.3 训练脚本的标准化模板
我习惯把训练脚本写成一个标准模板,所有项目都套这个模板。模板的核心是:配置驱动、日志完善、检查点自动保存。
# src/training/train.py import logging import yaml import mlflow from pathlib import Path def load_config(path): with open(path) as f: return yaml.safe_load(f) def setup_logging(log_path): logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_path), logging.StreamHandler() ] ) def train(config): setup_logging(config['log_path']) logging.info(f"Starting training with config: {config}") # 加载数据 train_data = load_data(config['data']['train_path']) val_data = load_data(config['data']['val_path']) # 训练 model = build_model(config['model']) for epoch in range(config['training']['epochs']): train_loss = train_epoch(model, train_data) val_loss = validate(model, val_data) logging.info(f"Epoch {epoch}: train_loss={train_loss:.4f}, val_loss={val_loss:.4f}") # 保存检查点 if val_loss < best_val_loss: best_val_loss = val_loss save_checkpoint(model, config['checkpoint_path']) return model if __name__ == "__main__": config = load_config("configs/base.yaml") train(config)这个模板的关键点是:所有参数从配置来,日志同时输出到文件和终端,检查点自动保存最好的模型。这样你跑训练的时候可以去干别的事,回来看日志就行。
5. 模型部署与服务化:从notebook到线上API
5.1 为什么FastAPI是AI服务的好选择
模型训练完,下一步是让其他系统能调用它。最简单的方式是写个Flask应用,但Flask是同步的,并发能力弱。FastAPI是异步的,性能好很多,而且自动生成API文档,调试方便。
一个最小的模型服务长这样:
# src/serving/app.py from fastapi import FastAPI from pydantic import BaseModel import joblib import numpy as np app = FastAPI() model = joblib.load("models/model.pkl") class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): prediction: float probability: float @app.post("/predict", response_model=PredictResponse) async def predict(request: PredictRequest): X = np.array(request.features).reshape(1, -1) pred = model.predict(X)[0] prob = model.predict_proba(X)[0].max() return PredictResponse(prediction=float(pred), probability=float(prob)) @app.get("/health") async def health(): return {"status": "ok"}跑起来用uvicorn src.serving.app:app --host 0.0.0.0 --port 8000,然后访问/docs就能看到自动生成的API文档,可以直接在浏览器里测试。
实操心得:模型加载一定要放在应用启动时,不要放在请求处理函数里。放在请求里每次都要重新加载模型,性能极差。如果模型很大,加载慢,可以用lazy loading,第一次请求时加载,之后缓存。
5.2 模型格式选择:pickle、ONNX还是TorchScript
模型保存格式直接影响部署的灵活性和性能。我对比过几种常见格式:
| 格式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| pickle | 简单、Python原生 | 依赖训练时的环境、不安全 | 内部快速原型 |
| ONNX | 跨框架、跨语言、性能好 | 转换可能丢精度 | 生产环境、多语言调用 |
| TorchScript | PyTorch原生、支持动态图 | 只适用于PyTorch | PyTorch模型部署 |
| SavedModel | TensorFlow原生、生态好 | 只适用于TF | TensorFlow模型部署 |
我的建议是:如果模型要上生产,优先转ONNX。ONNX Runtime的推理速度通常比原生框架快,而且可以在C++、Java、C#里调用,不依赖Python环境。转换也不难:
import torch import torch.onnx # 假设model是PyTorch模型 dummy_input = torch.randn(1, input_dim) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}} )转换后可以用ONNX Runtime加载:
import onnxruntime as ort session = ort.InferenceSession("model.onnx") input_name = session.get_inputs()[0].name output = session.run(None, {input_name: X.astype(np.float32)})实测下来,ONNX Runtime的推理延迟比PyTorch原生低30%左右,内存占用也小。
5.3 容器化部署:Dockerfile怎么写才不踩坑
模型服务要部署到服务器,Docker是最方便的方式。但写Dockerfile有几个坑:
第一,基础镜像不要用python:3.9这种全量镜像,太大。用slim版本,能小一半。
第二,依赖安装要分层。先复制requirements.txt,安装依赖,再复制代码。这样改代码时不用重装依赖,构建快。
第三,模型文件不要打进镜像。镜像应该只包含代码和依赖,模型文件通过挂载卷或启动时下载。
一个经过验证的Dockerfile:
FROM python:3.9-slim WORKDIR /app # 先装依赖 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再复制代码 COPY src/ ./src/ COPY configs/ ./configs/ # 模型通过卷挂载 VOLUME /app/models EXPOSE 8000 CMD ["uvicorn", "src.serving.app:app", "--host", "0.0.0.0", "--port", "8000"]构建和运行:
docker build -t my-ai-service . docker run -d -p 8000:8000 -v /path/to/models:/app/models my-ai-service注意:
requirements.txt里要固定版本号,比如fastapi==0.104.1,不要写fastapi。不固定版本的话,今天构建能跑,明天可能就挂了,因为依赖更新了。
6. 监控与迭代:上线只是开始,不是结束
6.1 线上监控要盯哪些指标
模型上线后,最怕的是“静默失败”——服务没挂,但预测结果越来越差。所以监控要分两层:系统层和模型层。
系统层指标用Prometheus收集,包括:请求量、延迟、错误率、CPU/内存使用率。这些指标Grafana有现成模板,配一下就行。
模型层指标需要自己埋点,包括:预测分布、特征分布、置信度分布。比如你可以记录每次请求的预测值,然后定期统计分布。如果发现预测分布突然偏移,说明数据分布变了,模型可能失效了。
from prometheus_client import Histogram, Counter PREDICTION_HIST = Histogram( 'model_prediction_value', 'Distribution of model predictions', buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] ) REQUEST_COUNT = Counter( 'model_request_total', 'Total model requests', ['status'] ) @app.post("/predict") async def predict(request: PredictRequest): try: result = model.predict(request.features) PREDICTION_HIST.observe(result) REQUEST_COUNT.labels(status="success").inc() return result except Exception as e: REQUEST_COUNT.labels(status="error").inc() raise6.2 数据漂移检测的简单实现
数据漂移是指线上数据的分布和训练数据不一致。检测方法有很多,最简单的是PSI(Population Stability Index)。PSI的计算方式是:把训练数据和线上数据按特征分桶,计算每个桶的占比差异,然后加权求和。
import numpy as np def calculate_psi(expected, actual, buckets=10): """计算PSI,expected是训练数据,actual是线上数据""" breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf expected_percents = np.histogram(expected, breakpoints)[0] / len(expected) actual_percents = np.histogram(actual, breakpoints)[0] / len(actual) # 避免除零 expected_percents = np.where(expected_percents == 0, 0.0001, expected_percents) actual_percents = np.where(actual_percents == 0, 0.0001, actual_percents) psi = np.sum((actual_percents - expected_percents) * np.log(actual_percents / expected_percents)) return psiPSI的判断标准:小于0.1说明分布稳定,0.1到0.25说明有轻微漂移,大于0.25说明漂移严重,需要重新训练模型。
实操心得:PSI检测要定期跑,比如每天跑一次。不要等出问题了才查。我习惯把PSI计算做成定时任务,结果写到数据库,Grafana里画成趋势图。这样漂移是渐变还是突变,一目了然。
6.3 模型迭代的闭环流程
模型迭代不是“重新训练一次”那么简单,而是一个闭环:监控发现问题、分析原因、准备数据、训练新模型、评估、上线、继续监控。
这个闭环里最容易忽略的是评估环节。新模型不能只看离线指标,还要做A/B测试。我的做法是,新模型上线后,先切10%的流量过去,对比新旧模型的线上指标。如果新模型在关键指标上不差于旧模型,再逐步扩大流量。
A/B测试的流量切分可以在网关层做,也可以在服务层做。服务层做更灵活:
import random MODEL_A = load_model("models/model_a.onnx") MODEL_B = load_model("models/model_b.onnx") @app.post("/predict") async def predict(request: PredictRequest): if random.random() < 0.1: model = MODEL_B version = "B" else: model = MODEL_A version = "A" result = model.predict(request.features) # 记录版本,方便后续分析 log_prediction(version, request.features, result) return result跑一周后,对比A和B的线上指标,决定是否全量切换。
7. 常见问题与排查技巧实录
7.1 训练时loss不下降,怎么排查
这是最常见的问题。排查顺序应该是:先看数据,再看模型,最后看超参。
第一步,检查数据。把一批数据喂给模型,看输出是否合理。如果模型输出全是同一个值,说明数据可能有问题,比如标签全是0,或者特征全是NaN。
第二步,检查模型。用一个极小的数据集(比如10条)过拟合一下。如果模型连10条数据都拟合不了,说明模型结构有问题,比如层数太少、激活函数不对。
第三步,检查超参。学习率太大,loss会震荡;学习率太小,loss下降极慢。可以试试用学习率finder,找一个合适的初始学习率。
# 简单的学习率finder lrs = np.logspace(-5, -1, 100) losses = [] for lr in lrs: model = build_model() optimizer = torch.optim.SGD(model.parameters(), lr=lr) loss = train_one_step(model, optimizer, data) losses.append(loss) # 画图,找loss下降最快的点 import matplotlib.pyplot as plt plt.plot(lrs, losses) plt.xscale('log') plt.show()7.2 线上服务延迟高,从哪里入手
延迟高的问题,排查思路是:先定位瓶颈在哪一层,再针对性优化。
| 排查点 | 可能原因 | 优化方法 |
|---|---|---|
| 网络层 | 请求体太大、网络带宽不足 | 压缩请求、升级带宽 |
| 应用层 | 同步阻塞、GIL限制 | 用异步框架、多进程 |
| 模型层 | 模型太大、计算量大 | 模型量化、剪枝、用ONNX |
| 数据层 | 特征查询慢 | 加缓存、预计算特征 |
我遇到最多的是模型层的问题。一个BERT模型,原始PyTorch推理要200ms,转ONNX后降到80ms,再量化到INT8后降到30ms。所以模型优化是收益最大的方向。
7.3 依赖冲突怎么解决
Python依赖冲突是AI工程的经典问题。项目A需要numpy 1.19,项目B需要numpy 1.21,装在一起就炸。解决方案有三个:
第一,用虚拟环境隔离。每个项目一个venv,互不影响。这是最基本的做法。
第二,用Docker。每个服务一个容器,依赖完全隔离。这是生产环境的标配。
第三,用poetry或pipenv管理依赖。它们会生成lock文件,确保每次安装的版本一致。
我个人的习惯是:开发时用conda创建虚拟环境,部署时用Docker。conda的好处是能装非Python依赖,比如CUDA、cuDNN,这对GPU训练很重要。
# 创建环境 conda create -n my_project python=3.9 conda activate my_project # 安装PyTorch(会自动装CUDA依赖) conda install pytorch torchvision torchaudio cudatoolkit=11.3 -c pytorch # 其他依赖用pip pip install -r requirements.txt7.4 常见问题速查表
| 问题现象 | 可能原因 | 快速排查方法 |
|---|---|---|
| 训练loss为NaN | 学习率太大、数据有inf | 降低学习率、检查数据 |
| 验证集指标远低于训练集 | 过拟合 | 加正则化、加数据、早停 |
| 线上预测结果和离线不一致 | 特征处理不一致 | 对比线上线下特征管道 |
| 服务启动报错找不到模型 | 路径不对、模型未挂载 | 检查挂载卷和配置路径 |
| 推理速度慢 | 模型未优化、批处理不当 | 转ONNX、加批处理 |
| 内存泄漏 | 全局变量累积、缓存未清理 | 用memory_profiler排查 |
8. 我在这套流程里踩过的几个坑
第一个坑是过早优化。刚开始做AI工程时,我总想一步到位,把MLflow、DVC、Airflow全装上。结果光是配这些工具就花了两周,真正写模型的时间反而少了。后来我学乖了,先跑通最小闭环:数据加载、训练、保存模型、FastAPI服务。等这个闭环跑顺了,再逐步加实验追踪、数据版本、监控。工具是解决问题的,不是制造问题的。
第二个坑是忽略测试。AI代码也需要单元测试,尤其是数据清洗和特征工程部分。这些代码逻辑复杂,容易出错,而且出错后很难发现。我现在的习惯是,每个清洗函数都写测试,用pytest跑。测试数据不用多,几条就够,关键是覆盖边界情况。
第三个坑是配置散落。早期我把数据库密码、API密钥写在代码里,后来代码传到git,密钥就泄露了。正确的做法是用环境变量或密钥管理服务。配置文件中只放非敏感信息,敏感信息通过环境变量注入。
import os DB_PASSWORD = os.environ.get("DB_PASSWORD") API_KEY = os.environ.get("API_KEY")第四个坑是不做回滚预案。新模型上线后效果变差,想回滚却发现旧模型没保存。所以每次上线新模型,一定要保留旧模型和对应的配置,确保能一键回滚。我的做法是,模型文件按版本号命名,配置也按版本号存档,上线时记录当前版本,回滚时切回上一个版本。
这套从零搭建AI工程体系的流程,我前后迭代了两年多,现在基本稳定了。新项目启动时,我直接复制这套骨架,改改配置就能跑。省下来的时间,可以花在真正重要的事情上:理解业务、优化模型、分析数据。工程化不是为了炫技,是为了让你能专注于创造价值的部分。