搭一個完整的 GPT
把注意力、前饋網路、殘差連線和 LayerNorm 組裝成 Transformer 塊,疊幾層,加上嵌入和輸出層,就是完整的 GPT。逐段讀 gpt.py,數清一百六十萬個參數在哪裡。
- 約 50 分鐘
- 難度:深入
- 實測:2026-09-15 torch 2.14,CPU,固定隨機種子
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
前兩課造好了零件:注意力讓每個字能從前面的字那裡收集資訊,多頭讓它能同時收集好幾種資訊,位置嵌入讓它知道順序。這一課把零件裝起來,得到一個完整的 GPT。
完整的程式碼在 code/09-transformer/gpt.py,不到 150 行。它的結構和 GPT-2 一樣,只是小得多。後面兩課的訓練和生成都用它。
python inspect_gpt.py
整體結構
词元编号
│
词元嵌入 + 位置嵌入
│
┌──────────────────────────┐
│ LayerNorm → 多头注意力 │ ─┐
│ + ←────────────────── ┘ 残差
│ LayerNorm → 前馈网络 │ ─┐
│ + ←────────────────── ┘ 残差
└──────────────────────────┘
│ (这样的块叠 4 层)
LayerNorm
│
输出层:对词表里每个字打分
從下往上看:每個字先變成一個向量(詞元嵌入加位置嵌入),經過若干個結構相同的 Transformer 塊,最後由輸出層對下一個字打分。塊裡的注意力我們已經寫過了,還有三樣新東西:前饋網路、殘差連線和 LayerNorm。
前饋網路:每個字自己想一想
注意力負責在字與字之間傳遞資訊。收集完資訊之後,每個字還要各自"消化"一下,這就是前饋網路:
self.mlp = nn.Sequential( # 前馈网络:先放大 4 倍,过激活函数,再缩回来
nn.Linear(cfg.n_embd, 4 * cfg.n_embd),
nn.GELU(),
nn.Linear(4 * cfg.n_embd, cfg.n_embd),
nn.Dropout(cfg.dropout),
)
它就是第 08 模組裡那種最普通的兩層網路:128 維放大到 512 維,經過啟用函式,再縮回 128 維。GELU 是一種和 ReLU 很像的啟用函式,只是在 0 附近是平滑的曲線,GPT-2 用的就是它。
前饋網路對每個位置單獨計算,位置之間互不影響。所以一個 Transformer 塊的分工是:注意力負責"交流",前饋網路負責"思考"。下面會看到,模型的大部分參數其實在前饋網路裡。
殘差連線:在原來的基礎上改一點
def forward(self, x, cache=None):
x = x + self.attn(self.ln1(x), cache) # 残差连接:在原来的基础上加一点修改
x = x + self.mlp(self.ln2(x))
return x
注意這裡是 x = x + ...,而不是 x = self.attn(x)。注意力和前饋網路算出的,不是新的向量,而是對原來向量的"修改量",加回到原來的向量上。這叫殘差連線。
為什麼要這樣?第 08 模組第 3 課講過,反向傳播時梯度要一層一層往回乘。層數一多,梯度連乘很多次,很容易變得極小(學不動)或者極大(訓練炸掉)。有了殘差連線,x + f(x) 對 x 的導數里始終有一個 1,梯度可以沿著"加號"這條路直接傳回前面的層,不必穿過每一層的變換。這是幾十層、上百層的深度網路能訓練起來的關鍵之一。
也可以換個角度理解:整個模型裡有一條從輸入直通輸出的"主幹道",每個塊都只是往上面加一點東西。
LayerNorm:讓數值保持穩定
第 08 模組第 2 課看到過,輸入的數值範圍差別太大,訓練會很難。一層層計算下來,向量裡的數也會越來越大或越來越小。LayerNorm 在每個子層之前,把每個向量調整成均值為 0、標準差為 1,再乘上、加上兩組可學的參數(讓模型自己決定合適的範圍)。
這和第 08 模組的標準化是同一個想法,只是對每個位置的向量單獨做,而且在網路中間反覆做。
注意 LayerNorm 放在注意力和前饋網路之前(self.attn(self.ln1(x))),而不是之後。這叫 Pre-LN,GPT-2 以來的模型基本都這樣放,訓練起來更穩定。最後輸出之前還有一個 LayerNorm(ln_f)。
輸入和輸出
self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
...
self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.head.weight = self.tok_emb.weight # 输出层和词元嵌入共用一套参数,GPT-2 也是这样做的
nn.Embedding 就是一張表:第 i 行是第 i 個字的向量。詞元嵌入的表有 6289 行(詞表大小),位置嵌入的表有 128 行(最多處理 128 個位置)。
輸出層把 128 維的向量變成 6289 個分數,每個字一個。這裡有一個小技巧:輸出層和詞元嵌入共用同一個矩陣。詞元嵌入把"字"變成"向量",輸出層反過來判斷"向量"最像哪個"字",用同一套參數很合理,而且能省下一大塊參數。
前向計算把這些串起來:
def forward(self, idx, targets=None, caches=None, start=0):
B, T = idx.shape
pos = torch.arange(start, start + T, device=idx.device)
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
for i, block in enumerate(self.blocks):
x = block(x, None if caches is None else caches[i])
logits = self.head(self.ln_f(x)) # (B, T, 词表大小):每个位置对下一个词元的打分
loss = None
if targets is not None:
loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
return logits, loss
caches 和 start 是給第 6 課的 KV 快取用的,現在先忽略。損失就是第 08 模組第 5 課的交叉熵:每個位置都是一個"從 6289 個字裡選一個"的分類問題。
形狀
== 2. 一批数据流过模型时的形状
输入的词元编号 (2, 6)
嵌入之后 (2, 6, 128)
经过 4 个块之后 (2, 6, 128)
输出层 (2, 6, 6289):每个位置对 6289 个字各打一个分
兩句詩、每句 6 個字,進去是 (2, 6)。嵌入之後每個字變成 128 維的向量。經過 4 個塊,形狀完全不變,這正是塊能隨便疊多少層的原因。最後每個位置得到 6289 個分數。
注意輸出在每個位置都有:一段 6 個字的文字,訓練時同時提供了 6 道"猜下一個字"的題。第 1 個位置看到"白"猜"日",第 2 個位置看到"白日"猜"依"……因為有因果掩碼,每道題都沒法偷看答案。這讓訓練的效率高了很多。
參數都在哪裡
== 1. 参数都在哪里(词表 6289 个字符)
词元嵌入 6289 × 128 = 804,992(输出层和它共用,不另算)
位置嵌入 128 × 128 = 16,384
每个块 198,272:注意力 66,048,前馈网络 131,712,两个 LayerNorm 512
4 个块共 793,088
合计 1,614,720,其中词元嵌入占 50%
一共 161 萬個參數。可以自己算一遍:
- 注意力:
qkv是 128×384 加 384 個偏置,proj是 128×128 加 128,合計 66048。 - 前饋網路:128×512 加 512,512×128 加 128,合計 131712,是注意力的兩倍。
- LayerNorm:每個有 128 個縮放和 128 個偏移,兩個共 512。
在我們這個小模型裡,一半的參數都在詞元嵌入這張表上,因為詞表有 6289 個字而向量只有 128 維。大模型的比例完全不同:向量維度是幾千,層數幾十,塊裡的參數遠遠多於嵌入。每個塊裡,前饋網路大約佔三分之二,注意力佔三分之一,這個比例在大模型裡也差不多。
訓練之前
== 3. 没训练过的模型,损失应该接近随便猜
损失 8.775,ln(6289) = 8.747
下一个字最可能是:骠蔫潮涨湄(随机初始化,毫无道理)
沒有訓練過的模型,對 6289 個字的打分幾乎一樣,相當於隨便猜,交叉熵應該是 ln(6289) = 8.747。實際是 8.775,非常接近。第 08 模組第 5 課說過:訓練開始時的損失如果離這個數很遠,多半是哪裡寫錯了。這是搭好模型之後第一個該做的檢查。
模型參數的初始值是用 std=0.02 的正態分佈隨機取的(_init 方法),這樣開始時每個字的分數都差不多,損失就接近隨便猜。
GPT 和真實的大模型有什麼不同
這個 GPT 和 GPT-2 的結構基本相同。截至 2026 年 9 月,主流的開源大模型在這個骨架上做了一些改進,常見的有:
- 位置資訊改用上一課提到的旋轉位置編碼(RoPE)。
- LayerNorm 換成計算更簡單的 RMSNorm。
- 前饋網路換成帶"門"的結構(比如 SwiGLU)。
- 多個注意力頭共用 K 和 V(分組查詢注意力),減少下一課 KV 快取佔的記憶體。
- 把前饋網路換成很多個"專家",每個字只用其中幾個(混合專家,MoE),DeepSeek 的模型就是這種結構。
但骨架沒變:嵌入、若干個"注意力 + 前饋網路"的塊、殘差連線、歸一化、輸出層。看懂了這個 150 行的 GPT,再去讀這些模型的程式碼,你會發現大部分都認得。
練習
- 把
n_layer改成 8、n_embd改成 256,參數變成多少?詞元嵌入的佔比變成多少?先用本課的公式算,再執行驗證。 - 把
Block.forward裡的兩個殘差連線去掉(x = self.attn(self.ln1(x))),用下一課的train.py訓練 300 步,和原來的損失比較。 - 把輸出層和詞元嵌入共用參數的那一行刪掉,參數多了多少?
自測
1. 一個 Transformer 塊裡有哪些部分?注意力和前饋網路的分工是什麼?
兩個子層:多頭因果自注意力和前饋網路,每個子層之前有一個 LayerNorm,外面套著殘差連線。注意力負責在不同位置之間傳遞資訊,前饋網路對每個位置單獨做計算,處理收集到的資訊。
2. 殘差連線為什麼能幫助訓練很深的網路?
殘差連線讓每個子層的輸出加回到輸入上,x + f(x) 對 x 的導數里始終有一個 1。反向傳播時梯度可以沿著這條直通的路徑傳回前面的層,不會因為連乘很多層而變得極小或極大。
3. 為什麼沒訓練過的模型的損失應該接近 ln(詞表大小)?
隨機初始化的參數很小,模型對每個字的打分差不多,softmax 之後每個字的機率約為 1/詞表大小。交叉熵是 -log(正確字的機率),也就是 -log(1/詞表大小) = ln(詞表大小)。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…