这个东西真不用一上来就几万行框架。
你把 GPT 外面那层壳先扒掉,剩下的核心动作其实就几步:把字切成 token,查 embedding,做注意力,过前馈,最后再把下一个 token 采样出来。真要徒手抡,200 行 Python 差不多就能把味道做出来。不是工业版,但骨架是对的。写过推理服务的人一般看到这里就知道,真正重的从来不是“原理实现不了”,而是训练、数据、算力、工程化这些后账。
我自己第一次拿 Python 重写这套东西的时候,最别扭的地方不是矩阵乘法,而是容易把“看起来像 GPT”和“真的抓住 GPT 核心”混成一回事。比如很多 demo 上来就堆一堆类,日志打满,参数名还挺唬人,最后一看,本质上只是个大点的 MLP,连因果掩码都没有。那不对。没有 mask,模型在预测当前位置时就偷看了后面的词,这事从根上就歪了。
先看最小骨架,压到能跑、能看、能改的程度:
import math
import numpy as np
defsoftmax(x):
x = x - np.max(x, axis=-1, keepdims=True)
e = np.exp(x)
return e / np.sum(e, axis=-1, keepdims=True)
classTinyGPT:
def__init__(self, vocab_size, d_model=32, n_head=4, max_len=64):
self.vocab_size = vocab_size
self.d_model = d_model
self.n_head = n_head
self.head_dim = d_model // n_head
self.token_emb = np.random.randn(vocab_size, d_model) * 0.02
self.pos_emb = np.random.randn(max_len, d_model) * 0.02
self.wq = np.random.randn(d_model, d_model) * 0.02
self.wk = np.random.randn(d_model, d_model) * 0.02
self.wv = np.random.randn(d_model, d_model) * 0.02
self.wo = np.random.randn(d_model, d_model) * 0.02
self.ff1 = np.random.randn(d_model, d_model * 4) * 0.02
self.ff2 = np.random.randn(d_model * 4, d_model) * 0.02
self.lm_head = np.random.randn(d_model, vocab_size) * 0.02
deflayer_norm(self, x, eps=1e-5):
mean = x.mean(axis=-1, keepdims=True)
var = x.var(axis=-1, keepdims=True)
return (x - mean) / np.sqrt(var + eps)
defsplit_heads(self, x):
t, c = x.shape
x = x.reshape(t, self.n_head, self.head_dim)
return np.transpose(x, (1, 0, 2))
defmerge_heads(self, x):
x = np.transpose(x, (1, 0, 2))
t, h, d = x.shape
return x.reshape(t, h * d)
defcausal_mask(self, t):
return np.triu(np.ones((t, t)), k=1) * -1e9
defattention(self, x):
q = self.split_heads(x @ self.wq)
k = self.split_heads(x @ self.wk)
v = self.split_heads(x @ self.wv)
score = q @ np.transpose(k, (0, 2, 1)) / math.sqrt(self.head_dim)
score += self.causal_mask(x.shape[0])
attn = softmax(score)
out = attn @ v
out = self.merge_heads(out)
return out @ self.wo
deffeed_forward(self, x):
x = x @ self.ff1
x = np.maximum(x, 0) # ReLU,够用了
return x @ self.ff2
defforward(self, ids):
t = len(ids)
x = self.token_emb[ids] + self.pos_emb[:t]
x = x + self.attention(self.layer_norm(x))
x = x + self.feed_forward(self.layer_norm(x))
logits = self.layer_norm(x) @ self.lm_head
return logits
defgenerate(self, ids, max_new_tokens=20):
ids = ids[:]
for _ in range(max_new_tokens):
logits = self.forward(ids)
next_id = int(np.argmax(logits[-1]))
ids.append(next_id)
return ids
这段代码干了四件正事。
第一,token_emb + pos_emb。词本身的语义,加上它在句子里的位置。否则“你打我”和“我打你”在模型眼里可能差不多,那肯定不行。
第二,多头注意力。这里别被“多头”两个字吓住,本质就是把一份表示切成几块,分别看不同关系。有的头偏爱近距离词,有的头盯主谓关系,有的头专门看分隔符。线上看 attention map 的时候,这种偏好挺明显。
第三,因果掩码。
defcausal_mask(self, t):
return np.triu(np.ones((t, t)), k=1) * -1e9
这一行很小,但是 GPT 和普通编码器模型分家的一刀。上三角直接打成极小值,softmax 以后这些位置就近似 0,模型只能看左边,不能偷看右边。你让它做“下一个 token 预测”,它才真是在猜,而不是抄答案。
第四,残差连接。这个在小 demo 里经常被省掉,但我一般不太信这种偷懒。没有残差,层一深就容易训崩,哪怕只是玩具实现,骨架也该摆正:
x = x + self.attention(self.layer_norm(x))
x = x + self.feed_forward(self.layer_norm(x))
看着只是两行,实际意思很重:先归一化,再做子层,再把原始输入加回来。Transformer 能堆起来,靠的就是这套。
当然,到这里还不能叫“会说话”。因为上面只是前向推理骨架,没有训练。你得至少给它一个非常土但能跑的训练循环,让它学会“看到前文,预测下一个字”。
text = "今天天气不错我们去公园散步今天天气很好我们去河边散步"
chars = sorted(set(text))
stoi = {ch:i for i, ch in enumerate(chars)}
itos = {i:ch for ch, i in stoi.items()}
data = [stoi[ch] for ch in text]
model = TinyGPT(vocab_size=len(chars))
lr = 1e-2
for step in range(200):
start = np.random.randint(0, len(data) - 9)
x_ids = data[start:start+8]
y_ids = data[start+1:start+9]
logits = model.forward(x_ids)
probs = softmax(logits)
loss = 0.0
for i, y in enumerate(y_ids):
loss -= np.log(probs[i, y] + 1e-9)
if step % 50 == 0:
print("step", step, "loss", round(loss, 4))
这段没写反向传播,严格说只是把训练过程摆出来,离真训练还差梯度更新。真要把反向也手写进去,200 行会有点紧,但也不是不行,只是代码观感会陡然变差。我一般写文章到这一步就收,不往“纯手搓 autograd”那个坑里跳。因为读者真正要抓住的不是某个求导细节,而是 GPT 的主路径:
输入 token → embedding → masked self-attention → feed-forward → logits → 预测下一个 token
你把这条链子理顺,很多看起来玄乎的名词就掉地上了。
最后给个最小可视化输出,把生成结果拼回字符串:
seed = [stoi["今"], stoi["天"], stoi["天"]]
out = model.generate(seed, max_new_tokens=6)
print("".join(itos[i] for i in out))
跑出来的东西大概率有点傻,这很正常。参数是随机的,数据也少得可怜。别被输出效果带偏,重点是结构已经对了。很多人学 GPT,问题不在不会调库,而在一上来就钻进 transformers、vllm、量化、KV Cache,最后脑子里剩一堆名词,没有主干。这个顺序我一直不太认。