1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
"ai-engineering-from-scratch"这个标题,第一次看到的时候我愣了一下。不是因为陌生,恰恰相反,是因为太熟悉了——过去两年,我见过太多人抱着"从零开始学AI工程"的念头冲进来,结果三天后就开始复制粘贴别人的训练脚本,两周后连自己模型为什么收敛都说不清楚。这个标题背后藏着的,其实是一个被严重低估的问题:AI工程的门槛,从来不在调包,而在于你有没有真正理解数据、模型、训练、部署这条链路上每一个环节的因果关系。
我自己是从传统后端转过来的,最早做推荐系统那会儿,连梯度下降都推不利索,全靠sklearn的fit和predict混日子。后来被一个线上事故逼着啃了三个月底层,才发现之前写的所谓"AI工程"代码,本质上就是在黑盒外面套了一层壳。所以当我看到"ai-engineering-from-scratch"这个方向时,第一反应不是"又一个教程",而是"终于有人愿意把这条脏活累活的路径讲清楚了"。
这篇文章想做的事情很具体:把AI工程从零搭建的完整路径拆开,告诉你每一步为什么这么做、不这么做会踩什么坑、以及在实际操作中哪些环节最容易翻车。适合谁看?如果你已经会写Python、懂一点线性代数和概率论,但面对一个真实的AI项目不知道从哪下手,那这篇内容就是给你准备的。如果你已经是老手,也可以看看我在数据版本管理、训练可复现性、推理性能优化这几个环节的实操记录,说不定能帮你省下几个通宵。
核心关键词"ai-engineering-from-scratch"我会在全文反复提到,但不会硬塞。我更想让你读完之后的感受是:原来从零搭一套AI工程体系,是这么一回事。
2. 整体设计思路:为什么我不建议你从模型开始
2.1 先搞清楚AI工程和算法研究的边界
很多人把AI工程和算法研究混为一谈,这是第一个大坑。算法研究的目标是刷高指标,AI工程的目标是让一个模型在真实业务场景里稳定、可维护、可迭代地跑起来。这两个目标的差异,决定了你从零搭建时的优先级完全不同。
我见过一个团队,花两个月调出了一个在公开数据集上F1值0.92的模型,结果上线第一天就崩了——因为线上数据的分布和训练集差了十万八千里,而且他们没有做任何数据监控。这就是典型的"研究思维做工程"。从零搭建AI工程体系,第一件事不是选模型,而是定义清楚你的输入输出契约、数据流转路径、以及失败时的降级策略。
具体来说,我会把整个体系分成五层:数据层、特征层、训练层、服务层、监控层。每一层都有独立的职责和接口,层与层之间通过明确的契约通信。这样做的好处是,当你的模型需要从XGBoost换成Transformer时,只需要改训练层和服务层的适配代码,数据层和特征层几乎不用动。
2.2 技术选型的核心逻辑:可控性优先于先进性
从零搭建的时候,最容易犯的错就是追新。今天看到某个新框架出来了,赶紧换;明天看到某个新工具火了,马上接。结果搭了半年,体系没成型,技术债倒是欠了一堆。
我的选型原则很简单:在满足性能要求的前提下,优先选你能完全掌控的组件。举个例子,模型服务这块,你可以用现成的推理框架,也可以自己用FastAPI写一个。如果你的QPS要求不高(比如每秒几十次),我强烈建议自己写。为什么?因为自己写的服务,每一行代码你都知道在干什么,出问题的时候能快速定位。用现成框架,一旦遇到版本兼容或者性能瓶颈,排查成本会高得离谱。
再比如数据版本管理。很多人一上来就上DVC或者LakeFS,但如果你只是一个人做项目,数据量在几十GB以内,用Git LFS加一套命名规范就足够了。工具越简单,你越能把精力放在真正重要的事情上——理解数据和模型的行为。
2.3 从零搭建的四个阶段划分
我把整个搭建过程分成四个阶段,每个阶段有明确的交付物和验收标准:
| 阶段 | 核心目标 | 交付物 | 验收标准 |
|---|---|---|---|
| 第一阶段 | 跑通最小闭环 | 一个能训练、能推理的脚本 | 在样例数据上端到端跑通 |
| 第二阶段 | 建立可复现流程 | 数据版本、配置管理、实验记录 | 同一配置能复现同一结果 |
| 第三阶段 | 服务化与性能优化 | 推理服务、批处理、缓存 | 满足延迟和吞吐要求 |
| 第四阶段 | 监控与迭代机制 | 数据漂移检测、模型重训流程 | 能自动发现并响应异常 |
这个划分的好处是,你不需要一次性把所有东西都搭好。每个阶段结束后,你都有一个能用的东西,而不是一堆半成品。我见过太多人想一步到位,结果三个月后连一个能跑的demo都没有。
提示:第一阶段千万不要追求完美。你的目标是在最短时间内让数据流过整个链路,哪怕模型用的是逻辑回归,哪怕服务用的是Flask自带的开发服务器。先跑通,再优化。
3. 核心细节解析:数据、训练、服务三个环节的实操要点
3.1 数据层:从零搭建时最容易被忽视的环节
数据层是AI工程的地基,但也是最容易被跳过的一环。很多人拿到数据就直接往模型里塞,结果训练集和测试集有重叠、类别极度不平衡、特征泄漏这些问题一个接一个。
从零搭建数据层,我建议你至少做四件事:
第一,建立数据字典。每一个字段的含义、类型、取值范围、缺失率、更新频率,都要记录清楚。这个字典不是给别人看的,是给你自己三个月后看的。我吃过亏,一个项目隔了两个月回来改,完全不记得某个字段的0和1代表什么,翻代码翻了半天。
第二,做数据质量检查。至少包括:缺失值比例、异常值检测、类别分布、时间分布。这些检查要写成脚本,每次数据更新都跑一遍。我一般用pandas-profiling生成一份报告,然后针对关键字段写断言。
第三,划分数据集要讲究策略。如果是时序数据,绝对不能随机划分,必须按时间切分。如果是分类任务,要保证训练集和测试集的类别分布一致。我通常会用分层抽样,并且保留一个独立的验证集用于调参。
第四,数据版本管理。哪怕你只用Git LFS,也要给每次数据更新打上标签。我的做法是:数据文件用日期加哈希命名,比如train_20240115_a3f2c1.parquet,然后在配置文件中引用这个文件名。这样任何时候都能追溯到用的是哪份数据。
# 一个简单的数据质量检查脚本示例 import pandas as pd def check_data_quality(df, target_col): report = {} report['missing_rate'] = df.isnull().mean().to_dict() report['target_distribution'] = df[target_col].value_counts(normalize=True).to_dict() report['numeric_summary'] = df.describe().to_dict() # 检查类别不平衡 target_dist = df[target_col].value_counts(normalize=True) if target_dist.min() < 0.05: print(f"警告:类别不平衡,最小类别占比 {target_dist.min():.2%}") return report3.2 训练层:可复现性是底线,不是加分项
训练层最核心的要求只有一个:可复现。同一份数据、同一份配置、同一个随机种子,必须得到同一个模型。做不到这一点,后面所有的实验对比都是空中楼阁。
从零搭建训练层,你需要控制三个东西:随机种子、配置管理、实验记录。
随机种子这块,Python的random、numpy的random、框架的random(比如PyTorch的torch.manual_seed)都要设置。如果用了GPU,还要设置CUDA的种子。我一般会写一个set_seed函数,在训练脚本开头调用。
配置管理我推荐用YAML文件,把所有超参数、数据路径、模型结构都写进去。不要用argparse传一堆参数,那样实验记录很难管理。YAML文件加上Git的版本控制,就能保证任何时候都能回到某个实验的配置。
实验记录这块,小规模可以用CSV或者SQLite,大规模可以用MLflow或者Weights & Biases。我个人的习惯是:每个实验一个文件夹,里面放配置文件、训练日志、模型权重、评估结果。文件夹命名用实验名_日期_哈希的格式。
# config.yaml 示例 data: train_path: "data/train_20240115_a3f2c1.parquet" valid_path: "data/valid_20240115_a3f2c1.parquet" target_col: "label" model: type: "xgboost" params: max_depth: 6 learning_rate: 0.05 n_estimators: 500 training: seed: 42 batch_size: 1024 epochs: 50 early_stopping_rounds: 10注意:配置文件里不要写绝对路径。用相对于项目根目录的路径,这样换一台机器也能跑。
3.3 服务层:推理性能优化的三个关键点
服务层是从零搭建时最能体现工程能力的地方。很多人模型训得好好的,一上线就崩,问题基本都出在服务层。
第一个关键点是批处理。单条推理的效率极低,尤其是深度学习模型。如果你的业务场景允许微批(比如每10毫秒攒一批请求),一定要做批处理。我实测下来,批大小从1提到32,吞吐量能提升10倍以上,延迟只增加几毫秒。
第二个关键点是缓存。很多请求的特征是重复的,尤其是推荐和搜索场景。在服务层加一层LRU缓存,命中率往往能到30%以上。缓存key用特征的哈希值,注意要设置合理的过期时间。
第三个关键点是降级策略。模型服务不可能永远不出问题,当模型推理超时或者返回异常时,要有兜底方案。最简单的降级是返回一个默认结果(比如热门商品),好一点的降级是切换到备用模型(比如轻量级的LRM)。
# 一个带批处理和缓存的推理服务示例 from fastapi import FastAPI from functools import lru_cache import numpy as np app = FastAPI() @lru_cache(maxsize=10000) def cached_predict(feature_hash): # 实际推理逻辑 pass @app.post("/predict") async def predict(request: dict): features = request["features"] feature_hash = hash(tuple(features)) # 先查缓存 cached = cached_predict(feature_hash) if cached is not None: return {"result": cached, "source": "cache"} # 批处理逻辑(简化示意) result = model.predict(np.array([features])) return {"result": result.tolist(), "source": "model"}4. 完整实操流程:从空文件夹到可服务的AI系统
4.1 项目结构初始化与依赖管理
从零开始的第一步,是建立一个清晰的项目结构。我用了很多年的结构是这样的:
ai-project/ ├── configs/ # 配置文件 ├── data/ # 数据目录(不纳入Git) ├── src/ │ ├── data/ # 数据处理代码 │ ├── features/ # 特征工程代码 │ ├── models/ # 模型定义 │ ├── training/ # 训练脚本 │ └── serving/ # 服务代码 ├── experiments/ # 实验记录 ├── tests/ # 测试代码 ├── requirements.txt └── README.md依赖管理我推荐用requirements.txt加虚拟环境。不要用全局Python环境,否则不同项目的依赖会打架。虚拟环境用venv就够了,简单直接。如果团队协作,可以考虑poetry,但一个人做项目没必要。
# 初始化项目 mkdir ai-project && cd ai-project python -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate pip install pandas numpy scikit-learn xgboost fastapi uvicorn pyyaml pip freeze > requirements.txt4.2 数据处理流水线的搭建
数据处理流水线的核心目标是:从原始数据到模型输入,每一步都可追溯、可复现。我一般会把流水线拆成三个脚本:prepare_data.py、build_features.py、split_data.py。
prepare_data.py负责读取原始数据、做基础清洗(去重、处理缺失值、类型转换)。这个脚本的输出是一份干净的中间数据,存成parquet格式。
build_features.py负责特征工程。所有的特征变换逻辑都写在这里,包括归一化、编码、交叉特征。注意,归一化的参数(均值和方差)必须从训练集计算,然后应用到验证集和测试集,否则就是特征泄漏。
split_data.py负责划分数据集。时序数据按时间切,非时序数据用分层抽样。划分结果要保存成独立的文件,并且记录划分的随机种子。
# build_features.py 的核心逻辑 import pandas as pd from sklearn.preprocessing import StandardScaler import joblib def build_features(train_df, valid_df, test_df, numeric_cols): scaler = StandardScaler() # 只在训练集上fit train_df[numeric_cols] = scaler.fit_transform(train_df[numeric_cols]) valid_df[numeric_cols] = scaler.transform(valid_df[numeric_cols]) test_df[numeric_cols] = scaler.transform(test_df[numeric_cols]) # 保存scaler供服务层使用 joblib.dump(scaler, "experiments/scaler.pkl") return train_df, valid_df, test_df提示:特征工程的代码一定要和服务层的特征处理代码保持一致。我的做法是把特征处理逻辑封装成一个类,训练和服务都调用同一个类,避免线上线下不一致。
4.3 模型训练与超参数搜索的实操记录
模型训练这块,我的建议是:先跑一个基线模型,再考虑复杂模型。基线模型用逻辑回归或者决策树,目的是验证数据流水线没问题、评估指标计算正确。基线跑通之后,再上XGBoost或者深度学习模型。
超参数搜索我一般用Optuna,比GridSearchCV灵活,支持早停和剪枝。搜索空间不要设太大,先粗后细。比如学习率先搜[0.01, 0.1, 0.3],找到大致范围后再细化。
import optuna import xgboost as xgb from sklearn.metrics import roc_auc_score def objective(trial): params = { "max_depth": trial.suggest_int("max_depth", 3, 10), "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True), "n_estimators": trial.suggest_int("n_estimators", 100, 1000), "subsample": trial.suggest_float("subsample", 0.6, 1.0), } model = xgb.XGBClassifier(**params, early_stopping_rounds=10) model.fit(X_train, y_train, eval_set=[(X_valid, y_valid)], verbose=False) preds = model.predict_proba(X_valid)[:, 1] return roc_auc_score(y_valid, preds) study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50) print(f"最佳参数: {study.best_params}") print(f"最佳AUC: {study.best_value:.4f}")实测下来,50次试验通常能找到接近最优的参数组合。如果数据量特别大,可以先用10%的采样数据搜参数,再用全量数据训练最终模型。
4.4 推理服务的部署与压测
推理服务部署这块,我用FastAPI加Uvicorn的组合最多。FastAPI的异步特性适合IO密集型的场景,Uvicorn的worker数量根据CPU核数来定,一般是核数加一。
压测用Locust或者wrk。我一般会测三个指标:P50延迟、P99延迟、QPS。P99延迟比平均延迟重要得多,因为用户体验是由长尾决定的。
# 启动服务 uvicorn src.serving.main:app --host 0.0.0.0 --port 8000 --workers 4 # 用wrk压测 wrk -t4 -c100 -d30s http://localhost:8000/predict压测的时候要注意,请求的数据要尽量模拟真实分布。我见过有人用全零的特征压测,结果QPS高得离谱,上线后直接崩了。正确的做法是从验证集里随机采样一批真实特征作为压测输入。
5. 常见问题与排查技巧实录
5.1 训练不收敛或指标异常的排查思路
训练出问题是最常见的,我整理了一个排查顺序,基本能覆盖80%的情况:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss不下降 | 学习率过大或过小 | 尝试1e-2到1e-5的学习率 |
| loss震荡 | batch size太小 | 增大batch size或减小学习率 |
| 训练集指标高,验证集低 | 过拟合 | 加正则化、早停、更多数据 |
| 训练集和验证集都低 | 欠拟合 | 增加模型复杂度、更多特征 |
| 指标突然变差 | 数据问题 | 检查数据分布是否变化 |
我踩过最坑的一次是:模型训练集AUC 0.95,验证集AUC 0.52。排查了半天,发现是特征里有一个字段在验证集里全是缺失值。所以,每次训练前都要检查训练集和验证集的特征分布,这个习惯帮我省了很多时间。
5.2 线上线下效果不一致的定位方法
线上线下不一致是AI工程里最头疼的问题之一。原因通常有三个:特征不一致、数据分布不一致、模型版本不一致。
定位方法很简单:在服务层记录每次推理的输入特征和输出结果,然后离线用同样的特征跑一遍模型,对比结果。如果离线结果和线上不一致,那就是服务层的特征处理有问题;如果一致但业务指标差,那就是数据分布的问题。
我一般会在服务层加一个采样日志,把1%的请求特征和结果存下来。这个日志不仅能用于排查问题,还能作为后续模型重训的数据来源。
5.3 性能瓶颈的快速定位技巧
服务层性能瓶颈的定位,我一般用"分层计时"的方法。在请求处理的每个关键节点打上时间戳,然后统计各阶段的耗时占比。
import time def predict_with_timing(features): timings = {} t0 = time.time() features = preprocess(features) timings['preprocess'] = time.time() - t0 t0 = time.time() result = model.predict(features) timings['inference'] = time.time() - t0 t0 = time.time() result = postprocess(result) timings['postprocess'] = time.time() - t0 return result, timings实测下来,大部分性能问题都出在预处理阶段,尤其是特征查询和拼接。如果预处理耗时占比超过50%,就要考虑加缓存或者预计算了。
注意:不要过早优化。先用分层计时找到瓶颈,再针对性优化。我见过有人一上来就把模型量化了,结果发现瓶颈根本不在推理,而在数据库查询。
5.4 模型更新与回滚的机制设计
模型更新不能直接覆盖线上模型,必须有一套灰度发布和回滚机制。我的做法是:新模型先跑影子模式,也就是接收线上流量但不返回结果,对比新老模型的输出差异。差异在可接受范围内,再切5%的流量做A/B测试。A/B测试指标正向,再逐步放大流量。
回滚机制要简单可靠。我一般会保留最近三个版本的模型文件,服务层通过配置切换版本。一旦发现问题,改配置重启服务就能回滚,整个过程不超过一分钟。
# 模型版本管理示例 import os class ModelManager: def __init__(self, model_dir): self.model_dir = model_dir self.current_version = None self.model = None def load(self, version): path = os.path.join(self.model_dir, f"model_{version}.pkl") self.model = joblib.load(path) self.current_version = version def rollback(self): versions = sorted(os.listdir(self.model_dir)) if len(versions) >= 2: previous = versions[-2] self.load(previous)这套机制看起来简单,但关键时刻能救命。我有一次上线新模型后,发现某个重要类别的召回率掉了20%,靠回滚机制在五分钟内恢复了服务,然后慢慢排查问题。
6. 从零搭建AI工程体系的个人体会
做AI工程这些年,我最大的体会是:从零搭建的价值不在于你用了多先进的技术,而在于你对每一个环节的理解深度。调包谁都会,但知道为什么调这个包、什么时候不该调这个包,才是工程能力的体现。
"ai-engineering-from-scratch"这个方向,我建议你至少完整走一遍。不要跳过数据层,不要跳过服务层,不要觉得监控层可有可无。每一个环节你亲手搭过一遍,后面用现成工具的时候,才知道工具帮你做了什么、没帮你做什么。
最后分享一个小技巧:每次搭建新项目的时候,我都会先写一个README.md,把整个体系的架构图画出来,把每个模块的职责写清楚。这个README不是给别人看的,是给我自己看的。写不清楚的地方,往往就是我没想清楚的地方。这个习惯帮我避免了很多返工。
如果你也在从零搭建自己的AI工程体系,欢迎交流踩坑经验。这个领域没有银弹,只有一个个具体的、琐碎的、但必须解决的问题。