CH·3從零打造 LLM/ 實作注意力機制

Build a Large Language Model (From Scratch) · Chapter 3

實作注意力機制Coding attention mechanisms

第 2 章把文字切成 token、變成 embedding 向量;但這些向量彼此孤立——「journey」不知道旁邊站著「Your」。本章實作 LLM 的心臟 attention:讓每個 token 回頭看整句話、按相關性向其他 token「借」資訊。我們會從無訓練權重的簡化版出發,一路升級成 GPT 在用的 multi-head causal attention。頁面中標註「書中真實輸出」的數值都與書一致。

00

本章定位:LLM 的心臟

整本書的三階段地圖裡,本章是 Stage 1 的第 2 步——也是整個架構裡最關鍵的零件。第 2 章的資料處理已完成,attention 做好之後,第 4 章把它裝進 GPT:

ch2 ✓ 資料處理
文字 → token → embedding
→ 本章:attention 機制simplified → QKV → causal → multi-head→ ch4 GPT 架構 → ch5 預訓練
→ ch6–7 微調

本章用四段進化把 attention 蓋出來,每一段都在前一段上加一個零件——會隨你往下讀自動打勾(也可以點擊跳到該節):

🔰 先建立心態

attention 一句話就講完:對序列裡的每個 token,算出「其他每個 token 跟我多相關」的一組權重,再按權重把大家的向量加權平均,得到一個融合了上下文的新向量(context vector)。本章從頭到尾都在重複同一條三步公式——分數 → softmax → 加權求和——只是逐步加料:可訓練權重、遮未來的 mask、防過擬合的 dropout、多頭並行。抓住這條主線,全章都不會迷路。

# 本章終點:親手寫出這個類別(ch4 會原封不動裝進 TransformerBlock) mha = MultiHeadAttention(d_in=768, d_out=768, context_length=1024, dropout=0.1, num_heads=12) # ← GPT-2 124M 的注意力模組 # 教學過程用迷你尺寸(d_in=3、d_out=2、6 個 token),所有數字都看得見、算得完。
3.1

The problem with modeling long sequences(長序列的難題)

input:德文句子
8 個 token
→encoder–decoder RNN整句壓進一顆固定大小的 hidden state→output:英文翻譯
(句子一長就走樣)

先看 attention 出現之前的世界。以翻譯為例:逐字直譯行不通——德文和英文的語序、文法結構不同,某些字必須「看到句子其他位置」才能翻對。transformer 之前的主流解法是 encoder–decoder RNN:encoder 把整句讀完、壓成一顆 hidden state,decoder 再從這顆向量開始逐字生出翻譯。切換三種做法,看問題出在哪:

RNN 的瓶頸一句話:decoder 只拿得到最後一顆 hidden state,看不到 encoder 中途的任何狀態。整句話——不管 8 個字還是 80 個字——都得塞進同一顆固定大小的向量,長句的前段資訊自然被稀釋、遺失。
3.2

Capturing data dependencies with attention mechanisms(attention 的由來)

2014:Bahdanau attention
幫 RNN 裝上「選擇性回看」
→2017:transformer發現根本不需要 RNN——self-attention 就夠→GPT 系列
本書實作的對象

2014 年的 Bahdanau attention 對症下藥:讓 decoder 每生成一個字,都能直接回看輸入序列的全部位置,並用一組權重決定「現在該多看誰」——上一節第 ③ 個分頁演的就是這件事。三年後(2017)研究者發現更激進的事實:RNN 根本可以整個拿掉,只靠 attention 就能建出更強的架構——transformer 就此誕生。

🔰 self-attention 的「self」是什麼意思?

Bahdanau attention 連接的是兩條序列(輸入的德文 ↔ 輸出的英文);self-attention 則是同一條序列自己看自己:句子裡每個 token 對「同句的所有 token」計算相關性。它的產出是把每個 token 的 embedding 升級成含上下文的表示——這正是 GPT 每一層在做的事,也是本章接下來要親手實作的東西。

