Mở đầu: model không "viết" — nó bốc thăm
Cảm giác khi đọc ChatGPT trả lời là một dòng suy nghĩ trôi chảy, có chủ đích. Sự thật kỹ thuật trần trụi hơn nhiều: ở MỖI vị trí, model chỉ làm đúng một việc — xuất ra một phân bố xác suất trên toàn bộ vocab (đúng cơ chế Bài 16 đã học), rồi một thuật toán BÊN NGOÀI model bốc thăm 1 token từ phân bố đó. "Văn bản trôi chảy" là ảo giác được tạo ra bằng cách lặp lại việc bốc thăm này hàng trăm, hàng nghìn lần liên tiếp.
Bài này tự xây một model bigram thật (huấn luyện trên toàn văn Truyện Kiều) và verify bằng số đo thật — không phải suy diễn — 4 chiến lược "bốc thăm" khác nhau: greedy, temperature, top-k/ top-p, và beam search. Bạn sẽ tận mắt thấy model tự viết rơi vào một vòng lặp 15 từ lặp đi lặp lại vô hạn khi dùng chiến lược ngây thơ nhất — hiện tượng có thật, không phải minh hoạ giả định. Cuối bài, khép nối vòng tròn reward/policy của Bài 17 vào chính cách ChatGPT được huấn luyện để "cư xử như trợ lý".
Tài nguyên ngoài: Holtzman et al. 2020 — The Curious Case of Neural Text Degeneration (paper gốc về top-p/nucleus sampling).
1. Từ logits đến chữ: sinh văn bản = lấy mẫu rồi lặp
Nhắc lại Bài 16: pretraining dạy model dự đoán $P(t_i \mid t_1, \ldots, t_{i-1})$ — xác suất của TỪNG token trong vocab, cho trước ngữ cảnh. Sinh văn bản chỉ là áp dụng lặp lại đúng phân bố đó: lấy 1 token từ $P$, nối vào ngữ cảnh, tính lại $P$ cho vị trí kế tiếp, lặp. Chiến lược lấy mẫu — cách chọn 1 token cụ thể từ phân bố xác suất — chính là chủ đề toàn bộ bài này.
Chiến lược ngây thơ nhất: greedy decoding — luôn chọn token có xác suất cao nhất. Nghe hợp lý ("luôn chọn cái tốt nhất"), nhưng có một lỗi nổi tiếng gọi là degeneration (thoái hoá): văn bản rơi vào lặp vô hạn kiểu "tôi nghĩ rằng tôi nghĩ rằng tôi nghĩ rằng...". Đây KHÔNG phải hiện tượng hiếm — hãy tự xem bằng model bigram huấn luyện thật trên Truyện Kiều:
// Greedy tu 5 tu khoi dau HOAN TOAN KHAC NHAU — tat ca deu roi vao
// DUNG 1 chu ky lap 15 tu, chi khac nhau vai buoc dau tien:
greedyGenerate(bigram, "Trăm", 40)
// "Trăm năm cũng là một vài bông hoa. Thanh minh vốn người ta,
// Chữ tài sắc thiên bạc mệnh một vài bông hoa. Thanh minh vốn..." (lap lai)
greedyGenerate(bigram, "người", 40)
// "người ta, Chữ tài sắc thiên bạc mệnh một vài bông hoa. Thanh
// minh vốn người ta, Chữ tài sắc thiên bạc mệnh một vài..." (lap lai)
greedyGenerate(bigram, "này", 40)
// "này đã thấy một vài bông hoa. Thanh minh vốn người ta, Chữ
// tài sắc thiên bạc mệnh một vài bông hoa. Thanh minh vốn..." (lap lai)
Cả 3 (và cả "chàng", "nàng" — 5/5 seed đã thử) đều hội tụ về ĐÚNG một chu kỳ 15 từ: "một vài bông hoa. Thanh minh vốn người ta, Chữ tài sắc thiên bạc mệnh" rồi lặp lại — mãi mãi, không bao giờ tự thoát ra. Vì sao vòng lặp tự CỦNG CỐ thay vì tự phá vỡ? Vì sau khi model sinh ra "…bạc mệnh", token có xác suất cao nhất tiếp theo (dựa trên tần suất đã học) LẠI ĐÚNG LÀ "một" — quay về đúng điểm bắt đầu chu kỳ. Không có cơ chế nào trong bản thân greedy decoding phá vỡ được vòng lặp này — nó tất định (deterministic), chọn y hệt một đường đi mỗi lần lặp qua cùng trạng thái.
2. Temperature: chia logit trước softmax
Cách sửa đơn giản nhất: đừng luôn chọn token tốt nhất — thỉnh thoảng chọn ngẫu nhiên có trọng số. Công thức:
$$P_i = \frac{\exp(z_i / T)}{\sum_j \exp(z_j / T)}$$
Giải nghĩa: $z_i$ là logit gốc của token $i$; $T$ (temperature) chia logit TRƯỚC khi đưa vào softmax. $T \to 0$: chia cho số rất nhỏ khiến logit lớn nhất bị khuếch đại cực mạnh so với phần còn lại — phân bố gần như dồn hết xác suất vào 1 token, tương đương greedy. $T$ lớn: chia nhỏ mọi khoảng cách giữa các logit — phân bố trở nên gần đều hơn (hỗn loạn hơn). Verify bằng số đo thật trên phân bố thật (từ "Nàng", 17 ứng viên tiếp theo, ứng viên hàng đầu "rằng:" chiếm 49,3% khối lượng xác suất gốc):
| Temperature | Đo được (300 lần lấy mẫu, seed cố định) | Khi nào dùng |
|---|---|---|
| $T = 0{,}1$ (rất thấp) | 300/300 lần chọn đúng token cao nhất ("rằng:") — thực chất là greedy | Sinh code, trả lời sự thật — cần tính lặp lại, không muốn "sáng tạo" |
| $T = 0{,}5$ | 9 token khác nhau xuất hiện trong 300 lần lấy mẫu | Cân bằng — vẫn ưu tiên token hợp lý nhưng có biến thiên |
| $T = 1{,}0$ (mặc định) | 17 token khác nhau xuất hiện — không khuếch đại cũng không san phẳng | Baseline, giữ nguyên phân bố học được |
| $T = 2{,}0$ (cao) | 17 token khác nhau (đã chạm hết toàn bộ 17 ứng viên có sẵn) | Sáng tác, brainstorm — chấp nhận rủi ro để đổi lấy đa dạng |
- $T=0{,}1$: $H \approx 0{,}000$ bit — hoàn toàn tất định, đúng như $300/300$ chọn cùng $1$ token.
- $T=0{,}5$: $H \approx 0{,}828$ bit — mới chỉ khoảng $20\%$ entropy tối đa.
- $T=1{,}0$: $H \approx 2{,}690$ bit — khoảng $66\%$ entropy tối đa.
- $T=2{,}0$: $H \approx 3{,}781$ bit — gần sát $93\%$ entropy tối đa, gần như phân bố đều thật sự.
function softmaxWithTemp(cands, T) {
const logits = cands.map(([, c]) => Math.log(c) / T); // chia logit cho T TRUOC
const maxL = Math.max(...logits);
const exps = logits.map((l) => Math.exp(l - maxL)); // tru max de on dinh so hoc
const sum = exps.reduce((a, b) => a + b, 0);
return exps.map((e) => e / sum);
}
3. Top-k & Top-p: cắt đuôi phân bố trước khi bốc thăm
Temperature làm PHẲNG cả phân bố, nhưng vẫn giữ nguyên MỌI token (kể cả những token vô lý, xác suất gần-0). Với vocab hàng chục nghìn token thật, cái đuôi dài xác suất tí hon đó chính là nguồn gốc của "từ ngẫu nhiên vô nghĩa" xuất hiện giữa câu — mỗi token trong đuôi có xác suất bé, nhưng CÓ RẤT NHIỀU token như vậy, nên tổng xác suất "bốc phải rác" không hề nhỏ. Hai kỹ thuật cắt đuôi trước khi bốc thăm:
| Chiến lược | Quy tắc | Verify trên "người" (121 ứng viên có sẵn) |
|---|---|---|
| Top-k | Chỉ giữ đúng $k$ token xác suất cao nhất, renormalize, bốc thăm trong đó | $k{=}5$: giữ đúng 5 ứng viên, tổng xác suất renormalize $= 1$ |
| Top-p (nucleus) | Giữ TẬP NHỎ NHẤT token có tổng xác suất tích luỹ $\ge p$ — số lượng token TỰ THAY ĐỔI theo từng bước | $p{=}0{,}8$: giữ 91/121 ứng viên (đã cắt bỏ 30 token đuôi xác suất tí hon) |
Khác biệt cốt lõi: top-k luôn giữ ĐÚNG $k$ token bất kể phân bố nhọn hay phẳng — với phân bố rất nhọn (1 token chiếm gần hết xác suất), top-k=50 vẫn ép model cân nhắc 49 token gần như vô nghĩa. Top-p thích nghi: phân bố nhọn → giữ ít token; phân bố phẳng → giữ nhiều hơn — đây là lý do top-p (giới thiệu bởi Holtzman et al. 2020) trở thành mặc định phổ biến hơn top-k thuần trong các API thương mại ngày nay.
- Phân bố NHỌN (1 token chiếm $95\%$, $5$ token còn lại chia đều $5\%$): top-$5$ vẫn phải nhét thêm $4$ token gần như rác (mỗi token chỉ $1\%$ xác suất gốc) vào tập bốc thăm, dù lẽ ra chỉ cần đúng $1$-$2$ token là đủ đại diện phân bố này.
- Phân bố PHẲNG ($20$ token xác suất bằng nhau, mỗi token $5\%$): top-$5$ chỉ giữ lại đúng $25\%$ tổng khối lượng xác suất hợp lý, LOẠI BỎ $15$ token còn lại — dù chúng tốt ngang hàng với $5$ token được giữ, không có lý do thống kê nào để loại chúng.
function topP(cands, p) {
const total = cands.reduce((s, [, c]) => s + c, 0);
const probs = cands.map(([w, c]) => [w, c / total]); // cands da sap giam dan
let cum = 0;
const kept = [];
for (const [w, prob] of probs) {
kept.push([w, prob]);
cum += prob;
if (cum >= p) break; // dung ngay khi du p khoi luong
}
const keptTotal = kept.reduce((s, [, pr]) => s + pr, 0);
return kept.map(([w, pr]) => [w, pr / keptTotal]); // renormalize ve tong 1
}
Còn một chiến lược nữa đáng nhắc: beam search — thay vì bốc thăm, giữ song song $k$ chuỗi có log-xác-suất TÍCH LUỸ cao nhất qua mỗi bước (tìm chuỗi tốt nhất, không phải token tốt nhất từng bước). Verify trên model bigram: beam(3) tìm ra chuỗi "người ta, Chữ trinh bạch nói năng" (log-prob $-13{,}04$) — tốt hơn hẳn greedy "người ta, Chữ tài sắc thiên bạc" (log-prob $-13{,}69$) trên cùng 6 bước. Vậy sao ít dùng cho sáng tác? Vì beam search tối ưu XÁC SUẤT TỔNG THỂ — thứ mà văn bản người thật viết ra không tối ưu (con người không nói theo cách "trung bình, an toàn nhất có thể"). Beam search cho văn bản đúng ngữ pháp, ít lỗi, nhưng có xu hướng chung chung, thiếu bất ngờ — "an toàn nhưng nhàm" đúng như tên gọi.
4. Alignment & RLHF: từ "hoàn thành văn bản" tới "làm trợ lý"
Một model chỉ pretrain xong (Bài 16) biết CHÍNH XÁC một việc: hoàn thành văn bản giống dữ liệu train. Hỏi nó "Thủ đô Việt Nam là gì?", nó có thể trả lời đúng — hoặc cũng có thể "hoàn thành" bằng cách liệt kê thêm 10 câu hỏi tương tự khác (vì trong dữ liệu train, sau 1 câu hỏi thường là... câu hỏi khác, như một bài thi trắc nghiệm). Nó chưa từng được dạy khái niệm "trợ lý". Alignment là quá trình dạy thêm hành vi đó, qua 3 tầng — và đây là nơi vốn từ vựng của Bài 17 quay lại nguyên vẹn:
| Tầng | Làm gì | Nối với Bài 17 |
|---|---|---|
| 1. SFT (Supervised Fine-Tuning) | Fine-tune trên các cặp (câu hỏi, câu trả lời mẫu) do con người viết — dạy "định dạng trợ lý" | Vẫn là supervised learning thường (Bài 1–3), chưa phải RL |
| 2. Reward model | Con người XẾP HẠNG nhiều câu trả lời cho cùng 1 câu hỏi; train 1 model NHỎ HỌC dự đoán "con người sẽ thích câu nào hơn" |
Model này CHÍNH LÀ nguồn reward — thay vì reward có sẵn như gridworld, giờ reward do 1
model khác ước lượng
|
| 3. RL (PPO) |
Dùng reward model làm "môi trường", tối ưu policy (chính là LLM) để tối đa hoá điểm
reward model chấm —
PPO
ở mức khái niệm, không cài đầy đủ
|
Đúng vòng lặp agent-environment-reward-policy của Bài 17, chỉ khác: environment là 1 model khác, không phải gridworld |
Và reward hacking (CoastRunners) tái xuất dưới tên gọi khác: sycophancy (nịnh hót) — nếu reward model có xu hướng chấm điểm cao cho câu trả lời "nghe có vẻ tự tin, đồng tình với người hỏi" hơn là câu trả lời "đúng nhưng phản biện", thì RL sẽ tối ưu ĐÚNG cái được chấm: model học cách nịnh người dùng thay vì trả lời chính xác. Y hệt bài học Bài 17 — không phải lỗi thuật toán, mà là lỗi thiết kế tín hiệu reward không khớp ý định thật.
Cả bài học tới giờ coi "ngữ cảnh" là thứ cố định — nhưng chính BẠN kiểm soát một phần ngữ cảnh đó qua prompt. Về mặt cơ chế, prompt không có gì huyền bí: nó là điều kiện hoá phân bố — thay vì lấy mẫu từ $P(t_i \mid \text{context})$ với ngữ cảnh rỗng, bạn ép ngữ cảnh bắt đầu bằng đúng những gì bạn viết, thu hẹp không gian các "văn bản hợp lý tiếp theo" lại gần với ý định của bạn.
Few-shot prompting (đưa vài ví dụ mẫu trước câu hỏi thật) hoạt động vì cùng lý do: model được train trên internet chứa vô số định dạng "ví dụ → ví dụ → ví dụ → …", nên khi thấy vài ví dụ đúng khuôn mẫu, phân bố token tiếp theo tự nhiên bị điều kiện hoá về "tiếp tục đúng khuôn mẫu đó".
Giới hạn thật sự: prompt chỉ điều kiện hoá phân bố xác suất trên những gì model ĐÃ HỌC được từ dữ liệu train — không thể "prompt" ra kiến thức model chưa từng thấy. Prompt giỏi giúp lấy đúng phần kiến thức có sẵn ra hiệu quả hơn; nó không phải phép màu bù đắp lỗ hổng kiến thức thật.
5. Thực hành: chỉnh sampling trên phân bố token thật
Demo dưới đây dùng đúng model bigram huấn luyện trên Truyện Kiều — gõ một từ bắt đầu (hoặc dùng mặc định), chỉnh temperature/top-k/top-p, xem biểu đồ phân bố bị cắt/nắn trực tiếp và văn bản sinh ra thay đổi tức thì. Đặt temperature về gần 0 để tận mắt xem model rơi vào vòng lặp 15 từ của Mục 1:
Biểu đồ hiện phân bố xác suất SAU KHI đã áp temperature + top-k + top-p (thanh xám = đã bị cắt bỏ). Đặt Temperature ≈ 0,1 và bấm sinh nhiều lần — văn bản luôn hội tụ về đúng chu kỳ 15 từ của Mục 1. Tăng Temperature lên ~1,5 để thấy văn bản đa dạng hơn hẳn (và đôi khi kém mạch lạc hơn).
Đối chiếu công nghiệp — HuggingFace generate() nhận đúng các tham số vừa học, tên gọi giống
hệt:
pip install transformers)
# Doi chieu 1-1 voi cac tham so vua hoc — cai dat: huggingface.co/docs/transformers
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer = AutoTokenizer.from_pretrained("gpt2")
inputs = tokenizer("Hom nay troi dep", return_tensors="pt")
output = model.generate(
**inputs,
max_new_tokens=30,
temperature=0.8, # dung Muc 2
top_k=50, # dung Muc 3
top_p=0.9, # dung Muc 3 (nucleus)
do_sample=True, # False = greedy (Muc 1) -> de bi lap nhu demo tren
)
print(tokenizer.decode(output[0]))
Tóm lược
- ✅ Sinh văn bản = lấy mẫu lặp lại: model chỉ xuất phân bố xác suất, thuật toán bên ngoài quyết định cách bốc thăm.
- ✅ Greedy decoding có thể degenerate: verified 5/5 seed hoàn toàn khác nhau đều rơi vào ĐÚNG 1 chu kỳ lặp 15 từ trên model bigram thật.
- ✅ Temperature: chia logit trước softmax; verified $T{=}0{,}1$ hội tụ về greedy (300/300), $T$ cao tăng đa dạng đo được (9→17 token khác nhau).
- ✅ Top-k/top-p cắt đuôi phân bố trước khi bốc; top-p thích nghi theo độ nhọn phân bố, phổ biến hơn top-k thuần trong API thật.
- ✅ Alignment 3 tầng (SFT → reward model → RL) tái sử dụng đúng vòng lặp agent-reward-policy của Bài 17; reward hacking tái xuất dưới tên sycophancy.
Tải file code thực hành minh họa bài học
File JavaScript cài model bigram từ Truyện Kiều + 4 chiến lược sinh văn bản (greedy, temperature,
top-k/top-p, beam search), verify bằng số đo thật (vòng lặp degenerate, hội tụ temperature, beam vs
greedy — chạy node sampling_strategies.js):
📖 Tài liệu tham khảo
- Holtzman et al. 2020 — The Curious Case of Neural Text Degeneration (paper gốc top-p/nucleus sampling)
- HuggingFace — How to generate text (greedy, beam, sampling, top-k, top-p)
- OpenAI — Aligning language models to follow instructions (InstructGPT, gốc của RLHF hiện đại)
- Wikipedia — Beam search
- HuggingFace — GenerationConfig (temperature, top_k, top_p, do_sample)
Bình luận