Bài 1 đã giới thiệu attention theo kiểu bird’s eye view: khi model xử lý token "nó" trong câu "Con mèo ngồi trên thảm vì nó mệt", nó “nhìn” lại các token trước, tính điểm liên quan, rồi pha trộn thông tin. Đủ để có mental model. Nhưng chưa đủ để hiểu tại sao.

Tại sao token cần ba vector Q, K, V thay vì một? Tại sao không dùng chính embedding gốc để tính điểm? Matrix W_Q, W_K, W_V xuất hiện từ đâu và học để làm gì? Làm sao model “biết” không được nhìn về tương lai?

Bài này trả lời từng câu, dùng analogy thư viện — đủ concrete để gắn vào code sau. Bài 10 sẽ implement self-attention từ đầu bằng NumPy. Bài này xây intuition trước.

Mental model: thư viện với hệ thống tra cứu

Tưởng tượng bạn vào thư viện cần tìm thông tin về machine learning.

Bạn có một câu hỏi — cụ thể hơn một chủ đề mơ hồ. Câu hỏi đó là cái bạn mang theo khi đi tra cứu. Mỗi cuốn sách trong thư viện có hai thứ: tên trên gáy (để bạn so khớp với câu hỏi mà không cần mở ra) và nội dung bên trong (thông tin thực sự bạn sẽ đọc nếu sách đó liên quan).

Quy trình:

Bạn có câu hỏi (Query)
Thư viện có sách, mỗi cuốn có tên trên gáy (Key)
Bên trong mỗi cuốn là nội dung thực sự (Value)

Bước 1: So khớp Query với từng Key -> tính điểm liên quan (score)
Bước 2: Sách nào khớp nhiều thì đọc nhiều, khớp ít thì đọc ít (weights)
Bước 3: Tổng hợp nội dung có trọng số (weighted sum of Values)

Không phải hard retrieval — tìm được đúng 1 cuốn. Mà là soft retrieval — đọc tất cả sách, cuốn nào liên quan thì đọc nhiều hơn, cuốn nào ít liên quan thì đọc ít, cuốn nào không liên quan thì gần như bỏ qua.

Đây chính là attention. Q là Query, K là Key, V là Value.

Tại sao cần tách Key và Value? Vì tên trên gáy sách (Key — để tra cứu) không cần giống nội dung bên trong (Value — thông tin thực). Tên gáy được tối ưu cho matching; nội dung được tối ưu cho thông tin truyền đi. Hai nhiệm vụ khác nhau, hai thứ khác nhau. Đây là lý do K và V là hai vector riêng biệt thay vì một.

Phần 1: Từ embedding đến Q, K, V

Trong bài 1, mỗi token được map sang một vector embedding kích thước D (vd 4096 chiều). Vector đó chứa “nghĩa” của token. Nhưng cùng một token xuất hiện ở các vị trí khác nhau trong câu, nó cần đóng các vai trò khác nhau:

  • Khi token này đang “hỏi” (tìm context từ các token khác): nó cần vector Q
  • Khi token này “trả lời” cho query của token khác: nó cần vector K
  • Khi thông tin của nó được dùng để tổng hợp: nó cần vector V

Ba vai trò, ba vector. Model không hard-code cách chuyển đổi — nó học ba ma trận chiếu:

W_Q: [D × d_k]    -- học cách tạo Query từ embedding
W_K: [D × d_k]    -- học cách tạo Key từ embedding
W_V: [D × d_v]    -- học cách tạo Value từ embedding

Với mỗi token có embedding x (kích thước D):

Q = x @ W_Q    -- vector hỏi: "tôi muốn biết gì"
K = x @ W_K    -- vector quảng cáo: "tôi có thông tin gì"
V = x @ W_V    -- vector nội dung: "tôi đưa ra thông tin gì"

Ba ma trận W_Q, W_K, W_V là parameters được học trong training — cùng với mọi weight khác của model. Chúng học được “chuyên môn hoá” cho ba vai trò, tối ưu hoá để attention hoạt động hiệu quả.

