$ cat ./blog/gen-ai/llm-fundamentals/tu-logits-den-cau-tra-loi.md
seriesCách hoạt động của LLMbài 9

Từ logits đến câu trả lời: decoding và sampling

Bài thứ chín trong series Cách hoạt động của LLM: logits đi qua một chuỗi bước biến đổi rồi mới thành token. Thứ tự các bước trong Hugging Face Transformers và vLLM đọc từ source, greedy và beam search, temperature, top-k, top-p, min-p chạy trên model thật, repetition penalty, constrained decoding cho JSON, và vì sao cùng một prompt với temperature 0 vẫn có thể ra hai câu trả lời.

~/diagrams/tu-logits-den-cau-tra-loi/decoding-pipeline.htmltương tác
mở riêng ↗
HìnhHàng trên là những gì chạy ở mỗi bước sinh token: logits từ lm_head đi qua penalty, grammar mask, temperature, bước cắt đuôi (top-k, top-p, min-p), rồi softmax và chọn một token. Token đó được nối vào context và forward pass chạy lại. Hàng dưới là năm hiểu lầm phổ biến, mỗi cái nối với bước mà nó hiểu sai. Thứ tự vẽ ở đây khớp với cả Hugging Face Transformers 5.18.0 và vLLM 0.30.0 ở điểm chính: mask đi trước temperature và trước bước cắt đuôi. Chi tiết khác nhau giữa hai thư viện nằm ở mục 02.
bước biến đổi logits (penalty, temperature, cắt đuôi)dữ liệu: logits và token được chọnkhung đỏ: grammar mask; khung xám: hiểu lầm phổ biến

Bài thứ tám kết thúc ở chỗ residual stream đi qua final norm rồi vào lm_head để thành logits: mỗi token trong vocabulary nhận một con số. Với Qwen2.5, đó là 151.936 con số cho mỗi bước sinh. Model không làm gì thêm sau đó. Việc biến 151.936 con số thành một token cụ thể là của một bước khác, gọi là decoding.

Bước này nghe như chi tiết kỹ thuật, nhưng nó giải thích phần lớn những gì người dùng thấy:

  • Cùng một model, sao lúc thì trả lời gọn, lúc lại lặp đi lặp lại một câu?
  • Sao bảo model “trả về JSON” thì đa số lần được, thỉnh thoảng parser lại báo lỗi?
  • Sao đặt temperature = 0 rồi mà gọi hai lần vẫn ra hai câu trả lời khác nhau?

Cả ba câu hỏi đều không nằm ở trọng số của model. Chúng nằm ở cách ta chọn token từ logits.

Bài 1, mục 8.5 đến 8.11 và bài 2, mục 09 đã giới thiệu greedy, temperature, top-k, top-p và các loại penalty ở mức định nghĩa. Bài này không nhắc lại phần đó mà đi sâu hơn ở năm chỗ:

  1. Decoding là một pipeline, và thứ tự các bước trong pipeline thay đổi kết quả. Ta đọc thẳng source của Hugging Face Transformers và vLLM để xem thứ tự thật.
  2. Vì sao beam search hợp với dịch máy nhưng làm văn bản mở bị lặp.
  3. Top-k, top-p và min-p cắt đuôi phân phối theo ba cách khác nhau, chạy trên một model thật để thấy khác biệt.
  4. Constrained decoding ép model trả JSON đúng schema bằng cách nào, và cái giá phải trả.
  5. Ba tầng nguyên nhân khiến cùng một prompt ra hai câu trả lời, kể cả khi đã tắt sampling.

01Từ logits đến một token

Nhắc lại vòng lặp mà bài 1 đã mô tả: model đọc toàn bộ context, trả ra logits cho vị trí tiếp theo, ta chọn một token, nối nó vào context, rồi chạy lại. Mỗi vòng sinh đúng một token. Decoding là hàm nằm giữa hai lần chạy model: nó nhận vector logits dài vocab_size mà model vừa trả ra, và trả lại đúng một token để nối vào context cho vòng sau.

Hàm này không có tham số học được. Nó chỉ là một quy tắc chọn, và có hai họ quy tắc:

  • Search: tìm chuỗi token có xác suất cao. Greedy và beam search thuộc họ này. Không có yếu tố ngẫu nhiên, nên về lý thuyết cùng input cho cùng output.
  • Sampling: nắn lại phân phối (temperature, cắt đuôi), rồi bốc ngẫu nhiên một token theo phân phối đó. Đây là cách mọi chatbot thương mại dùng mặc định.

Một ví dụ dùng xuyên suốt bài

Để tính được bằng tay, ta dùng một phân phối nhỏ với sáu ứng viên. Giả sử model đang viết câu “Món tôi muốn ăn tối nay là” và logits cho sáu token mạnh nhất như sau. Đây là logits giả định cho dễ tính, không lấy từ model thật; phần chạy trên model thật nằm ở mục 04.

Token phở bún chả cơm tấm pizza sushi kem
Logit 3,0 2,4 2,0 1,0 0,4 −0,5

Đoạn code dưới đây chỉ dùng Python chuẩn. Nó cài lại bốn phép biến đổi mà bài sẽ đi qua (temperature, top-k, top-p, min-p), theo đúng quy ước của các thư viện: token bị loại thì logit thành -inf, rồi softmax trên phần còn lại.

import math

tokens = ["phở", "bún chả", "cơm tấm", "pizza", "sushi", "kem"]
logits = [3.0, 2.4, 2.0, 1.0, 0.4, -0.5]

def softmax(xs, T=1.0):
    m = max(x / T for x in xs if x != -math.inf)
    e = [math.exp(x / T - m) if x != -math.inf else 0.0 for x in xs]
    return [v / sum(e) for v in e]

def mask(xs, keep):                       # token bị loại -> logit = -inf
    return [x if k else -math.inf for x, k in zip(xs, keep)]

def top_k(xs, k):
    cut = sorted(xs, reverse=True)[k - 1]
    return mask(xs, [x >= cut for x in xs])

def top_p(xs, p):
    probs = softmax(xs)
    keep, total = [False] * len(xs), 0.0
    for i in sorted(range(len(xs)), key=lambda i: -probs[i]):
        keep[i], total = True, total + probs[i]
        if total >= p:
            break
    return mask(xs, keep)

def min_p(xs, p):
    probs = softmax(xs)
    return mask(xs, [q >= p * max(probs) for q in probs])

def temp(xs, T):
    return [x / T for x in xs]

def show(name, xs):
    cells = "  ".join(f"{t}={q:.2f}" if q else f"{t}=--" for t, q in zip(tokens, softmax(xs)))
    print(f"{name:<40}{cells}")

for T in (0.5, 1.0, 1.5):
    show(f"temperature {T}", temp(logits, T))
show("top-k 3", top_k(logits, 3))
show("top-p 0.9", top_p(logits, 0.9))
show("min-p 0.1", min_p(logits, 0.1))
print()
show("top-p 0.9 -> temperature 1.5", temp(top_p(logits, 0.9), 1.5))
show("temperature 1.5 -> top-p 0.9", top_p(temp(logits, 1.5), 0.9))
show("HF:   T 1.5 -> top-p 0.9 -> min-p 0.1", min_p(top_p(temp(logits, 1.5), 0.9), 0.1))
show("vLLM: T 1.5 -> min-p 0.1 -> top-p 0.9", top_p(min_p(temp(logits, 1.5), 0.1), 0.9))

Output:

temperature 0.5                         phở=0.68  bún chả=0.21  cơm tấm=0.09  pizza=0.01  sushi=0.00  kem=0.00
temperature 1.0                         phở=0.46  bún chả=0.25  cơm tấm=0.17  pizza=0.06  sushi=0.03  kem=0.01
temperature 1.5                         phở=0.37  bún chả=0.25  cơm tấm=0.19  pizza=0.10  sushi=0.06  kem=0.04
top-k 3                                 phở=0.52  bún chả=0.29  cơm tấm=0.19  pizza=--  sushi=--  kem=--
top-p 0.9                               phở=0.49  bún chả=0.27  cơm tấm=0.18  pizza=0.07  sushi=--  kem=--
min-p 0.1                               phở=0.49  bún chả=0.27  cơm tấm=0.18  pizza=0.07  sushi=--  kem=--

top-p 0.9 -> temperature 1.5            phở=0.41  bún chả=0.27  cơm tấm=0.21  pizza=0.11  sushi=--  kem=--
temperature 1.5 -> top-p 0.9            phở=0.38  bún chả=0.26  cơm tấm=0.20  pizza=0.10  sushi=0.07  kem=--
HF:   T 1.5 -> top-p 0.9 -> min-p 0.1   phở=0.38  bún chả=0.26  cơm tấm=0.20  pizza=0.10  sushi=0.07  kem=--
vLLM: T 1.5 -> min-p 0.1 -> top-p 0.9   phở=0.41  bún chả=0.27  cơm tấm=0.21  pizza=0.11  sushi=--  kem=--

