1. 项目概述:为什么会有"从零开始做AI工程"这件事
"ai-engineering-from-scratch"这个项目,说白了就是一套完整的、从零起步的AI工程实战笔记和代码仓库。市面上讲AI的教程多如牛毛,但绝大多数要么落在"调库跑模型"的入门层,要么直接跳到"分布式训练"的进阶层,中间这块"工程化"的真空地带几乎没人认真填。这个项目要解决的就是这个断层:当数据、模型、训练、部署、监控这些环节必须连成一条完整的链路时,中间到底发生了什么,每一步又该怎么选型、怎么落地。
我自己做这个项目的原因很直接。带过不少新人,也面试过不少候选人,发现很多人能聊透Transformer的注意力机制,能背出各种Loss函数的公式,但一问他"模型训练完了怎么上线""QPS涨了十倍怎么办""数据分布漂移了如何发现",就沉默了。这不是基础不扎实,而是缺少一套完整的工程视野。所以我把过去几年在AI工程方向踩过的坑、沉淀下来的套路,整理成了这个从零开始的实战路线。
这个项目适合谁?如果你正在从算法岗转向工程岗,或者刚入行想做AI平台的开发,又或者你是独立开发者想把自己的模型快速产品化,那这套东西应该能帮你省不少弯路。内容覆盖了从需求分析、数据管道、模型训练、性能优化到部署监控的完整闭环,并且每个环节都配了可运行的代码和具体参数,不是那种只讲概念的PPT式教程。
2. 整体设计与路线拆解:先想清楚再动手,少走一半弯路
2.1 工程思维和算法思维的区别在哪里
我在项目开篇就强调了一个核心观点:AI工程师和算法研究员的工作方式有本质区别。研究员追求的是模型指标的极限,哪怕为了一个点的提升跑一百组实验也值;工程师追求的则是系统的稳定、可维护和可复现,模型指标只是整个系统的一个环节。
这个定位决定了整个项目的设计思路。我不会手把手教你怎么从零实现一个BERT或者Diffusion模型——那是深度学习课程的事。我关注的是:假设你现在手上已经有一个效果还不错的模型,你怎么把它变成一套能长期稳定运行的服务。这里面的学问远比想象中大:特征怎么管理、实验怎么追踪、模型怎么版本化、推理延迟怎么压、资源怎么调度、线上效果怎么监测,每一个环节展开都是一整篇文章。
所以这个项目在结构上分成了几个独立的模块:工程基础设施、数据管道、模型训练管理、推理优化、部署与监控。每个模块之间保持松耦合,你可以按顺序学习,也可以直接跳到当前最需要的部分。我在设计时就刻意避免"必须从头读到尾"的线式结构,毕竟每个人遇到的实际问题不一样。
2.2 技术栈选型背后的逻辑
技术选型方面,整个项目基于Python生态,主要使用了PyTorch、MLflow、Airflow、FastAPI、Docker和Prometheus这套组合。选它们的理由不是"最新最潮",而是"最成熟最稳"。
用PyTorch做模型训练和推理,是因为它的动态图机制在工程调试时实在太方便了,而且TorchServe和TorchScript这些配套工具让从训练到部署的衔接顺畅很多。实验追踪用MLflow,它虽然在UI和多租户支持上不如商业化的Weights & Biases,但胜在开源、可自托管、API简单,对中小团队来说性价比很高。数据管道用Airflow调度,虽然被很多人吐槽"太重了",但它胜在生态成熟、可视化好,依赖管理和失败重试机制也比Cron脚本靠谱得多。服务化用FastAPI,异步支持好、性能不错、自动生成OpenAPI文档,调试起来特别顺手。监控这块选了Prometheus加Grafana的组合,这基本是云原生时代的标配,指标采集和可视化都非常成熟。
这套选型覆盖了从数据到上线的大多数场景,而且全部是开源方案。我给项目设定的原则是:能自托管的不用云厂商绑定的服务,能用标准工具的不用自己造的轮子。这样读者在复现项目时不会被云服务商的差异卡住,拿来就能在自己机器上跑通。
3. 核心模块实战:数据、训练、推理三条主线的关键细节
3.1 数据管道的设计:别再让训练脚本直接读数据库了
很多人一开始做AI项目时的习惯是:训练脚本里直接连数据库查数据,或者从本地CSV读入,然后开始跑模型。这种思路在小规模实验阶段没问题,但一旦到了需要定期更新模型、多团队协作的时候,就会变成灾难。
我在项目里设计了一套标准的数据管道流程,核心思路是"数据即产物"。原始数据从业务库同步到数据湖,经过清洗和特征工程变成特征数据集,然后再进一步加工成训练集和测试集,每一层产物都有明确的格式规范和版本记录。这样做的好处是:特征计算逻辑可以被训练和推理两套流程复用,保证离线训练和在线推理用的是同一套特征逻辑,不会出现"训练时用A逻辑、上线时用B逻辑"这种经典翻车事故。
数据版本管理是很多人忽略的重点。模型效果变差了,你想回溯到三个月前的训练数据重跑一遍实验,如果数据没有版本化,这件事根本做不了。我在项目里用DVC管理数据集版本,把元数据存在Git里,实际数据文件放在对象存储或NAS上,这样既享受了Git的版本管理能力,又不需要把大文件直接塞进仓库。整个流程大概长这样:
# 1. 初始化DVC dvc init # 2. 添加远程存储(本地目录模拟) dvc remote add -d myremote /mnt/data/dvcstore # 3. 添加数据目录并生成.dvc文件 dvc add data/raw git add data/raw.dvc .dvc/config git commit -m "add raw dataset" # 4. 数据变更后推送新版本 dvc push在使用DVC时有一个特别容易踩的坑:.dvc文件里记录的是文件哈希值,如果有人改了数据内容但没执行dvc add,那么版本记录和数据实际内容就不一致了。所以建议把dvc add和git commit写成一个固定的发布流程,最好在CI里做校验,确保每次数据发布都有完整的版本记录。
关于Airflow调度,很多初学者一上来就想着实现复杂的DAG依赖,但我在实际项目中更倾向于Keep It Simple的原则。数据管道最重要的是稳定性和可观测性,而不是花哨的依赖关系。我的建议是:DAG数量控制在十个以内,每个DAG的task数也尽量精简,调度频率定在业务需要的水平就行,没必要为了"实时"而实时。每天跑一次的批任务,比一个"看起来实时但经常挂"的流任务可靠得多。
3.2 模型训练的工程化管理:从"能跑"到"可复现"
训练环节是整个项目里代码最多的部分。我设计了一个标准化的训练流程框架,把训练过程拆成配置管理、模型管理、实验追踪三个子模块。
配置管理方面,我用YAML文件承载所有的超参数和训练配置,而不是把参数散落在代码里或者通过命令行传几十个参数。每一条实验记录都对应一个配置文件的快照,这样实验的可复现性就有了基本保障。配置文件大致长这样:
model: name: text_classifier backbone: bert-base-chinese hidden_size: 768 num_labels: 10 data: train_path: data/processed/train.parquet eval_path: data/processed/eval.parquet batch_size: 32 max_seq_len: 128 train: optimizer: adamw learning_rate: 2e-5 weight_decay: 0.01 num_epochs: 5 warmup_ratio: 0.1 seed: 42 output: run_dir: runs/experiment_001 save_model_metric: f1_macro在训练代码里,我会强制要求先加载配置再初始化所有组件,模型结构、数据加载器、优化器、日志器都是从配置对象里读取参数。这样整个训练过程就变得非常"声明式",想跑一组新实验只需要复制一份配置改几个关键参数,不必动代码。
模型管理这块,我养成了两个习惯。第一个是每轮训练结束都保存checkpoint,但只保留最近几轮和历史上效果最好的那个,避免磁盘被一轮轮的模型填满。第二个是模型产出一律通过MLflow登记注册,模型的路径、指标、依赖的Python环境、训练配置全部记录在MLflow的model registry里。这样等到部署的时候,直接在registry里挑一个版本授权发布就行,不需要到处找模型文件。
实验追踪可能是很多人觉得"不就是一个日志功能吗"的部分,但真正做深了才知道它的价值。我一般会记录这几类信息:系统资源占用(GPU显存、CPU、内存)、训练指标(loss、准确率、F1)、数据相关指标(样本数、标签分布)、模型结构摘要和参数量。有了这四类信息,当你训练效果异常的时候,才可能快速定位是数据问题、模型问题还是资源问题。
训练过程中还有几个细节值得单独说一下:
一是随机种子管理。我固定了Python、NumPy、PyTorch和CUDA的随机种子,但需要明确一点:固定种子只能减少随机性,在GPU上训练仍然不可能做到100%可复现。所以训练代码里我还会把环境变量和依赖版本也记录到实验日志里,至少做到"问题和环境能对上号"。
二是混合精度训练。现在的主流显卡基本都支持FP16或者BF16,开启之后训练速度能提升30%到50%,显存占用也能降低不少。但新手用AMP时容易出问题,比如Loss出现NaN或者模型不收敛。我的经验是:在开AMP的时候一定要同时做梯度裁剪,并且密切关注Loss曲线的走势,一旦发现异常就先关掉AMP排查,不要硬着头皮跑完。
三是早停策略。我实现了一个简单的EarlyStopping回调,监控验证集上的目标指标,连续N个epoch没有提升就终止训练,然后回滚到历史最优模型。这个机制看着简单,但能节省大量的无效训练时间,特别是做超参搜索的时候,价值非常明显。
3.3 推理优化:怎么把延迟从200ms压到50ms
模型训练完成之后,真正的硬仗在推理这一步。我见过太多模型在离线评测时指标漂亮,一上线就超时的情况。原因很简单,离线评测用的是一个都没人抢的批量GPU,线上是几十个并发请求挤一个推理服务,资源竞争完全不是一个量级。
推理服务我有一个通用框架,用FastAPI封装TorchScript模型,配合异步请求处理和动态批处理。框架的伪代码大致如下:
import asyncio from fastapi import FastAPI from transformers import BertTokenizer import torch app = FastAPI() model = torch.jit.load("models/text_classifier_v3.pt", map_location="cpu") model.eval() tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") # 信号量控制最大并发,防止GPU显存溢出 sem = asyncio.Semaphore(16) @app.post("/predict") async def predict(request: dict): async with sem: text = request["text"] inputs = tokenizer(text, max_length=128, truncation=True, padding=True, return_tensors="pt") with torch.no_grad(): logits = model(**inputs) pred = logits.argmax(dim=-1).item() return {"label": pred}这个版本只是基准实现,真正的优化动作在后面。我梳理了一套推理优化的优先级清单:
第一优先级是模型端优化。能从模型结构上降延迟,收益最大。量化是性价比最高的一招,把FP32模型转成INT8,推理延迟通常能下降一半以上,模型体积缩小到原来的四分之一,在NLP任务上精度损失一般能控制在1%以内。TensorRT或者ONNX Runtime的图优化也是这个环节的武器,ONNX Runtime加Dynamic Quantization在CPU上就能跑出不错的效果,不需要上GPU。
第二优先级是服务端优化。动态批处理非常有效,把一小段时间内的多个请求攒在一起,拼成一个batch送到GPU上,充分利用显存并行计算能力。这个逻辑实现起来不复杂,就是维护一个请求队列,每隔几十毫秒或者攒够N个请求就触发一次批量推理。我实测过,在并发100的场景下,动态批处理能把吞吐量拉高两倍以上。但需要注意:动态批处理会增加单个请求的最坏延迟,所以上线前一定要压测,找到延迟和吞吐量之间的平衡点。
第三优先级是缓存策略。对于业务场景来说,很多查询是有重复的。我在服务层面加了一个简单的LRU缓存,key是文本哈希值,value是推理结果。热门请求的命中率上来了之后,整体QPS压力小了很多,用户体验也更稳定。
部署环节,我推荐用Docker镜像加Kubernetes的方式。镜像里只打包模型文件和推理服务代码,模型文件可以从MLflow registry拉取,这样镜像本身做到和版本完全对应。K8s的HPA(Horizontal Pod Autoscaler)可以基于自定义指标做自动扩缩容,配合Prometheus的QPS和延迟指标,服务在大促或者流量高峰时能自适应地扩出副本,高峰过去了又能自动回收,省了不少钱。
4. 部署与监控:把服务跑起来只是开始,不掉线才算本事
4.1 Docker镜像和启动脚本的避坑提示
部署环节的坑和训练环节完全不同。训练环境挂了最多就是重跑一次实验,线上服务挂了那就是事故。我在这个模块里专门整理了一套经过反复验证的镜像构建方案。
基础镜像方面,我强烈建议用PyTorch官方提供的GPU镜像作为底座,不要自己从零装CUDA和cuDNN。官方镜像是经过充分测试的,各库之间的版本兼容性有保障。版本选型也有一点讲究:不要追求最新版本,选一个社区使用量大、已知问题少的稳定版本即可。我自己的镜像tag通常会固定到小版本,比如pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime,而不是用latest,这样构建出的镜像才是可复现的。
一个很容易被忽视的问题是镜像体积。一个装了CUDA的PyTorch镜像动辄几个GB,部署和拉取都很痛苦。优化方案是分阶段构建:在构建阶段用完整的开发镜像装依赖、编译扩展,最终运行阶段换成slim镜像,只拷贝运行所需的Python包和模型文件。我最好的记录是把一个4.7GB的镜像降到了1.2GB,部署速度提升了接近四倍。
启动脚本里我加了一个耗时几分钟的冷启动问题。加大模型加载到GPU确实需要时间,如果每次扩容都要等模型重新加载,流量高峰根本接不住。我自己的解法是借助K8s的pre-stop钩子和readinessProbe做优雅上下线:新Pod加载完成后才接收流量,旧的Pod等存量请求处理完了再退出,这样滚动更新时服务基本不会断。另外,如果你的模型比较大,考虑准备一些常驻的"热Pod",流量高峰时直接顶上。
4.2 线上监控的三类指标和告警配置
监控是我认为整个项目里最"工程化"的环节,也是最容易被初学者忽略的。我自己总结了一套线上模型服务必须盯的三类监控指标:
第一类是系统指标,包括CPU使用率、内存使用率、GPU显存占用、GPU利用率、网络带宽和磁盘I/O。这些指标反映的是服务的"身体状态"。GPU利用率长期很低但显存占满,大概率是你的推理代码有显存泄漏或者批处理设置不合理。
第二类是服务指标,包括QPS、P50/P95/P99延迟、错误率、超时率、熔断次数。这些指标反映的是服务的"工作表现"。其中P99延迟比平均延迟重要得多,平均延迟低但P99很高,意味着有大量请求被拖得很慢,用户体感依然很差。如果P99和P50差距过大,往往说明有慢请求在排队,需要检查动态批处理的队列大小和超时设置。
第三类是业务指标,包括预测结果的标签分布、置信度分布、空结果率。这些指标反映的是模型在线上真实环境的表现。标签分布和训练集分布差异太大,基本可以断定数据漂移了,模型已经开始失真,需要尽快采集新样本做重训。
告警配置上我建议抓住"少而精准"的原则。告警项设得太多,天天半夜被吵醒,最后大家都会选择忽略。真正需要告警的其实就几个场景:P99延迟超过阈值持续五分钟、错误率超过1%且请求量大于某个绝对值、GPU显存占用接近上限、模型输出空结果率异常升高。每个告警都要配置好恢复条件,避免告警风暴之后一堆永不恢复的"僵尸告警"。
关于日志的处理,我强烈建议结构化日志。Python里用logging库加上JSON格式化器,把每条日志输出为JSON格式,包含时间戳、请求ID、业务字段、日志级别等。这样日志能直接接入ELK或者Loki做检索,排查问题的时候按请求ID串联全链路的日志,定位时间从半小时起步缩短到三分钟内。
5. 常见问题与排查技巧:这些坑我替你踩过了
5.1 训练阶段的典型问题和解决思路
训练阶段最常见的问题,我列一个速查表,都是我在实际项目里反复遇到的:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| Loss不下降 | 学习率过大或过小 | 先把学习率调到1e-4左右跑几十步看趋势,再做范围搜索 |
| Loss变成NaN | 梯度爆炸或混合精度溢出 | 开梯度裁剪;调低学习率;检查数据里是否有异常值 |
| GPU显存不足 | batch size过大或模型太大 | 减小batch size;检查是否有显存泄漏;开启梯度检查点 |
| 验证集效果远低于训练集 | 过拟合 | 加正则化、减小模型容量、增大数据增强、提前早停 |
| 训练速度突然变慢 | 数据加载成为瓶颈 | 检查DataLoader的num_workers和prefetch_factor设置 |
| 多卡训练Loss抖动 | 梯度同步或batch norm问题 | 确认DistributedSampler使用正确;BatchNorm换成SyncBatchNorm |
数据加载变成瓶颈的现象特别常见。很多时候GPU利用率只有30%,你以为模型太复杂算不动,其实问题出在CPU来不及把数据喂到GPU。在PyTorch里,num_workers设置为CPU核心数的一半或三分之二,prefetch_factor默认2,这些参数看起来不起眼,调对了训练速度能提升不少。我自己的经验是,在NVIDIA Nsight Systems里一眼就能看出GPU的空闲气泡,如果DataLoader的时间占比特别高,那就该优化数据管道了。
还有一个很隐蔽的坑:训练里用了BatchNorm,但在推理阶段忘了切换成eval模式。BatchNorm在训练时会用当前batch的均值和方差做归一化,在推理时要用全局统计量。如果代码里没有调用model.eval(),推理结果会和训练时有很大的分布差异,尤其在小batch场景下会特别明显。
5.2 服务上线后的翻车记录
服务上线后的坑往往更致命,因为它直接影响用户。我分享一个我自己印象特别深刻的案例:一次对NLP分类服务做压测,离线单请求延迟只有30ms,但100并发压测时P99直接飙到了800ms,而且随着压测时间延长延迟还在持续增大。
一开始我以为是模型推理太慢,后来用cProfile分析才发现,问题根本不在推理,而在请求处理逻辑里。我为了做特征工程,在每次请求时都要从数据库读取一份用户画像数据,数据库的连接池太小,并发一高就全堵在等数据库响应上了。把连接池调大并加上缓存之后,P99降到了120ms以内。
这个案例给我的经验是:线上服务性能排查,永远先从系统画像开始,而不是直接猜模型慢。用py-spy或者cProfile拿到CPU火焰图,看时间到底花在哪里;用top或者nvidia-smi看CPU和GPU有没有忙起来。数据说话,别凭感觉猜。
另一个高频问题是内存泄漏。TorchScript模型如果推理的输入长度一直变化,导致图里的张量反复重新分配,就可能出现内存碎片化导致RSS持续上涨。解决的办法是固定输入长度,不足的部分padding,超过的部分截断。我当时排查的时候发现内存以每小时100MB的速度在涨,就是因为输入长度不固定导致的。固定到128之后,内存曲线变成了平稳的锯齿形。
混合精度推理也是一个大坑。如果模型用FP16推理,但输入的特征分布和训练时差异很大,输出的数值范围可能溢出,导致预测结果退化。这种情况下,量化模型上线前一定要用线上的真实流量做离线比对测试,对比指标至少包括准确率、置信度分布和误差样本分析。只有这些比对通过,量化模型才适合上线。
6. 性能压测与容量预估:线上不崩的底线操作
6.1 压测的正确姿势:从脚本到工具都别太随意
很多人以为压测就是拿ab命令跑一下,看看QPS有多少,其实这样得到的数据参考价值很低。我自己总结了一套相对完整的压测方案,供大家参考。
压测工具我推荐wrk或者Locust。wrk适合做纯HTTP的短连接压测,性能极高,单机就能压出很大的压力。Locust是Python写的,可以写自定义的请求逻辑,模拟更真实的用户行为。我自己习惯先用wrk做一轮粗暴压测,确定服务的性能上限,再用Locust做带业务逻辑的混合场景压测,模拟更真实的高峰流量。
压测脚本里有两件事一定要做对。第一是逐步加压而不是直接打满。我从100并发开始,逐步增加到200、500、1000,观察每个并发水平下的延迟和错误率变化曲线。这样可以看出服务的瓶颈在哪一个环节。第二是压测时长不要低于十分钟。头几分钟很多内存泄漏和连接池耗尽的问题还没暴露出来,压够时间才有说服力。
6.2 容量预估的一个实用公式
容量预估这件事,我总结出一个非常实用的小公式:单机QPS = 1000 / P95延迟(ms) x 并发数 x 服务可用性系数。服务可用性系数一般取0.7到0.8,给系统留出余量,防止高峰时段的毛刺直接拖垮服务。
假设你的推理服务P95延迟为100ms,单机能开16个并发跑推理,那么单机理论QPS约为1000 / 100 x 16 = 160。考虑到CPU抢占、GC停顿和网络开销,乘0.75的系数,单机真实QPS大概在120左右。如果峰值流量是1000 QPS,那么至少需要1000 / 120,约9个实例,再预留35%的峰值冗余,配置12个副本相对稳妥。
这个估算公式不一定精确,但至少能让你心里有数。比"感觉应该够了吧"靠谱得多。而且算清楚之后,K8s的HPA最大副本数、NodeGroup的机器数都能推算出来,预算也好做。
7. 项目复盘与个人经验总结
7.1 三个月实践下来最大的收获
整个"ai-engineering-from-scratch"项目从构思到落地,我大概花了两三个月时间,中间经历了无数次的推翻重来。最深的体会是:AI工程本质上是一门"约束下的妥协艺术"。模型效果、推理速度、资源成本、部署复杂度、团队协作效率,这五个维度很难同时做到最优,真正成熟的做法是明确业务目标后,有取舍地做优化。
比如,如果业务对延迟特别敏感,那就可以接受1%到2%的精度损失,上量化和TensorRT优化。如果业务对精度要求极高,那就要预留够多的GPU资源,并做好缓存和批处理来支撑延迟要求。这些取舍没有绝对的对错,关键是决策过程要让整个团队都参与并达成共识,避免上线之后因为技术分歧来回折腾。
另外,我越来越觉得可观测性是AI工程最值得投资的部分。很多团队花大力气打磨模型精度,却连一套像样的监控面板都没有。模型上线之后效果变差,只能等用户来投诉才知道。我在这次项目里把监控体系搭到了"能看清每一个环节"的程度:从数据管道的任务耗时,到训练过程的Loss曲线,到推理服务的P99延迟,再到线上预测结果的分布漂移,每一个环节都有对应的指标看板。这笔投入的回报率非常高,几乎每次线上疑难杂症都能通过指标快速定位方向。
7.2 后续学习的扩展方向
这个项目目前覆盖的是单机到小规模集群的范畴,还可以往几个方向继续扩展。一个是大规模分布式训练,比如用DeepSpeed或者Megatron-LM跑千亿级参数模型,涉及张量并行、流水线并行、ZeRO优化器等更底层的技术,工程复杂度完全不一样。另一个是更完善的特征平台建设,比如让特征在离线训练和在线推理中通过统一的Feature Store服务来管理,解决特征一致性问题。
如果你想把这套东西进一步完善,我建议可以给项目增加一个完整的CI/CD流水线,让代码提交后能自动触发训练、自动跑评估、自动生成部署镜像,真正做到"代码到上线全链路自动化"。相比我目前手动操作的半自动流程,自动化之后团队的交付效率还会有明显的提升。
最后分享一个我个人的习惯:每次项目结束后,我会把所有的操作记录、遇到的问题和解决方案整理成一篇类似"复盘笔记"的文档,放在仓库的docs目录下。这个习惯坚持了几年,积累下来就是一笔非常宝贵的个人知识库。遇到相似问题的时候,翻一翻自己写的笔记,往往比搜索引擎好用得多。AI工程这条路特别长,体系化的知识积累比偶然的灵光一现可靠得多。