1. 从零搭建AI工程体系,为什么我劝你别急着调包
很多人一提到AI工程,脑子里第一反应就是pip install几个框架,然后照着官方示例跑通一个demo,就觉得已经入门了。我刚开始接触这块的时候也是这个心态,觉得模型能跑起来、能输出结果就算完事。但真正到了要交付一个能扛住真实流量、能持续迭代、能排查问题的系统时,才发现之前那套玩法根本不够用。
ai-engineering-from-scratch这个标题,核心讲的其实就是一件事:把AI工程当成一门正经的工程学科来对待,从最底层的数据处理、模型训练、服务部署、监控运维一步步搭起来,而不是停留在调包和跑demo的层面。它适合那些已经会用Python、懂一点机器学习基础,但一到生产环境就抓瞎的开发者;也适合那些做了几年后端,想转AI方向但不知道从哪下手的工程师。
我写这篇东西的出发点很简单:市面上讲AI的文章,要么是论文解读,要么是框架文档翻译,真正讲“怎么把一个AI系统从零搭到能用”的内容太少了。我自己踩过的坑、熬过的夜、翻过的日志,希望能帮你少走一点弯路。下面我会按照一个完整的AI工程生命周期来拆解,从整体设计思路到每个环节的实操细节,尽量把“为什么这么做”讲清楚,而不是只丢一堆代码让你抄。
2. 整体架构设计与技术选型思路
2.1 为什么要有“从零”这个执念
先说说为什么我坚持要从零搭,而不是直接用现成的平台或者高度封装的框架。原因有三个。
第一,排查问题的能力是建立在理解底层的基础上的。你用封装好的API跑模型,一旦输出结果不对,你根本不知道是数据预处理出了问题、模型加载出了问题、还是后处理逻辑有bug。但如果你自己写过数据管道、自己加载过权重、自己实现过推理循环,你就能像剥洋葱一样一层层定位。
第二,定制化需求迟早会来。业务方今天说“能不能加个新特征”,明天说“能不能换个损失函数”,后天说“能不能支持多模型融合”。如果你用的是高度封装的方案,每次改动都要跟框架的抽象层搏斗;但如果是自己搭的,改起来就是改自己的代码,心里有底。
第三,成本控制。现成的AI平台按调用次数或者算力时长收费,量小的时候无所谓,量一大账单就吓人。自己搭一套,虽然前期投入大,但长期来看单位成本低得多,而且资源调度完全自主。
当然,我不是说所有场景都要从零造轮子。如果你只是做个内部工具、验证一个想法,用现成方案完全没问题。但如果你想真正掌握AI工程这门手艺,从零搭一遍是绕不过去的。
2.2 分层架构:把复杂度关进笼子里
一个完整的AI工程系统,我习惯把它分成五层。这个分法不是教科书上的标准答案,是我自己在实际项目中总结出来的,好处是每一层的职责边界清晰,出问题的时候能快速定位到是哪一层的事。
| 层级 | 职责 | 典型技术选型 | 常见坑点 |
|---|---|---|---|
| 数据层 | 数据采集、清洗、标注、存储 | Pandas、Spark、Label Studio | 数据泄漏、标注不一致 |
| 特征层 | 特征提取、转换、存储 | Feast、Feast、自研特征库 | 训练/推理特征不一致 |
| 模型层 | 模型定义、训练、调优 | PyTorch、Lightning、Optuna | 过拟合、梯度爆炸 |
| 服务层 | 模型部署、推理API | FastAPI、Triton、ONNX Runtime | 延迟高、并发上不去 |
| 运维层 | 监控、日志、告警、回滚 | Prometheus、Grafana、ELK | 指标缺失、告警风暴 |
这个表看着简单,但每一层展开都是一堆细节。我见过太多项目,数据层和特征层糊在一起,结果训练的时候特征是对的,上线之后特征错了,查了一周才发现是两边用的时间窗口不一致。所以分层不是为了好看,是为了让每一层的输入输出可验证、可复现。
2.3 技术选型的几个关键决策
选型这块我不打算列一堆框架让你挑,而是讲几个我实际做决策时的思考框架。
训练框架选PyTorch还是TensorFlow?我的判断标准很简单:看你的团队背景和社区生态。PyTorch在研究和快速迭代场景下更顺手,动态图调试方便;TensorFlow在部署和移动端有优势。但说实话,现在两者的差距在缩小,选哪个都能干活,关键是团队里有人能hold住。
服务框架选FastAPI还是Triton?如果你的模型是标准的深度学习模型,Triton的性能和并发能力更强,但学习曲线陡;如果只是简单的sklearn模型或者自定义逻辑多,FastAPI更灵活。我一般先用FastAPI快速搭原型,等性能瓶颈出现了再考虑迁移到Triton。
特征存储要不要上Feast?看你的特征复用程度。如果只有一两个模型用同一批特征,自己写个特征库就够了;如果有十几个模型共享特征,Feast这种专门的特征存储能省很多事。但引入新组件意味着新的运维成本,要权衡。
提示:选型的时候不要只看技术指标,还要看团队的学习成本和运维成本。一个你团队玩不转的“先进”方案,不如一个大家都能维护的“普通”方案。
3. 数据管道与特征工程的核心细节
3.1 数据清洗:脏数据比你想象的多
我做过一个项目,原始数据是从业务系统导出的CSV,看起来挺规整。结果一跑统计,发现时间字段有五种格式,数值字段里混着中文单位,还有几万条重复记录。如果你直接把这些数据喂给模型,结果可想而知。
数据清洗我一般按这个顺序来:
- 格式统一:时间字段全部转成ISO 8601,数值字段去掉单位并转成float,类别字段统一大小写和编码。
- 缺失值处理:先统计缺失比例,超过50%的字段直接考虑丢弃;低于50%的根据业务含义选择填充策略(均值、中位数、众数、前向填充等)。
- 异常值检测:用IQR或者Z-score先筛一遍,但不要急着删,先看看这些异常值是不是有业务含义。我遇到过“异常值”其实是高价值用户的情况,删了就亏大了。
- 去重:根据业务主键去重,注意有些重复是正常的(比如用户多次购买),要区分对待。
import pandas as pd import numpy as np def clean_data(df): # 时间字段标准化 df['timestamp'] = pd.to_datetime(df['timestamp'], errors='coerce') # 数值字段清洗 df['amount'] = df['amount'].astype(str).str.replace(r'[^\d.]', '', regex=True) df['amount'] = pd.to_numeric(df['amount'], errors='coerce') # 缺失值统计 missing_ratio = df.isnull().mean() cols_to_drop = missing_ratio[missing_ratio > 0.5].index df = df.drop(columns=cols_to_drop) # 剩余缺失值填充 for col in df.select_dtypes(include=[np.number]).columns: df[col] = df[col].fillna(df[col].median()) return df这段代码看着简单,但每一步都有讲究。比如errors='coerce'会把无法解析的时间变成NaT,而不是直接报错,这样你能看到有多少条数据有问题。再比如填充缺失值用中位数而不是均值,是因为中位数对异常值更鲁棒。
3.2 特征工程:训练和推理必须用同一套逻辑
这是我最想强调的一点。很多项目在训练的时候用Pandas做特征,上线的时候用另一套代码做特征,结果两边算出来的特征分布不一致,模型效果直接崩掉。
解决方案是把特征计算逻辑封装成独立的模块,训练和推理都调用同一个模块。这个模块的输入是原始数据,输出是特征向量,中间不依赖任何训练时才有的状态(比如全局均值、方差这些要从训练集算出来存下来,推理时直接加载)。
class FeatureEngineer: def __init__(self, stats=None): self.stats = stats or {} def fit(self, df): # 计算并存储训练集统计量 self.stats['amount_mean'] = df['amount'].mean() self.stats['amount_std'] = df['amount'].std() return self def transform(self, df): # 使用存储的统计量做标准化 df['amount_normalized'] = (df['amount'] - self.stats['amount_mean']) / self.stats['amount_std'] return df这个模式的好处是,训练时先fit再transform,推理时直接transform,用的都是同一套stats。你可以把stats存成JSON或者pickle,部署的时候一起打包进去。
3.3 数据版本管理:别让“上次那版数据”成为谜题
数据版本管理是很多人忽略的环节。你训练了一个模型,效果不错,过了一个月想复现,结果发现数据已经更新了,原始数据找不到了。这种情况我遇到过不止一次。
我的做法是每次训练用的数据集都打上版本号,存到对象存储里,同时在数据库里记录版本号和对应的训练任务ID。这样任何时候你都能追溯到某个模型是用哪版数据训练的。工具方面,DVC或者LakeFS都能做这件事,但最简单的方案就是自己写个脚本,把数据快照存下来,成本也不高。
注意:数据版本管理不是大公司的专利,小团队更应该做,因为小团队经不起“数据找不到了重新标一遍”的折腾。
4. 模型训练与调优的实操要点
4.1 训练循环:别小看这几行代码
很多人觉得训练循环就是for epoch in range(n): for batch in dataloader: ...,没什么好讲的。但实际项目中,训练循环里藏着很多细节。
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for batch_idx, (data, target) in enumerate(dataloader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() # 每100个batch打印一次日志 if batch_idx % 100 == 0: print(f'Batch {batch_idx}, Loss: {loss.item():.4f}') return total_loss / len(dataloader)这里有几个点值得说。梯度裁剪在RNN和Transformer类模型里几乎是必须的,不加的话训练很容易发散。日志打印频率要适中,太频繁影响性能,太稀疏看不到训练状态。device管理要统一,不要一会儿CPU一会儿GPU,数据在设备间来回拷贝很耗时。
4.2 超参数调优:别用网格搜索硬怼
超参数调优我见过最粗暴的做法是网格搜索,把学习率、batch size、层数、隐藏单元数排列组合全跑一遍。这种方法在小规模实验里还行,但稍微大一点的模型,跑一轮就要几个小时,网格搜索根本跑不起。
我推荐用贝叶斯优化或者Hyperband这类方法。Optuna是我用得比较顺手的库,它支持剪枝,能在训练早期就判断出哪些试验不值得继续跑,节省大量算力。
import optuna def objective(trial): lr = trial.suggest_float('lr', 1e-5, 1e-2, log=True) batch_size = trial.suggest_categorical('batch_size', [16, 32, 64, 128]) hidden_dim = trial.suggest_int('hidden_dim', 64, 512, step=64) model = build_model(hidden_dim) train_loader = get_dataloader(batch_size) for epoch in range(10): loss = train_one_epoch(model, train_loader, ...) trial.report(loss, epoch) # 剪枝:如果中间结果不好,提前终止 if trial.should_prune(): raise optuna.TrialPruned() return loss study = optuna.create_study(direction='minimize', pruner=optuna.pruners.MedianPruner()) study.optimize(objective, n_trials=50)这个模式的关键是trial.report和trial.should_prune,它让Optuna能在训练过程中动态判断哪些试验值得继续。实测下来,同样的算力预算,贝叶斯优化比网格搜索能找到更好的超参数组合。
4.3 过拟合与欠拟合:看曲线比看指标更管用
判断模型是过拟合还是欠拟合,我一般不看最终的准确率,而是看训练损失和验证损失的曲线。
- 训练损失持续下降,验证损失先降后升:过拟合。解决方案是加正则化(L1/L2、Dropout)、减少模型复杂度、增加数据量。
- 训练损失和验证损失都居高不下:欠拟合。解决方案是增加模型复杂度、加特征、减小正则化强度。
- 训练损失和验证损失都下降但验证损失波动大:可能是batch size太小或者学习率太高,试试调参。
我习惯在训练脚本里把每个epoch的损失都记下来,训练结束后画个图,一眼就能看出问题。TensorBoard或者Weights & Biases都能做这件事,但最简单的就是存成CSV然后用matplotlib画。
实操心得:不要等到训练结束才看曲线,边训练边看。如果发现验证损失连续几个epoch都在上升,直接停掉,省得浪费时间。
5. 模型部署与服务化的关键环节
5.1 模型导出:别把训练代码直接搬上线
训练环境和推理环境的要求不一样。训练时你可能用PyTorch的动态图,方便调试;但推理时你需要的是静态图或者中间格式,加载快、依赖少、跨平台。
我一般把PyTorch模型导出成ONNX格式,然后用ONNX Runtime做推理。ONNX的好处是跨框架、跨平台,而且ONNX Runtime对推理做了很多优化,速度比原生PyTorch快不少。
import torch import torch.onnx # 导出模型 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'}}, opset_version=13 )dynamic_axes这个参数很关键,它让导出的模型支持动态batch size。如果不设置,模型就固定了batch size,推理时只能一条一条来,性能很差。
5.2 推理服务:FastAPI快速搭原型
FastAPI是我用得最多的推理服务框架,原因是它异步支持好、自动生成文档、类型检查严格。下面是一个最简版的推理服务。
from fastapi import FastAPI from pydantic import BaseModel import onnxruntime as ort import numpy as np app = FastAPI() session = ort.InferenceSession("model.onnx") class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): prediction: float confidence: float @app.post("/predict", response_model=PredictResponse) async def predict(request: PredictRequest): input_array = np.array([request.features], dtype=np.float32) outputs = session.run(None, {'input': input_array}) prediction = float(outputs[0][0]) confidence = float(outputs[1][0]) return PredictResponse(prediction=prediction, confidence=confidence)这个服务跑起来之后,你可以用uvicorn main:app --host 0.0.0.0 --port 8000启动,然后访问/docs就能看到自动生成的API文档。对于内部工具或者小规模服务,这套方案完全够用。
5.3 性能优化:从毫秒到微秒的折腾
当你的服务开始扛真实流量的时候,性能问题就会暴露出来。我总结了几条优化路径,按投入产出比排序:
- 批处理:把多个请求攒成一个batch一起推理,GPU利用率能提升好几倍。但要注意延迟和吞吐的权衡,batch size太大延迟会上去。
- 模型量化:把FP32转成FP16或者INT8,模型体积减小、推理速度提升,精度损失通常在可接受范围内。ONNX Runtime和TensorRT都支持量化。
- 缓存:对于重复的输入,直接返回缓存结果。用Redis或者内存缓存都行,命中率取决于业务场景。
- 异步推理:FastAPI的异步支持配合线程池,能让CPU和GPU的利用率都上去。
from concurrent.futures import ThreadPoolExecutor import asyncio executor = ThreadPoolExecutor(max_workers=4) @app.post("/predict") async def predict(request: PredictRequest): loop = asyncio.get_event_loop() result = await loop.run_in_executor(executor, run_inference, request.features) return result这段代码把推理放到线程池里执行,避免阻塞事件循环。对于IO密集型和计算密集型混合的场景,这种模式很实用。
注意:性能优化不要凭感觉,一定要先做profiling。用
cProfile或者py-spy找到真正的瓶颈,再针对性优化。我见过有人花了一周优化模型推理,结果发现瓶颈在JSON序列化上。
6. 监控、日志与问题排查实录
6.1 监控指标:别只看准确率
模型上线之后,很多人只盯着准确率看。但准确率是个滞后指标,等它掉下来的时候,业务已经受影响了。我一般会监控这几类指标:
| 指标类型 | 具体指标 | 监控目的 |
|---|---|---|
| 服务指标 | QPS、延迟P99、错误率 | 服务健康度 |
| 模型指标 | 输入分布、输出分布、置信度分布 | 数据漂移检测 |
| 业务指标 | 转化率、点击率、GMV | 业务效果 |
| 资源指标 | GPU利用率、内存、显存 | 资源瓶颈 |
输入分布监控是我觉得最有价值但最容易被忽略的。如果线上请求的特征分布和训练数据分布差异变大,模型效果肯定会下降。你可以用KL散度或者PSI(Population Stability Index)来量化这个差异,超过阈值就告警。
6.2 日志设计:出问题的时候能救命
日志不是越多越好,而是在关键路径上打关键信息。我一般会在这些地方打日志:
- 请求进入时:记录请求ID、输入特征摘要
- 推理完成时:记录请求ID、输出结果、耗时
- 异常发生时:记录请求ID、异常类型、堆栈信息
- 定期统计:记录QPS、延迟分布、错误率
请求ID是串联整个链路的钥匙,一定要在入口生成,然后一路透传下去。这样出问题的时候,你拿着请求ID就能把整个链路的日志串起来。
import uuid import logging logger = logging.getLogger(__name__) @app.post("/predict") async def predict(request: PredictRequest): request_id = str(uuid.uuid4()) logger.info(f"Request {request_id} started, features: {request.features[:5]}...") try: result = run_inference(request.features) logger.info(f"Request {request_id} completed, prediction: {result}") return result except Exception as e: logger.error(f"Request {request_id} failed: {str(e)}", exc_info=True) raise6.3 常见问题速查表
下面这张表是我在实际项目中遇到过的典型问题,以及对应的排查思路。你可以把它当成一个checklist,出问题的时候按顺序过一遍。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 推理结果全一样 | 模型加载失败、输入未归一化 | 检查模型文件、打印输入输出 | 重新导出模型、加归一化 |
| 延迟突然升高 | 流量突增、资源竞争、GC | 看QPS曲线、GPU利用率、GC日志 | 扩容、限流、调GC参数 |
| 准确率下降 | 数据漂移、特征bug、模型退化 | 对比线上线下特征分布 | 重新训练、修复特征逻辑 |
| 服务频繁重启 | 内存泄漏、OOM | 看内存曲线、dmesg日志 | 修泄漏、加内存限制 |
| 部分请求超时 | 长尾请求、批处理等待 | 看延迟分布、batch size | 设超时、拆分batch |
这张表里的每一条我都在真实环境里遇到过,有些问题排查起来很快,有些花了好几天。希望这张表能帮你缩短排查时间。
6.4 一个真实的排查案例
说一个我印象比较深的案例。有一次线上模型的准确率突然掉了5个点,但服务指标一切正常,延迟、错误率都没变化。我先查了输入分布,发现某个特征的均值偏移了20%,明显是数据漂移。但为什么数据会漂移?
顺着这个特征往上查,发现是上游业务系统改了一个字段的默认值,导致这个特征的计算逻辑变了。上游改的时候没通知我们,我们也没做输入分布的监控告警,所以过了三天才发现。
这件事之后,我做了两件事:一是加了输入分布的实时监控和告警,二是和上游团队建立了变更通知机制。技术手段能解决一部分问题,但跨团队的信息同步同样重要。
实操心得:排查问题的时候,先确认“什么时候开始的”,再确认“变化的是什么”,最后确认“为什么变化”。这个顺序能帮你快速缩小范围。
7. 持续迭代与工程化沉淀
7.1 实验管理:别让实验结果散落在各处
做AI项目,实验数量很快就上去了。今天试个新特征,明天试个新模型,后天调个超参数。如果没有实验管理,过两周你就不记得哪个结果对应哪次实验了。
我一般用MLflow或者Weights & Biases来管理实验。每次实验记录这几样东西:代码版本(git commit)、数据版本、超参数、评估指标、模型文件。这样任何时候你都能复现某个实验,或者对比不同实验的结果。
import mlflow mlflow.set_experiment("my-ai-project") with mlflow.start_run(): mlflow.log_param("learning_rate", 0.001) mlflow.log_param("batch_size", 32) mlflow.log_metric("val_loss", 0.23) mlflow.log_artifact("model.onnx") mlflow.log_artifact("feature_engineer.pkl")这几行代码看着不起眼,但坚持记录之后,你会发现排查问题和复现结果变得非常轻松。
7.2 自动化流水线:把人从重复劳动里解放出来
当你的项目从“一周跑一次训练”变成“每天跑一次训练”的时候,手动操作就不可持续了。这时候需要把整个流程自动化:数据拉取、特征计算、模型训练、评估、导出、部署,全部串成一条流水线。
我一般用Airflow或者Prefect来编排。核心思路是把每个步骤封装成独立的任务,任务之间通过依赖关系连接,失败自动重试,成功自动触发下一步。
from prefect import flow, task @task(retries=3) def fetch_data(): ... @task def compute_features(): ... @task def train_model(): ... @task def evaluate_model(): ... @flow def training_pipeline(): data = fetch_data() features = compute_features(data) model = train_model(features) metrics = evaluate_model(model) if metrics['accuracy'] > 0.9: deploy_model(model) training_pipeline()这个流水线跑起来之后,你每天只需要看结果就行,不用手动操作。而且因为每一步都有日志和重试机制,出问题的时候也能快速定位。
7.3 文档与知识沉淀:别让经验只留在你脑子里
最后说一个容易被忽略但很重要的事:文档。AI项目的人员流动率不低,如果核心逻辑只留在某个人脑子里,他一走项目就瘫了。
我要求团队里每个人在完成一个模块之后,必须写清楚这几件事:这个模块解决什么问题、输入输出是什么、关键参数怎么调、踩过哪些坑。文档不用长篇大论,但一定要能让人照着跑起来。
另外,代码注释也很重要。特别是那些“看起来奇怪但实际有原因”的代码,一定要注释清楚为什么这么写。比如“这里用中位数而不是均值,是因为数据里有极端异常值”,这种注释能帮后来的人省很多时间。
提示:文档最好的写法是“给三个月后的自己看”。三个月后你肯定不记得当时的思路了,所以写文档的时候假设读者是完全不了解背景的人。
8. 一些零散但值钱的经验
上面按模块讲了一遍,最后再分享几个零散但我觉得很值钱的经验。
关于环境管理:用Docker把训练环境和推理环境都容器化,依赖版本全部锁死。我见过太多“在我机器上能跑”的悲剧,容器化能解决90%的环境问题。
关于随机种子:训练脚本里一定要固定随机种子,包括Python的random、NumPy的np.random、PyTorch的torch.manual_seed。不然每次训练结果都不一样,你根本分不清是模型改了有效果还是随机波动。
关于模型大小:不要盲目追求大模型。我做过一个对比,一个小模型加好特征,效果比大模型加烂特征好得多。而且小模型推理快、部署成本低,性价比高很多。
关于上线节奏:新模型上线不要直接全量替换,先做AB测试或者灰度发布。用5%的流量跑新模型,观察一周没问题再逐步放大。这样即使新模型有问题,影响范围也可控。
关于回滚:一定要有回滚方案。模型文件、配置文件、服务版本都要能一键回滚。我遇到过新模型上线后效果暴跌,幸好回滚机制完善,五分钟就恢复了。
关于沟通:AI工程师不是只跟代码打交道,还要跟产品、运营、后端、数据团队沟通。把技术问题翻译成业务语言,把业务需求翻译成技术方案,这个能力比调参重要得多。
这个领域变化很快,新框架、新方法层出不穷。但底层的东西——数据质量、特征一致性、服务稳定性、监控完备性——这些是不变的。把基础打牢,上层的东西学起来就快。我到现在还在不断踩坑、不断补课,希望这篇东西能让你少踩几个我踩过的坑。