Năm 2017, nhóm nghiên cứu tại Google công bố bài báo khoa học lịch sử: "Attention Is All You Need", giới thiệu kiến trúc mạng Transformer. Sự kiện này đã kết liễu thời kỳ thống trị kéo dài hàng thập kỷ của các mạng nơ-ron tuần hoàn RNN/LSTM và mở ra kỷ nguyên của các mô hình ngôn ngữ lớn (LLM) thay đổi thế giới ngày nay.

Điểm mấu chốt của Transformer là việc loại bỏ hoàn toàn các vòng lặp tuần tự theo bước thời gian, cho phép song song hóa tính toán 100% trên phần cứng đồ họa GPU. Bài học này sẽ đưa kiến trúc Transformer dưới kính hiển vi: mổ xẻ toán học của phép toán Scaled Dot-Product Self-Attention (Q, K, V), cơ chế Multi-Head Attention, mã hóa vị trí (Positional Encoding), và tự tay lắp ráp một khối Transformer Block hoàn chỉnh bằng PyTorch.

✅ Bạn cần gì trước khi bắt đầu
Thư viện: pip install torch. Không tải dữ liệu, không cần mạng.

Kiến thức cần có: Bài 9 — ý tưởng Attention và vì sao nó ra đời (điểm nghẽn của Context Vector tĩnh). Bài này lấy đúng ý tưởng đó rồi bỏ hẳn phần tuần hoàn. Bài 2 cho tích vô hướng, Bài 3 cho phép nhân ma trận và ghép chiều — mục 10.1 dùng cả hai liên tục.

10.1 Cuộc cách mạng song song hóa: Cơ chế Self-Attention

Như chúng ta đã tìm hiểu ở Bài 9, mạng nơ-ron tuần hoàn (RNN) xử lý câu văn một cách tuần tự từng từ một. Điều này đồng nghĩa với việc nơ-ron không thể tính toán từ thứ 10 nếu chưa tính xong trạng thái ẩn của từ thứ 9.

Ngược lại, Transformer xử lý tất cả các từ trong câu văn cùng một lúc. Để bắt được ngữ cảnh liên kết giữa các từ, nó giới thiệu phép toán Self-Attention (Tự chú ý). Mỗi từ đầu vào sẽ tự động tính toán mức độ liên quan ngữ nghĩa của chính nó với tất cả các từ khác trong câu thông qua ba vector đại diện:

  • Query ($Q$ - Câu hỏi): Đại diện cho thông tin từ hiện tại đang đi tìm ngữ cảnh liên quan.
  • Key ($K$ - Từ khóa): Đại diện cho nhãn định danh đặc trưng của các từ khác trong câu để đối chiếu.
  • Value ($V$ - Giá trị): Đại diện cho nội dung thông tin thực tế của từ đó được nạp đi sau khi đã tìm thấy sự chú ý thích hợp.
📐 Công thức toán học Scaled Dot-Product Self-Attention
Với các ma trận Query $Q$, Key $K$, và Value $V$ (trong đó $d_k$ là kích thước số chiều của Key), phép toán tự chú ý được tính bằng công thức: $$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V$$ Giải phẫu từng bước của công thức:
  1. Nhân chập Q và K ($Q K^T$): Đo lượng điểm tương đồng (tích vô hướng) giữa tất cả các cặp từ trong câu văn. Kết quả trả về một ma trận vuông kích thước $N \times N$ (với $N$ là chiều dài chuỗi).
  2. Chia tỉ lệ cho $\sqrt{d_k}$ (Scaling factor): Khi số chiều $d_k$ rất lớn, tích vô hướng $Q K^T$ có xu hướng có trị số cực kỳ lớn, làm đẩy hàm Softmax vào vùng bão hòa có độ dốc cực nhỏ (Vanishing Gradient). Phép chia cho $\sqrt{d_k}$ giữ cho phân phối điểm số có phương sai bằng 1, giúp đạo hàm truyền ngược ổn định.
  3. Hàm kích hoạt Softmax: Chuyển các điểm tương đồng thành phân phối xác suất chú ý có giá trị từ 0 đến 1.
  4. Nhân với Value ($V$): Lấy trọng số xác suất chú ý nhân chập với nội dung thật của Value để thu về các vector ngữ cảnh tổng hợp cao cấp.