Tại sao không dùng chính x để tính score? Nếu dùng x @ x^T (dot product giữa các embedding gốc), ta chỉ đo “hai token có embedding gần nhau không” — hoàn toàn phụ thuộc vào semantic từ bước embedding, không học được. Thêm W_Q, W_K cho phép model học cách tính relevance phức tạp hơn nhiều: token "nó" có thể cần relevance cao với "thảm" dù hai từ về mặt semantic không gần nhau — vì đây là coreference resolution, không phải semantic similarity.

Kích thước thực tế: trong GPT-2 small, D = 768, d_k = d_v = 64 (với 12 heads, tổng 768). Trong Llama-3-8B, D = 4096, d_k = 128 (với 32 heads). W_Q và W_K thường có cùng kích thước để Q và K có cùng chiều và dot product khả dụng.

Phần 2: Attention score — Q nhìn K

Với N tokens trong input, mỗi token có Q và K riêng. Bây giờ tính score — “token i liên quan đến token j mức bao nhiêu”:

score(i, j) = Q_i · K_j    -- dot product, bài 2

Dot product (bài 2) đo mức độ “cùng hướng” của hai vector. Nếu Q_i và K_j cùng hướng → score cao → token i chú ý nhiều đến token j.

Với N tokens, cần tính N×N cặp score — tất cả tokens hỏi tất cả tokens:

            K_1    K_2    K_3    ... K_N
       Q_1 [s11   s12    s13    ... s1N]
       Q_2 [s21   s22    s23    ... s2N]
       Q_3 [s31   s32    s33    ... s3N]
       ...
       Q_N [sN1   sN2    sN3    ... sNN]

Matrix này tính bằng một phép nhân ma trận gọn:

scores = Q @ K^T    -- shape: [N, N]

Hiệu quả tính toán đây là một trong những lý do Transformer thống trị: toàn bộ attention matrix tính song song một lần, không lần lượt từng token như RNN.

Scaling: scores thường chia cho sqrt(d_k) trước khi softmax:

scores = Q @ K^T / sqrt(d_k)

Lý do: khi d_k lớn, dot product tự nhiên có magnitude lớn, đẩy softmax vào vùng gradient gần 0 (vì exp của số rất lớn áp đảo exp của số nhỏ, xác suất co lại về 0/1). Chia sqrt(d_k) giữ scores ở vùng ổn định hơn. Chi tiết sẽ có ở bài 10 khi implement.

Phần 3: Softmax — biến score thành trọng số

Scores thô là số thực tuỳ ý — có thể âm, có thể lớn, không có ý nghĩa trực tiếp về “bao nhiêu phần trăm chú ý”. Cần chuẩn hoá thành phân phối.

Softmax (bài 4) làm chính xác điều đó — biến mỗi hàng của score matrix thành probability distribution (non-negative, tổng bằng 1):

attention_weights[i] = softmax(scores[i])

Ý nghĩa của hàng i sau softmax: “token i phân bổ chú ý thế nào cho các token khác”. Ví dụ:

token "nó" (row i):
    attention weights:
        "Con"    0.03
        "mèo"    0.12
        "ngồi"   0.02
        "trên"   0.02
        "thảm"   0.71   <-- chú ý nhiều nhất
        "vì"     0.04
        "nó"     0.06

Model học được "nó" attend nhiều đến "thảm" — đây là coreference resolution xảy ra trong attention, không phải một module riêng biệt nào được lập trình thủ công. Nó emerge từ training.

Đây là điểm thú vị nhất của attention: không ai lập trình model phải học coreference, syntactic agreement, hay long-range dependency — chúng tự xuất hiện từ gradient descent qua billions of tokens training data. Từng attention head học một loại pattern khác nhau.

Phần 4: Weighted sum — V được trộn theo weights

Bước cuối: dùng attention weights để pha trộn các Value vectors:

