从零构建迷你GPT:PyTorch实现Transformer与自注意力机制
2026/9/16 4:30:50 网站建设 项目流程

这是你的第15、16章学习内容:用一套极其简化的代码,把“Transformer + 预训练 + 自回归生成”这条主线走一遍。我会先讲清楚架构里的核心部件到底在做什么,再带你实现一个真正能训练、能生成文本的迷你 GPT,模型大小控制在几千万参数以内,普通笔记本 CPU 也能跑通。

这篇内容适合以下读者:

正在学深度学习,看完了 CNN、RNN,想进入大模型领域的同学; 已经能熟练使用 Hugging Face 的transformers库,但不清楚模型内部到底是什么样的开发者; 需要在实验环境下快速验证 LLM 训练流程、做教学演示或课程作业的技术人员。

读完本文,你会掌握:Transformer 的完整组成、自注意力机制的 Python 实现、如何用 PyTorch 从零写一个 GPT 模型、如何训练一个最小可用的字符级语言模型,以及训练和推理阶段最常见的坑。

1. 从零构建 LLM 之前,先搞清楚几个关键问题

很多人一上来就下载别人训练好的大模型权重,加载 Hugging Face 的AutoModelForCausalLM,然后开始做各种 Prompt 实验。这当然没有错,但有一个问题:你始终在“使用模型”,而不是“理解模型”。

从零构建 LLM 的意义不在于做出一个能比肩 GPT-4 的产品,而在于把下面这些问题彻底弄清楚:

  • Token 到底是什么?字符串变成数字 ID 的过程发生了什么?
  • Transformer 的输入层、注意力层、前馈网络层分别解决了什么问题?
  • 为什么 GPT 模型要做“因果掩码”,训练时的每个 token 为什么只能看到它前面的内容?
  • 训练一个大模型时,损失函数、优化器、学习率调度分别起到什么作用?
  • 推理时的 temperature、top-k、top-p 采样为什么能改变生成效果?

如果你能亲手实现一次,这些问题就不再是抽象概念。

1.1 我们这次要构建的目标

为了在普通硬件上跑通,本文构建的不是“大”语言模型,而是一个“小但完整”的语言模型。它的特征如下:

  • 基于字符级 Tokenizer,不需要分词库;
  • 使用标准的 Decoder-Only Transformer 结构;
  • 参数量约 1000 万到 5000 万级别;
  • 在小型文本语料上训练,能生成符合语法结构的文本;
  • 训练代码完整可运行,不需要 GPU。

这个目标很有价值,因为所有大模型的核心机制都被保留了,同时去掉的只是“参数规模”和“数据规模”,而这两项是可以通过算力扩展的。

2. 第15章:构建 LLM 前必须掌握的 Transformer 核心原理

2.1 从词向量到 Token Embedding

深度学习中,任何文本进入神经网络之前,都要变成数字。最基础的做法是建立一个词表,把每个词或字符映射成一个编号。假设词表大小是vocab_size,那么每个 token 就有一个 0 到vocab_size - 1的整数索引。

然后通过一个 Embedding 层,把这些索引映射为稠密向量。Embedding 层本质上是一个形状为[vocab_size, d_model]的矩阵。d_model是模型的隐藏维度,决定了每个 token 被表示成多长的向量。

2.2 位置编码:让模型知道顺序

Transformer 结构本身没有递归,也没有卷积,它处理的是集合。如果不加任何位置信息,模型会把句子当成一个“词袋”。所以必须加入位置编码。

现代 LLM 普遍使用可学习的绝对位置编码,也就是初始化一个形状为[max_seq_len, d_model]的矩阵,随模型一起训练。实现时,只需要把 token embedding 和位置 embedding 相加即可。

2.3 自注意力机制:理解上下文的关键

自注意力是 Transformer 的核心。它解决的问题是:对于句子中的每一个 token,如何根据它与其他 token 的关系来更新自己的表示。

具体做法:

  1. 将输入向量分别乘以三个权重矩阵,得到 Q(查询)、K(键)、V(值);
  2. 计算 Q 与 K 的点积,得到注意力分数;
  3. 将分数除以sqrt(d_k)进行缩放,防止点积过大导致 softmax 梯度消失;
  4. 使用 softmax 归一化,得到每个 token 对当前 token 的注意力权重;
  5. 用权重对 V 加权求和,得到输出。

2.4 多头注意力:让模型从多个角度理解