⚠️ Cạm bẫy: Độ phức tạp tính toán bình phương O(N^2)
Vì phép toán nhân ma trận vuông $Q K^T$ so sánh mọi từ với mọi từ khác trong câu văn, độ phức tạp không gian và thời gian của lớp Self-Attention tăng theo hàm mũ bình phương $\mathcal{O}(N^2)$ (với $N$ là độ dài ngữ cảnh). Đây là lý do tại sao các mô hình LLM tiêu tốn lượng VRAM khổng lồ khi bạn tăng độ dài cửa sổ chat (Context Window) và là điểm nghẽn lớn nhất của kiến trúc Transformer.
🔢 Con số thật: vì sao chia cho $\sqrt{d_k}$ ổn định được phương sai
Giả sử các thành phần của $Q$ và $K$ đều được khởi tạo ngẫu nhiên độc lập theo phân phối chuẩn $\mathcal{N}(0, 1)$. Mô phỏng thống kê thực tế (20.000 lần lấy mẫu mỗi trường hợp) cho thấy:
  • $d_k = 8$: phương sai của $Q K^T$ (chưa chia tỉ lệ) $\approx 8.09$ — gần đúng bằng $d_k$.
  • $d_k = 64$: phương sai $\approx 63.22$.
  • $d_k = 512$: phương sai $\approx 512.09$.
Đúng như lý thuyết dự đoán: phương sai của tích vô hướng $d_k$ số hạng độc lập tăng tuyến tính theo $d_k$. Sau khi chia cho $\sqrt{d_k}$, cả ba trường hợp đều đưa phương sai về xấp xỉ $1.0$ (0.988 → 1.011 → 1.0002) bất kể $d_k$ lớn hay nhỏ — đây chính là lý do phép chia tỉ lệ giữ cho phân phối điểm số ổn định, không phụ thuộc vào kích thước mô hình.

10.2 Học đa chiều: Multi-Head Attention & Mã hóa vị trí Positional Encoding

Nếu chỉ sử dụng một luồng Attention đơn lẻ, mô hình chỉ có thể tập trung chú ý vào một mối liên kết ngữ nghĩa duy nhất tại một thời điểm (ví dụ: liên kết ngữ pháp danh từ - động từ).

Để khắc phục, Transformer sử dụng cơ chế Multi-Head Attention (Chú ý đa luồng). Thay vì tính Attention trực tiếp trên các vector có số chiều gốc $d_{\text{model}}$, mạng nơ-ron chia nhỏ các chiều này thành $h$ luồng song song (mỗi luồng có số chiều nhỏ hơn là $d_k = d_{\text{model}} / h$). Mỗi luồng "Head" sẽ được khởi tạo các ma trận trọng số độc lập để tự do học các mối quan hệ ngữ cảnh khác nhau (ví dụ: Head 1 học quan hệ cú pháp chủ vị; Head 2 học quan hệ địa lý; Head 3 học quan hệ thời gian). Sau đó, kết quả từ toàn bộ các Head được ghép nối (concatenate) lại và chiếu qua một lớp Linear để trả về số chiều gốc.

Vì Transformer xử lý song song toàn bộ các từ cùng một lúc, mô hình không hề biết thứ tự từ trước sau. Với Transformer, câu văn "Tôi yêu bạn" và câu "Bạn yêu tôi" có ma trận biểu diễn hoàn toàn giống hệt nhau (xem câu văn như một túi từ xáo trộn không thứ tự - Bag of Words).

