☰
从NumPy到LLM:程序员如何用数组思维理解大模型
2026/9/30 5:08:44 网站建设 项目流程

1. 从一行 NumPy 说起:为什么程序员要啃 LLM 这块硬骨头

刚入行那会儿,我对 NumPy 的全部认知就是“一个比 list 快很多的数组库”。写个np.array([1,2,3]),做个矩阵乘法,顶多再算个均值方差,感觉已经摸到了天花板。直到后来真正开始接触大模型,才发现自己当年那点 NumPy 功底,其实正是理解 LLM 的第一块敲门砖。你可能会问,NumPy 和大模型之间隔着十万八千里吧?一个是科学计算的基础库,一个是动辄几百亿参数的庞然大物,能有什么关系?关系大了去了。

大模型说到底就是一堆张量在流动。你输入的每一句话,先被切成 token,再映射成向量,然后经过几十层 Transformer 的加工,每一层里全是矩阵乘法、加法、归一化、激活函数。这些操作在底层实现上,和你在 NumPy 里做的np.dot、np.matmul、np.exp没有本质区别,只是规模从几十个元素变成了几百万个元素,并且跑在了 GPU 上。所以我的学习路径很明确:先把 NumPy 里那些数组操作、广播机制、shape 变换彻底搞明白,再去理解 Transformer 的架构,最后动手跑通一个小规模的 LLM,整个过程会顺畅很多。

这篇笔记适合谁看?如果你是 Python 开发者,用过 NumPy 但没碰过深度学习;或者你尝试过读 Transformer 论文但被各种矩阵维度绕晕了;又或者你想自己跑一个开源大模型但不知道从哪下手,那这篇内容应该能帮到你。我不会堆砌公式,也不会一上来就甩一堆论文链接,而是按照我自己踩过的坑,从最基础的数组操作开始,一步步把 LLM 的核心逻辑拆开讲清楚。你不需要有 GPU 集群,一台普通笔记本就能跟着走完大部分流程。

2. NumPy 基础回顾:那些你以为是“常识”但可能没吃透的东西

2.1 从 list 到 ndarray,快在哪里

很多人知道 NumPy 比 Python list 快,但说不清楚为什么快。我一开始也这样,直到有次面试被问到“NumPy 和 list 比快在哪”,才认真去查了底层实现。简单说,Python list 存的是对象的指针,每个元素都是一个完整的 Python 对象,有类型信息、引用计数、内存地址。你做一个循环加法,每次都要做类型检查、拆箱、运算、装箱,开销巨大。而 NumPy 的 ndarray 是一块连续的内存,里面存的是同一种数据类型的原始值,比如 float64 就是 8 个字节挨着排。做加法时,底层直接用 C 循环遍历这块内存,没有 Python 解释器的介入,速度自然快几十倍甚至上百倍。

你可以自己测一下:创建一个一百万元素的 list 和一个一百万元素的 ndarray,分别做求和,用timeit跑一下,差距非常直观。我实测下来,NumPy 的求和通常比纯 Python 循环快 50 到 100 倍,如果用了向量化操作而不是循环,差距还会更大。这个“向量化”思维,就是后面理解大模型计算的关键。大模型里没有人在 Python 层面写 for 循环去算每个神经元的输出,全是矩阵运算,一次算一整层。

2.2 Shape 和广播机制:大模型里最容易翻车的地方

Shape 这个概念,刚开始学 NumPy 的时候觉得很简单,不就是数组的维度吗?但到了 Transformer 里,shape 变换能把你绕到怀疑人生。我见过太多新手在跑模型时报错RuntimeError: mat1 and mat2 shapes cannot be multiplied,根源就是没搞清楚 shape 怎么对齐。

NumPy 的广播机制是这样的:当两个数组做运算时,如果 shape 不完全一致,NumPy 会尝试从右往左逐维比较,维度相等或其中一个为 1 时才能广播,否则报错。举个例子,一个 shape 为(3, 4)的矩阵和一个 shape 为(4,)的向量相加,向量会被广播成(3, 4),每一行都加上这个向量。这个机制在 Transformer 里到处都是,比如给每个 token 的 embedding 加上位置编码,就是用一个(seq_len, d_model)的矩阵加上一个(seq_len, d_model)的位置编码矩阵,shape 完全一致,直接相加。

