☰
从零手搓AI工程:NumPy实现GPT与框架调包对比
2026/10/30 17:04:31 网站建设 项目流程

1. 从零手搓AI工程:为什么我不建议你直接调包

第一次看到ai-engineering-from-scratch这个项目名的时候,我脑子里蹦出来的画面是:一个人坐在黑漆漆的终端前面,从矩阵乘法开始,一行一行把Transformer搭出来,中间不碰任何现成的深度学习框架。后来我自己真去走了一遍这条路,才发现它真正的价值不在于“造轮子”,而在于把AI工程里那些被框架封装掉的决策点重新暴露出来。

这个项目适合谁?如果你已经能用PyTorch或TensorFlow跑通一个分类模型,但被问到“为什么这里要除以根号d_k”“LayerNorm到底归一化了什么”“KV Cache省下的到底是哪部分计算”时答不上来,那这个方向就是给你准备的。它解决的核心问题是:把AI工程从“调包经验”变成“可推导的工程直觉”。你不再依赖文档告诉你某个参数该填多少,而是能从计算量和数值稳定性两个角度自己算出来。

我前后花了大概三周时间,用纯NumPy加少量Python标准库,从零实现了一个能跑通的小型GPT结构,包括分词、嵌入、多头注意力、前馈网络、训练循环和推理采样。整个过程踩的坑比我想象的多,但也正是这些坑让我对现在用的框架有了完全不同的理解。下面我把整个思路、关键细节和实操过程拆开讲,你如果也想走一遍这条路,可以直接照着抄作业。

2. 整体设计与技术选型:为什么是NumPy而不是别的

2.1 核心思路:把框架的“黑盒”拆成可验证的白盒

从零做AI工程,第一个决策就是用什么作为计算底座。可选方案无非三类:纯Python列表、NumPy、或者自己写一个极简的张量库。我最终选了NumPy,理由有三条。

第一,纯Python列表做矩阵乘法在规模稍大时慢到无法接受。一个(64, 128) @ (128, 256)的矩阵乘,用嵌套循环要跑好几秒,而NumPy底层是BLAS,毫秒级完成。训练循环里这种操作成千上万次,纯Python根本跑不动。

第二,自己写张量库虽然更能体现“从零”,但会把大量时间花在内存管理和广播规则上,偏离了AI工程本身的核心。我的目标是理解模型结构和训练逻辑,不是重造一个数值计算库。

第三,NumPy的API足够底层,np.dot、np.exp、np.max这些函数不会帮你做任何“智能”的事。比如softmax的数值稳定性,NumPy不会自动帮你减最大值,你得自己写。这正是我想要的效果——每一个数值细节都由我负责。

提示:如果你连NumPy的广播机制都不熟,建议先花半天把np.newaxis、keepdims、axis参数搞清楚,否则后面调试梯度时会非常痛苦。

2.2 模型规模的选择:为什么我选了一个“玩具级”配置

真正动手前,我给自己定了一个硬约束:整个模型必须能在一台普通笔记本的CPU上,几分钟内完成一次前向传播。这不是为了性能,而是为了调试效率。

最终配置是这样的:词表大小5000,嵌入维度128,注意力头数4,层数4,上下文长度64,前馈网络隐藏层维度512。参数量大概在200万左右。这个规模下,一次前向传播在CPU上大约0.3秒,一次完整训练迭代(batch size 8)大约2秒。我可以快速验证每个模块是否正确,而不需要等半天看结果。

如果你一上来就搞一个上亿参数的配置,调试周期会拉长到无法忍受。从零实现的第一原则是:先让一个小模型跑通,再谈规模。这一点和用框架时的思路完全相反,框架里你习惯先定大配置再调,但从零实现时,小配置才是你的朋友。

2.3 模块划分:六个必须独立验证的组件

我把整个工程拆成六个独立模块,每个模块都有明确的输入输出和验证方法:

  • 分词器:把文本转成整数ID序列,再转回来。验证方法是往返一致性。
  • 嵌入层:把ID序列映射成向量,加上位置编码。验证方法是检查输出形状和位置编码的数值范围。
  • 单头注意力:实现缩放点积注意力。验证方法是手动构造小矩阵,对比手算结果。
  • 多头注意力:把单头拆成多个头再拼接。验证方法是检查头维度是否正确分割。
  • 前馈网络与残差:两层线性加激活,配合残差连接和LayerNorm。验证方法是检查梯度是否消失。
  • 训练循环与采样:交叉熵损失、反向传播、参数更新、自回归生成。验证方法是看损失是否下降、生成文本是否合理。