单头注意力只能捕捉一种关系。多头注意力把d_model维度的向量切分成多个头,每个头独立进行注意力计算,最后拼接并线性变换。这样模型可以同时关注语法关系、语义关系、指代关系等不同信息。

2.5 前馈神经网络:非线性变换

注意力层负责收集信息,前馈网络负责加工信息。每个 Transformer Block 中都包含一个两层全连接网络,通常使用 GELU 或 ReLU 激活函数。它把每个 token 的表示从d_model维度映射到更高维度,再映射回来。

2.6 层归一化与残差连接

深层网络训练时容易出现梯度消失或梯度爆炸。残差连接把输入直接加到输出上,让梯度可以顺畅回传。层归一化则对每个 token 的特征维度做归一化,让训练更加稳定。

现代 GPT 模型中常见做法是先做层归一化,再进入注意力或前馈网络,这种方式叫 Pre-LN,它的训练稳定性比 Post-LN 更好。

2.7 因果掩码:GPT 的生成逻辑

GPT 这类自回归模型在训练时,需要预测下一个 token。对于序列中的第 i 个位置,它只能看到前 i 个 token,不能看到后面的内容。这就是因果掩码。

实现时,构造一个上三角矩阵,将来位置的信息设为-inf,经过 softmax 后这些位置的概率趋近于 0。

3. 第16章:基于 PyTorch 从零构建迷你 GPT

3.1 环境准备

本文所有代码基于以下环境验证:

  • Python 3.9 或更高版本;
  • PyTorch 2.0 或更高版本;
  • NumPy;
  • 操作系统:Windows、Linux、macOS 均可;
  • 硬件:建议 CPU 可运行,如果有 NVIDIA GPU 会更高效。

如果还没有安装 PyTorch,执行以下命令:

pip install torch --index-url https://download.pytorch.org/whl/cpu

如果有 GPU,建议到 PyTorch 官网选择对应 CUDA 版本。验证安装是否成功:

import torch print(torch.__version__) print(torch.cuda.is_available())

3.2 准备训练语料

为了演示完整的训练流程,我们使用一个非常小的英文语料。这段文本不需要太大,重点是跑通流程。

# 文件路径:data/corpus.py corpus = """ The quick brown fox jumps over the lazy dog. A journey of a thousand miles begins with a single step. All that glitters is not gold. The only thing we have to fear is fear itself. To be or not to be, that is the question. """ with open("data/tiny_corpus.txt", "w", encoding="utf-8") as f: f.write(corpus)

在这个示例中,语料很小,模型会记住这些句子。实际项目中,你应该准备更大规模的文本数据集,比如维基百科的纯文本版本或某个领域的文档集。

3.3 实现字符级 Tokenizer

词表里存什么,取决于任务需求。这里我们采用字符级分词,好处是不需要依赖额外的分词库,逻辑非常清晰。

# 文件路径:model/tokenizer.py class CharTokenizer: def __init__(self, text): chars = sorted(list(set(text))) self.stoi = {ch: i for i, ch in enumerate(chars)} self.itos = {i: ch for i, ch in enumerate(chars)} self.vocab_size = len(chars) def encode(self, text): return [self.stoi[ch] for ch in text] def decode(self, ids): return "".join(self.itos[i] for i in ids)

示例用法:

from model.tokenizer import CharTokenizer tokenizer = CharTokenizer(corpus) print("词表大小:", tokenizer.vocab_size) print("编码结果:", tokenizer.encode("hello")[:10]) print("解码结果:", tokenizer.decode(tokenizer.encode("hello")))

字符级 tokenizer 的问题是不够高效。一个英文单词需要多个字符才能表示,这意味着序列长度很长,训练成本高。真实 LLM 使用的是 BPE、WordPiece 或 SentencePiece 等子词分词方法。不过在学习阶段,字符级实现足以让你明白 token 化的核心流程。

3.4 数据加载与批次构建

训练 GPT 时,我们需要把文本切成长度固定的序列,并构造“输入-标签”对。例如输入是[1, 2, 3, 4],标签就是[2, 3, 4, 5],每个位置预测下一个 token。

# 文件路径:model/dataset.py import torch def load_tokens(text, tokenizer, seq_len=64): ids = tokenizer.encode(text) data = torch.tensor(ids, dtype=torch.long) n = len(data) X, y = [], [] for i in range(0, n - seq_len - 1, seq_len): X.append(data[i:i + seq_len]) y.append(data[i + 1:i + seq_len + 1]) return torch.stack(X), torch.stack(y) def get_batch(X, y, batch_size=8): idx = torch.randint(0, X.shape[0], (batch_size,)) return X[idx], y[idx]

