这个项目标题是我在某次把旧实验目录整个推翻重写时,随手敲下来的名字:“ai-engineering-from-scratch”。字面意思是“从零开始搞AI工程”,但它后面成了我大半年里最值的一次重构。不是说我从零实现Transformer、从零写CUDA,而是我把一个靠拼装API和现成库堆起来的模型项目,彻底拆成了一条自己可以完全掌控的工程链路——从数据清洗、样本构建、模型训练、指标验证到服务部署,每一步都用最直白的方式给自己交代清楚。如果你现在正处在一个“能跑通别人的notebook,但自己从头搭一套完整AI项目就手忙脚乱”的状态,这个标题对应的思路应该对你有用。
我身边很多朋友在这个阶段会特别焦虑:模型框架日新月异,工具越来越多,似乎直接调用就能出结果,为什么还要“从零开始”。我只能说,框架永远是帮你节省时间的,不是替你思考的。当你只知道“调包”而不知道内部数据怎么流动,遇到Loss掉不下去、指标不涨、结果复现不了的时候,你会发现自己连排查问题的入口都找不到。所以我更想跟你讲清楚,这一套从零搭建的AI工程,到底是怎么拆解的、有哪些关键环节、以及我实际写代码时踩过的坑。
1. 为什么我从零开始搭 AI 工程,而不是直接用成熟训练框架
1.1 框架解决的是通用问题,而你的任务往往是特定的
一圈训练框架用下来,你会发现一个现象:框架越高级,离你的具体问题越远。Hugging Face的Transformers把数据管道、模型加载、训练循环都封装得很舒服,但你换了任务类型之后,经常要跟它的内部设计做对抗。比如我想在训练时做一种比较特殊的数据采样:某些样本的出现频率要对训练轮数动态衰减,让模型后期少看噪声数据。这个需求写在框架里要翻源码,自己写训练循环几行就搞定了。
从零搭建的最大价值,不是“图省事”,而是让自己清楚每一个参数到底在影响什么。框架帮你遮住了复杂度,也就遮住了判断力。你失去判断力之后,出了问题只能上网提问,而别人没有你的数据,也没有你的Loss曲线,那些提问通常得不到真正有用的答案。
我自己选择从零开始还有另一个原因:稳定性。框架升级频繁,接口说改就改。某个版本跑得好好的训练脚本,换了新版本可能直接报错,因为内部类名变了。我会把模型训练这一层做成自己可以控制的结构:数据流是显式的,优化器状态是明确的,就连随机种子落在哪里都是自己安排的。这样换环境、换GPU型号、换数据量,都不会影响核心逻辑。
1.2 从零搭建的另一层价值:成本与调试门槛都能降下来
很多初学者以为“从零搭建”意味着更高的成本,恰恰相反。你只在模型结构层面复用一个可靠的框架,比如直接用PyTorch或者某种开源模型仓库,但训练、评估、数据这些工程部分自己控制,成本是最低的。因为每一层都是透明的,一个环节出问题,直接定位到那一层,而不是让黑箱替你藏问题。
举一个实际数字:我早期用现成框架训练一个文本分类模型,官方文档里的默认配置一启动就占满了显存。我当时以为是我数据太大了,来回调batch_size调了三天。后来用自己写的训练循环才发现,框架默认把验证集也塞进了同一个批次,加上梯度累积内部的中间变量,显存翻倍。如果我对工程链路没有掌控力,这种问题会消耗我很多时间。
自己搭工程还能帮你真正控制成本。大模型训练不是只有GPU费用,还有电费、时间、调试成本和试错成本。一个干净的工程链路可以让你用很小的数据集先跑通全部环节,确认每次改动的影响后,再上完整数据。这个节奏是任何AI项目都应该有的基本素养。
2. 动手之前,先把AI项目拆成可度量的四个要素
2.1 数据组件:质量、去重和划分优先级高于一切
从零搭建AI工程,我第一个要拆解的一定是数据,而不是模型。很多项目和面试简历最大的问题就是数据部分一笔带过,但真实训练中数据往往决定了模型能力的上限。我之前做文本分类,模型结构换了三轮,指标纹丝不动,最后发现是有大量重复样本被放进了验证集。重复样本既存在于训练集内部,也存在于训练集和验证集之间,导致模型看起来“很厉害”,一换真实数据就露馅。
清洗和去重是数据的底座。最基础的操作包括:
- 按哈希或MinHash做精确和近似去重,文档级别和句子级别都要去;
- 过滤掉过短、过长、包含乱码或异常重复符号的样本;
- 对文本类数据做统一编码处理,去掉隐藏的控制字符;
- 做一次全局的标签分布统计,如果某些类别样本太少,要想好是增采样还是合成样本;
- 从确定性的时间、来源、ID等维度做训练集和验证集的分割,不要随机打乱后直接切分,避免同源数据泄漏到验证集。
这些步聚要比调参重要几个数量级。我见过太多团队在数据没洗干净的情况下花大力气换模型尝试,那是不折不扣的浪费。数学上可以这么理解:模型的容量再大,也无法弥补数据分布本身的错误信号。你把标签贴错了,模型会“认真”学习你的错误,然后给你一个看似合理的错误结果。
2.2 模型基线:先用简单模型跑通,再考虑复杂结构
从零搭建项目的时候,我的习惯是先建立一个“极不可能出错”的基线。做文本任务时,一个词频统计加上逻辑回归就够用;做图像任务时,一个简单的卷积模型就够用;做序列任务时,可以先尝试浅层模型。很多人会觉得这样太简单,不够“AI”。但你要明白,基线模型有三大用处:第一,验证数据管道的正确性;第二,拿到一个合理的指标参考线;第三,帮你分辨后续模型的提升到底是数据带来的,还是结构带来的。
基线模型还有一个问题帮你顺手解决:你真的知道自己的任务在多难吗?用简单模型如果已经达到90分,你就不需要为了两分牺牲大量资源和时间。用简单模型如果只有30分,你也能更早意识到问题可能出在数据或者任务定义上,而不是模型不够先进。
我曾经带过一个实习生,上来就想用大模型微调做意图识别,我让他先用TF-IDF加逻辑回归。他花半天跑完,准确率到了85%。后来他把数据清洗做了一轮,同样逻辑回归直接到90%。这一下就说明数据的作用远大于模型。这个案例后来成了我判断项目健康度的标准动作。
2.3 训练目标:明确你在优化什么,而不是“再训几轮”
很多项目从零搭建后最大的问题不是不会写代码,而是不知道在优化什么。AI工程不能只有“Loss下降”这一个信号,因为过拟合时Loss也会下降。你在项目开始前就要定义清楚:离线指标是什么,线上业务指标又是什么。分类任务要有精确率、召回率、F1;生成任务要有BLEU、ROUGE或人工评估;回归任务要有MAE、RMSE;大规模语言模型还要考虑困惑度、回答准确率、事实一致性。
更好的做法是把指标分成两类:一类是训练时就能快速反馈的代理指标,另一类是最终衡量效果的终极指标。代理指标不需要跟终极指标完全一致,但必须高度相关。比如训练一个生成式摘要模型,终极指标是摘要的事实一致性,但训练时你不可能每一步跑一次大模型评估,那么代理指标可以是ROUGE或者抽取式重叠度。你要保证代理指标提升时,终极指标的大方向也是提升的,否则就需要停下来重新设计。
我见过最浪费时间的问题就是目标不清晰。一个人闷头训练两周,然后发现评价指标选错了,所有实验都要重来。这个成本比从零搭建工程本身高得多。所以我会在项目启动的第一天就把评估脚本写出来,用一个小模型把全部流程跑到能出数字,再去增加训练数据量。整个逻辑顺序一定是:先有评估,再有训练,最后调参。
3. 核心模块的实现细节,以及我在写代码时的真实取舍
3.1 目录结构:从一开始就为可复现做准备
从零搭建的工程,目录结构决定了你后面三个月的幸福感。我的标准结构大致是这样:
ai-engineering-from-scratch/ ├── config/ # 所有实验配置,yaml或json均可 │ ├── data_config.yaml │ └── train_config.yaml ├── data/ │ ├── raw/ # 原始数据,只读不写 │ ├── processed/ # 清洗后的数据,带版本标记 │ └── splits/ # 训练/验证/测试划分 ├── src/ │ ├── data/ │ │ ├── cleaning.py │ │ ├── dedup.py │ │ └── dataset.py │ ├── models/ │ │ ├── base_model.py │ │ └── model_wrapper.py │ ├── train/ │ │ ├── trainer.py │ │ └── optimizer.py │ └── evaluate/ │ └── metrics.py ├── experiments/ │ ├── exp_001/ │ ├── exp_002/ │ └── run_record.md └── scripts/ ├── run_train.sh └── run_eval.sh这个结构的核心思想是:数据原始目录永远只读,清洗后的数据带版本,所有实验配置和运行记录绑定在一起。我经历过的痛苦几乎都来自“只记得代码改过,不记得为什么改”。有了实验记录目录之后,每跑一次训练,就把当时的配置、代码commit编号、数据版本号、训练日志一起存进去。三个月后你回头看,还能准确说出某个数字是怎么来的。这一点对AI工程来说,比写100个函数都重要。
3.2 数据加载与采样:容易忽略但对结果影响很大的部分
数据加载写得好不好,决定了训练效率的上限。很多人直接用现成的DataLoader机械地加载数据,我发现很容易出问题的地方有三个:随机种子的一致性、顺序保持、以及样本量变化时的稳定性。
第一,随机种子。我在代码里固定了Python的random、NumPy的random和PyTorch的random,种子来源于配置文件。做分布式或多卡训练时,还需要为每个进程设置不同但可复现的种子偏移。否则你连一次实验都复现不了。
第二,顺序保持。对文本任务,我通常先对所有样本按ID排序,再做一次性shuffle。这样即便某个DataLoader在断点恢复后行为不同,你还是能从原始顺序推导出采样结果。很多数据泄漏问题,就是因为这里图省事。
第三,样本量变化。我在训练文本模型时往往会做“动态采样”:某些长文档会被切成长度为512的多个片段,而原始样本量取决于切割方式。如果这个逻辑不稳定,同一份数据在不同代码版本里会展现出不同分布,让实验结果没法比较。
数据加载模块还有一个容易出错但很少人注意的点:样本的批处理要尽量平衡长度。文本、语音、图像处理中都有“形状差异”的问题,如果你把长度悬殊的样本塞进同一个批次,短样本得到大量填充,计算效率大大降低。我会把样本按长度排序后分桶,再在每个桶内做随机采样,这个技巧在实际训练中能提高20%到30%的吞吐量。
3.3 训练循环:混合精度、梯度累积与学习率调度
训练循环是AI工程的心脏,我会把最核心的经验都放在这一节。下面这一段是我经常使用的完整且直白的伪代码结构:
model.train() optimizer.zero_grad() for batch_idx, batch in enumerate(train_loader): inputs = batch["input_ids"].to(device) labels = batch["labels"].to(device) with torch.cuda.amp.autocast(): outputs = model(inputs, labels=labels) loss = outputs.loss / grad_accumulation_steps loss.backward() if (batch_idx + 1) % grad_accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()这里有两个细节我要特别解释:混合精度和梯度累积。
混合精度不是“把模型变成半精度”。它的本质是,在计算前向和反向传播时用FP16加速,同时在优化器状态里保留FP32的权重复本,让它在不牺牲稳定性的前提下大幅降低显存占用、提高吞吐量。用torch.cuda.amp的autocast和GradScaler是简单有效的做法。我见过很多新手直接把模型转成.half()训练,结果Loss直接发散,这不是混合精度,而是误用。
梯度累积解决的是“显存不够、batch又不够大”的矛盾。假设你想要的batch是64,但显存只能放下16,那就可以设置grad_accumulation_steps=4,每4个batch做一次参数更新。这跟你直接跑batch=64的效果并不完全等价,因为BN层对应的统计是逐小批次的,但绝大多数任务里这个差异可以接受。在Loss这一层做除法归一化也很关键,因为PyTorch在反向传播时是累加梯度,除非你除以累积步数,否则梯度会被放大4倍,学习率就要跟着调。
学习率调度我基本会固定用一个组合:前几步做warmup,后面跟一个线性或cosine衰减。设置warmup的原因很实际:训练初期模型权重是随机或预训练初始状态,梯度的统计量非常不稳定,过大的学习率会让参数一下子飞出合理的区域。一般warmup步数占总步数的5%到10%就够了。衰减则是为了让模型在后期能稳定收敛,避免在最优区域附近来回震荡。你可以跑一组极端实验来看:没有warmup时训练半天指标都上不去,加上几百步就能稳定下降,我自己在这个问题上吃过亏。
检查点保存也要提前设计好。我既保存“最新”的checkpoint,也保存“最优验证指标”的checkpoint。两个文件名要区分清楚,因为训练后期可能会过拟合,只有最优验证指标的checkpoint才有上线价值。保存的字段除了权重,还要带上当时的学习率、优化器状态、随机种子和数据版本,方便断点续跑和复现。
3.4 评估模块:和训练分开,但要一起设计
评估模块是很多从零项目里最后才补的东西,但这是错的。我建议在写训练脚本之前就把评估脚本写好,哪怕它只有几行。评估的重要性在于:它是你判断“训练是否有效”的唯一标准。没有评估的训练,不过是在盲目调参。
评估有几个容易踩的坑:第一,评估数据必须和训练数据完全隔离。我刚才提到过,数据泄漏不是只有“忘了分割”这一种形式,很多人用了全量数据做统计归一化,比如计算词表的频率、计算均值和方差,这就把验证集的信息泄露进去了。所以,凡是需要拟合在数据上的统计量,都必须只对训练集计算,再应用到验证集和测试集。
第二,评估过程不能跟训练抢资源。我在代码里把每个epoch结束后的验证逻辑独立成一个函数,在单独的进程中调用。训练Loop里直接调用验证函数虽然方便,但会让GPU利用率出现周期性的尖峰和谷值,还容易把验证阶段的梯度误放进训练流程。最好是在验证时给模型设一个validation模式,确保Dropout和BatchNorm的行为正确。
第三,评估指标要分数据子集看。只给一个平均分远不够,我会把样本按来源、长度、类别等维度切分,分别计算指标。这能帮你定位模型在哪些地方失效。比如一个意图识别模型整体F1为80%,但你看长句类别发现F1只有30%,那调参方向就完全不同。宏观率指标往往会掩盖这种关键差异。
4. 从跑通到跑好:我在试运行阶段踩过的典型坑
4.1 Loss 正常下降,但验证集指标不动
这是我在训练过程中最常遇到的现象,也是最让人困惑的一个。表面上看,模型在训练集上不断进步,Loss曲线的确在往下走,但验证集上一看,准确率、F1一点变化都没有。这通常有三个原因。
第一个原因,训练Loss和验证指标对应的目标不完全一致。如果验证指标是准确率,训练目标却是交叉熵,模型可能为了降低交叉熵而在某几个易混淆类别上增加了预测概率,但阈值不变时准确率不会立刻改变。把验证指标换成模型在正确类别上的平均置信度,往往就能看到变化。
第二个原因,模型出现了“早熟”,这是我自己起的叫法。模型在早期先用最容易的特征做出基本的判断,之后它只是在拟合噪声,所以训练Loss继续降低面验证指标不动。这种情况适合用正则化、数据增强、或者更早的早停策略来解决,再盲目训下去没有意义。
第三个原因,评估代码本身有问题。有些人的验证集因为shuffle方式没固定,每次都跟其他阶段混合,指标抖动巨大,根本看不见真实趋势。我的检查方式是把验证集固定,多次跑同一个权重,看指标是否稳定,如果波动超过1%,第一嫌疑就是评估逻辑而不是训练逻辑。
4.2 显存溢出不等于代码写错
新手看到CUDA out of memory通常会以为自己代码写得不行,但显存溢出很多时候是合理的工程问题。我的排查顺序是:先看是不是批次太大;再看是不是输入长度没有padding到统一区间;最后看是否有缓存没有被释放。
批次太大是最容易解决的,把batch_size降下来,或者开梯度累积。输入长度的问题容易被忽略:很多人给文本按最长样本的99%分位做截断后,剩下的极长文本偶尔会撑爆显存。解法是训练时对长度超过上限的样本截断,或者把它们放进一个单独的小batch里处理。第三个“缓存没有释放”则和代码逻辑有关,比如每步都生成新的计算图,却没有用torch.no_grad()做推理,或者用了一个会跨epoch保留梯度的动态数据结构。
还有一个我实际验证过很有效的手段:用torch.cuda.max_memory_allocated()统计峰值显存用量,先跑一个batch看基准,再逐步增加batch_size。这比你每次报错之后瞎猜要高效得多。只要峰值显存不超过物理显存的一个安全比例,比如80%到90%,训练一般不会中断。
4.3 模型不收敛,先检查哪几个地方
Loss完全不下降的排查顺序跟很多人想的不一样。我第一个查的是数据,不是学习率。你先打印一批样本,看看输入和标签对齐没有。文本任务里最常见的问题,是把标签错位了一个token,或者做了padding之后标签没有做相应的mask。数据没问题的话,再查模型输出范围,比如二分类任务的logit是否都是同一个值,这能说明模型有没有从数据中学到任何信号。
接下来查学习率。学习率太大,Loss会在一个区间剧烈震荡然后发散;学习率太小,Loss下降极其缓慢,几乎像一条水平线。我通常会在开始大规模训练前跑一个“学习率扫描”:从小到大设置几个实验,比如1e-5到1e-2之间取几个值,每轮只训练几百个batch,看哪段区间Loss下降最稳定。
还要检查优化器的配置。Adamw和Adam看似接近,但weight decay有时候不只是“正则化”这么简单,它能影响最终的泛化表现。我把weight decay设置过高时,模型训练经常出现Loss下不去的情况。另外,不要忘了loss本身的计算逻辑:比如一个复杂的机器翻译项目,如果计算mask不够严谨,padding位置也会产生Loss,模型会一边提升有效位置的表现,一边降低无效位置的Loss,整体指标自然就会混乱。
5. 这个项目还可以继续扩展的部分
5.1 把训练链路升级成能反复使用的流程
从零搭建的最大收获不是某一个实验的结果,而是一条能复用的流程。我现在会把这个目录进一步抽象成模板:配置管理、数据清洗、训练循环、评估模块,各自相对独立,换任务时只替换模型和数据组件。这样新项目能在一到两天内跑通整个基线。
往这个方向扩展时,有两个关键点:第一,把流程里的“实验记录”做成自动化。每次训练开始,自动采集代码commit、数据版本、配置内容和环境依赖到实验目录内。第二,把数据版本管理纳入进来。原始数据在一段时间内可能会被补充更新,如果不加版本,你很难知道某个实验结果到底对应哪一批数据。这个其实在工业数据链路里是必备的,自己做小项目时也值得提前设计。
后面如果要加入更大规模分布式训练,从零搭的那套逻辑也能平滑升级。只需要把数据采样改成分布式采样器,把梯度同步改成跨卡通信,训练部分的核心结构不需要推翻。这就是框架透明带来的底气。
5.2 把训练链路升级成能反复使用的流程
模型训练完不是工程的终点,最后总要面对推理和服务。我一开始只写了离线评估的脚本,后面又补了一个轻量的推理模块。核心思路是:在离线和在线推理之间共用同一套数据预处理代码。很多人训练时用一种方式切词、tokenize,服务时又用框架默认的预处理方法,结果模型分毫未改,上线后指标却掉一大截,原因几乎必然在这里。
部署时我建议先做单机批量推理脚本,加上一个最简单的HTTP服务接口,验证延迟和吞吐。如果响应时间超标,再去考虑模型量化、剪枝,或者增加缓存。这些手段都要基于可量化的指标来做判断,不要为了炫技而做。这个项目的下一步,我打算把混淆矩阵、样本级预测结果、线上反馈日志统一存下来,做成一套持续追踪模型表现的闭环。因为AI工程真正的价值,不是训练完那一刻模型多准,而是它在真实环境里长时间稳定可维护。
说到底,从零搭建标的从来不是“不用现成库”,而是“能讲清楚自己系统里每一个环节的前因后果”。把这条链路走通之后,你再回过头去看各种框架,会发现它们不再是黑箱,而是你可以按需借用的工具。这个感觉,跟一个新手时期完全不一样。