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ý".


📚 Điều kiện tiên quyết
Bắt buộc: Bài 16 (token, cross-entropy, next-token prediction) và Bài 17 (reward, policy, reward hacking — tái sử dụng nguyên vẹn ở Mục 4 dưới tên gọi khác).
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_degeneration.js (kết quả THẬT — đã verify bằng self-test)
// 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
🔢 Đo "độ hỗn loạn" bằng entropy — không chỉ đếm số token khác nhau
Bảng trên đếm "có bao nhiêu token khác nhau xuất hiện trong 300 lần lấy mẫu" — một thước đo thô. Thước đo chuẩn hơn trong lý thuyết thông tin là entropy Shannon ($H = -\sum_i p_i \log_2 p_i$, đơn vị bit) — đo trực tiếp trên chính phân bố xác suất, không cần lấy mẫu. Tính cho phân bố 17 ứng viên của "Nàng" (đã kiểm chứng bằng Python), so với entropy tối đa lý thuyết $\log_2(17) \approx 4{,}087$ bit (phân bố đều tuyệt đối):
  • $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ự.
Entropy tăng đơn điệu và mượt theo $T$ — không có bước nhảy đột ngột nào, xác nhận temperature điều chỉnh độ "hỗn loạn" của phân bố một cách liên tục và có thể định lượng chính xác, không chỉ là cảm giác định tính "cao hơn thì sáng tạo hơn".
temperature.js (đúng code demo cuối bài dùng)
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.

🧠 Top-k thất bại cụ thể ra sao ở 2 thái cực phân bố?
Dựng $2$ phân bố đối lập, cùng dùng $k=5$ (đã kiểm chứng bằng Python):
  • 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.
Cùng một $k=5$ cố định nhưng cho ra 2 kết quả sai theo 2 hướng ĐỐI LẬP: quá RỘNG RÃI khi phân bố nhọn (giữ dư token rác), quá KHẮT KHE khi phân bố phẳng (loại bỏ nhầm token tốt). Đây là bằng chứng cụ thể cho lý do top-p (tự điều chỉnh số lượng theo hình dạng thật của từng phân bố) khắc phục được điểm yếu cấu trúc này của top-k.
top_p.js (nucleus sampling)
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.

⚠️ Cạm bẫy: nhầm "xác suất cao nhất mỗi bước" với "chuỗi hay nhất"
Trực giác greedy decoding dựa trên giả định ngầm: "chọn tốt nhất ở MỖI bước sẽ cho kết quả tốt nhất TỔNG THỂ". Sai — đây chính là lý do greedy rơi vào vòng lặp 15 từ ở Mục 1. Bài toán tìm chuỗi log-prob cao nhất tuyệt đối (decoding tối ưu toàn cục) là NP-hard với vocab lớn — không ai giải chính xác được trong thời gian thực. Beam search là một xấp xỉ tốt hơn greedy (nhìn trước nhiều chuỗi song song thay vì 1), nhưng vẫn chỉ là XẤP XỈ — không đảm bảo tìm ra chuỗi tối ưu tuyệt đối, và như đã thấy, vẫn có xu hướng hội tụ về các mẫu hình an toàn/lặp lại khi chuỗi đủ dà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

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.

🔬 Đào sâu: prompt engineering nhìn từ xác suấ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:

🎲 Sampling Playground — model bigram thật từ Truyện Kiều
Đang tải corpus…

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:

hf_generate.py (chạy ngoài browser — cần 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 về sampling_strategies.js

📖 Tài liệu tham khảo

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

Bài 17: Học tăng cường: Q-learning Bài 19: Capstone: GPT-mini tiếng Việt trong browser Quay lại Lộ trình Series Trí Tuệ Nhân Tạo

Bình luận