$ cat ./blog/gen-ai/llm-fundamentals/su-dung-transformers.md
seriesCách hoạt động của LLMbài 2

Sử dụng Transformers: từ pipeline() đến triển khai LLM tối ưu

Bài thứ hai trong series Cách hoạt động của LLM: mở hộp đen pipeline(), hiểu tokenizer, batching, attention mask, device và dtype, rồi đi tiếp tới TGI, vLLM, llama.cpp, KV cache, quantization và các tham số sampling.

~/diagrams/su-dung-transformers/transformers-pipeline.htmltương tác
mở riêng ↗
Hìnhpipeline() gói ba giai đoạn thành một lời gọi: tokenizer biến văn bản thành tensor, Transformer body tạo hidden states, task head đổi chúng thành logits, và post-processing biến logits thành câu trả lời.
tiền xử lýmodel và headhậu xử lý

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?
  • AutoModel khác AutoModelForSequenceClassification ở chỗ nào, và vì sao thư viện có tới cả chục class AutoModelFor...?
  • 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
~/diagrams/su-dung-transformers/transformers-pipeline.htmltương tác
mở riêng ↗
HìnhLuồng chính của pipeline() nằm ở hàng trên: văn bản thô, tokenizer, tensor, Transformer body, task head, kết quả. Hàng dưới là những bước mà API tiện lợi này làm thay bạn - tách subword, thêm special token, gom batch, hidden states, logits và Softmax.
tiền xử lýmodel và headhậu xử lý

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 CrossEntropyLoss trong 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 configurationweights.

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"
~/diagrams/su-dung-transformers/transformers-tokenizer.htmltương tác
mở riêng ↗
HìnhToàn bộ tiền xử lý trong một hình: văn bản được tách subword và bọc special token, ánh xạ sang token ID, cắt nếu vượt context, pad cho bằng chiều dài, sinh attention mask, rồi mới thành cặp tensor mà model nhận và tra vào embedding table.
tách và ánh xạchuẩn hoá batchtensor vào model

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:

  1. Normalize text: lowercase, bỏ dấu, chuẩn hoá Unicode - tuỳ tokenizer.
  2. Pre-tokenization: tách theo khoảng trắng và dấu câu.
  3. Subword tokenization.
  4. Ánh xạ token sang ID.
  5. Thêm special token theo đúng format của model.
  6. Truncation nếu vượt max_length.
  7. Padding cho các sequence bằng nhau.
  8. Sinh attention_mask, và token_type_ids nếu model cần.
  9. 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_gradeval 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_previousV_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.

~/diagrams/su-dung-transformers/transformers-inference-request.htmltương tác
mở riêng ↗
HìnhĐường đi của một request generation: server xếp nó vào hàng đợi, scheduler chạy prefill cho cả prompt và ghi K, V vào cache, token đầu tiên quay về người dùng - đây là TTFT. Sau đó mỗi bước decode chỉ tính một token mới và dùng lại K, V cũ, trong khi request mới liên tục được chèn vào các slot trống.
prefillvòng lặp decodebatching và dọn dẹp

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.

~/diagrams/su-dung-transformers/transformers-deployment.htmltương tác
mở riêng ↗
HìnhCùng một application, cùng một giao diện API, ba hướng triển khai khác nhau: TGI và vLLM phục vụ trên datacenter GPU với KV cache dạng block và attention kernel tối ưu, còn llama.cpp chạy model quantized GGUF trên máy cá nhân với khả năng offload layer giữa CPU và GPU.
application và APIruntime và cơ chếphần cứng

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ế:

  1. Model sinh ra EOS token.
  2. Đạt max_new_tokens.
  3. 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ể.

~/diagrams/su-dung-transformers/transformers-debug-layers.htmltương tác
mở riêng ↗
HìnhQuy trình chẩn đoán: từ triệu chứng, phân loại xem nó thuộc lớp nào, rồi chỉ kiểm tra trong đúng lớp đó - tokenizer và mask ở lớp 1, head và logits ở lớp 2, bộ nhớ và batching ở lớp 3 - trước khi sửa một thay đổi mỗi lần và đo lại. Ba thẻ bên phải là nội dung đầy đủ của từng lớp.
lớp 1 · representationlớp 2 · neural computationlớp 3 · inference system

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.

~/diagrams/su-dung-transformers/transformers-llm-stack.htmltương tác
mở riêng ↗
HìnhTám thành phần tạo nên một hệ thống LLM, xếp theo đường đi thật của dữ liệu. Khối giữa là những thứ đi cùng checkpoint và bạn không tự chọn rời từng cái; khối bên phải là những thứ bạn cấu hình khi triển khai. Nhãn nhỏ trên mỗi ô cho biết thành phần đó quyết định điều gì, hoặc hỏng thì gây ra triệu chứng nào.
biểu diễn đầu vàomodel artifactruntime và phần cứ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.

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.