3.3.1

A simple self-attention mechanism(簡化版:先懂概念)

input:inputs [6, 3]
6 個 token 的 embedding
→點積算分數 → softmax → 加權求和整章唯一的一條公式,先用「無訓練權重」版走一遍→output:context vector
z² [3]

全章的工作句子:"Your journey starts with one step"——六個 token,每個已經是 3 維 embedding(書中為了排版選了迷你維度;真實 GPT 是 768 維起跳)。我們指定第 2 個 token journey(x²)當 query:「以 journey 的視角,該注意誰?」

第一步的核心:點積=相似度

attention score 的算法出奇地簡單:query 和每個 token 的 embedding 做點積(對應元素相乘再相加)。點積衡量兩個向量的「對齊程度」——方向愈接近、值愈大。滑過任何一個 token,看 journey 對它的分數怎麼算出來:

滑過上面任何一個 token 試試 →
讀出直覺:journey 對 starts 的分數 1.4754 幾乎跟對自己(1.4950)一樣高——兩者的 embedding 恰好很像;對 one 只有 0.7070。attention 就靠這種「相似度」決定資訊該從誰身上流過來。

三步組出 context vector(書中真實數值)

拿到分數之後還差兩步:把分數正規化成總和為 1 的權重,再用權重對所有 embedding 加權求和。按「下一步」逐步推導:

🔰 context vector 到底是什麼?

原本的 x² 只代表「journey 這個詞本身」;z² 則是「journey 眼中的整句話」——按注意力比例把六個向量攪拌在一起:13.85% 的 Your + 23.79% 的自己 + 23.33% 的 starts + …。維度不變(還是 3 維),但資訊變多了:它同時記得自己是誰、以及上下文長什麼樣。LLM 後續所有預測,用的都是這種「攪拌過」的表示。

3.3.2

Computing attention weights for all input tokens(一次算全部 token)

input:inputs [6, 3]→inputs @ inputs.T → softmax(dim=-1) → @ inputs三行矩陣乘法,取代兩層 for 迴圈→output:all_context_vecs [6, 3]
六個 context vector 一次到位

剛才只服務了 journey 一個 query。但每個 token 都需要自己的 context vector——把「query 對所有 token 點積」推廣成「所有 token 對所有 token」,就得到 6×6 的注意力矩陣。for 迴圈太慢,一個矩陣乘法 inputs @ inputs.T 全包。切換分數/權重,點左側 token 選 query 列,滑過格子看數值(底色深淺=數值大小;書中真實輸出):

點左側的 token 換 query 列;滑過格子看單一數值
# 整個 3.3 節,濃縮成三行(形狀: [6,3] @ [3,6] → [6,6] → [6,6] @ [6,3] → [6,3])
attn_scores  = inputs @ inputs.T                    # 所有 pair 的點積
attn_weights = torch.softmax(attn_scores, dim=-1)   # dim=-1:對「每一列」正規化
all_context_vecs = attn_weights @ inputs            # 每列權重 × 所有 embedding
驗算:矩陣第 2 列算出的 context vector [0.4419, 0.6515, 0.5683] 和上一節手算的 z² 一模一樣 ✓。dim=-1 的意思是沿最後一個維度(列內的行方向)做 softmax——所以「每一列」總和為 1,一列=一個 query 的注意力分佈。
3.4

Implementing self-attention with trainable weights(可訓練權重:scaled dot-product)

input:x [6, 3]→W_q / W_k / W_v 投影 → q·kᵀ ÷ √d_k → softmax → @ valuesscaled dot-product attention——GPT 真正在用的版本→output:context [6, 2]
(d_out=2)

簡化版有個致命限制:「相似度」被 embedding 寫死了,模型沒有任何可以學的東西。升級的方式:點積前先讓向量過三個可訓練的投影矩陣 W_q / W_k / W_v——它們是注意力模組裡唯一的權重參數,訓練時逐步調整,讓模型「學會」該用什麼標準去衡量相關性。