output[i] = sum_j (attention_weights[i, j] * V[j])

Token i nhận một vector mới — tổ hợp có trọng số của V của tất cả tokens. Tokens nào được chú ý nhiều thì đóng góp nhiều vào output.

Viết gọn dạng matrix:

Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) @ V

Đây là công thức từ paper “Attention is All You Need”. Không có tham số học được trong công thức này — chỉ là phép tính. Tham số được học nằm ở W_Q, W_K, W_V tạo ra Q, K, V từ embedding.

Kết quả: mỗi token giờ có vector output kích thước d_v — vector này không còn chỉ mang nghĩa của riêng token đó, mà mang thông tin từ toàn bộ context, được pha trộn theo mức độ relevance. Token "nó" sau attention chứa chủ yếu thông tin của "thảm" — model biết "nó" đang chỉ cái gì.

Tại sao “soft”? Vì mọi tokens đều đóng góp (dù nhỏ). Khác với hard retrieval (chọn 1 kết quả duy nhất), soft retrieval giữ gradient flow qua toàn bộ tokens — training bằng backprop (bài 3) hoạt động được vì mọi thứ đều differentiable.

Phần 5: Causal masking — không cho nhìn tương lai

Trong các LLM autoregressive như GPT, model sinh token từng cái một theo thứ tự. Khi đang sinh token tại vị trí t, nó chưa biết tokens ở vị trí t+1, t+2, .... Nhưng attention matrix tính tất cả cặp (i, j) — không có gì ngăn token 3 attend đến token 7 theo cơ chế đã mô tả.

Cần một cách để “che” thông tin tương lai. Giải pháp: causal mask (còn gọi là triangular mask).

Trước bước softmax, set tất cả scores tại vị trí (i, j) với j > i (token j ở sau token i) thành -inf:

scores_masked = scores + mask

trong đó mask:
    [[0,    -inf,  -inf],
     [0,    0,     -inf],
     [0,    0,     0   ]]

Khi softmax xử lý -inf: exp(-inf) = 0. Trọng số attention tại những vị trí này về đúng 0 — token i không nhận thông tin từ token j ở tương lai.

Kết quả: attention matrix trở thành lower triangular — token 1 chỉ nhìn token 1, token 2 nhìn token 1 và 2, token N nhìn tất cả:

            token1  token2  token3
   token1  [ 1.0    0.0     0.0  ]
   token2  [ 0.4    0.6     0.0  ]
   token3  [ 0.3    0.5     0.2  ]

Tại sao mask sau scaling, trước softmax? Vì cần -inf để exp ra 0. Nếu mask sau softmax, cần normalize lại — phức tạp hơn không cần thiết.

Không phải tất cả models đều cần causal mask. BERT dùng bidirectional attention — token có thể nhìn cả hai phía. Được dùng cho classification và embedding tasks, không dùng cho text generation. Causal mask là đặc trưng của các autoregressive models.

Phần 6: Tự tính attention cho 3-token sentence

Code Python thuần với NumPy — không PyTorch. Mục tiêu là thấy từng bước rõ ràng.

import numpy as np
np.random.seed(0)

# 3 tokens, embedding dim = 4
# Giả sử: "the" (token 1), "cat" (token 2), "sat" (token 3)
X = np.array([
    [1.0, 0.0, 1.0, 0.0],  # token 1: "the"
    [0.0, 2.0, 0.0, 2.0],  # token 2: "cat"
    [1.0, 1.0, 1.0, 1.0],  # token 3: "sat"
])

# Projection matrices (normally learned via training)
d_k = 4
W_Q = np.random.randn(4, d_k) * 0.5
W_K = np.random.randn(4, d_k) * 0.5
W_V = np.random.randn(4, d_k) * 0.5

# Tạo Q, K, V cho tất cả tokens
Q = X @ W_Q   # shape: [3, 4]
K = X @ W_K   # shape: [3, 4]
V = X @ W_V   # shape: [3, 4]