Để đưa thông tin thứ tự thời gian vào, ta bắt buộc phải cộng thêm một vector Positional Encoding (Mã hóa vị trí) trực tiếp vào vector nhúng từ (Word Embedding) trước khi đưa vào khối Attention đầu tiên.

📐 Công thức mã hóa vị trí hình sin (Sinusoidal Positional Encoding)
Trong bài báo gốc của Google, nhóm nghiên cứu sử dụng các sóng hình sin và cosin có tần số khác nhau để tự động tạo ra một vector mã hóa vị trí duy nhất ứng với tọa độ $pos$ và chiều thứ $i$: $$PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{\frac{2i}{d_{\text{model}}}}}\right)$$ $$PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{\frac{2i}{d_{\text{model}}}}}\right)$$ Mối liên hệ hình sin này cho phép mô hình dễ dàng học được vị trí tương đối giữa các từ (ví dụ từ đứng cách nhau $k$ ký tự) vì $PE_{pos+k}$ có thể biểu diễn dưới dạng hàm tuyến tính của $PE_{pos}$.
⚠️ Ngộ nhận thường gặp: Multi-Head Attention KHÔNG tốn thêm tham số
Nhiều người học lầm tưởng rằng chia thành $h$ luồng Head sẽ nhân số tham số lên $h$ lần. Thực tế hoàn toàn ngược lại: 4 lớp nn.Linear ($W_q, W_k, W_v, W_o$) luôn có kích thước cố định $d_{\text{model}} \times d_{\text{model}}$ bất kể chia thành bao nhiêu Head, vì việc "chia luồng" chỉ là thao tác .view() định hình lại (reshape) tensor sẵn có, không tạo ra ma trận trọng số mới. Với $d_{\text{model}} = 64$ (đã tính và kiểm chứng bằng code), tổng tham số của 4 lớp Linear là đúng $16.640$ tham số dù dùng $1$ Head hay $8$ Head — số Head chỉ thay đổi cách bộ nhớ được "cắt lát" để tính song song nhiều góc nhìn ngữ cảnh khác nhau, chứ không làm mô hình nặng hơn.

10.3 Kiến trúc Encoder-Decoder và mô hình GPT (Decoder-only)

Kiến trúc Transformer nguyên bản gồm hai phần lớn xếp chồng lên nhau:

  • Encoder (Bộ mã hóa): Đọc toàn bộ câu gốc để trích xuất đặc trưng hai chiều (Bidirectional). Được sử dụng trong các mô hình hiểu ngôn ngữ như BERT.
  • Decoder (Bộ giải mã): Tích hợp thêm cơ chế tự hồi quy (Autoregressive) để sinh từng từ tiếp theo.

Đối với phần Decoder, khi huấn luyện sinh văn bản, chúng ta bắt buộc phải sử dụng một bộ mặt nạ gọi là Causal Mask (Mặt nạ nhân quả / Look-ahead Mask). Lớp mặt nạ này che đi toàn bộ các từ ở tương lai bằng cách ghi đè điểm số tích vô hướng $Q K^T$ tại các vị trí đó thành $-\infty$ trước khi đưa vào hàm Softmax: $$\text{Softmax}(-\infty) = 0$$ Điều này đảm bảo khi dự đoán từ tiếp theo ở bước $t$, mô hình hoàn toàn không thể "ăn gian" nhìn thấy dữ liệu ở các bước $t+1, t+2$ trong quá trình huấn luyện.

💡 Sự thống trị của mô hình Decoder-only (GPT)
Hầu hết các LLM mạnh mẽ nhất hiện nay như GPT-4, Llama, Gemini hay Claude đều loại bỏ hoàn toàn phần Encoder truyền thống và chuyển sang thiết kế Decoder-only (Chỉ sử dụng bộ giải mã). Kiến trúc này đơn giản hơn rất nhiều, dễ dàng mở rộng quy mô (scalable) và tối ưu hóa tính toán tự hồi quy để sinh chữ một cách sáng tạo và thông minh.

