Khi nào bạn thực sự cần fine-tuning?
Mình đã từng dùng GPT-4 để phân loại email hỗ trợ khách hàng cho một dự án — kết quả khá ổn, nhưng chi phí API mỗi tháng lên đến vài trăm đô. Sau khi fine-tune Mistral 7B với 2000 mẫu dữ liệu thực tế, độ chính xác tương đương mà chi phí inference giảm 90%.
Trước khi bắt tay vào, hãy tự hỏi một câu: bài toán này có thực sự cần fine-tuning không? Nó đáng đầu tư khi:
- Model gốc không hiểu domain-specific vocabulary (y tế, pháp lý, kỹ thuật chuyên ngành)
- Cần output theo format cố định mà prompt engineering không đủ ổn định
- Muốn giảm chi phí bằng cách dùng model nhỏ hơn nhưng vẫn giữ chất lượng
- Dữ liệu huấn luyện quá nhạy cảm để gửi lên API bên thứ ba
Còn nếu chỉ cần thay đổi giọng văn hay cách trả lời, thử prompt engineering hoặc few-shot learning trước. Nhanh hơn, ít rắc rối hơn — và phần lớn trường hợp là đủ rồi.
Các khái niệm cốt lõi cần nắm trước khi bắt đầu
Full fine-tuning vs Parameter-Efficient Fine-Tuning (PEFT)
Full fine-tuning cập nhật toàn bộ trọng số model. Với Mistral 7B, điều đó có nghĩa là bạn cần ít nhất 40GB VRAM — không thực tế với máy cá nhân hay VM thông thường. Đó là lý do hầu hết dự án thực tế hiện nay chuyển sang LoRA (Low-Rank Adaptation), kỹ thuật PEFT phổ biến nhất hiện tại.
Ý tưởng của LoRA khá thông minh: thay vì cập nhật ma trận trọng số W khổng lồ, ta chèn thêm hai ma trận nhỏ A và B sao cho ΔW = A × B. Số tham số cần train giảm còn 0.3–10% so với full fine-tuning, nhưng kết quả gần như tương đương.
Dataset format quan trọng như thế nào?
Đây là chỗ mình thấy nhiều người hay bị vấp nhất. Model không học từ văn bản thô — nó học từ các cặp (instruction, response) được format theo template chuẩn của từng model. Dùng sai template là model sẽ không học được gì cả, hoặc ra output lạ.
Ví dụ format Alpaca (dùng cho nhiều model instruction-tuned):
{
"instruction": "Phân loại email này theo danh mục: complaint, question, feedback",
"input": "Tôi đặt hàng 3 ngày rồi mà chưa nhận được. Tôi có thể hủy không?",
"output": "complaint"
}
Còn đây là format ChatML (dùng cho Mistral, Qwen, nhiều model mới hơn):
{
"messages": [
{"role": "system", "content": "Bạn là chuyên gia phân loại email hỗ trợ khách hàng."},
{"role": "user", "content": "Phân loại: Tôi đặt hàng 3 ngày rồi mà chưa nhận được."},
{"role": "assistant", "content": "complaint"}
]
}
Thực hành: Fine-tune model phân loại văn bản với LoRA
Bước 1: Chuẩn bị môi trường
Mình khuyên dùng Google Colab (T4 GPU miễn phí) hoặc Kaggle Notebooks cho lần đầu thử. Khi đã quen, chuyển sang RunPod hoặc Lambda Labs nếu cần train lâu hơn.
pip install transformers datasets peft trl accelerate bitsandbytes
Lưu ý: bitsandbytes cho phép load model ở dạng 4-bit (QLoRA), giúp train Mistral 7B trên GPU 16GB VRAM — điều mà full precision đòi hỏi 40GB+.
Bước 2: Chuẩn bị dataset
Chất lượng data quan trọng hơn số lượng. 500 mẫu tốt thường cho kết quả tốt hơn 5000 mẫu nhiễu. Kinh nghiệm của mình: dành 60% thời gian cho việc làm sạch và chuẩn hóa data, 40% còn lại mới là code train.
from datasets import Dataset
import json
# Load data từ file JSONL
with open("email_data.jsonl", "r") as f:
raw_data = [json.loads(line) for line in f]
# Tạo Hugging Face Dataset
dataset = Dataset.from_list(raw_data)
# Chia train/test (80/20)
dataset = dataset.train_test_split(test_size=0.2, seed=42)
print(dataset)
Bước 3: Load model với quantization 4-bit (QLoRA)
QLoRA hoạt động nhờ bước này — load model ở dạng 4-bit thay vì fp16, giảm VRAM từ ~14GB xuống còn khoảng 4–5GB:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_name = "mistralai/Mistral-7B-Instruct-v0.2"
# Config quantization 4-bit
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=quant_config,
device_map="auto"
)
Bước 4: Cấu hình LoRA adapter
Giá trị r (rank) ảnh hưởng trực tiếp đến dung lượng adapter và chất lượng kết quả. Bắt đầu với r=16 cho hầu hết task; tăng lên 32–64 nếu task phức tạp:
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# Cần chuẩn bị model trước khi thêm LoRA
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16, # Rank — tăng lên cho task phức tạp hơn (thử 8, 16, 32, 64)
lora_alpha=32, # Scaling factor, thường đặt = 2*r
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Kết quả: trainable params: 13,631,488 || all params: 3,765,600,256 || trainable%: 0.36
Bước 5: Format data và train
SFTTrainer từ thư viện TRL xử lý phần lớn boilerplate. Chú ý gradient_accumulation_steps — đây là cách mô phỏng batch size lớn khi VRAM không đủ:
from trl import SFTTrainer
from transformers import TrainingArguments
def format_prompt(sample):
return f"""[INST] {sample['instruction']}
{sample['input']} [/INST] {sample['output']}"""
training_args = TrainingArguments(
output_dir="./output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # Effective batch size = 16
learning_rate=2e-4,
fp16=True,
logging_steps=50,
save_strategy="epoch",
evaluation_strategy="epoch",
warmup_ratio=0.03,
lr_scheduler_type="cosine",
report_to="none" # Tắt wandb nếu chưa setup
)
trainer = SFTTrainer(
model=model,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
formatting_func=format_prompt,
args=training_args,
max_seq_length=1024,
)
trainer.train()
Bước 6: Lưu và merge adapter
Sau khi train xong, bạn có thể lưu chỉ LoRA adapter (nhỏ vài chục MB) hoặc merge vào model gốc để deploy dễ hơn:
# Lưu LoRA adapter (nhỏ, dễ share)
model.save_pretrained("./my-email-classifier-adapter")
tokenizer.save_pretrained("./my-email-classifier-adapter")
# Hoặc merge vào model gốc và lưu full model
from peft import AutoPeftModelForCausalLM
merged_model = AutoPeftModelForCausalLM.from_pretrained(
"./my-email-classifier-adapter",
torch_dtype=torch.float16,
device_map="auto"
)
merged_model = merged_model.merge_and_unload()
merged_model.save_pretrained("./my-email-classifier-merged")
Một vài tips từ thực chiến
- Theo dõi training loss: Loss giảm quá nhanh rồi plateau sớm thường báo hiệu dataset quá nhỏ hoặc learning rate quá cao. Mình thường bắt đầu với lr=2e-4 rồi điều chỉnh dần.
- Tránh overfitting: Luôn giữ lại validation set. Eval loss tăng trong khi train loss vẫn giảm — dừng ngay, không cần train thêm.
- Gradient checkpointing: Thêm
gradient_checkpointing=Truevào TrainingArguments để giảm VRAM, đổi lại tốc độ train chậm hơn ~20%. - Số lượng epoch: Dataset nhỏ dưới 1000 mẫu thì 3–5 epoch thường đủ. Dataset lớn hơn thì 1–2 epoch là hợp lý.
Kết luận: Bắt đầu nhỏ, đo lường kỹ
Sau nhiều lần fine-tune, điều mình rút ra là: phần khó nhất không phải code hay hyperparameter — mà là data. 80% vấn đề gặp phải đến từ data bẩn, label sai, hoặc phân phối lệch giữa train và test set.
Lộ trình thực tế: bắt đầu với 200–500 mẫu, train thử trên Colab, đánh giá kỹ trên test set. Khi model đã ổn định, mới scale up data và chuyển sang GPU mạnh hơn. Deploy production thì Ollama phù hợp cho team nhỏ, còn vLLM nếu cần throughput cao.
Source code đầy đủ — bao gồm script làm sạch data, training loop và inference — mình để trên GitHub. Nếu bạn đang làm bài toán tương tự hoặc gặp lỗi lạ trong quá trình train, để lại comment bên dưới nhé.

