1. 动手之前,先把LLaMA2的技术账算清楚
如果你打算“动手实现一个LLaMA2大模型”,我建议先放下“必须训出70B”的执念。真正值得做的是把LLaMA2的核心结构、数据流、训练流程和推理细节,在一套小规模但五脏俱全的代码里完整跑通。这篇内容就是我实践一个迷你LLaMA2复现项目的总结,面向想深入理解大模型内部机制的开发者:你不需要先有多少张GPU,只要有一颗愿意把每个算子拆开看的耐心。
我的目标很简单:从零构建一个约1.3B参数的LLaMA2类模型,用中文语料训练,最后能生成通顺的短文本。注意,这里并不是真的去复刻原版70B,而是把LLaMA2最关键的设计全部保留:RMSNorm、RoPE旋转位置编码、SwiGLU激活、GQA分组查询注意力、KV Cache推理优化。为什么选这条路线?因为“复刻70B”的结果大概率只是下载权重然后做推理,投入产出比很低;而把规模缩小到1B以下,你才有机会亲眼看到每一层参数的形状怎么流动、训练loss是怎么一步步降下来的。
1.1 为什么要用“缩小版复现”来学习
看10篇LLaMA2结构解析,不如自己手搓一次模型定义。大模型领域的实践经验非常依赖“亲手跑通”,只看别人代码和亲自动手的差距,可以大到像读菜谱和真正做饭之间的差距。一个100M~1.3B规模的模型,在一张24G显存的消费级显卡上就能完成短序列训练,能够覆盖RMSNorm、RoPE、GQA、SwiGLU、KV Cache这些核心概念。等结构跑通后,再把规模扩大,分布式训练、优化器分片等难题才会真正暴露出来。
做这个项目时,我把目标拆成了几个层次:第一层,模型能用PyTorch定义出来,前向传播没有bug;第二层,用中文语料训练若干步,loss出现肉眼可见的下降;第三层,推理阶段能生成像样的文本,而不是循环输出重复token;第四层,在有限显存条件下跑通多卡方案。如果你也能按这个顺序推进,那么对LLaMA2的理解会扎实很多。
1.2 LLaMA2对标准Transformer动了哪几刀
LLaMA2并不是凭空发明了一种全新架构,它还是Transformer的解码器结构,但在几个关键位置上做了“手术”。我把这些改动整理成一张速查表,方便对照后续代码:
| 组件 | 标准Transformer的做法 | LLaMA2的做法 | 核心收益 |
|---|---|---|---|
| 归一化层 | Post-Norm,通常用LayerNorm | Pre-Norm,用RMSNorm | 训练更稳定,省去均值计算 |
| 激活函数 | ReLU或GeLU | SwiGLU(SiLU门控线性单元) | 相同参数下表达力更强 |
| 位置编码 | 绝对位置编码或正弦编码 | RoPE旋转位置编码 | 更好的外推能力,天然适合注意力机制 |
| 注意力头 | 多头注意力MHA | 分组查询注意力GQA | 显著减少KV Cache显存 |
| 上下文长度 | 通常较短 | 训练时可达4K甚至更长 | 长篇语义建模更强 |
这几个“手术”背后都有一个共同的理念:在相同参数量和算力预算下,尽量把资源花在更有信息量的地方。RMSNorm去掉均值统计,减少了计算量;SwiGLU让前馈网络的门控信息更丰富;RoPE把位置信息直接编码进向量旋转角度;GQA则牺牲一点点注意力头的独立性,换来KV缓存的大幅缩减。
如果你原先只在类库的抽象API层面上调用过Transformer,直接跳到LLaMA2会觉得很多参数很难理解。没关系,下面我会带着你从模型核心代码开始,逐段打通。
2. PyTorch从零手写模型核心:RMSNorm、旋转编码与自注意力
下面这段代码是我实际项目中经过简化后的结构,变量命名尽量贴近PyTorch生态习惯。我不会一口气贴出完整的长文件,而是把几个关键子模块单独拆开讲,这样更容易对照理解。
2.1 RMSNorm:去掉均值计算的后LayerNorm
RMSNorm的想法很直接:既然LayerNorm要算均值又要算方差,那能不能不要均值,直接对每个token的向量做“均方根归一化”?
import torch import torch.nn as nn import torch.nn.functional as F class RMSNorm(nn.Module): def __init__(self, dim, eps=1e-6): super().__init__() self.eps = eps self.weight = nn.Parameter(torch.ones(dim)) def forward(self, x): rms = torch.sqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) return x / rms * self.weight注意这里的eps放在根号里面,数值稳定性更好。我在刚开始实现时把eps放在根号外面,短序列训练还好,长序列一跑就频繁出现NaN,后来排查了半天才发现是这个细节。
为什么去掉均值之后还能稳定训练?原因是Transformer每个子层后面都跟随残差连接,残差分支本身提供了一定的均值偏移补偿能力。RMSNorm相比LayerNorm少了统计均值这一步,计算上更轻量,在很多大模型实验中被证明效果几乎无损。这个weight是可以学习的,相当于在每个维度上做了缩放。
2.2 旋转位置编码RoPE:给每个维度一个“转角”
RoPE的核心思想是把位置信息编码成向量的旋转角度。每个token的查询向量和键向量,会按照它们的位置做不同角度的旋转,这样点积的结果就自然携带了相对位置信息。实现时,把高维向量按每两个维度为一组做旋转:
def precompute_rotary_freqs(dim, seq_len, base=10000.0, device="cpu"): inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, device=device).float() / dim)) t = torch.arange(seq_len, device=device).float() freqs = torch.einsum("i,j->ij", t, inv_freq) # [seq_len, dim/2] return freqs.cos(), freqs.sin() def apply_rotary(x, cos, sin): # x: [batch, heads, seq_len, head_dim] x = x.reshape(*x.shape[:-1], -1, 2) # [..., dim/2, 2] x1, x2 = x[..., 0], x[..., 1] cos = cos.unsqueeze(0).unsqueeze(0) # [1, 1, seq_len, dim/2] sin = sin.unsqueeze(0).unsqueeze(0) out1 = x1 * cos - x2 * sin out2 = x1 * sin + x2 * cos return torch.stack([out1, out2], dim=-1).flatten(-2)这段代码我一开始总是写不对,干脆直接使用“把向量reshape成若干二维坐标对,再逐对旋转”的思路。关键在于cos和sin的形状要和x匹配成广播友好的结构。
比公式更重要的直觉是:RoPE对同一位置的所有查询、键向量做相同角度的旋转,那么位置差距越大的两个向量,旋转角度差异越大,点积就越难对齐。这正好模拟了“相对位置”的语义,也为后来的长度外推技术(比如NTK缩放)留了窗口。
2.3 分组查询注意力GQA:少算一些KV
标准多头注意力MHA要求每个注意力头都有独立的K、V线性层,KV Cache的大小随头数线性增长。LLaMA2在大模型版本里改用GQA,让多个查询头共享同一组K、V头,成倍减少KV缓存和计算量。
def repeat_kv(x, n_rep): bsz, num_kv, seq_len, head_dim = x.shape if n_rep == 1: return x return x[:, :, None, :, :].expand(bsz, num_kv, n_rep, seq_len, head_dim).reshape( bsz, num_kv * n_rep, seq_len, head_dim ) class CausalAttention(nn.Module): def __init__(self, hidden_size, num_heads, num_kv_heads): super().__init__() self.num_heads = num_heads self.num_kv_heads = num_kv_heads self.head_dim = hidden_size // num_heads self.n_rep = num_heads // num_kv_heads self.q_proj = nn.Linear(hidden_size, hidden_size, bias=False) self.kv_proj = nn.Linear(hidden_size, self.num_kv_heads * self.head_dim * 2, bias=False) self.o_proj = nn.Linear(hidden_size, hidden_size, bias=False) def forward(self, x, cos, sin): bsz, seq_len, _ = x.shape q = self.q_proj(x).view(bsz, seq_len, self.num_heads, self.head_dim).transpose(1, 2) k, v = self.kv_proj(x).view(bsz, seq_len, self.num_kv_heads, self.head_dim * 2).split( self.head_dim, dim=-1 ) k, v = k.transpose(1, 2), v.transpose(1, 2) q = apply_rotary(q, cos[:seq_len], sin[:seq_len]) k = apply_rotary(k, cos[:seq_len], sin[:seq_len]) k = repeat_kv(k, self.n_rep) v = repeat_kv(v, self.n_rep) attn_weight = q @ k.transpose(-2, -1) / (self.head_dim ** 0.5) mask = torch.tril(torch.ones(seq_len, seq_len, device=x.device)).view(1, 1, seq_len, seq_len) attn_weight = attn_weight.masked_fill(mask == 0, float("-inf")) attn_weight = F.softmax(attn_weight, dim=-1) out = attn_weight @ v out = out.transpose(1, 2).reshape(bsz, seq_len, -1) return self.o_proj(out)GQA里还有个容易误解的repeat_kv操作:共享K、V头之后,需要把K、V复制到和查询头一样的数量,方便做矩阵乘。实际推理时根本不复制,因为KV Cache里只需保存一份K、V,查询头各自来取用即可。这是GQA节省显存的关键点。我记得最初实现时直接复制K、V,推理阶段内存暴涨,后来才明白复制操作只是训练阶段为了方便,推理时应直接复用。
2.4 SwiGLU前馈层与Decoder块的组装
LLaMA2的前馈网络不是简单的“线性+激活+线性”,而是用SiLU对gate分支做门控,再与up分支逐元素相乘,最后经过down投影:
class FeedForward(nn.Module): def __init__(self, hidden_size, intermediate_size): super().__init__() self.gate = nn.Linear(hidden_size, intermediate_size, bias=False) self.up = nn.Linear(hidden_size, intermediate_size, bias=False) self.down = nn.Linear(intermediate_size, hidden_size, bias=False) def forward(self, x): return self.down(F.silu(self.gate(x)) * self.up(x))E大模型里SwiGLU的中间维度一般不是简单4倍,而是int(4 * hidden_size * 2 / 3)附近,再往上取整到某个倍数,具体值可以从对应规模的配置表中找到。小规模复现时直接取hidden_size * 4也能跑。Decoder层组装时,就是在残差连接中交替放置CausalAttention和FeedForward,每一层都在输入进入子层前先做RMSNorm。
当把所有Layer组装成完整模型后,先跑一次前向,检查logits的形状是否为[batch, seq_len, vocab_size],这一步能过滤掉绝大多数维度错误。我在这个阶段踩过不少坑,最典型的就是Q、K、V头数的乘积和hidden_size对不上,导致view时直接报错。建议在代码里加断言:q.shape == (bsz, self.num_heads, seq_len, self.head_dim)。
3. 让模型跑起来:词表、数据流与训练循环的实现细节
模型结构搭好,只是完成了第一步。真正让LLaMA2“活过来”的是数据和训练循环。这一节我用自己的中文小语料实践来说,训练一个中文版的迷你模型。
3.1 中文场景下分词器和词表的选择
中文大模型的第一步往往是确定tokenizer。英文分词器对中文并不友好:一个汉字被拆成好几段字节,序列长度成倍膨胀,训练效率很低。实践中较好的做法是在中文语料上重新训练一个BPE分词器,词表大小从8K到32K不等。
我最初直接使用了通用开源tokenizer,词表12K,在医疗问答训练集上效果还行,但换到网络小说语料时,很多常用词汇被切得稀碎。后来我在自己的语料上继续训练BPE,把常见词、标点、数字片段统统合并成词,训练速度肉眼可见地提升。如果你不想重新训练tokenizer,至少也要在现有tokenizer基础上加Special Token,不要让文本中频繁出现未知词。
词表大小的选择会影响模型的记忆容量和训练速度。8K词表在小规模实验里手感最舒服,模型很快就能记住常用词;32K词表表达能力更强,但对一个百M级模型来说,输出层占了大量参数,导致训练开销变大。我的建议是:小规模验证阶段先用16K,跑通流程后再考虑扩展。
3.2 构造训练样本:用“块打包”而不是简单拼接
语言模型训练本质上是“根据前文预测下一个token”。构造训练样本时,通常会把一个语料文档切成长度为block_size的块,相邻块之间首尾相接,这样能最大化利用每个token,减少padding浪费。
我实际使用的数据流处理逻辑是:
def build_blocked_dataset(text, tokenizer, block_size=512): tokens = tokenizer.encode(text) blocks = [] for i in range(0, len(tokens) - block_size - 1, block_size): block = tokens[i: i + block_size + 1] blocks.append(block) return blocks这里的关键是把每个块切成长度block_size + 1,训练时前block_size个token作为输入,后移一位的block_size个token作为标签。这样每个block能贡献block_size个有效的监督信号,而不是浪费最后一位。另一个需要注意的坑是跨文档的attention泄漏:如果简单地把不同文档首尾相接,模型会在预测时“偷看”不属于当前文档的token。严谨一点的做法是先给文档首尾添加Special Token,再按块切分时记录每条样本所在的文档范围,在注意力掩码上把跨文档的位置置为-inf。小型实验里如果文档很长,偷看问题没那么明显,但这属于迟早要面对的工程细节。
中文语料还有一个常见预处理:清洗重复段落、过滤广告和无意义符号、按标题或句号切分大文件。文本清洗看似不起眼,但对最终生成质量影响极大。我观察到,语料里残留的URL和乱码,会让模型在生成文本时突然崩出一长串符号。
3.3 训练循环、混合精度与loss观察
训练循环我采用标准的自回归语言建模流程:
model.train() optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) scaler = torch.cuda.amp.GradScaler() for step, batch in enumerate(train_loader): input_ids = batch["input_ids"].to(device) labels = batch["labels"].to(device) with torch.autocast("cuda", dtype=torch.bfloat16): logits = model(input_ids) # [batch, seq_len, vocab_size] loss = F.cross_entropy( logits.view(-1, logits.size(-1)), labels.view(-1), ignore_index=-100, ) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True)训练初期有一个很实的判断标准:loss的初始值应该在ln(vocab_size)附近。如果词表大小是16K,那起步大约在9.7左右;如果一上来就比这个值低很多,很可能是数据泄漏或者标签错位了。我踩过一轮,训练第一个epoch之后loss降得非常快,后来发现数据预处理时把标签错了一位,模型其实在“预测自己”而不是“预测下一个token”,损失当然低,但生成时一塌糊涂。
学习率策略上,先做一小段线性预热,再进入余弦退火,是大模型的惯例做法。预热能避免模型在初始几步产生极端梯度,余弦退火则让loss在后期平稳收敛。每次训练时建议保存checkpoint,并记录每个step的loss到日志文件,或者直接在命令行打印。不要只看最后的平均loss,要看曲线变化趋势。我曾遇到loss在第3000步突然飙升的情况,后来查出来是一条语料里有不可见字符,导致target全部错乱,这类问题没有曲线日志很难定位。
4. 显存、速度与分布式训练:从单卡到多卡的资源规划
你可能已经发现,1.3B规模的模型单张卡训练依然吃力。这一节我想回答一个很多人问过的问题:到底多大的模型需要多少显存?怎么在有限资源下训练?
4.1 模型大小、优化器状态和激活值的显存估算
显存消耗主要来自三块:模型参数、优化器状态、中间激活值。我们可以做一个粗略估算,假设参数有N个:
| 项目 | 显存占用 | 说明 |
|---|---|---|
| 参数本身(BF16) | 2N字节 | 训练时通常保留BF16前向 |
| 梯度(BF16) | 2N字节 | 反向传播后保存梯度 |
| AdamW状态 | 约12N字节 | 包含FP32主参数、一阶动量、二阶动量 |
| 中间激活 | 取决于batch size、序列长度、层数 | 通常比参数显存更不可控 |
举个例子,一个1.3B参数的模型,参数量约为26亿字节即2.6GB;优化器状态约需要12倍单精度参数的容量,即约6.2GB;梯度再占2.6GB;光模型、梯度、优化器这三块就超过11GB。再叠加一个不小心的激活值,比如batch size为1、序列长度1024、隐藏维度2048,也会轻松吃下数GB显存。所以24G显存的单卡跑1.3B模型训练,实际上必须用梯度累积和小batch来回腾挪。
很多人容易漏掉“中间激活值”这块硬骨头,它的大小大致正比于batch_size × seq_len × hidden_size × layer_num × 操作系数。所以当出现OOM时,第一步该做的不是说“模型太大了所以没救”,而是把batch size减半、序列长度缩短,或者开启激活重计算。
4.2 混合精度、梯度累积与DeepSpeed ZeRO
混合精度是训练大模型的常规操作。BF16比FP16在训练稳定性上好很多,因为BF16保留的指数范围更大,不容易出现下溢,但它的小数精度低,所以依然要配合FP32的master weight和优化器状态。
显存不够时,梯度累积是一种调参技巧:每步只计算一个mini-batch的梯度,攒好几个batch的梯度后再更新一次参数。这等于在不大幅增加显存的前提下模拟更大的batch size。注意,梯度累积不改变单个batch内的并行度,它的效果是扩大“有效batch size”,从而让梯度估计更稳定。
如果单卡还是放不下,就要上分布式方案。实践中最常用的思路是:用DeepSpeed的ZeRO第一阶段或第二阶段,把优化器状态、梯度等分片到多张卡上。比如原本1.3B模型的优化器状态需要6GB,两张卡各承担3GB,显存压力就小很多。配合CPU offload还能进一步腾出GPU显存,但会显著拖慢训练速度。我个人的建议是:先保证batch size和序列长度能塞进显存,再考虑分片;分片层级越高,通信开销越大,速度越慢,并不是“越高级越好”。
4.3 训练中常见的崩溃问题与我的排查顺序
训练崩溃大概是每个手搓大模型的人都会遇到的噩梦。我把自己的排查顺序整理成了固定流程:
首先看loss是否出现NaN或inf,出现就检查学习率是否过高、是否用了FP16而不是BF16、数据中是否混入了异常文本。其次是判断模型更新是否正常,可以用梯度范数观察,当梯度范数突然放大时,要及时开启梯度裁剪,把最大范数限制在1.0左右。再然后检查数据流是否错位,一个简单的验证方式是取一个小batch,人工检查模型输出的预测token和真实标签是否语义吻合。
如果是多卡训练崩溃,优先怀疑通信配置和batch大小不均,减少batch往往最快见效。我遇到过一次连续两次OOM,最后发现不是显存不够,而是DataLoader的num_workers开得太多,内存被打爆。这种问题在单卡实验中最容易被忽略,排错时不要只盯着GPU,CPU内存和磁盘IO也得看一眼。
5. 推理优化与踩坑实录:从逐token生成到量化落盘
模型训练告一段落后,最终要验证生成效果。这一节讲推理实现、KV Cache和量化落地,也分享一些最常见的坑。
5.1 自回归生成与KV Cache,真的不能含糊
自回归生成就是一个循环:拿到已有的token序列,预测下一个token,再把新token拼进序列,重复执行直到遇到结束符或达到最大长度。这个逻辑本身很简单,但直接按照“每次把整个历史序列输入模型”的方式写,性能会非常差。原因是没有缓存历史层的K、V向量,导致每个新token都要把前面的注意力全部重新算一遍。
引入KV Cache后,每个解码层会把当前token产生的K、V向量追加到缓存中,下一次解码时只需要传入最后一个token的hidden_state,并用缓存里的全部K、V做注意力。代码层面的做法是在注意力模块里加一个可选的past_key_value参数:
if past_key_value is None: past_key_value = (k, v) else: k = torch.cat([past_key_value[0], k], dim=-2) v = torch.cat([past_key_value[1], v], dim=-2) past_key_value = (k, v)这个改动看起来不大,但对推理速度的影响是数量级的。我最初为了图省事没有实现Cache,生成200个token时单次推理就慢到不可接受。补上Cache之后,长文本生成速度翻了接近十倍,注意力计算量从“序列长度的二次方”降为线性递增。
一个额外提醒:使用KV Cache时,RoPE的cos、sin仍应根据全局位置生成,不能只用当前token的相对位置。这里出错不会报错,只会让生成结果莫名错乱,我当时排查了很久才发现是缓存位置索引没对齐。
5.2 量化方案对比:在有限资源里把模型跑起来
推理阶段的显存压力比训练小很多,但一个几十B的大模型想在消费级显卡上跑,依然绕不开量化。常见的路线大致有两种:weight-only量化和动态量化。
weight-only量化是把权重从FP16压到INT4或INT8,激活值仍然是高精度。这类方案显存占用低,适合GPU推理。另一种是CPU友好的GGUF格式,它把权重打包成紧凑存储格式,配合特定推理框架在CPU端使用。在模型质量上,4-bit量化通常只损失少量困惑度,但在敏感任务上可能出现明显偏差。
我给小模型做过一个简单对比:FP16生成的文本和INT8量化后生成的文本差别很小;但INT4量化后,偶尔会出现信息缺失或重复输出。如果你的应用场景对质量要求高,建议先用INT8或GPTQ的8-bit版本,确认生成效果后再看是否需要更低bit数。量化后的模型必须做一遍冒烟测试,不要只看显存占用符合预期就上线。
5.3 重复生成的坑:温度、采样与惩罚项怎么配
训练完一个几B的模型后,最让人头疼的问题往往是“生成结果不断重复”。这首先是模型的固有能力问题,但采样策略能显著改善观感。温度参数控制输出概率的锐利程度:温度越低,模型越倾向于高概率token;温度越高,输出越多样但越容易胡言乱语。基础调参时建议从0.7开始,低于0.5时文本容易变得机械,高于1.0时连贯性明显下降。
核心采样也值得开启:每次采样时只保留概率累计到一定阈值内的token,再重新归一化采样。这样既保留了多样性,又排除了长尾噪声。最后还可以加一个重复惩罚项,对已经生成过的token做概率压低,缓解复读机现象。但惩罚系数不能太大,超过1.3的时候模型会为了避开重复而选择不自然的词,反而让结果变得混乱。
我在一次生成测试中,温度设置为0.6、top_p为0.9、重复惩罚1.05后,效果比较接近自然文本。这个组合并非万能,但可以作为起手式,再根据你的业务数据微调。
最后聊点操作层面的体会。我最开始用小语料训练时,loss下降到一定程度后就不再降低了,模型永远只会输出高频词。后来发现问题出在数据处理环节:语料里有大量重复的空白字符,把tokenizer切得七零八落。重新清洗数据后,同样的结构和参数,loss明显降得更深。这个项目的收获不在于复现了某个开源模型的精度,而在于把模型训练的每个环节亲手过了一遍。当你真正理解RMSNorm为什么能去掉均值、GQA为什么能省显存、KV Cache为什么能提速时,再去读其他大模型论文,你会发现自己不再只是在看公式,而是能自动在脑中映射到对应的张量流动过程。