10.4 Dự án thực hành bài 10: Xây dựng khối Transformer Block hoàn chỉnh bằng PyTorch

Dự án thực hành của bài học này là tự tay lập trình từ đầu một khối Transformer Block hoàn chỉnh sử dụng PyTorch.

Chúng ta sẽ xây dựng lớp tự chú ý đa luồng MultiHeadAttention, kết hợp với các kết nối tắt (Residual Connections), lớp chuẩn hóa LayerNorm và mạng thần kinh truyền thẳng FeedForward để đóng gói thành khối kiến trúc hoàn chỉnh.

transformer_block.py
import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        assert d_model % num_heads == 0, "d_model must divide evenly by num_heads"
        
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        
        # Linear projections producing Query, Key and Value.
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        
        # Final projection, applied after the heads are concatenated.
        self.W_o = nn.Linear(d_model, d_model)
        
    def forward(self, q, k, v, mask=None):
        batch_size, seq_len, _ = q.size()
        
        # 1. Project the input, then split it across the heads.
        #    Shapes: (batch, seq, d_model) -> (batch, seq, heads, d_k) -> (batch, heads, seq, d_k)
        Q = self.W_q(q).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_k(k).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_v(v).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        
        # 2. Scaled dot-product scores. Dividing by sqrt(d_k) keeps the values in a
        #    range where Softmax does not saturate.
        # Q K^T: (Batch, heads, Seq, d_k) x (Batch, heads, d_k, Seq) -> (Batch, heads, Seq, Seq)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k, dtype=torch.float32))
        
        # Apply the mask. -1e9 before Softmax becomes effectively 0 after it.
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
            
        # 3. Softmax turns the scores into attention weights summing to 1.
        attention_weights = F.softmax(scores, dim=-1)
        
        # 4. Weight the Values, then restore the original dimensions.
        # (Batch, heads, Seq, Seq) x (Batch, heads, Seq, d_k) -> (Batch, heads, Seq, d_k)
        context = torch.matmul(attention_weights, V)
        
        #    Concatenate the heads back together: (batch, seq, d_model)
        context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
        
        # Output projection.
        output = self.W_o(context)
        return output

class FeedForwardNetwork(nn.Module):
    def __init__(self, d_model, d_ff):
        super(FeedForwardNetwork, self).__init__()
        self.linear1 = nn.Linear(d_model, d_ff)
        self.relu = nn.ReLU()
        self.linear2 = nn.Linear(d_ff, d_model)
        
    def forward(self, x):
        return self.linear2(self.relu(self.linear1(x)))

class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super(TransformerBlock, self).__init__()
        
        # Multi-head attention.
        self.attention = MultiHeadAttention(d_model, num_heads)
        
        # LayerNorm, applied around each sub-block (the residual connections below
        # are what let gradients reach the early layers of a deep stack).
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        
        # The position-wise feed-forward network.
        self.feed_forward = FeedForwardNetwork(d_model, d_ff)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, x, mask=None):
        # 1. Self-attention + residual connection + LayerNorm.
        #    x + attn_out is the residual: it gives gradients a path straight back.
        attn_out = self.attention(x, x, x, mask)
        x = self.norm1(x + self.dropout(attn_out))
        
        # 2. Feed-forward + residual connection + LayerNorm, same pattern again.
        ff_out = self.feed_forward(x)
        x = self.norm2(x + self.dropout(ff_out))
        
        return x