每个模块单独测试通过后,再组装成完整模型。这个顺序非常重要,不要试图一次性把所有模块拼起来再调试,那样出问题时你根本不知道是哪一层错了。

3. 核心细节解析:那些框架帮你做了但你必须自己写的东西

3.1 数值稳定性:softmax为什么要减最大值

这是从零实现时第一个真正让我停下来思考的点。softmax的公式是exp(x_i) / sum(exp(x_j)),看起来很简单。但如果你直接按公式写,当x_i稍微大一点,比如100,exp(100)就溢出了,结果是inf,整个计算就废了。

框架里你从来不用担心这个,因为PyTorch的softmax内部自动做了最大值减法。但从零实现时,你必须自己写:

def softmax(x, axis=-1): x_max = np.max(x, axis=axis, keepdims=True) exp_x = np.exp(x - x_max) return exp_x / np.sum(exp_x, axis=axis, keepdims=True)

减最大值不改变数学结果,因为分子分母同时乘了exp(-x_max),但数值上把指数范围压到了[-inf, 0],exp的结果永远在(0, 1]之间,不会溢出。

这个细节在注意力机制里尤其关键。注意力分数是query和key的点积,维度是d_k,点积结果会随d_k增大而增大。这就是为什么注意力里要除以sqrt(d_k)——不只是为了梯度稳定,也是为了把softmax的输入拉回一个数值安全的范围。

注意:如果你在实现时发现损失突然变成NaN,第一个要检查的就是softmax有没有做最大值减法,第二个是log里有没有加极小值。

3.2 位置编码:为什么用正弦函数而不是可学习嵌入

位置编码有两种主流做法:可学习的嵌入向量,或者固定的正弦函数。我最终选了正弦函数,原因有两个。

第一,正弦编码不需要训练参数,在小模型上更不容易过拟合。我的训练数据量不大,可学习的位置嵌入很容易记住训练集的位置模式,泛化到稍长的序列就崩了。

第二,正弦编码的数值范围是固定的[-1, 1],和嵌入向量的量级匹配。可学习嵌入初始化时是随机的,如果初始化方差没调好,位置信息会淹没在词嵌入里。

具体实现是这样的:

