窗外羽毛球场的灯都熄了,我盯着屏幕上那条loss: 0.0000的日志愣住了。这不是我第一次训练模型,却是我第一次没用一个完整的深度学习框架——每一个算子、每一次梯度更新、每一条数据管线都是自己手写出来的。那一刻我才敢说,我算是入了 AI Engineering 的门。很多人听到"AI Engineering from scratch",第一反应是"从零手搓一个基础模型",但真正做过的人会告诉你,工程的核心在于理解每一步为什么存在。这篇博文会完整记录我从零开始搭建一条 AI 工程链路的全过程:先从数学和 NumPy 手写前向反向传播,再迁移到 PyTorch 张量层,接着搭数据管线、上分布式训练,最后量化和部署成可用服务。整个过程踩过的坑、做过的取舍,我都会写清楚。
如果你正在学 AI,但只会用现成框架调参,或者你是个后端工程师,想踏实进入 AI 领域,这篇文章就是为你准备的。它不会教你炼丹玄学,而是告诉你一个模型从念头到上线的每一步,到底需要什么。
1. 为什么说"从零"不是重复造轮子——这个项目的边界划定
准备动手之前,我先把"from scratch"的定义搞清楚,否则很容易陷入两种极端:一种是什么都从二进制开始写,另一种是嘴上说从零,实际model = GPT()一把梭。
1.1 我理解的"从零"边界
我给自己定的标准是:不依赖任何封装好的训练/推理框架,但允许使用基础科学计算库和操作系统资源。换句话说,NumPy 可以,PyTorch 的nn.Module不允许;自己的反向传播必须有,autograd不能用。推理部署阶段,我允许使用标准编译器和推理引擎,但模型结构、权重加载、后处理逻辑必须是我自己写的。
这样划定边界,是因为真正的目的是理解机制,而不是表演行为艺术。就像学做饭,你不需要从种水稻开始,但你必须亲手切菜、掌握火候,而不是买了料理包加热一下就说自己会做饭。
1.2 项目想解决的三个核心问题
这个项目在动手前,我明确要回答三个问题:
- 模型内部到底发生了什么?从输入张量到损失函数,中间的数据形状变化和数值变化,必须能自己画出来。
- 训练一套可用模型需要哪些工程环节?数据、模型、训练、评估、部署、监控,一个都不能少。
- 工程化中的性能瓶颈在哪里?单卡到多卡、FP32 到 INT8、Python 到编译优化,每个环节的提升到底有多明显。
1.3 项目路线图
| 阶段 | 目标 | 产出物 |
|---|---|---|
| 第一阶段 | 用 NumPy 手写两层 MLP 完成真实分类任务 | 训练脚本 + 梯度推导文档 |
| 第二阶段 | 移植到 PyTorch 张量操作,搭建数据管线 | DataLoader + 训练循环 |
| 第三阶段 | 用分布式训练组件扩展到单机多卡 | DDP 训练脚本 + 扩展报告 |
| 第四阶段 | 模型导出、量化、服务化部署 | ONNX 推理服务 + 压测数据 |
这四个阶段踩完,我才敢说自己对 AI 工程有了系统性的认知,比之前东一榔头西一棒子学三个月有效得多。
2. 从 0 到 1:第一个不依赖框架的训练脚本——用 NumPy 手写 MLP
所有深度学习初学者都该走一遍这条路,因为当你亲手实现反向传播后,所有框架的backward()在你眼里就不再有黑魔法了。
2.1 为什么选择 MLP 而非 Transformer 起步
很多网上的教程一上来就教你从零实现一个简化版 Transformer,说实话,这是劝退式教学。我选择从 MLP 切入,原因很务实:
- MLP 的反向传播链条最短,参数更新逻辑一目了然。
- 数据集用二分类的合成数据即可,不需要下载和预处理复杂数据。
- 我能把梯度推导完整写在纸上,再翻译成代码,这个过程中任何模糊的地方都会暴露出来。
我的目标不是做一个大模型,而是做一个我能完全控制、每一个数字都知道从哪来的小模型。
2.2 数学原理先行:前向、损失、反向公式
一个两层 MLP(输入层-隐藏层-输出层)的前向传播可以表达为:
[ h = XW_1 + b_1, \quad a = \text{ReLU}(h), \quad z = aW_2 + b_2, \quad \hat{y} = \sigma(z) ]
这里我用 Sigmoid 做二分类输出,损失函数用交叉熵的简化形式。反向传播的关键是链式法则,我花了很久才彻底理解了中间层的梯度为什么等于\u201c误差对输出的导数 × 权重转置 × 激活函数的导数\u201d。
简化后,核心公式就四条:
dz = y_hat - y # 输出层梯度,二分类简化结果 dW2 = a.T @ dz db2 = dz.sum(axis=0) da = dz @ W2.T dh = da * (h > 0) # ReLU 的导数 dW1 = X.T @ dh db1 = dh.sum(axis=0)2.3 具体代码实现及运行效果
我直接给出简化但完整可跑的版本,如果你要复现,建议先把数据和参数调对,再逐步加入正则化和复杂优化器。
import numpy as np def relu(x): return np.maximum(0, x) # 生成二分类数据 np.random.seed(2025) X = np.random.randn(1000, 20) true_w = np.random.randn(20, 1) y = (X @ true_w + np.random.randn(1000, 1) * 0.1 > 0).astype(float) # 初始化 input_dim, hidden_dim = 20, 32 W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) b1 = np.zeros((1, hidden_dim)) W2 = np.random.randn(hidden_dim, 1) * np.sqrt(2.0 / hidden_dim) b2 = np.zeros((1, 1)) lr, epochs = 0.1, 30000 for i in range(epochs): h = X @ W1 + b1 a = relu(h) z = a @ W2 + b2 y_hat = 1 / (1 + np.exp(-z)) # sigmoid loss = -(y * np.log(y_hat + 1e-8) + (1 - y) * np.log(1 - y_hat + 1e-8)).mean() dz = (y_hat - y) / y.shape[0] dW2 = a.T @ dz db2 = dz.sum(axis=0, keepdims=True) da = dz @ W2.T dh = da * (h > 0) dW1 = X.T @ dh db1 = dh.sum(axis=0, keepdims=True) # 原地更新 W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 if i % 5000 == 0: acc = ((y_hat > 0.5).astype(float) == y).mean() print(f"step {i}, loss {loss:.4f}, acc {acc:.4f}")训练结果在意料之中,最后 loss 降到 0.1 以下,准确率超过 97%。但比结果更关键的体验是:当你能手动分解出每一步的张量形状,你才算真正理解了反向传播。
2.4 从 NumPy 到 PyTorch 的写法和踩坑
把这段逻辑搬到 PyTorch 很简单,只需把np.random.randn换成torch.randn、@换成torch.mm即可。但踩了个重要坑:默认情况下 PyTorch 的torch.Tensor运算会自动创建计算图,所以我在迁移时不是直接禁用autograd,而是用torch.no_grad()包住前向传播,甚至干脆用torch.tensor(..., requires_grad=False)来保证所有操作都是纯张量数值计算。
这个阶段让我明白了一件事:框架本身不神奇,它的价值在于自动微分带来的工程效率,但如果你不懂底层的梯度推导,走上分布式和分布式后训练优化时就很难排查问题。后面遇到 PyTorch 的 DDP 梯度同步问题,我回忆起这段手工推导,一下子就理解了allreduce到底在同步什么东西。
3. 数据管线和训练稳定性——模型学得好不好,一半是数据工程的功劳
模型写完、能跑通之后,我一度以为自己已经叫 AI 工程师了。真正做下去才明白,模型代码是整条链路里最简单的一环,大部分时间都耗在数据管线和训练稳定性调试上。
3.1 一个合格的 DataLoader 应该具备什么
我把数据集从 NumPy 换成了一个真实的文本分类任务(新闻标题分类),这时问题来了:数据不是已经躺在内存里的ndarray了,而是散落在磁盘上的几万个 JSON 文件。为了不把内存爆掉,也为了让训练时长可控,我决定手写一个基础的数据加载器,它必须具备:
- 流式读取:每次只加载当前 batch 的数据,而不是全部读入内存;
- shuffle 功能:每个 epoch 开始时打乱样本顺序,防止模型学到序列中的假规律;
- 可复现性:shuffle 的随机种子要在每个 epoch 固定并记录,方便精确定位一个训练中断后的复现问题;
- 多进程预读:主进程负责训练,子进程负责读取和预处理,把 I/O 时间隐藏起来。
我用torch.utils.data.Dataset搭了骨架,可以多进程预取。这里最容易被忽略的就是:如果多进程用默认 fork 方式,刚开始会报错,因为和主进程的 CUDA 上下文冲突了。解决方法是设置multiprocessing_context="spawn"或者把数据加载器的num_workers设为 0,我最后使用 spawn 方式解决了。
3.2 Tokenizer 的标准化问题:比想象中耗时间
做文本任务,tokenizer 是一个绕不开的坑。我一开始用的正则切词非常粗放,结果训练集准确率能到 98%,验证集只有 70%——典型的数据泄漏问题。原因是我用的简易切词器把英文字母全部转小写时,把用户 ID 和普通单词混在了一起,模型记住了 ID 的区分模式。
后来我改用 HuggingFace 提供的字符级 BPE tokenizer,并专门花半天检查字典大小和 sentence-piece 参数。这里我想提醒所有做自然语言处理的新手:不要自己发明 tokenizer,除非你知道为什么要做这一步。一个不标准的词表会给后面的模型部署、在线推理带来巨大的对齐成本。
3.3 训练学不稳怎么办:梯度裁剪和学习率调度
当我把模型从 MLP 扩大到连接层和注意力机制的简化版后,训练 loss 曲线出现了明显的锯齿状震荡。排除了数据问题、排除了初始化问题后,锁定在两个原因:
- 序列长度较长时,梯度范数会自然增长,我的模型梯度出现爆长;
- 固定学习率在后期过大,导致参数在最优点附近来回震荡。
我的优化方案组合拳如下:
- 对梯度做全局范数裁剪,阈值设为 1.0,训练稳定性明显提升;
- 使用带 warmup 的线性学习率调度:前 10% 的 step 从 0 线性升到峰值,之后按余弦退火降到 0;
- 把 batch size 从 32 调到 64,因为更大的 batch 意味着梯度噪声更小。
这套组合下来,验证 loss 从震荡状态变为平滑下降。我常用的梯度范数监控写法很简单:在每个 step 结束后取torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0),同时打印梯度范数值,它能帮你快速判断模型是否处于健康训练状态。
3.4 监控指标设计:别只看准确率
我在训练循环里加了很多监控项:loss、准确率、梯度范数、学习率、显存占用。还有一个特别重要的:在每个 epoch 结束后,用验证集计算一次 F1-score 和混淆矩阵。因为文本分类任务类别不均衡,准确率会骗人,F1-score 才能真正反映模型的实际表现。
这个阶段最大的体会是:数据工程不是"准备好数据丢给模型"那么简单,它是一个持续迭代的工程系统,占了整个 AI 项目大约 60% 的时间投入。
4. 从单卡到多卡:分布式训练不能只靠调参,还得懂一点点系统原理
模型训练稳定之后,我面对的下一个现实问题是:模型规模扩大后,单张 GPU 显存装不下了,训练一次要好几天。这时候,分布式训练就变成了必须掌握的本领。
4.1 集群与框架选型:我用到的方案
我选了 PyTorch 的 Distributed Data Parallel(DDP),原因很直接:
- 原生、社区活跃、debug 查资料方便;
- 它基于 NCCL,在多卡环境下的 allreduce 效率比第三方框架高;
- 只需要改动少量代码,就能从单卡切到多卡。
4.2 DDP 的启动方式和关键代码
DDP 的启动方式并不复杂。假设我有 2 台机器各 4 张卡,需要先指定 master 地址和端口,然后在每台机器上跑:
# 机器1(master) python train.py --rank 0 --world_size 8 --master_addr 192.168.1.10 --master_port 29500 # 机器2(worker) python train.py --rank 1 --world_size 8 --master_addr 192.168.1.10 --master_port 29500对应的初始化代码如下:
import torch.distributed as dist dist.init_process_group( backend="nccl", init_method="tcp://192.168.1.10:29500", rank=args.rank, world_size=args.world_size, ) torch.cuda.set_device(args.local_rank) model = MyModel().cuda() model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[args.local_rank])之后,前向传播和反向传播的调用方式和单卡差不多,但 DDP 会在反向传播后自动做梯度 allreduce,将不同 GPU 上的梯度取平均。
4.3 多卡训练后必须处理的坑
这里我踩了三个大坑,必须详细说:
batch size 变化带来的收敛性差异。单卡 batch size 是 64,8 卡后总 batch size 变成 512,学习率没有相应调整,导致 loss 曲线起飞。经验法则是:学习率随 batch size 线性放大(线性缩放规则),但要注意梯度裁剪的阈值也要同步调整。
数据加载器的种子问题。每个进程如果都用同一个随机种子,会导致每个卡上的验证 shuffle 完全一致,测试结果有偏差。正确做法是让每个进程的种子错开,例如
random.seed(42 + rank)。NCCL 超时。在通信较慢的几台机器上,
torch.distributed默认的 timeout 只有 30 分钟,大模型训练可能因为网络抖动导致训练挂起。把timeout=timedelta(minutes=60)加到init_process_group里,解决了大部分问题。
4.4 实测扩展效率:期望与现实的差距
我在 4 张 V100 上做了一个简单基准测试。理论上 4 卡可以获得 4 倍加速,但由于通信开销、负载不均匀和数据加载遇到瓶颈,实际加速比只有 3.1 倍。扩展效率 77.5%,在我这个数据规模和模型结构下已经算可以接受了。如果模型再大一点、通信时间占比更高,加速比会更低,这时候就需要评估梯度压缩或流水线并行等进阶方案了。
这个阶段让我意识到:分布式训练的性能问题,往往不是"能不能跑"的问题,而是"跑到什么程度才合理"的问题。懂得理论峰值和现实差距的原因分析,才是工程能力的分水岭。
5. 训练完不叫结束:推理优化与部署的最后一公里
模型训练好、验证指标合格,仅仅完成了 60% 的工作。真正交给业务方,还要面对推理延迟、显存限制、服务稳定性这些工程问题。
5.1 模型导出:从 PyTorch 权重到标准格式
提交到线上我选了 ONNX 作为中间格式,因为它能统一推理栈,规避 PyTorch 运行时的依赖开销。导出时最容易踩的坑是动态轴未声明,导致导出的模型只能接受固定长度输入。
下面是标准的导出代码,我特意加了动态轴配置:
import torch model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size"}}, opset_version=13, )导出后用onnxruntime加载,验证输出和 PyTorch 输出差异小于1e-5。
5.2 量化:从 FP32 到 FP16 相对容易,真正的大头是 INT8
我把线上推理精度的主要优化锁定在两个方向——FP16 和 INT8。
FP16 量化几乎无痛。如果 GPU 架构支持 Tensor Core,把输入张量和权重转为 FP16,推理速度能直接提升 1.5-2 倍。我的模型里有一些对精度比较敏感的小数值操作,所以 FP16 后验证了精度,下降幅度在可接受范围内。
INT8 量化才是真正的难点。它不是把张量简单缩短成 8 位整数那么简单,而是要标定、选取合适的量化参数,否则某些层会掉点严重。我用了逐通道(per-channel)量化,避免激活值分布极端导致的精度崩塌。
量化完成后,我把模型放到了 ONNX Runtime 的 CUDA execution provider 上跑,实测对比:FP32 平均延迟 7.9ms,FP16 平均延迟 4.2ms,INT8 平均延迟 3.5ms。收益非常直观,但 INT8 需要重新校准时间,所以业务场景才更适合选择它。
5.3 服务化部署下的显存泄漏排查
部署时遇到最磨人的问题:用 ONNX Runtime 跑一个 GPU 推理服务,QPS 稳定后显存开始以肉眼可见的速度增长。几天后容器被 OOM 杀掉,业务直接报警。
排查过程是这样的:
- 先怀疑是数据并发问题,把进程数从 4 降到 1,问题依旧;
- 用
nvidia-smi --query-gpu=memory.used --format=csv每 5 秒采样一次,确认显存在每处理完 1000 个请求后增加约 50MB; - 反复阅读 ONNX Runtime 文档,发现是 Python 绑定中的 OrtValue 被反复分配但释放不及时,于是我改用
Session.run_on_device接口,间接让显存分配器回收。
更换接口后,显存稳定在初始值附近,问题解决。这类问题在单次推理 demo 时根本不会暴露,只有真正压测时才会冒出来。
这里我想给一个实际建议:任何 GPU 服务上线前,必须做至少一小时的持续压测,并监控显存趋势,而不是只测单请求延迟。
5.4 推理服务架构的鲁棒性设计
一个完备的推理服务不只是把模型加载起来等请求,还需要考虑:输入尺寸校验、超时控制、故障降级、模型热更新。我的最终服务结构是这样:
- 网关层:负责鉴权、限流、超时管理;
- 推理层:无状态的多 worker 并行执行 ONNX 模型;
- 模型仓库:保存多个版本模型,支持按比例灰度切换新模型;
- 监控:Prometheus + 自研指标中间件,采集延迟分位数和显存使用率。
这种分层结构让模型迭代和业务解耦,新模型上线只需要改配置,不需要改动服务代码。
6. 那些看起来简单、实际要命的坑——真实的排错链路记录
最后这一章,我把整个过程中最有代表性的三个排查过程完整记录下来。它们的共同点是:表面症状五花八门,根因往往藏在系统的交叉处。
6.1 案例一:数据集切分的随机种子未固定,导致训验证指标来回波动
有一周我连续跑了好几次实验,验证集准确率在 83% 到 91% 之间波动,训练曲线却非常稳定。刚开始怀疑是模型初始化的问题,后来把所有超参数和固定随机种子都调了一遍,但还是没解决。
最后我去检查数据集切分的代码,发现train_test_split没有传random_state,每次运行切分结果都不同,导致验证集和训练集分布不稳定。修复后,实验指标立刻变得可复现。这个教训让我养成一个习惯:所有和数据相关的随机操作,必须显式固定种子并记录到实验日志里。
6.2 案例二:多卡训练时验证集混淆矩阵为空,是 rank 通信问题
第一次跑多卡时,我满心期待地打印每个 epoch 的测试集混淆矩阵,但惊讶地发现其中两张卡输出的全是 NaN。看起来像是数据和标签没有对齐,但单卡时完全正常。
排查后发现,我在验证阶段用了print(confusion_matrix(y_true, y_pred)),但y_true是从验证集里按 rank 分片得到的。在多卡环境下,每个 rank 拿到的数据分片不同,而我没有把各 rank 的预测结果同步到 rank 0 再做合并。结果在所有卡上分别打印混淆矩阵,自然对不上。
解法是在验证阶段先用all_gather把所有预测结果收集到主卡,再统一计算指标。修改后指标完全正常。
6.3 案例三:显存 OOM 不发生在训练中,而发生在评估阶段
训练 process 跑到第 7 个 epoch 时,显存 OOM,但前 6 个 epoch 都好好的。我一度怀疑是数据泄漏导致激活值异常增长,花了一晚上查代码。
最后用torch.cuda.max_memory_allocated()分段打点,发现 OOM 总是发生在验证集推理阶段,原因是验证集序列过长、没有做 batch 截断,导致中间激活值远超训练 batch。加了一个简单的验证集 max length 截断,显存降为原来的 40%。这提醒我:评估阶段的显存占用不一定比训练小,尤其是变长输入场景,不做限制早晚出问题。
6.4 哪些场景真的需要从零写,哪些应该直接用现成方案
经历了这么多,我对"从零"这件事有了更成熟的看法:
值得从零写的地方:
- 核心数学模型(至少实现一次);
- 数据管线中贴合业务的自定义逻辑;
- 推理服务的调度和监控,因为这是业务的差异化所在。
直接用现成的更合适:
- 分布式框架的通信原语;
- 成熟的 tokenizer 库;
- 标准优化器和学习率调度器。
工程师的时间是稀缺资源,我们的目标不是把每个轮子都重新发明一遍,而是知道轮子内部如何运作,在出现问题时能快速判断是轮子本身坏了,还是路面太差。这次 from scratch 的项目,最大的收获正是这层判断力。
如果你看了这篇文章也打算动手试一试,我的建议是:先不要追求大模型和大数据,用几百条数据、一个小模型、一段能跑通的前向反向传播代码,走完整个 AI 工程的全链路。等把这趟流程走顺了,你会拥有比多数只会用框架的人更扎实的底层直觉。这种直觉,在真正遇到性能瓶颈和线上事故时,就是帮你快速定位问题的那根救命稻草。