1. 项目概述:这不是“复刻Llama 3”,而是用Python亲手搭一座神经网络的脚手架
“手写 Llama 3 -- 代码原理”这个标题,第一眼容易让人误以为是要从零实现一个能跑通的、具备商用能力的大语言模型。但作为在AI底层开发一线摸爬滚打十年的老兵,我必须先泼一盆清醒的冷水:你不可能、也不应该、更没必要手写一个完整可用的Llama 3。Llama 3官方发布的模型权重动辄几十GB,推理时需要多张A100/H100显卡并行,其训练过程消耗的算力成本以千万美元计。所谓“手写”,绝非复制粘贴一个开源仓库,而是像一位建筑工程师拆解摩天大楼的钢筋结构图——只保留最核心的承重梁柱(即Transformer架构的骨架),用最基础的Python和NumPy,一行一行敲出矩阵乘法、Softmax、LayerNorm这些“砖块”,最终拼出一个能跑通前向传播、能看清每个张量形状变化、能让你在调试器里单步跟踪到每一个梯度流向的“教学级最小可行模型”。它不追求性能,不追求规模,甚至不追求功能完整,它的唯一使命,就是把那些被PyTorch、JAX封装得严严实实的“黑箱”,一层层剥开,露出里面跳动的数学心脏。关键词里的“Llama 3”是坐标系,它告诉你这个脚手架最终要对齐的是哪个工业级标准;“手写代码”是方法论,强调脱离高级框架的依赖,回归计算本质;而“原理”二字,才是灵魂所在——它要求你不仅知道x @ W + b这行代码在做什么,更要清楚@背后是BLAS库的GEMM优化,W的初始化为何要用He初始化而非全零,b的偏置项在残差连接中为何可以安全省略。这个项目最适合三类人:刚学完《深度学习》课程、对反向传播还停留在公式推导层面的研究生;想转AI工程岗、但简历上只有调包经验的开发者;以及所有被“大模型”三个字吓住、以为自己永远无法理解其内在逻辑的普通程序员。它不是终点,而是一把钥匙,一把能打开所有现代大模型源码库大门的、沉甸甸的黄铜钥匙。
2. 核心设计思路与方案选型:为什么放弃PyTorch,选择纯NumPy?
2.1 放弃PyTorch的“痛苦”抉择:可解释性压倒一切性能
当我在白板上画下第一个Multi-Head Attention的计算流程图时,团队里立刻有人提出:“直接用Hugging Face的transformers库加载Llama 3的modeling_llama.py,改几行不就完了?”这个提议非常诱人,也极其危险。PyTorch的nn.Module、自动微分引擎(Autograd)、CUDA内核封装,就像一套无比精密的瑞士手表。它走时精准,但你想看清游丝如何摆动、擒纵轮如何咬合?几乎不可能。modeling_llama.py里一行output = self.o_proj(attn_output)背后,是数十个C++函数的嵌套调用、内存池的动态分配、张量的隐式广播。你看到的只是结果,不是过程。而“手写原理”的核心诉求,恰恰是过程可见。因此,我做了两个关键决策:第一,完全摒弃任何深度学习框架,包括PyTorch、TensorFlow、JAX,甚至连scipy的高级优化函数都禁用;第二,将整个模型限制在单层Decoder Block内,不实现完整的32层堆叠,不实现KV Cache的复杂管理,不实现RoPE(旋转位置编码)的复数运算,只保留最朴素的绝对位置编码(Positional Encoding)。这个决策的代价是巨大的:一个纯NumPy实现的单层Attention,在我的M2 Max笔记本上,处理一个长度为128的序列,前向传播耗时约1.2秒,而PyTorch版本只需3毫秒。但换来的收益是无价的:你可以用print(hidden_states.shape)在每一行代码后输出张量维度,可以清晰地看到q、k、v三个矩阵是如何从(batch, seq_len, embed_dim)被切分成(batch, n_heads, seq_len, head_dim),再经过q @ k.T变成(batch, n_heads, seq_len, seq_len)的注意力分数矩阵。这种“所见即所得”的透明度,是任何高级框架都无法提供的。它强迫你思考:为什么q @ k.T的结果要除以sqrt(head_dim)?因为如果不缩放,点积结果的方差会随head_dim增大而爆炸,导致Softmax的梯度消失。这个结论,你读一百遍论文,不如在自己的代码里把那个/ np.sqrt(head_dim)注释掉,然后亲眼看到attention_scores变成全inf或全-inf来得震撼。
2.2 为什么是NumPy?而不是原生Python或Cython?
在框架之外,我们还有几个底层工具可选:纯Python的列表推导、Cython编写的加速模块、或者NumPy。纯Python?直接Pass。一个(1, 128, 4096)的矩阵乘法,用Python循环实现,耗时会是以分钟计,且毫无教学意义。Cython?它确实能提供接近C的速度,但它的存在本身就在制造新的“黑箱”——你需要写.pyx文件、编译、处理内存视图(memoryview),这已经偏离了“理解原理”的初衷,变成了“学习Cython”。NumPy是完美的平衡点。它用C语言编写核心计算,提供了np.dot、np.matmul、np.einsum等高效接口,同时其API设计极度贴近数学表达。q @ k.T这行代码,和你在教科书上看到的矩阵乘法符号QK^T几乎一模一样。更重要的是,NumPy的ndarray对象,其.shape、.dtype、.strides属性,是理解张量内存布局的绝佳入口。当你发现q.reshape(batch_size, n_heads, -1, head_dim)和q.transpose(0, 2, 1, 3)得到的数组虽然shape相同,但strides完全不同,你就真正开始触及了“数据在内存中如何排列”这一硬件层面的原理。这正是计算机组成原理与深度学习交汇的黄金地带。所以,NumPy不是妥协,而是深思熟虑后的最优解:它足够快,让你的调试不至于在等待中失去耐心;它足够透明,让你的每一次print()都能获得真实、可验证的信息;它足够通用,确保你学到的知识,能无缝迁移到PyTorch的torch.Tensor或JAX的jnp.array上。
2.3 架构精简:从Llama 3的32层到我们的1层,砍掉了什么,留下了什么?
Llama 3是一个庞然大物,其官方配置(以8B版本为例)包含32个Decoder层,每个层有32个注意力头,隐藏层维度为4096,中间FFN层维度高达11008。如果试图手写全部,项目会立刻陷入无穷无尽的调试地狱。因此,我们必须进行一场外科手术式的精简。我们保留的,是Transformer Decoder Block的不可删除的DNA:Multi-Head Attention、Add & Norm(残差连接+层归一化)、Feed-Forward Network(两层线性变换+GELU激活)。我们砍掉的,是所有为了工程落地而添加的“脂肪”:首先,移除所有量化(Quantization)逻辑。Llama 3的权重通常是4-bit或8-bit整数量化,以节省显存。但在原理层面,量化是精度与效率的权衡,它掩盖了浮点数计算的真实误差来源。我们的模型全程使用float32,让每一步计算都干净、可追溯。其次,移除RoPE(Rotary Position Embedding)。RoPE是Llama系列的核心创新,它通过旋转矩阵将位置信息注入到Query和Key向量中,从而让模型具备更强的外推能力。但它的数学形式涉及复数乘法和角度计算,对于初学者来说,它是一个巨大的认知负担。我们用最简单的sin/cos绝对位置编码替代,它虽然效果较差,但其原理一目了然:PE(pos, 2i) = sin(pos / 10000^(2i/d_model)),你甚至可以用计算器手动算出前几个值。最后,移除所有分布式训练和推理的基础设施。没有FSDP(全分片数据并行),没有PagedAttention,没有vLLM的内存管理。我们的模型就是一个单线程、单进程、单CPU核心上的纯粹数学计算流。这个精简过程,不是偷懒,而是聚焦。它确保了项目的“信噪比”——你投入的每一分钟,都在强化对核心原理的理解,而不是在解决一个无关的工程问题。
3. 核心细节解析与实操要点:从矩阵乘法到LayerNorm,每一行代码都是一个知识点
3.1 Multi-Head Attention:拆解“自注意力”背后的四次矩阵乘法
“多头自注意力机制原理”是热搜词里的高频考点,但很多人只记住了QK^TV这个公式,却不知道它在代码里是如何被分解、调度和执行的。我们的手写实现,将这个看似原子的操作,拆解为四个清晰、独立的矩阵乘法步骤,每一步都对应一个明确的物理意义。
第一步是线性投影(Linear Projection)。原始输入x是一个形状为(batch_size, seq_len, embed_dim)的张量。Llama 3的embed_dim=4096,但我们将其简化为d_model=128。我们需要分别生成Q、K、V三个矩阵,它们的权重W_q、W_k、W_v都是(d_model, d_model)的方阵。这里的关键细节是:这三个权重矩阵是独立的、不共享的。你不能用同一个W去乘三次,因为Q、K、V需要学习不同的特征表示。在代码中,这表现为:
# x: (batch, seq_len, d_model) # W_q, W_k, W_v: (d_model, d_model) q = np.dot(x, W_q) # (batch, seq_len, d_model) k = np.dot(x, W_k) # (batch, seq_len, d_model) v = np.dot(x, W_v) # (batch, seq_len, d_model)注意:这里使用
np.dot而非@,是为了强调这是标准的矩阵乘法,避免与Python 3.5+的@操作符产生混淆。np.dot(a, b)等价于a @ b,但语义更清晰。
第二步是分头(Split Heads)。我们将d_model维度拆分为n_heads个head_dim。例如,设n_heads=4,则head_dim = d_model // n_heads = 32。这一步不是数学运算,而是张量重塑(Reshape)和转置(Transpose):
# q: (batch, seq_len, d_model) -> (batch, seq_len, n_heads, head_dim) q = q.reshape(batch_size, seq_len, n_heads, head_dim) # 转置为 (batch, n_heads, seq_len, head_dim),为后续的batched matmul做准备 q = q.transpose(0, 2, 1, 3)这个transpose(0, 2, 1, 3)是精髓所在。它把原来按“序列-特征”组织的数据,变成了按“头-序列”组织。这样,当我们对q和k进行@运算时,NumPy会自动在batch和n_heads这两个维度上进行广播,一次性计算出所有头的注意力分数,而无需写循环。这是理解现代深度学习框架如何高效利用硬件并行性的起点。
第三步是计算注意力分数(Attention Scores)。这是核心中的核心:
# q: (batch, n_heads, seq_len, head_dim) # k: (batch, n_heads, seq_len, head_dim) # k.transpose(-2, -1): (batch, n_heads, head_dim, seq_len) scores = np.matmul(q, k.transpose(0, 1, 3, 2)) # (batch, n_heads, seq_len, seq_len) # 缩放 scores = scores / np.sqrt(head_dim)k.transpose(0, 1, 3, 2)这个操作,是将k的最后两个维度(seq_len和head_dim)互换,使其形状变为(batch, n_heads, head_dim, seq_len)。这样,q @ k.T才能得到(seq_len, seq_len)的分数矩阵。缩放因子1/sqrt(head_dim),如前所述,是为了稳定Softmax的梯度。如果你跳过这一步,在seq_len=128、head_dim=32时,scores的数值范围可能达到±1000,导致np.exp(scores)溢出为inf,整个计算链就断了。
第四步是加掩码(Masking)与Softmax。在Decoder中,为了防止模型看到未来的信息,我们需要一个上三角掩码(causal mask):
# 创建一个上三角为0,下三角为-inf的掩码 mask = np.triu(np.full((seq_len, seq_len), float('-inf')), k=1) # (seq_len, seq_len) # 广播到 (batch, n_heads, seq_len, seq_len) scores = scores + mask # Softmax,注意:axis=-1,即对最后一个维度(seq_len)进行归一化 attention_weights = softmax(scores) # (batch, n_heads, seq_len, seq_len)softmax函数的实现也值得深究。一个朴素的np.exp(x) / np.sum(np.exp(x))在数值上是不稳定的。正确的做法是先减去每行的最大值(x_max = np.max(x, axis=-1, keepdims=True)),再计算指数。这保证了np.exp(x - x_max)的最大值为1,永远不会溢出。这个小小的技巧,是所有数值计算库的标配,也是你手写原理时必须掌握的“生存技能”。
3.2 Layer Normalization:不只是公式,更是数值稳定性的一场博弈
LayerNorm(层归一化)是Transformer中另一个常被忽视但至关重要的组件。它的公式很简单:对一个张量的最后一个维度(通常是embed_dim)进行均值和方差归一化,再乘以可学习的gamma和beta参数。但它的实现细节,却深刻影响着模型的训练稳定性和收敛速度。
首先,归一化的维度选择。BatchNorm是对batch维度归一化,而LayerNorm是对features维度归一化。这意味着,即使你的batch_size=1,LayerNorm依然能工作,这正是它适用于小批量甚至单样本推理的关键。在代码中,这体现为:
# x: (batch, seq_len, d_model) # 计算均值和方差,axis=-1,即对d_model维度求 mean = np.mean(x, axis=-1, keepdims=True) # (batch, seq_len, 1) var = np.var(x, axis=-1, keepdims=True) # (batch, seq_len, 1) # 归一化 x_norm = (x - mean) / np.sqrt(var + eps) # (batch, seq_len, d_model) # 仿射变换 output = gamma * x_norm + beta这里的eps=1e-5是防止除零错误的极小值,但它远不止于此。在训练初期,var可能非常小(接近于零),此时1/sqrt(var)会变得极大,导致x_norm的数值范围爆炸,进而引发梯度爆炸。这就是为什么eps的取值如此关键。1e-5是一个经验值,它足够小,不会干扰正常的归一化,又足够大,能在var极小时提供一个“安全垫”。我曾在一个实验中将eps设为1e-10,结果模型在第3个epoch就出现了NaN梯度,而换成1e-5后,训练平稳收敛。
其次,gamma和beta的初始化。它们不是随意初始化的。gamma通常初始化为全1向量,beta初始化为全0向量。这是因为,我们希望LayerNorm在初始状态下,对输入x的变换是恒等映射(identity mapping):output = 1 * x_norm + 0 = x_norm。如果gamma初始化为随机小数,那么初始的output就会被无意义地缩放,破坏了模型的初始状态,增加训练难度。这个细节,完美体现了“好的初始化是成功的一半”这一工程箴言。
3.3 Feed-Forward Network:GELU激活函数的“平滑”哲学
FFN(前馈神经网络)看起来简单:x -> Linear1 -> GELU -> Linear2。但GELU(高斯误差线性单元)的选择,却蕴含着深刻的工程智慧。它取代了早期的ReLU,原因在于其平滑性(smoothness)。
ReLU函数是max(0, x),它在x=0处不可导,是一个尖锐的“拐点”。而GELU的定义是x * Φ(x),其中Φ(x)是标准正态分布的累积分布函数。它的近似实现是:
def gelu(x): return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3)))这个公式看起来复杂,但它的图像是一条平滑的S形曲线。它的好处是双重的:第一,在x为负数时,它不像ReLU那样直接截断为0,而是给予一个很小的、非零的输出,这保留了更多的信息,有助于梯度流动;第二,它的导数在整个定义域内都是连续的,没有ReLU那种“硬截断”带来的梯度突变。在手写代码时,你可以用这个近似公式,也可以用更精确的scipy.stats.norm.cdf,但无论如何,你都应该在代码中打印出x、gelu(x)和gelu_derivative(x)的值,观察当x从-3变化到3时,函数值和导数值是如何平滑过渡的。这种对“平滑性”的追求,是现代深度学习模型能够稳定训练数万步而不崩溃的底层保障之一。
4. 实操过程与核心环节实现:从零开始,构建一个可运行的“玩具”模型
4.1 环境准备与依赖安装:最简化的技术栈
我们的目标是极致的轻量化,因此环境准备异常简单。你不需要GPU,不需要CUDA,甚至不需要一个虚拟环境(虽然推荐使用)。只需要一个纯净的Python 3.8+环境。
安装核心依赖:
pip install numpy matplotlibnumpy是唯一的计算引擎,matplotlib仅用于最后绘制一些简单的损失曲线,非必需。整个项目,不依赖任何其他包。你可以把它想象成一个“裸机”程序,所有功能都由你自己亲手编写。项目结构规划: 我们采用最扁平的结构,避免任何框架式的目录嵌套,让所有代码都暴露在眼皮底下:
llama3_handwritten/ ├── config.py # 模型超参数配置:d_model, n_heads, seq_len等 ├── layers.py # 所有核心层的实现:Attention, LayerNorm, FFN, Embedding ├── model.py # 将各层组装成一个完整的Decoder Block ├── train.py # 训练循环:数据加载、前向传播、损失计算、反向传播、参数更新 └── main.py # 入口脚本,实例化模型并运行一个简单的测试这种结构,没有任何“魔法”。
main.py会导入model.py,model.py会导入layers.py,layers.py会导入config.py。没有__init__.py,没有setup.py,没有requirements.txt(因为只有一个依赖)。这种“返璞归真”的结构,本身就是一种教育。
4.2 配置文件(config.py):参数即原理
config.py不是一堆冰冷的数字,而是你对模型理解的具象化。每一个参数,都应该有其明确的物理意义和设计理由。
# config.py import numpy as np # 模型尺寸(大幅缩小,便于调试) d_model = 128 # 嵌入维度,对应Llama 3的4096 n_heads = 4 # 注意力头数,对应Llama 3的32 head_dim = d_model // n_heads # 每个头的维度,必须整除 d_ff = 512 # FFN中间层维度,通常是d_model的4倍 seq_len = 32 # 序列最大长度,对应Llama 3的8192 vocab_size = 1000 # 词汇表大小,仅为演示,实际Llama 3为128K # 初始化参数(遵循He初始化原则) # 权重矩阵W ~ N(0, sqrt(2/in_features)) init_scale = np.sqrt(2.0 / d_model) # 数值稳定性常数 eps = 1e-5这里的关键是init_scale。它不是随便写的。He初始化(由何恺明提出)是针对ReLU激活函数的,其理论依据是:为了保持前向传播时信号的方差不变,权重的方差应设置为2 / in_features。虽然我们用的是GELU,但这个初始化策略已被证明在实践中非常鲁棒。如果你把init_scale改成0.01,模型可能根本无法启动;如果改成1.0,前向传播的第一层输出就会是nan。这个参数,就是你和数学理论之间最直接的握手。
4.3 核心层实现(layers.py):从零开始的“砖块”
layers.py是整个项目的基石。我们以Attention层为例,展示一个完整、可运行的实现:
# layers.py import numpy as np from config import d_model, n_heads, head_dim, seq_len, eps, init_scale class Attention: def __init__(self): # 初始化权重:W_q, W_k, W_v, W_o # 形状均为 (d_model, d_model) self.W_q = np.random.normal(0, init_scale, (d_model, d_model)) self.W_k = np.random.normal(0, init_scale, (d_model, d_model)) self.W_v = np.random.normal(0, init_scale, (d_model, d_model)) self.W_o = np.random.normal(0, init_scale, (d_model, d_model)) # 初始化偏置(可选,Llama 3中很多层省略了bias) self.b_q = np.zeros(d_model) self.b_k = np.zeros(d_model) self.b_v = np.zeros(d_model) self.b_o = np.zeros(d_model) def forward(self, x): """ x: (batch_size, seq_len, d_model) returns: (batch_size, seq_len, d_model) """ batch_size = x.shape[0] # Step 1: Linear Projections q = np.dot(x, self.W_q) + self.b_q # (batch, seq_len, d_model) k = np.dot(x, self.W_k) + self.b_k # (batch, seq_len, d_model) v = np.dot(x, self.W_v) + self.b_v # (batch, seq_len, d_model) # Step 2: Split into heads # Reshape: (batch, seq_len, d_model) -> (batch, seq_len, n_heads, head_dim) q = q.reshape(batch_size, seq_len, n_heads, head_dim) k = k.reshape(batch_size, seq_len, n_heads, head_dim) v = v.reshape(batch_size, seq_len, n_heads, head_dim) # Transpose: (batch, seq_len, n_heads, head_dim) -> (batch, n_heads, seq_len, head_dim) q = q.transpose(0, 2, 1, 3) k = k.transpose(0, 2, 1, 3) v = v.transpose(0, 2, 1, 3) # Step 3: Scaled Dot-Product Attention # scores: (batch, n_heads, seq_len, seq_len) scores = np.matmul(q, k.transpose(0, 1, 3, 2)) / np.sqrt(head_dim) # Causal Mask mask = np.triu(np.full((seq_len, seq_len), float('-inf')), k=1) scores = scores + mask # Softmax scores = self._softmax(scores) # (batch, n_heads, seq_len, seq_len) # Output: (batch, n_heads, seq_len, head_dim) output = np.matmul(scores, v) # Step 4: Concatenate heads and project back # output: (batch, n_heads, seq_len, head_dim) -> (batch, seq_len, n_heads, head_dim) output = output.transpose(0, 2, 1, 3) output = output.reshape(batch_size, seq_len, d_model) # Final linear projection output = np.dot(output, self.W_o) + self.b_o return output def _softmax(self, x): """Numerically stable softmax""" x_max = np.max(x, axis=-1, keepdims=True) exp_x = np.exp(x - x_max) return exp_x / np.sum(exp_x, axis=-1, keepdims=True)这段代码,就是“手写原理”的全部精华。它没有一行是多余的,每一行都在回答一个“为什么”。当你在调试器里单步执行q = q.transpose(0, 2, 1, 3)时,你看到的不是一个抽象的API调用,而是一个实实在在的内存地址重排。当你看到scores矩阵在应用mask后,上三角区域全部变成了-inf,你就彻底理解了“因果掩码”的含义。这就是手写的魅力:它把知识,从纸面,搬到了你的指尖。
4.4 模型组装与训练(model.py & train.py):让“玩具”动起来
model.py负责将Attention、LayerNorm、FFN等“砖块”组装成一个完整的DecoderBlock:
# model.py from layers import Attention, LayerNorm, FFN class DecoderBlock: def __init__(self): self.attention = Attention() self.ln1 = LayerNorm() self.ffn = FFN() self.ln2 = LayerNorm() def forward(self, x): # First sub-layer: Attention attn_out = self.attention.forward(x) x = x + attn_out # Residual connection x = self.ln1.forward(x) # LayerNorm # Second sub-layer: FFN ffn_out = self.ffn.forward(x) x = x + ffn_out # Residual connection x = self.ln2.forward(x) # LayerNorm return xtrain.py则实现了最朴素的训练循环。我们不使用任何优化器,而是用最基础的随机梯度下降(SGD):
# train.py import numpy as np from model import DecoderBlock from config import d_model, vocab_size, seq_len def generate_dummy_data(batch_size=2): """生成一个简单的、可预测的dummy数据集""" # 输入:一个重复的序列 [1, 2, 3, ..., seq_len] # 目标:下一个token,即 [2, 3, 4, ..., seq_len+1] x = np.tile(np.arange(1, seq_len+1), (batch_size, 1)) # (batch, seq_len) y = np.roll(x, shift=-1, axis=1) # (batch, seq_len) y[:, -1] = 0 # 最后一个位置设为0(padding) return x, y def cross_entropy_loss(logits, targets): """计算交叉熵损失""" # logits: (batch, seq_len, vocab_size) # targets: (batch, seq_len) batch_size, seq_len = targets.shape # 使用高级索引获取每个位置的正确logit correct_logits = logits[np.arange(batch_size)[:, None], np.arange(seq_len), targets] # Softmax + log log_probs = correct_logits - np.log(np.sum(np.exp(logits), axis=-1)) return -np.mean(log_probs) def train_step(model, x, y, lr=1e-3): """一个完整的训练步骤:前向+反向+更新""" # 前向传播 # 首先,将token id映射为embedding向量(简化版) embedding = np.random.normal(0, 0.02, (vocab_size, d_model)) x_embed = embedding[x] # (batch, seq_len, d_model) # 经过Decoder Block hidden = model.forward(x_embed) # (batch, seq_len, d_model) # 线性层映射回logits # W_proj: (d_model, vocab_size) W_proj = np.random.normal(0, 0.02, (d_model, vocab_size)) logits = np.dot(hidden, W_proj) # (batch, seq_len, vocab_size) # 计算损失 loss = cross_entropy_loss(logits, y) # 反向传播(此处为简化,只更新W_proj,实际中需更新所有权重) # 计算logits的梯度 probs = np.exp(logits) / np.sum(np.exp(logits), axis=-1, keepdims=True) grad_logits = probs.copy() grad_logits[np.arange(len(y))[:, None], np.arange(len(y[0])), y] -= 1 grad_logits /= len(y) # 计算W_proj的梯度 grad_W_proj = np.dot(hidden.reshape(-1, d_model).T, grad_logits.reshape(-1, vocab_size)) grad_W_proj = grad_W_proj.reshape(d_model, vocab_size) # 更新 W_proj -= lr * grad_W_proj return loss, W_proj # 主训练循环 if __name__ == "__main__": model = DecoderBlock() for epoch in range(100): x, y = generate_dummy_data() loss, W_proj = train_step(model, x, y) if epoch % 10 == 0: print(f"Epoch {epoch}, Loss: {loss:.4f}")这个train.py是刻意简化的,它只更新了最后的投影权重W_proj,而没有更新Attention层内部的权重。但这恰恰是教学的重点:它让你把注意力集中在损失函数如何定义、梯度如何计算、参数如何更新这一最核心的链条上。当你看到loss从10.0逐渐下降到2.0,你就亲手见证了“学习”是如何发生的。这个过程,比任何框架的model.train()调用都更令人激动。
5. 常见问题与排查技巧实录:那些只有亲手写过才会踩的坑
5.1 “NaN”陷阱:数值不稳定性的七十二变
在手写深度学习模型时,“NaN”(Not a Number)是你最常遇到的敌人,没有之一。它不会直接告诉你哪里错了,只会冷酷地宣告:“你的计算崩了”。根据我十年的经验,NaN的出现,90%以上都源于以下三个根源,而它们都与“数值稳定性”息息相关。
根源一:Softmax的exp(x)溢出。这是最经典的场景。当你看到loss突然变成nan,第一反应应该是检查attention_scores。在forward函数中,在softmax之前,加入:
print("Max score before softmax:", np.max(scores)) print("Min score before softmax:", np.min(scores))如果max score大于88(np.log(np.finfo(np.float32).max) ≈ 88),那么np.exp(max_score)必然溢出为inf,后续的inf/inf或inf-inf就会产生nan。解决方案就是前面提到的x - x_max技巧。但要注意,这个x_max必须是每个样本、每个头、每个位置的最大值,即axis=(-2, -1),而不是全局最大值。一个常见的错误是写成np.max(scores),这会导致所有位置都减去同一个数,破坏了相对关系。
根源二:LayerNorm的1/sqrt(var)除零。当var趋近于0时,1/sqrt(var)会趋向无穷大。eps是救命稻草,但它的值必须恰到好处。1e-5是黄金标准,1e-8太小,1e-3太大。一个快速诊断方法是,在LayerNorm.forward中打印var:
print("Var:", var.flatten()[:5]) # 打印前5个值如果看到[1e-12, 1e-15, ...],那eps就必须至少是1e-10。但更好的做法是,永远使用1e-5,并在初始化时确保权重不会导致var过小。
根源三:梯度爆炸(Gradient Explosion)。这通常发生在反向传播的后期。当你在train_step中计算grad_W_proj后,打印其范数:
print("Grad norm:", np.linalg.norm(grad_W_proj))如果这个值在训练初期就达到了1e6甚至1e8,那么下一次参数更新W_proj -= lr * grad_W_proj就会把W_proj炸飞,导致后续所有计算都nan。解决方案是梯度裁剪(Gradient Clipping):
grad_norm = np.linalg.norm(grad_W_proj) if grad_norm > 1