大神!徒手只用 200 行 Python 代码重现 GPT 核心!
你真把 GPT 拆开看一遍,会发现它吓人的地方不在“模型”两个字,而在它把几件并不新鲜的东西拧到了一起:词嵌入、位置编码、自注意力、前馈层、残差、采样。代码摊开,其实没那么玄。真要徒手写一个能跑的“小 GPT”,200 行 Python 差不多够了。不是工业版,离 ChatGPT 也远,但“下一个 token 怎么猜出来”这件事,已经能看明白了。写这种东西我一般不先管训练,先把前向过程捋顺,不然代码越写越像黑盒。
先看最小骨架。输入是一串 token id,先过 embedding,再叠几层 Transformer Block,最后线性映射到词表大小,取最后一个位置做预测。
import math
import torch
import torch.nn as nn
import torch.nn.functional as FclassGPT(nn.Module):
def__init__(self, vocab_size, dim=128, n_head=4, n_layer=4, max_len=128):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, dim)
self.pos_emb = nn.Parameter(torch.randn(1, max_len, dim) * 0.02)
self.blocks = nn.ModuleList([
Block(dim, n_head) for _ in range(n_layer)
])
self.ln = nn.LayerNorm(dim)
self.head = nn.Linear(dim, vocab_size, bias=False)
defforward(self, x):
b, t = x.shape
h = self.tok_emb(x) + self.pos_emb[:, :t, :]
for block in self.blocks:
h = block(h)
h = self.ln(h)
return self.head(h)
这几行里最值钱的其实不是 Embedding,而是 h = self.tok_emb(x) + self.pos_emb[:, :t, :]。没有位置编码,模型只知道“这些词出现过”,不知道谁在前谁在后。你让它写句子,它会像把零件倒地上拌一遍。
接下来是核心:Masked Self-Attention。GPT 跟普通编码器一个很大的区别,就是它只能看左边,不能偷看未来 token。这个遮罩如果漏了,训练 loss 可能很好看,生成时一塌糊涂,这种坑我第一眼就会先查 mask。
classCausalSelfAttention(nn.Module):
def__init__(self, dim, n_head):
super().__init__()
self.n_head = n_head
self.head_dim = dim // n_head
self.qkv = nn.Linear(dim, dim * 3)
self.proj = nn.Linear(dim, dim)defforward(self, x):
b, t, c = x.shape
qkv = self.qkv(x)
q, k, v = qkv.chunk(3, dim=-1)
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)) / math.sqrt(self.head_dim)
mask = torch.tril(torch.ones(t, t, device=x.device)).bool()
att = att.masked_fill(~mask, float("-inf"))
att = F.softmax(att, dim=-1)
out = att @ v
out = out.transpose(1, 2).contiguous().view(b, t, c)
return self.proj(out)
这里别被矩阵乘法唬住。它干的事很朴素:当前 token 去看前面每个 token,算相关性,谁更相关,权重就更大。比如“今天天气很”,下一个大概率盯着“好”;如果是“数据库连接池”,它可能更盯着“连接”和“池”。GPT 的“像人话”,很大程度就靠这个权重分配在撑着。
一个 Block 其实就是 Attention 加一个前馈网络,再套上残差。残差这种东西写模型时属于“没它不舒服”,层一深,训练就发飘。
classBlock(nn.Module):
def__init__(self, dim, n_head):
super().__init__()
self.ln1 = nn.LayerNorm(dim)
self.attn = CausalSelfAttention(dim, n_head)
self.ln2 = nn.LayerNorm(dim)
self.ffn = nn.Sequential(
nn.Linear(dim, dim * 4),
nn.GELU(),
nn.Linear(dim * 4, dim)
)defforward(self, x):
x = x + self.attn(self.ln1(x))
x = x + self.ffn(self.ln2(x))
return x
到这里,前向过程已经齐了。剩下两个问题:怎么训练,怎么生成。
训练并不复杂,本质就是把一句话右移一位做监督。输入“春眠不觉晓”,目标就是“眠不觉晓”。代码甚至有点土:
deftrain_step(model, optimizer, x, y):
model.train()
logits = model(x)
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)),
y.view(-1)
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss.item()
生成也一样,拿最后一个位置的 logits,做 softmax,再采样。温度高一点,放飞自我;温度低一点,保守复读。线上真排问题时,我一般先把 temperature 压低,不然你分不清是模型真不会,还是采样太浪。
@torch.no_grad()
defgenerate(model, x, max_new_tokens=20, temperature=1.0):
model.eval()
for _ in range(max_new_tokens):
logits = model(x)
next_token_logits = logits[:, -1, :] / temperature
probs = F.softmax(next_token_logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
x = torch.cat([x, next_token], dim=1)
return x
你把这些拼起来,再加一点数据预处理、词表编码,200 行左右确实能跑出一个“会续写”的小玩意。它当然不聪明,参数量小得可怜,训练数据也寒酸,写两句可能就开始胡说八道。但 GPT 的骨架已经在这了:输入 token,叠位置,做因果注意力,层层抽特征,最后预测下一个 token。
很多人第一次看大模型,容易把注意力都放在“几千亿参数”“显卡烧了多少张”上。那个是规模问题,不是原理问题。原理这块,真没必要先被吓住。你自己把这 200 行写出来,哪怕跑出来的结果像个半吊子聊天机器人,也比看十篇概念文强。因为你会知道它不是在“理解世界”,它是在一个很大的概率空间里,尽可能像样地接下去。这个判断一旦立住,后面再看 KV Cache、RoPE、并行训练、量化部署,脑子就不容易乱。