3.5 构建 Transformer Block

现在开始核心部分:构建一个标准的 Decoder-Only Transformer Block。

# 文件路径:model/transformer.py import torch import torch.nn as nn import torch.nn.functional as F class LayerNorm(nn.Module): def __init__(self, d_model, eps=1e-5): super().__init__() self.eps = eps self.gamma = nn.Parameter(torch.ones(d_model)) self.beta = nn.Parameter(torch.zeros(d_model)) def forward(self, x): mean = x.mean(-1, keepdim=True) var = x.var(-1, keepdim=True, unbiased=False) x = (x - mean) / torch.sqrt(var + self.eps) return self.gamma * x + self.beta class CausalSelfAttention(nn.Module): def __init__(self, d_model, n_head, dropout=0.1): super().__init__() assert d_model % n_head == 0 self.d_model = d_model self.n_head = n_head self.head_dim = d_model // n_head self.c_attn = nn.Linear(d_model, 3 * d_model) self.c_proj = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): B, T, C = x.shape qkv = self.c_attn(x) q, k, v = qkv.split(self.d_model, dim=2) q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2) k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2) v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2) att = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5) mask = torch.tril(torch.ones(T, T, device=x.device)).view(1, 1, T, T) att = att.masked_fill(mask == 0, float("-inf")) att = F.softmax(att, dim=-1) att = self.dropout(att) y = att @ v y = y.transpose(1, 2).contiguous().view(B, T, C) return self.c_proj(y) class FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.net = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model), nn.Dropout(dropout) ) def forward(self, x): return self.net(x) class TransformerBlock(nn.Module): def __init__(self, d_model, n_head, d_ff, dropout=0.1): super().__init__() self.ln1 = LayerNorm(d_model) self.attn = CausalSelfAttention(d_model, n_head, dropout) self.ln2 = LayerNorm(d_model) self.ffn = FeedForward(d_model, d_ff, dropout) def forward(self, x): x = x + self.attn(self.ln1(x)) x = x + self.ffn(self.ln2(x)) return x

这段代码中,最关键的是CausalSelfAttention中的掩码操作。torch.tril生成下三角矩阵,矩阵中右上角的元素全部被替换为负无穷。这样 softmax 之后,当前位置不会关注未来位置的信息。

3.6 构建完整的 GPT 模型

有了 token embedding、位置编码和 Transformer Block,现在可以把它们组合成完整的 GPT 模型。

# 文件路径:model/gpt.py import torch import torch.nn as nn from model.transformer import TransformerBlock, LayerNorm class MiniGPT(nn.Module): def __init__(self, vocab_size, d_model=128, n_head=4, d_ff=512, n_layer=4, max_seq_len=64, dropout=0.1): super().__init__() self.token_embedding = nn.Embedding(vocab_size, d_model) self.position_embedding = nn.Embedding(max_seq_len, d_model) self.blocks = nn.Sequential(*[ TransformerBlock(d_model, n_head, d_ff, dropout) for _ in range(n_layer) ]) self.ln_f = LayerNorm(d_model) self.lm_head = nn.Linear(d_model, vocab_size) def forward(self, idx, targets=None): B, T = idx.shape tok_emb = self.token_embedding(idx) pos = torch.arange(T, device=idx.device).unsqueeze(0) pos_emb = self.position_embedding(pos) x = tok_emb + pos_emb x = self.blocks(x) x = self.ln_f(x) logits = self.lm_head(x) loss = None if targets is not None: B, T, C = logits.shape loss = nn.functional.cross_entropy( logits.view(B * T, C), targets.view(B * T) ) return logits, loss @torch.no_grad() def generate(self, idx, max_new_tokens=100, temperature=0.8, top_k=None): self.eval() for _ in range(max_new_tokens): idx_cond = idx[:, -self.position_embedding.weight.shape[0]:] logits, _ = self.forward(idx_cond) logits = logits[:, -1, :] / temperature if top_k is not None: v, _ = torch.topk(logits, min(top_k, logits.size(-1))) logits[logits < v[:, [-1]]] = float("-inf") probs = torch.softmax(logits, dim=-1) next_token = torch.multinomial(probs, num_samples=1) idx = torch.cat([idx, next_token], dim=1) return idx

generate是推理函数。它每一步只取最后一个 token 的 logits,经过温度缩放和可选 top-k 过滤后,从概率分布中采样下一个 token。

3.7 编写训练循环