但到了注意力机制里,事情就复杂了。Query、Key、Value 三个矩阵的 shape 变换,加上多头注意力的拆分和合并,稍不注意就会搞错维度。我的经验是,每次写代码之前,先在纸上把每一步的 shape 写出来,标注清楚哪个维度是 batch size,哪个是序列长度,哪个是特征维度。这个习惯帮我省了无数调试时间。

2.3 矩阵运算:从np.dot到np.matmul的坑

NumPy 里做矩阵乘法有好几个函数,np.dot、np.matmul、@运算符,还有np.multiply做逐元素乘法。新手很容易搞混。np.dot对于二维数组就是矩阵乘法,但对于高维数组,它的行为是“对最后两个维度做矩阵乘法,前面的维度做外积”,非常反直觉。np.matmul和@的行为更一致:它把数组当成一批矩阵,对最后两个维度做矩阵乘法,前面的维度是 batch 维度。所以在写 Transformer 代码时,我强烈建议统一用@或np.matmul,避免np.dot带来的意外。

还有一个常见错误是混淆了矩阵乘法和逐元素乘法。比如在计算注意力分数时,Q @ K.T是矩阵乘法,得到的是(seq_len, seq_len)的分数矩阵;而如果你不小心写成了Q * K.T,那就是逐元素乘法,shape 对不上直接报错,就算对上了结果也完全不对。这种错误在调试时很难发现,因为代码不报错,只是结果很差。我的做法是,在关键步骤后面打印 shape,确认每一步都符合预期。

3. 从 NumPy 到 Tensor:数据表示的统一视角

3.1 Tensor 到底是什么,和 ndarray 有什么区别

PyTorch 的 Tensor 和 NumPy 的 ndarray 在概念上几乎一样,都是多维数组。区别在于 Tensor 可以跑在 GPU 上,支持自动求导,并且有专门的深度学习算子优化。你可以把 Tensor 理解成“加强版的 ndarray”。事实上,你可以用torch.from_numpy()把一个 ndarray 直接转成 Tensor,内存是共享的,改一个另一个也会变。反过来,用.numpy()可以把 Tensor 转回 ndarray,但前提是它不在 GPU 上,也不需要梯度。

我刚开始学的时候,喜欢在 NumPy 里把数据预处理完,再转成 Tensor 喂给模型。后来发现,其实很多操作直接在 Tensor 上做更方便,尤其是涉及到 GPU 加速的时候。比如归一化、切片、拼接,Tensor 的 API 和 NumPy 几乎一一对应,迁移成本很低。所以如果你 NumPy 用得熟,上手 PyTorch 会非常快。

3.2 Shape 在 Transformer 里的具体含义

在 Transformer 里,最常见的输入 shape 是(batch_size, seq_len, d_model)。这三个维度分别代表:一次处理多少条样本,每条样本有多少个 token,每个 token 用多少维的向量表示。比如 batch_size=32,seq_len=128,d_model=512,意思就是一次喂给模型 32 句话,每句话 128 个 token,每个 token 是一个 512 维的向量。

这个 shape 会随着网络层的变化而变化。经过多头注意力后,d_model 会被拆成 num_heads 个头,每个头的维度是 d_model // num_heads。比如 d_model=512,num_heads=8,每个头就是 64 维。这时候 shape 会变成(batch_size, num_heads, seq_len, head_dim)。计算完注意力后再合并回去,变回(batch_size, seq_len, d_model)。这个拆分和合并的过程,是 Transformer 代码里最容易写错的地方之一。我建议你手动画一遍这个 shape 变换的流程图,比看十遍代码都管用。

3.3 广播机制在注意力计算中的应用

注意力机制的核心公式是Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V。这里面每一步都涉及 shape 变换和广播。Q的 shape 是(batch_size, num_heads, seq_len, head_dim),K^T的 shape 是(batch_size, num_heads, head_dim, seq_len),两者做矩阵乘法得到(batch_size, num_heads, seq_len, seq_len)。然后除以sqrt(d_k),这里d_k是一个标量,广播到所有元素。接着做 softmax,在最后一个维度上归一化。最后和V做矩阵乘法,V的 shape 是(batch_size, num_heads, seq_len, head_dim),得到(batch_size, num_heads, seq_len, head_dim)。

如果你用 NumPy 手动实现一遍这个计算,会对每一步的 shape 有非常深刻的理解。我当初就是用一个 2x2 的小矩阵,手动算了一遍注意力,才真正搞明白 Q、K、V 到底在干什么。那个“key 我是谁、query 我在找什么、value 我能提供什么”的比喻,虽然有点粗糙,但确实帮助记忆。

