模組 09 · 第 4 課

搭一個完整的 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

cachesstart 是給第 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,再去讀這些模型的程式碼,你會發現大部分都認得。

練習

  1. n_layer 改成 8、n_embd 改成 256,參數變成多少?詞元嵌入的佔比變成多少?先用本課的公式算,再執行驗證。
  2. Block.forward 裡的兩個殘差連線去掉(x = self.attn(self.ln1(x))),用下一課的 train.py 訓練 300 步,和原來的損失比較。
  3. 把輸出層和詞元嵌入共用參數的那一行刪掉,參數多了多少?

自測

1. 一個 Transformer 塊裡有哪些部分?注意力和前饋網路的分工是什麼?

兩個子層:多頭因果自注意力和前饋網路,每個子層之前有一個 LayerNorm,外面套著殘差連線。注意力負責在不同位置之間傳遞資訊,前饋網路對每個位置單獨做計算,處理收集到的資訊。

2. 殘差連線為什麼能幫助訓練很深的網路?

殘差連線讓每個子層的輸出加回到輸入上,x + f(x) 對 x 的導數里始終有一個 1。反向傳播時梯度可以沿著這條直通的路徑傳回前面的層,不會因為連乘很多層而變得極小或極大。

3. 為什麼沒訓練過的模型的損失應該接近 ln(詞表大小)?

隨機初始化的參數很小,模型對每個字的打分差不多,softmax 之後每個字的機率約為 1/詞表大小。交叉熵是 -log(正確字的機率),也就是 -log(1/詞表大小) = ln(詞表大小)。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…