1. 从一行 NumPy 说起:为什么程序员该懂点 LLM
1.1 一个真实的学习起点
我最早接触 NumPy 的时候,纯粹是为了处理一批传感器采集的数据。那时候的想法很简单:Python 的 list 用着挺顺手,为什么还要学一个新库?直到我用 list 做了一次 1000×1000 的矩阵乘法,跑了将近 8 秒,换成 NumPy 之后直接降到 0.02 秒。这个差距让我第一次意识到,底层数据结构的选择,直接决定了上层能做什么。
后来我开始接触大语言模型(LLM),发现一个很有意思的现象:几乎所有 LLM 的底层运算,归根结底都是 NumPy 那套东西的延伸——张量(Tensor)运算、矩阵乘法、广播机制、维度变换。Transformer 架构看起来很复杂,但拆开来看,每一层都是矩阵运算的堆叠。你如果理解了 NumPy 里的shape、broadcast、einsum,再看 Transformer 的代码,会发现很多“魔法”其实没那么神秘。
这篇笔记就是记录我从一行 NumPy 代码出发,一步步走到理解 LLM 核心原理的过程。适合谁看?如果你是有一定 Python 基础、想搞明白大模型到底怎么运转的程序员,或者你已经在用 LLM 的 API 但想往下挖一层,那这些内容应该对你有用。我不会堆砌公式,而是尽量用代码和类比把关键概念讲清楚。
1.2 从 NumPy 到 Tensor 的自然延伸
NumPy 的ndarray和深度学习框架里的Tensor,本质上描述的是同一件事:多维数组加上一套运算规则。区别在于,Tensor 多了两个能力——自动求导和硬件加速(GPU/TPU)。但如果你把这两个能力暂时放一边,Tensor 的很多操作逻辑和 NumPy 几乎一模一样。
举个例子,你在 NumPy 里写a @ b做矩阵乘法,在 PyTorch 里写a @ b做的是同样的事。你在 NumPy 里用a.reshape(2, 3, 4)改变数组形状,在 PyTorch 里也是a.reshape(2, 3, 4)。甚至广播机制(broadcasting)的规则都高度一致。这意味着,你花时间学 NumPy 的每一分钟,在学 LLM 的时候都会变成已有的知识储备。
我见过不少新手一上来就想跑 Transformer 模型,结果卡在shape不匹配的报错上,一调就是半天。其实这类问题的根源往往不在 Transformer 本身,而在对多维数组运算的理解不够扎实。所以我的建议是:别急着看 Transformer 的论文,先把 NumPy 里的矩阵运算、广播、轴变换这几个概念吃透,后面会省很多时间。
2. NumPy 核心操作:LLM 的地基怎么打
2.1 矩阵乘法:从手算到 einsum
矩阵乘法是 LLM 里出现频率最高的运算。Transformer 里的注意力机制、前馈网络、投影层,全都是矩阵乘法。NumPy 提供了几种写法:
import numpy as np # 方式一:np.dot a = np.random.randn(4, 8) b = np.random.randn(8, 16) c = np.dot(a, b) # shape: (4, 16) # 方式二:@ 运算符(推荐) c = a @ b # 同上 # 方式三:np.matmul(支持批量) a_batch = np.random.randn(2, 4, 8) b_batch = np.random.randn(2, 8, 16) c_batch = np.matmul(a_batch, b_batch) # shape: (2, 4, 16)np.dot和@在二维情况下等价,但到了三维及以上,行为就不一样了。np.dot做的是张量缩并,而np.matmul(也就是@)做的是批量矩阵乘法。在 LLM 的代码里,你几乎总是需要批量矩阵乘法,因为输入是一个 batch 的数据。所以养成用@的习惯,能避免很多维度上的困惑。
再来说np.einsum,这个函数看起来吓人,但用熟了之后非常优雅。注意力机制里的Q @ K^T可以用 einsum 写成:
# Q: (batch, seq_len, d_k) # K: (batch, seq_len, d_k) # 计算注意力分数 scores = np.einsum('bqd,bkd->bqk', Q, K)这行代码的意思是:对 Q 和 K 的最后一个维度做点积,保留 batch 和序列长度维度。用@写就是Q @ K.transpose(0, 2, 1),效果一样,但 einsum 的写法更直观地表达了“我在做什么运算”。当然,einsum 在性能上不一定总是最优,实际工程中还是用@居多,但理解 einsum 对读懂论文里的公式很有帮助。
2.2 广播机制:让 shape 不再打架
广播(broadcasting)是 NumPy 里最容易被低估的特性。它的规则很简单:从右往左对齐维度,要么相等,要么其中一个是 1,要么其中一个不存在。但就是这个简单规则,在 LLM 里到处都在用。
比如在 Transformer 的位置编码里,你需要把一个(seq_len, d_model)的编码矩阵加到(batch, seq_len, d_model)的输入上。NumPy 会自动把编码矩阵广播到 batch 维度:
# positional_encoding: (seq_len, d_model) # x: (batch, seq_len, d_model) x = x + positional_encoding # 自动广播再比如层归一化(LayerNorm)里,你需要对每个样本的最后一维做归一化,然后乘以一个可学习的缩放参数gamma(shape 为(d_model,))。广播机制让(batch, seq_len, d_model)和(d_model,)能直接相乘,不需要手动扩展维度。
我踩过的一个坑是:广播虽然方便,但容易掩盖维度错误。比如你本意是想让两个(batch, 1)的向量做外积得到(batch, batch),结果因为广播规则,得到了一个(batch, batch)的矩阵,但语义完全不对。所以我的经验是:在关键运算前,养成打印 shape 的习惯。一行print(x.shape)能帮你省下大量调试时间。
2.3 Shape 变换:view、reshape、transpose 的区别
在 LLM 代码里,shape 变换无处不在。多头注意力机制就是一个典型的例子:你需要把(batch, seq_len, d_model)的输入拆成(batch, num_heads, seq_len, d_head),其中d_model = num_heads * d_head。
batch, seq_len, d_model = x.shape num_heads = 8 d_head = d_model // num_heads # 拆分多头 x = x.reshape(batch, seq_len, num_heads, d_head) x = x.transpose(0, 2, 1, 3) # (batch, num_heads, seq_len, d_head)这里reshape和transpose配合使用。注意transpose之后,数组在内存里不再连续,如果后续要做view操作会报错。这时候需要先.contiguous()(PyTorch)或.copy()(NumPy)。这个细节在实际写代码时经常遇到,尤其是调试 shape 报错的时候。
还有一个容易混淆的点:reshape和view的区别。在 NumPy 里只有reshape,它会尽量返回视图,如果不行就复制。在 PyTorch 里,view要求内存连续,reshape更灵活。新手建议统一用reshape,除非你明确知道内存布局是连续的,并且追求极致性能。
3. Transformer 拆解:从 NumPy 视角看懂注意力机制
3.1 注意力的三个关键:Query、Key、Value
Transformer 的核心是自注意力机制(Self-Attention)。很多教程一上来就抛公式,但我觉得用一句话就能说清楚:
Query 是“我在找什么”,Key 是“我有什么”,Value 是“我能提供什么”。
具体来说,每个 token 会生成三个向量:Query(Q)、Key(K)、Value(V)。注意力分数的计算过程是:用我的 Query 去和所有 token 的 Key 做点积,得到一组权重,然后用这些权重对所有 token 的 Value 做加权求和。
用 NumPy 写出来就是:
# x: (batch, seq_len, d_model) # W_q, W_k, W_v: (d_model, d_k) Q = x @ W_q # (batch, seq_len, d_k) K = x @ W_k # (batch, seq_len, d_k) V = x @ W_v # (batch, seq_len, d_v) # 计算注意力分数 scores = Q @ K.transpose(0, 2, 1) # (batch, seq_len, seq_len) scores = scores / np.sqrt(d_k) # 缩放 # Softmax 归一化 weights = np.exp(scores - scores.max(axis=-1, keepdims=True)) weights = weights / weights.sum(axis=-1, keepdims=True) # 加权求和 output = weights @ V # (batch, seq_len, d_v)这段代码就是自注意力的全部核心。看起来不复杂,对吧?但有几个细节值得展开。
为什么要除以sqrt(d_k)?因为当d_k很大时,Q 和 K 的点积会变得很大,导致 softmax 的梯度极小,训练不动。除以sqrt(d_k)相当于把方差拉回到 1 附近,让 softmax 的输出更平滑。这个技巧叫“缩放点积注意力”(Scaled Dot-Product Attention)。
Softmax 为什么要减去最大值?这是数值稳定性的考虑。exp函数在输入很大时会溢出,减去最大值后,最大的输入变成 0,exp(0)=1,不会溢出。这个技巧在 NumPy 里手动实现 softmax 时一定要加上。
3.2 多头注意力:并行的智慧
单头注意力只能捕捉一种关系模式。多头注意力(Multi-Head Attention)的思路是:用多组不同的 Q、K、V 投影,让模型同时关注不同的特征子空间。
实现上,就是把d_model拆成num_heads份,每份独立做注意力,最后拼接起来再投影一次。用 NumPy 写:
# 假设 d_model = 512, num_heads = 8, d_head = 64 # x: (batch, seq_len, 512) # 一次性计算所有头的 Q、K、V Q = x @ W_q # (batch, seq_len, 512) K = x @ W_k V = x @ W_v # 拆分成多头 Q = Q.reshape(batch, seq_len, num_heads, d_head).transpose(0, 2, 1, 3) K = K.reshape(batch, seq_len, num_heads, d_head).transpose(0, 2, 1, 3) V = V.reshape(batch, seq_len, num_heads, d_head).transpose(0, 2, 1, 3) # 每个头独立做注意力 scores = Q @ K.transpose(0, 1, 3, 2) # (batch, num_heads, seq_len, seq_len) weights = softmax(scores / np.sqrt(d_head)) output = weights @ V # (batch, num_heads, seq_len, d_head) # 拼接多头 output = output.transpose(0, 2, 1, 3).reshape(batch, seq_len, d_model) output = output @ W_o # 最终投影这里的关键操作是transpose和reshape的配合。transpose(0, 2, 1, 3)把num_heads维度换到前面,让每个头的数据在内存里连续,方便后续的批量矩阵乘法。最后再换回来,用reshape把多头的结果拼回d_model。
我一开始看这段代码的时候,被 transpose 的参数绕晕了。后来我的方法是:在纸上画出每个维度的含义,然后一步步跟踪 shape 的变化。比如(batch, seq_len, num_heads, d_head)经过transpose(0, 2, 1, 3)变成(batch, num_heads, seq_len, d_head),这样就能清楚地知道每个维度代表什么。
3.3 位置编码:给模型一个“顺序感”
自注意力机制本身是位置无关的——把输入序列打乱,注意力的输出不变。但语言是有顺序的,所以需要位置编码(Positional Encoding)来注入位置信息。
原始 Transformer 用的是正弦位置编码:
def positional_encoding(seq_len, d_model): pos = np.arange(seq_len)[:, np.newaxis] # (seq_len, 1) i = np.arange(d_model)[np.newaxis, :] # (1, d_model) angle = pos / np.power(10000, (2 * (i // 2)) / d_model) encoding = np.zeros((seq_len, d_model)) encoding[:, 0::2] = np.sin(angle[:, 0::2]) encoding[:, 1::2] = np.cos(angle[:, 1::2]) return encoding这段代码用到了 NumPy 的广播和切片赋值。pos是(seq_len, 1),i是(1, d_model),相除之后广播成(seq_len, d_model)。然后偶数维度用 sin,奇数维度用 cos。
现在主流的大模型(如 LLaMA、GPT 系列)更多用旋转位置编码(RoPE)或可学习的位置编码。但不管哪种,核心思路是一样的:给每个位置一个独特的向量表示,让模型能区分不同位置的 token。
4. 从 NumPy 到 LLM 框架:工具链的演进
4.1 为什么需要专门的框架
用 NumPy 手写一个 Transformer 的前向传播,对于理解原理非常有帮助。但如果你真的要用它来训练模型,很快就会遇到瓶颈:
- 没有自动求导:你需要手动推导每个操作的梯度,工作量巨大且容易出错。
- 没有 GPU 加速:NumPy 默认在 CPU 上运行,训练大模型的速度无法接受。
- 没有优化器:SGD、Adam 等优化器需要自己实现。
所以实际工程中,我们会用 PyTorch、TensorFlow、JAX 等框架。这些框架提供了自动求导、GPU 加速、分布式训练等能力。但它们的底层运算逻辑,和 NumPy 是一脉相承的。
以 PyTorch 为例,把上面的 NumPy 代码翻译过去,几乎只需要把np换成torch,再加上requires_grad=True:
import torch Q = x @ W_q # 自动求导 scores = Q @ K.transpose(-2, -1) / torch.sqrt(torch.tensor(d_k)) weights = torch.softmax(scores, dim=-1) output = weights @ VPyTorch 的softmax已经内置了数值稳定性处理,不需要手动减最大值。transpose(-2, -1)也比 NumPy 的transpose(0, 2, 1)更直观——用负数索引表示“最后两个维度互换”。
4.2 张量并行与显存优化
当你试图训练一个真正的 LLM 时,会发现显存不够用。一个 7B 参数的模型,光是参数就占 28GB(FP32),加上梯度、优化器状态、激活值,轻松超过 100GB。这时候就需要各种优化技术:
- 混合精度训练:用 FP16 或 BF16 存储参数和计算,显存减半。
- 梯度累积:用小 batch 多次前向传播,累积梯度后再更新,模拟大 batch。
- 张量并行:把矩阵乘法拆到多张卡上,比如把
W_q按列切分。 - ZeRO 优化:把优化器状态、梯度、参数分片到多张卡上。
这些技术的底层,仍然是对张量 shape 的操作。比如张量并行里的列切分,就是把(d_model, d_model)的权重矩阵切成(d_model, d_model/num_gpus),每张卡算一部分,最后用all-reduce汇总。如果你理解 NumPy 的切片和拼接,这些概念就不难理解。
4.3 推理部署:ONNX 与量化
训练完之后,模型要部署到生产环境。这时候关注的重点从“训练速度”变成了“推理延迟”和“吞吐量”。常见的优化手段包括:
- ONNX 导出:把 PyTorch 模型转成 ONNX 格式,然后用 ONNX Runtime 推理,跨平台兼容性好。
- 量化:把 FP16 转成 INT8,模型体积缩小一半,推理速度提升,精度损失通常在可接受范围内。
- KV Cache:自回归生成时,缓存之前 token 的 Key 和 Value,避免重复计算。
KV Cache 是一个很好的例子,它本质上是一个 shape 操作:把之前计算的 K 和 V 存起来,每次只计算新 token 的 K 和 V,然后拼接。用 NumPy 的话说就是np.concatenate([cache, new_k], axis=1)。理解了这个,再看推理框架的代码就不会觉得陌生。
5. 常见问题与排查技巧实录
5.1 Shape 不匹配:最常见的报错
Shape 不匹配是 LLM 开发中最常见的错误。我整理了一个速查表:
| 报错信息 | 常见原因 | 解决方法 |
|---|---|---|
matmul: Input operand 1 has a mismatch | 矩阵乘法维度不对 | 检查a.shape[-1]是否等于b.shape[-2] |
could not broadcast | 广播规则不满足 | 从右往左对齐,确保维度相等或为 1 |
view size is not compatible | reshape 时元素总数不对 | 计算np.prod(shape)是否一致 |
expected 4D input | 卷积层输入维度不对 | 确认输入是(batch, channel, height, width) |
我的经验是:在写代码之前,先在纸上画出每个张量的 shape。比如输入是(batch, seq_len, d_model),经过 Q 投影后是(batch, seq_len, d_k),经过注意力后是(batch, seq_len, d_v)。把这条链路画清楚,写代码时就不容易出错。
5.2 NumPy 版本不匹配
ModuleNotFoundError: No module named 'numpy'或者版本冲突,是新手经常遇到的问题。我的建议是:
- 用虚拟环境:
python -m venv myenv,然后source myenv/bin/activate(Linux/Mac)或myenv\Scripts\activate(Windows)。 - 固定版本:在
requirements.txt里写numpy==1.24.0,避免自动升级到不兼容的版本。 - 检查依赖:
pip list | grep numpy看看实际安装的版本。
如果遇到numpy和pandas、scipy的版本冲突,可以用pip install --upgrade --force-reinstall强制重装,或者用conda管理环境,它的依赖解析更靠谱。
5.3 梯度消失与梯度爆炸
训练 Transformer 时,梯度问题很常见。表现是 loss 不下降,或者突然变成 NaN。排查思路:
- 检查学习率:太大容易爆炸,太小容易消失。可以试试 warmup + cosine decay。
- 检查初始化:权重初始化太小会导致梯度消失,太大容易爆炸。常用 Xavier 或 Kaiming 初始化。
- 检查 LayerNorm:LayerNorm 能稳定训练,但如果位置放错(比如放在残差连接之后),效果会打折扣。
- 梯度裁剪:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),防止梯度爆炸。
我踩过的一个坑是:忘记加 warmup。Transformer 训练初期,梯度很大,直接上大学习率容易发散。加上 warmup 之后,前几千步学习率从 0 线性增加到目标值,训练就稳定多了。
5.4 显存不足的排查与优化
显存不足(OOM)是训练大模型时的家常便饭。排查步骤:
- 打印显存占用:
torch.cuda.memory_allocated()看看当前用了多少。 - 减小 batch size:最直接的方法,但可能影响训练效果,可以用梯度累积补偿。
- 检查中间变量:有些中间变量没有及时释放,可以用
del删除,然后torch.cuda.empty_cache()。 - 用混合精度:
torch.cuda.amp自动混合精度,显存减半,速度提升。 - 检查模型并行:如果模型太大,单卡放不下,需要用张量并行或流水线并行。
我的经验是:先用小模型和小数据跑通流程,再逐步放大。一上来就上大模型,遇到 OOM 都不知道是哪里出了问题。小规模跑通之后,每一步的显存占用心里有数,放大时就知道该在哪里优化。
6. 学习路径与资源推荐
6.1 从 NumPy 到 Transformer 的路线图
如果你是从零开始,我建议按这个顺序走:
- NumPy 基础:数组创建、索引切片、广播、矩阵乘法、axis 操作。推荐官方教程和《Python 科学计算与数据科学应用》。
- 手写注意力机制:用 NumPy 实现单头和多头注意力,理解 Q、K、V 的含义。
- 手写完整 Transformer:包括位置编码、前馈网络、残差连接、LayerNorm。
- 迁移到 PyTorch:把 NumPy 代码翻译成 PyTorch,加上自动求导和 GPU 支持。
- 训练一个小模型:比如字符级语言模型,数据集用莎士比亚文本,感受完整的训练流程。
- 阅读开源代码:看 HuggingFace Transformers、nanoGPT 等项目的实现,学习工程技巧。
这个路线的好处是:每一步都有可运行的代码,每一步都能看到结果。不会出现“看了一堆论文但还是不会写代码”的情况。
6.2 值得反复看的资料
- The Illustrated Transformer:用图解的方式讲注意力机制,非常适合入门。
- nanoGPT:Karpathy 写的极简 GPT 实现,代码量少,适合精读。
- HuggingFace Transformers:工业级实现,代码量大但注释详细,适合查阅。
- LLM Wiki:社区维护的知识库,涵盖 LLM 的各个方面,适合系统学习。
我个人的习惯是:先看图解建立直觉,再看代码验证细节,最后自己动手写一遍。光看不动手,很容易产生“我懂了”的错觉,一写代码就卡壳。
6.3 一些实用的调试技巧
- 打印 shape:在关键步骤后加
print(x.shape),确认维度符合预期。 - 用小数据:用
batch=2, seq_len=4, d_model=8这样的小数据调试,出错时容易定位。 - 单元测试:对每个模块写测试,比如注意力的输出 shape 是否正确,softmax 的权重和是否为 1。
- 可视化:用 matplotlib 画出注意力权重,直观感受模型在关注什么。
- 对比实现:自己写的和 PyTorch 内置的对比,确保数值一致。
我在实际使用中发现,调试 LLM 代码最有效的方法,是把大问题拆成小问题。比如注意力机制不工作,先单独测试 Q、K、V 的投影,再测试分数计算,再测试 softmax,一步步缩小范围。不要一上来就怀疑整个模型有问题。
最后再分享一个小技巧:如果你在学 Transformer 的时候觉得公式太难,不妨先把公式放一边,用 NumPy 把代码写出来,跑通之后再回头看公式,会发现很多符号其实就是在描述你写过的那些操作。代码是具体的,公式是抽象的,从具体到抽象,理解起来会顺畅很多。