4. Transformer 架构拆解:从论文到可运行的代码

4.1 自注意力机制:Q、K、V 到底在算什么

自注意力机制的本质是:对于序列中的每一个 token,它需要看看其他所有 token,决定从谁那里获取信息。Query 代表当前 token 在“询问”什么,Key 代表每个 token 能“回答”什么,Value 代表每个 token 实际“提供”的信息。计算过程是:用 Query 和所有 Key 做点积,得到相关性分数,softmax 归一化后作为权重,对 Value 加权求和。

用 NumPy 实现的话,假设输入X的 shape 是(seq_len, d_model),三个权重矩阵W_q、W_k、W_v的 shape 都是(d_model, d_k)。那么Q = X @ W_q,K = X @ W_k,V = X @ W_v,shape 都是(seq_len, d_k)。然后scores = Q @ K.T / sqrt(d_k),shape 是(seq_len, seq_len)。attention_weights = softmax(scores),最后output = attention_weights @ V,shape 是(seq_len, d_k)。这就是最基础的单头注意力。

多头注意力就是把这个过程并行做多次,每次用不同的W_q、W_k、W_v,然后把结果拼接起来,再过一个线性层。这样做的好处是,不同的头可以关注不同的模式,比如有的头关注语法关系,有的头关注语义相似度。

4.2 位置编码:为什么需要它,怎么实现

Transformer 本身没有循环结构,也没有卷积结构,它对序列的顺序是无感知的。如果你把输入序列打乱,自注意力的输出只会跟着打乱,不会意识到顺序变了。所以需要位置编码,给每个位置一个独特的向量,加到 token 的 embedding 上。

原始论文用的是正弦余弦位置编码,公式是PE(pos, 2i) = sin(pos / 10000^(2i/d_model)),PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))。这个设计很巧妙,不同位置的编码可以通过线性变换相互表示,模型能学到相对位置关系。用 NumPy 实现的话,就是创建一个(seq_len, d_model)的矩阵,然后按公式填充。我建议你手动实现一遍,感受一下不同维度的频率变化。

现在很多模型改用可学习的位置编码,就是直接用一个(max_seq_len, d_model)的矩阵,让模型自己学。效果差不多,但实现更简单。不过理解正弦编码的原理,对理解位置信息的本质很有帮助。

4.3 残差连接和层归一化:让深层网络能训练

残差连接就是output = x + sublayer(x),把输入直接加到输出上。这个操作看起来简单,但它是深层网络能训练的关键。没有残差连接,梯度在反向传播时会逐层衰减,几十层之后基本就消失了。有了残差连接,梯度可以走“捷径”直接回传,训练起来稳定很多。

层归一化是对每个样本的最后一维做归一化,均值为 0,方差为 1,然后再缩放和平移。和 BatchNorm 不同,LayerNorm 不依赖 batch 维度,所以对 batch size 不敏感,适合变长序列。在 Transformer 里,通常是LayerNorm(x + sublayer(x)),先残差再归一化。也有先归一化再残差的变体,叫 Pre-LN,训练更稳定,现在很多模型都用这种。

用 NumPy 实现 LayerNorm 很简单:mean = x.mean(axis=-1, keepdims=True),std = x.std(axis=-1, keepdims=True),output = (x - mean) / (std + eps) * gamma + beta。其中gamma和beta是可学习的参数。注意eps要加在标准差上,防止除零。

4.4 前馈网络:Transformer 里的“思考”层

每个 Transformer 层里,除了注意力,还有一个前馈网络。它就是一个两层的全连接网络,中间有 ReLU 激活:FFN(x) = max(0, x @ W1 + b1) @ W2 + b2。通常W1会把维度扩大 4 倍,W2再缩回去。比如 d_model=512,中间层就是 2048。

这个前馈网络的作用,可以理解为对注意力收集到的信息进行“加工”和“存储”。注意力负责从序列中提取信息,前馈网络负责对这些信息做非线性变换。有研究表明,前馈网络里存储了大量的知识,相当于模型的一个“记忆库”。

用 NumPy 实现就是两个矩阵乘法加一个 ReLU。ReLU 就是np.maximum(0, x),非常简单。但要注意,W1和W2的 shape 要匹配,W1是(d_model, d_ff),W2是(d_ff, d_model)。

