Ở bài trước, chúng ta đã đi qua bên trong một Transformer: token, attention, residual, FFN, RoPE, ba họ kiến trúc và vòng lặp sinh văn bản.
Bài này đi theo hướng ngược lại: từ ngoài vào. Bạn mở editor, gõ ba dòng code, và có ngay một model chạy được:
from transformers import pipeline
classifier = pipeline("sentiment-analysis")
classifier("I really like this course!")
Ba dòng đó rất tiện. Nhưng chúng cũng che đi gần như toàn bộ những thứ quyết định việc hệ thống của bạn chạy nhanh hay chậm, đúng hay sai, tốn 4 GB hay 32 GB VRAM.
Model không đọc chuỗi văn bản. Nó nhận tensor số. Tokenizer không chỉ “tách từ” - nó quyết định vocabulary, token ID, special token, padding và attention mask. Output của Transformer thường chưa phải đáp án: đó là hidden representation hoặc logits, và còn cần một task head hoặc một bước decoding phía sau.
Mental model quan trọng nhất của cả bài:
Raw text
↓
Tokenizer
↓
Token IDs + attention mask + metadata
↓
Embedding
↓
Transformer layers
↓
Hidden representations
↓
Task head / LM head
↓
Logits
↓
Post-processing hoặc decoding
↓
Prediction / generated text
Bài viết đi từ đầu chuỗi đó - cách một câu tiếng Anh biến thành tensor - đến cuối chuỗi: cách phục vụ model cho nhiều người dùng cùng lúc bằng TGI, vLLM hoặc llama.cpp.
Những câu hỏi mà bài sẽ trả lời:
- Vì sao tokenizer và model bắt buộc phải cùng một checkpoint, và chuyện gì xảy ra nếu không?
AutoModelkhácAutoModelForSequenceClassificationở chỗ nào, và vì sao thư viện có tới cả chục classAutoModelFor...?- Logits là gì, và vì sao Softmax không phải một bước trang trí?
- Padding và attention mask giải quyết hai vấn đề khác nhau ra sao?
- Vì sao một model 7B “chỉ” có 7 tỷ tham số nhưng lại cần 28 GB VRAM ở FP32?
- KV cache tiết kiệm được gì, và đổi lại nó ăn của bạn cái gì?
- PagedAttention, continuous batching và FlashAttention thực sự tối ưu điều gì?
- Khi nào nên chọn vLLM, khi nào nên chọn TGI, và khi nào llama.cpp mới là đáp án đúng?
01Một model không chỉ là một file
Khi nói “tôi tải model về”, chúng ta thường nghĩ tới một file duy nhất. Thực tế, để dùng được một pretrained Transformer, cần tối thiểu ba thứ đi cùng nhau:
| Thành phần | Nội dung | Nếu thiếu |
|---|---|---|
| Kiến trúc | số layer, hidden size, số attention head, vocab size, kiểu positional encoding | không dựng được network để nạp trọng số |
| Trọng số | hàng triệu đến hàng tỷ giá trị số đã học | có khung nhưng không có kiến thức |
| Quy tắc tiền xử lý | tokenizer, vocabulary, special token, template | model nhận input sai định dạng |
Hai thứ đầu thường được nhắc tới. Thứ ba là thứ hay bị bỏ qua nhất, và cũng là nguồn của một lớp bug rất khó chịu.
1.1 Vì sao tokenizer và model phải khớp nhau
Token ID không phải một con số có ý nghĩa toán học. Nó là chỉ số dòng trong bảng embedding.
Giả sử tokenizer A gán:
"bank" → 2924
còn tokenizer B, với vocabulary khác, gán:
2924 → "##ology"
Nếu bạn tokenize bằng A rồi đưa ID vào model B, model sẽ lấy đúng dòng 2924 trong embedding table của nó - nhưng đó là vector của ##ology, không phải của bank. Model không báo lỗi. Nó chỉ trả về kết quả sai.
Đây là một trong những loại bug tệ nhất: thất bại im lặng. Shape đúng, dtype đúng, code chạy trơn tru, chỉ có ngữ nghĩa là hỏng từ bước đầu tiên.
Vì vậy pattern chuẩn là dùng cùng một checkpoint cho cả hai:
from transformers import AutoTokenizer, AutoModel
checkpoint = "bert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModel.from_pretrained(checkpoint)
1.2 Auto là factory, không phải architecture
AutoModel không phải một kiến trúc. Nó là một factory: đọc file config của checkpoint, tìm trường model_type, rồi khởi tạo đúng class tương ứng.
config.json
↓
model_type: "bert" → BertModel
model_type: "llama" → LlamaModel
model_type: "gpt2" → GPT2Model
Nhờ vậy, hàng trăm kiến trúc khác nhau dùng chung một API:
AutoTokenizer
AutoModel
AutoModelForSequenceClassification
AutoModelForTokenClassification
AutoModelForQuestionAnswering
AutoModelForMaskedLM
AutoModelForCausalLM
Đây không chỉ là tiện lợi. Nó có nghĩa là code của bạn có thể đổi từ BERT sang RoBERTa sang DeBERTa mà gần như không phải sửa gì - miễn là task head không đổi.
02Bên trong pipeline()
pipeline() là ba giai đoạn được gói thành một lời gọi:
"This movie is amazing!"
↓
Tokenizer ← preprocessing
↓
input_ids + attention_mask
↓
Model ← inference
↓
Logits
↓
Softmax ← post-processing
↓
POSITIVE: 0.982
2.1 Vì sao phải chuyển văn bản thành số
Bên trong một Transformer chỉ có bốn nhóm phép toán: nhân ma trận, cộng, chuẩn hoá và hàm kích hoạt - attention chính là một tổ hợp của chúng. Không có phép nào nhận vào chuỗi ký tự.
Nên bước đầu tiên luôn là ánh xạ văn bản sang số:
"I love transformers"
↓
["I", "love", "transform", "##ers"]
↓
[1045, 2293, 10938, 2545]
Nhắc lại điều quan trọng: các ID này không mang nghĩa số học. Token 2000 không “lớn gấp đôi” token 1000. Chúng chỉ là index.
2.2 Embedding: từ index thành vector
Nếu vocabulary có 30.522 token và hidden size là 768, embedding layer là một ma trận:
30.522 × 768
Mỗi token ID chọn ra đúng một dòng - một vector 768 chiều.
Vì thế shape thay đổi như sau:
input_ids: [batch_size, sequence_length]
↓
embeddings: [batch_size, sequence_length, hidden_size]
Cụ thể, batch 4 câu, mỗi câu 128 token, hidden size 768:
input_ids: [4, 128]
embeddings: [4, 128, 768]
Đây là chỗ đáng dừng lại một chút. Bảng embedding của một model có vocabulary 32.000 và hidden size 4.096 đã là:
32.000 × 4.096 ≈ 131 triệu tham số
chỉ riêng cho việc tra cứu. Đó là lý do vocabulary size là một quyết định kiến trúc, không phải chi tiết phụ.
2.3 Static embedding và contextual representation
Sau embedding, các vector đi qua nhiều Transformer layer. Đây là nơi self-attention làm representation của mỗi token phụ thuộc vào ngữ cảnh.
I deposited money at the bank.
The fisherman sat on the river bank.
Cả hai câu bắt đầu từ cùng một vector embedding cho bank. Nhưng sau vài layer attention, hai hidden state sẽ khác hẳn nhau: một cái nằm gần vùng “tài chính”, cái kia gần vùng “địa hình”.
Đó chính là khác biệt giữa static embedding - Word2Vec, GloVe: mỗi từ một vector cố định - và contextual representation, nơi vector phụ thuộc vào cả câu.
2.4 Hidden states chưa phải câu trả lời
Output của base Transformer thường có shape:
[batch_size, sequence_length, hidden_size]
và được gọi là hidden states. Một AutoModel thuần chỉ dừng ở đây - nó tạo biểu diễn, không tạo dự đoán.
Muốn giải một bài toán cụ thể, cần gắn thêm một head:
Transformer body
↓
Hidden representation
↓
Classification head
↓
Class logits
2.5 Logits và Softmax
AutoModelForSequenceClassification đặt một classification head lên trên body. Với batch 2 câu và bài toán 2 nhãn, logits có shape:
[2, 2]
Giá trị bên trong có thể là:
[-1.7, 2.3]
Đây chưa phải xác suất. Chúng có thể âm, có thể lớn hơn 1, và tổng không bằng 1. Để đọc chúng như một phân phối, ta dùng Softmax:
import torch
probabilities = torch.softmax(outputs.logits, dim=-1)
Công thức:
exp(z_i)
p_i = -------------------------
Σ_j exp(z_j)
Kết quả:
[-1.7, 2.3] → [0.018, 0.982]
Nếu index 0 là NEGATIVE và index 1 là POSITIVE, model kết luận POSITIVE với xác suất 0.982.
Một chi tiết dễ bỏ qua: khi training với
CrossEntropyLosstrong PyTorch, bạn đưa vào logits, không phải xác suất - vì hàm loss đã tự làm log-softmax bên trong. Softmax thủ công chỉ dành cho lúc diễn giải kết quả.
Nhìn theo cách này, pipeline() không phải hộp đen. Nó chỉ là ba bước quen thuộc được đóng gói lại.
03Model: config, weights và head
Một pretrained model là tổng của configuration và weights.
3.1 Configuration là bản thiết kế
Configuration chứa những thứ quyết định hình dạng của network:
model_type
hidden_size
num_hidden_layers
num_attention_heads
vocab_size
intermediate_size
max_position_embeddings
num_labels
Một model BERT-base có 12 layer, hidden size 768, 12 attention head. Chỉ từ config, bạn tái tạo được đúng cấu trúc - nhưng là một network với trọng số ngẫu nhiên, chưa biết gì cả.
3.2 Weights là kiến thức
Weights là phần được tối ưu suốt quá trình training: embedding table, các ma trận chiếu Q/K/V, ma trận output projection, feed-forward weights, tham số normalization, và task head.
Phân biệt này rất quan trọng:
Architecture ≠ learned knowledge
Hai model cùng kiến trúc nhưng pretrain trên corpus khác nhau sẽ hành xử rất khác. Ngược lại, một model có thể giữ nguyên body và chỉ thay head để chuyển sang task mới - đó chính là cơ chế của fine-tuning.
Lưu và nạp lại:
model.save_pretrained("./my_model")
Thư mục kết quả chứa config và trọng số, thường ở định dạng safetensors - an toàn hơn pickle vì nó không cho phép thực thi code tuỳ ý khi load.
from transformers import AutoModel
model = AutoModel.from_pretrained("./my_model")
3.3 Head quyết định output
Đây là bảng ánh xạ đáng thuộc:
| Class | Bài toán | Shape output |
|---|---|---|
AutoModel |
chỉ lấy biểu diễn | [batch, seq_len, hidden] |
AutoModelForSequenceClassification |
phân loại cả câu | [batch, num_labels] |
AutoModelForTokenClassification |
NER, POS tagging | [batch, seq_len, num_labels] |
AutoModelForQuestionAnswering |
trích xuất câu trả lời | 2 tensor [batch, seq_len] cho start và end |
AutoModelForMaskedLM |
đoán token bị che | [batch, seq_len, vocab_size] |
AutoModelForCausalLM |
sinh token tiếp theo | [batch, seq_len, vocab_size] |
Với causal language modeling, mục tiêu là:
P(x_t | x_1, x_2, ..., x_(t-1))
Đây là objective của gần như mọi generative LLM hiện đại.
Chú ý shape của hai dòng cuối: có vocab_size trong đó. Với vocabulary 32.000 và context 4.096 token, riêng tensor logits ở FP16 đã là:
4.096 × 32.000 × 2 bytes ≈ 262 MB
cho một sequence. Đó là lý do các inference engine chỉ tính logits cho vị trí cuối cùng trong lúc decode, thay vì cho toàn bộ chuỗi.
3.4 Không phải model nào cũng nhận cùng input
Ngoài input_ids, một số kiến trúc BERT-like cần token_type_ids để phân biệt hai đoạn trong cặp câu:
[CLS] Sentence A [SEP] Sentence B [SEP]
0 0 0 0 0 1 1 1 1
Các model decoder-only thường không có trường này. Bạn không cần nhớ model nào cần gì - tokenizer đi kèm checkpoint đã biết, và nó sẽ trả về đúng bộ input mà model mong đợi. Đây là một lý do nữa để không tự dựng input bằng tay.
04Tokenizer: cầu nối giữa chữ và số
Bài toán của tokenizer là chọn đơn vị biểu diễn: không quá lớn để vocabulary phình ra, không quá nhỏ để sequence dài lê thê.
4.1 Word-based: đơn giản nhưng vỡ trận
"Transformers are powerful"
↓
["Transformers", "are", "powerful"]
Dễ hiểu. Nhưng vocabulary bùng nổ ngay lập tức: run, runs, running, runner, runners thành năm entry riêng biệt, dù chúng chia sẻ gần hết ngữ nghĩa. Thêm tên riêng, từ hiếm, lỗi chính tả, hashtag, và vocabulary có thể vượt hàng triệu.
Tệ hơn: từ nằm ngoài vocabulary phải map về [UNK]. Thông tin biến mất hoàn toàn - model thấy [UNK] chứ không thấy antidisestablishmentarianism.
4.2 Character-level: cực kia của quang phổ
"hello" → ["h", "e", "l", "l", "o"]
Vocabulary bé xíu, gần như không có [UNK]. Nhưng sequence dài gấp bốn đến năm lần.
Và đây không chỉ là chuyện tốn RAM. Self-attention truyền thống có độ phức tạp gần O(n²) theo sequence length:
20 token → 20 × 20 = 400 ô attention
100 token → 100 × 100 = 10.000 ô
Sequence dài gấp 5 làm attention matrix lớn gấp 25.
4.3 Subword: điểm cân bằng
Ý tưởng: từ phổ biến giữ nguyên, từ hiếm tách thành mảnh có nghĩa.
unbelievable
→ un + believ + able
Nhờ vậy believable, unbelievable, believer chia sẻ chung phần lõi believ, và model học được liên hệ giữa chúng.
Ba họ thuật toán chính:
| Thuật toán | Cách xây vocabulary | Dấu hiệu nhận biết |
|---|---|---|
| BPE | bắt đầu từ ký tự, lặp lại merge cặp xuất hiện nhiều nhất | GPT-2, RoBERTa, Llama |
| WordPiece | tương tự BPE nhưng chọn merge theo tiêu chí likelihood | BERT - token nối có tiền tố ## |
| Unigram | bắt đầu với tập candidate lớn, dần loại token ít hữu ích | T5, ALBERT, thường đi cùng SentencePiece |
WordPiece cho output dạng:
["Transform", "##ers"]
trong đó ## báo hiệu “token này nối tiếp từ trước, không có khoảng trắng”.
4.4 Ba khái niệm rất dễ nhầm
String "Transformers are great"
↓
Token ["Transform", "##ers", "are", "great"]
↓
Integer ID [13809, 2545, 2024, 2307]
↓
Embedding 4 vector số thực, mỗi vector d chiều
Token là mảnh văn bản. ID là chỉ số. Embedding là vector. Ba thứ khác nhau, ở ba tầng khác nhau.
4.5 Encoding và decoding
Encoding là chiều đi: text sang token IDs. Decoding là chiều về: token IDs sang text.
Với model sinh văn bản, decoding không phải chi tiết phụ. Model chỉ sinh ra một dãy số; chính tokenizer là thứ ghép chúng lại thành chữ, xử lý khoảng trắng, nối ##ers vào Transform, và bỏ special token khi cần.
tokenizer.decode(output_ids, skip_special_tokens=True)
4.6 Special token
Tuỳ kiến trúc, bạn sẽ gặp:
| Token | Vai trò |
|---|---|
[CLS] |
đứng đầu sequence trong BERT-like, thường dùng làm representation cho cả câu |
[SEP] |
ranh giới giữa các segment |
[PAD] |
lấp cho các sequence trong batch bằng chiều dài |
[MASK] |
vị trí bị che trong masked language modeling |
BOS / EOS |
đầu và cuối sequence trong model sinh văn bản |
Nguyên tắc: đừng tự đoán model cần token nào. Tokenizer đi kèm checkpoint đã biết chính xác format mà model được train, và mặc định sẽ thêm đúng.
Với chat model còn có thêm một lớp nữa - chat template - quyết định vai trò system, user, assistant được bọc bằng token gì. Đưa prompt thô vào một chat model mà bỏ qua template là một cách rất hiệu quả để làm chất lượng output tụt xuống mà không hiểu vì sao.
05Batch, padding và attention mask
Xử lý từng câu một là cách lãng phí GPU nhanh nhất. GPU mạnh ở chỗ làm cùng một phép toán trên rất nhiều dữ liệu song song.
Sequence A ┐
Sequence B ├──→ Batch → GPU
Sequence C ┘
Vấn đề: tensor phải là hình chữ nhật, còn câu thì dài ngắn khác nhau.
5.1 Padding sửa hình dạng
Giả sử hai sequence dài 5 và 3 token. Thêm [PAD] vào cái ngắn hơn:
A: [12, 45, 89, 32, 18]
B: [55, 29, 19, 0, 0]
Với pad_token_id = 0, tensor giờ có shape [2, 5]. Hợp lệ.
5.2 Nhưng padding phá ngữ nghĩa
Nếu model coi [PAD] như một token bình thường, self-attention vẫn sẽ tính điểm tương quan với các vị trí padding và trộn chúng vào representation. Kết quả: cùng một câu, đặt trong hai batch khác nhau, cho ra hai kết quả khác nhau.
attention_mask giải quyết chuyện này:
input_ids: [55, 29, 19, 0, 0]
attention_mask: [ 1, 1, 1, 0, 0]
Ở đây 1 nghĩa là token thật, 0 nghĩa là vị trí cần bỏ qua.
Cơ chế bên trong đơn giản đến bất ngờ: trước khi Softmax, attention score tại các vị trí bị mask được cộng thêm một số âm rất lớn, thường là -inf hoặc -1e9:
score = [2.1, 0.8, 1.5, -1e9, -1e9]
↓ softmax
attention = [0.60, 0.16, 0.24, 0.00, 0.00]
exp(-1e9) xấp xỉ 0, nên trọng số attention vào padding gần như biến mất.
Có thể tóm gọn:
Padding làm tensor có đúng shape. Attention mask làm phép tính có đúng nghĩa.
Đây cũng là lý do bạn gần như không bao giờ nên tự tay tạo input_ids mà quên attention_mask.
5.3 Batch dimension
Một sequence:
[101, 2023, 2003, 102]
có shape [4]. Nhưng model mong đợi [batch_size, sequence_length]. Nên ngay cả khi chỉ có một câu, nó phải được bọc thêm một chiều:
[[101, 2023, 2003, 102]] shape [1, 4]
Quên bước này là nguồn của rất nhiều lỗi shape mismatch lúc mới bắt đầu.
5.4 Truncation và sequence quá dài
Mỗi kiến trúc có giới hạn context riêng. Vượt giới hạn thì cách đơn giản nhất là cắt:
tokenizer(
text,
truncation=True,
max_length=512,
)
Nhưng cắt cứng phần đuôi là một quyết định có giá. Nếu tài liệu của bạn để kết luận ở cuối, bạn vừa vứt đi đúng phần quan trọng nhất.
Với tài liệu dài, các chiến lược thực tế hơn:
- Chunking: chia thành nhiều đoạn, xử lý riêng, gộp kết quả.
- Sliding window: các đoạn chồng lấn nhau để không mất ngữ cảnh ở ranh giới.
- Retrieval: chỉ nạp phần liên quan tới câu hỏi - đây chính là ý tưởng của RAG.
- Long-context model: chọn kiến trúc có context window lớn hơn.
- Hierarchical processing: tóm tắt từng đoạn rồi tóm tắt các bản tóm tắt.
5.5 Padding trái hay padding phải
right padding:
[token, token, token, PAD, PAD]
left padding:
[PAD, PAD, token, token, token]
Với model encoder dùng để phân loại, right padding là mặc định hợp lý.
Với decoder-only model khi sinh văn bản theo batch, left padding thường là lựa chọn đúng. Lý do: model sinh token mới dựa trên vị trí cuối cùng của sequence. Nếu padding nằm bên phải, “vị trí cuối cùng” của các câu ngắn lại rơi vào ô [PAD], và generation bắt đầu từ sai chỗ. Đẩy padding sang trái làm mọi sequence trong batch kết thúc thẳng hàng ở bên phải.
tokenizer.padding_side = "left"
06Ghép lại thành một script hoàn chỉnh
Sau khi hiểu từng mảnh, workflow thực tế lại rất ngắn:
import torch
from transformers import (
AutoTokenizer,
AutoModelForSequenceClassification,
)
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
model.eval()
texts = [
"I really enjoyed this movie.",
"This was extremely disappointing.",
]
inputs = tokenizer(
texts,
padding=True,
truncation=True,
return_tensors="pt",
)
with torch.no_grad():
outputs = model(**inputs)
probabilities = torch.softmax(outputs.logits, dim=-1)
print(probabilities)
print(model.config.id2label)
6.1 Một lời gọi, rất nhiều việc
Lời gọi tokenizer ở trên thực hiện toàn bộ chuỗi sau:
- Normalize text: lowercase, bỏ dấu, chuẩn hoá Unicode - tuỳ tokenizer.
- Pre-tokenization: tách theo khoảng trắng và dấu câu.
- Subword tokenization.
- Ánh xạ token sang ID.
- Thêm special token theo đúng format của model.
- Truncation nếu vượt
max_length. - Padding cho các sequence bằng nhau.
- Sinh
attention_mask, vàtoken_type_idsnếu model cần. - Chuyển tất cả thành PyTorch tensor.
Đây là lý do nên dùng API cấp cao thay vì tự làm từng bước - trừ khi bạn đang debug chính tokenizer.
6.2 Cú pháp model(**inputs)
inputs là một dictionary:
{
"input_ids": ...,
"attention_mask": ...
}
nên model(**inputs) tương đương với:
model(
input_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
)
Cú pháp unpack này rất phổ biến trong hệ sinh thái Transformers, và nó có một lợi ích thực dụng: nếu tokenizer trả thêm token_type_ids, code của bạn không phải sửa gì cả.
6.3 no_grad và eval là hai thứ khác nhau
Hai thứ này hay bị gộp làm một:
model.eval()chuyển module sang chế độ inference: tắt dropout, dùng running statistics cho batch norm. Ảnh hưởng đến kết quả.torch.no_grad()bảo PyTorch đừng dựng computation graph. Ảnh hưởng đến bộ nhớ và tốc độ, không ảnh hưởng kết quả.
Cả hai đều nên có khi inference. Với workload chỉ inference, torch.inference_mode() là phiên bản chặt hơn của no_grad() và thường nhanh hơn một chút:
with torch.inference_mode():
outputs = model(**inputs)
6.4 Đừng quên id2label
Model trả về index, không trả về tên nhãn. Ánh xạ nằm trong config:
model.config.id2label
# {0: 'NEGATIVE', 1: 'POSITIVE'}
Hardcode “0 là NEGATIVE” là một giả định chỉ đúng cho đúng checkpoint đó. Đổi model, thứ tự có thể đảo.
07Device, dtype và bài toán bộ nhớ
7.1 Model và input phải cùng device
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
inputs = {
key: value.to(device)
for key, value in inputs.items()
}
Nếu quên, PyTorch sẽ ném ra lỗi kiểu Expected all tensors to be on the same device. Đây là lỗi dễ sửa nhất trong bài này.
7.2 Dtype quyết định bạn chạy được model nào
Với model 7 tỷ tham số, chỉ tính riêng weights:
FP32: 7B × 4 bytes ≈ 28 GB
FP16: 7B × 2 bytes ≈ 14 GB
INT8: 7B × 1 byte ≈ 7 GB
4-bit: 7B × 0.5 byte ≈ 3.5 GB
Con số thực tế luôn lớn hơn, vì còn:
- KV cache, tăng theo context length và batch size - xem phần 8.
- Activation trong lúc forward.
- Runtime buffer và phân mảnh bộ nhớ của allocator.
- Một số tensor, thường là layer norm và embedding, không được quantize.
Nhưng phép tính thô này đủ để trả lời câu hỏi thực tế nhất: “model này có chạy nổi trên card của tôi không?”
Một quy tắc ngón tay cái hữu ích: lấy số tham số tính bằng tỷ, nhân 2 nếu dùng FP16, rồi cộng thêm 20-30% cho phần còn lại. Model 7B ở FP16 cần khoảng 16-18 GB để chạy thoải mái với context vừa phải.
model = AutoModelForCausalLM.from_pretrained(
checkpoint,
torch_dtype=torch.float16,
device_map="auto",
)
device_map="auto" cho phép trải model qua nhiều GPU, hoặc đẩy bớt layer xuống CPU khi VRAM không đủ.
08Từ notebook đến production
Một model chạy được trong notebook chưa chắc sẵn sàng cho production. Khi phục vụ nhiều người dùng cùng lúc, hệ thống phải xử lý các request có prompt dài ngắn khác nhau, output length khác nhau, sampling parameter khác nhau, và đến vào những thời điểm khác nhau.
Bài toán không còn là “làm sao chạy model” mà là “làm sao giữ GPU bận liên tục trong khi latency, throughput và memory vẫn chấp nhận được”.
8.1 Hai metric, và chúng thường xung đột
| Metric | Đo cái gì | Ai quan tâm |
|---|---|---|
| Latency | thời gian một request phải đợi - với LLM thường tách thành TTFT (Time To First Token) và tốc độ sinh token sau đó | người dùng cuối |
| Throughput | tổng lượng token toàn hệ thống sinh ra mỗi giây | người trả tiền hạ tầng |
Tăng batch size thường làm throughput tăng và latency của từng request tăng theo. Không có cấu hình nào tối ưu cả hai; bạn chọn điểm cân bằng theo sản phẩm.
8.2 Vì sao generation khó tối ưu hơn classification
Classification là một lần forward. Generation là autoregressive: mỗi token mới phụ thuộc toàn bộ token trước đó.
Prompt
↓
Predict token 1
↓
Prompt + token 1
↓
Predict token 2
↓
Prompt + token 1 + token 2
↓
...
Sinh 200 token nghĩa là 200 lần forward pass tuần tự. Không song song hoá được theo chiều thời gian.
8.3 KV cache: đổi VRAM lấy compute
Trong self-attention:
Q = XW_Q
K = XW_K
V = XW_V
Điểm mấu chốt: khi sinh token mới, K và V của các token cũ không thay đổi. Tính lại chúng ở mỗi bước là lãng phí thuần tuý.
Nên ta lưu K_previous và V_previous lại, và ở bước tiếp theo chỉ cần tính Q_new, K_new, V_new cho đúng một token.
Không có cache, sinh n token tốn O(n²) phép tính attention. Có cache, mỗi bước chỉ còn tuyến tính theo độ dài hiện tại.
Cái giá là bộ nhớ. Kích thước KV cache:
2 × num_layers × num_kv_heads × head_dim × seq_len × batch_size × bytes_per_value
Số 2 là cho K và V. Ví dụ một model 7B kiểu Llama với 32 layer, 32 KV head, head_dim 128, ở FP16, context 4.096 token, batch 1:
2 × 32 × 32 × 128 × 4096 × 1 × 2 bytes ≈ 2.1 GB
Với batch 16, con số đó thành khoảng 34 GB - nhiều hơn cả trọng số model. Đây chính là lý do các kiến trúc hiện đại chuyển sang GQA, đã nói ở bài trước: giảm num_kv_heads từ 32 xuống 8 cắt KV cache đi 4 lần.
8.4 PagedAttention: quản lý cache như virtual memory
Cách cấp phát ngây thơ là dành trước một vùng liên tục đủ cho max_length của mỗi sequence. Vấn đề: hầu hết request không dùng hết, và phần thừa bị khoá lại vô ích. Đây là internal fragmentation, và trong thực tế nó có thể lãng phí quá nửa VRAM dành cho cache.
vLLM giải bài này bằng PagedAttention: chia KV cache thành các block nhỏ cố định, giống trang nhớ trong virtual memory. Block của cùng một sequence không cần nằm liền nhau:
GPU memory:
[A1] [B1] [A2] [C1] [B2] [A3]
Một bảng ánh xạ biết block nào thuộc sequence nào. Hệ quả:
- Phân mảnh gần như biến mất - chỉ lãng phí tối đa một block cuối cùng.
- Nhiều sequence được phục vụ đồng thời hơn trên cùng phần cứng.
- Các request chia sẻ chung prefix, ví dụ system prompt hoặc few-shot example, có thể dùng chung block thay vì nhân bản.
8.5 Continuous batching
Batching truyền thống, hay “static batching”, gom một nhóm request, chạy đến khi tất cả hoàn tất, rồi mới nhận nhóm mới. Với generation, đây là thảm hoạ: nếu request A xong sau 20 token còn request B cần 200 token, slot của A nằm không suốt 180 bước.
Continuous batching cho phép chèn request mới ngay khi một sequence kết thúc:
Step 1: A B C
Step 2: A B C
Step 3: D B C ← A xong, D vào ngay
Step 4: D B E ← C xong, E vào ngay
Trong thực tế đây là một trong những tối ưu có lợi nhất về throughput - thường tăng gấp nhiều lần so với static batching, mà không đổi gì bên trong model.
8.6 FlashAttention: tối ưu ở tầng kernel
Attention chuẩn:
Attention(Q, K, V) = softmax(QKᵀ / √d)V
Vấn đề không nằm ở số phép tính, mà ở đường đi của dữ liệu. GPU có nhiều tầng bộ nhớ với tốc độ rất khác nhau:
SRAM / shared memory → rất nhanh, rất nhỏ
HBM / VRAM → lớn, chậm hơn nhiều lần
Một implementation ngây thơ sẽ materialize toàn bộ ma trận QKᵀ kích thước seq_len × seq_len vào HBM, đọc lại để tính softmax, ghi ra, rồi đọc lại lần nữa để nhân với V. Với context 8.192, ma trận đó có 67 triệu phần tử - cho mỗi head, mỗi layer.
FlashAttention tổ chức phép tính theo block: nạp một khối Q, K, V vào SRAM, tính softmax từng phần theo thuật toán online, tích luỹ kết quả, rồi mới ghi ra một lần. Ma trận attention đầy đủ không bao giờ tồn tại trong HBM.
Điểm quan trọng: FlashAttention không đổi định nghĩa toán học của attention. Kết quả giống hệt, sai số dấu phẩy động không đáng kể. Nó chỉ thay đổi cách thực thi để hợp với cấu trúc bộ nhớ của GPU.
8.7 TGI: inference server trong hệ sinh thái Hugging Face
Text Generation Inference đứng giữa client và GPU:
Client
↓
HTTP API
↓
Inference Server
├── queue requests
├── batch requests
├── manage KV cache
├── run generation
└── stream tokens
↓
GPU
Những khả năng thường quan trọng ở tầng này: continuous batching, token streaming, optimized attention kernel, multi-GPU qua tensor parallelism, metrics cho observability, request scheduling và các tham số điều khiển generation.
8.8 llama.cpp: hướng đi hoàn toàn khác
llama.cpp không cạnh tranh trên sân datacenter GPU. Nó mạnh ở local inference, CPU inference, phần cứng phổ thông, tính di động - chạy được trên Mac, Windows, Linux, Android, thậm chí Raspberry Pi - và model quantized.
Đây là lựa chọn đúng khi bạn muốn chạy LLM trên laptop, trên máy khách hàng, hoặc trong môi trường không có internet.
Quantization là thứ làm điều đó khả thi. Thay vì lưu weights ở FP16, model được biểu diễn ở 8, 6, 5, 4 bit hoặc thấp hơn. Với model 8B:
FP32: 8B × 4 bytes ≈ 32 GB
FP16: 8B × 2 bytes ≈ 16 GB
8-bit: 8B × 1 byte ≈ 8 GB
4-bit: 8B × 0.5 byte ≈ 4 GB
Về bản chất, quantization ánh xạ một dải giá trị float sang một tập mức rời rạc nhỏ hơn, kèm theo scale và đôi khi zero-point để xấp xỉ lại giá trị gốc lúc tính toán. Đổi lại việc giảm bộ nhớ là một lượng precision bị mất - đây luôn là trade-off giữa memory, speed và quality.
Trong hệ sinh thái llama.cpp, GGUF là định dạng đóng gói tensor, metadata kiến trúc, metadata tokenizer và thông tin quantization vào một file. Các tên như Q4_K_M, Q5_K_M, Q8_0 mô tả scheme khác nhau:
Q4/Q5/Q8- số bit trung bình mỗi trọng số._K- dùng k-quant, chia block và lưu scale theo nhóm để giảm sai số._M/_S/_L- mức medium, small, large trong cùng họ.
Không nên hiểu đơn giản rằng bit thấp hơn luôn tốt hơn. Lựa chọn phụ thuộc dung lượng RAM/VRAM, kích thước model, tốc độ mong muốn và mức quality loss chấp nhận được. Một kinh nghiệm phổ biến: model lớn hơn ở mức quantize thấp hơn thường tốt hơn model nhỏ hơn ở mức quantize cao hơn, nếu cả hai đều vừa bộ nhớ.
Khi model không vừa hoàn toàn trong VRAM, llama.cpp hỗ trợ offloading: đặt n layer đầu trên GPU và phần còn lại trên CPU. Càng nhiều layer trên GPU thì càng nhanh, nhưng càng cần nhiều VRAM. Đây là một núm vặn rất thực dụng khi phần cứng của bạn nằm ở giữa hai mức.
8.9 OpenAI-compatible API
Một xu hướng đã thành chuẩn de facto: mọi inference server đều cung cấp endpoint tương thích OpenAI.
Application
↓
Generic chat/completions client
↓
Inference endpoint
├── hosted service
├── TGI
├── vLLM
└── local server
Lợi ích rất cụ thể: bạn có thể prototype với API trả phí, chuyển sang vLLM self-hosted khi lên production, và fallback sang llama.cpp cho môi trường offline - mà tầng application gần như không đổi một dòng nào.
8.10 Chọn nhanh
| Nhu cầu | Lựa chọn phù hợp |
|---|---|
| Production GPU serving | TGI hoặc vLLM |
| Throughput cao, nhiều request đồng thời | vLLM |
| Workflow xoay quanh Hugging Face | TGI |
| Quản lý KV cache hiệu quả | vLLM |
| Local inference | llama.cpp |
| Ưu tiên CPU | llama.cpp |
| Model quantized GGUF | llama.cpp |
| Phần cứng phổ thông | llama.cpp |
| API server cho nhiều user | TGI hoặc vLLM |
09Điều khiển generation
Memory management chỉ là một nửa. Nửa còn lại: chọn token tiếp theo như thế nào.
9.1 Greedy decoding
Luôn chọn token có xác suất cao nhất:
next_token = torch.argmax(logits, dim=-1)
Output deterministic - cùng input luôn cho cùng output. Rất tốt cho task cần độ ổn định như trích xuất dữ liệu, phân loại, sinh code có cấu trúc. Nhưng văn bản dễ khô cứng và lặp.
9.2 Temperature
Temperature nắn độ “sắc” của phân phối trước khi lấy mẫu:
softmax(logits / T)
| T | Hiệu ứng |
|---|---|
T → 0 |
tiệm cận greedy, token mạnh nhất áp đảo |
T < 1 |
phân phối sắc hơn, output ổn định hơn |
T = 1 |
giữ nguyên phân phối model học được |
T > 1 |
phân phối phẳng hơn, token ít khả năng có cơ hội hơn |
Ví dụ với logits [3.0, 1.0, 0.5]:
T = 0.5 → [0.93, 0.05, 0.02]
T = 1.0 → [0.79, 0.11, 0.10]
T = 2.0 → [0.55, 0.20, 0.25]
9.3 Top-k và top-p
Top-k giữ lại k token có xác suất cao nhất rồi mới sample. Vocabulary 50.000 token với top_k = 50 nghĩa là 49.950 ứng viên bị loại trước khi bốc.
Nhược điểm: k cố định không phù hợp với mọi ngữ cảnh. Có lúc model rất chắc chắn nên một ứng viên là đủ, có lúc model rất phân vân nên 50 ứng viên vẫn ít.
Top-p, hay nucleus sampling, linh hoạt hơn: giữ tập token nhỏ nhất sao cho tổng xác suất tích luỹ đạt ngưỡng p.
A 0.35 ← tích luỹ 0.35
B 0.25 ← tích luỹ 0.60
C 0.15 ← tích luỹ 0.75
D 0.10 ← tích luỹ 0.85 ✓ vượt 0.8, dừng
E 0.05
Với top_p = 0.8, tập giữ lại là A + B + C + D. Khi model chắc chắn, tập này tự thu nhỏ; khi model phân vân, nó tự mở rộng.
Trong thực tế, top-p và temperature thường được dùng cùng nhau, còn top-k được đặt như một giới hạn an toàn ở trên.
9.4 Các loại penalty
LLM có thể rơi vào vòng lặp - lặp lại một cụm từ, hoặc thậm chí lặp lại chính một câu vô hạn. Ba cơ chế thường gặp:
| Tham số | Cơ chế |
|---|---|
| Repetition penalty | chia logits của token đã xuất hiện cho một hệ số, giảm khả năng dùng lại |
| Frequency penalty | trừ một lượng tỷ lệ với số lần token đã xuất hiện |
| Presence penalty | trừ một lượng cố định nếu token đã từng xuất hiện, bất kể bao nhiêu lần |
Presence penalty đẩy model sang chủ đề mới. Frequency penalty chống lặp từ. Chúng giải quyết hai vấn đề hơi khác nhau, và đặt quá cao sẽ làm văn bản mất tự nhiên.
9.5 Điều kiện dừng
Generation phải biết khi nào thì thôi. Ba cơ chế:
- Model sinh ra EOS token.
- Đạt
max_new_tokens. - Gặp stop sequence do application định nghĩa:
stop = ["###", "</answer>", "\nUser:"]
Stop sequence đặc biệt quan trọng khi bạn tự dựng prompt format - không có nó, model sẽ vui vẻ sinh tiếp cả lượt hội thoại của người dùng.
9.6 Context length và output length không độc lập
Đây là chỗ rất hay bị hiểu nhầm. Nếu model có context window 8.192 token và prompt đã chiếm 7.000 token, phần còn lại cho output chỉ là:
8.192 - 7.000 = 1.192 token
Đặt max_new_tokens = 4000 trong tình huống đó sẽ không cho bạn 4.000 token. Nó sẽ bị cắt, hoặc gây lỗi, tuỳ engine.
Nói cách khác: context window là ngân sách chung cho cả input lẫn output, không phải giới hạn riêng cho input.
10Ba lớp để debug một hệ thống LLM
Toàn bộ bài này có thể gói vào ba lớp. Đây không chỉ là cách sắp xếp kiến thức - nó là một quy trình chẩn đoán.
Khi có sự cố, câu hỏi đầu tiên không phải “đổi model nào bây giờ”, mà là “triệu chứng này thuộc lớp nào?”. Trả lời được câu đó, không gian tìm kiếm thu hẹp lại ngay từ toàn bộ hệ thống xuống còn vài chỗ cụ thể.
Bảng dưới đây là phiên bản tra cứu nhanh của bước “phân loại” trong sơ đồ:
| Triệu chứng | Lớp cần kiểm tra | Việc cần làm |
|---|---|---|
| Output vô nghĩa, model “không hiểu” input | Lớp 1 | in ra tokenizer.decode(input_ids) và xem model thực sự nhận gì; kiểm tra chat template |
| Kết quả đổi khi thay đổi batch size | Lớp 1 | thiếu attention_mask, hoặc padding sai bên |
| Lỗi shape mismatch | Lớp 1 và 2 | batch dimension, hoặc head sai loại |
| Nhãn đúng nhưng tên nhãn sai | Lớp 2 | kiểm tra model.config.id2label |
| Output lặp vô hạn | Lớp 2 và 3 | penalty, temperature, stop sequence |
| Inference chậm | Lớp 3 | batch size, KV cache, attention kernel, memory bandwidth |
| Hết VRAM | Lớp 3 | dtype thấp hơn, quantization, offloading, giảm context |
Cách chia này hiệu quả vì nó ngăn một sai lầm rất phổ biến: đổi model khi vấn đề nằm ở tokenizer, hoặc tinh chỉnh prompt khi vấn đề thực sự là hết VRAM.
Kết luận
Ban đầu chúng ta thường nhìn Transformer như một model. Nhưng một hệ thống LLM thực tế là tổ hợp của tám thành phần, mỗi thành phần đều có thể là điểm hỏng và đều có núm vặn riêng.
Điều sơ đồ này nói thêm so với một danh sách: tám thành phần không nằm cùng một tầng quyền quyết định. Ba thứ ở giữa - architecture, weights, task head - đi liền với checkpoint; bạn chọn cả gói hoặc không chọn, không thể lấy weights của model này ghép vào tokenizer của model khác. Ba thứ bên phải - generation, memory, inference engine - là những thứ bạn thật sự cấu hình được sau khi model đã cố định, và cũng chính là nơi phần lớn công việc tối ưu diễn ra.
Ba thẻ dưới sơ đồ là ba đường debug thường dùng nhất, tương ứng ba triệu chứng phổ biến nhất: output sai, inference chậm, và model không vừa GPU.
Khi đó pipeline() không còn là hộp đen. Nó chỉ là một abstraction tiện lợi nằm trên một chuỗi xử lý mà bạn đã hiểu đủ sâu để mở ra, thay đổi và tối ưu khi cần.
Phần 8.10 và phần 9 của bài này đã chạm tới một ràng buộc mà mọi hệ thống LLM nghiêm túc đều phải sống chung: context window là ngân sách hữu hạn, dùng chung cho cả input lẫn output. Series sẽ quay lại đúng ràng buộc đó ở phần context engineering.
Nhưng trước hết, bài tiếp theo đi theo hướng ngược lại. Cả bài này lẫn bài đầu tiên đều nói về một model đã biết sẵn thứ gì đó và cách dùng nó cho tốt. Phần ba nói về việc thay đổi chính những gì nó biết: fine-tuning một pretrained model - từ data pipeline và rò rỉ dữ liệu, qua padding và chi phí thật của một tensor, tới cross-entropy, gradient, optimizer và cách đọc learning curve.