1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始也是这么想的直到有一次线上推理服务在高峰期直接雪崩日志里全是显存溢出和请求超时我才意识到——只会调包的人根本不知道模型在底层到底经历了什么。ai-engineering-from-scratch这个项目标题核心不在于“AI”而在于“from scratch”。它代表的是一种学习路径不依赖现成的高级封装从最基础的张量运算、数据加载、模型定义、训练循环、推理优化一路手搓上去。这条路走起来很慢但走完之后你对整个AI系统的掌控力会发生质变。这篇文章适合那些已经会写Python、用过PyTorch或TensorFlow、但总觉得自己在“盲人摸象”的开发者。我会把从零搭建一个可训练、可推理、可部署的AI工程链路拆开讲清楚每个环节为什么这么设计以及我在实际操作中踩过的那些坑。先明确一个观点从零手搓不等于不用框架。你不需要自己写CUDA内核也不需要从二进制开始实现矩阵乘法。真正的“from scratch”是指你清楚每一层抽象下面发生了什么当框架出问题时你有能力往下钻一层去排查。比如你知道DataLoader的num_workers设置不当会导致GPU利用率忽高忽低你知道model.train()和model.eval()切换时BN层和Dropout层的行为差异你知道混合精度训练中GradScaler为什么必须配合autocast使用。这些细节才是AI工程和调包侠之间的分水岭。2. 环境搭建别让版本冲突吃掉你三天时间2.1 驱动、CUDA、cuDNN、框架的四层依赖关系AI工程的环境搭建是第一个大坑而且这个坑的深度远超你的想象。很多人拿到一台新机器兴冲冲地pip install torch然后发现torch.cuda.is_available()返回False接着就开始在网上搜各种“解决方案”最后把系统环境搞得一团糟。你需要先理解这四层依赖的包含关系显卡驱动是最底层的它决定了你的系统能支持的最高CUDA版本CUDA Toolkit是第二层它提供了GPU编程的运行时和编译器cuDNN是第三层它是针对深度神经网络的加速库PyTorch/TensorFlow是第四层它们在编译时会链接特定版本的CUDA和cuDNN。关键点在于PyTorch的每个版本都是针对特定CUDA版本编译的。比如torch2.1.0默认对应CUDA 12.1如果你系统里装的是CUDA 11.8直接pip install torch就会装到CPU版本或者报错。正确的做法是去PyTorch官网找到对应的安装命令比如pip install torch2.1.0 torchvision0.16.0 torchaudio2.1.0 --index-url https://download.pytorch.org/whl/cu121我个人的经验是用conda管理CUDA和cuDNN用pip管理Python包。conda可以帮你把CUDA Toolkit和cuDNN装到虚拟环境里不污染系统环境。具体操作conda create -n ai-scratch python3.10 conda activate ai-scratch conda install cudatoolkit11.8 cudnn8.9 -c conda-forge pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118这样装完之后torch.version.cuda会显示11.8torch.backends.cudnn.version()会显示8900左右。如果这两个对不上后面训练时会出现各种莫名其妙的错误比如CUDA error: CUBLAS_STATUS_NOT_INITIALIZED。2.2 虚拟环境隔离与依赖锁定我见过太多人把所有包都装在base环境里结果不同项目之间互相冲突。ai-engineering-from-scratch这种项目往往需要尝试不同版本的框架和库所以环境隔离是必须的。除了conda我还建议用pip-tools来锁定依赖。具体做法是维护一个requirements.in文件里面只写顶层依赖比如torch2.1.0 numpy2.0 pandas scikit-learn tqdm tensorboard然后运行pip-compile requirements.in生成requirements.txt里面会包含所有间接依赖的精确版本。这样你在不同机器上复现环境时不会因为某个小版本更新导致结果不一致。这个习惯在团队协作中尤其重要我曾经因为同事升级了numpy到2.0导致整个数据预处理管道报错排查了半天才发现是np.float_被移除了。注意numpy 2.0和很多老版本的深度学习库不兼容建议在AI工程项目中暂时锁定numpy2.0等生态完全跟上再升级。3. 数据管道模型还没开始训这里就能卡你一周3.1 从原始文件到Tensor的完整链路设计数据管道是AI工程中最容易被低估的环节。很多人觉得数据加载就是Dataset加DataLoader几行代码搞定。但实际项目中数据管道的复杂度往往超过模型本身。一个完整的从零数据管道应该包含以下步骤原始数据读取 → 清洗与过滤 → 特征提取 → 分词/编码 → 张量转换 → 批处理 → 设备传输。每一步都有坑。以文本分类任务为例原始数据可能是CSV文件里面有几万条文本和标签。第一步读取时如果用pandas.read_csv一次性加载内存可能直接爆掉。正确的做法是分块读取import pandas as pd chunks pd.read_csv(data.csv, chunksize10000) for chunk in chunks: # 处理每个chunk pass第二步清洗时要注意文本中的特殊字符、HTML标签、多余空格。我习惯写一个clean_text函数用正则表达式统一处理import re def clean_text(text): text re.sub(r[^], , text) # 去HTML标签 text re.sub(r\s, , text) # 合并空白字符 text text.strip() return text第三步分词如果用BERT类模型需要用对应的tokenizer。这里有个性能陷阱tokenizer默认是Python实现的速度很慢。可以开启use_fastTrue使用Rust实现速度能快10倍以上。第四步张量转换时要注意padding和truncation的策略。我见过有人把所有文本都padding到最大长度512结果短文本浪费了大量计算。更好的做法是动态padding即每个batch内padding到该batch的最大长度而不是全局最大长度。在PyTorch中可以通过自定义collate_fn实现def collate_fn(batch): texts, labels zip(*batch) max_len max(len(t) for t in texts) padded torch.zeros(len(texts), max_len, dtypetorch.long) for i, t in enumerate(texts): padded[i, :len(t)] t return padded, torch.tensor(labels)3.2 DataLoader的num_workers到底设多少DataLoader的num_workers参数是另一个经典坑。设成0表示在主进程加载数据设成大于0会启动子进程。很多人直接设成8或者16结果发现GPU利用率反而下降了。原因在于每个worker都会复制一份数据集对象如果数据集很大内存会被撑爆。而且worker之间的数据是通过共享内存传输的如果传输的数据量太大IPC开销会超过并行加载的收益。我的经验值是num_workers设为CPU物理核心数的1/4到1/2。比如8核CPU设2到4比较合适。同时开启pin_memoryTrue这样数据从CPU传到GPU时会更快。另外如果数据集不大比如几万条直接把整个数据集加载到内存里设num_workers0反而更稳定。还有一个隐藏问题在Windows上num_workers0需要把DataLoader的创建放在if __name__ __main__:下面否则会无限递归创建子进程。这个坑我在Windows上调试了一下午才找到原因。4. 模型定义与训练循环手搓一遍才懂的细节4.1 nn.Module的forward里到底发生了什么从零写模型第一步是继承nn.Module实现__init__和forward。但很多人不知道的是forward并不是直接被调用的。当你写output model(input)时实际调用的是model.__call__(input)而__call__里面会先执行一系列hook然后再调用forward。这意味着你可以在forward前后插入自定义逻辑比如梯度裁剪、激活值监控、特征可视化。我经常用register_forward_hook来查看中间层的输出分布判断是否存在梯度消失或爆炸def hook_fn(module, input, output): print(f{module.__class__.__name__}: output mean{output.mean():.4f}, std{output.std():.4f}) for name, module in model.named_modules(): if isinstance(module, nn.Linear): module.register_forward_hook(hook_fn)另一个细节是参数初始化。PyTorch默认用Kaiming初始化但对于不同的激活函数最佳初始化策略不同。比如用ReLU时Kaiming正态初始化是合适的用Tanh时Xavier初始化更好。从零搭建时最好显式地初始化每一层def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.zeros_(m.bias) model.apply(init_weights)4.2 训练循环中的梯度累积与学习率调度训练循环看起来简单前向传播、计算损失、反向传播、更新参数。但实际工程中你需要处理梯度累积、梯度裁剪、学习率调度、混合精度等一系列问题。梯度累积是为了在显存不足时模拟更大的batch size。比如你想用batch size 64但显存只够32那就可以每32个样本做一次反向传播但不清空梯度累积两次后再更新参数accumulation_steps 2 optimizer.zero_grad() for i, (inputs, labels) in enumerate(dataloader): outputs model(inputs) loss criterion(outputs, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()注意损失要除以accumulation_steps否则梯度会放大。梯度裁剪是防止梯度爆炸的常用手段特别是RNN和Transformer类模型。PyTorch提供了torch.nn.utils.clip_grad_norm_一般设max_norm1.0torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)学习率调度方面我推荐用OneCycleLR或者CosineAnnealingLR。OneCycleLR先 warmup 再衰减在很多任务上收敛更快。使用时要注意step的调用时机scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr1e-3, total_stepslen(dataloader) * num_epochs ) for epoch in range(num_epochs): for batch in dataloader: # 训练步骤 scheduler.step()注意OneCycleLR的step必须在每个batch后调用而不是每个epoch后。这个细节搞错的话学习率曲线会完全不对。4.3 混合精度训练省显存但别省掉GradScaler混合精度训练AMP是现在训练大模型的标配可以省30%到50%的显存速度也能提升。但很多人只加了autocast忘了GradScaler结果训练loss直接变成NaN。正确的用法from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for inputs, labels in dataloader: optimizer.zero_grad() with autocast(): outputs model(inputs) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是放大loss防止梯度在FP16下下溢。scaler.step会先检查梯度是否有inf或nan如果有就跳过这一步更新。这个机制在训练初期特别重要因为初期梯度往往很小。5. 推理与部署从checkpoint到线上服务的最后一公里5.1 模型导出与ONNX Runtime加速训练完模型后下一步是部署。直接拿PyTorch模型上线不是不行但推理速度往往不如优化过的运行时。ONNX Runtime是一个跨平台的推理引擎支持CPU和GPU而且对很多模型有图优化。导出ONNX的代码dummy_input torch.randn(1, 3, 224, 224).to(device) 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_version13 )注意dynamic_axes的设置这样导出的模型支持动态batch size。如果不设推理时只能传固定batch size线上服务会很受限。导出后可以用onnxruntime加载import onnxruntime as ort session ort.InferenceSession(model.onnx, providers[CUDAExecutionProvider]) inputs {input: np.random.randn(1, 3, 224, 224).astype(np.float32)} outputs session.run(None, inputs)我实测下来对于ResNet50这类模型ONNX Runtime在GPU上的推理速度比原生PyTorch快20%到30%而且显存占用更低。5.2 批处理与动态batching的工程实现线上推理服务的一个核心优化点是动态batching。单个请求推理效率很低GPU利用率可能只有10%。动态batching的思路是把短时间内到达的多个请求攒成一个batch一起推理然后拆分结果返回。实现方式可以用一个队列加一个后台线程import queue import threading request_queue queue.Queue() batch_size 8 timeout 0.01 # 10ms def batch_worker(): while True: batch [] try: while len(batch) batch_size: item request_queue.get(timeouttimeout) batch.append(item) except queue.Empty: pass if batch: inputs torch.stack([item[input] for item in batch]) with torch.no_grad(): outputs model(inputs) for item, output in zip(batch, outputs): item[future].set_result(output.cpu().numpy())这个模式在线上服务中非常常见但要注意超时设置。如果timeout太长单个请求的延迟会增加如果太短batch攒不起来GPU利用率上不去。我一般设5到10毫秒具体要看QPS和延迟要求。5.3 显存管理与OOM排查的实战思路显存溢出OOM是推理服务最常见的故障。排查OOM时不要只看nvidia-smi的总显存要看PyTorch的显存分配器状态print(torch.cuda.memory_summary())这个命令会打印出已分配显存、缓存显存、碎片情况。如果缓存显存很大但已分配显存不大说明是碎片问题可以通过torch.cuda.empty_cache()缓解但更好的做法是重启服务。另一个常见原因是推理时没有用torch.no_grad()导致计算图被保留显存持续增长。在推理代码中一定要加with torch.no_grad(): outputs model(inputs)如果模型特别大单卡放不下可以考虑模型并行或者量化。量化方面PyTorch支持动态量化和静态量化可以把FP32模型压到INT8显存减少75%速度提升2到4倍。但量化会带来精度损失需要做校准。6. 实验管理与可复现性别让三个月后的自己骂现在的你6.1 随机种子、日志与checkpoint的规范AI实验的可复现性是个老生常谈的问题但真正做到的人不多。我见过太多人跑出一个好结果过两周想复现却怎么也对不上最后发现是忘了设随机种子。完整的随机种子设置应该覆盖所有随机源import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False注意cudnn.deterministicTrue会降低一些性能但能保证卷积操作的结果可复现。如果追求极致速度可以设benchmarkTrue但每次运行结果会有微小差异。日志方面我建议用tensorboard或者wandb记录loss曲线、学习率、梯度范数。特别是梯度范数能帮你判断训练是否稳定。如果梯度范数突然飙升说明可能要梯度爆炸了需要调小学习率或者加梯度裁剪。Checkpoint保存不要只保存state_dict还要保存优化器状态、epoch数、学习率调度器状态否则断点续训时学习率会从头开始torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), loss: loss, }, fcheckpoint_epoch_{epoch}.pt)6.2 从实验到生产的配置分离实验代码和生产代码要分开。实验时你可能会频繁改模型结构、调超参数但生产环境需要稳定。我习惯用配置文件来管理差异比如用yaml文件# config/experiment.yaml model: name: resnet50 num_classes: 10 training: batch_size: 64 lr: 0.001 epochs: 100# config/production.yaml model: name: resnet50 num_classes: 10 checkpoint: /models/best.pt inference: batch_size: 8 device: cuda然后用一个load_config函数根据环境变量加载不同配置。这样实验和生产共享同一套代码只是配置不同减少了“实验能跑、生产报错”的情况。7. 我踩过的三个典型坑与排查链路7.1 Loss变成NaN从数据到梯度的逐层排查第一次遇到loss变NaN时我完全懵了。后来总结出一套排查链路先查数据再查模型最后查训练配置。数据方面检查是否有NaN或inf值assert not torch.isnan(inputs).any(), 输入包含NaN assert not torch.isinf(inputs).any(), 输入包含inf模型方面检查是否有除零操作或者log(0)。比如自定义损失函数里用了torch.log(p)如果p0就会变成-inf。解决办法是加一个极小值loss -torch.log(p 1e-8)训练配置方面检查学习率是否太大。学习率过大时参数更新步长太大loss会直接飞掉。可以先用小学习率比如1e-5跑几步看loss是否稳定下降再逐步调大。7.2 GPU利用率忽高忽低数据加载瓶颈的定位方法训练时nvidia-smi显示GPU利用率在20%到90%之间跳动说明数据加载是瓶颈。定位方法是看DataLoader的耗时import time start time.time() for inputs, labels in dataloader: data_time time.time() - start # 训练步骤 start time.time() print(fdata loading time: {data_time:.4f}s)如果data loading时间接近甚至超过训练时间就需要优化数据管道。常见优化手段包括把数据预处理结果缓存到磁盘、用更快的tokenizer、减少num_workers、把数据全部加载到内存。7.3 推理结果和训练结果不一致eval模式与后处理的陷阱训练时准确率95%推理时只有70%这种问题通常有三个原因忘了切换eval模式、后处理不一致、数据预处理不一致。model.eval()会关闭Dropout和BatchNorm的训练行为。如果忘了加Dropout会随机丢弃神经元BatchNorm会用batch统计量而不是全局统计量结果肯定不对。后处理方面训练时可能用了某种阈值或argmax策略推理时也要保持一致。我见过有人在训练时用argmax推理时用threshold0.5结果当然对不上。数据预处理方面训练时的归一化参数mean、std要和推理时一致。如果训练时用了ImageNet的均值和方差推理时也要用同样的值。8. 从零手搓之后再看框架源码是什么体验走完一遍从零搭建的流程后我再去看PyTorch的源码感觉完全不一样了。以前看nn.Conv2d只觉得是个黑盒现在知道它底层调用了cuDNN的卷积算法知道padding和stride如何影响输出尺寸知道groups参数如何实现分组卷积。更重要的是遇到问题时你知道该往哪个方向排查。显存溢出时你会去看memory_summary而不是盲目调小batch size训练不收敛时你会去检查梯度范数和数据分布而不是反复换优化器推理变慢时你会去分析是数据预处理瓶颈还是模型计算瓶颈而不是直接加机器。ai-engineering-from-scratch这条路没有捷径但每一步都算数。你手搓过的每一个训练循环、调试过的每一个OOM、修复过的每一个NaN都会变成你作为AI工程师的肌肉记忆。框架会更新API会变化但这些底层能力不会过时。最后分享一个我个人的习惯每学一个新模型或新技术我都会先用最小规模的数据手搓一遍前向和反向传播确认自己理解了每个张量的形状和每个操作的数学含义然后再用框架实现。这个习惯让我在后来做模型压缩和推理优化时能快速定位到性能瓶颈在哪一层。如果你也在走这条路不妨试试这个方法。