5. 动手跑一个迷你 LLM:从 NumPy 到 PyTorch

5.1 环境准备和依赖安装

先说环境。Python 版本建议 3.9 以上,太老的版本有些库不支持。NumPy 安装很简单,pip install numpy就行。如果你遇到ModuleNotFoundError: No module named 'numpy',要么是没装,要么是装到了别的 Python 环境里。我建议用虚拟环境,python -m venv llm-env,然后激活,再装依赖。这样不会污染系统环境,也方便管理版本。

PyTorch 的安装稍微麻烦一点,因为要选对 CUDA 版本。如果你没有 GPU,直接装 CPU 版就行:pip install torch torchvision torchaudio。有 GPU 的话,去 PyTorch 官网查对应的安装命令。我踩过的坑是,NumPy 版本和 PyTorch 版本不匹配,导致torch.from_numpy()报错。解决办法是升级 NumPy 到最新版,或者降级 PyTorch 到兼容版本。一般来说,PyTorch 1.10 以上配 NumPy 1.21 以上都没问题。

5.2 用 NumPy 实现一个单头注意力

先不急着上 PyTorch,用 NumPy 手动实现一个单头注意力,能帮你彻底理解计算过程。假设输入X的 shape 是(seq_len, d_model),我们初始化三个权重矩阵:

import numpy as np np.random.seed(42) seq_len = 4 d_model = 8 d_k = 4 X = np.random.randn(seq_len, d_model) W_q = np.random.randn(d_model, d_k) W_k = np.random.randn(d_model, d_k) W_v = np.random.randn(d_model, d_k) Q = X @ W_q K = X @ W_k V = X @ W_v scores = Q @ K.T / np.sqrt(d_k) attention_weights = np.exp(scores) / np.exp(scores).sum(axis=-1, keepdims=True) output = attention_weights @ V print("Q shape:", Q.shape) print("K shape:", K.shape) print("V shape:", V.shape) print("scores shape:", scores.shape) print("attention_weights shape:", attention_weights.shape) print("output shape:", output.shape)

跑一遍,看看每个变量的 shape,再手动算一下scores的第一个元素,验证一下是不是Q[0]和K[0]的点积除以sqrt(d_k)。这个过程能帮你把注意力的计算逻辑刻在脑子里。

5.3 用 PyTorch 搭建一个完整的 Transformer 层

理解了 NumPy 版本后,用 PyTorch 实现就简单多了,因为很多操作都有现成的 API。下面是一个完整的 Transformer 层:

import torch import torch.nn as nn class TransformerLayer(nn.Module): def __init__(self, d_model, num_heads, d_ff, dropout=0.1): super().__init__() self.attention = nn.MultiheadAttention(d_model, num_heads, dropout=dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model) ) self.dropout = nn.Dropout(dropout) def forward(self, x): attn_output, _ = self.attention(x, x, x) x = self.norm1(x + self.dropout(attn_output)) ffn_output = self.ffn(x) x = self.norm2(x + self.dropout(ffn_output)) return x

注意nn.MultiheadAttention的输入默认是(seq_len, batch_size, d_model),和 NumPy 版本的(batch_size, seq_len, d_model)不一样。如果你习惯后者,可以在输入前用transpose转一下。这个 shape 顺序的差异,是很多人从 NumPy 转到 PyTorch 时第一个踩的坑。

5.4 训练一个小型语言模型

有了 Transformer 层,就可以堆一个几层的小模型,在字符级语言建模任务上训练。数据集可以用莎士比亚的文本,或者任何你喜欢的文本。任务很简单:给模型一个字符序列,让它预测下一个字符。损失函数用交叉熵,优化器用 Adam。

训练循环大概是这样的:把文本转成字符索引,切成长度为 seq_len 的片段,输入模型,得到每个位置的输出,和真实的下一个字符做交叉熵。反向传播,更新参数。跑几千步之后,模型就能生成看起来有点意思的文本了。虽然规模很小,但整个流程和大模型训练是一样的,只是数据量和参数量不同。

我建议你在这个小模型上多调调参数,比如改改层数、头数、学习率,观察损失曲线的变化。这种“手感”对后面理解大模型的训练动态很有帮助。

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

6.1 Shape 不匹配:最常见的报错及解决思路