🔰 query / key / value 是什麼比喻?

借自資料庫檢索:query=搜尋語句(我現在想找什麼)、key=索引(每筆資料用來被比對的標籤)、value=內容(真正要取回的東西)。attention 的流程就是:拿 query 跟每個 key 比對(點積),比對結果決定從每個 value 取多少。同一個 token 會同時擁有 q、k、v 三種身分——分別由三個 W 投影出來。

照舊以 journey(x²)當主角,d_in=3、d_out=2(書中故意讓輸出維度不同,方便看清楚形狀;真實 GPT 裡 d_in=d_out)。按「下一步」逐步推導(書中真實數值,seed 123):

🔰 兩種「權重」別搞混

權重參數(weight parameters,W_q/W_k/W_v 裡的數字):訓練學出來的,訓練完就固定;注意力權重(attention weights,0.1500、0.2264…):每句話現算的,隨輸入而變。前者是「評分標準」,後者是「這一句的評分結果」。

為什麼要除以 √d_k?拖拖看

維度 d_k 愈大,點積是愈多項的加總,數值天然愈大;大分數進 softmax 會把分佈推成幾乎 one-hot——softmax 進入「飽和區」,梯度趨近 0,模型學不動。除以 √d_k 恰好把分數的變異數拉回原尺度。拖動 d_k(以 journey 的六個分數模擬放大,softmax 為即時計算):

d_k = 2 (GPT-2 每個 head 的 d_k = 64)

❌ 不縮放:softmax(q·kᵀ)

✓ 有縮放:softmax(q·kᵀ / √d_k)

包成類別:SelfAttention_v1 → v2

class SelfAttention_v1(nn.Module):
    def __init__(self, d_in, d_out):
        super().__init__()
        self.W_query = nn.Parameter(torch.rand(d_in, d_out))   # W_key / W_value 同
    def forward(self, x):
        queries = x @ self.W_query ; keys = x @ self.W_key ; values = x @ self.W_value
        attn_scores  = queries @ keys.T
        attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
        return attn_weights @ values               # ← context vectors
# sa_v1 = SelfAttention_v1(3, 2)(seed 123)——六個 token 的 context vectors(書中真實輸出) tensor([[0.2996, 0.8053], [0.3061, 0.8210], # ← 第 2 列=剛才手算的 journey context ✓ [0.3058, 0.8203], [0.2948, 0.7939], [0.2927, 0.7891], [0.2990, 0.8040]], grad_fn=<MmBackward0>)
v2 只改一件事:把 nn.Parameter(torch.rand(...)) 換成 nn.Linear(d_in, d_out, bias=qkv_bias)。數學相同(bias 關掉時 Linear 就是矩陣乘法),但 nn.Linear 的權重初始化方案更好、訓練更穩——所以 v2(seed 789)的輸出(第一列 [-0.0739, 0.0713]…)和 v1 不同純粹是初始值不同,機制一模一樣。qkv_bias 這個參數是 ch5 的伏筆:載入 OpenAI GPT-2 權重時要設 True。
3.5

Hiding future words with causal attention(遮住未來:因果注意力)

input:attn_scores [6, 6]→上三角填 −inf → softmax → dropout每個位置只准看「自己與過去」→output:causal attn_weights
上三角精確為 0

GPT 的任務是預測下一個字。訓練時整句一起算,如果 journey 的 context vector 裡混進了 starts、step 的資訊,就等於作弊看到未來——預測「journey 的下一個字」時答案已經洩題。解法:把注意力矩陣對角線以上(每列的未來位置)遮掉。這叫 causal attention(也稱 masked attention)。

兩種遮法對照:為什麼 −inf 才是標準答案

直覺做法(A):先照常 softmax,再把上三角歸零、每列重新除以列和——可行,但要三步。標準做法(B):在分數階段就把上三角填 −inf,softmax 一步到位。切換做法、按步驟走(書中真實數值,權重來自上一節的 sa_v2):