def positional_encoding(seq_len, d_model): pos = np.arange(seq_len)[:, np.newaxis] i = np.arange(d_model)[np.newaxis, :] angle = pos / np.power(10000, (2 * (i // 2)) / d_model) angle[:, 0::2] = np.sin(angle[:, 0::2]) angle[:, 1::2] = np.cos(angle[:, 1::2]) return angle

这里有个容易搞错的地方:i // 2的整除操作。偶数维度用sin,奇数维度用cos,频率随维度指数衰减。我第一遍写的时候忘了整除,导致sin和cos的频率对不上,模型训练损失一直不降。后来打印出位置编码矩阵一看,相邻维度的值几乎一样,完全没有区分度。

3.3 反向传播:手写梯度比想象中更需要耐心

从零实现最硬核的部分就是反向传播。框架的autograd帮你做了所有链式法则,但从零实现时,每一个操作的梯度你都得自己推、自己写。

我以交叉熵损失为例。前向是loss = -log(softmax(logits)[target]),反向时梯度是softmax(logits) - one_hot(target)。这个结果很简洁,但推导过程需要用到softmax的雅可比矩阵,我建议你至少手推一遍,否则很容易在实现时把维度搞错。

矩阵乘法的反向传播更绕。如果前向是Y = X @ W,那么dX = dY @ W.T,dW = X.T @ dY。这里的关键是维度必须对齐。我踩过的坑是:当X是三维张量(batch, seq, dim)时,X.T并不是我想要的转置。NumPy的.T默认反转所有轴,而我只想交换最后两个轴。正确做法是np.swapaxes(X, -1, -2)。

# 前向 Y = X @ W # 反向 dX = dY @ W.swapaxes(-1, -2) dW = X.swapaxes(-1, -2) @ dY

这个细节在批量训练时特别容易出错,因为小批量测试时维度是二维的,.T恰好正确,一旦加上batch维度就错了。我的建议是:所有矩阵操作都用swapaxes显式指定轴,不要用.T。

3.4 LayerNorm:归一化的轴选择决定成败

LayerNorm看起来简单:减均值、除标准差、再缩放平移。但归一化的轴选错了,整个模型就学不到东西。

在Transformer里,LayerNorm是对最后一维(特征维度)做归一化,而不是对batch或序列维度。也就是说,每个位置的每个样本独立归一化。我第一版实现时对batch维度做了归一化,结果训练损失震荡得非常厉害,因为不同样本的统计量被混在一起了。

def layer_norm(x, gamma, beta, eps=1e-5): mean = np.mean(x, axis=-1, keepdims=True) var = np.var(x, axis=-1, keepdims=True) x_norm = (x - mean) / np.sqrt(var + eps) return gamma * x_norm + beta

eps取1e-5是常见做法,防止方差为0时除零。gamma和beta是可学习参数,初始化为1和0,这样训练初期LayerNorm近似恒等变换,不会破坏预训练权重(虽然我们从零训练,但这个初始化习惯要保持)。

4. 实操过程:从空文件到能生成文本的完整流程

4.1 环境准备与数据管道

环境很简单,只需要NumPy和标准库。我用的Python 3.10,NumPy 1.24。不需要GPU,CPU足够跑这个小模型。

pip install numpy

数据方面,我用了一段公开的英文文本作为训练语料,大概5MB。分词器我选了最简单的字符级分词,而不是BPE。原因很简单:字符级分词不需要训练,词表就是所有出现过的字符,实现起来只有几十行代码。BPE虽然效果更好,但训练分词器本身就是一个独立工程,会分散注意力。

字符级分词的实现:

class CharTokenizer: def __init__(self, text): self.chars = sorted(list(set(text))) self.char_to_id = {c: i for i, c in enumerate(self.chars)} self.id_to_char = {i: c for c, i in self.char_to_id.items()} def encode(self, text): return [self.char_to_id[c] for c in text] def decode(self, ids): return ''.join([self.id_to_char[i] for i in ids])

验证往返一致性:tokenizer.decode(tokenizer.encode("hello")) == "hello"。这一步必须通过,否则后面所有训练都是错的。

4.2 单头注意力的实现与验证

单头注意力是整个模型的核心。我把它拆成四步:线性投影得到Q、K、V,计算注意力分数,softmax归一化,加权求和。

def scaled_dot_product_attention(Q, K, V, mask=None): d_k = Q.shape[-1] scores = Q @ K.swapaxes(-1, -2) / np.sqrt(d_k) if mask is not None: scores = np.where(mask, scores, -1e9) attn = softmax(scores, axis=-1) return attn @ V, attn

验证方法:构造一个2x2的Q和K,手动算出注意力权重,对比代码输出。我构造的例子是Q和K完全相同,那么注意力权重应该是对角线占优。实测结果符合预期。

mask的处理是另一个关键点。自回归生成时,每个位置只能看到自己和之前的位置,所以需要一个上三角为0的mask。我用np.triu生成:

mask = np.tril(np.ones((seq_len, seq_len)))

tril生成下三角矩阵,1表示可见,0表示不可见。在softmax之前把不可见位置设成-1e9,softmax之后这些位置就变成0了。

提示:-1e9不要用-inf,因为-inf参与减法会变成NaN。-1e9足够小,softmax后是0,又不会污染计算。

4.3 多头注意力的拆分与拼接

多头注意力的思路是:把d_model维的Q、K、V拆成h个头,每个头维度是d_model / h,各自做注意力,最后拼接再投影。

拆分用reshape加transpose:

def split_heads(x, num_heads): batch, seq_len, d_model = x.shape d_head = d_model // num_heads x = x.reshape(batch, seq_len, num_heads, d_head) return x.transpose(0, 2, 1, 3) # (batch, heads, seq, d_head)

拼接是逆操作:

def combine_heads(x): batch, heads, seq_len, d_head = x.shape x = x.transpose(0, 2, 1, 3) # (batch, seq, heads, d_head) return x.reshape(batch, seq_len, heads * d_head)

这里最容易错的是transpose的轴顺序。我第一版把(0, 2, 1, 3)写成了(0, 1, 2, 3),结果每个头看到的是错误的维度切片,注意力完全失效。验证方法:拆分后再拼接,结果应该和原张量完全一致。

4.4 训练循环:损失下降只是第一步

训练循环包括前向传播、损失计算、反向传播、参数更新。我用的是最简单的随机梯度下降,学习率0.01,没有动量。

for epoch in range(num_epochs): for batch in get_batches(data, batch_size): logits = model.forward(batch) loss = cross_entropy(logits, batch) grads = model.backward(batch) for param, grad in zip(model.params, grads): param -= learning_rate * grad

损失从初始的8.5左右(log(5000))逐渐下降到2.0左右,说明模型确实在学。但损失下降不代表模型有用,还要看生成效果。

我每隔500步做一次生成测试,用模型自回归采样20个字符。初期生成的是乱码,中期开始出现常见字母组合,后期能生成类似单词的结构。这个过程非常直观,比看损失曲线更有成就感。

4.5 自回归采样:温度参数的实际影响

采样时有一个关键参数:温度。温度小于1时,概率分布更尖锐,生成更确定但可能重复;温度大于1时,分布更平坦,生成更多样但可能不连贯。

def sample(model, start_ids, max_len, temperature=1.0): ids = start_ids.copy() for _ in range(max_len): logits = model.forward(np.array([ids]))[0, -1] logits = logits / temperature probs = softmax(logits) next_id = np.random.choice(len(probs), p=probs) ids.append(next_id) return ids

我实测下来,温度0.8左右生成质量最好,既有一定多样性,又不会太离谱。温度1.5以上基本就是随机字符了。

5. 常见问题与排查技巧实录

5.1 损失不下降的五个排查方向

从零实现时,损失不下降是最常见的问题。我整理了一个排查顺序,按可能性从高到低:

排查项检查方法常见错误
学习率打印梯度范数学习率太大导致震荡,太小导致不收敛
梯度计算数值梯度对比矩阵转置轴错误,softmax梯度符号错误
参数初始化检查初始损失初始损失应接近log(vocab_size)
数据管道检查输入输出标签错位,mask方向反了
归一化检查LayerNorm轴对batch维度归一化而非特征维度

我遇到最隐蔽的一个问题是标签错位。语言模型的目标是预测下一个字符,所以输入是data[i:i+seq_len],标签应该是data[i+1:i+seq_len+1]。我第一版把标签写成了和输入一样,模型学的是复制输入,损失确实在降,但生成时完全没用。

5.2 梯度爆炸与裁剪

从零实现时,梯度爆炸比梯度消失更常见,因为我没有用任何自适应优化器。一个简单的梯度裁剪就能解决:

def clip_gradients(grads, max_norm=1.0): total_norm = np.sqrt(sum(np.sum(g**2) for g in grads)) if total_norm > max_norm: scale = max_norm / (total_norm + 1e-6) grads = [g * scale for g in grads] return grads

max_norm取1.0是常见值。裁剪后梯度方向不变,只缩小模长,能有效防止参数更新过大导致的NaN。

5.3 内存与速度的平衡

纯NumPy实现没有GPU加速,内存和速度都需要注意。我的经验是:

  • batch size不要超过16,否则内存占用会超过2GB。
  • 上下文长度不要超过128,注意力矩阵是seq_len^2,128已经产生16384个元素。
  • 前馈网络隐藏层维度不要超过4 * d_model,再大收益递减但计算量线性增长。

如果速度太慢,可以用np.float32代替np.float64,速度能提升约30%,精度损失可以忽略。

5.4 生成结果重复的解决思路

自回归生成时,模型容易陷入重复循环,比如一直输出同一个字符。这是训练不充分和采样策略共同导致的。除了调温度,还可以用top-k采样:只从概率最高的k个字符里采样。

def top_k_sample(probs, k=10): top_k_ids = np.argsort(probs)[-k:] top_k_probs = probs[top_k_ids] top_k_probs = top_k_probs / np.sum(top_k_probs) return np.random.choice(top_k_ids, p=top_k_probs)

k取10到50之间效果比较好。太小会失去多样性,太大和普通采样没区别。

6. 从零实现之后:我对AI工程的新理解

走完这一遍之后,我再看PyTorch的nn.MultiheadAttention,感觉完全不一样了。以前我只知道它有三个投影矩阵,现在我知道每个矩阵的形状、初始化方式、以及为什么d_model必须能被num_heads整除。以前我调temperature参数靠感觉,现在我能从softmax的数值范围解释它为什么有效。

这个项目最大的价值不是产出一个能用的模型——说实话,这个玩具模型生成质量远不如任何现成的小型语言模型。它的价值在于把AI工程从“配置调参”拉回到“原理推导”。当你亲手写过一遍反向传播,你就不会再对loss.backward()感到神秘;当你亲手处理过softmax溢出,你就不会再对log_softmax的存在感到困惑。

如果你也想走这条路,我的建议是:不要追求一次写对,要追求每次出错都知道为什么错。我前后重构了三次,第一次是维度错误,第二次是梯度错误,第三次是数值稳定性问题。每一次重构都让我对模型的理解更深一层。这个过程中积累的调试直觉,是任何框架文档都给不了的。

最后分享一个我踩过的小坑:在实现位置编码时,np.power(10000, ...)里的10000不要写成1000或者100000,这个值决定了不同维度频率的衰减速度,改大了长序列的位置区分度会下降,改小了短序列的相邻位置编码太相似。10000是经过验证的平衡点,直接用就好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询