完整教程讲解用 PyTorch 从零实现字符级 GPT,涵盖 Transformer 所有核心组件,是理解现代 LLM 的最佳学习路径。
一份完整的代码示例指南
生成式 AI 已经改变了我们构建智能应用的方式。大规模语言模型 (LLM),如 GPT、Llama 和 Gemma,可以编写代码、回答问题、总结文档,甚至解决复杂问题。虽然使用这些现有模型很直接,但理解它们如何真正工作是另一回事。
许多工程师一开始会对现有模型进行微调,或使用 Hugging Face 这样的框架。虽然这些工具非常有价值,但它们往往隐藏了使语言模型成为可能的机制。从头开始构建一个小型 GPT 风格模型是理解基础概念的最好方式之一。
本文介绍了仅使用 PyTorch 构建迷你生成式 AI 模型的完整过程。在本文结束时,你将理解 Transformer 语言模型的每一个主要组件,并为探索更大的架构奠定坚实基础。
虽然我们将构建的模型包含约 1000 万个参数——与当今数十亿参数的模型相比微不足道——但它包含了现代 LLM 中使用的相同基本构建块。
我们的目标是创建一个字符级 GPT 模型,能够从文本中学习模式并逐个 token 生成新文本。
这个过程包括五个主要阶段:
创建用于下一 token 预测的数据集
实现 Transformer 架构
每个阶段都建立在前一个阶段的基础上,逐步将原始文本转化为智能语言模型。
首先,为机器学习准备你的 Mac:
# 安装 Xcode 命令行工具
xcode-select --install
# 创建项目目录
mkdir my-gpt-from-scratch && cd my-gpt-from-scratch
# 创建虚拟环境
python3 -m venv .venv
source .venv/bin/activate
# 安装依赖
pip install torch numpy tqdm
验证 PyTorch 可以使用你的 Mac GPU:
import torch
print(f"MPS available: {torch.backends.mps.is_available()}")
print(f"MPS built: {torch.backends.mps.is_built()}")
# 应该输出:True, True
my-gpt-from-scratch/
│
├── tokenizer.py # 字符级分词器
├── data.py # 数据集和批处理
├── model.py # GPT 架构
├── train.py # 训练循环
├── generate.py # 文本生成
├── config.py # 超参数
├── utils.py # 辅助函数
└── data/
└── input.txt # 训练语料库
在神经网络能够理解语言之前,它必须将文本转换为数字。这个过程称为分词 (tokenization)。为了简单起见,我们将使用字符级分词器,其中每个唯一字符都获得一个唯一整数。
# 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 ch,i in self.stoi.items()}
self.vocab_size = len(chars)
def encode(self, text):
"""将文本转换为 token ID"""
return [self.stoi[c] for c in text]
def decode(self, ids):
"""将 token ID 转换回文本"""
return ''.join(self.itos[i] for i in ids)
text = "hello world"
tok = CharTokenizer(text)
ids = tok.encode("hello")
print(ids) # [1, 0, 2, 2, 3]
print(tok.decode(ids)) # hello
语言模型学习一个出乎意料的简单任务:预测下一个 token。
考虑句子:"Machine learning is amazing"
在训练期间,模型反复看到如下示例:
我们不会每次迭代都输入整个数据集,而是创建称为上下文 (context) 或序列的小块。
# data.py
import torch
class TextDataset:
def __init__(self, data, block_size):
"""
data: token ID 列表
block_size: 上下文长度 (如 256)
"""
self.data = torch.tensor(data, dtype=torch.long)
self.block_size = block_size
def get_batch(self, batch_size):
"""获取一批输入-目标对"""
# 随机起始位置
ix = torch.randint(
len(self.data) - self.block_size - 1,
(batch_size,)
)
# 输入序列
x = torch.stack([
self.data[i:i+self.block_size]
for i in ix
])
# 目标序列 (移位 1)
y = torch.stack([
self.data[i+1:i+self.block_size+1]
for i in ix
])
return x, y
现代语言模型使用 Transformer 架构构建,该架构在里程碑论文"Attention Is All You Need"中引入。
神经网络无法直接对整数 ID 进行操作。相反,每个 token 都映射到一个称为嵌入的密集向量。
import torch.nn as nn
# 在你的模型类中:
token_embedding = nn.Embedding(
vocab_size, # 唯一 token 的数量
embedding_dim # 每个嵌入向量的大小 (如 384)
)
与人类不同,Transformer 没有对序列顺序的内置理解。没有位置信息,"dog bites man" 和 "man bites dog" 看起来是相同的。
# 位置嵌入
position_embedding = nn.Embedding(
block_size, # 最大序列长度
embedding_dim # 与 token 嵌入相同
)
# 在前向传播中:
B, T = x.shape # 批大小,序列长度
tok_emb = token_embedding(x) # 形状:(B, T, n_embed)
pos = position_embedding(
torch.arange(T, device=x.device)
) # 形状:(T, n_embed)
x = tok_emb + pos # 添加位置信息
自注意力允许模型权衡不同 token 的重要性。每个 token 问:"我应该关注哪些以前的 token?"
class Head(nn.Module):
def __init__(self, n_embed, head_size, block_size):
super().__init__()
self.key = nn.Linear(n_embed, head_size, bias=False)
self.query = nn.Linear(n_embed, head_size, bias=False)
self.value = nn.Linear(n_embed, head_size, bias=False)
# 因果掩码 (防止查看未来 token)
self.register_buffer(
"tril",
torch.tril(torch.ones(block_size, block_size))
)
def forward(self, x):
B, T, C = x.shape
# 计算 key、query、value
k = self.key(x) # (B, T, head_size)
q = self.query(x) # (B, T, head_size)
# 计算注意力分数
wei = q @ k.transpose(-2, -1) # (B, T, T)
# 缩放 (稳定梯度)
wei = wei / (k.shape[-1] ** 0.5)
# 应用因果掩码
wei = wei.masked_fill(
self.tril[:T, :T] == 0,
float("-inf")
)
# 转换为概率
wei = wei.softmax(dim=-1) # (B, T, T)
# 将注意力应用于值
v = self.value(x) # (B, T, head_size)
out = wei @ v # (B, T, head_size)
return out
因为语言包含许多类型的关系,我们使用多个注意力头。不同的头可以专注于不同的模式。
class MultiHeadAttention(nn.Module):
def __init__(self, num_heads, n_embed, block_size):
super().__init__()
head_size = n_embed // num_heads
self.heads = nn.ModuleList([
Head(n_embed, head_size, block_size)
for _ in range(num_heads)
])
self.proj = nn.Linear(n_embed, n_embed)
def forward(self, x):
# 运行所有头并连接
out = torch.cat(
[h(x) for h in self.heads],
dim=-1
)
return self.proj(out)
在注意力收集信息后,每个 token 通过一个小型神经网络,该网络转换表示。
class FeedForward(nn.Module):
def __init__(self, n_embed):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embed, 4 * n_embed), # 展开
nn.GELU(), # 激活函数
nn.Linear(4 * n_embed, n_embed) # 收缩
)
def forward(self, x):
return self.net(x)
层归一化和残差连接使训练稳定。
class Block(nn.Module):
def __init__(self, n_embed, n_head, block_size):
super().__init__()
self.sa = MultiHeadAttention(n_head, n_embed, block_size)
self.ffwd = FeedForward(n_embed)
self.ln1 = nn.LayerNorm(n_embed)
self.ln2 = nn.LayerNorm(n_embed)
def forward(self, x):
# 带有残差连接的注意力
x = x + self.sa(self.ln1(x))
# 带有残差连接的前馈
x = x + self.ffwd(self.ln2(x))
return x
class GPT(nn.Module):
def __init__(self, vocab_size, n_embed, n_head, n_layer, block_size):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, n_embed) self.position_embedding = nn.Embedding(block_size, n_embed)
self.blocks = nn.Sequential(*[ Block(n_embed, n_head, block_size) for _ in range(n_layer) ])
self.ln = nn.LayerNorm(n_embed) self.lm_head = nn.Linear(n_embed, vocab_size)
def forward(self, idx): B, T = idx.shape
tok_emb = self.token_embedding(idx)
pos_emb = self.position_embedding(torch.arange(T, device=idx.device))
x = tok_emb + pos_emb
x = self.blocks(x)
x = self.ln(x)
logits = self.lm_head(x)
return logits
## 阶段 4:训练模型
首先,定义超参数:
vocab_size = 100 # Character-level vocabulary block_size = 256 # Context length n_embed = 384 # Embedding dimension n_head = 6 # Number of attention heads n_layer = 8 # Number of transformer layers
batch_size = 64 learning_rate = 3e-4 max_iters = 10000 eval_interval = 100
max_new_tokens = 300 temperature = 0.8
import torch import torch.nn.functional as F from config import * from tokenizer import CharTokenizer from data import TextDataset from model import GPT
with open('data/input.txt', 'r', encoding='utf-8') as f: text = f.read()
tokenizer = CharTokenizer(text) vocab_size = tokenizer.vocab_size
data = tokenizer.encode(text) dataset = TextDataset(data, block_size)
model = GPT(vocab_size, n_embed, n_head, n_layer, block_size)
device = 'mps' if torch.backends.mps.is_available() else 'cpu' model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
for step in range(max_iters): # Get batch xb, yb = dataset.get_batch(batch_size) xb, yb = xb.to(device), yb.to(device)
logits = model(xb)
loss = F.cross_entropy(
logits.view(-1, vocab_size),
yb.view(-1)
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if step % eval_interval == 0:
print(f"Step {step}: loss = {loss.item():.4f}")
torch.save(model.state_dict(), 'model.pt')
## 阶段 5:生成文本
import torch from config import * from tokenizer import CharTokenizer from model import GPT
def generate(model, tokenizer, start_text, max_new_tokens, temperature=0.8): """Generate text from a starting prompt""" model.eval()
idx = torch.tensor([tokenizer.encode(start_text)], dtype=torch.long)
idx = idx.to(next(model.parameters()).device)
for _ in range(max_new_tokens): # Get predictions with torch.no_grad(): logits = model(idx) logits = logits[:, -1, :] # Get last token's logits
logits = logits / temperature
probs = logits.softmax(dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, next_token], dim=1)
generated = tokenizer.decode(idx[0].tolist())
return generated
model = GPT(vocab_size, n_embed, n_head, n_layer, block_size) model.load_state_dict(torch.load('model.pt')) model.to(device)
prompt = "Once upon a time" generated_text = generate( model, tokenizer, prompt, max_new_tokens=300, temperature=0.8 ) print(generated_text)
## 高级采样:Top-K 和 Top-P
def generate_with_sampling(model, tokenizer, start_text, max_new_tokens, temperature=0.8, top_k=None, top_p=None): """Generate with Top-K or Nucleus (Top-P) sampling""" model.eval()
idx = torch.tensor([tokenizer.encode(start_text)], dtype=torch.long) idx = idx.to(next(model.parameters()).device)
for _ in range(max_new_tokens): with torch.no_grad(): logits = model(idx) 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')
if top_p is not None:
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
sorted_indices_to_remove = cumulative_probs > top_p
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
sorted_indices_to_remove[..., 0] = 0
indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove) logits[indices_to_remove] = float('-inf')
probs = F.softmax(logits, dim=-1) next_token = torch.multinomial(probs, num_samples=1) idx = torch.cat([idx, next_token], dim=1)
return tokenizer.decode(idx[0].tolist())
## 约 1000 万参数模型的配置
下面是一套紧凑的 GPT 配置,参数量接近 1000 万:
## 性能基准
启用 MPS 后端后,在 MacBook 上训练一个拥有 1000 万参数的模型快得惊人:
M1 Max:训练速度约为 CPU 的 7.7 倍
M2 Pro:一个 1000 万参数的模型在约 1 小时内便能展现出不错的效果
M3 Pro:可在不到一小时内完成同一模型的训练
device = torch.device('mps' if torch.backends.mps.is_available() else 'cpu') model = model.to(device)
从零构建出一个可运行的 GPT 后,你便具备了探索更多高级理念的基础:
## 分词改进
Byte Pair Encoding(BPE):比字符级分词更高效
SentencePiece:支持 Unicode 和多种语言
字节级分词:保留原始字节
## 架构增强
Rotary Positional Embeddings(RoPE):提供更好的相对位置表示
FlashAttention:加速注意力计算
KV Caching:加速推理
RMSNorm:比 LayerNorm 更快的替代方案
Weight Tying:在输入与输出之间共享嵌入权重
## 训练优化
混合精度训练(FP16/BF16)
梯度检查点
学习率调度:预热 + 余弦衰减
梯度累积
LoRA 和 QLoRA:高效微调
RLHF:基于人类反馈的强化学习
RAG:检索增强生成
混合专家模型(MoE)
## 建议的学习路线
构建字符级分词器 ✓
实现批次创建和下一 Token 预测 ✓
添加 Token 嵌入和位置嵌入 ✓
实现掩码自注意力 ✓
扩展为多头注意力 ✓
添加前馈网络、层归一化和残差连接 ✓
堆叠多个 Transformer 块 ✓
使用交叉熵损失和 AdamW 进行训练 ✓
实现带采样的自回归生成 ✓
后续步骤:添加 dropout 以实现正则化 实现学习率调度 添加验证指标和早停机制 尝试不同的模型规模 尝试 BPE 分词
添加 dropout 以实现正则化
实现学习率调度
添加验证指标和早停机制
尝试不同的模型规模
## 训练数据源示例
对于你的第一个模型,可以从单一文本语料库入手:
公版书籍:Project Gutenberg
莎士比亚戏剧:全集
Wikipedia 摘录:取最开始的几 MB 文本
TinyStories:小型故事数据集
代码数据集:用于构建代码模型
对于拥有 1000 万参数的模型,只需几百 KB 的干净文本,就足以生成可辨识的语言模式。
使用生成式 AI 与真正理解它之间的差距,比看上去要小。所有现代语言模型——从紧凑的开源模型到前沿规模的系统——都依赖相同的核心理念:分词、嵌入、自注意力、前馈网络、归一化、残差连接以及下一 Token 预测。
从零构建一个小型 GPT 模型不会产生 ChatGPT 级别的智能,但它会为你带来更加宝贵的东西:直觉。你将理解 Transformer 为何有效、它们如何学习,以及优化、微调和创新的机会在哪里。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。