训练循环的核心流程是:

  • 前向传播得到 logits 和 loss;
  • 反向传播计算梯度;
  • 使用 AdamW 优化器更新参数;
  • 周期性输出训练 loss;
  • 保存模型 checkpoint。
# 文件路径:train.py import torch import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from model.gpt import MiniGPT from model.tokenizer import CharTokenizer from model.dataset import load_tokens, get_batch def train(): # 读取语料 with open("data/tiny_corpus.txt", "r", encoding="utf-8") as f: text = f.read() tokenizer = CharTokenizer(text) X, y = load_tokens(text, tokenizer, seq_len=64) model = MiniGPT( vocab_size=tokenizer.vocab_size, d_model=128, n_head=4, d_ff=512, n_layer=4, max_seq_len=64, dropout=0.1 ) optimizer = optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.01) scheduler = CosineAnnealingLR(optimizer, T_max=500) device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) epochs = 500 batch_size = 16 for step in range(epochs): xb, yb = get_batch(X, y, batch_size) xb, yb = xb.to(device), yb.to(device) logits, loss = model(xb, targets=yb) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() if step % 50 == 0: print(f"step {step}, loss {loss.item():.4f}") # 保存模型 torch.save(model.state_dict(), "checkpoints/minigpt.pth") print("训练完成,模型已保存") if __name__ == "__main__": train()

这里用了clip_grad_norm_做梯度裁剪,这是训练 Transformer 时的常见技巧。它可以防止梯度幅度过大导致训练不稳定。

3.8 编写模型推理与文本生成

训练完成后,可以用generate方法生成新的文本。

# 文件路径:generate.py import torch from model.gpt import MiniGPT from model.tokenizer import CharTokenizer def generate_text(): with open("data/tiny_corpus.txt", "r", encoding="utf-8") as f: text = f.read() tokenizer = CharTokenizer(text) model = MiniGPT( vocab_size=tokenizer.vocab_size, d_model=128, n_head=4, d_ff=512, n_layer=4, max_seq_len=64, dropout=0.1 ) model.load_state_dict(torch.load("checkpoints/minigpt.pth", map_location="cpu")) model.eval() start = torch.tensor([tokenizer.encode("The ")]) output = model.generate(start, max_new_tokens=80, temperature=0.7) generated = tokenizer.decode(output[0].tolist()) print(generated) if __name__ == "__main__": generate_text()

4. 运行结果与效果验证

4.1 训练阶段的输出

在 CPU 环境下,训练 500 步可能只需几十秒到几分钟。预期输出接近这样:

step 0, loss 3.2211 step 50, loss 1.4723 step 100, loss 1.1210 step 150, loss 0.8776 step 200, loss 0.7354 step 250, loss 0.6821 step 300, loss 0.6308 step 350, loss 0.6065 step 400, loss 0.5733 step 450, loss 0.5589

loss 持续下降说明模型正在学习。如果你发现 loss 卡住不动,或者反而上升,需要检查学习率是否过大、数据批次是否存在问题。

4.2 生成阶段的结果

由于训练语料很小,生成结果可能不是完整的自然语句,但应能看出模型学习到了字符之间的组合规律。一个合理的结果示例:

The quick brown fing a to the to be that the only thing we have to to be or not to be, that is the question.

对比随机初始化的模型,训练后的模型生成的字符序列在语法上明显更有规律。这不仅证明代码正确,也说明 Transformer 确实能够从数据中学习语言规律。

4.3 判断训练是否成功

判断训练是否成功,可以从三个角度验证:

  • 训练 loss 是否持续下降并趋于收敛;
  • 模型是否能够记住训练语料中的固定短语;
  • 在未见过的前缀上,模型生成的文本是否符合语料风格。

如果生成结果完全是乱码,优先检查 tokenizer 的 encode 和 decode 是否正确,再检查数据加载时的偏移是否正确。

5. 常见问题与排查方法

问题现象可能原因排查方式解决方案
训练 loss 一直是高值且不下降学习率设置不当,或数据加载有误打印输入批次和标签,检查 token 对应关系降低学习率,检查标签是否错位
生成文本全是重复字符模型训练不充分,或 temperature 过低增大训练步数,尝试调高 temperature将 temperature 调到 0.8 以上,继续训练
显存不足批次过大或序列过长逐步缩小 batch size 或 seq_len减小 batch_size 到 4 或 2
推理速度极慢步数太多,模型在 CPU 上运行检查是否加载到 GPU将模型放到 cuda 设备,或减少 max_new_tokens
位置编码越界报错输入序列长度超过 max_seq_len检查 generate 中 idx_cond 的截断使用idx[:, -max_seq_len:]截断输入
loading checkpoint 时 key 不匹配模型参数不同比较模型配置是否与保存时一致同步 d_model、n_head、n_layer 等参数

