Mở đầu: LLM chưa từng nhìn thấy một chữ cái nào
Đây là sự thật gây sốc nhất trong cả series: khi bạn gõ "xin chào" vào ChatGPT, model không hề nhìn thấy 8 ký tự x-i-n-space-c-h-à-o. Nó nhìn thấy đúng 2-3 con số nguyên — ID của 2-3 "khối chữ" đã được cắt sẵn từ trước. Toàn bộ kiến trúc Transformer đồ sộ của Bài 14, mọi phép nhân ma trận, mọi tham số học được — tất cả vận hành trên một bảng chữ cái nhân tạo mà chính model chưa bao giờ "thấy" các chữ cái gốc tạo nên nó. Đây không phải chi tiết kỹ thuật vặt vãnh: nó giải thích trực tiếp vì sao GPT-4 từng đếm sai số chữ "r" trong "strawberry", và vì sao cùng một câu tiếng Việt luôn tốn tiền API nhiều hơn câu tiếng Anh tương đương.
Bài này tự xây bộ cắt chữ đó — BPE — từ đúng một ý tưởng: đếm cặp ký tự kề nhau xuất hiện nhiều nhất, gộp chúng lại, lặp lại. Ta sẽ train nó thật trên toàn văn Truyện Kiều, đo bằng số thật vì sao vocab lớn hơn thì câu ngắn lại, tự tay tái hiện lỗi "strawberry" bằng chính tokenizer của mình, rồi khép vòng tròn: pretraining hoá ra chỉ là bài toán phân loại của Bài 3 — chỉ khác "nhãn" giờ là hàng chục nghìn lớp thay vì 2.
Tài nguyên ngoài: Sennrich et al. 2016 — Neural Machine Translation of Rare Words with Subword Units (paper gốc của BPE), tiktoken (thư viện tokenizer thật của OpenAI).
1. Từ chữ tới số: 3 mức tách
Mạng neural chỉ nhận số — bước đầu tiên của MỌI hệ thống NLP là quyết định cách băm văn bản thành các đơn
vị rời rạc rồi đánh số cho từng đơn vị (nối thẳng embeddingLookup của
Bài 12: mỗi ID tra ra đúng 1 hàng trong bảng embedding). Có
3 mức tách, mỗi mức đánh đổi khác nhau:
| Mức tách | Ví dụ ("không gian") | Vấn đề |
|---|---|---|
| Ký tự | k / h / ô / n / g / _ / g / i / a / n | Chuỗi quá dài — 10 ký tự = 10 bước cho model "nhớ", chi phí attention $O(n^2)$ (Mục 4) nổ nhanh |
| Nguyên từ | không_gian (1 token) | Vocab nổ (tiếng Việt có hàng trăm nghìn từ ghép); từ MỚI chưa từng gặp (OOV) → model bó tay hoàn toàn |
| Subword (BPE) | không / _gian (2 token) | Điểm cân bằng: vocab kích thước vừa phải, từ mới vẫn ghép được từ mảnh đã biết — không có "chưa từng gặp" |
Tiếng Việt có một đặc thù không ngôn ngữ nào trong series gặp trước đây: dấu thanh. "ma", "má", "mà", "mả", "mã", "mạ" — 6 chữ khác nghĩa hoàn toàn, chỉ khác dấu. Một tokenizer tách theo byte thô (cách GPT thật làm với UTF-8) có nguy cơ cắt đứt tổ hợp byte mã hoá 1 ký tự có dấu giữa chừng, tạo ra chuỗi byte không hợp lệ. Bài này (và file tải về) cố tình tách theo code point (ký tự Unicode đã chuẩn hoá) thay vì byte thô, để mỗi "ạ", "ữ", "ộ" luôn là đúng 1 đơn vị — đơn giản hoá cho mục đích học, đổi lấy việc không mô phỏng 100% cách GPT thật xử lý UTF-8 byte-level.
2. BPE tự xây: đếm cặp kề, merge, lặp lại
Thuật toán BPE (Sennrich et al. 2016) chỉ có 3 bước, lặp lại $N$ lần (N = số merge = kích thước vocab):
- Biểu diễn mỗi từ thành chuỗi ký tự, thêm token
</w>đánh dấu cuối từ; - Đếm mọi cặp ký hiệu kề nhau trên toàn corpus, tìm cặp xuất hiện nhiều nhất;
- Gộp (merge) cặp đó thành 1 ký hiệu mới, cập nhật lại toàn bộ corpus, quay lại bước 2.
Ví dụ tí hon tính tay trước khi tổng quát: corpus chỉ có "ab ab ab ab cd cd ef". Ban đầu mỗi
từ là chuỗi ký tự: a b </w> (×4), c d </w> (×2),
e f </w> (×1). Đếm cặp kề: $(a,b){=}4$, $(b,{<}/w{>}){=}4$, $(c,d){=}2$,
$(d,{<}/w{>}){=}2$, $(e,f){=}1$, $(f,{<}/w{>}){=}1$ — cặp $(a,b)$ (hoặc $(b,{<}/w{>})$, đồng hạng) thắng
với tần suất 4, được merge đầu tiên thành ký hiệu ab (đã xác nhận bằng self-test: merge đầu
tiên chính xác là cặp này).
function getPairFreqs(vocab) {
const pairs = new Map();
for (const [symbolStr, freq] of vocab) {
const symbols = symbolStr.split(' ');
for (let i = 0; i < symbols.length - 1; i++) {
const pair = symbols[i] + '\0' + symbols[i + 1];
pairs.set(pair, (pairs.get(pair) || 0) + freq); // dem theo TAN SUAT tu, khong phai dem 1
}
}
return pairs;
}
function trainBPE(text, numMerges) {
let vocab = initVocabFromText(text); // moi tu -> "c h u </w>", tan suat
const merges = [];
for (let i = 0; i < numMerges; i++) {
const pairs = getPairFreqs(vocab);
const bestPair = [...pairs.entries()].reduce((a, b) => (b[1] > a[1] ? b : a))[0];
vocab = mergeVocab(vocab, bestPair); // thay THE cap do bang 1 ky hieu moi
merges.push(bestPair); // LUU DUNG THU TU — can de encode van ban moi sau nay
}
return merges;
}
Train thật trên 20.000 ký tự đầu Truyện Kiều (đã kiểm chứng bằng self-test Node — xem file tải về), rồi encode câu "Trăm năm trong cõi người ta, chữ tài chữ mệnh khéo là ghét nhau." ở 2 kích thước vocab khác nhau — đo được số token thật, không suy diễn:
| Vocab size (số merge) | Số token của câu trên (64 ký tự) | Vài token mẫu |
|---|---|---|
| $\sim 100$ | 39 token | Tr / ă / m</w> / n / ă / m</w> / tr / ong</w>… |
| $\sim 1000$ | 19 token | Trăm</w> / năm</w> / trong</w> / c / õ / i</w>… |
Vocab 1.000 cho ra ít hơn một nửa số token so với vocab 100, trên đúng cùng một câu — vì với nhiều merge hơn, những từ phổ biến ("Trăm", "năm", "trong") đã được gộp trọn vẹn thành 1 token duy nhất thay vì rớt về từng mảnh ký tự rời rạc. Đây chính là lý do vocab size là một quyết định kỹ thuật thật: vocab lớn hơn → câu ngắn hơn (rẻ hơn khi tính theo token) nhưng bảng embedding to hơn (tốn bộ nhớ hơn) — đánh đổi kinh điển không có đáp án "đúng" tuyệt đối, GPT thật dùng vocab cỡ 50.000-100.000+ làm điểm cân bằng.
Việc encode xong phải decode ngược lại đúng y hệt câu gốc — không đúng thì tokenizer vô dụng dù merge có "thông minh" cỡ nào. Đây là chỗ một bug tinh vi thật sự xuất hiện khi tự viết BPE lần đầu (đã tự bắt được qua self-test, không phải đoán):
// Ban dau tuong "</w>" luon la 1 token DUNG RIENG, nen decode chi can
// kiem tra t === "</w>" de biet ranh gioi tu. SAI: sau nhieu vong merge,
// "</w>" thuong bi GAN DINH vao ky tu cuoi thanh 1 token DUY NHAT (vd
// "ta,</w>" thay vi "ta," roi rieng "</w>") — phai kiem tra SUFFIX,
// khong phai so sanh bang tuyet doi. Tu-test round-trip da bat dung loi nay.
function decodeTokens(tokens) {
const words = [];
let word = '';
for (const t of tokens) {
if (t.endsWith('</w>')) { // SUFFIX check — KHONG phai t === '</w>'
word += t.slice(0, -4);
words.push(word);
word = '';
} else {
word += t;
}
}
if (word) words.push(word);
return words.join(' ');
}
Đây là lỗi có thật, nổi tiếng, và tự tay tái hiện được bằng đúng tokenizer vừa xây. Từ
"strawberry" không hề xuất hiện trong corpus tiếng Việt dùng để train — encode nó bằng bộ
merge học từ Truyện Kiều (vocab~500) cho ra (đã verify bằng self-test):
["s", "tr", "a", "w", "b", "e", "r", "r", "y</w>"] — 9 token cho 10 ký tự.
Đếm tay: "strawberry" có 3 chữ "r" (vị trí thứ 3, 8, 9). Nhưng nhìn vào danh sách
token trên, chỉ có 2 token là chữ "r" đứng độc lập (vị trí 8, 9) — chữ
"r" thứ 3 (vị trí 3 trong từ gốc) đã bị nuốt vào bên trong token "tr",
không còn hiện diện như một đơn vị riêng mà model có thể "đếm" được. Một model chỉ suy luận trên chuỗi
ID token — không phải chuỗi ký tự — hoàn toàn có lý do hợp lý để đếm ra 2 thay vì 3: nó chưa từng được
huấn luyện để "biết" rằng token "tr" bí mật chứa 1 chữ "r" bên trong. Đây không phải
model "ngu" hay "không biết đếm" — nó đang trung thực báo cáo đúng những gì nó nhìn thấy, và
cái nó nhìn thấy không phải là chữ cái.
Bài học thực chiến: bất kỳ tác vụ nào đòi hỏi suy luận ở mức ký tự (đếm chữ cái, đảo ngược chuỗi, đánh vần) đều là điểm yếu cấu trúc của mọi LLM dựa trên subword tokenizer — không phải giới hạn của việc "học chưa đủ dữ liệu".
3. Pretraining = next-token prediction: cross-entropy tái xuất
Giờ mới tới câu hỏi cốt lõi: LLM học "hiểu" ngôn ngữ bằng cách nào? Câu trả lời gây bất ngờ vì sự đơn giản: đoán token tiếp theo, hết. Cho model xem $t_1, t_2, \ldots, t_{i-1}$, bắt nó đoán $t_i$; đúng ngữ pháp, đúng logic, đúng sự thật — không phải mục tiêu trực tiếp, mà là hệ quả gián tiếp của việc tối ưu một mục tiêu duy nhất: xác suất đoán đúng token kế tiếp càng cao càng tốt.
Về mặt công thức, đây là đúng bài toán phân loại của Bài 3 — không có gì mới, chỉ đổi quy mô: thay vì phân loại nhị phân (spam/không spam), giờ phân loại hàng chục nghìn lớp (mỗi token trong vocab là 1 lớp). Cross-entropy y hệt công thức cũ:
$$L = -\log P(t_i \mid t_1, \ldots, t_{i-1})$$
Giải nghĩa: $P(t_i \mid t_1, \ldots, t_{i-1})$ là xác suất model gán cho đúng token thật $t_i$, tính từ softmax trên toàn bộ vocab (đầu ra cuối cùng của Transformer — Bài 14 Mục 4.5) — loss càng nhỏ khi model càng "tự tin đúng chỗ". Kỹ thuật train gọi là teacher forcing: ở mỗi bước, model luôn được cho xem token ĐÚNG THẬT của bước trước (từ dữ liệu), không phải token nó vừa tự đoán — tránh một lỗi tự đoán lan truyền và khuếch đại qua hàng nghìn bước.
// Dung cong thuc Bai 3/10 — CHI khac: so lop = kich thuoc VOCAB (hang
// chuc nghin), khong phai 2 (spam/khong spam) hay 10 (chu so MNIST).
import { softmaxCrossEntropy } from './ai-neuro.js';
function nextTokenLoss(logitsSequence, targetTokenIds, vocabSize) {
let totalLoss = 0;
for (let t = 0; t < targetTokenIds.length; t++) {
const yOneHot = oneHot(targetTokenIds[t], vocabSize); // dung 1 lop THAT trong so vocabSize lop
totalLoss += softmaxCrossEntropy(logitsSequence[t], yOneHot).data[0];
}
return totalLoss / targetTokenIds.length; // trung binh tren toan chuoi
}
Cách đọc điểm số phổ biến trong ngành là perplexity $= e^{L}$ — "độ bối rối" trung bình, đọc như "model coi như đang phân vân đều giữa bao nhiêu token khả dĩ tại mỗi bước". Perplexity = 1 là đoán đúng tuyệt đối; perplexity = kích thước vocab là đoán ngẫu nhiên hoàn toàn (không học được gì). Vì sao "chỉ đoán từ tiếp theo" lại ép model phải hiểu ngữ pháp, sự thật, thậm chí suy luận? Trực giác được tóm trong nguyên lý gây tranh cãi "nén tốt tương đương hiểu" (compression ↔ understanding): đoán đúng token kế tiếp của câu "Thủ đô của Việt Nam là ___" đòi hỏi model phải "biết" sự thật địa lý, không có cách nào đoán đúng bằng thống kê bề mặt thuần tuý khi câu đủ đa dạng ngữ cảnh — dù đây vẫn là chủ đề tranh luận học thuật chưa ngã ngũ hoàn toàn về mức độ "hiểu" thật sự đến đâu.
- $L=0{,}1$: perplexity $\approx 1{,}105$ — model gần như chắc chắn, "phân vân" giữa hơn $1$ chút.
- $L=1{,}0$: perplexity $\approx 2{,}718$ ($=e$) — như đang phân vân đều giữa khoảng $2$-$3$ lựa chọn khả dĩ.
- $L=3{,}0$: perplexity $\approx 20{,}1$ — phân vân giữa khoảng $20$ token khả dĩ mỗi bước.
- $L=5{,}0$: perplexity $\approx 148{,}4$ — mô hình khá "mù mờ", gần như đoán ngẫu nhiên trong một nhóm lớn.
Bài 14 đã học self-attention tính $QK^T$ — ma trận điểm số kích thước $T
\times T$ với $T$ là độ dài chuỗi (context window). Nghĩa là chi phí tính toán tăng theo
bình phương độ dài chuỗi, không phải tuyến tính — gấp đôi context window không làm
chậm gấp đôi, mà gấp khoảng 4 lần. Đo THẬT bằng đúng multiHeadAttention của Bài
14 (không suy diễn):
| Độ dài chuỗi $T$ | Thời gian 1 lần forward attention | Tỉ lệ so với $T$ trước (gấp đôi) |
|---|---|---|
| 64 | $3{,}0$ ms | — |
| 128 | $10{,}6$ ms | $3{,}47\times$ |
| 256 | $37{,}0$ ms | $3{,}51\times$ |
Gấp đôi $T$ làm thời gian tăng $\approx 3{,}5$ lần — gần khớp lý thuyết $O(T^2)$ (lý tưởng 4 lần; chênh lệch do chi phí cố định không phụ thuộc $T$ vẫn chiếm một phần khi $T$ chưa đủ lớn). Đây chính xác là lý do context window (số token model "nhớ" cùng lúc) là tài nguyên đắt đỏ nhất của LLM, và vì sao các kiến trúc mới (sparse attention, sliding window…) đều nhắm vào việc phá vỡ rào cản $O(n^2)$ này.
Chi phí $O(n^2)$ chỉ là một cạnh của tam giác scaling: tham số ($N$) — dữ liệu ($D$) — compute ($C$). Nghiên cứu Chinchilla (Hoffmann et al. 2022, DeepMind) đo thực nghiệm và chỉ ra: với ngân sách compute cố định, phần lớn mô hình thời kỳ trước bị huấn luyện thiếu dữ liệu so với số tham số — tăng dữ liệu tỉ lệ với tham số cho kết quả tốt hơn hẳn so với chỉ tăng tham số suông (ở mức khái niệm, không đi sâu công thức đóng của paper). Về "emergent abilities" (khả năng "đột hiện" ở quy mô lớn: model nhỏ hoàn toàn không làm được một việc, model đủ lớn bỗng làm tốt) — đây là chủ đề đang tranh cãi thật sự trong giới nghiên cứu: một phe cho rằng đó là bước nhảy chất lượng thật của khả năng suy luận; phe khác (Schaeffer et al. 2023) lập luận nó phần lớn là ảo giác của cách đo — đổi thước đo liên tục thay vì nhị phân đúng/sai thì đường cong "đột hiện" biến mất, chỉ còn cải thiện mượt dần. Trình bày cả hai phía vì đây là câu hỏi mở, không phải sự thật đã chốt.
4. Thực hành: BPE Tokenizer Live
Demo dưới đây train 2 bộ BPE thật (vocab ~100 và ~1.000 merge) ngay khi trang tải, trên đúng 20.000 ký tự đầu Truyện Kiều (mất chưa tới 1 giây — đã đo trong self-test). Gõ bất kỳ câu tiếng Việt nào (kể cả câu không có trong Truyện Kiều) và xem nó bị cắt thành token khác nhau ra sao ở 2 mức vocab:
Vocab ~100 (– token):
Vocab ~1.000 (– token):
Thử gõ "strawberry" để tái hiện đúng pitfall Mục 2 — hoặc một câu dài để thấy vocab lớn hơn luôn cho ít token hơn. Mỗi ô vuông màu là 1 token; token càng dài (gộp được nhiều ký tự) càng chứng tỏ BPE đã "học" đúng cấu trúc từ phổ biến của Truyện Kiều.
Đối chiếu công nghiệp: tiktoken là tokenizer BPE thật của OpenAI (cùng ý tưởng merge, khác ở
chỗ hoạt động trên byte UTF-8 thay vì ký tự, và vocab đã train sẵn ~100.000 token trên kho dữ liệu khổng
lồ). Sự thật về chi phí thường bị bỏ qua: cùng một ý nghĩa, câu tiếng Việt luôn tốn nhiều token hơn câu
tiếng Anh tương đương — vì vocab của các tokenizer thương mại được train chủ yếu trên dữ liệu tiếng Anh,
tiếng Việt (nhiều dấu thanh, ít xuất hiện hơn trong dữ liệu train gốc) bị cắt vụn hơn, ảnh hưởng trực tiếp
tới chi phí gọi API (tính tiền theo token):
pip install tiktoken)
# Doi chieu tokenizer BPE that cua OpenAI — cung y tuong merge, vocab lon hon nhieu
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o")
en = "The weather is nice today, I want to go for a walk."
vi = "Hom nay troi dep, toi muon di dao mot chut." # cung noi dung, dich sang tieng Viet
print(len(enc.encode(en))) # so token tieng Anh
print(len(enc.encode(vi))) # so token tieng Viet — TRUOC GIO luon cao hon ro ret
# dau thanh (khong go trong vi du nay) con lam ty le nay te hon
Tóm lược
Chương này lật lại một giả định ngầm ai cũng có: LLM "đọc chữ" — không, nó đọc token:
- ✅ 3 mức tách: ký tự (chuỗi quá dài) — nguyên từ (vocab nổ + OOV) — subword/BPE (điểm cân bằng, không có "chưa từng gặp").
- ✅ BPE tự xây: đếm cặp kề phổ biến nhất → merge → lặp; verified vocab lớn hơn giảm >50% số token trên cùng câu (100→39, 1.000→19).
- ✅ Pitfall "strawberry" verified bằng chính tokenizer tự xây: chữ "r" thứ 3 bị nuốt vào token "tr" — model đếm token "r" ra 2 thay vì 3, một lỗi cấu trúc chứ không phải "ngu".
- ✅ Pretraining = next-token prediction: đúng bài toán phân loại Bài 3, chỉ đổi số lớp thành kích thước vocab; teacher forcing tránh lỗi tự lan truyền.
- ✅ Context window $O(n^2)$ verified bằng đo thời gian thật: gấp đôi $T$ → thời gian tăng $\sim 3{,}5$ lần (T=64→128→256) — lý do context dài luôn đắt.
Tải file code thực hành minh họa bài học
File JavaScript cài BPE đầy đủ (train/encode/decode), verify bằng số đo thật (vocab 100 vs 1.000 token,
pitfall "strawberry") trên corpus Truyện Kiều vendored (chạy node bpe_tokenizer.js):
📖 Tài liệu tham khảo
- Sennrich, Haddow, Birch 2016 — Neural Machine Translation of Rare Words with Subword Units (paper gốc BPE cho NLP)
- OpenAI — tiktoken (tokenizer BPE thật, mã nguồn mở)
- Hoffmann et al. 2022 — Training Compute-Optimal Large Language Models (Chinchilla, DeepMind)
- Schaeffer, Miranda, Koyejo 2023 — Are Emergent Abilities of Large Language Models a Mirage?
- Wikipedia — Perplexity (ngôn ngữ học tính toán)
Bình luận