if __name__ == "__main__":
    # Seed, so the numbers below are the same on every run.
    torch.manual_seed(42)

    d_model, num_heads, d_ff = 64, 8, 256
    seq_len, batch_size = 10, 2

    block = TransformerBlock(d_model=d_model, num_heads=num_heads, d_ff=d_ff)
    block.eval()  # no dropout, so the causal test below is deterministic
    print(f"=== Transformer block: d_model={d_model}, heads={num_heads}, d_ff={d_ff} ===")

    x = torch.randn(batch_size, seq_len, d_model)
    print(f"input shape:  {tuple(x.shape)}")

    # Lower-triangular mask: position i may attend to 0..i, never to i+1 onwards.
    causal_mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0).unsqueeze(1)
    print(f"causal mask shape: {tuple(causal_mask.shape)}")

    with torch.no_grad():
        output = block(x, mask=causal_mask)
    print(f"output shape: {tuple(output.shape)}  (identical to the input, as it must be)")

    # ---------------------------------------------------------------- the real test
    # Matching shapes prove almost nothing: an attention that ignored the mask
    # entirely would still return the right shape. The property that actually
    # matters for a decoder (GPT) is CAUSALITY — position i must not see i+1.
    #
    # So: change ONLY the last token and re-run. If the mask works, every earlier
    # position must come out bit-for-bit identical, because none of them was
    # allowed to look at the token we changed.
    print("\n=== Does the causal mask actually work? ===")
    x_perturbed = x.clone()
    x_perturbed[:, -1, :] = torch.randn(batch_size, d_model)  # rewrite the LAST token

    with torch.no_grad():
        output_perturbed = block(x_perturbed, mask=causal_mask)

    earlier_drift = (output[:, :-1, :] - output_perturbed[:, :-1, :]).abs().max().item()
    last_drift = (output[:, -1, :] - output_perturbed[:, -1, :]).abs().max().item()

    print(f"  changed the last token only")
    print(f"  largest change in positions 0..{seq_len - 2}: {earlier_drift:.2e}")
    print(f"  largest change in the last position:  {last_drift:.4f}")

    if earlier_drift < 1e-6 < last_drift:
        print("  PASS — earlier positions did not move, the last one did.")
        print("  That is causality: the past cannot see the future.")
    else:
        print("  FAIL — the mask is leaking information backwards in time.")

    # And the counter-test: without a mask, changing the last token must disturb
    # everything, because every position now attends to every other.
    with torch.no_grad():
        free = block(x, mask=None)
        free_perturbed = block(x_perturbed, mask=None)
    free_drift = (free[:, :-1, :] - free_perturbed[:, :-1, :]).abs().max().item()
    print(f"\n  same experiment with NO mask: earlier positions moved by {free_drift:.4f}")
    print("  Non-zero, as expected — which confirms the test above measures the mask")
    print("  and not some accident of the architecture.")
💡 Khác biệt giữa Batch Normalization và Layer Normalization
Trong khi mạng CNN sử dụng Batch Normalization (chuẩn hóa dữ liệu theo chiều dọc lô dữ liệu), Transformer lại sử dụng Layer Normalization (LayerNorm) (chuẩn hóa độc lập dữ liệu theo từng mẫu riêng biệt dọc theo chiều số đặc trưng). LayerNorm tỏ ra vượt trội hoàn toàn khi xử lý dữ liệu chuỗi có độ dài thay đổi liên tục, giúp ổn định hóa trị số kích hoạt của mạng cực kỳ tốt.

Chạy nó ra như sau:

Terminal
=== Transformer block: d_model=64, heads=8, d_ff=256 ===
input shape:  (2, 10, 64)
causal mask shape: (1, 1, 10, 10)
output shape: (2, 10, 64)  (identical to the input, as it must be)

=== Does the causal mask actually work? ===
  changed the last token only
  largest change in positions 0..8: 0.00e+00
  largest change in the last position:  3.9485
  PASS — earlier positions did not move, the last one did.
  That is causality: the past cannot see the future.

  same experiment with NO mask: earlier positions moved by 0.2319
  Non-zero, as expected — which confirms the test above measures the mask
  and not some accident of the architecture.
