Build a Large Language Model (From Scratch) · Chapter 4
從零實作一個能生成文字的 GPT 模型Implementing a GPT model from scratch to generate text
前一章我們刻好了 multi-head attention。這一章把其餘積木補齊,照 GPT-2(124M)的架構組裝成一台完整的 GPT——可以跑 forward pass、輸出 logits、逐字生成文字。章節中的關鍵 tensor 都以真實數值的 array 呈現,與書中執行輸出一致(暖身區的示意值會另行標明)。
本章定位:還缺哪些積木?
attention 只是引擎,要組成一台能吐字的 GPT 還需要一批「看似配角、缺一不可」的零件。這是本章的積木清單——它會隨你往下讀自動打勾(也可以點擊跳到該節):
GPT 的架構沒有想像中複雜。它的「深」不是來自千奇百怪的結構,而是同一個 transformer block 重複 12 次。理解一個 block,就理解了整台模型的主體。另外:本章做完的模型還不會講人話——權重是隨機的,訓練(pretraining)是第 5 章的事,所以結尾生成出亂碼是預期結果。
模型有兩種「使用模式」,千萬別混在一起:🏋️ 訓練 training=forward 算預測 → 算 loss → backward 算梯度 → 更新權重,靠大量資料把權重調好(第 5 章的主題);💬 推論 inference=權重固定不動、只做 forward,一次吐一個字。本章主要在搭兩種模式共用的架構;但凡講到「梯度」(LayerNorm 的動機、shortcut、前向×反向)都是在講訓練,而 4.7 的文字生成是推論。各節標題旁會掛上對應的標籤。
暖身:tensor 就是「多層的 array」
模型看不懂文字,只吃數字。一個字(token)→ 一串數字(vector);一句話 → 一疊向量(matrix);一批句子 → 再疊一層(3 維 tensor)。以單字 moves 為例:
Coding an LLM architecture(實作 LLM 架構)
[2, 4]→GPTModel(本節先搭骨架)embedding → 12×block → final LN → head→output:logits
[2, 4, 50257]
「參數(parameters)」就是模型裡可被訓練調整的權重(weights)。一層 2048×2048 的權重矩陣就有 2048×2048 = 4,194,304 個參數。我們瞄準 GPT-2 small(約 124M 參數):它的預訓練權重公開、筆電跑得動;GPT-3 架構幾乎相同,只是規模放大到 175B。
所有超參數集中在一份設定字典。點任一行看說明:
佔位骨架 DummyGPTModel
在刻好每個零件前,先用「什麼都不做、原樣回傳」的假模組把骨架搭起來,看清楚資料怎麼從輸入流到輸出:
class DummyGPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"]) # token 查表成向量
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"]) # 位置查表成向量
self.drop_emb = nn.Dropout(cfg["drop_rate"])
self.trf_blocks = nn.Sequential( # 12 個(假的)block
*[DummyTransformerBlock(cfg) for _ in range(cfg["n_layers"])])
self.final_norm = DummyLayerNorm(cfg["emb_dim"]) # (假的)最終正規化
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)
def forward(self, in_idx):
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(in_idx.shape[1], device=in_idx.device))
x = tok_embeds + pos_embeds # 「是什麼字」+「在第幾位」
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
return self.out_head(x) # 投影回 50257 維 → logits
動畫:一個英文字如何走進模型
在餵整個 batch 之前,先看單一個字的完整旅程:文字 → 查 vocabulary 得到整數 ID → 到 embedding 表取出那一列向量。選一個字、按播放:
查 vocab
→
查表取列
→
實際餵資料:兩句話的 batch
用 tiktoken 把兩句話編碼成一個 batch。滑過下面的字或數字,看文字 ↔ token ID 的對應(注意兩句開頭都是 Every → 同一個 ID 6109):
輸出形狀 [2, 4, 50257]
把 batch 餵進模型(torch.manual_seed(123)),輸出 logits 的形狀是 torch.Size([2, 4, 50257])。點下面三個維度,看每一維在 array 裡對應什麼:
深入:為什麼能一次算 4 個位置,又不會「偷看未來」?(causal mask)🏋️ 訓練
雖然整句 token 是一起送進 transformer 做一次矩陣運算,但 causal mask 讓每個位置的 hidden state 只能利用自己與前面的 token——所以它產生的 logits 從未看過未來。點一個位置,看它的視野和它在回答的問題:
把 4 個位置想成同一間考場的 4 個學生:大家同時開考(一次矩陣運算),但監考老師(causal mask)規定——第 1 位只能看自己的考卷、第 2 位能看第 1~2 位、第 3 位能看前三位、第 4 位能看全部。沒有任何人能偷看「未來的答案」。這就是為什麼訓練時整句平行輸入,學到的能力和推論時「一步一步往後生成」完全一致。
Normalizing activations with layer normalization
[2, 4, 768]→LayerNorm(768)每個 token 的 768 維 → mean 0 / var 1,再乘 scale 加 shift→output:x′
[2, 4, 768](形狀不變)
深層網路常遇到 vanishing / exploding gradient(梯度消失/爆炸),訓練極不穩定。Layer normalization 的核心想法:把一層的輸出調整成 mean 0、variance 1(unit variance),加速收斂、穩定訓練。
像考試「調分」:把一組高低不齊的數字拉成「平均 0、散開程度一致」的標準形狀。它是每個樣本自己調自己的(沿特徵維),跟 batch 裡其他樣本無關——這是它和 batch normalization 最大的差別,也是它不怕 batch size 變動、適合 LLM 的原因。
互動①:訊號穿過 30 層,會發生什麼事?
把一個訊號丟進 30 層的網路(真實矩陣相乘)。第 l 顆點=訊號過完第 l 層時的大小——精確說是該層輸出向量的 RMS(均方根:√mean(x²),24 個數字「典型有多大」;不用平均值是因為正負會抵銷)。點縮小到看不見=訊號消失、點變紅=數值爆掉;滑鼠停在點上可看實際數值。
模擬中每層算的是 y = (s·W)·x:W 是那一層的權重矩陣(幾百個不同的數字,每層都不一樣),s(scale)是把整個 W 一起放大/縮小的倍率——代表該層權重的「整體大小」。點的大小才是 activation(訊號值):s 決定 activation 每過一層被放大或縮小多少,走到第 l 層時量級 ≈ s^l。可怕之處在於要連乘 30 次:s=0.9 看似無害,0.9³⁰ ≈ 0.04;s=1.1 也看似無害,1.1³⁰ ≈ 17.4。真實網路裡沒有一顆叫 s 的旋鈕——權重大小是初始化與訓練自然形成的,沒人能保證它剛好停在 1.0(Xavier/He initialization 就是想把每層的等效 s 調到 ≈ 1)。
互動②:那 loss 會怎樣?(瀏覽器裡真的訓練 300 步)🏋️ 訓練
同一個 6 層小網路,只差在初始權重的倍率 s(就是互動①那顆旋鈕,訓練開始時權重的整體大小):s=0.3(太小)、s=3.0(太大)、s=3.0 但每層加 LayerNorm。在你的瀏覽器裡實際跑 300 步訓練(真實 forward + backward + 權重更新),把 loss 畫出來:
先看 dim 往哪個方向算
書中的例子:torch.randn(2,5) 過一層 Linear(5,6)+ReLU,得到一個 2×6 的輸出(ReLU 把負值壓成 0,所以沒有負數)。切換 dim,看平均值是「沿哪個方向」算的:
一步步做正規化(數值即時計算,和書中輸出一致)
class LayerNorm(nn.Module):
def __init__(self, emb_dim):
super().__init__()
self.eps = 1e-5 # 防止除以零
self.scale = nn.Parameter(torch.ones(emb_dim)) # 可訓練縮放
self.shift = nn.Parameter(torch.zeros(emb_dim)) # 可訓練平移
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True) # keepdim 保形狀好做廣播
var = x.var(dim=-1, keepdim=True, unbiased=False)
norm_x = (x - mean) / torch.sqrt(var + self.eps)
return self.scale * norm_x + self.shift
n−1(稱為 Bessel's correction):因為用樣本自己的平均當中心,會系統性低估散佈程度,除以 n−1 可以修正這個偏差;反之分母用 n,得到的就是所謂「有偏(biased)」的變異數估計。這裡刻意選 n,理由有二:① 對 LLM 來說這個 n 是 embedding 維度(768),n 和 n−1 的差別約 0.13%,實務上可忽略;② 相容性才是重點——原始 GPT-2 是用 TensorFlow 實作的,其變異數預設就是除以 n;我們採一模一樣的算法,之後把 OpenAI 的預訓練權重載進來時,LayerNorm 的行為才會和那些權重當初被訓練時完全一致。Implementing a feed forward network with GELU activations
[2, 3, 768]→FeedForwardLinear 768→3072 · GELU · Linear 3072→768→output
[2, 3, 768](形狀不變)
LLM 不用傳統 ReLU,改用更平滑的 GELU(Gaussian error linear unit)。精確定義是 GELU(x) = x·Φ(x)(Φ 是標準常態的 CDF),實務用便宜的近似式(原始 GPT-2 也用它):
GELU(x) ≈ 0.5·x·(1 + tanh[ √(2/π)·(x + 0.044715·x³) ])
互動:GELU 的好處——神經元不會「死掉」
一個神經元收到的輸入值 z(=前一層輸出乘上權重的加總)在訓練過程會不斷漂移——因為權重每一步都在變。下圖把 24 個神經元照它們的 z 值排在數線上,z = 0 是 ReLU 的生死線:一旦漂到左邊(負區),ReLU 的輸出是 0、梯度也恰為 0 → 權重從此不再更新 → 這個神經元永遠困在負區(dying ReLU)。GELU 在負區仍保留一點梯度,還能被訓練慢慢拉回右邊。
FeedForward:768 → 3072 → 768
transformer block 裡的前饋網路=兩個 Linear 夾一個 GELU。先放大 4 倍到更寬的空間探索特徵、過非線性、再收縮回原維度。點「下一步」看形狀流動(用書中的輸入 [2, 3, 768]):
class FeedForward(nn.Module):
def __init__(self, cfg):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]), # 768 → 3072(放大 4 倍)
GELU(),
nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]), # 3072 → 768(收縮回來)
)
補充:前向傳播 × 反向傳播🏋️ 訓練
[2]→小型網路Linear+tanh(3 節點)→ Linear→output:ŷ 與 loss
(backward 再沿原路回傳梯度)
下一節會用「各層梯度」證明 shortcut 的威力——先花兩分鐘搞懂梯度從哪來。forward pass 把輸入算成預測、算出 loss;backward pass(backpropagation)用連鎖律從 loss 往回,算出每個權重「該往哪調」。
Adding shortcut connections(加入捷徑連接)🏋️ 為了訓練
直接加回→output = 子層(x) + x
(形狀相同才能相加)
Shortcut connection(又稱 skip / residual connection,源自電腦視覺的 ResNet)作法樸素到不可思議:把某一層的輸入,直接加到那一層的輸出上。這等於替反向傳播的梯度開一條「抄近路」的旁路,繞過中間運算、避免梯度層層縮小。
書中用一個 5 層小網路(每層 Linear+GELU)實測。forward 裡可選擇要不要加捷徑:
def forward(self, x):
for layer in self.layers:
layer_output = layer(x)
if self.use_shortcut and x.shape == layer_output.shape:
x = x + layer_output # 加回輸入=捷徑
else:
x = layer_output
return x
切換開關,比較各層權重的平均絕對梯度(右側是書中實際的 print 輸出):
Connecting attention and linear layers in a transformer block
[2, 4, 768]→TransformerBlockLN → attention → +x · LN → FFN → +x→output
[2, 4, 768](內容重新編碼)
把 MultiHeadAttention、LayerNorm、FeedForward、dropout、shortcut 全部組進一個 transformer block——GPT-2 small 把它重複 12 次。兩個子模組分工明確:
- multi-head attention:「跨位置」的資訊交流——讓每個 token 看到並吸收其他 token 的資訊。
- feed forward:「逐位置」的加工——對每個位置的向量獨立做非線性變換。
逐行走一遍 forward(注意兩個子層都是同一個範式:存 shortcut → LayerNorm → 子層 → dropout → 加回):
[2, 4, 768]、輸出仍是 [2, 4, 768],但每個位置的向量已被重新編碼成整合了整句資訊的 context vector——每過一個 block,token 的表徵就「更懂上下文」。Coding the GPT model(實作完整 GPT)
[batch, tokens]→GPTModeltok+pos embedding → dropout → 12×TransformerBlock → final LN → linear head→output:logits
[batch, tokens, 50257]
把骨架裡的假模組換成真貨,就是完整的 GPTModel:embedding → 12× TransformerBlock → final LayerNorm → linear output head。輸出形狀依然 [2, 4, 50257]——複習一下這三個數字:2=batch(一次餵了 2 句話)、4=每句 4 個 token、50257=每個 token 位置對詞彙表全部 50257 個字各給一個分數(logits)。也就是「2 句 × 每句 4 個位置 × 每個位置一整排『下一個字是誰』的評分」(回 4.1 的維度互動)。
self.trf_blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])]) # 這次是真的 12 層
self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)
參數量:163M 與 124M 的差別(weight tying)
祕密在 weight tying(權重綁定):原始 GPT-2 讓輸出層直接重用 token embedding 的權重。看形狀就懂——兩層一模一樣、互為反向操作:
token ID → 768 維向量
768 維向量 → 50257 個分數
我們的實作採「兩層各自獨立」(163M),因為經驗上訓練效果較好、現代 LLM 也多如此;tying 的好處是省記憶體。163M 個 32-bit float(每個 4 bytes)合計 621.83 MB——「小」模型的權重也相當可觀。
兩層做的事互為鏡像:token embedding 把「字」查成「向量」;輸出層把「向量」變回「每個字的分數」。若共用同一份權重,第 t 個字的 logit 就等於 h · e_t——拿目前的表徵 h 去和每個字的 embedding 做內積(相似度):「h 長得最像哪個字的 embedding,哪個字分數就最高」。這在語意上非常自然:同一套「字的座標系」同時負責讀入與輸出。
- 省多少:50257 × 768 ≈ 38.6M 個參數(float32 約 147 MB)——tying 後 163M → 124M、權重檔 622 MB → 約 475 MB。
- 代價:輸入與輸出被迫共用同一套表徵,少了一份可以分工的權重。實務上兩派都有:原始 GPT-2 綁(省參數);本書實作不綁——經驗上各自獨立的訓練表現通常略好。
- 之後會再遇到:載入 OpenAI 的 GPT-2 預訓練權重時,因為官方是綁的,要把同一份 embedding 權重同時填進 tok_emb 與 out_head。
動手感受規模:參數計算器
拖動滑桿即時計算總參數與記憶體。preset 是書中列的四種 GPT-2 尺寸(medium 1024/24 層、large 1280/36、XL 1600/48)。計算器用的公式如下(帶入 GPT-2 small 的數值驗算):
# 記 E = emb_dim(768)、V = vocab_size(50257)、C = context_length(1024)、L = n_layers(12) Token embedding = V × E = 50257×768 = 38,597,376 Positional embedding = C × E = 1024×768 = 786,432 # 每個 transformer block: attention = 3E²(Q,K,V,無 bias)+ (E²+E)(輸出投影含 bias) = 3×768² + (768²+768) = 1,769,472+590,592 = 2,360,064 feed forward = (E·4E + 4E) + (4E·E + E) # 768→3072 再 3072→768,皆含 bias = (768×3072+3072) + (3072×768+768) = 4,722,432 2× LayerNorm = 2×(E + E)(scale 與 shift) = 4×768 = 3,072 → 每 block 小計 = 7,085,568 Transformer ×L = 12 × 7,085,568 = 85,026,816 Final LayerNorm = 2E = 2×768 = 1,536 ── 到這裡的小計(=weight tying 後的總數) 124,412,160 Output head(不綁時)= V × E = 50257×768 = 38,597,376 ── 全部(我們的實作) 163,009,536 # 記憶體:163,009,536 × 4 bytes (float32) ≈ 621.83 MB
Generating text(生成文字)💬 推論
[1, n]→model → 取 [:, -1, :] → softmax → argmax只看最後一個位置的預測→output:idx
[1, n+1](接回再跑,迴圈)
生成式模型一次產生一個 token、接龍式寫句子。單步機制:模型輸出 logits → 只取最後一個位置那條 50257 維向量 → softmax 轉機率 → argmax 挑最大 → 把新 token ID 接回輸入 → 再跑一輪。
def generate_text_simple(model, idx, max_new_tokens, context_size):
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:] # 超長就裁掉前面
with torch.no_grad(): # 推論不用算梯度,省時省記憶體
logits = model(idx_cond)
logits = logits[:, -1, :] # (batch, n_token, vocab) → 只取最後一格
probas = torch.softmax(logits, dim=-1)
idx_next = torch.argmax(probas, dim=-1, keepdim=True) # greedy:挑機率最高
idx = torch.cat((idx, idx_next), dim=1) # 接回序列尾巴
return idx
實跑:模型還沒訓練,會發生什麼事?
把 "Hello, I am" 編碼成 [15496, 11, 314, 716]、模型切到 model.eval()(關掉 dropout),生成 6 個新 token。按「生成下一個 token」逐步看 ID array 長大——這些是書中用 seed 123 跑出的真實輸出:
本章小結
- Layer normalization 沿特徵維(dim=-1)把輸出調成 mean 0 / var 1,穩定訓練;scale/shift 可訓練、eps 防除零、unbiased=False 相容 GPT-2 權重。
- GELU 平滑、負值保留非零梯度,比 ReLU 更適合深層 LLM。
- FeedForward:768 → 3072 → 768,「膨脹—非線性—收縮」探索更豐富的表徵。
- Shortcut connection 把輸入加回輸出;5 層實測:無捷徑第 1 層梯度 0.0002(消失)、有捷徑 0.22(穩定),差三個數量級。
- Transformer block=attention(跨位置)+ feed forward(逐位置),Pre-LayerNorm+雙捷徑;形狀不變、內容重新編碼。
- GPTModel=embedding + 12×block + final norm + output head;我們 163M,GPT-2 靠 weight tying 記為 124M,權重約 622 MB。
- 生成=取最後一格 logits → softmax → argmax(greedy)→ 接回輸入循環;未訓練 → 亂碼。
知識檢核
// 六題,作答後立即看解析
頁面中的計算皆為瀏覽器即時運算 · 快捷鍵 T 切換主題