Nửa trên là những gì bài 1 và bài 2 đã nói: temperature nắn độ nhọn, ba kiểu cắt đuôi loại bớt ứng viên. Nửa dưới mới là chuyện của bài này. Cùng các tham số, chỉ đổi thứ tự áp dụng, mà tập token còn lại khác nhau: có lúc sushi còn cơ hội được chọn, có lúc không. Hai dòng cuối còn đặt tên HF và vLLM. Mục tiếp theo giải thích vì sao.


02Decoding là một pipeline, và thứ tự là một phần của thuật toán

Khi bạn gọi generate(temperature=0.7, top_p=0.9, repetition_penalty=1.1), thư viện không có một hàm “sampling” duy nhất nhận ba tham số đó. Nó dựng một danh sách các bước, mỗi bước nhận logits và trả ra logits đã sửa. Hugging Face gọi mỗi bước là một LogitsProcessor. vLLM gom chúng vào class Sampler. Hình 1 ở đầu bài vẽ khung chung của danh sách này.

Thứ tự các bước được viết cứng trong code, không phải trong tài liệu. Nên cách chắc chắn nhất để biết thứ tự là đọc source.

Hugging Face Transformers 5.18.0

Danh sách được dựng trong hàm _get_logits_processor của generation/utils.py. Rút gọn những bước hay gặp, theo đúng thứ tự trong code:

# Bước Tham số Dòng code
1 Classifier-free guidance guidance_scale L1422
2 Sequence bias sequence_bias L1432–L1433
3 Repetition penalty repetition_penalty L1452–L1453
4 Cấm lặp n-gram no_repeat_ngram_size L1460–L1461
5 Cấm từ bad_words_ids L1485–L1487
6 Độ dài tối thiểu min_length, min_new_tokens L1492–L1516
7 Ràng buộc tiền tố prefix_allowed_tokens_fn L1517–L1519
8 Processor do bạn truyền vào logits_processor=[...] L1571
9 Temperature temperature L1589–L1590
10 Top-k top_k L1593–L1595
11 Top-p top_p L1597–L1599
12 Min-p min_p L1601–L1604

Các bước 9 đến 12 nằm trong khối if generation_config.do_sample: ở L1574, nên chúng chỉ chạy khi bật sampling. Bước 1 đến 8 chạy cả khi greedy. Sau đó, vòng lặp sinh trong hàm _sample gọi cả danh sách ở L3219, rồi softmax ở L3243 và bốc token bằng torch.multinomial ở L3245. Nếu không sampling thì dùng torch.argmax ở L3247.

Hai chi tiết đáng nhớ:

  • Processor bạn tự viết (bước 8) được nối vào sau các bước mặc định nhưng trước temperature. Hàm gộp danh sách ở L1674–L1709 còn có một luật nữa: nếu processor của bạn trùng kiểu với một bước mặc định, bản của bạn thay thế bản mặc định ngay tại vị trí đó.
  • Trong source có một dòng comment ở L1570: “TODO (joao): find a strategy to specify the order of the processors”. Nói cách khác, chính người viết cũng coi thứ tự này là chuyện chưa xong.

vLLM 0.30.0

vLLM tách việc này làm hai chỗ. Trước khi vào sampler, model runner áp grammar bitmask của structured output lên logits ở gpu_model_runner.py L4595–L4599, rồi mới gọi sampler ở L4602. Trong sampler.py, thứ tự là:

# Bước Tham số Dòng code
0 Grammar bitmask (trong model runner) structured_outputs L4595–L4599
1 Chỉ giữ token cho phép allowed_token_ids L392–L394
2 Cấm từ bad_words L396–L398
3 Processor có thể đổi kết quả argmax min_tokens, logit_bias L400–L402
4 Penalty: repetition, frequency, presence repetition_penalty, frequency_penalty, presence_penalty L404–L405
5 Tính sẵn kết quả greedy temperature gần 0 L258–L272
6 Temperature temperature L276–L279
7 Processor không đổi argmax, mặc định là min-p min_p L281–L284
8 Top-k, top-p rồi lấy mẫu top_k, top_p, seed L286–L292
9 Request nào có temperature dưới 1e-5 thì lấy kết quả greedy L297–L300

Docstring ở đầu class Sampler mô tả đúng thứ tự này. Chi tiết “argmax-invariant” đáng giải thích: vLLM chia processor làm hai loại. Loại có thể đổi kết quả argmax (như logit_bias) phải chạy trước bước greedy. Loại không bao giờ đổi argmax (như min-p, vì token mạnh nhất luôn được giữ) chỉ cần chạy cho các request có sampling.

llama.cpp: temperature đứng cuối

Để thấy thứ tự không phải là chuyện hiển nhiên, nhìn sang llama.cpp. Chuỗi sampler mặc định trong common/common.h là: penalties → DRY → top-n-sigma → top-k → typical-p → top-p → min-p → XTC → temperature. Temperature đứng sau mọi bước cắt đuôi. Hệ quả: ở llama.cpp, tăng temperature không làm top-p hay min-p giữ thêm token. Nó chỉ làm phẳng phân phối bên trong tập token đã được giữ lại.

Thứ tự thay đổi kết quả thế nào

Quay lại nửa dưới output ở mục 01:

  • Temperature trước hay sau top-p. Áp top-p trên phân phối gốc (T = 1) rồi mới chia cho 1,5 thì giữ 4 token. Chia cho 1,5 trước thì phân phối phẳng hơn, cần nhiều token hơn mới đủ 90% khối xác suất, nên giữ 5 token. HF và vLLM thuộc kiểu thứ hai, llama.cpp thuộc kiểu thứ nhất.
  • Min-p trước hay sau top-p. Cả hai thư viện đều đặt temperature trước. Nhưng HF chạy top-p rồi mới min-p, còn vLLM chạy min-p rồi mới top-k và top-p. Khi min-p chạy trước, nó loại kem, phân phối được chuẩn hoá lại trên năm token còn lại, và top-p 0,9 tính trên phân phối mới đó chỉ cần bốn token. Cùng T = 1,5, top_p = 0,9, min_p = 0,1, HF giữ 5 token còn vLLM giữ 4.

Chênh lệch này nhỏ ở ví dụ sáu token, nhưng nó có nghĩa là: cùng một bộ tham số chưa chắc cho cùng một phân phối trên hai engine khác nhau. Nếu bạn tinh chỉnh tham số trên HF rồi deploy bằng vLLM hoặc llama.cpp, hãy kiểm tra lại.

Mask phải đi trước bước cắt đuôi

Có một ràng buộc về thứ tự mà cả HF lẫn vLLM đều giữ: mọi bước cấm token (grammar mask, bad_words, allowed_token_ids, processor tự viết) chạy trước temperature và bước cắt đuôi.

Lý do dễ thấy khi thử làm ngược lại. Giả sử model đang sinh JSON và vị trí tiếp theo bắt buộc phải là dấu ". Nếu top-k 20 chạy trước, nó giữ 20 token mạnh nhất, và rất có thể không token nào trong 20 token đó là ". Khi mask chạy sau, nó loại cả 20, và không còn token hợp lệ nào để chọn. Chạy mask trước thì top-k chỉ chọn trong những token đã hợp lệ.

Ngược lại, penalty và mask đổi chỗ cho nhau không làm thay đổi kết quả: penalty nhân hoặc chia một logit, còn -inf nhân hay chia cho số dương vẫn là -inf. Vì thế HF đặt penalty trước processor tự viết, vLLM đặt grammar mask trước penalty, và hai cách cho cùng kết quả.

Tham số mặc định không phải của thư viện

Một bẫy hay gặp: bạn không truyền tham số nào và nghĩ mình đang chạy greedy. Thực tế, cả HF lẫn vLLM đều đọc file generation_config.json đi kèm model. Với Qwen2.5-0.5B-Instruct, file đó đặt sẵn:

{
  "do_sample": true,
  "temperature": 0.7,
  "top_p": 0.8,
  "top_k": 20,
  "repetition_penalty": 1.1
}

Ở vLLM 0.30.0, tham số generation_config mặc định là "auto", nghĩa là cấu hình này cũng được nạp làm giá trị mặc định cho mọi request. Còn ở HF, nếu bạn gọi generate(do_sample=False), các bước sampling bị tắt nhưng repetition_penalty = 1.1 vẫn được áp dụng, vì đó là bước 3, nằm ngoài khối do_sample. Mọi đoạn code greedy trong bài này đều chịu penalty 1,1 đó, trừ khi ghi rõ là đã tắt.


03Search: greedy và beam search

Bài 2, mục 9.1 đã định nghĩa greedy: mỗi bước chọn token có logit cao nhất. Bài 1, mục 8.11 giới thiệu beam search. Ở đây ta xem chúng thực sự tối ưu cái gì, và vì sao cái đích đó lại có vấn đề.

Greedy không tìm ra chuỗi có xác suất cao nhất

Xác suất của cả chuỗi là tích xác suất từng bước. Greedy tối ưu từng bước một, nên có thể bỏ lỡ chuỗi tốt hơn. Một ví dụ hai bước:

Bước 1 Xác suất Bước 2 tốt nhất sau đó Xác suất Cả chuỗi
A 0,5 A → X 0,3 0,15
B 0,4 B → Y 0,9 0,36

Greedy chọn A ở bước 1 và kết thúc với chuỗi có xác suất 0,15. Chuỗi B → Y có xác suất 0,36 nhưng greedy không bao giờ nhìn tới.

Beam search: giữ B ứng viên song song

Beam search sửa đúng chỗ đó. Thay vì giữ một chuỗi, nó giữ B chuỗi (beam width). Mỗi bước, nó mở rộng mỗi chuỗi bằng mọi token có thể, tính tổng log-xác suất của từng chuỗi mới, rồi chỉ giữ lại B chuỗi tốt nhất. Với B = 2, ví dụ trên giữ cả A lẫn B ở bước 1, và tìm ra B → Y ở bước 2.

Beam search có một thiên lệch có sẵn: log-xác suất luôn âm, nên chuỗi càng dài tổng càng thấp. Không điều chỉnh gì thì beam search luôn thích câu ngắn. Các thư viện bù lại bằng length penalty. HF chia điểm của chuỗi cho độ_dài ^ length_penalty, mặc định length_penalty = 1.0, tức là lấy log-xác suất trung bình mỗi token (docstring của compute_transition_scores viết đúng công thức này). Hệ thống dịch máy GNMT của Google dùng một dạng khác, ((5 + |Y|) / 6) ^ α (Wu et al. 2016).

Thiên lệch về câu ngắn này mạnh tới mức nào? Stahlberg và Byrne 2019 viết một thuật toán tìm chính xác chuỗi có xác suất cao nhất cho một model dịch Transformer trên bộ test WMT15 Anh–Đức. Kết quả: với hơn 50% số câu, chuỗi có xác suất cao nhất là bản dịch rỗng. Beam search chỉ cho bản dịch tốt vì nó không tìm ra được chuỗi tối ưu đó.

Vì sao beam search vẫn hợp với dịch máy

Nếu tìm chính xác cho kết quả tệ, tại sao beam search vẫn là chuẩn trong dịch máy và tóm tắt? Meister et al. 2020 đặt đúng câu hỏi đó trong tiêu đề paper, If beam search is the answer, what was the question?. Câu trả lời của họ: beam search ngầm tối ưu một mục tiêu khác ngoài xác suất, đó là phân bố thông tin đều giữa các token (uniform information density), và mức độ tuân theo tính chất này tương quan mạnh với điểm BLEU trong thí nghiệm dịch của họ.

Ngoài ra, các bài toán như dịch máy có ba đặc điểm khiến search hợp lý: đáp án đúng khá hẹp, phân phối thường rất nhọn, và độ dài output gần như biết trước từ độ dài input.

Vì sao search làm văn bản mở bị lặp

Văn bản mở (chat, viết bài, kể chuyện) thì ngược lại: có rất nhiều câu trả lời tốt, và câu trả lời có xác suất cao nhất thường nhạt và lặp. Holtzman et al. 2019 cho thấy hai điều:

  • Văn bản do người viết không nằm ở vùng xác suất cao nhất của model. Người viết thường xuyên chọn những từ mà model đánh giá là ít khả năng.
  • Lặp lại tự củng cố: mỗi lần một cụm từ lặp lại, xác suất model lặp nó thêm lần nữa lại tăng. Greedy và beam search đi theo xác suất cao nhất nên rơi thẳng vào vòng lặp này.

Zhang et al. 2021 xác nhận hiện tượng likelihood trap: chuỗi có xác suất cao lại thường có chất lượng thấp.

Chạy thử trên Qwen2.5-0.5B-Instruct, cho model viết tiếp một câu mở mà không dùng chat template, và tắt repetition penalty mặc định để thấy rõ hành vi gốc:

# transformers==5.18.0, torch==2.14.1 (CPU)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32).eval()

inputs = tok("The best thing about living in Hanoi is", return_tensors="pt")
n = inputs["input_ids"].shape[1]
common = dict(max_new_tokens=60, repetition_penalty=1.0)   # tắt penalty mặc định 1.1 của model

runs = {
    "greedy": dict(do_sample=False),
    "beam 5": dict(do_sample=False, num_beams=5),
    "sample T=0.7, top-p 0.8": dict(do_sample=True, temperature=0.7, top_p=0.8, top_k=0),
}
for label, kw in runs.items():
    torch.manual_seed(0)
    out = model.generate(**inputs, **common, **kw)[0, n:]
    text = tok.decode(out, skip_special_tokens=True)
    words = text.split()
    print(f"[{label}] từ khác nhau / tổng: {len(set(words))}/{len(words)}\n{text!r}\n")

Output:

[greedy] từ khác nhau / tổng: 21/51
" the city's beautiful architecture. The city is full of interesting buildings, including the National Museum of Vietnam, the National Museum of China, and the National Museum of the United States. The city is also full of interesting museums, including the National Museum of Vietnam, the National Museum of China, and the"

[beam 5] từ khác nhau / tổng: 32/51
' that it is the capital city of Vietnam. It is the largest city in Vietnam, and it is also the largest city in Southeast Asia. It is located on the banks of the Mekong River, and it has a population of over 10 million people. The city is known for its'

[sample T=0.7, top-p 0.8] từ khác nhau / tổng: 39/54
' the culture. The people are very friendly and welcoming, and the streets are bustling with activity. The city is also home to many different types of restaurants, from traditional Vietnamese dishes to modern fusion cuisine. There are also many parks and outdoor spaces to enjoy the beautiful scenery. The weather is pleasant and the'

Ba kết quả minh hoạ ba ý của mục này:

  • Greedy rơi vào vòng lặp sau chừng 30 token: cả cụm “the National Museum of Vietnam, the National Museum of China” quay lại nguyên văn.
  • Beam 5 viết trôi chảy, không lặp trong 60 token này, nhưng sai sự thật liên tục: Hà Nội không nằm bên sông Mekong, không phải thành phố lớn nhất Việt Nam hay Đông Nam Á. Chuỗi có xác suất cao theo model không đồng nghĩa với chuỗi đúng.
  • Sampling đa dạng nhất (39 từ khác nhau trên 54) và nghe tự nhiên nhất, dù nội dung chung chung.

Đây là một lần chạy trên một model nhỏ, không phải benchmark. Nhưng nó cho thấy đúng xu hướng mà các paper trên mô tả.

Beam search còn tốn kém: B beam là B chuỗi phải chạy song song, kèm B lần KV cache. Các API chat thương mại như OpenAI Chat Completions không có tham số beam search; tham số n của họ trả về n mẫu độc lập, không phải n beam.


04Sampling: temperature, top-k, top-p, min-p

Bài 2, mục 9.2 và mục 9.3 đã định nghĩa temperature, top-k, top-p. Mục này đặt bốn kỹ thuật cạnh nhau theo một cách nhìn: temperature đổi hình dạng phân phối, ba kỹ thuật còn lại cắt đuôi phân phối.

Temperature đổi hình dạng, không đổi thứ hạng

p_i = softmax(z / T)_i

Chia mọi logit cho cùng một số dương không làm thay đổi thứ tự của chúng. Token mạnh nhất vẫn mạnh nhất ở mọi T. Đó là lý do:

  • greedy không quan tâm temperature (argmax không đổi), và vLLM tính kết quả greedy trước bước temperature, như bảng ở mục 02;
  • T → 0 dồn toàn bộ xác suất về token mạnh nhất, tức là tiến về greedy. Không thư viện nào chia cho 0 thật. vLLM coi T dưới 1e-5 là greedy. HF thì báo lỗi nếu bạn đặt temperature = 0 khi đang bật sampling, kèm lời nhắc dùng do_sample=False (logits_process.py L289–L296).

Vấn đề của temperature cao nằm ở đuôi phân phối. Vocabulary có 151.936 token. Phần lớn trong số đó vô nghĩa ở một vị trí cụ thể, mỗi token có xác suất cực nhỏ, nhưng cộng lại thì đáng kể. Temperature cao nâng cả khối đuôi đó lên. Ba kỹ thuật dưới đây là ba cách cắt nó đi.

Ba cách cắt đuôi

Kỹ thuật Giữ lại Ngưỡng phụ thuộc vào Nguồn
Top-k k token có xác suất cao nhất không gì cả, k cố định Fan et al. 2018
Top-p (nucleus) tập nhỏ nhất có tổng xác suất ≥ p toàn bộ hình dạng phân phối Holtzman et al. 2019
Min-p token có p_i ≥ p_base · p_max xác suất của token mạnh nhất Nguyen et al. 2024

Top-k là cách cũ nhất. Fan et al. 2018 dùng k = 10 để sinh truyện. Nhược điểm của nó nằm ngay trong định nghĩa: k không biết model đang chắc chắn đến đâu.

Top-p sửa điều đó: khi phân phối nhọn, vài token đã đủ p; khi phân phối phẳng, tập giữ lại tự mở rộng. Nhưng chính sự tự mở rộng đó là điểm yếu khi temperature cao, như thí nghiệm dưới đây sẽ cho thấy.

Min-p đặt ngưỡng tương đối so với token mạnh nhất. Với p_base = 0,1: nếu token mạnh nhất có xác suất 0,9, mọi token dưới 0,09 bị loại; nếu token mạnh nhất chỉ có 0,2, ngưỡng hạ xuống 0,02. Model càng chắc chắn, ngưỡng càng cao.

Chạy trên model thật: phân phối nhọn và phân phối phẳng

Đoạn code dưới đây lấy logits thật của Qwen2.5-0.5B-Instruct cho hai câu hỏi: một câu có đáp án gần như duy nhất, một câu có rất nhiều đáp án hợp lý. Sau đó nó đưa logits qua đúng các class LogitsProcessor của HF và đếm xem còn bao nhiêu token có thể được chọn.

# transformers==5.18.0, torch==2.14.1 (CPU)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.generation.logits_process import (
    LogitsProcessorList, MinPLogitsWarper, TemperatureLogitsWarper,
    TopKLogitsWarper, TopPLogitsWarper,
)

name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32).eval()

def next_token_logits(question):
    msgs = [{"role": "user", "content": question}]
    inputs = tok.apply_chat_template(msgs, add_generation_prompt=True,
                                     return_tensors="pt", return_dict=True)
    with torch.no_grad():
        return model(**inputs).logits[:, -1, :]          # shape [1, vocab]

def survivors(logits, *warpers):
    out = LogitsProcessorList(warpers)(None, logits.clone())
    return int(torch.isfinite(out).sum())

prompts = {
    "nhọn": "What is the capital of France? Answer with one word.",
    "phẳng": "Name a food. Answer with one word.",
}
print(f"vocab = {model.config.vocab_size:,}")
for label, q in prompts.items():
    logits = next_token_logits(q)
    probs = logits.softmax(-1)[0]
    top = probs.topk(4)
    print(f"\n[{label}] top-4:", ", ".join(f"{tok.decode([int(i)])!r}={p:.3f}"
                                         for p, i in zip(top.values, top.indices)))
    for T in (0.7, 1.0, 1.5):
        t = TemperatureLogitsWarper(T)
        print(f"  T={T}: top-k 50 -> {survivors(logits, t, TopKLogitsWarper(50)):>4}"
              f" | top-p 0.9 -> {survivors(logits, t, TopPLogitsWarper(0.9)):>4}"
              f" | min-p 0.1 -> {survivors(logits, t, MinPLogitsWarper(0.1)):>4}")

Output:

vocab = 151,936

[nhọn] top-4: 'Paris'=0.987, ' Paris'=0.004, 'L'=0.003, '巴黎'=0.002
  T=0.7: top-k 50 ->   50 | top-p 0.9 ->    1 | min-p 0.1 ->    1
  T=1.0: top-k 50 ->   50 | top-p 0.9 ->    1 | min-p 0.1 ->    1
  T=1.5: top-k 50 ->   50 | top-p 0.9 ->   12 | min-p 0.1 ->    1

[phẳng] top-4: 'N'=0.164, 'Pizza'=0.126, 'Chinese'=0.088, 'P'=0.076
  T=0.7: top-k 50 ->   50 | top-p 0.9 ->   13 | min-p 0.1 ->    7
  T=1.0: top-k 50 ->   50 | top-p 0.9 ->   41 | min-p 0.1 ->   11
  T=1.5: top-k 50 ->   50 | top-p 0.9 -> 3439 | min-p 0.1 ->   28

Đọc bảng này theo từng cột:

  • Top-k 50 luôn giữ đúng 50 token, dù model đang chắc 98,7% rằng đáp án là Paris. Ở T = 1,5, 49 token còn lại (trong đó có L, 巴黎, London) có cơ hội thật để được chọn.
  • Top-p 0,9 ổn ở T = 1, nhưng vỡ ở phân phối phẳng khi temperature cao: từ 41 token nhảy lên 3.439 token. Temperature 1,5 làm đuôi dày lên, nên phải gom rất nhiều token xác suất nhỏ mới đủ 90%.
  • Min-p 0,1 tăng chậm và có kiểm soát: 7, 11, rồi 28 token. Ở câu hỏi về thủ đô, nó giữ đúng một token ở mọi temperature.

Một chi tiết nhỏ mà bảng cho thấy: các token N và P đứng đầu phân phối phẳng không phải từ hoàn chỉnh. Model đang định viết Noodles hay Pasta theo từng mảnh subword, như bài về tokenization đã giải thích. Decoding chọn từng mảnh, không chọn từng từ.

Min-p có thật sự tốt hơn?

Nguyen et al. 2024 đề xuất min-p với kết luận rằng nó cải thiện cả chất lượng lẫn độ đa dạng so với top-p, nhất là ở temperature cao. Paper được chọn trình bày oral ở ICLR 2025, và min-p có mặt trong HF, vLLM, llama.cpp.

Tuy vậy, Schaeffer et al. 2025 phân tích lại bằng chứng và đi tới kết luận khác: đánh giá bằng người của paper gốc bỏ sót dữ liệu và làm sai kiểm định thống kê; khi quét đủ siêu tham số cho mọi phương pháp, min-p không vượt các baseline trên các benchmark mà paper gốc dùng. Tác giả paper gốc đã chạy lại một đánh giá bằng người mới, và theo Schaeffer et al., kết quả mới đó cũng không cho thấy min-p tốt hơn.

Kết luận an toàn hơn: lợi thế của min-p là cơ chế, không phải một khẳng định đã được chứng minh về chất lượng. Ngưỡng của nó co giãn theo độ tự tin của model, nên nó ít nhạy với temperature hơn top-p, như bảng trên cho thấy. Nếu bạn cần temperature cao để có văn bản đa dạng, min-p là lựa chọn hợp lý. Nếu bạn để temperature quanh 0,7, khác biệt giữa min-p và top-p nhỏ hơn nhiều.

Gợi ý cấu hình theo loại tác vụ

Bảng này là điểm xuất phát, không phải chuẩn. Nếu nhà phát triển model công bố cấu hình khuyến nghị (như generation_config.json của Qwen ở mục 02), hãy bắt đầu từ cấu hình đó.

Tác vụ Cấu hình bắt đầu Được gì Mất gì
Trích xuất, phân loại, sinh code ngắn greedy, hoặc T từ 0 đến 0,2 output ổn định, dễ kiểm thử dễ lặp khi output dài; vẫn có thể sai một cách nhất quán
Trích xuất cần đúng format như trên, cộng constrained decoding (mục 06) output luôn parse được chi phí biên dịch grammar; có thể kém hơn ở tác vụ cần suy luận
Chat, hỏi đáp T khoảng 0,7, top_p từ 0,8 đến 0,95 tự nhiên, ít lặp mỗi lần gọi một khác; khó tái hiện lỗi
Viết sáng tạo, brainstorm T từ 1,0 đến 1,3, min_p từ 0,05 đến 0,1 đa dạng dễ lạc đề; cần người đọc lại

Một nguyên tắc từ bài 1, mục 8.6 vẫn đúng: temperature và top-p cùng tác động lên một phân phối, nên chỉnh một tham số mỗi lần.


05Chống lặp: repetition, frequency và presence penalty

Bài 2, mục 9.4 đã liệt kê ba loại penalty. Mục này xem công thức thật của chúng trong code, vì có một chi tiết dễ sai và một chi tiết về phạm vi ít người để ý.

Repetition penalty: chia hay nhân?

Repetition penalty đến từ paper CTRL (Keskar et al. 2019). Công thức gốc chia logit của mọi token đã xuất hiện cho một hệ số θ, và paper khuyên dùng θ ≈ 1,2:

p_i = exp( x_i / (T · I(i ∈ g)) ) / Σ_j exp( x_j / (T · I(j ∈ g)) )
I(c) = θ nếu c đúng, ngược lại 1          g = các token đã sinh

Chia cho θ > 1 làm logit dương nhỏ đi, tức là giảm xác suất. Nhưng với logit âm, chia cho θ lại kéo nó về gần 0, tức là tăng xác suất. Một token model vốn không muốn lặp lại (logit âm) sẽ được thưởng thay vì bị phạt.

Cả HF lẫn vLLM đều sửa điều này giống nhau: logit dương thì chia, logit âm thì nhân với θ. Xem logits_process.py L390 của HF và _custom_ops.py L373–L375 của vLLM. Kiểm tra bằng số:

# transformers==5.18.0, torch==2.14.1
import torch
from transformers.generation.logits_process import RepetitionPenaltyLogitsProcessor

#               token 0 (đã xuất hiện)   token 1 (đã xuất hiện)   token 2 (chưa)
logits = torch.tensor([[2.0, -2.0, 0.5]])
seen = torch.tensor([[0, 1]])            # input_ids: token 0 và 1 đã có trong context
theta = 1.3

naive = logits.clone()
naive[0, :2] = naive[0, :2] / theta      # chỉ chia, như công thức gốc trong paper CTRL
hf = RepetitionPenaltyLogitsProcessor(penalty=theta)(seen, logits.clone())

print("gốc   :", logits.tolist()[0])
print("chỉ chia:", [round(x, 3) for x in naive.tolist()[0]])
print("HF    :", [round(x, 3) for x in hf.tolist()[0]])

Output:

gốc   : [2.0, -2.0, 0.5]
chỉ chia: [1.538, -1.538, 0.5]
HF    : [1.538, -2.6, 0.5]

Với cách “chỉ chia”, token 1 đi từ −2,0 lên −1,54, tức là dễ được chọn hơn sau khi bị “phạt”. Với HF, nó xuống −2,6 như mong đợi. Nếu bạn tự cài repetition penalty, đây là lỗi đầu tiên cần tránh.

Frequency và presence penalty: phép trừ cộng dồn

Frequency và presence penalty đến từ API của OpenAI và hoạt động theo kiểu khác hẳn: trừ một lượng khỏi logit thay vì nhân hay chia. vLLM cài đúng công thức đó ở layers/utils.py L79–L80:

logit_i -= frequency_penalty · số_lần_token_i_đã_xuất_hiện
logit_i -= presence_penalty  · (1 nếu token i đã xuất hiện, ngược lại 0)

Frequency penalty tăng theo số lần lặp, nên một từ lặp mười lần bị phạt nặng gấp mười lần một từ lặp một lần. Presence penalty chỉ quan tâm “đã xuất hiện hay chưa”. OpenAI cho cả hai nằm trong khoảng −2,0 đến 2,0.

Phạt cả prompt hay chỉ phạt output?

Đây là chi tiết ít người để ý, và nó khác nhau giữa các loại penalty:

Penalty Tính trên Nguồn
Repetition penalty (HF) toàn bộ input_ids: prompt và output docstring, L306–L310
Repetition penalty (vLLM) prompt và output _custom_ops.py L371–L372
Frequency, presence penalty (vLLM) chỉ output layers/utils.py L79–L80

Hệ quả thực tế rõ nhất là ở RAG. Prompt chứa đoạn tài liệu mà câu trả lời cần trích lại: tên sản phẩm, mã lỗi, con số. Repetition penalty phạt chính những token đó, đẩy model sang dùng từ đồng nghĩa hoặc viết sai tên. HF cho phép bỏ prompt khỏi phạm vi phạt bằng tham số prompt_ignore_length của RepetitionPenaltyLogitsProcessor, truyền vào qua logits_processor.

Khi nào penalty gây hại

Penalty là công cụ thô: nó không biết lần lặp nào là lỗi và lần nào là bắt buộc.

  • Tên riêng và thuật ngữ phải lặp lại đúng. Phạt chúng thì model đổi cách viết.
  • Code lặp token liên tục: self, (, ), thụt lề, tên biến. Phạt chúng làm code sai cú pháp.
  • JSON lặp dấu ", :, , và tên key. Đây là lý do không nên bật penalty cao khi cần output có cấu trúc.
  • no_repeat_ngram_size cứng hơn nữa: nó cấm tuyệt đối mọi n-gram đã xuất hiện, nên chỉ cần một tên riêng dài hai token xuất hiện lần thứ hai là model buộc phải viết sai.

Nhà phát triển model biết điều này, nên họ chỉnh penalty rất nhẹ. Qwen2.5-0.5B-Instruct đặt repetition_penalty = 1.1 trong generation_config.json, thấp hơn 1,2 mà paper CTRL gợi ý.


06Constrained decoding và structured output

Ứng dụng thật thường không cần văn bản. Nó cần một nhãn phân loại, một đối tượng JSON, một lệnh gọi hàm. Cách đơn giản nhất là ghi vào prompt “chỉ trả về JSON”. Cách này đúng phần lớn thời gian, và “phần lớn” chính là vấn đề: một hệ thống xử lý một triệu request mỗi ngày với tỉ lệ lỗi 1% vẫn có mười nghìn lần parser báo lỗi.

Ý tưởng: mask logits theo ngữ pháp

Constrained decoding giải quyết bài toán ở tầng decoding, không phải tầng prompt. Ở mỗi bước, trước khi chọn token, ta đặt logit của mọi token có thể làm output sai cú pháp thành -inf. Token đó có xác suất 0 và không bao giờ được chọn. Output vì thế luôn hợp lệ theo ngữ pháp, không cần hy vọng.

Phiên bản đơn giản nhất: ép output là một trong vài nhãn cho trước. Đoạn code dưới đây viết một LogitsProcessor riêng làm đúng việc đó (chỉ xử lý batch size 1 cho gọn):

# transformers==5.18.0, torch==2.14.1 (CPU)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, LogitsProcessor, LogitsProcessorList

name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32).eval()

class ChoiceProcessor(LogitsProcessor):
    """Chỉ cho phép output là đúng một trong các chuỗi `choices`, rồi EOS."""
    def __init__(self, choices, prompt_len):
        self.seqs = [tok.encode(c, add_special_tokens=False) for c in choices]
        self.prompt_len = prompt_len

    def __call__(self, input_ids, scores):
        done = input_ids[0, self.prompt_len:].tolist()
        allowed = set()
        for seq in self.seqs:
            if seq[:len(done)] == done:
                allowed.add(seq[len(done)] if len(done) < len(seq) else tok.eos_token_id)
        mask = torch.full_like(scores, float("-inf"))
        mask[:, list(allowed)] = 0.0
        return scores + mask                      # token không hợp lệ -> -inf

review = "The pho was cold, but the staff were lovely and fixed it fast."
msgs = [{"role": "user", "content": f"Classify the sentiment of this review: {review}"}]
inputs = tok.apply_chat_template(msgs, add_generation_prompt=True,
                                 return_tensors="pt", return_dict=True)
n = inputs["input_ids"].shape[1]

free = model.generate(**inputs, max_new_tokens=30, do_sample=False)
choices = ["positive", "negative", "mixed"]
forced = model.generate(**inputs, max_new_tokens=5, do_sample=False,
                        logits_processor=LogitsProcessorList([ChoiceProcessor(choices, n)]))
print("Không ràng buộc:", repr(tok.decode(free[0, n:], skip_special_tokens=True)))
print("Có ràng buộc   :", repr(tok.decode(forced[0, n:], skip_special_tokens=True)))
print("Token ids của các nhãn:", {c: tok.encode(c, add_special_tokens=False) for c in choices})

Output:

Không ràng buộc: 'The sentiment of this review can be classified as positive. The reviewer expresses satisfaction with both the food quality (the pho) and the service provided by'
Có ràng buộc   : 'positive'
Token ids của các nhãn: {'positive': [30487], 'negative': [42224], 'mixed': [56685]}

Không ràng buộc, model viết cả một đoạn văn mà code phía sau phải tự tìm nhãn trong đó. Có ràng buộc, output là đúng một nhãn rồi dừng. Processor này nằm ở bước 8 trong bảng HF ở mục 02, tức là chạy trước temperature và bước cắt đuôi, đúng như mục 02 lập luận.

Để ý thêm: review vừa chê (phở nguội) vừa khen (nhân viên dễ thương), nên mixed mới là nhãn hợp lý hơn. Model vẫn chọn positive. Constrained decoding đảm bảo hình dạng của output, không đảm bảo nội dung.

Từ danh sách nhãn đến JSON Schema

Danh sách nhãn thì kiểm bằng tiền tố là đủ. JSON Schema phức tạp hơn nhiều: key nào bắt buộc, kiểu dữ liệu, enum, mảng, object lồng nhau. Có hai bước tiến quan trọng:

  • Outlines (Willard và Louf 2023) biến regex thành một máy trạng thái hữu hạn (FSM). Việc quy một JSON Schema về regex trước khi dựng FSM là tính năng của thư viện Outlines, paper không trình bày bước này. Điểm mấu chốt của paper là dựng sẵn một bảng tra: với mỗi trạng thái của FSM, những token nào trong vocabulary được phép. Lúc sinh, mỗi bước chỉ cần tra bảng thay vì kiểm từng token.
  • XGrammar (Dong et al. 2024) xử lý ngữ pháp phi ngữ cảnh (CFG), cần thiết cho cấu trúc lồng nhau tuỳ ý, bằng một automaton có ngăn xếp (pushdown automaton, PDA). Nó chia vocabulary làm hai nhóm: token không phụ thuộc ngữ cảnh, kiểm trước được, và token phụ thuộc ngữ cảnh, phải kiểm lúc chạy. Paper báo cáo tốc độ nhanh hơn tới 100 lần so với các giải pháp trước đó, và chi phí gần như bằng 0 khi chạy cùng inference engine.

llama.cpp có định dạng ngữ pháp riêng gọi là GBNF, theo cùng ý tưởng.

~/diagrams/tu-logits-den-cau-tra-loi/constrained-decoding.htmltương tác
mở riêng ↗
HìnhHàng trên chạy một lần trước khi sinh: JSON Schema (hoặc regex, CFG) được biên dịch thành FSM hoặc PDA, rồi dựng thành bảng tra từ trạng thái sang tập token hợp lệ. Hàng dưới chạy ở mỗi bước: từ trạng thái hiện tại, tra ra bitmask một bit cho mỗi token, mask logits rồi sampling như thường. Token mới đẩy automaton đi tiếp một bước và vòng lặp tiếp tục.
bước tính: biên dịch, mask logitstrạng thái và token thay đổi theo từng bướckhung đỏ: bitmask; khung tím: bảng tra dựng sẵn; khung xanh lam: schema đầu vào

Ranh giới token không khớp ranh giới ngữ pháp

Ngữ pháp được viết theo ký tự, còn model sinh theo token. Một token có thể chứa nhiều ký hiệu ngữ pháp cùng lúc: tokenizer của Qwen có token cho ",, cho "} hay cho ":". Để biết một token có hợp lệ ở trạng thái hiện tại không, phải cho cả chuỗi ký tự của token đó chạy qua automaton. Đây là lý do việc dựng sẵn bảng tra quan trọng: làm việc này cho 151.936 token ở mỗi bước là quá đắt.

Chiều ngược lại cũng có vấn đề. Ví dụ trên ép output bắt đầu bằng token positive (id 30487, không có dấu cách ở đầu). Tokenizer còn có token positive có dấu cách ở đầu (id 6785). Nếu model “muốn” viết bằng token có dấu cách, ràng buộc buộc nó vào một cách tách token mà nó ít gặp lúc training. Những lệch nhỏ như vậy có thể làm chất lượng giảm theo cách khó thấy.

JSON mode không phải structured output theo schema

Hai khái niệm này hay bị dùng lẫn:

  • JSON mode chỉ đảm bảo output là JSON hợp lệ. Key nào, kiểu gì, giá trị nào là việc của model.
  • Structured output theo schema đảm bảo output khớp một JSON Schema cụ thể.

OpenAI tách bạch hai chế độ này. Khi ra mắt Structured Outputs vào tháng 8/2024, họ báo cáo rằng trên bộ đánh giá tuân thủ JSON Schema phức tạp của họ, gpt-4o-2024-08-06 với Structured Outputs đạt 100%, trong khi gpt-4-0613 chỉ dựa vào prompt đạt dưới 40%.

Chạy thử trên CPU với XGrammar và HF để thấy khác biệt. Schema yêu cầu sentiment là một trong ba giá trị viết thường:

# transformers==5.18.0, torch==2.14.1 (CPU), xgrammar==0.2.8, pydantic==2.13.5
import json
import torch
import xgrammar as xgr
from pydantic import BaseModel
from typing import Literal
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer

class Review(BaseModel):
    dish: str
    sentiment: Literal["positive", "negative", "mixed"]
    score: int

name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32).eval()

# Biên dịch JSON Schema thành grammar một lần; mỗi bước decode chỉ còn tra bitmask
vocab_size = AutoConfig.from_pretrained(name).vocab_size      # 151.936, lớn hơn len(tok)
info = xgr.TokenizerInfo.from_huggingface(tok, vocab_size=vocab_size)
grammar = xgr.GrammarCompiler(info).compile_json_schema(Review.model_json_schema())

review = "The pho was cold, but the staff were lovely and fixed it fast."
msgs = [{"role": "user", "content": f"Extract dish, sentiment and a 1-5 score as JSON: {review}"}]
inputs = tok.apply_chat_template(msgs, add_generation_prompt=True,
                                 return_tensors="pt", return_dict=True)
n = inputs["input_ids"].shape[1]

free = model.generate(**inputs, max_new_tokens=80, do_sample=False)
forced = model.generate(**inputs, max_new_tokens=80, do_sample=False,
                        logits_processor=[xgr.contrib.hf.LogitsProcessor(grammar)])

for label, out in [("Không ràng buộc", free), ("Theo schema", forced)]:
    text = tok.decode(out[0, n:], skip_special_tokens=True)
    print(f"--- {label} ---\n{text}")
    try:
        print("=> hợp lệ theo schema:", Review.model_validate(json.loads(text)))
    except Exception as e:
        print("=> không parse được theo schema:", type(e).__name__)

Output:

--- Không ràng buộc ---
{
  "dish": "Pho",
  "sentiment": "Positive",
  "score": 4
}
=> không parse được theo schema: ValidationError
--- Theo schema ---
{
  "dish": "Pho",
  "sentiment": "positive",
  "score": 4
}
=> hợp lệ theo schema: dish='Pho' sentiment='positive' score=4

Không ràng buộc, model vẫn trả ra JSON hợp lệ: json.loads chạy được. Nhưng nó viết "Positive" viết hoa, nên kiểm tra theo schema thất bại. Đó đúng là khoảng cách giữa “JSON mode” và “đúng schema”. Có ràng buộc, sentiment chỉ có thể là một trong ba giá trị đã khai báo, và output qua được kiểm tra.

Và một lần nữa: output hợp lệ vẫn nói positive, dù review có cả khen lẫn chê. Schema ép hình dạng, model vẫn chọn giá trị.

Trong vLLM

Ở vLLM 0.30.0, structured output được cấu hình qua structured_outputs. Các tham số cũ guided_json, guided_regex, guided_choice đã bị bỏ từ v0.12.0 (tài liệu structured outputs của vLLM). Backend mặc định là "auto", chọn giữa xgrammar, guidance, outlines và lm-format-enforcer tuỳ theo request (config/structured_outputs.py L12–L25).

# vllm==0.30.0 — KHÔNG chạy trong bài: cần cài vLLM, thường kèm GPU NVIDIA.
# Viết theo API trong vllm/sampling_params.py ở tag v0.30.0.
from vllm import LLM, SamplingParams
from vllm.sampling_params import StructuredOutputsParams

# Review là class Pydantic định nghĩa ở đoạn code XGrammar phía trên.

llm = LLM(model="Qwen/Qwen2.5-0.5B-Instruct")
params = SamplingParams(
    temperature=0.0,
    structured_outputs=StructuredOutputsParams(json=Review.model_json_schema()),
)
out = llm.generate("Extract dish, sentiment and a 1-5 score as JSON: ...", params)
print(out[0].outputs[0].text)

Qua server tương thích OpenAI, bạn có thể dùng response_format dạng json_schema như với API của OpenAI, hoặc truyền extra_body={"structured_outputs": {"json": schema}}.

Cái giá của việc ép format