# Tính attention scores
scores = Q @ K.T / np.sqrt(d_k)   # shape: [3, 3]

# Causal mask -- token không nhìn tương lai
mask = np.triu(np.ones_like(scores), k=1) * -1e9
scores = scores + mask

# Softmax row-wise (mỗi hàng là distribution của 1 token)
exp_scores = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights = exp_scores / exp_scores.sum(axis=-1, keepdims=True)

# Weighted sum của Values
output = weights @ V   # shape: [3, 4]

print("Attention weights:\n", weights.round(3))
print("\nOutput:\n", output.round(3))

Chạy code này sẽ cho attention weights dạng:

Attention weights:
 [[1.    0.    0.   ]     <- "the" chỉ nhìn chính nó (không có gì trước)
  [0.xxx 0.xxx 0.   ]     <- "cat" nhìn "the" và "cat"
  [0.xxx 0.xxx 0.xxx]]    <- "sat" nhìn cả 3 tokens

Hàng đầu luôn là [1, 0, 0] vì causal mask — token đầu tiên không có context, chỉ nhìn chính mình. Hàng cuối cùng có thể attend đến tất cả.

Tie back to analogy: row 3 ("sat") là bạn đang tra cứu thư viện. Query của "sat" so khớp với Keys của "the", "cat", "sat". Weights cho biết đọc Value của mỗi token bao nhiêu. Output của "sat" là tổng hợp thông tin từ toàn bộ 3 tokens — nó “biết” context của cả câu.

Thử tự tay: thay X bằng embedding khác, xem weights thay đổi. W_Q, W_K, W_V là random nên kết quả không có ý nghĩa ngữ nghĩa — nhưng cơ chế hoạt động đúng. Ở model thật, weights học được qua training sẽ tạo ra patterns có nghĩa.

Cheatsheet

Ký hiệuNghĩaRole
xEmbedding của tokenInput, kích thước D
W_QQuery projection matrixLearned, [D × d_k]
W_KKey projection matrixLearned, [D × d_k]
W_VValue projection matrixLearned, [D × d_v]
Q = x @ W_QQuery vector”Tôi muốn biết gì”
K = x @ W_KKey vector”Tôi có thông tin gì”
V = x @ W_VValue vector”Tôi đưa ra thông tin gì”
scores = Q @ K^T / sqrt(d_k)Raw attention scores[N × N]
weights = softmax(scores)Attention weights[N × N], mỗi hàng tổng = 1
output = weights @ VContextualized output[N × d_v]

Công thức tổng quát:

Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V

5 điểm phải nhớ:

  1. Q, K, V là ba projections học được từ cùng một embedding — ba vai trò khác nhau cho cùng một token
  2. Score = dot product(Q_i, K_j) — đo mức độ token i muốn lấy thông tin từ token j
  3. Softmax biến scores thành probability distribution — attention weights
  4. Output = weighted sum of Values — token i nhận context pha trộn từ toàn câu
  5. Causal mask set -inf cho tương lai trước softmax — GPT không “nhìn trộm”

Lời kết

Bây giờ bạn đã có intuition đầy đủ về Q/K/V — không phải ba vector tuỳ tiện mà là ba vai trò chuyên biệt cho cùng một token. Attention là soft database lookup: Query so khớp với Keys, lấy Values theo trọng số.

Bài 10 sẽ implement self-attention từ đầu bằng NumPy — multi-token batch, verify kết quả với PyTorch, rồi mở rộng sang multi-head attention. Code sẽ dài hơn, nhưng intuition đã có rồi thì logic code rất tự nhiên.

Trước khi đến bài 10: chạy đoạn code ở phần 6 với embedding X của riêng bạn. Thay W_Q, W_K, W_V bằng identity matrix xem điều gì xảy ra. Tắt causal mask xem weights thay đổi thế nào. Một tiếng experiment sẽ giúp phần 6 của bài 10 có nghĩa hơn nhiều so với chỉ đọc.