🔬 Vì sao phép kiểm này quan trọng hơn việc khớp kích thước
Kích thước đầu ra bằng đầu vào là điều kiện cần, nhưng nó gần như không chứng minh gì. Một cơ chế attention bỏ qua hoàn toàn mặt nạ — hoặc thậm chí chỉ lấy trung bình mọi token — vẫn trả về đúng kích thước đó. Nếu bạn tự cài attention và chỉ kiểm shape, bạn có thể để lọt một lỗi phá huỷ toàn bộ mô hình.

Tính chất thật sự cần đúng ở một Decoder (tức GPT) là nhân quả: vị trí $i$ không được nhìn thấy vị trí $i+1$. Nếu nó nhìn được, mô hình sẽ "gian lận" trong lúc huấn luyện — nó thấy trước từ cần dự đoán — rồi sụp hoàn toàn khi sinh văn bản thật, vì lúc đó tương lai chưa tồn tại.

Cách kiểm trong script rất đơn giản và dứt khoát: đổi duy nhất token cuối rồi chạy lại. Nếu mặt nạ đúng, mọi vị trí trước đó phải ra giống hệt từng bit — vì không vị trí nào được phép nhìn token vừa đổi. Kết quả đo được là 0.00e+00: không phải "rất nhỏ", mà là đúng bằng không.

Và phép thử đối chứng mới làm phép kiểm có giá trị: chạy lại không có mặt nạ, các vị trí trước đổi 0,2319 — khác 0 rõ rệt. Nếu thiếu bước này, con số 0 ở trên có thể chỉ là do kiến trúc tình cờ không truyền thông tin, chứ không phải do mặt nạ làm việc. Đây là thói quen nên mang theo: một phép kiểm cho kết quả "đúng như mong đợi" chỉ đáng tin khi bạn cũng chứng minh được nó biết báo sai.

Cách chạy dự án này trên máy bạn

  1. Cài: pip install torch.
  2. Tải transformer_block.py ở cuối bài, hoặc gõ lại đoạn code trên.
  3. Chạy: python3 transformer_block.py. Bạn sẽ ra đúng những con số trên nhờ torch.manual_seed(42)block.eval()eval() tắt dropout, nếu không thì hai lần chạy khác nhau và phép kiểm nhân quả sẽ báo sai.
  4. Rồi thử phá nó theo hai cách:
    • Xoá dòng áp dụng mặt nạ trong MultiHeadAttention (chỗ điền -1e9). Phép kiểm sẽ chuyển từ PASS sang FAIL, và bạn thấy chính xác lỗi đó biểu hiện thế nào — thay vì phải tin lời mô tả.
    • Đổi torch.tril thành torch.triu (mặt nạ tam giác trên). Mô hình vẫn chạy, kích thước vẫn đúng, nhưng giờ nó chỉ nhìn được tương lai — đúng kiểu lỗi mà phép kiểm shape không bao giờ phát hiện.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt được:
  • Đạt được: Giải phẫu chi tiết cơ chế Self-Attention và Multi-Head Attention trong Transformer.
  • Đạt được: Hiểu rõ cấu trúc song song hóa khối Encoder-Decoder làm nền móng cho các siêu mô hình ngôn ngữ lớn (LLM).

Cầu nối bài tiếp theo: Sau khi đã hiểu cấu tạo phần cứng bên trong LLM, chúng ta sẽ bắt đầu học cách lập trình tương tác với các LLM đã được huấn luyện thông qua API và các kỹ thuật Prompt Engineering ở Bài số 11.

Tải file code thực hành minh họa bài học

File Python transformer_block.py — mã nguồn triển khai lớp chú ý đa luồng MultiHeadAttention và khối TransformerBlock (chạy python transformer_block.py, yêu cầu cài đặt thư viện torch):

Tải về transformer_block.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 9: Mạng tuần hoàn (RNN) & Attention Bài 11: Lập trình Prompt & Làm chủ API LLM Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận