Một LLM (large language model, mô hình ngôn ngữ lớn) trông như một người trả lời câu hỏi, nhưng về bản chất nó chỉ làm một việc: nhìn dãy token đã có và ước lượng xác suất của token kế tiếp. Hiểu đúng cơ chế đó giúp bạn giải thích vì sao LLM đếm sai chữ cái, vì sao cùng một câu hỏi cho hai đáp án khác nhau, và vì sao dán thêm tài liệu vào prompt đôi khi làm câu trả lời tệ đi. Bài này mở đầu nhóm bài về AI sinh văn bản, đi qua bốn khái niệm: token, logits và sampling, ngữ cảnh (context window), và các giới hạn thực tế.
Bài nối tiếp Transformer và cơ chế attention: đọc hiểu kiến trúc qua code và Fine-tune PhoBERT với Hugging Face cho phân tích cảm xúc tiếng Việt. Mọi số liệu là output thật trên Apple M4, 16 GB, Python 3.12, PyTorch 2.4.0, Transformers 4.44.0, tiktoken 0.7.0 và Ollama chạy qwen2:1.5b. Mô hình nhỏ nên con số tuyệt đối kém mô hình thương mại, cơ chế thì giống hệt.
LLM thực chất làm gì: dự đoán token kế tiếp
Các LLM hiện nay là mô hình tự hồi quy (autoregressive) kiến trúc Transformer decoder. Mỗi bước sinh văn bản gồm năm việc: (1) tokenizer biến văn bản thành dãy số nguyên; (2) mạng Transformer cho ra một vector logits có độ dài bằng kích thước từ vựng; (3) softmax biến logits thành phân phối xác suất; (4) thuật toán giải mã (decoding) chọn một token từ phân phối đó; (5) token được nối vào đầu vào và vòng lặp chạy lại cho tới khi gặp token kết thúc hoặc đủ độ dài. Không có bước "suy nghĩ rồi mới viết": câu trả lời dài 200 token là kết quả của 200 lần lặp như vậy.
Hệ quả: LLM không "biết" điều sắp nói ở token thứ 10 khi đang viết token thứ 1, và chất lượng đầu ra phụ thuộc hai thứ tách biệt là phân phối xác suất (do huấn luyện quyết định) và cách chọn token từ phân phối đó (do bạn cấu hình).
Token và tokenizer: đơn vị mà LLM thực sự đọc
Một LLM không đọc ký tự hay từ mà đọc token, các mảnh văn bản nằm giữa ký tự và từ. Theo tài liệu Tokenization algorithms của Hugging Face, ba thuật toán phổ biến là BPE, Unigram và WordPiece. BPE (byte pair encoding, Sennrich và cộng sự, 2015) bắt đầu từ các ký tự rồi lặp lại việc gộp cặp liền kề xuất hiện nhiều nhất thành một token mới, cho tới khi đạt kích thước từ vựng mong muốn. Biến thể byte-level BPE lấy 256 giá trị byte làm bảng chữ cái gốc nên mọi chuỗi Unicode đều mã hoá được, không cần token "không biết" (<unk>); GPT-2 dùng cách này với từ vựng 50.257 token.
Với người đưa LLM vào sản phẩm tiếng Việt, điểm cần nhớ là từ vựng được học từ dữ liệu huấn luyện, nên ngôn ngữ ít xuất hiện bị cắt thành nhiều mảnh hơn. Script tokens_demo.py đếm token của bốn câu mẫu bằng ba bộ mã hoá của OpenAI qua tiktoken (gpt2, cl100k_base của GPT-4, o200k_base của GPT-4o) và tokenizer Qwen2.
[Số token / số ký tự] (ký tự tính theo Unicode, không phải byte)
Loại ký tự byte gpt2 cl100k o200k qwen2
Tiếng Anh 57 57 11 12 12 15
Tiếng Việt 62 76 45 27 18 23
Code 64 64 25 17 17 17
Số 16 16 7 6 6 16Câu tiếng Việt 62 ký tự cần 45 token với gpt2, 27 với cl100k_base, 23 với Qwen2 và 18 với o200k_base, còn câu tiếng Anh 57 ký tự chỉ cần 11 đến 15 token. Với cl100k_base, bản tiếng Việt tốn token gấp hơn hai lần bản tiếng Anh, kéo theo hoá đơn API, độ trễ và ngữ cảnh khả dụng. Nhìn cách cắt cụ thể sẽ thấy nguyên nhân:
[Cách cl100k_base cắt câu tiếng Việt]
[b'V', b'ui', b' l\xc3\xb2ng', b' x', b'\xc3\xa1c', b' nh', b'\xe1\xba\xadn', b' than', b'h', b' to', b'\xc3\xa1n', b' cho', b' \xc4\x91', b'\xc6\xa1n']
[Cách Qwen2 cắt câu tiếng Việt]
['V', 'ui', 'Ġlòng', 'Ġxác', 'ĠnháºŃn', 'Ġthan', 'h', 'Ġtoán', 'Ġcho', 'ĠÄijÆ¡n', 'ĠhÃłng', 'Ġ', '2', '0', '4', '8', '1', 'ĠtrÆ°á»Ľc', 'Ġthứ', 'ĠS', 'á', 'u', '.']Dấu tiếng Việt bị tách thành mảnh byte riêng (nh + ận, x + ác); tokenizer Qwen2 hiển thị dạng byte-level đã ánh xạ (Ġ là dấu cách, ò là hai byte của chữ ò). Bài học: đếm token bằng đúng tokenizer của LLM bạn gọi, đừng ước lượng theo số từ hay số ký tự.
Ba chi tiết khác trong output. Số được cắt khác nhau: cl100k_base gộp ba chữ số một token còn Qwen2 tách từng chữ số, nên chuỗi 16 chữ số tốn 6 và 16 token, ảnh hưởng tới khả năng làm tính. order, Order, ORDER và order có dấu cách đứng trước là bốn token khác nhau, nên khoảng trắng thừa làm đổi đầu vào. Từ vựng có 50.257 (gpt2), 100.277 (cl100k_base), 200.019 (o200k_base) và 151.646 (Qwen2) token.
Token cũng giải thích lỗi "ngớ ngẩn" quen thuộc của LLM: từ strawberry được thấy dưới dạng ba mảnh (str + aw + berry với cl100k_base), không phải chuỗi chữ cái, nên đếm chữ cái là việc phải học gián tiếp.
Từ logits đến xác suất: temperature của LLM
Sau khi Transformer xử lý xong prompt, đầu ra tại vị trí cuối cùng là vector logits, mỗi phần tử ứng với một token trong từ vựng. Chuyển sang xác suất bằng softmax. Temperature T chia logits trước khi softmax: T nhỏ làm phân phối nhọn hơn, T lớn làm phân phối phẳng hơn, và T tiến về 0 tương đương luôn chọn token có xác suất cao nhất. Script sampling_demo.py lấy logits trực tiếp từ Qwen2-0.5B với prompt "Thủ đô của Việt Nam là", rồi tự cài softmax bằng NumPy.
[T=1.0] top-6: ' Hà':0.248, ' Thủ':0.096, ' thủ':0.065, ' sa':0.037, ':\n':0.032, ' Ho':0.028
entropy = 4.27 nat | tổng xác suất 6 token đầu = 0.506
[T=0.2] top-4: ' Hà':0.990, ' Thủ':0.009, ' thủ':0.001, ' sa':0.000 | entropy = 0.06
[T=0.7] top-4: ' Hà':0.526, ' Thủ':0.135, ' thủ':0.079, ' sa':0.034 | entropy = 2.12
[T=1.5] top-4: ' Hà':0.049, ' Thủ':0.026, ' thủ':0.020, ' sa':0.014 | entropy = 8.13Ở T = 1,0 mô hình dồn 24,8% cho Hà, sáu token đầu mới chiếm 50,6%: phần còn lại nằm trong "đuôi" dài hàng chục nghìn token. Hạ T xuống 0,2 đẩy Hà lên 99,0% (entropy 0,06); nâng T lên 1,5 làm nó tụt xuống 4,9% (entropy 8,13). Với LLM này, kích thước logits (151.936) lớn hơn từ vựng thật (151.646) vì bảng embedding được làm tròn lên.
Sampling trong LLM: greedy, top-k và top-p
LLM có ba cách chọn token phổ biến. Greedy luôn lấy token xác suất cao nhất; theo tài liệu Generation strategies của Hugging Face, greedy hợp với đầu ra ngắn nhưng bắt đầu lặp lại khi sinh văn bản dài. Sampling (multinomial) lấy ngẫu nhiên theo phân phối, nên mọi token có xác suất khác 0 đều có cơ hội. Beam search giữ nhiều chuỗi ứng viên và chọn chuỗi có xác suất tổng cao nhất, hợp với tác vụ bám sát đầu vào như nhận dạng giọng nói hay mô tả ảnh.
Sampling thuần tuý bốc trúng đuôi phân phối đầy token vô nghĩa thì cả câu đi lệch. Bài báo The Curious Case of Neural Text Degeneration (Holtzman và cộng sự, ICLR 2020) cho thấy giải mã tối đa hoá xác suất cho văn bản nhàm chán, lặp lại, và đề xuất nucleus sampling (top-p): chỉ lấy mẫu từ tập token nhỏ nhất có tổng xác suất đạt p. Top-k đơn giản hơn: giữ k token đứng đầu. Cài cả hai:
def top_k_filter(p, k):
q = p.copy(); q[np.argsort(-p)[k:]] = 0; return q / q.sum()
def top_p_filter(p, top_p):
order = np.argsort(-p); cum = np.cumsum(p[order])
cut = np.searchsorted(cum, top_p) + 1 # số token nhỏ nhất có tổng xác suất >= top_p
q = np.zeros_like(p); q[order[:cut]] = p[order[:cut]]; return q / q.sum(), cut
[Số token còn lại sau khi lọc]
top_k=40 -> 40 token
top_p=0.5 -> 6 token
top_p=0.9 -> 205 token
top_p=0.95 -> 621 tokenTop-k cố định 40 token bất kể phân phối nhọn hay phẳng, còn top-p tự co giãn: p = 0,5 giữ 6 token, p = 0,9 giữ 205, p = 0,95 giữ 621. Bốc mẫu 2.000 lần từ từng phân phối rồi đếm số token khác nhau xuất hiện:
[2.000 lần lấy mẫu token kế tiếp: số token khác nhau xuất hiện, token hay gặp nhất]
T=1.0, không lọc : (338, ' Hà')
T=1.0, top_k=40 : (40, ' Hà')
T=1.0, top_p=0.9 : (158, ' Hà')
T=0.2 : (3, ' Hà')
T=1.5, không lọc : (1184, ' Hà')Không lọc ở T = 1,0 cho 338 token khác nhau; top-k = 40 còn 40; top-p = 0,9 còn 158; T = 0,2 chỉ còn 3; T = 1,5 không lọc cho 1.184. Vì vậy khuyến nghị thực tế là chỉnh một trong hai núm (temperature hoặc top-p), không vặn cả hai.
Cuối cùng là cho LLM sinh văn bản thật bằng model.generate của Transformers với 14 token mới:
[Sinh 14 token]
greedy : Hà Nội. Hà Nội là thủ đô của Việt Nam, là một
greedy (lần 2): Hà Nội. Hà Nội là thủ đô của Việt Nam, là một
T=1.0 seed=1: :\nTrại Quốc gia \n* Thành phố Hồ Chí Minh\n
T=1.0 seed=2: thủ đô capital Thủ đô tại Trung Quốc mở rộng, được gọi
T=1.0 seed=3: : Paris | Tổng hợp\nNhằm phân tích thị trường Nhà nước
T=1.5 seed=1: :\nTrại Pierre奇督囍voir fi 필ופ پ.perform
T=1.5 seed=2: thủ phủ quê tắtỏi tại kênh L序列 mở.\n yüz compens foreverGreedy cho một đáp án, chạy hai lần giống hệt. Sampling ở T = 1,0 cho ba kết quả khác nhau, có kết quả lạc đề (Paris, Trung Quốc); ở T = 1,5 văn bản vỡ thành mảnh nhiều ngôn ngữ. Sampling ở LLM luôn đánh đổi tính nhất quán lấy đa dạng.
Greedy hay temperature = 0 lặp lại được trên máy bạn, nhưng với API thương mại không được đảm bảo tuyệt đối vì tính toán song song trên GPU, batching và cập nhật mô hình. Đừng kiểm thử bằng so sánh chuỗi chính xác; bài Prompt engineering có hệ thống sẽ nói về kiểm thử theo tiêu chí.
Context window: LLM nhớ được bao nhiêu
Context window (cửa sổ ngữ cảnh) là tổng số token LLM xử lý trong một lần gọi, gồm cả prompt lẫn phần sinh ra. Một LLM không có trí nhớ giữa các lần gọi: "cuộc hội thoại" là client gửi lại toàn bộ lịch sử mỗi lượt. Theo thông số các nhà cung cấp công bố đến tháng 8/2024, GPT-4o và Llama 3.1 có cửa sổ 128K token, Claude 3.5 Sonnet 200K, Gemini 1.5 Pro từ 1 đến 2 triệu (bản 2 triệu mở cho nhà phát triển từ tháng 6/2024); Qwen2-1.5B trong bài có 32.768 (đọc từ metadata qua Ollama).
Cửa sổ dài không miễn phí: LLM lưu key và value của mọi token trước đó ở mọi lớp, gọi là KV cache. Mỗi token tốn 2 (key và value) × số lớp × số KV head × head_dim × số byte. Qwen2-1.5B có 28 lớp, 12 query head nhưng chỉ 2 KV head nhờ grouped-query attention (GQA), head_dim = 128:
[KV cache] mỗi token = 2 x 28 lớp x 2 KV head x 128 x 2 byte = 28,672 byte
4096 token -> 112.0 MiB
32768 token -> 896.0 MiB32.768 token chiếm 896 MiB; nếu giữ đủ 12 KV head thì là 5.376 MiB. Đó là lý do nhiều LLM đời mới dùng GQA. Thời gian xử lý prompt cũng tăng theo độ dài; tổng thời gian trên Ollama (xử lý prompt và sinh tối đa 40 token) là:
[Thời gian tổng theo độ dài prompt]
312 token | 1.2s
1187 token | 1.8s
5874 token | 8.2sDài không có nghĩa là dùng tốt: lost in the middle
Nhét vừa cửa sổ chưa chắc LLM dùng được. Bài báo Lost in the Middle (Liu và cộng sự, TACL 2023) cho thấy hiệu năng thường cao nhất khi thông tin nằm ở đầu hoặc cuối ngữ cảnh và giảm rõ khi nằm ở giữa. Thí nghiệm: danh sách 200 dòng "Đơn hàng DH-7xxx có mã xác nhận ..., giao tại kho ..." (5.891 token), hỏi mã của đơn ở các vùng khác nhau (mỗi vùng 8 câu, temperature = 0).
[Tra cứu trong danh sách 200 dòng, num_ctx=8192, mỗi vị trí hỏi 8 dòng khác nhau]
vùng 0% | prompt 5891 token | đúng 7/8
vùng 25% | prompt 5891 token | đúng 2/8
vùng 50% | prompt 5891 token | đúng 1/8
vùng 75% | prompt 5891 token | đúng 1/8
vùng 95% | prompt 5891 token | đúng 1/8
Vùng đầu đúng 7/8, vùng 25% còn 2/8, các vùng sau chỉ 1/8. Vùng cuối không hồi phục như đường cong chữ U trong bài báo, vì mô hình nhỏ yếu hơn ở tra cứu nói chung; mẫu 8 câu mỗi vùng cũng rất nhỏ. Nguyên tắc rút ra: đặt thông tin quan trọng ở đầu hoặc cuối prompt, và với tài liệu dài, truy xuất đúng đoạn cần thiết thường tốt hơn dán tất cả.
Bẫy num_ctx: cắt prompt mà không báo lỗi
Tài liệu FAQ của Ollama mô tả cửa sổ mặc định chỉ vài nghìn token, đổi bằng tham số num_ctx; giá trị mặc định thay đổi giữa các phiên bản nên hãy đặt tường minh. Các output dưới đây chạy trên Ollama 0.40.15 (lấy từ API /api/version); hành vi cắt prompt ở đây là quan sát trên phiên bản đó, bản Ollama cũ hơn có thể khác. Khi prompt dài hơn num_ctx, Ollama không báo lỗi mà bỏ bớt phần đầu. Hỏi 8 đơn nằm ở đầu danh sách với ba giá trị num_ctx:
[num_ctx nhỏ hơn prompt: hỏi 8 dòng ĐẦU danh sách]
num_ctx= 8192 | prompt_eval_count= 5891 | đúng 7/8
num_ctx= 2048 | prompt_eval_count= 1026 | đúng 0/8
num_ctx= 1024 | prompt_eval_count= 514 | đúng 0/8Với 8192, LLM thấy đủ 5.891 token và đúng 7/8. Với 2048 và 1024, prompt_eval_count chỉ còn 1.026 và 514 token, tức hơn 80% prompt bị loại trước khi mô hình nhìn thấy, và kết quả đúng 0/8 mà không có lỗi nào được báo. Cách phòng thủ cho ứng dụng LLM: so prompt_eval_count (hoặc usage.prompt_tokens ở API khác) với số token đã tính trước và cảnh báo khi lệch.
Giới hạn của LLM mà bạn phải thiết kế xung quanh
Từ cơ chế ở trên suy ra danh sách giới hạn khá ổn định của mọi LLM, bất kể mô hình lớn hay nhỏ.
- Hallucination (bịa thông tin): LLM tối ưu để sinh văn bản có xác suất cao, không phải văn bản đúng. Cần nguồn đối chiếu (RAG), kiểm tra bằng code, và cho phép trả lời "không biết".
- Kiến thức dừng ở ngày cắt dữ liệu huấn luyện: LLM không biết giá hôm nay hay chính sách mới của công ty bạn nếu không đưa vào prompt.
- Không tất định: sampling cho kết quả khác nhau, và kết quả có thể đổi giữa các phiên bản mô hình.
- Tính toán và đếm không đáng tin: do cách cắt token, các phép tính nhiều chữ số hay đếm ký tự dễ sai. Dùng công cụ (code) cho việc này.
- Ngữ cảnh hữu hạn và dùng không đều: vượt cửa sổ thì bị cắt hoặc bị từ chối; còn trong cửa sổ, thông tin ở giữa dễ bị bỏ qua.
- Prompt injection: văn bản từ nguồn ngoài (email, trang web) nằm cùng cửa sổ với chỉ dẫn của bạn, và LLM không phân biệt chắc chắn đâu là lệnh, đâu là dữ liệu. Đừng giao quyền hành động nhạy cảm cho LLM mà thiếu lớp kiểm soát.
Khi nào nên và không nên dùng LLM
LLM mạnh ở việc ngôn ngữ mở như tóm tắt, phân loại ý định, trích xuất thông tin từ văn bản tự do và trả lời dựa trên tài liệu cung cấp. LLM yếu và đắt ở việc đã có quy tắc rõ ràng: kiểm tra định dạng, tính tiền, so khớp mã đơn hàng. Phân loại hàng triệu bình luận với nhãn cố định thường rẻ hơn nếu dùng mô hình nhỏ như PhoBERT; hãy thử đường rẻ nhất trước và đo trên dữ liệu thật.
Lỗi thường gặp
- Ước lượng token bằng số từ: với tiếng Việt sai lệch lớn, như bảng đếm ở trên.
- Đặt temperature cao cho tác vụ trích xuất, hoặc vặn cả temperature lẫn top-p cùng lúc.
- Dán cả tài liệu dài và tin rằng LLM đọc hết: thông tin ở giữa dễ bị bỏ sót.
- Không kiểm tra số token thực tế gửi đi: prompt bị cắt âm thầm khi
num_ctxnhỏ.
Checklist
- Với mọi tính năng LLM, đếm token bằng tokenizer đúng của mô hình và theo dõi cả token vào lẫn token ra.
- Greedy hoặc T thấp cho trích xuất và phân loại; T vừa phải cùng top-p cho sinh nội dung.
- Đặt tường minh kích thước ngữ cảnh và cảnh báo khi số token thực tế lệch số token dự kiến.
- Giao việc tính toán, đếm, kiểm tra định dạng cho code; dùng LLM cho phần hiểu ngôn ngữ.
Mã nguồn trong code/s7-31 gồm ba script tokens_demo.py, sampling_demo.py, context_demo.py, các file *_output.txt (output đã dán ở trên) và requirements.txt. Hai script đầu tải Qwen/Qwen2-0.5B từ Hugging Face Hub, script thứ ba cần Ollama với qwen2:1.5b (chọn dòng Qwen2 vì đúng thời điểm bài, tháng 8/2024). Dữ liệu đơn hàng là giả lập.
Bài tiếp theo
Prompt engineering có hệ thống: vai trò, ví dụ mẫu và kiểm thử prompt. Khi đã hiểu LLM chỉ đoán token kế tiếp từ ngữ cảnh, bài sau chuyển sang cách viết ngữ cảnh đó một cách có kỷ luật: phân vai, đưa ví dụ mẫu và đo xem prompt mới có tốt hơn prompt cũ hay không.