🔰 做法 A 會不會「資訊洩漏」?

乍看之下 A 有個疑慮:第①步的 softmax 分母包含了未來位置,未來似乎影響過剩下的數值。但書中點出 softmax 的數學性質:歸零+重新正規化之後,結果恰好等於「一開始就只在可見範圍內做 softmax」——未來位置的影響被完整抵消,兩條路殊途同歸(你可以在上面驗證:A 第③步 ≡ B 第②步)。B 勝出是因為省步驟、數值更穩;而且 −inf 這招之後還會再見——ch5 的 top-k sampling 用的是同一手。

再加一層保險:dropout

訓練時隨機把一部分注意力權重歸零,逼模型不要過度依賴任何特定位置——這是對抗 overfitting 的經典手段。被留下的值要 ×1/(1−p) 補償,讓整列的期望值不變。拖動 dropout 率、按「重抽」換遮罩(在 causal 權重上模擬;瀏覽器端固定種子亂數):

dropout p = 0.50
# 對照:書中 torch 的真實輸出(seed 123、p=0.5、套在 causal 權重上)——規則相同、遮罩位置不同(RNG 不同) tensor([[2.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000], [0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000], # 整列被丟光也可能發生 [0.7599, 0.6194, 0.6206, 0.0000, 0.0000, 0.0000], # 0.3800×2=0.7599(×1/(1−0.5) 補償) [0.0000, 0.4921, 0.4925, 0.0000, 0.0000, 0.0000], [0.0000, 0.3966, 0.0000, 0.3775, 0.0000, 0.0000], [0.0000, 0.3327, 0.3331, 0.3084, 0.3331, 0.0000]])
只在訓練時開!dropout 是訓練的正則化手段,推論時一律關閉(ch5 會看到 model.eval() 就是在關它)。書中示範用 p=0.5 好看清楚;實際訓練 GPT 用 0.1–0.2。

包成類別:CausalAttention(點一行看說明)

把 mask 和 dropout 收進類別,同時讓 forward 支援 batch 輸入——第 2 章 data loader 吐出來的可是 [batch, tokens, d_in] 的三維 tensor(書中用 batch = torch.stack((inputs, inputs)) → [2, 6, 3] 模擬):

👆 點一行程式,看它為什麼這樣寫
# ca = CausalAttention(d_in=3, d_out=2, context_length=6, dropout=0.0)(seed 123) context_vecs = ca(batch) context_vecs.shape: torch.Size([2, 6, 2]) # [batch, tokens, d_out]——形狀正確,可以接 batch 了
3.6

Extending single-head attention to multi-head attention(多頭注意力)

input:x [2, 6, d_in]→num_heads 組 causal attention 並行 → 串接 → out_proj每個 head 在自己的子空間學不同的注意模式→output:[2, 6, d_out]
本章最終成品

一組 W_q/W_k/W_v 只能學一種「相關性的標準」。multi-head attention 讓多組投影並行——有的 head 可能學語法關係、有的學指代關係——各自算出 context 後串接起來。最直觀的實作是疊 wrapper:

class MultiHeadAttentionWrapper(nn.Module):
    def __init__(self, d_in, d_out, context_length, dropout, num_heads, qkv_bias=False):
        super().__init__()
        self.heads = nn.ModuleList([CausalAttention(d_in, d_out, context_length, dropout, qkv_bias)
                                    for _ in range(num_heads)])   # num_heads 個獨立單頭
    def forward(self, x):
        return torch.cat([head(x) for head in self.heads], dim=-1) # 沿最後一維串接

num_heads=2、每頭 d_out=2 → 串接後每個 token 變成 2×2=4 維(書中真實輸出,seed 123;顏色區分兩個 head):

練習 3.2 的答案藏在形狀裡:想讓最終輸出是 2 維、又維持 num_heads=2?把每頭的 d_out 改成 1 就行——輸出維度=d_out × num_heads。另外注意兩個 batch 的輸出完全相同,因為書中的 batch 是同一句話疊兩次。