Shape 不匹配是深度学习里最高频的报错,没有之一。典型错误信息是RuntimeError: mat1 and mat2 shapes cannot be multiplied或者ValueError: operands could not be broadcast together。排查思路很简单:在报错的那一行前面,把涉及到的所有张量的 shape 打印出来,逐个对照。

比如矩阵乘法A @ B,要求A的最后一维等于B的倒数第二维。如果A是(32, 128, 512),B是(512, 768),那没问题,结果是(32, 128, 768)。但如果B是(256, 768),就会报错。这时候要么改B的 shape,要么在A和B之间加一个线性层做维度变换。

广播错误通常是两个数组的 shape 从右往左比较时,某一维既不相等也不为 1。比如(3, 4)和(3, 5)相加就会报错。解决办法是reshape或者unsqueeze补维度。

6.2 梯度消失和梯度爆炸:现象、原因和应对

梯度消失的表现是,模型训练一段时间后,损失不再下降,参数几乎不变。原因是反向传播时梯度逐层相乘,如果每层的梯度都小于 1,几十层之后就会趋近于 0。梯度爆炸则相反,损失突然变成 NaN,原因是梯度乘积太大,数值溢出。

应对方法有几个:一是用残差连接,让梯度有捷径可走;二是用 LayerNorm,把每层的输出归一化,稳定数值范围;三是用梯度裁剪,把梯度的范数限制在一个阈值内;四是选合适的初始化方法,比如 Xavier 或 Kaiming 初始化。我在训练小模型时,通常加上梯度裁剪,torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),效果很明显。

6.3 显存不够用:从 batch size 到混合精度

跑大模型最头疼的就是显存不够。报错通常是CUDA out of memory。解决办法按优先级排:第一,减小 batch size,这是最直接的;第二,用梯度累积,把多个小 batch 的梯度累加起来再更新,等效于大 batch;第三,用混合精度训练,torch.cuda.amp,把部分计算转成 float16,显存占用能降一半左右;第四,用梯度检查点,牺牲计算时间换显存;第五,如果还不行,就只能换更大的显卡或者用模型并行。

我自己的经验是,先从小 batch size 开始跑通,再逐步增大,找到显存的上限。同时用torch.cuda.memory_summary()看看显存都花在哪了,有时候是中间激活值占了大头,这时候梯度检查点就很管用。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
ModuleNotFoundError: No module named 'numpy'未安装或环境不对pip list查看pip install numpy或激活正确环境
mat1 and mat2 shapes cannot be multiplied矩阵维度不匹配打印两个矩阵的 shape调整维度或加线性层
损失变成 NaN梯度爆炸或学习率太大打印梯度范数梯度裁剪、降低学习率
显存不足batch size 太大或模型太大torch.cuda.memory_summary()减小 batch、混合精度、梯度检查点
模型不收敛学习率不当或初始化不好观察损失曲线调学习率、换初始化方法
NumPy 版本不匹配版本冲突pip show numpy升级或降级到兼容版本

7. 从迷你模型到 LLM:下一步可以怎么走

跑通迷你模型之后,你对 Transformer 的整个数据流应该已经有了直观感受。接下来如果想继续深入,有几个方向可以选。一是读开源大模型的代码,比如 LLaMA、GPT-NeoX,看看工业级实现和教学实现有什么区别,比如 KV Cache、旋转位置编码、分组查询注意力这些优化技巧。二是用 Hugging Face 的 Transformers 库加载预训练模型,做推理和微调,感受一下真正的 LLM 能力。三是研究 LLM 的应用层,比如 RAG、Agent、知识库,这些方向对算力要求低,但工程复杂度高,适合开发者切入。

我个人觉得,从 NumPy 到 Transformer 再到 LLM,这条路径最大的价值不是让你成为算法专家,而是让你对“大模型到底在干什么”有一个祛魅的理解。你知道它不是什么魔法,就是一堆矩阵运算和梯度下降,只是规模大到了产生质变的程度。有了这个认知,再去用 LLM 做应用、做产品,心里会踏实很多,遇到问题也知道从哪下手排查。

最后分享一个小技巧:如果你在调试 Transformer 代码时卡住了,不妨把序列长度设为 2,模型维度设为 4,头数设为 1,然后用 NumPy 手动算一遍前向传播,再和 PyTorch 的输出对比。如果两者一致,说明你的理解是对的;如果不一致,逐层对比,很快就能找到问题。这个“最小复现”的思路,是我调试所有深度学习代码的万能钥匙。

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

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

立即咨询