1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,拖几个组件,调一下API,然后跑通一个Demo,就觉得自己已经入门了。我刚开始也是这么想的,直到有一次线上推理服务在高峰期直接雪崩,日志里全是显存溢出的报错,我才意识到,只会调包和拼装,根本撑不起一个能扛住真实流量的AI系统。ai-engineering-from-scratch这个标题,说的就是从最底层开始,把AI工程里那些被封装层藏起来的东西,一层一层剥开,自己动手搭一遍。它适合那些已经会用现成框架跑模型,但一遇到性能瓶颈、部署故障、数据管道堵塞就束手无策的人。这篇文章不会教你调参,也不会给你一个可以直接上线的生产系统,而是带你走一遍从数据摄入、特征处理、模型训练、推理优化到服务监控的完整链路,每一步都告诉你为什么这么做,以及不这么做会死在哪里。
我见过太多团队,模型在Notebook里准确率95%,一上生产就掉到70%不到,原因往往不是模型本身,而是特征在离线训练和在线推理时的计算逻辑不一致。这种问题,你调包是永远发现不了的,只有自己从零实现一遍特征管道,才会真正理解“训练-服务偏差”是怎么产生的。所以,这篇内容的核心价值,是帮你建立一套完整的AI工程心智模型,让你在遇到问题时,能顺着数据流和计算图,快速定位到根因,而不是对着黑盒干瞪眼。
2. 数据管道:从原始日志到训练样本的脏活累活
2.1 为什么特征存储不能直接用数据库替代
很多刚接触AI工程的人会觉得,特征不就是一些数值吗,我直接存MySQL里,训练的时候查出来不就行了。我一开始也这么干过,结果训练任务跑了六个小时,光查特征就花了四个半小时。原因很简单,数据库是按行存储的,适合点查,但训练需要的是按列批量读取,而且往往要扫描全量样本。特征存储的核心设计目标,是支持高吞吐的批量读取和低延迟的在线点查,这两种需求对底层存储的要求完全不同。
从零实现一个简易特征存储,你可以先用Parquet文件做离线存储,因为Parquet是列式格式,读取特定列时不需要扫描整行,IO效率比CSV高一个数量级。在线部分可以用Redis做缓存,但要注意,Redis里的特征必须和离线计算逻辑完全一致。我踩过的一个坑是,离线用Pandas计算均值填充缺失值,在线用Java写了个类似的逻辑,结果Pandas的均值默认跳过NaN,而Java那边没跳过,导致线上线下特征分布直接漂移。后来我强制要求,所有特征计算逻辑必须用同一套代码生成,离线用Spark跑,在线用同样的UDF包装成服务,从根源上杜绝不一致。
注意:特征存储的元数据管理比存储本身更重要。每个特征必须记录来源表、计算逻辑、更新频率、负责人。没有元数据的特征存储,三个月后就是一座没人敢动的屎山。
2.2 数据版本控制:别让模型训练变成开盲盒
你有没有遇到过这种情况:上周训练了一个模型,效果很好,这周想复现,结果发现数据被覆盖了,或者上游表结构变了,怎么都跑不出同样的结果。这就是没有做数据版本控制的后果。从零搭建AI工程,数据版本控制是必须迈过去的一道坎。我的做法是,每次训练任务启动时,给当前的数据快照打一个唯一ID,这个ID关联到具体的文件路径、分区信息、以及上游表的commit hash。这样即使数据后续被更新,你也能通过ID找回当时的快照。
具体实现上,可以用Delta Lake或者Hudi这类支持时间旅行的存储格式,它们底层通过事务日志记录每次写入,你可以查询任意历史版本。如果不想引入太重的东西,也可以自己写一个简单的清单文件,每次数据更新时,把新文件的路径和校验和追加到一个JSON文件里,训练时读取指定版本的清单。这个方案虽然土,但在数据量不大、更新不频繁的场景下,足够用了。关键是养成习惯:任何进入训练管道的数据,必须有一个不可变的版本标识。
2.3 样本权重与时间衰减:让模型学会“喜新厌旧”
在推荐和风控场景里,数据的时效性非常关键。三个月前的用户行为和昨天的用户行为,对当前预测的价值完全不同。如果你直接把所有历史样本等权重喂给模型,模型会被大量过时数据带偏。从零实现的时候,我建议在样本生成阶段就引入时间衰减权重。一个简单有效的公式是weight = exp(-lambda * days_ago),其中lambda控制衰减速度,可以通过实验调整。
但这里有个坑:权重不能直接乘在损失函数里就完事。如果权重差异太大,梯度更新会被少数高权重样本主导,导致模型不稳定。我的经验是,对权重做归一化,让每个batch内的权重均值为1,同时设置一个权重下限,比如0.1,防止过老的样本完全被丢弃。另外,验证集和测试集不要加时间衰减,保持原始分布,否则你评估的就不是真实场景下的表现了。
3. 模型训练:从单机脚本到分布式训练的跨越
3.1 为什么你的第一个分布式训练任务总是失败
单机训练跑通之后,下一步自然是上多卡或者多机。但很多人第一次跑分布式训练,都会遇到Loss不收敛、梯度爆炸、甚至直接卡死的问题。我当初用PyTorch的DDP,以为把模型包一层DistributedDataParallel就完事了,结果发现每个进程的随机种子不一样,数据增强的结果不同,导致各卡梯度方向不一致,训练直接发散。后来我强制在训练脚本开头设置统一的随机种子,并且确保数据加载器的shuffle逻辑在所有进程间同步。
另一个常见问题是BatchNorm层。在分布式训练中,如果BatchNorm的统计量只在单卡上计算,相当于每个卡看到的batch size变小了,统计量有偏。解决方案是使用SyncBatchNorm,它在所有卡之间同步均值和方差。但SyncBatchNorm会引入额外的通信开销,如果卡间带宽不够,反而会拖慢训练。我的建议是,如果单卡batch size大于16,可以先用普通BatchNorm,影响不大;如果小于8,再考虑SyncBatchNorm。
3.2 混合精度训练:省显存不是唯一目的
混合精度训练现在几乎是标配了,但很多人只知道它能省显存,不知道它还能加速。原理是,现代GPU对FP16的计算吞吐量通常是FP32的两倍甚至更高。但直接切FP16会带来梯度下溢的问题,因为FP16能表示的最小正数大约是6e-8,很多小梯度直接变成0。所以需要配合损失缩放(Loss Scaling),在反向传播前把损失放大,计算完梯度再缩回去。
从零实现混合精度,你可以用NVIDIA的Apex或者PyTorch自带的torch.cuda.amp。我推荐后者,因为它是官方维护的,兼容性更好。使用的时候注意,不是所有操作都适合FP16,比如Softmax、LayerNorm这些对数值范围敏感的算子,最好保持在FP32。torch.cuda.amp的autocast上下文管理器会自动帮你处理这些,但你要知道它背后做了什么,否则遇到NaN的时候会一脸懵。
3.3 梯度累积与梯度裁剪:小显存跑大模型的妥协艺术
如果你想用单张24G的卡微调一个7B参数的模型,全量参数加载进去就快满了,更别说优化器状态和梯度。这时候梯度累积就是救命稻草。它的思路是,把一个大的batch拆成几个小batch,分别前向和反向,但不清空梯度,等累积到指定步数再统一更新。这样等效于增大了batch size,但显存占用不变。
不过梯度累积有个细节:如果你用了BatchNorm,累积的梯度对应的统计量是基于小batch的,和真正的大batch不一致。所以要么改用GroupNorm或者LayerNorm,要么接受这个偏差。另外,梯度裁剪要在累积完成后、更新之前做,否则每个小batch都裁一次,累积后的梯度范数会偏小。我一般用torch.nn.utils.clip_grad_norm_,在optimizer.step()之前调用,裁剪阈值设1.0或者0.5,根据模型稳定性调整。
4. 推理服务:把模型变成能扛流量的API
4.1 动态批处理:吞吐量和延迟的平衡术
模型训练完,部署成API,第一个问题就是怎么处理并发请求。最简单的方式是一个请求一个推理,但GPU利用率极低,因为大部分时间都在等数据搬运。动态批处理(Dynamic Batching)的思路是,把短时间内到达的多个请求合并成一个batch,一起送进GPU,算完再拆开返回。这样能显著提升吞吐量,但代价是增加了延迟,因为请求要等一会儿才能凑够一个batch。
从零实现动态批处理,你需要一个队列和一个调度器。调度器每隔几毫秒检查一次队列,如果队列长度达到阈值,或者等待时间超过上限,就取出当前所有请求组成batch。阈值和超时时间需要根据你的SLA来调。比如你的P99延迟要求是100ms,那超时时间就不能超过50ms,留一半给推理本身。我实测下来,对于BERT这类模型,batch size在8到16之间,吞吐量提升最明显,再大收益就递减了。
注意:动态批处理要求所有请求的输入长度一致,或者至少能padding到同一长度。如果请求长度差异很大,padding会浪费大量算力。这时候可以考虑按长度分桶,不同桶用不同的batch。
4.2 模型量化:INT8不是万能药
量化是推理优化的另一大利器,把FP32的权重和激活值转成INT8,模型体积缩小四倍,推理速度也能提升两三倍。但量化不是没有代价的,精度损失是必然的。我见过有人直接把一个检测模型量化成INT8,mAP掉了十个点,就是因为检测头对数值精度太敏感。
从零做量化,有两种方式:训练后量化(PTQ)和量化感知训练(QAT)。PTQ简单,拿训练好的模型,跑一遍校准数据集,统计激活值的动态范围,然后直接转INT8。QAT复杂,要在训练时模拟量化误差,让模型提前适应。我的建议是,如果PTQ掉点不超过1%,就用PTQ;如果掉点严重,再考虑QAT。另外,不是所有层都适合量化,比如第一层和最后一层,通常保持FP32更稳。
4.3 服务监控:别等用户投诉了才发现模型挂了
推理服务上线不是终点,而是起点。你需要监控的指标包括:QPS、P99延迟、错误率、GPU利用率、显存占用。但这些还不够,AI服务特有的监控是特征漂移和预测分布漂移。特征漂移是指线上特征分布和训练时不一致,预测分布漂移是指模型输出的分布发生了变化。这两个指标能帮你在模型效果真正恶化之前就发出预警。
实现上,你可以每天采样一部分线上请求,计算其特征的均值和方差,和训练集的统计量做对比。如果偏差超过阈值,就触发告警。预测分布可以用KL散度或者PSI(Population Stability Index)来衡量。我一般设两个阈值:黄色预警和红色预警。黄色预警时人工检查,红色预警时自动回滚到上一个稳定版本。这套机制帮我避免了好几次线上事故,有一次上游数据表字段类型从int变成string,特征管道直接算出了全零,如果没有监控,可能第二天才发现。
5. 那些只有踩过才知道的工程细节
5.1 随机种子的陷阱:你以为设了,其实没设
在AI工程里,可复现性是个大问题。你设了random.seed(42)、np.random.seed(42)、torch.manual_seed(42),以为万事大吉了。但如果你用了CUDA,还需要设torch.cuda.manual_seed_all(42)。如果用了cuDNN,还要设torch.backends.cudnn.deterministic = True和torch.backends.cudnn.benchmark = False。即使这样,某些CUDA算子仍然是非确定性的,比如atomicAdd。所以,完全可复现在大规模训练中几乎是不可能的,你能做的是把随机性控制在可接受的范围内。
我的做法是,在训练脚本里把所有能设的种子都设上,并且在日志里记录当前的环境信息,包括CUDA版本、cuDNN版本、PyTorch版本、GPU型号。这样即使结果有微小差异,你也能判断是不是环境变化导致的。另外,数据加载器的worker_init_fn也要设种子,否则每个epoch的数据顺序可能不同。
5.2 日志与实验管理:别用Excel记结果了
我见过太多人用Excel或者记事本记录实验参数和结果,过两周自己都看不懂哪行是哪次实验。从零搭建AI工程,实验管理是必须的。你可以用MLflow或者Weights & Biases,但如果不想依赖外部服务,也可以自己写一个简单的实验记录系统。核心是:每次实验生成一个唯一ID,记录超参数、代码版本(git commit hash)、数据版本、环境信息、以及所有中间指标。
我自己的做法是,在训练脚本里加一个ExperimentLogger类,每次启动时创建一个目录,把配置文件、git diff、甚至pip freeze的输出都存进去。训练过程中的loss、accuracy、学习率等指标,按step或epoch写入一个JSON Lines文件。这样任何时候你都能回溯到某次实验的完整上下文。这个习惯看起来麻烦,但当你需要复现三个月前的一个结果时,你会感谢自己。
5.3 依赖管理:今天能跑,明天就崩
Python的依赖管理是出了名的混乱。你今天pip install了一堆包,跑通了,明天换台机器,同样的命令,可能就报错了。原因是依赖的版本没有锁定。从零做AI工程,我强烈建议用pip-tools或者poetry来管理依赖。pip-tools的思路是,你在一个requirements.in文件里写顶层依赖,然后pip-compile生成一个锁定了所有传递依赖版本的requirements.txt。这样在任何机器上安装,版本都是一致的。
另外,CUDA版本和PyTorch版本的兼容性是个大坑。PyTorch 1.x和2.x的API有变化,CUDA 11.x和12.x的驱动要求也不同。我的经验是,在Docker镜像里固定所有版本,包括CUDA基础镜像的tag。不要用latest,因为latest随时会变。把Dockerfile也纳入版本控制,这样环境本身也是可复现的。
6. 从零搭建的边界:什么时候该停下来用现成方案
自己从零实现AI工程的各个组件,目的是理解原理,而不是为了在生产环境里重复造轮子。当你清楚了特征存储为什么要用列式格式、分布式训练为什么要同步梯度、推理服务为什么要做动态批处理之后,你就有了判断力:什么时候该自己写,什么时候该用成熟方案。
比如,小规模场景下,自己写一个基于Parquet和Redis的特征存储完全够用,没必要上Feast或者Tecton。但当你需要跨团队共享特征、需要点查延迟在10ms以内、需要自动回填历史特征时,自研的成本就太高了。同样,推理服务如果QPS只有几十,直接Flask加个锁就够了,没必要上Triton或者TensorRT。但QPS上千、模型多个版本并行、需要A/B测试时,专门的推理服务器就是必须的。
我的判断标准是:如果某个组件的维护成本开始超过它带来的收益,或者它开始阻碍你快速迭代,那就是时候换成成熟方案了。从零实现的价值,是让你在换方案的时候,知道自己在换什么,以及新方案在哪些地方做了妥协。这种判断力,是调包调不出来的。
7. 我个人在实际操作中的几点体会
第一,不要一开始就追求大而全的架构。我见过有人上来就设计微服务、消息队列、Kubernetes集群,结果模型还没跑通,运维复杂度已经压垮了团队。从零开始,意味着从最简单的脚本开始,遇到瓶颈再演进。先写一个能跑通的训练脚本,再加日志,再加分布式,再加服务化。每一步都解决一个具体问题,而不是提前解决想象中的问题。
第二,数据质量比模型结构重要得多。我做过很多次实验,把模型从BERT换成RoBERTa,或者加几层Transformer,效果提升往往不到一个点。但把训练数据里的噪声清洗一遍,或者修正标注错误,效果能提升三到五个点。所以,在AI工程里,花在数据管道上的时间,回报率远高于花在模型调参上的时间。
第三,监控和告警要尽早做。不要等线上出事了才想起来加监控。从第一个模型上线开始,就要有基本的指标采集和告警。哪怕只是每天跑一个脚本,检查一下预测分布有没有异常,也能帮你避免大事故。我现在的习惯是,任何服务上线前,必须有三条告警规则:错误率超过阈值、延迟超过阈值、特征分布偏移超过阈值。没有这三条,不允许上线。
第四,文档是给自己写的。不要觉得写文档是浪费时间。三个月后的你,和现在的你,几乎不是同一个人。你现在觉得理所当然的设计,三个月后可能完全想不起来为什么这么做。所以,每个模块的README、每个关键决策的注释、每次实验的记录,都是在给未来的自己铺路。我吃过这个亏,一个特征计算逻辑,当时觉得很简单没写注释,后来排查一个线上问题时,花了整整两天才搞明白当时的意图。从那以后,我强制自己写文档,哪怕只是几句话。