Constrained decoding không miễn phí:

  • Chi phí biên dịch. Schema phải được biên dịch thành automaton trước request đầu tiên. Engine thường cache kết quả, nên chi phí này chủ yếu rơi vào request đầu tiên của mỗi schema mới.
  • Chi phí mỗi bước. Tính bitmask cho cả vocabulary ở mỗi bước. XGrammar giảm phần này tới mức gần như không đáng kể, nhưng các backend cũ hơn có thể chậm thấy rõ.
  • Chất lượng suy luận. Tam et al. 2024 (Let Me Speak Freely?) đo thấy khả năng suy luận của LLM giảm đáng kể khi bị ép format, và format càng chặt thì giảm càng nhiều. Kết quả này bị tranh luận: nhóm phát triển Outlines (.txt) phản biện trong bài Say What You Mean rằng mức giảm chủ yếu đến từ cách viết prompt trong thí nghiệm, và khi sửa prompt thì structured generation cho kết quả tốt hơn generation tự do.

Cách làm thực tế để giảm rủi ro thứ ba: cho model một trường văn bản tự do để suy luận trước trường đáp án (ví dụ key reasoning đứng trước key answer trong schema), hoặc tách làm hai bước, bước đầu suy luận tự do và bước sau chỉ trích xuất.


07Vì sao cùng một prompt lại ra hai câu trả lời

Câu hỏi này có ba tầng nguyên nhân, từ dễ thấy đến khó thấy.

Tầng 1: sampling là ngẫu nhiên, và seed chỉ cố định phần ngẫu nhiên

Với temperature > 0, mỗi lần gọi bốc một token ngẫu nhiên theo phân phối. Hai lần gọi khác nhau là đúng thiết kế. Seed cố định bộ sinh số ngẫu nhiên: torch.manual_seed trong HF, tham số seed cho từng request trong vLLM. vLLM không bốc mẫu bằng torch.multinomial như HF mà chia xác suất cho một nhiễu phân phối mũ rồi lấy argmax (topk_topp_sampler.py L464–L486), một cách bốc mẫu tương đương về phân phối. Đây là đường native. Trên GPU NVIDIA, khi request có top-k hoặc top-p, vLLM mặc định dùng sampler của FlashInfer (VLLM_USE_FLASHINFER_SAMPLER mặc định bật, envs.py L49). Nhưng FlashInfer không hỗ trợ bộ sinh số ngẫu nhiên riêng cho từng request, nên chỉ cần trong batch có request đặt seed là cả batch quay về đường native (topk_topp_sampler.py L185–L192). Những gì vừa tả là đường của model runner V1. Model runner V2, mặc định từ vLLM v0.30.0, khác ở hai điểm: đường dự phòng là một kernel Triton Gumbel-max với số ngẫu nhiên tính từ cặp (seed, pos), và FlashInfer bị tắt cho cả batch khi trong batch có request đặt seed hoặc có request greedy (gpu/sample/sampler.py L300–L308). Vậy nên cùng seed trên hai engine khác nhau không cho cùng token.

Quan trọng hơn: seed chỉ cố định con số ngẫu nhiên. Nó không cố định logits. Nếu logits thay đổi dù rất ít, cùng một con số ngẫu nhiên vẫn có thể rơi vào token khác. Đó là tầng thứ hai.

Tầng 2: greedy cũng không tất định, vì phép cộng số thực

Đặt temperature = 0 thì không còn ngẫu nhiên. Nhưng logits vẫn có thể thay đổi giữa hai lần gọi. Gốc rễ nằm ở một tính chất của số thực dấu phẩy động: phép cộng không có tính kết hợp.

>>> (0.1 + 1e20) - 1e20
0.0
>>> 0.1 + (1e20 - 1e20)
0.1

Cùng ba số, cộng theo thứ tự khác nhau ra kết quả khác nhau. Một phép nhân ma trận trên GPU là hàng triệu phép cộng như vậy, và thứ tự cộng phụ thuộc vào cách kernel chia việc.

Giả thuyết phổ biến là: các luồng GPU chạy song song, luồng nào xong trước thì cộng trước, nên thứ tự cộng ngẫu nhiên. Bài Defeating Nondeterminism in LLM Inference của Thinking Machines (Horace He và cộng sự, 9/2025) chỉ ra rằng giả thuyết này không đúng với forward pass của LLM. Các kernel dùng trong forward pass thường cho kết quả giống hệt nhau từ lần chạy này sang lần chạy khác khi input y hệt, kể cả batch.

Nguyên nhân thật là các kernel đó không bất biến theo batch (batch invariance). Kernel chọn cách chia phép tính (chia khối, chia reduction) tuỳ theo kích thước batch, nên kết quả cho request của bạn phụ thuộc vào việc có bao nhiêu request khác đang chạy cùng. Mà trên một server, batch size thay đổi theo tải từ những người dùng khác. Với bạn, đó là một biến ngẫu nhiên.

Thí nghiệm của Thinking Machines, trích nguyên từ bài viết:

Thiết lập Kết quả
Qwen3-235B-A22B-Instruct-2507, temperature = 0, prompt “Tell me about Richard Feynman”, 1.000 lần, mỗi lần 1.000 token 80 câu trả lời khác nhau; câu phổ biến nhất xuất hiện 78 lần
Chỗ bắt đầu khác nhau 102 token đầu giống hệt nhau. Ở token thứ 103, sau “Feynman was born on May 11, 1918, in”, 992 lần viết “Queens, New York”, 8 lần viết “New York City”
Bật kernel batch-invariant cả 1.000 câu trả lời giống hệt nhau
Chi phí (thí nghiệm riêng: Qwen3-8B, 1.000 chuỗi, output dài 90–110 token) vLLM mặc định 26 giây; bản tất định chưa tối ưu 55 giây; thêm kernel attention cải tiến 42 giây

Tự kiểm chứng trên CPU

Thí nghiệm dưới đây kiểm tra cùng hiện tượng ở quy mô nhỏ: đưa một prompt vào model một mình, rồi đưa cùng prompt đó vào chung batch với các prompt khác, và so logits của token tiếp theo. Prompt cần kiểm tra là prompt dài nhất, nên nó không bị thêm padding. Mọi khác biệt chỉ có thể đến từ số học, không đến từ input.

# transformers==5.18.0, torch==2.14.1 (CPU)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name, padding_side="left")

def chat(q):
    return tok.apply_chat_template([{"role": "user", "content": q}],
                                   add_generation_prompt=True, tokenize=False)

target = chat("Tell me about Richard Feynman, the physicist, in a few sentences.")
others = [chat(f"Haiku about {i}.") for i in range(15)]
n = len(tok(target).input_ids)
# target dài nhất nên không bị pad: mọi khác biệt chỉ đến từ số học, không đến từ input
assert all(len(tok(o).input_ids) < n for o in others)

for dtype in (torch.float32, torch.bfloat16):
    model = AutoModelForCausalLM.from_pretrained(name, dtype=dtype).eval()
    enc = lambda batch: tok(batch, return_tensors="pt", padding=True)
    with torch.no_grad():
        alone = model(**enc([target])).logits[0, -1].float()
        diffs = [(alone - model(**enc([target] + others[: s - 1])).logits[0, -1].float())
                 .abs().max().item() for s in (2, 4, 8, 16)]
        a = model.generate(**enc([target]), max_new_tokens=200, do_sample=False)[0, n:]
        b = model.generate(**enc([target] + others[:7]), max_new_tokens=200, do_sample=False)[0, n:]
    first = next((i for i, (x, y) in enumerate(zip(a.tolist(), b.tolist())) if x != y), None)
    print(f"{str(dtype):<15} max|Δlogit| batch 2/4/8/16 = "
          + " / ".join(f"{d:.1e}" for d in diffs)
          + f" | greedy batch 1 vs 8, token khác đầu tiên: {first}")

Output với torch 2.14.1:

torch.float32   max|Δlogit| batch 2/4/8/16 = 1.5e-05 / 2.6e-05 / 2.8e-05 / 2.6e-05 | greedy batch 1 vs 8, token khác đầu tiên: None
torch.bfloat16  max|Δlogit| batch 2/4/8/16 = 0.0e+00 / 0.0e+00 / 0.0e+00 / 0.0e+00 | greedy batch 1 vs 8, token khác đầu tiên: None

Trước khi nâng lên torch 2.14.1, tôi đã chạy đúng đoạn code này với torch 2.8.0 trên cùng máy, và kết quả bf16 khác hẳn:

torch.float32   max|Δlogit| batch 2/4/8/16 = 2.3e-05 / 2.7e-05 / 2.8e-05 / 3.3e-05 | greedy batch 1 vs 8, token khác đầu tiên: None
torch.bfloat16  max|Δlogit| batch 2/4/8/16 = 3.8e-01 / 3.8e-01 / 3.8e-01 / 3.8e-01 | greedy batch 1 vs 8, token khác đầu tiên: 61