高效版:一次大投影,再「切」出多頭

wrapper 的缺點:for head in self.heads 是串行跑 num_heads 次矩陣乘法。MultiHeadAttention 類別改成:只做一次大投影(d_out 維),再用 view / transpose 把它「切」成 num_heads 份,批次矩陣乘法一次算完所有 head。點頭數,看形狀流與參數量(以 GPT-2 尺寸 d_out=768 示範):

# MultiHeadAttention.forward 的核心(d_out 必須能被 num_heads 整除)
keys = self.W_key(x)                    # [b, T, d_out]——一次大投影
keys = keys.view(b, T, num_heads, head_dim)        # 「切」出 head 維(免費,只是重新解讀)
keys = keys.transpose(1, 2)                        # [b, heads, T, head_dim]——head 提前,方便批次運算
attn_scores = queries @ keys.transpose(2, 3)       # [b, heads, T, T]:每頭一張注意力矩陣,一次算完
attn_scores.masked_fill_(mask_bool, -torch.inf)    # causal mask 照舊
attn_weights = torch.softmax(attn_scores / head_dim**0.5, dim=-1)   # √d_k 用的是 head_dim
context = (attn_weights @ values).transpose(1, 2)  # 換回 [b, T, heads, head_dim]
context = context.contiguous().view(b, T, d_out)   # 串接=把 head 維攤平
return self.out_proj(context)                      # 出場前再過一個 Linear 融合各頭(慣例配備)
# mha = MultiHeadAttention(d_in=3, d_out=2, context_length=6, dropout=0.0, num_heads=2)(seed 123,書中真實輸出) tensor([[[0.3190, 0.4858], # Your [0.2943, 0.3897], # journey [0.2856, 0.3593], # starts [0.2693, 0.3873], # with [0.2639, 0.3928], # one [0.2575, 0.4028]], # step [[…同上,第二個 batch…]]], grad_fn=<ViewBackward0>) context_vecs.shape: torch.Size([2, 6, 2]) # 這次 d_out=2 是「總維度」,每頭 head_dim=1
本章完成!這個 MultiHeadAttention 類別會原封不動進入第 4 章的 TransformerBlock(12 層、每層 12 頭、768 維),再由第 5 章訓練它。GPT-2 最小版 12 頭、768 維;最大版(1.5B)25 頭、1600 維——同一份程式碼,只改參數。
▣

本章小結

  • 為什麼要 attention:RNN 把整句壓進一顆固定 hidden state,長句必失真;attention 讓每個位置直接回看所有位置。
  • 一條主線公式:分數(點積)→ softmax 正規化 → 對 value 加權求和 → context vector。全章四個版本都是它的加料版。
  • 簡化版:分數=embedding 兩兩點積(inputs @ inputs.T);journey 的 z² = [0.4419, 0.6515, 0.5683]。
  • 可訓練版(scaled dot-product):x 先過 W_q/W_k/W_v 投影;分數除以 √d_k 防止 softmax 飽和、梯度消失;nn.Linear 版(v2)初始化更好。
  • causal mask:分數上三角填 −inf 再 softmax(一步到位、數學上等價於「只在可見範圍 softmax」)——生成式 LLM 不准偷看未來。
  • dropout:訓練時隨機丟權重、倖存值 ×1/(1−p) 補償;推論時關閉。
  • multi-head:d_out 切成 num_heads × head_dim,view / transpose 批次算完;參數量與頭數無關。GPT-2 124M:12 頭 × 64 維。
✓

知識檢核

// 七題,作答後立即看解析

從零打造 LLM · 第 3 章互動教材 | 講解為原創文字,程式碼與數值對應 Sebastian Raschka《Build a Large Language Model (From Scratch)》Ch.3 的公開程式與執行輸出,非原文翻譯
標註「書中真實輸出」的數值皆與書一致;其餘互動為瀏覽器即時運算 · 快捷鍵 T 切換主題