Sau 20 bài lý thuyết, đã đến lúc làm tay. Bài này là capstone của Part 5: bạn sẽ thực sự fine-tune Llama-3-8B trên dataset tiếng Việt, từ setup GPU đến deploy adapter.
Tổng cost ước tính: $15-25 cho GPU rental, $0 cho phần còn lại. Thời gian: 4-6 giờ thực hành (bao gồm download model, tokenize, train, eval).
Sau bài này bạn có:
- Một adapter LoRA file ~200MB chứa fine-tune Vietnamese
- Hiểu rõ pipeline thực tế từ raw dataset đến trained model
- Một template script có thể tái sử dụng cho dataset khác
Bài này không phải copy-paste. Mỗi bước có lý do, có pitfall, có cách verify. Đọc kỹ, làm theo, đừng skip.
Mental model: pipeline tổng thể
[Pretrained Llama-3-8B]
|
v
[1. Setup GPU cloud rental]
|
v
[2. Download model + Vietnamese tokenizer test]
|
v
[3. Load dataset VN]
|
v
[4. Tokenize + apply chat template]
|
v
[5. Configure QLoRA + SFT]
|
v
[6. Train 1-2 giờ]
|
v
[7. Eval và iterate]
|
v
[Output: LoRA adapter ~200MB]
Mỗi bước có thể fail. Bài này list cụ thể cách verify trước khi qua bước sau.
Phần 1: Setup GPU cloud rental
Để fine-tune Llama-3-8B với QLoRA, ta cần ~12GB VRAM. Có nhiều provider:
| Provider | GPU | Giá ~ | Setup |
|---|---|---|---|
| Vast.ai | RTX 3090 24GB | $0.20-0.40/h | Self-service, marketplace |
| RunPod | A40 48GB | $0.39/h | Self-service, easy |
| RunPod | A100 40GB | $1.10/h | Faster |
| Lambda | A100 40GB | $1.10/h | Reliable, hơi chậm setup |
| Colab Pro+ | A100 40GB | $50/tháng | Time limited, có disconnect |
Cho beginner, RunPod hoặc Vast.ai là lựa chọn tốt. Chọn template “PyTorch 2.x” có sẵn CUDA, drivers.
Setup workflow (Vast.ai, similar cho RunPod):
1. Tạo account, deposit $30
2. Search GPU: filter "RTX 3090" hoặc "A40", "Storage > 100GB"
3. Pick instance giá ~$0.30/h, status "Verified"
4. "Rent" -> chọn image "pytorch/pytorch:2.1.0-cuda12.1-cudnn8-devel"
5. Wait 1-2 phút cho instance khởi động
6. SSH vào: ssh root@<ip> -p <port>
(host và port lấy từ dashboard)
Verify GPU sau khi SSH:
nvidia-smi
Expected output: GPU listed, CUDA version, VRAM available. Nếu không, instance broken, terminate và rent cái khác.
Tip: pick instance “Verified” thay vì “Unverified”, tránh trường hợp GPU thật sự không có hoặc unstable.
Phần 2: Setup environment
Trên remote instance:
apt update && apt install -y git wget tmux htop
# Tạo workspace
mkdir -p /workspace && cd /workspace
# Setup conda/python env
python -m pip install --upgrade pip
pip install torch transformers accelerate datasets peft trl bitsandbytes wandb sentencepiece protobuf
Verify:
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name())"
Expected: True RTX 3090 (hoặc tương đương).
Pip install xong, tạo tmux session để training không bị mất khi SSH disconnect:
tmux new -s train
Sau này, attach lại bằng tmux attach -t train.
Phần 3: Download model và dataset
Llama-3-8B là gated model. Cần HuggingFace account và accept license:
huggingface-cli login
# Paste token từ huggingface.co/settings/tokens
Sau đó download bằng Python:
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_id = "meta-llama/Meta-Llama-3-8B"
tokenizer = AutoTokenizer.from_pretrained(model_id)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
)
Download mất 5-10 phút tùy bandwidth. Output ~16GB.
Test tokenizer với tiếng Việt:
text = "Xin chào, hôm nay trời đẹp quá!"
tokens = tokenizer.tokenize(text)
print(tokens)
print(f"Số token: {len(tokens)}")
Llama-3 tokenizer dùng tiktoken base, support tiếng Việt khá tốt (mỗi từ 1-2 tokens). Tốt hơn nhiều Llama-2 (mỗi chữ cái thường thành 1 token).
Dataset: dùng dataset VN public từ HuggingFace.
Một dataset open source phổ biến cho instruction VN: vilm/vi-alpaca (~50K samples, Alpaca format tiếng Việt). Hoặc bkai-foundation-models/vi-alpaca-train (~80K).
from datasets import load_dataset
dataset = load_dataset("vilm/vi-alpaca", split="train")
print(dataset[0])
Output mẫu:
{
"instruction": "Hãy viết một đoạn văn ngắn về Hà Nội.",
"input": "",
"output": "Hà Nội là thủ đô của Việt Nam, một thành phố cổ kính với hơn nghìn năm lịch sử..."
}
Filter các sample quá dài hoặc quá ngắn:
def filter_length(example):
text_len = len(example["instruction"]) + len(example.get("input", "")) + len(example["output"])
return 50 < text_len < 3000
dataset = dataset.filter(filter_length)
print(f"After filter: {len(dataset)} samples")
Subset nhỏ để chạy nhanh lần đầu:
dataset = dataset.shuffle(seed=42).select(range(5000))
5000 sample đủ để verify pipeline. Train production nên dùng 30K+.
Phần 4: Tokenize và format
Convert Alpaca format sang chat template:
def to_chat_format(example):
instruction = example["instruction"]
inp = example.get("input", "").strip()
if inp:
user_msg = f"{instruction}\n\n{inp}"
else:
user_msg = instruction
return {
"messages": [
{"role": "user", "content": user_msg},
{"role": "assistant", "content": example["output"]},
]
}
dataset = dataset.map(to_chat_format).remove_columns(["instruction", "input", "output"])
Verify:
print(dataset[0])
print(tokenizer.apply_chat_template(dataset[0]["messages"], tokenize=False))
Expected output: full template với <|begin_of_text|>, <|start_header_id|>user, etc.
Phần 5: Configure QLoRA
Setup QLoRA dùng bitsandbytes cho 4-bit và peft cho LoRA:
from transformers import BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
)
model.config.use_cache = False
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=32,
lora_alpha=64,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
Expected output:
trainable params: 83,886,080 || all params: 8,116,553,728 || trainable%: 1.0335
83M trainable, 8B total. 1% trainable.
Verify GPU memory:
nvidia-smi --query-gpu=memory.used,memory.total --format=csv
Expected: ~7-9GB used (Llama-3-8B NF4 + adapter + activation buffer).
Phần 6: Configure trainer và train
Setup SFTTrainer:
from trl import SFTTrainer, SFTConfig
training_config = SFTConfig(
output_dir="/workspace/llama3-vi-sft",
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=2,
learning_rate=2e-4,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
bf16=True,
logging_steps=20,
save_steps=200,
save_total_limit=2,
max_seq_length=2048,
packing=True,
report_to="none",
)
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
args=training_config,
tokenizer=tokenizer,
)
trainer.train()
Tham số quan trọng:
per_device_train_batch_size=2: 4-bit NF4 còn tốn memory, batch lớn dễ OOM.gradient_accumulation_steps=8: effective batch = 2 * 8 = 16 samples / step. Tốt cho gradient stability.num_train_epochs=2: 1 epoch là minimum, 2 thường tốt nhất. Quá nhiều dễ overfit.learning_rate=2e-4: cao điển hình cho LoRA. Full FT thường 1e-5.packing=True: gộp nhiều short example vào 1 sequence dài. Tăng throughput 2-3x.
Training output mẫu:
{'loss': 1.8421, 'grad_norm': 0.523, 'learning_rate': 0.00018, 'epoch': 0.05}
{'loss': 1.5234, 'grad_norm': 0.412, 'learning_rate': 0.00019, 'epoch': 0.16}
{'loss': 1.3851, 'grad_norm': 0.367, 'learning_rate': 0.00018, 'epoch': 0.32}
{'loss': 1.2745, 'grad_norm': 0.341, 'learning_rate': 0.00016, 'epoch': 0.55}
...
{'loss': 0.9821, 'grad_norm': 0.298, 'learning_rate': 0.00002, 'epoch': 1.95}
Pattern healthy: loss giảm smooth, grad norm < 1.0, learning rate warmup rồi cosine decay.
Wall-clock estimate:
- 5000 samples * 2 epochs = 10000 samples
- Effective batch 16 => 625 steps
- RTX 3090: ~5 sec/step
- Tổng: ~52 phút
Trên A40 hoặc A100, nhanh hơn 1.5-2x.
Cost: 1h * $0.30/h = $0.30 cho 5K samples. 50K samples ~$3. 200K samples ~$12. Vẫn under $20.
Phần 7: Save và test adapter
Sau training:
trainer.save_model("/workspace/llama3-vi-sft/final")
Adapter folder ~250MB. Có thể download về local hoặc upload lên HuggingFace Hub.
Test:
from peft import PeftModel
base_model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
)
model = PeftModel.from_pretrained(base_model, "/workspace/llama3-vi-sft/final")
def chat(user_msg):
messages = [{"role": "user", "content": user_msg}]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to("cuda")
outputs = model.generate(**inputs, max_new_tokens=300, do_sample=True, temperature=0.7, top_p=0.9)
return tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
print(chat("Viết một bài thơ ngắn về mùa thu Hà Nội"))
print("---")
print(chat("Giải thích blockchain bằng tiếng Việt đơn giản"))
print("---")
print(chat("Hai cộng hai bằng mấy?"))
Compare với base model (chưa fine-tune):
base_only = AutoModelForCausalLM.from_pretrained(model_id, quantization_config=bnb_config, device_map="auto")
# Test same prompts với base_only, compare output
Expected difference:
- Base model thường mix English/Vietnamese, hoặc continue text style (không answer).
- Fine-tuned model trả lời thuần tiếng Việt, follow instruction.
Nếu fine-tuned model output kém hơn base, có vấn đề:
- Overfit (learning rate quá cao, epochs quá nhiều)
- Dataset quality kém
- Target modules sai
Phần 8: Pitfall thực tế
Tôi từng chạy pipeline này trên một dataset 10K VN samples, kết quả model “quên” English. Khi hỏi tiếng Anh, generate broken English mixed Vietnamese.
Lý do: catastrophic forgetting. Fine-tune mạnh trên một ngôn ngữ làm degrade ngôn ngữ khác. Đây là vấn đề kinh điển của continual learning.
Fix:
- Mix dataset: trộn 30% English instruction vào dataset VN. Llama-3-8B-Instruct có một template phong phú, học VN không cần overwrite English.
- Lower learning rate: 1e-4 thay vì 2e-4. Slower adaptation, less forgetting.
- Lower LoRA rank: r=16 thay vì r=32. Adapter nhỏ hơn, ít overwrite hơn.
- Higher beta nếu DPO (next step): keep model gần reference hơn.
Pattern: forgetting tăng tỷ lệ với “magnitude của update”. Giảm magnitude là giảm forgetting, đổi lại là kết quả VN cũng nhẹ hơn. Trade-off.
Phần 9: Iteration plan
Lần chạy đầu thường không hoàn hảo. Plan iterate:
Iteration 1 (5K samples, 2 epochs):
Verify: pipeline chạy không lỗi, loss giảm, output ok
Khoảng 1 giờ, $0.30
Iteration 2 (filter + 20K samples, 2 epochs):
Verify: quality better, không catastrophic forget
~3 giờ, $1
Iteration 3 (50K samples, mixed VN + EN, 1 epoch):
Production-ish quality
~5 giờ, $2
Iteration 4 (DPO trên preference data VN):
Polish quality cuối
~2 giờ, $0.60
Tổng: ~10 giờ training, ~$5-10 GPU
Hoặc nếu thuê A100 cho tốc độ:
- A100 40GB: $1.10/h
- Pipeline cùng vậy, nhanh hơn 2x
- Tổng: $5-6 GPU
Đó là lý do title bài là “$20 GPU”. Có buffer cho mistake.
Phần 10: Deploy adapter
Có 2 cách deploy:
A. Keep adapter tách (flexible):
base = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, device_map="auto")
model = PeftModel.from_pretrained(base, "/path/to/adapter")
Memory cao hơn (load base BF16), nhưng có thể swap adapter cho task khác.
B. Merge và deploy (single artifact):
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16)
model = PeftModel.from_pretrained(model, "/path/to/adapter")
model = model.merge_and_unload()
model.save_pretrained("/path/to/merged-model")
tokenizer.save_pretrained("/path/to/merged-model")
Output: merged model ~16GB. Serve bằng vllm, llama.cpp, hoặc ollama (sau khi convert GGUF). Sẽ bàn ở Part 6.
Cheatsheet: full script tóm tắt
| Bước | Command / Code |
|---|---|
| Setup GPU | RunPod / Vast.ai instance + tmux |
| Install | pip install torch transformers peft trl bitsandbytes datasets accelerate |
| Login HF | huggingface-cli login |
| Load model | AutoModelForCausalLM.from_pretrained(..., quantization_config=bnb) |
| Load dataset | load_dataset("vilm/vi-alpaca") |
| Format | apply_chat_template() |
| LoRA config | LoraConfig(r=32, target_modules="all-linear") |
| Train | SFTTrainer(...).train() |
| Save | trainer.save_model("./final") |
| Test | PeftModel.from_pretrained(base, "./final") |
| Merge | model.merge_and_unload() |
| Budget |
|---|
| GPU rental (RTX 3090 1-2h training): $0.50-1 |
| GPU rental (test + iterate): $3-5 |
| Optional: HuggingFace Hub storage (free for public) |
| Total: under $20 cho full pipeline |
Lời kết
Đây là kết thúc Part 5. Bạn đã đi từ “training là gì” (bài 14) đến “có model fine-tune VN” (bài 21). Trên đường có scaling laws, mixed precision, distributed training, LoRA, QLoRA, SFT, DPO. Mỗi khái niệm rời rạc tự nó khó tiếp cận, nhưng đặt liên tiếp thì pipeline trở nên tự nhiên.
Hands-on cuối: thực sự làm bài này từng bước. Đừng đọc xong và đóng tab. Setup GPU, chạy script, save adapter của riêng bạn. Khoảnh khắc khi adapter đầu tiên generate đoạn văn tiếng Việt theo style của dataset bạn, là khoảnh khắc bạn từ “đọc về LLM” thành “biết train LLM”. Khác biệt là vô giá.
Một số mở rộng đáng làm:
- Thay dataset VN bằng dataset domain-specific bạn quan tâm (code, legal, medical, finance). Process tương tự, chỉ khác data.
- Continue pretrain trước SFT: nếu domain corpus đủ lớn (1B+ tokens), train một stage continue pretrain trước SFT. Cải thiện quality đáng kể.
- DPO trên adapter sau SFT: tạo 1K-5K cặp preference (chosen, rejected) cho domain của bạn, train DPO 1 epoch. Polish quality cuối.
- Deploy lên Ollama hoặc llama.cpp local: convert merged model sang GGUF (quantize Q4_K_M), serve trên laptop. Sẽ học chi tiết ở Part 6.
Part 6 sẽ vào inference và production: quantization (INT8, INT4, NF4, BitNet), inference frameworks (vLLM, llama.cpp, Ollama), KV cache, PagedAttention, RAG, agent. Đây là phần làm cho model “useful trong app thật”, không chỉ trong Jupyter notebook. Bài 22 mở đầu với quantization advanced.
Hẹn gặp ở Part 6.