Mỗi cấu hình đều được chạy hai lần và cho cùng output, nên kết quả tất định khi batch giữ nguyên. Thứ thay đổi kết quả là batch size. Ba điều rút ra:

  1. Logits thật sự đổi theo batch size, kể cả trên CPU. Ở float32, độ lệch cỡ 1e-5. Mức này không đủ làm đổi token nào trong 200 token greedy ở thí nghiệm này, vì không có bước nào có hai token mạnh nhất sát nhau đến vậy.
  2. Ở bf16 với torch 2.8.0, độ lệch lên tới 0,38, và câu trả lời greedy rẽ sang hướng khác từ token thứ 61. Đây là cùng hiện tượng Thinking Machines quan sát được, ở quy mô nhỏ hơn. Ở bf16, số bit dành cho phần định trị ít hơn nhiều so với float32, nên sai số làm tròn lớn hơn.
  3. Cùng một đoạn code, chỉ đổi phiên bản torch, bf16 từ không bất biến thành bất biến theo batch trong thí nghiệm này. Batch invariance là tính chất của kernel cụ thể, không phải của model hay của thuật toán decoding. Nâng cấp thư viện có thể làm nó xuất hiện hoặc biến mất.

Tầng 3: phía nhà cung cấp API

Khi gọi một API, bạn còn không kiểm soát được những thứ bên dưới:

  • Model và hạ tầng thay đổi. OpenAI trả về trường system_fingerprint để đánh dấu cấu hình backend. Theo cookbook của họ, tham số seed chỉ giúp output “phần lớn” giống nhau theo kiểu best effort, tính tất định không được đảm bảo, và khi system_fingerprint đổi thì output có thể đổi theo.
  • Request được phục vụ trên phần cứng và batch khác nhau, nên mọi điều ở tầng 2 đều áp dụng.
  • Với model Mixture of Experts, có một giả thuyết hay được nhắc: khi expert có giới hạn sức chứa, việc token của bạn được route tới expert nào có thể phụ thuộc vào các token khác trong batch. Đây là suy luận từ cách MoE hoạt động, chưa có nhà cung cấp lớn nào xác nhận công khai đó là nguyên nhân trong hệ thống của họ.

Bạn làm được gì

Mục tiêu Việc nên làm
Tái hiện một lỗi khi tự host model cố định seed, đặt temperature 0, ghi lại toàn bộ tham số và phiên bản thư viện; nếu cần giống hệt từng bit, bật chế độ batch-invariant (vLLM 0.30.0: biến môi trường VLLM_BATCH_INVARIANT=1, đang beta, cần GPU NVIDIA compute capability 8.0 trở lên) hoặc chạy batch size 1
Gọi API của nhà cung cấp ghi log system_fingerprint (nếu có), tên model đầy đủ kèm ngày phát hành, mọi tham số sampling; coi seed là best effort
Đánh giá model đừng dựa vào một lần chạy; chạy nhiều mẫu và báo cáo phân phối kết quả
Cần trả lời giống hệt cho cùng câu hỏi cache output ở tầng ứng dụng thay vì trông vào tính tất định của model

Thinking Machines còn nêu một lý do khác khiến tính tất định quan trọng: trong reinforcement learning, nếu số học lúc sinh mẫu khác số học lúc train, quá trình train thực chất trở thành off-policy. Khi họ làm cho hai bên giống hệt nhau từng bit, KL divergence giữa sampler và trainer bằng 0, và quá trình train trở lại đúng on-policy.


08Những chỗ dễ hiểu sai

Năm dòng đầu khớp với hàng dưới của Hình 1.

Hiểu lầm Thực tế
“Repetition penalty chỉ là chia logit” công thức gốc của CTRL chia, nhưng chia một logit âm lại làm token dễ được chọn hơn. HF và vLLM chia logit dương và nhân logit âm
“JSON mode đảm bảo output đúng schema” JSON mode chỉ đảm bảo JSON hợp lệ. Cần structured output theo schema; ví dụ ở mục 06 cho ra JSON hợp lệ nhưng sai enum ("Positive")
“temperature = 0 là tất định” không còn ngẫu nhiên khi sampling, nhưng logits vẫn đổi theo batch size vì kernel không bất biến theo batch. Thinking Machines nhận được 80 câu trả lời khác nhau từ 1.000 lần gọi
“Top-p giữ một số token cố định” số token giữ lại thay đổi theo hình dạng phân phối: từ 1 token đến 3.439 token trong thí nghiệm ở mục 04
“Cố định seed là đủ để tái hiện” seed chỉ cố định con số ngẫu nhiên, không cố định logits. Batch, phần cứng, phiên bản thư viện và backend của nhà cung cấp vẫn đổi được kết quả
“Không truyền tham số nào thì generate chạy greedy” HF và vLLM đọc generation_config.json của model. Qwen2.5-0.5B-Instruct mặc định sampling với T = 0,7, top_p = 0,8, top_k = 20, repetition_penalty = 1,1
“Cùng tham số thì mọi engine cho cùng phân phối” thứ tự bước khác nhau: HF chạy top-p trước min-p, vLLM chạy min-p trước top-p, llama.cpp đặt temperature sau mọi bước cắt đuôi
“Greedy tìm ra câu trả lời có xác suất cao nhất” greedy tối ưu từng bước, không tối ưu cả chuỗi. Và chuỗi có xác suất cao nhất thường không phải chuỗi tốt nhất: với hơn 50% câu trong thí nghiệm dịch của Stahlberg và Byrne, đó là bản dịch rỗng
“Min-p đã được chứng minh tốt hơn top-p” paper gốc báo cáo như vậy, nhưng phân tích lại của Schaeffer et al. 2025 không tìm thấy lợi thế khi so sánh công bằng. Lợi thế chắc chắn của min-p là ngưỡng co giãn theo độ tự tin
“Constrained decoding làm output đúng” nó làm output đúng hình dạng. Giá trị bên trong vẫn do model chọn, và ép format chặt có thể làm giảm chất lượng suy luận

Tổng kết: model cho phân phối, decoding chọn câu trả lời

Model dừng lại ở logits. Mọi thứ người dùng nhìn thấy sau đó, câu trả lời gọn hay lặp, JSON parse được hay không, hai lần gọi giống hay khác nhau, được quyết định ở bước decoding.

Những gì nên mang theo:

  • decoding là một pipeline các bước biến đổi logits, và thứ tự các bước là một phần của thuật toán: HF 5.18.0 chạy temperature → top-k → top-p → min-p, vLLM 0.30.0 chạy temperature → min-p → top-k/top-p, llama.cpp đặt temperature cuối cùng;
  • mọi bước cấm token (grammar mask, bad words) phải chạy trước bước cắt đuôi, nếu không có thể không còn token hợp lệ nào;
  • greedy và beam search tìm chuỗi xác suất cao, hợp với bài toán có đáp án hẹp như dịch máy, nhưng làm văn bản mở bị lặp và nhạt; xác suất cao không đồng nghĩa với đúng;
  • temperature đổi hình dạng phân phối mà không đổi thứ hạng; top-k, top-p, min-p cắt đuôi theo số lượng cố định, khối xác suất tích luỹ và ngưỡng tương đối; ở temperature cao top-p có thể giữ hàng nghìn token, min-p thì không;
  • repetition penalty phải nhân logit âm, phạt cả prompt, và gây hại cho tên riêng, code, JSON; frequency và presence penalty là phép trừ chỉ tính trên output;
  • constrained decoding mask logits theo một automaton dựng từ schema, đảm bảo output đúng cú pháp nhưng không đảm bảo đúng nội dung, và JSON mode không phải là structured output theo schema;
  • cùng prompt ra hai câu trả lời vì ba tầng: sampling ngẫu nhiên, kernel không bất biến theo batch (kể cả khi temperature = 0), và những thay đổi phía nhà cung cấp mà seed không kiểm soát được.

Bài này xem model như một hàm đã có sẵn, trả ra logits tốt. Bài 10 quay lại câu hỏi đứng trước tất cả: pretraining, tức là model học cách trả ra những logits đó bằng cách nào. Bài 10 sẽ nói về mục tiêu next-token prediction và scaling laws. Còn hai kỹ thuật làm decoding chạy nhanh hơn, KV cache và speculative decoding, sẽ có một bài riêng ở bài 13.


Tài liệu tham khảo

Paper

Blog và tài liệu

Source code

Trương Đức Dũng

AI Engineer - Hà Nội

Tập trung vào LLM, RAG và các hệ thống AI cần chạy ổn định trong môi trường vận hành thật.