1. 这个项目到底在解决什么问题
第一次看到 "ai-engineering-from-scratch" 这个标题,我脑子里蹦出来的第一个念头是:又是一个教人调包的教程?但仔细琢磨了一下 "from scratch" 这几个字,我意识到它想做的事情可能完全不一样。市面上讲 AI 工程的内容,绝大多数都是从pip install开始,然后调几个 API,跑通一个 demo 就结束了。但真正在生产环境里摸爬滚打过的人都知道,从"能跑"到"能用"之间,隔着一整个太平洋。
这个项目的核心价值,我理解下来,是试图把 AI 工程这件事从最底层往上拆解——不是教你怎么用现成的框架,而是让你理解每一层抽象背后到底发生了什么。它面向的不是只想快速出活的人,而是那些希望真正掌握 AI 系统构建能力、能够在遇到问题时自己排查和优化的人。说白了,它想培养的是"能造轮子"的人,而不是"只会用轮子"的人。
我之所以对这个方向特别有感触,是因为在过去几年里,我见过太多团队在 AI 项目上踩坑。模型在 notebook 里跑得好好的,一上生产就各种问题:推理延迟飙升、显存溢出、批处理效率低下、数据管道堵塞。这些问题的根源,往往不是模型本身不够好,而是工程层面的基础设施没有打好。而 "from scratch" 这个思路,恰恰是在帮你补齐这块短板。
这篇文章我会从项目设计的底层逻辑出发,把 AI 工程从零构建的完整路径拆开来讲,包括核心模块的设计思路、关键环节的实操要点、我自己的踩坑经验,以及一些可以直接拿来用的方案。不管你是刚入行的新手,还是已经有一定经验但想系统补课的从业者,应该都能从中找到对自己有用的东西。
2. 从零构建 AI 工程的整体设计思路
2.1 为什么"从零"比"调包"更重要
很多人可能会问:现在框架这么成熟,PyTorch、TensorFlow、JAX 都帮你把底层封装好了,为什么还要从零开始?这不是重复造轮子吗?
我的回答是:造轮子的目的不是为了用这个轮子,而是为了理解轮子为什么是圆的。
举个例子。你在用 PyTorch 训练模型的时候,loss.backward()一行代码就搞定了反向传播。但如果你从来没有手动实现过一次反向传播,你就不会理解计算图是怎么构建的、梯度是怎么流动的、为什么有些操作会导致梯度消失。当你的模型训练不收敛的时候,你只能盲目地调学习率、换优化器,而不知道问题的根源在哪里。
从零构建的意义在于,它让你拥有"向下看"的能力。当抽象层出问题的时候,你能穿透下去,看到底层的运作机制。这种能力在排查生产环境的疑难杂症时,价值是不可估量的。
2.2 分层架构:从硬件到应用
我在设计自己的 AI 工程学习路径时,习惯把它分成几个层次来理解。这个分层思路和 "from scratch" 的理念高度吻合:
| 层级 | 核心内容 | 关键能力 |
|---|---|---|
| 硬件层 | GPU/CPU 架构、内存层次、并行计算 | 理解算力瓶颈在哪里 |
| 算子层 | 矩阵运算、卷积、激活函数的手动实现 | 掌握计算的基本单元 |
| 框架层 | 自动微分、计算图、内存管理 | 理解框架帮你做了什么 |
| 模型层 | 网络结构设计、训练策略、评估方法 | 能独立设计和调优模型 |
| 系统层 | 数据处理管道、服务部署、监控运维 | 让模型真正产生价值 |
这个分层不是割裂的,而是层层递进的。你在硬件层理解的东西,会直接影响你在系统层的决策。比如你知道 GPU 的显存带宽是瓶颈,在设计数据管道的时候就会特别注意数据传输的效率。
2.3 技术选型背后的考量
"From scratch" 并不意味着什么都要用最原始的方式来实现。关键在于选择哪些东西自己造、哪些东西可以用现成的。我的原则是:核心原理相关的部分自己实现,工程效率相关的部分善用工具。
具体来说:
- 自己实现的部分:反向传播、梯度下降、注意力机制、卷积运算、损失函数。这些是理解 AI 系统运作的核心,必须亲手写过一遍。
- 可以用现成工具的部分:数据加载(用 PyTorch 的 DataLoader)、分布式通信(用 NCCL)、模型部署(用 ONNX Runtime 或 TensorRT)。这些是工程基础设施,没必要重复造。
这个原则帮我节省了大量时间,同时保证了核心能力的建立。我见过一些人走极端,什么都要自己写,结果花了三个月写了一个性能只有 PyTorch 十分之一的框架,这就本末倒置了。
提示:判断一个模块是否值得自己实现,标准是"理解它是否能帮你更好地理解上层系统"。如果答案是肯定的,就自己写;如果只是为了替代现成工具,那就没必要。
3. 核心模块的拆解与实操要点
3.1 张量运算:一切的基础
AI 工程的地基是张量运算。不管你后面做的是 CV 还是 NLP,底层都是矩阵乘法、加法、激活函数这些东西。从零实现一个简易的张量库,是我建议每个人做的第一个练习。
为什么?因为当你自己实现过张量的广播机制、内存布局、视图操作之后,你就能理解为什么有些操作快、有些操作慢,为什么contiguous()有时候是必要的,为什么转置操作可能带来性能开销。
我自己实现的时候,核心是围绕一个Tensor类展开的。这个类需要包含几个关键部分:
- 数据存储:底层用一个扁平化的数组来存数据,而不是嵌套列表。这是性能的关键。
- 形状信息:用一个元组来记录张量的形状,支持任意维度。
- 步长(stride):这是很多人忽略的部分,但它决定了你如何在不复制数据的情况下实现转置、切片等操作。
- 梯度存储:为自动微分预留的字段。
步长这个概念值得多说两句。假设你有一个 2x3 的矩阵,按行存储是[1,2,3,4,5,6]。如果你要取第一列[1,4],有两种方式:一种是复制出来形成新的连续内存,另一种是通过步长来"虚拟"地表示。后者不复制数据,但访问时需要通过步长计算偏移量。理解这个机制,你就能明白为什么 PyTorch 里view和reshape有区别,为什么有些操作需要先contiguous()。
3.2 自动微分:框架的魔法核心
自动微分是深度学习框架最核心的魔法。很多人用了好几年 PyTorch,但对backward()到底做了什么其实一知半解。从零实现一个自动微分引擎,会让你对整个训练过程的理解上升一个层次。
实现自动微分的核心思路是构建计算图。每次你对张量做运算,就相当于在图中添加一个节点。每个节点记录了它的输入、输出以及局部的梯度计算方式。当你调用反向传播时,就从输出节点开始,沿着图反向传播梯度。
我实现的时候采用的是"定义即运行"(define-by-run)的方式,也就是 PyTorch 的做法。每次前向计算时动态构建计算图,而不是像早期的 TensorFlow 那样先定义静态图再运行。动态图的好处是调试方便,你可以像写普通 Python 代码一样写模型,出错了直接 print 就能看到中间结果。
关键实现细节:
class Tensor: def __init__(self, data, requires_grad=False): self.data = data self.requires_grad = requires_grad self.grad = None self._backward = lambda: None self._prev = set() def __add__(self, other): out = Tensor(self.data + other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = out.grad if self.grad is None else self.grad + out.grad if other.requires_grad: other.grad = out.grad if other.grad is None else other.grad + out.grad out._backward = _backward out._prev = {self, other} return out这段代码看起来简单,但它揭示了自动微分的本质:每个操作都定义了前向计算和反向传播两个部分。加法操作的反向传播就是把梯度原样传递给两个输入。乘法操作则是把梯度乘以另一个输入的值。
注意:实现自动微分时,梯度的累加非常重要。同一个张量可能在计算图中被多次使用,每次使用都会贡献一部分梯度,必须累加而不是覆盖。这是新手最容易犯的错误之一。
3.3 神经网络层:从原子到分子
有了张量和自动微分,就可以搭建神经网络层了。全连接层、卷积层、注意力层,本质上都是张量运算的组合。
全连接层最简单:y = xW + b。但实现的时候要注意初始化策略。全连接层的权重初始化不能全用零,否则所有神经元的输出都一样,反向传播时梯度也一样,网络永远学不到东西。常用的初始化方法有 Xavier 初始化和 Kaiming 初始化,选择哪种取决于激活函数的类型。
卷积层的实现更有意思。最直观的实现方式是嵌套循环,但那样性能极差。实际中会用 im2col 技术把卷积转换成矩阵乘法,或者用 FFT 来加速。我自己实现的时候先用循环写了一个正确版本,然后再优化成 im2col 版本,对比两者的性能差异,对卷积的理解会深刻很多。
注意力机制是 Transformer 的核心。它的本质是:根据 Query 和 Key 的相似度来计算权重,然后对 Value 进行加权求和。实现的时候要注意 mask 的处理,以及 softmax 的数值稳定性。softmax 在计算时如果直接取指数,数值可能会溢出,标准做法是先减去最大值再取指数。
3.4 训练循环:把所有东西串起来
训练循环是把前面所有模块串起来的地方。一个完整的训练循环包含:
- 前向传播:输入数据通过网络,得到预测结果
- 计算损失:用损失函数衡量预测和真实值的差距
- 反向传播:计算损失对所有参数的梯度
- 参数更新:用优化器根据梯度更新参数
- 梯度清零:为下一轮迭代做准备
这五步看起来简单,但每一步都有坑。比如梯度清零,如果你忘了这一步,梯度会不断累加,训练很快就会发散。再比如学习率的设置,太大容易震荡,太小收敛太慢。
我在实现优化器的时候,从最简单的 SGD 开始,然后逐步实现 Momentum、RMSProp、Adam。每实现一个,就在同一个任务上对比效果。这样你能直观地感受到不同优化器的特点:SGD 简单但收敛慢,Momentum 能加速收敛但可能过冲,Adam 自适应学习率但有时泛化不如 SGD。
4. 完整实操流程与关键环节
4.1 环境搭建与工具链选择
动手之前,先把环境搭好。我的建议是不要一上来就搞复杂的配置,用最简洁的方式开始。
Python 环境用 conda 或者 venv 都行,关键是隔离。依赖方面,初期只需要 NumPy 做数值计算的基础支撑。对,你没看错,从零实现 AI 工程,初期连 PyTorch 都不需要。等你把张量和自动微分自己实现了一遍之后,再引入 PyTorch 做对比验证,效果会更好。
开发工具我推荐 Jupyter Notebook 和 VS Code 结合使用。Notebook 适合做探索性的实验,比如验证一个梯度计算是否正确;VS Code 适合写结构化的代码,比如实现一个完整的训练框架。两者配合,效率最高。
版本管理用 Git,这个不用多说。但我要强调的是,每次实现一个新功能就 commit 一次,commit message 写清楚做了什么。因为从零实现的过程中,你经常会发现之前的设计有问题需要回退,有清晰的 commit 历史会救命。
4.2 分阶段实现路线图
我把整个从零构建的过程分成四个阶段,每个阶段有明确的产出物:
第一阶段:基础张量库(1-2周)
产出物:一个支持基本运算、广播、形状操作的 Tensor 类。
这个阶段的关键是理解内存布局和步长机制。我建议先实现最基础的连续内存版本,确保正确性,然后再考虑优化。不要一上来就追求性能,正确性永远优先。
第二阶段:自动微分引擎(1-2周)
产出物:一个支持动态计算图和反向传播的 autograd 系统。
这个阶段的难点在于处理复杂的计算图。你需要考虑:如何处理分支(一个张量被多个操作使用)、如何处理原地操作、如何管理内存(计算图太大会爆内存)。我的做法是先用小规模的计算图验证正确性,然后逐步增加复杂度。
第三阶段:神经网络模块(2-3周)
产出物:常用的网络层(全连接、卷积、注意力)、损失函数、优化器。
这个阶段你会真正体会到"组合"的力量。每个模块单独看都不复杂,但组合起来就能表达非常复杂的函数。实现的时候要注意模块的接口设计,好的接口能让后续的组装变得非常顺畅。
第四阶段:训练与评估(1-2周)
产出物:一个完整的训练框架,支持数据加载、训练循环、验证、模型保存。
这个阶段是把前面所有工作整合起来。你会遇到很多工程问题:数据怎么高效加载、训练怎么监控、模型怎么保存和恢复。这些问题在实际项目中同样会遇到,提前解决它们非常有价值。
4.3 以 MNIST 为例的端到端实现
理论说了这么多,用一个具体的例子把整个流程串起来。MNIST 手写数字识别是经典的入门任务,数据简单、任务明确,非常适合验证你的从零实现是否正确。
数据准备:MNIST 的数据是 28x28 的灰度图。你需要写一个数据加载器,把图片转成张量,做归一化(把像素值从 0-255 缩放到 0-1),然后分批。批处理的好处是利用矩阵运算的并行性,一次处理多个样本比逐个处理快得多。
模型定义:一个简单的多层感知机就够了。输入层 784 维(28x28 展平),隐藏层 128 维用 ReLU 激活,输出层 10 维对应 10 个数字。用你自己实现的层来搭建这个网络。
训练配置:损失函数用交叉熵,优化器用 Adam,学习率设 0.001,批大小设 64。这些超参数不是拍脑袋定的,交叉熵适合分类任务,Adam 对学习率不敏感适合初学者,批大小 64 是经验和显存的平衡。
训练过程:跑 10 个 epoch,每个 epoch 记录训练损失和验证准确率。正常情况下,你应该能看到损失稳步下降,准确率逐步上升。如果损失不降反升,检查学习率是不是太大了;如果损失降得很慢,检查数据预处理是不是有问题。
结果验证:训练完成后,在测试集上评估准确率。一个正确实现的多层感知机在 MNIST 上应该能达到 97% 以上的准确率。如果差很多,说明实现有问题,需要逐步排查。
提示:从零实现的过程中,最有效的调试方法是和 PyTorch 对比。用相同的初始化、相同的数据、相同的超参数,分别跑你的实现和 PyTorch,对比每一步的输出。差异出现在哪里,问题就在哪里。
4.4 性能优化:从能跑到跑得快
当你的实现能正确运行之后,下一步是优化性能。从零实现的好处是,你清楚地知道每一个操作的开销在哪里,优化起来有的放矢。
第一个优化点是向量化。Python 的循环非常慢,能用 NumPy 的向量化操作就不要用循环。比如计算全连接层,用x @ W + b而不是循环遍历每个神经元。
第二个优化点是内存管理。避免不必要的内存分配和复制。比如在反向传播时,很多中间结果是可以复用的。用原地操作(in-place operation)可以减少内存分配,但要注意不要破坏计算图需要的中间值。
第三个优化点是利用批处理。把多个样本打包成一批,可以充分利用矩阵运算的并行性。批越大,计算效率越高,但内存占用也越大。需要根据显存大小找到合适的批大小。
我实测下来,经过这三轮优化,一个从零实现的训练框架在 MNIST 上的训练速度可以从最初的每 epoch 几分钟优化到几十秒,虽然还是比 PyTorch 慢,但已经在一个可接受的范围内了。
5. 常见问题与排查技巧实录
5.1 梯度相关的问题
梯度问题是从零实现 AI 工程时最常见的坑。我整理了一个速查表:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失不下降 | 梯度为零或极小 | 打印每层梯度范数 | 检查激活函数、初始化 |
| 损失震荡 | 学习率太大 | 观察损失曲线 | 降低学习率 |
| 损失变成 NaN | 梯度爆炸或数值溢出 | 检查中间值范围 | 梯度裁剪、数值稳定化 |
| 梯度形状不对 | 广播机制理解有误 | 对比 PyTorch 输出 | 检查张量形状 |
| 部分参数无梯度 | 计算图断裂 | 检查 requires_grad | 确保所有参数参与计算 |
梯度消失和梯度爆炸是经典问题。梯度消失通常发生在深层网络中,反向传播时梯度逐层衰减,到了底层就几乎为零了。解决方案包括使用 ReLU 激活函数、残差连接、Batch Normalization 等。梯度爆炸则相反,梯度逐层放大,最终溢出。解决方案主要是梯度裁剪,设定一个阈值,超过就缩放回来。
5.2 数值稳定性问题
数值稳定性是从零实现时特别容易忽略的问题。框架帮你处理了很多细节,自己实现的时候如果不在意,就会遇到各种奇怪的 bug。
最典型的是 softmax 的溢出问题。softmax 的公式是exp(x) / sum(exp(x))。如果 x 的值很大,比如 1000,exp(1000)就溢出了。解决方案是先减去最大值:exp(x - max(x)) / sum(exp(x - max(x)))。数学上等价,但数值上稳定。
交叉熵损失也有类似的问题。直接计算-log(softmax(x))在数值上不稳定,通常会把它和 softmax 合并成一个操作,用 log-sum-exp 技巧来计算。
另一个容易忽略的是除零问题。归一化、求平均等操作都可能遇到除零。加一个极小的 epsilon(比如 1e-8)就能避免。
5.3 内存与性能问题
从零实现时,内存管理完全靠自己。我踩过的坑包括:
- 计算图不释放:每次前向传播都构建计算图,如果不及时释放,内存会持续增长。解决方案是在反向传播完成后清空计算图。
- 中间结果过多:反向传播需要用到前向传播的中间结果,如果全部保留,内存占用会很大。可以在不需要的时候及时释放。
- 不必要的复制:张量操作中很多地方会隐式复制数据,比如转置、切片。理解步长机制可以避免不必要的复制。
性能方面,我最大的教训是不要过早优化。先把功能实现正确,再用 profiler 找到真正的瓶颈,针对性地优化。我见过太多人花大量时间优化了一个只占总时间 1% 的操作,而真正的瓶颈在别的地方。
5.4 调试技巧与工具
从零实现的调试和用框架调试很不一样。框架里你可以直接 print 中间结果,但从零实现时,中间结果可能非常多,print 会淹没你。
我的做法是写一个简单的可视化工具,把损失曲线、梯度分布、参数分布画出来。这些图能直观地告诉你训练是否正常。损失曲线应该是平滑下降的,梯度分布应该是有一定范围的,参数分布不应该有极端值。
另一个技巧是单元测试。每实现一个模块,就写几个测试用例验证正确性。比如实现矩阵乘法,就测试一下单位矩阵、零矩阵、随机矩阵的结果。这些测试看起来简单,但能帮你及早发现 bug,避免在集成时才发现问题。
注意:从零实现的过程中,最大的风险是"看起来对了"。你的实现可能在简单情况下正确,但在边界情况下出错。所以测试用例要覆盖各种边界情况:空张量、单元素张量、极端值、非连续内存等。
6. 从学习项目到生产系统的距离
6.1 工程化需要补的课
从零实现一个能跑的 AI 系统,和构建一个能在生产环境稳定运行的系统,中间还有很大的距离。这个距离不是算法层面的,而是工程层面的。
首先是可复现性。你的实验需要能被别人复现,这要求你固定随机种子、记录所有超参数、保存数据版本。我见过太多项目,换一台机器跑结果就不一样,排查起来非常痛苦。
其次是可扩展性。学习项目通常在小数据集上跑,生产环境要处理海量数据。你的数据管道能不能水平扩展?你的训练能不能分布式?这些都需要在架构设计时就考虑。
再次是可观测性。生产系统需要监控:训练是否正常、推理延迟多少、资源利用率如何。这些监控指标能帮你及早发现问题,避免故障扩大。
最后是容错性。生产环境什么都会发生:机器故障、网络抖动、数据异常。你的系统需要能优雅地处理这些情况,而不是一崩了之。
6.2 我踩过的生产环境坑
说几个我在实际项目中踩过的坑,都是从零实现时不会遇到,但生产环境一定会遇到的。
数据管道成为瓶颈:模型训练很快,但数据加载跟不上,GPU 利用率只有 30%。解决方案是用多进程预取数据,让数据加载和模型计算并行。
显存碎片化:长时间运行后,显存碎片化导致无法分配大块内存。解决方案是预分配显存池,或者定期重启服务。
模型版本管理混乱:不同版本的模型混在一起,分不清哪个是哪个。解决方案是建立模型注册表,每次训练自动记录版本、超参数、评估指标。
推理延迟不稳定:平均延迟看起来正常,但 P99 延迟很高。解决方案是分析延迟分布,找到长尾的原因,可能是某些输入触发了低效的计算路径。
6.3 持续学习的方向
AI 工程是一个快速发展的领域,从零构建的能力是基础,但不是终点。有了这个基础之后,你可以往几个方向深入:
- 分布式训练:数据并行、模型并行、流水线并行,处理超大规模模型
- 推理优化:量化、剪枝、知识蒸馏,让模型在资源受限的环境下运行
- MLOps:自动化训练管道、持续部署、A/B 测试,让 AI 系统持续迭代
- 特定领域深入:CV、NLP、推荐系统,每个领域都有独特的工程挑战
我的建议是先把从零构建的基础打牢,然后选择一个方向深入。不要贪多,AI 工程太广了,什么都学等于什么都没学。
最后分享一个我自己的体会:从零实现最大的价值不是让你成为"什么都能自己写"的人,而是让你成为"知道什么时候该自己写、什么时候该用现成工具"的人。这种判断力,才是 AI 工程师最核心的竞争力。我在实际项目中做技术选型的时候,正是因为理解底层原理,才能准确评估不同方案的优劣,做出合理的决策。这个能力,是单纯调包学不来的。