1. 从零构建AI工程能力:为什么“手搓一遍”比调包更值钱
这两年AI应用开发的门槛肉眼可见地降低了。一个刚入行的开发者,借助现成的框架和API,半天就能搭出一个能跑通的对话机器人。但如果你真的在团队里带过人,或者自己经历过从“能跑”到“能扛住线上流量”的完整过程,就会发现一个很尴尬的事实:大部分所谓的AI工程能力,其实是框架能力,不是你的能力。
我见过太多这样的情况——模型效果不好,第一反应是换个更大的模型;推理速度慢,第一反应是加机器;显存爆了,第一反应是减小batch size然后祈祷。这些操作本身没错,但如果你不知道背后的显存是怎么算出来的、推理延迟到底卡在哪一环、量化到底损失了什么,那你永远只能停留在“调参侠”的层面。
ai-engineering-from-scratch这个方向之所以值得认真对待,是因为它逼着你去面对那些被框架封装掉的细节。从张量的内存布局,到注意力机制的计算复杂度,再到推理引擎的调度策略,这些东西你亲手实现一遍,和只看文档是完全不同的体验。就像学开车,你可以直接上路,但如果你连离合和变速箱的原理都不清楚,遇到陡坡起步就会慌。
这篇文章适合两类人:一类是有一定Python和深度学习基础,但一直停留在“调包”层面,想真正理解AI系统底层运作机制的开发者;另一类是在实际项目中遇到了性能瓶颈,发现光靠调API解决不了问题,需要从工程角度重新审视整个链路的工程师。我会从最基础的环境搭建讲起,一路走到推理优化和部署,把每个环节的“为什么”和“怎么做”都掰开揉碎。
提示:这篇文章不会教你如何调用某个具体框架的API,而是聚焦于那些跨框架通用的底层原理和工程实践。你学到的知识,换一个框架依然能用。
2. 环境搭建:别急着装CUDA,先把这几个概念理清楚
2.1 为什么你的环境总是配不对
新手配深度学习环境,十有八九会卡在版本兼容性上。CUDA版本、cuDNN版本、PyTorch版本、Python版本,这四个东西之间的依赖关系像一张蜘蛛网。我见过有人为了跑一个demo,重装了三次系统。
问题的根源在于,很多人是“照着教程一步步敲命令”,而不是理解每个组件到底在干什么。CUDA是NVIDIA的并行计算平台,它让你的代码能跑在GPU上;cuDNN是专门为深度学习优化的加速库,卷积、池化这些操作都靠它;PyTorch是上层框架,它调用CUDA和cuDNN来完成计算。三者是层层依赖的关系。
我的建议是,先确定PyTorch版本,然后反推CUDA和cuDNN版本。比如你要用PyTorch 2.1,它官方支持CUDA 11.8和12.1,那你就去NVIDIA官网下载对应的CUDA Toolkit,再下载匹配的cuDNN。不要反过来,先装一个最新的CUDA 12.4,然后发现PyTorch还不支持,又折腾着降级。
# 查看当前CUDA版本 nvcc --version # 查看GPU信息 nvidia-smi # 查看PyTorch是否能用GPU python -c "import torch; print(torch.cuda.is_available())"还有一个容易被忽略的点:Python环境隔离。我强烈建议用conda或者venv给每个项目建独立环境。原因很简单,不同项目依赖的库版本可能冲突,全局安装迟早会出问题。conda的好处是它能帮你管理CUDA Toolkit的版本,不需要在系统层面装一堆东西。
2.2 显存到底被谁吃了
环境配好之后,第一个让人困惑的问题往往是:为什么我的模型一跑就OOM(Out of Memory)?明明参数量看起来不大。
这里需要建立一个基本的显存账本。模型训练时的显存占用主要来自四部分:模型参数、梯度、优化器状态、中间激活值。前三个是固定的,中间激活值则和batch size、序列长度直接相关。
举个例子,一个7B参数的模型,用FP16精度存储,光参数就要占14GB。训练时梯度也是FP16,再加14GB。如果用Adam优化器,它需要保存一阶矩和二阶矩,通常是FP32精度,那就是7B × 4字节 × 2 = 56GB。加起来已经84GB了,还没算激活值。所以7B模型全量微调,没有几张A100是搞不定的。
这也是为什么现在流行LoRA、QLoRA这些参数高效微调方法——它们只训练一小部分参数,优化器状态大幅减少,显存需求直接降一个数量级。理解了这个账本,你就知道该往哪个方向优化了。
注意:
torch.cuda.memory_allocated()和torch.cuda.max_memory_allocated()是你排查显存问题的好帮手,前者看当前占用,后者看峰值占用。
2.3 一个最小可用的工程目录结构
很多人写AI项目,所有代码堆在一个文件里,跑通了就不管了。等到要复现或者迁移的时候,自己都看不懂。从工程角度,我建议从一开始就养成好习惯:
project/ ├── configs/ # 配置文件 ├── data/ # 数据相关 │ ├── raw/ │ └── processed/ ├── src/ │ ├── data/ # 数据加载和预处理 │ ├── models/ # 模型定义 │ ├── training/ # 训练循环 │ ├── inference/ # 推理逻辑 │ └── utils/ # 工具函数 ├── experiments/ # 实验记录 ├── notebooks/ # 探索性分析 └── requirements.txt这个结构的好处是职责清晰。模型定义归模型定义,训练逻辑归训练逻辑,推理部署归推理部署。当你需要把训练好的模型部署到生产环境时,只需要拿走models和inference两个目录,不用在一堆训练代码里大海捞针。
3. 张量与自动求导:亲手实现一遍才算真正理解
3.1 张量不只是多维数组
PyTorch的Tensor和NumPy的ndarray看起来很像,但有一个本质区别:Tensor可以跑在GPU上,并且支持自动求导。这两个能力是深度学习框架的基石。
从工程角度看,你需要理解张量的几个关键属性:shape(形状)、dtype(数据类型)、device(设备)、requires_grad(是否需要梯度)。这四个属性决定了这个张量能做什么运算、占用多少显存、在哪个设备上计算。
我建议你亲手写一段代码,创建一个张量,然后做各种操作,观察它的属性变化。比如:
import torch # 创建一个需要梯度的张量 x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) print(x.shape, x.dtype, x.device, x.requires_grad) # 做一次运算 y = x ** 2 z = y.sum() # 反向传播 z.backward() print(x.grad) # 输出 dz/dx = 2x这段代码虽然简单,但它展示了自动求导的核心流程:前向计算构建计算图,反向传播沿计算图求梯度。理解了这个流程,你才能明白为什么有时候需要detach(),为什么有时候需要with torch.no_grad()。
3.2 计算图是怎么构建和释放的
PyTorch采用动态计算图,每次前向传播都会重新构建计算图。这带来了灵活性,但也意味着如果你不小心保留了中间变量,计算图就不会释放,显存会持续增长。
一个典型的坑是在训练循环里把loss累加到列表里:
# 错误做法 losses = [] for batch in dataloader: loss = model(batch) losses.append(loss) # loss还带着计算图,显存不会释放 loss.backward() optimizer.step()正确的做法是只记录数值:
# 正确做法 losses = [] for batch in dataloader: loss = model(batch) losses.append(loss.item()) # 只取数值,断开计算图 loss.backward() optimizer.step()这个细节在训练小模型时可能感觉不到,但训练大模型时,几轮下来显存就爆了。我当初踩这个坑的时候,排查了半天才发现是日志记录的问题。
3.3 从零实现一个线性回归
要真正理解自动求导,最好的方式是手动实现一个简单的线性回归,不用nn.Linear,而是自己定义参数和计算过程。
import torch # 生成数据 X = torch.randn(100, 1) true_w = torch.tensor([[2.0]]) true_b = torch.tensor([1.0]) y = X @ true_w + true_b + torch.randn(100, 1) * 0.1 # 手动初始化参数 w = torch.randn(1, 1, requires_grad=True) b = torch.zeros(1, requires_grad=True) # 训练循环 lr = 0.01 for epoch in range(100): # 前向 y_pred = X @ w + b loss = ((y_pred - y) ** 2).mean() # 反向 loss.backward() # 更新参数(注意要暂停梯度追踪) with torch.no_grad(): w -= lr * w.grad b -= lr * b.grad w.grad.zero_() b.grad.zero_() if epoch % 20 == 0: print(f"Epoch {epoch}, Loss: {loss.item():.4f}")这段代码虽然简单,但它包含了训练的所有核心要素:前向计算、损失计算、反向传播、参数更新、梯度清零。你亲手写一遍,比看十遍文档都管用。
4. 模型训练的核心工程问题:不只是调参
4.1 数据加载为什么成了瓶颈
很多人优化模型性能时只盯着GPU利用率,却忽略了数据加载这个隐形杀手。我见过一个案例,模型本身计算只需要50毫秒,但数据加载花了200毫秒,GPU大部分时间在等数据。
PyTorch的DataLoader有几个关键参数:num_workers、pin_memory、prefetch_factor。num_workers决定了用几个进程加载数据,设置得太小会来不及,设置得太大反而会因为进程切换开销降低效率。经验值是设置为CPU核心数的一半左右,但具体要看数据预处理的复杂度。
pin_memory=True会把数据加载到锁页内存,这样从CPU传到GPU会更快。这个参数在GPU训练时几乎总是应该开启。
还有一个容易被忽略的点:数据预处理的位置。如果你在__getitem__里做复杂的图像增强,那每个worker都在重复计算。更好的做法是离线预处理一次,或者用GPU做增强。
4.2 混合精度训练:省显存还能提速
混合精度训练的核心思想是:前向和反向用FP16计算,参数更新用FP32。这样既能利用FP16的计算速度,又能保持FP32的数值稳定性。
PyTorch提供了torch.cuda.amp来自动管理这个过程:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for batch in dataloader: optimizer.zero_grad() with autocast(): output = model(batch) loss = criterion(output, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是放大loss,防止FP16下梯度下溢。这个机制很巧妙,但如果你不理解它的原理,遇到NaN loss的时候就会一头雾水。
实测下来,混合精度训练通常能省30%-50%的显存,速度提升20%-30%。但要注意,不是所有操作都适合FP16,比如softmax、layer norm这些对数值范围敏感的操作,通常需要保持FP32。
4.3 梯度累积:小显存跑大batch
当你只有一张消费级显卡,但需要大batch size来稳定训练时,梯度累积是一个实用的技巧。它的原理很简单:多次前向反向,累积梯度,然后一次性更新参数。
accumulation_steps = 4 for i, batch in enumerate(dataloader): output = model(batch) loss = criterion(output, target) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意loss要除以累积步数,这样梯度的量级才和真实大batch一致。这个技巧在微调大模型时特别有用,我经常用它在单卡上模拟多卡的大batch效果。
4.4 学习率调度:什么时候该降,降多少
学习率是最重要的超参数,没有之一。但很多人设了一个固定值就不管了。实际上,好的学习率调度能让训练效果提升一个档次。
常见的调度策略有:StepLR(每隔固定步数降一次)、CosineAnnealingLR(余弦退火)、OneCycleLR(先升后降)。我个人的经验是,微调任务用CosineAnnealing比较稳,从零训练用OneCycle收敛更快。
还有一个实用技巧是warmup:在训练初期用很小的学习率,逐渐升到设定值。这是因为模型初始参数是随机的,直接大学习率容易训崩。warmup通常占总步数的5%-10%。
from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR warmup = LinearLR(optimizer, start_factor=0.1, total_iters=100) cosine = CosineAnnealingLR(optimizer, T_max=900) scheduler = SequentialLR(optimizer, [warmup, cosine], milestones=[100])5. 推理优化:从能跑到跑得快
5.1 推理和训练到底有什么不同
训练时我们关心的是梯度能不能算对、loss能不能降下去;推理时我们关心的是延迟、吞吐、显存占用。这两个场景的优化目标完全不同。
推理时不需要计算梯度,所以可以关掉自动求导,用torch.no_grad()或者torch.inference_mode()。后者比前者更彻底,它连版本计数都不记录,速度更快。
另一个关键区别是batch size。训练时batch size影响梯度质量,推理时batch size影响吞吐和延迟。在线服务通常batch size=1,追求低延迟;离线批处理可以大batch,追求高吞吐。这两种场景的优化策略完全不同。
5.2 模型量化:用精度换速度
量化是把模型的权重和激活值从FP32/FP16降到INT8甚至INT4,从而减少显存占用和计算量。常见的量化方法有:
| 量化方式 | 精度 | 显存节省 | 适用场景 |
|---|---|---|---|
| FP16 | 高 | 50% | 通用推理 |
| INT8 | 中 | 75% | 对精度要求不极端的场景 |
| INT4 | 低 | 87.5% | 大模型边缘部署 |
量化的核心挑战是精度损失。训练后量化(PTQ)简单但精度损失大,量化感知训练(QAT)精度好但需要重新训练。实践中,INT8量化通常能保持99%以上的精度,INT4则需要更精细的处理。
我实测过一个7B模型,FP16推理需要14GB显存,INT8量化后只要7GB,INT4只要3.5GB。速度方面,INT8在支持INT8指令的GPU上能快1.5-2倍。
5.3 KV Cache:自回归生成的加速利器
自回归生成时,每生成一个token都要重新计算之前所有token的注意力。这导致计算量随序列长度平方增长。KV Cache的思路是:把之前计算过的Key和Value缓存起来,生成新token时只计算当前token的Query,然后和缓存的KV做注意力。
这个优化能把生成复杂度从O(n²)降到O(n)。但KV Cache也占显存,序列越长占用越大。对于长文本生成,KV Cache可能比模型本身还占显存。
# 简化的KV Cache逻辑 class AttentionWithKVCache: def __init__(self): self.cache_k = None self.cache_v = None def forward(self, x, use_cache=True): q, k, v = self.compute_qkv(x) if use_cache and self.cache_k is not None: k = torch.cat([self.cache_k, k], dim=1) v = torch.cat([self.cache_v, v], dim=1) if use_cache: self.cache_k = k self.cache_v = v return self.attention(q, k, v)5.4 批处理与动态填充
在线服务中,请求是陆续到达的。如果每个请求单独处理,GPU利用率很低。批处理是把多个请求攒在一起同时推理,提高吞吐。
但不同请求的输入长度不同,直接padding到最大长度会浪费计算。动态填充(dynamic padding)是按batch内最长序列填充,而不是全局最大长度。更进一步的优化是连续批处理(continuous batching),它允许新请求随时加入正在处理的batch,不用等当前batch全部完成。
这些优化在vLLM、TensorRT-LLM这些推理引擎里都有实现。理解它们的原理,你才能根据实际场景选择合适的方案。
6. 部署与监控:模型上线只是开始
6.1 从checkpoint到服务的完整链路
训练好的模型要变成可用的服务,中间还有不少工作。首先是模型导出,把PyTorch的checkpoint转成推理引擎能识别的格式,比如ONNX、TensorRT。然后是服务封装,用FastAPI或者gRPC暴露接口。最后是容器化,用Docker打包环境,Kubernetes做编排。
这个链路里每一步都有坑。比如ONNX导出时,动态轴设置不对会导致变长输入失败;TensorRT构建引擎时,优化级别选太高可能导致精度下降;Docker镜像里CUDA版本和宿主机不匹配会直接跑不起来。
我的建议是,先在本地把整个链路跑通,再上生产环境。本地跑通意味着你能控制所有变量,出了问题好排查。直接上生产环境,出了问题你连日志都看不全。
6.2 监控什么指标,怎么告警
模型服务上线后,你需要监控三类指标:系统指标(GPU利用率、显存占用、CPU负载)、服务指标(QPS、延迟P50/P99、错误率)、业务指标(生成质量、用户反馈)。
系统指标帮你判断资源是否够用,服务指标帮你发现性能瓶颈,业务指标帮你评估模型效果。这三类指标缺一不可。
告警策略上,我建议对P99延迟和错误率设硬阈值,超过就告警。GPU利用率可以设软阈值,持续高于90%就考虑扩容。业务指标通常需要人工审核,不适合自动告警。
提示:延迟指标一定要看P99而不是平均值。平均值会掩盖长尾请求,而用户体验往往由最慢的那1%决定。
6.3 版本管理与回滚
模型更新比代码更新更复杂,因为模型文件大、加载慢、效果评估周期长。我见过团队直接覆盖线上模型文件,结果新模型效果不好,想回滚发现旧文件已经被删了。
正确的做法是:每次模型更新都保留完整版本,用版本号区分。上线新版本时,先小流量灰度,观察指标正常后再全量。如果出问题,一键切回旧版本。
模型版本管理可以用MLflow、DVC这些工具,也可以简单地用文件系统加命名规范。关键是要有记录:这个版本是什么时候训练的、用了什么数据、评估指标是多少、谁批准的。
7. 那些只有踩过才知道的坑
7.1 随机种子不是万能的
设置随机种子能让实验可复现,但很多人不知道的是,即使设置了种子,不同GPU型号、不同CUDA版本、不同框架版本的结果也可能不同。这是因为某些CUDA操作的实现细节会随版本变化,导致浮点运算顺序不同,最终结果有微小差异。
所以,如果你要严格复现一个实验,除了设置种子,还要固定硬件和软件环境。如果只是对比不同模型结构,那点微小差异通常不影响结论。
import torch import numpy as np import random def set_seed(seed=42): 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注意deterministic=True会降低性能,因为cuDNN不能用一些非确定性的快速算法。所以只在需要严格复现时开启,日常训练可以关掉。
7.2 数据泄漏比你想的更常见
数据泄漏是指训练数据里包含了测试数据的信息,导致评估指标虚高。常见的泄漏场景包括:预处理时用了全量数据的统计量、时间序列数据随机划分、同一用户的数据同时出现在训练集和测试集。
我见过一个案例,团队做文本分类,预处理时用全量数据计算了词表的IDF权重,然后划分训练测试集。结果测试集的指标比实际高了10个点,上线后效果大打折扣。
避免数据泄漏的原则是:任何用到数据的操作,都要在训练集上拟合,然后应用到测试集。标准化、归一化、词表构建、特征选择,统统如此。
7.3 显存碎片化:为什么重启能解决问题
有时候你会遇到这种情况:明明显存还有剩余,但就是分配不出来,报OOM。这通常是显存碎片化导致的。
PyTorch的显存分配器会缓存已释放的显存块,以便快速重用。但如果请求的显存块大小和缓存的不匹配,就会产生碎片。长时间运行的服务容易出现这个问题。
解决方案有几个:设置PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True让分配器更灵活;定期重启服务;或者用torch.cuda.empty_cache()手动清理缓存(但注意这会影响性能)。
7.4 模型保存的坑:state_dict还是整个模型
PyTorch保存模型有两种方式:保存整个模型对象,或者只保存state_dict。我强烈建议只保存state_dict。
原因是,保存整个模型会把模型类的定义也序列化进去。如果之后你修改了模型类的代码,加载旧模型就会失败。而state_dict只包含参数张量,和模型类解耦,只要模型结构不变,代码怎么改都能加载。
# 推荐:只保存state_dict torch.save(model.state_dict(), "model.pt") # 加载时先实例化模型,再加载参数 model = MyModel() model.load_state_dict(torch.load("model.pt"))如果需要保存优化器状态、epoch数这些训练信息,可以一起打包成一个字典:
checkpoint = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "epoch": epoch, "loss": best_loss, } torch.save(checkpoint, "checkpoint.pt")8. 从项目到能力:怎么把学到的东西变成自己的
8.1 建立自己的代码模板库
每次做新项目都从零开始写训练循环、数据加载、日志记录,效率太低了。我的做法是维护一套自己的代码模板,新项目直接复制粘贴,然后改改就能用。
模板里应该包含:标准的训练循环、常用的学习率调度、混合精度训练、梯度累积、日志记录、模型保存和加载、指标计算。这些东西在每个项目里都差不多,没必要重复造轮子。
但要注意,模板是起点不是终点。每个项目都有特殊需求,该改的地方还是要改。模板的价值在于让你跳过那些重复劳动,把精力放在真正有挑战的地方。
8.2 读源码:从使用者到贡献者
用框架遇到问题时,很多人第一反应是搜教程、问别人。但最有效的方式其实是直接读源码。PyTorch的源码写得相当清晰,torch/nn/modules/transformer.py、torch/optim/adam.py这些文件,读一遍你对Transformer和Adam的理解会上一个台阶。
读源码的另一个好处是,你能看到框架作者是怎么处理边界情况的。比如Adam里的偏差校正、LayerNorm里的数值稳定性处理,这些细节在论文里可能一笔带过,但在工程实现里至关重要。
8.3 参与开源:从issue到PR
如果你想把AI工程能力提升到更高层次,参与开源项目是一个很好的途径。从提issue开始,到修复文档错误,再到提交代码PR,这个过程能让你接触到真实项目的工程标准。
我自己的经验是,第一次提PR的时候被reviewer指出了很多问题:代码风格不一致、缺少测试、边界情况没处理。虽然当时有点受挫,但正是这些反馈让我意识到,工业级代码和实验代码的差距有多大。
8.4 保持对底层的好奇心
AI领域变化很快,新模型、新框架、新工具层出不穷。但底层的东西变化很慢:矩阵乘法、梯度下降、注意力机制,这些核心原理十年没变过。
我的建议是,花70%的时间在底层原理上,30%的时间在最新工具上。底层原理让你有判断力,知道什么工具值得学、什么只是昙花一现。最新工具让你保持手感,不至于和实际脱节。
回到ai-engineering-from-scratch这个主题,它的价值不在于让你重新发明轮子,而在于让你理解轮子是怎么转的。当你理解了这些,再用框架的时候,你就不是盲目地调API,而是知道每一步在做什么、为什么这么做、出了问题该往哪个方向排查。这种能力,才是真正属于你自己的。