6. 最佳实践与工程建议

6.1 数据层面

字符级 tokenizer 适合学习,不适合生产。在训练真实的语言模型时,建议使用 BPE 或 SentencePiece 预训练一个子词词表。子词分词既能减小词表大小,又能处理未登录词。

另外一个容易被忽视的问题是训练语料的质量。语料中的噪声、重复内容和无关格式会直接拉低模型效果。在训练前,建议做去重、清洗、按领域筛选等预处理。

6.2 模型结构层面

d_modeln_headn_layerd_ff这四个参数共同决定了模型的容量和训练成本。它们之间不是随意组合的。实际经验是:

  • 注意力头数通常为 8 或 16;
  • d_model必须是n_head的整数倍;
  • d_ff通常是d_model的 4 倍;
  • 模型层数增加,收敛速度变慢,但表达能力更强。

从本项目的小模型扩展到真实规模的 GPT 时,可以先使用小模型把所有代码流程验证一遍,再逐步增大参数。这样避免因为代码错误浪费大量训练资源。

6.3 训练策略层面

Transformer 训练最常用的优化器是 AdamW,不是普通 Adam。AdamW 将权重衰减从梯度更新中分离出来,能更有效地缓解过拟合。

学习率调度同样重要。本项目使用余弦退火,训练后段学习率逐渐降低,帮助模型收敛到更优的点。对于更大规模的模型,通常还会加入 warmup 阶段,让学习率从很小的值逐渐上升到最大学习率。

6.4 工程落地层面

在真实项目中,需要补充以下几个工程能力:

  • 模型 checkpoint 定期保存,防止训练中断;
  • 训练日志记录,方便分析和回溯;
  • 使用混合精度训练,减少显存占用并提速;
  • 在验证集上监控 loss,防止过拟合;
  • 推理服务化时加入输入长度限制和并发控制。

6.5 安全与合规

如果你打算基于开源模型做二次开发或部署,需要注意:

  • 训练数据不要包含个人信息、隐私数据和未授权内容;
  • 生成能力可能带来内容风险,部署到生产环境时要增加内容审核机制;
  • 开源模型有各自的使用协议,商业使用前建议先核对 License;
  • 涉及生产环境变更时,先在测试环境验证,做好备份和回滚方案。

7. 从迷你 GPT 走向真实 LLM 的路线图

当你已经跑通这个迷你 GPT,下一步可以按阶段继续深入:

第一阶段,把 tokenizer 替换为 BPE 或 WordPiece,用更大的语料训练同一结构,观察模型效果的变化。

第二阶段,加入更完整的训练配置,包括 warmup、梯度累积、分布式数据并行。此时可以用多卡训练一个亿级参数模型。

第三阶段,使用 Hugging Face 的GPT2LMHeadModelLlamaForCausalLM作为底座,学习加载预训练权重,做 LoRA 微调和指令微调。你会发现,本项目实现的结构与这些模型的核心代码高度一致。

第四阶段,深入 RAG、Agent、工具调用等大模型应用方向。这时候你已经能够理解模型内部机制,做应用时不再是“调包侠”,而是能真正理解问题根源的工程师。

8. 总结

从零构建 LLM 并不是一件遥不可及的事情,只是把复杂的框架拆开之后,核心部件其实非常清晰:token 化、Embedding、位置编码、自注意力、前馈网络、层归一化、残差连接、因果掩码。

本文完整实现了一个迷你 GPT,并跑通了训练和推理流程。你不妨亲手运行一遍,然后尝试修改几个关键参数,比如增加层数、换一个更大的语料、调整 temperature,直观感受这些变化对生成质量的影响。

作为后续学习方向,推荐关注以下技术点:

  • BPE 分词算法与语料预处理;
  • 混合精度训练与显存优化;
  • LoRA、QLoRA 等高效微调方法;
  • 指令微调与人类反馈对齐;
  • 模型量化推理与部署。

记住,大模型的能力本质上来自于架构和数据的组合。当你理解了架构,再去看那些动辄几十亿参数的模型,你会发现它们只是在更大规模上重复你已经在本文中实现的机制。建议收藏本文,动手实践时遇到问题可以随时回来对照参考。

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

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

立即咨询