1. 从零搭建AI工程能力,为什么大多数人卡在“会调包”这一步
“ai-engineering-from-scratch”这个标题,第一次看到的时候我就觉得它戳中了一个很真实的痛点。现在市面上讲AI的教程铺天盖地,但绝大多数都在教你“怎么调用某个库”“怎么跑通某个预训练模型”,真正从工程角度把一套AI系统从零搭起来的内容少得可怜。我自己带过几个刚入行的同学,他们能熟练地用几行代码加载模型、跑推理,但一旦问到“这个模型怎么部署到生产环境”“数据管道怎么设计”“推理延迟怎么优化”,基本就卡住了。这就是典型的“会调包,不会做工程”。
所谓AI工程,本质上不是研究算法本身有多先进,而是把算法变成一个可靠、可维护、可扩展的系统。它涵盖的东西比大多数人想象的要多得多:数据采集与清洗、特征工程、模型训练与调优、模型压缩与加速、服务部署、监控告警、版本管理、A/B测试……每一个环节都有大量的工程决策要做。而“from scratch”这个限定词,意味着我们不是站在巨人的肩膀上随便调个API就完事,而是要从最基础的组件开始,理解每一层的原理和取舍。
这篇文章适合谁看?如果你已经掌握了Python基础,了解一些机器学习的基本概念,但不知道如何把一个模型变成真正能用的产品,那这篇内容就是为你准备的。如果你是有一定经验的工程师,想系统性地梳理AI工程的全链路,也能从中找到一些被忽略的细节。我会尽量用从业者的视角,把每个环节的“为什么”讲清楚,而不是只丢一堆代码让你抄。
2. 先搞清楚AI工程和传统软件工程到底差在哪
2.1 不确定性是最大的敌人
传统软件工程里,你写一个函数,输入A就得到B,逻辑是确定的。但AI系统不一样,模型输出的结果带有概率性,同样的输入可能得到不同的输出,而且你很难用传统的单元测试去验证“这个输出是对的”。这就导致整个工程体系需要重新设计。比如在传统后端开发里,一个接口返回500错误,你查日志就能定位问题。但在AI系统里,模型效果下降了20%,你根本不知道是数据漂移了、特征管道出bug了、还是模型本身老化了。这种不确定性贯穿了整个AI工程的生命周期,也是为什么AI工程比传统软件工程复杂得多的根本原因。
我在实际项目中遇到过这样的情况:一个推荐模型上线三个月后效果突然下滑,排查了一整天才发现是上游数据源的一个字段格式悄悄变了,导致特征计算出了偏差。传统软件里这种问题会直接报错,但AI系统里它只是“效果差了一点”,不会给你任何显式报错。所以做AI工程,你必须建立一套完整的监控体系,不光监控系统指标,还要监控数据分布和模型输出分布。
2.2 数据是代码,而且是不受控的代码
在传统软件工程里,代码是核心资产,数据是附属品。但在AI工程里,数据的地位至少和代码同等重要,甚至更重要。问题在于,代码你可以用Git做版本管理,每次变更都有记录、可回滚。但数据呢?几百GB的训练数据,你怎么做版本管理?怎么保证这次训练用的数据和上次是同一份?怎么追踪数据清洗过程中的每一步变换?这些都是传统软件工程没有遇到过的问题。
我见过太多团队在数据管理上翻车。最典型的情况是:某个同学在训练前手动改了几行清洗脚本,重新跑了一遍数据,但没有记录改了什么。两周后发现模型效果不对,想复现之前的版本,结果发现数据已经对不上了。所以我现在做任何AI项目,第一件事就是搭数据版本管理的基础设施。工具层面可以用DVC或者LakeFS,但更重要的是建立规范:任何数据变换必须通过代码完成,不允许手动改数据;每次数据变更必须记录版本号和变更说明。
2.3 实验管理和代码管理是两回事
传统软件开发中,一个功能分支合并到主干就完事了。但AI工程里,你可能会同时跑几十个实验,每个实验有不同的超参数、不同的特征组合、不同的数据版本。这些实验的配置怎么管理?结果怎么对比?哪个实验对应哪个模型文件?如果没有一套实验管理机制,很快就会乱成一锅粥。
我的做法是:用配置文件管理所有实验参数,每个实验有唯一的ID,实验ID关联到代码commit、数据版本、超参数配置和模型文件路径。工具上可以用MLflow或者Weights & Biases,但核心不是工具,而是养成“任何实验都可追溯”的习惯。这一点在团队协作中尤其重要,否则你根本不知道同事上周跑的那个效果很好的模型到底是怎么训出来的。
3. 从零搭建AI工程环境,我的选型和踩坑记录
3.1 开发环境:为什么我最终放弃了本地裸装
刚开始做AI项目的时候,我习惯在本地机器上直接pip install各种包。但很快问题就来了:项目A需要PyTorch 1.12,项目B需要PyTorch 2.0,两个项目的CUDA版本还不一样。每次切换项目都要重新配环境,有时候配了半天还搞不定依赖冲突。后来我转向了容器化方案,用Docker把每个项目的环境隔离起来。再后来发现Docker虽然隔离性好,但构建镜像和传输镜像的时间成本太高,尤其是GPU镜像动辄几个GB。最终我选择了Conda加Docker的组合方案:用Conda管理Python依赖,用Docker管理系统级依赖和CUDA版本。开发阶段用Conda环境快速迭代,部署阶段用Docker保证环境一致性。
这里有个细节值得注意:Conda环境的导出不要用conda env export,因为它会把所有依赖的精确版本都锁死,换一台机器可能就装不上了。我一般用conda env export --no-builds,去掉build号,兼容性会好很多。另外,pip和conda混用是万恶之源,能只用一种就只用一种。如果非要用pip装conda里没有的包,记得在conda环境激活后再pip install,并且记录好pip安装的包列表。
3.2 计算资源:什么时候该上GPU集群
很多刚入行的同学一上来就想搞多卡训练,觉得GPU越多越好。但实际上,大部分中小规模的项目,单卡甚至CPU就够了。我做过一个文本分类项目,数据量大概50万条,用单张消费级显卡训练一个BERT-base模型,不到两小时就跑完了。这种情况下上多卡完全是浪费资源,因为多卡通信的开销可能比计算本身还大。
判断是否需要多卡训练,我的经验法则是:如果单卡训练时间超过一天,且你确认瓶颈在计算而不在数据加载,那才考虑多卡。而且多卡训练不是简单地把batch size乘以卡数就行,学习率、warmup步数都要相应调整。我见过有人直接把单卡的训练脚本改成多卡,结果loss曲线完全不对,就是因为没有调整学习率。一般来说,batch size扩大k倍,学习率也要扩大k倍左右,但具体还要看优化器和任务类型。
3.3 存储方案:小文件多了真的很要命
AI项目的数据集往往包含大量小文件,比如几十万张图片、几百万条文本记录。如果直接存在本地文件系统上,读取速度会非常慢,因为每次打开文件都有IO开销。我刚开始做图像分类的时候,直接把图片存在硬盘上,DataLoader的读取速度成了整个训练流程的瓶颈,GPU利用率只有30%左右。后来把图片打包成TFRecord或者LMDB格式,读取速度提升了将近5倍,GPU利用率直接拉满。
对于文本数据,我一般会预处理成统一的二进制格式,比如用HDF5或者Parquet存储。Parquet的好处是列式存储,读取特定列的时候不需要加载整个文件,而且压缩比很高。但Parquet不适合频繁写入的场景,如果你的数据管道需要不断追加新数据,可以考虑用LMDB或者RocksDB。存储方案的选择没有银弹,关键是根据你的数据特点和访问模式来定。
4. 数据管道:AI工程里最容易被低估的环节
4.1 数据清洗不是“跑个脚本”那么简单
很多人觉得数据清洗就是写个脚本,把缺失值填一填、异常值删一删就完事了。但实际项目中,数据清洗往往占据了整个项目60%以上的时间。而且清洗的逻辑不是拍脑袋决定的,需要结合业务理解。举个例子,我在做一个用户行为预测项目时,发现很多用户的年龄字段是空的。如果直接删掉这些记录,会损失将近30%的数据。后来跟业务方沟通才知道,这些空值其实是有含义的——用户没有填写年龄,往往意味着他对隐私比较敏感,这类用户的行为模式和其他用户有显著差异。所以最后我没有删这些记录,而是把“年龄是否缺失”作为一个单独的特征加进了模型。
数据清洗的另一个坑是:训练时的清洗逻辑和推理时的清洗逻辑必须完全一致。我见过一个团队在训练时对缺失值做了均值填充,但推理时忘了做这一步,导致线上效果和离线评估差了十万八千里。解决这个问题的办法是把清洗逻辑封装成一个统一的模块,训练和推理都调用同一个模块,确保逻辑一致。更进一步,可以把清洗逻辑做成配置文件驱动的,这样修改清洗规则不需要改代码,降低出错概率。
4.2 特征工程:别急着上深度学习
现在很多人一提到AI就是深度学习,觉得特征工程是过时的东西。但我在实际项目中的体会是:对于结构化数据,好的特征工程往往比换个更复杂的模型更有效。我做过一个金融风控项目,一开始直接用原始特征喂给一个深度神经网络,AUC只有0.72。后来花了两周时间做特征工程,构造了一些交叉特征和时间窗口统计特征,AUC直接提升到了0.81。而换更复杂的模型架构,提升可能只有0.01到0.02。
特征工程的核心是理解业务。比如在风控场景里,“用户最近7天的交易次数”比“用户的总交易次数”更有预测力,因为前者捕捉了近期行为变化。这种特征不是靠自动特征工程工具能发现的,需要你对业务有深入理解。当然,对于图像、语音、文本这类非结构化数据,深度学习确实能自动学到好的特征表示,这时候特征工程的优先级可以降低。但即便如此,数据增强、归一化、分词策略这些预处理步骤仍然属于广义的特征工程范畴,不能忽视。
4.3 数据加载的性能优化:别让GPU等数据
训练模型的时候,最浪费的情况就是GPU算完了在等数据。我见过不少项目,模型本身不复杂,但训练速度慢得离谱,排查半天发现是DataLoader的num_workers设成了0,数据加载完全串行。一般来说,num_workers设成CPU核心数的2到4倍比较合适,但也不是越大越好,因为每个worker都会复制一份数据到内存里,设太大反而会导致内存不足。
另一个常见的性能问题是数据预处理太慢。比如在图像任务中,如果每次读取图片都要做resize、归一化、随机裁剪等操作,CPU很容易成为瓶颈。我的做法是提前把能预计算的操作都做掉,比如把图片统一resize到固定尺寸再存起来,训练时只做随机增强。这样虽然多占了一些存储空间,但训练速度能提升好几倍。还有一个技巧是用NVIDIA的DALI库,它能把数据预处理放到GPU上做,进一步减轻CPU负担。不过DALI的学习曲线比较陡,如果项目时间紧,用PyTorch自带的DataLoader配合足够的num_workers也能满足大部分场景。
5. 模型训练与调优:那些文档里不会写的经验
5.1 超参数搜索:网格搜索是最笨但最可靠的方法
超参数调优的方法很多,从最笨的网格搜索到贝叶斯优化、遗传算法等等。我的经验是:如果超参数空间不大(比如只有学习率和batch size两个参数),网格搜索其实是最可靠的,因为它能覆盖整个空间,不会陷入局部最优。而且网格搜索可以完全并行化,你有多少GPU就能同时跑多少个实验。贝叶斯优化虽然理论上更高效,但它需要串行迭代,每次都要等上一个实验跑完才能决定下一个实验的参数,实际 wall-clock 时间不一定比网格搜索快。
对于超参数空间很大的情况,我一般会分两阶段:先用随机搜索快速缩小范围,然后在缩小后的范围内做精细的网格搜索。随机搜索的好处是你不需要预先定义网格点,直接从分布中采样就行。根据经验,随机搜索跑60个实验的效果,基本能赶上网格搜索跑几百个实验。另外,学习率是最重要的超参数,没有之一。我一般会先固定其他参数,单独对学习率做一次粗调,找到大致范围后再联合调其他参数。
5.2 过拟合和欠拟合:看曲线不如看业务指标
判断模型是过拟合还是欠拟合,教科书上教的是看训练loss和验证loss的曲线。训练loss下降但验证loss上升就是过拟合,两个都不降就是欠拟合。但实际项目中,我更多是看业务指标。比如在一个推荐系统里,离线AUC很高但线上点击率没提升,这往往不是过拟合,而是离线评估指标和线上业务指标不一致导致的。这时候你需要检查的是:离线评估的数据分布和线上是否一致?评估指标是否真正反映了业务目标?
真正的过拟合在业务指标上的表现是:模型在训练集覆盖的用户上表现很好,但在新用户或长尾用户上表现很差。这时候加正则化、加Dropout、减少模型参数量都是常规操作。但有时候过拟合的根源是数据问题,比如训练数据里存在泄漏(leakage),模型学到了一些在训练时存在但线上不存在的特征。这种情况加再多正则化也没用,必须从数据源头解决。
5.3 模型集成:不是所有场景都值得做
模型集成是提升效果的一个常用手段,bagging、boosting、stacking各种方法层出不穷。但我的经验是:模型集成的收益和成本需要仔细权衡。训练多个模型意味着多倍的训练时间和推理时间,如果线上服务对延迟敏感,集成可能根本不可行。我一般只在两种情况下考虑集成:一是竞赛场景,不计成本追求极致效果;二是线上服务对延迟不敏感,比如离线批量预测任务。
如果决定做集成,最简单的做法是训练多个不同随机种子的同架构模型,然后对输出取平均。这种方法实现简单,效果稳定,通常能带来1到2个百分点的提升。更复杂的做法是训练不同架构的模型做stacking,但收益不一定比简单平均高多少,而且维护成本高很多。我个人的偏好是:先把单模型调到极致,如果单模型已经满足了业务需求,就不要为了那一点点提升去搞集成。
6. 模型部署:从notebook到生产环境的惊险一跃
6.1 模型序列化:pickle是个陷阱
在notebook里训练完模型,最自然的保存方式就是torch.save或者joblib.dump。但这些基于pickle的序列化方式在生产环境里是个大坑。首先,pickle文件在不同Python版本、不同库版本之间兼容性很差,你在开发环境保存的模型,到了生产环境可能加载失败。其次,pickle文件可以执行任意代码,存在安全隐患。我现在的做法是:对于PyTorch模型,用torch.jit.trace或者torch.jit.script导出成TorchScript格式,它不依赖Python运行时,可以在C++环境中加载。对于sklearn模型,用ONNX格式导出,跨框架兼容性更好。
导出TorchScript的时候有个坑:如果你的模型有动态控制流(比如if-else依赖输入值),torch.jit.trace会失败,因为它只记录了一条执行路径。这时候需要用torch.jit.script,它支持动态控制流,但对代码写法有要求,不是所有Python语法都支持。我一般会先尝试trace,如果报错再改用script。另外,导出后一定要做数值一致性验证:用同一批输入分别跑原始模型和导出后的模型,确保输出差异在可接受范围内。
6.2 推理服务框架:Flask够用,但不够好
很多教程教你用Flask写一个推理接口,几行代码就能跑起来。但Flask是同步框架,并发能力很弱,而且没有内置的批处理、模型版本管理、监控指标等功能。在生产环境里,我一般会用专门的推理服务框架。TorchServe是PyTorch官方出的,支持多模型管理、动态批处理、指标监控,开箱即用。Triton Inference Server是NVIDIA出的,支持多种框架(PyTorch、TensorFlow、ONNX等),性能优化做得很好,但配置相对复杂。
选择哪个框架,主要看你的技术栈和团队熟悉程度。如果团队主要用PyTorch,TorchServe上手最快。如果需要同时服务多个框架的模型,或者对推理性能有极致要求,Triton更合适。不管用哪个框架,有几个功能是必须的:健康检查接口、模型版本管理、请求日志记录、推理延迟监控。这些功能看起来不起眼,但线上出问题的时候,没有它们你根本无从下手。
6.3 批处理与延迟的权衡
推理服务的一个核心矛盾是吞吐量和延迟的权衡。批处理能提高吞吐量,因为GPU擅长并行计算,一次处理一批数据比逐条处理效率高得多。但批处理会增加延迟,因为你需要等足够多的请求凑成一批才能开始推理。对于实时性要求高的场景(比如搜索排序),延迟可能比吞吐量更重要,这时候批处理窗口要设得很小,甚至不做批处理。对于离线批量预测场景,吞吐量是唯一指标,批处理越大越好。
我的做法是:先确定业务的延迟要求,然后在这个约束下最大化批处理大小。比如业务要求P99延迟不超过100毫秒,那我就从batch size=1开始逐步增加,找到延迟刚好接近100毫秒的那个batch size。另外,动态批处理是个好东西,它能在请求量大的时候自动增大batch size提高吞吐,请求量小的时候减小batch size降低延迟。TorchServe和Triton都支持动态批处理,配置一下就能用。
7. 监控与迭代:模型上线只是开始
7.1 数据漂移检测:别等效果掉了才反应过来
模型上线后,最怕的就是数据分布悄悄变了,模型效果慢慢下降,但你没有及时发现。数据漂移检测的核心是监控输入特征的分布变化。常用的方法有PSI(Population Stability Index)、KL散度、KS检验等。我一般会对每个重要特征计算PSI,PSI小于0.1表示分布稳定,0.1到0.25表示有轻微漂移,大于0.25表示显著漂移,需要警惕。
但PSI有个问题:它只检测单变量分布的变化,检测不到特征之间相关性的变化。所以除了PSI,我还会监控模型输出分数的分布。如果输出分数的均值或方差发生了显著变化,即使输入特征的PSI都正常,也可能意味着模型遇到了新的数据模式。另外,如果业务上有实时反馈(比如点击、转化),可以直接监控业务指标的变化,这是最直接的信号。但业务反馈往往有延迟,所以数据漂移检测是更早的预警信号。
7.2 模型重训练:定时还是触发式
模型重训练的策略有两种:定时重训练和触发式重训练。定时重训练就是每隔固定时间(比如每周、每月)重新训练一次模型,不管效果有没有下降。触发式重训练是当监控指标超过阈值时才触发重训练。两种策略各有优劣:定时重训练实现简单,但可能在不必要的时候浪费计算资源;触发式重训练更高效,但需要完善的监控体系,而且从触发到模型上线有时间延迟,可能来不及。
我的做法是两者结合:设置一个较长的定时重训练周期(比如每月一次)作为兜底,同时设置触发式重训练应对突发情况。触发条件一般包括:数据漂移超过阈值、业务指标下降超过阈值、或者有新的标注数据积累到一定量。重训练不是简单地用新数据重新跑一遍训练脚本,还需要做模型评估、A/B测试、灰度发布等一系列流程。这些流程最好自动化,否则每次重训练都要人工介入,效率很低且容易出错。
7.3 A/B测试:别只看平均值
A/B测试是评估新模型效果的黄金标准,但很多人做A/B测试只看平均指标,忽略了分布。比如新模型的平均点击率比旧模型高了1%,看起来是好事,但如果新模型在头部用户上效果下降、在长尾用户上效果提升,长期来看可能损害核心用户体验。所以我在做A/B测试时,除了看整体指标,还会分用户群体看指标变化,确保新模型不会在某些群体上表现异常差。
另一个容易被忽略的点是A/B测试的样本量和持续时间。样本量太小,指标差异可能是随机波动导致的;持续时间太短,可能覆盖不到用户行为的周期性变化(比如工作日和周末行为不同)。我一般会至少跑一周的A/B测试,确保覆盖完整的用户行为周期。如果指标差异不显著,宁可多跑几天,也不要急着下结论。统计显著性不是唯一标准,业务显著性同样重要——如果提升只有0.1%,即使统计显著,也不值得为此增加系统复杂度。
8. 一些让我少走了很多弯路的工具和习惯
8.1 配置管理:别把参数硬编码在代码里
我刚开始做项目的时候,习惯把学习率、batch size这些参数直接写在训练脚本里。每次调参都要改代码,改完还要重新提交Git,非常麻烦。后来我把所有参数抽到一个YAML配置文件里,训练脚本只负责读取配置并执行。这样调参只需要改配置文件,不需要动代码。更进一步,我用Hydra做配置管理,它支持配置继承、命令行覆盖、多组实验配置,非常灵活。
配置管理还有一个好处是可复现性。每个实验对应一个配置文件,配置文件里有唯一的实验ID,实验ID关联到模型文件、日志文件、评估结果。这样任何时候想复现某个实验,只需要找到对应的配置文件重新跑一遍就行。我现在的习惯是:任何一次训练都必须有对应的配置文件,不允许在命令行里直接传参数(除非是临时调试)。这个习惯看起来麻烦,但长期来看节省了大量排查问题的时间。
8.2 日志记录:print是最低效的调试方式
很多刚入行的同学调试代码全靠print,训练脚本里到处都是print语句。但print有几个问题:没有时间戳、没有日志级别、不能输出到文件、不能结构化查询。我现在的做法是用Python的logging模块,配置好格式和输出目标。训练时的关键信息(loss、学习率、梯度范数等)用结构化日志记录,比如JSON格式,方便后续用脚本分析。
除了训练日志,推理服务的日志也很重要。我一般会记录每个请求的输入特征摘要、模型输出、推理耗时、请求ID。这样当用户反馈问题时,可以通过请求ID快速定位到具体的请求日志。但要注意日志脱敏,不要把用户敏感信息直接打到日志里。另外,日志量大的时候要考虑采样,不然磁盘很快就被写满了。我一般会对正常请求做1%采样,对异常请求100%记录。
8.3 代码规范:AI项目也需要工程纪律
AI项目往往给人一种“能跑就行”的印象,代码质量普遍不如传统后端项目。但我实际体会是:AI项目的代码更需要工程纪律,因为AI系统的调试难度更高,代码混乱会让排查问题变得几乎不可能。我要求团队里的AI项目必须遵守基本的代码规范:函数职责单一、变量命名清晰、关键逻辑有注释、类型注解尽量加。特别是数据清洗和特征工程部分,一定要写单元测试,确保每次修改不会破坏已有逻辑。
另外,我强烈建议AI项目也做代码审查。很多人觉得AI代码是“实验性”的,不需要审查。但正是这种心态导致了很多低级错误,比如数据泄漏、标签错误、评估指标计算错误。代码审查不需要像后端项目那么严格,但至少要有一个人看过你的核心逻辑,确认没有明显问题。我见过一个项目,因为评估指标计算时把precision和recall搞反了,导致团队花了两个月优化一个实际上在退步的模型。这种错误如果有代码审查,一眼就能发现。
9. 关于AI工程能力建设,我个人的一些真实体会
做AI工程这些年,最大的感受是:算法知识决定你能不能做出一个模型,工程能力决定你能不能把模型变成产品。很多算法很强的同学,在工业界发展受限,往往不是因为算法不够好,而是因为工程能力跟不上。反过来,工程能力强的同学,即使算法基础一般,也能通过扎实的工程实践做出有价值的AI系统。
另一个体会是:AI工程没有银弹,每个项目都有其独特性。网上那些“最佳实践”可以参考,但不能照搬。比如批处理大小设多少、学习率设多少、模型多久重训练一次,这些都没有标准答案,需要根据你的数据特点、业务需求、计算资源来具体分析。我见过有人把某个竞赛的获胜方案直接搬到工业场景,结果效果一塌糊涂,因为竞赛数据和工业数据的分布完全不同。
最后想说的是:AI工程是一个快速发展的领域,新的工具和框架层出不穷。但底层的东西变化很慢:数据质量决定模型上限、工程规范决定系统可靠性、监控体系决定问题发现速度。把这些基础打牢,不管工具怎么变,你都能快速适应。我在实际项目中踩过的坑、总结的经验,基本都围绕这些底层原则展开。希望这些内容对正在从零搭建AI工程能力的你有所帮助。