Bỏ qua điều hướng
CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ HQG
Giai đoạn 4 · Fine-tuneTrung cấp

Fine-tune LLM bằng LoRA/QLoRA: từ dữ liệu hội thoại tiếng Việt đến adapter, merge và GGUF

Chuẩn bị dữ liệu hội thoại JSONL, làm sạch dữ liệu tiếng Việt, tách train/eval, fine-tune QLoRA bằng Hugging Face TRL, chỉnh tham số chính, đánh giá, merge adapter và xuất GGUF. Kèm ghi chú Unsloth, LLaMA-Factory, Axolotl.

Khoảng 9 phút đọcCập nhật: 09/20269 bước
Mục tiêu

Huấn luyện xong một adapter LoRA trên dữ liệu nội bộ, so sánh được với mô hình gốc, merge thành mô hình hoàn chỉnh và xuất GGUF để chạy trên máy nhỏ.

Dành cho ai
  • Kỹ sư AI tuỳ biến mô hình cho nghiệp vụ
  • Nhóm dữ liệu chuẩn bị bộ hội thoại huấn luyện
Yêu cầu
  • Một GPU NVIDIA có đủ VRAM cho mô hình ở dạng 4-bit cộng activation — ước lượng tại /ai/tinh-cau-hinh
  • RAM hệ thống và NVMe đủ cho mô hình gốc, checkpoint và bản merge
  • Môi trường theo bài Python & PyTorch (transformers, peft, trl, bitsandbytes)
  • Tài khoản Hugging Face nếu mô hình yêu cầu chấp nhận giấy phép
Mục lục bài

Khi nào nên fine-tune, khi nào dùng RAG

Nhu cầuHướng phù hợp
Trả lời dựa trên tài liệu thay đổi thường xuyên (quy trình, bảng giá, chính sách)RAG — xem bài RAG tài liệu nội bộ
Đổi văn phong, định dạng đầu ra, cách xưng hô, thuật ngữ ngànhFine-tune LoRA
Học một tác vụ hẹp lặp lại (phân loại, trích xuất có cấu trúc)Fine-tune LoRA, thường với mô hình nhỏ
Cả kiến thức mới lẫn văn phongRAG + fine-tune nhẹ

LoRA huấn luyện một phần nhỏ trọng số bổ sung (adapter) thay vì toàn bộ mô hình. QLoRA nạp mô hình gốc ở dạng 4-bit để tiết kiệm VRAM, adapter vẫn huấn luyện ở độ chính xác cao hơn.

Các bước thực hiện

Tiến độ của bạn
0/9

Tiến độ chỉ lưu trên trình duyệt này.

  1. Bước 1: Chuẩn bị dữ liệu hội thoại dạng JSONL

    Mỗi dòng là một hội thoại với trường messages. Đây là định dạng TRL và nhiều công cụ khác đọc trực tiếp, và sẽ được áp chat template của mô hình khi huấn luyện.

    data/raw.jsonl (mỗi dòng một object)
    {"messages": [{"role": "system", "content": "Bạn là trợ lý kỹ thuật của công ty, trả lời ngắn gọn, chính xác."}, {"role": "user", "content": "Máy chủ báo lỗi nguồn dự phòng thì kiểm tra gì trước?"}, {"role": "assistant", "content": "Kiểm tra nhật ký BMC để xem nguồn nào lỗi, rồi kiểm tra dây nguồn và mạch điện cấp cho nguồn đó trước khi thay thiết bị."}]}
    • Câu trả lời mẫu phải là câu trả lời bạn MUỐN mô hình học — dữ liệu kém chất lượng cho ra mô hình kém.
    • Giữ system prompt thống nhất với lúc triển khai.
    • Loại bỏ thông tin cá nhân, mật khẩu, số hợp đồng… trước khi đưa vào huấn luyện (xem bài bảo mật dữ liệu AI).
  2. Bước 2: Làm sạch dữ liệu tiếng Việt và tách train/eval

    Văn bản tiếng Việt chép từ nhiều nguồn hay lẫn hai kiểu mã hoá dấu (Unicode dựng sẵn NFC và tổ hợp NFD). Nhìn giống nhau nhưng tokenizer coi là khác nhau — chuẩn hoá về NFC trước tiên.

    prepare_data.py
    import json
    import re
    import unicodedata
    from datasets import Dataset
    
    def clean(text: str) -> str:
        text = unicodedata.normalize("NFC", text)
        text = text.replace("\u00a0", " ")
        text = re.sub(r"[ \t]+", " ", text)
        text = re.sub(r"\n{3,}", "\n\n", text)
        return text.strip()
    
    rows, seen = [], set()
    with open("data/raw.jsonl", encoding="utf-8") as f:
        for line in f:
            item = json.loads(line)
            msgs = [{"role": m["role"], "content": clean(m["content"])} for m in item["messages"]]
            if not msgs or msgs[-1]["role"] != "assistant" or not msgs[-1]["content"]:
                continue  # bỏ mẫu không có câu trả lời
            key = json.dumps(msgs, ensure_ascii=False)
            if key in seen:
                continue  # bỏ mẫu trùng
            seen.add(key)
            rows.append({"messages": msgs})
    
    ds = Dataset.from_list(rows).train_test_split(test_size=0.05, seed=42)
    ds["train"].to_json("data/train.jsonl", force_ascii=False)
    ds["test"].to_json("data/eval.jsonl", force_ascii=False)
    print("train:", len(ds["train"]), "eval:", len(ds["test"]))

    Mẹo: Tỷ lệ tách eval 5% là điểm khởi đầu. Với dữ liệu ít, hãy tự chọn tay một bộ eval đại diện các loại câu hỏi quan trọng và không để trùng nội dung với tập train.

  3. Bước 3: Viết script fine-tune QLoRA với TRL

    Ví dụ dùng một mô hình instruct cỡ nhỏ-trung. Thay MODEL_ID bằng mô hình bạn chọn (xem danh sách mô hình) và đọc giấy phép trên model card trước khi dùng thương mại.

    train_sft.py
    import torch
    from datasets import load_dataset
    from peft import LoraConfig
    from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
    from trl import SFTConfig, SFTTrainer
    
    MODEL_ID = "Qwen/Qwen2.5-7B-Instruct"  # ví dụ
    OUT = "out/qwen7b-lora"
    
    ds = load_dataset("json", data_files={"train": "data/train.jsonl", "eval": "data/eval.jsonl"})
    
    bnb = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
        bnb_4bit_use_double_quant=True,
    )
    tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_ID,
        quantization_config=bnb,
        dtype=torch.bfloat16,
        attn_implementation="sdpa",  # hoặc "flash_attention_2" nếu đã cài
    )
    
    peft_config = LoraConfig(
        r=16,
        lora_alpha=32,
        lora_dropout=0.05,
        target_modules="all-linear",
        task_type="CAUSAL_LM",
    )
    
    args = SFTConfig(
        output_dir=OUT,
        num_train_epochs=2,
        per_device_train_batch_size=2,
        gradient_accumulation_steps=8,
        learning_rate=2e-4,
        lr_scheduler_type="cosine",
        warmup_ratio=0.03,
        bf16=True,
        gradient_checkpointing=True,
        max_length=2048,
        assistant_only_loss=True,
        eval_strategy="steps",
        eval_steps=100,
        save_steps=100,
        save_total_limit=3,
        logging_steps=10,
        report_to="none",
    )
    
    trainer = SFTTrainer(
        model=model,
        args=args,
        train_dataset=ds["train"],
        eval_dataset=ds["eval"],
        processing_class=tokenizer,
        peft_config=peft_config,
    )
    trainer.train()
    trainer.save_model(f"{OUT}/final")

    Cảnh báo: Tên tham số của TRL/transformers thay đổi giữa các bản (ví dụ max_length từng là max_seq_length, dtype từng là torch_dtype; assistant_only_loss cần chat template hỗ trợ). Nếu gặp lỗi tham số lạ, đối chiếu tài liệu đúng phiên bản bạn đang cài.

  4. Bước 4: Hiểu và chỉnh các tham số chính

    Điểm khởi đầu phổ biến trong cộng đồng — không phải công thức, cần thử trên dữ liệu của bạn.
    Tham sốÝ nghĩaCách chỉnh
    r (rank)Dung lượng của adapterThường thử 8–64; tăng khi tác vụ phức tạp và dữ liệu nhiều
    lora_alphaHệ số nhân cập nhật LoRAHay đặt bằng r hoặc 2×r
    learning_rateTốc độ họcLoRA thường quanh 1e-4 đến 2e-4; giảm nếu loss dao động mạnh
    num_train_epochsSố lượt qua dữ liệu1–3; eval loss tăng trở lại là dấu hiệu quá khớp
    max_lengthĐộ dài chuỗi tối đa (token)Theo độ dài hội thoại thực; càng dài càng tốn VRAM
    batch × gradient_accumulationBatch hiệu dụngGiữ batch hiệu dụng ổn định, giảm batch mỗi GPU khi thiếu VRAM
    gradient_checkpointingTính lại activation thay vì lưuBật khi thiếu VRAM; đổi lại chạy chậm hơn
  5. Bước 5: Chạy huấn luyện và theo dõi loss

    Bash
    python prepare_data.py
    python train_sft.py 2>&1 | tee train.log
    # Terminal khác: theo dõi GPU
    watch -n 2 nvidia-smi

    Theo dõi losseval_loss trong log. Loss train giảm mà eval_loss tăng là quá khớp: giảm epoch, giảm learning rate hoặc bổ sung dữ liệu. Hết VRAM: giảm per_device_train_batch_size, giảm max_length, bật gradient checkpointing.

  6. Bước 6: Đánh giá: so sánh với mô hình gốc

    Eval loss chưa đủ để kết luận. Chuẩn bị bộ câu hỏi thật từ người dùng nghiệp vụ (không nằm trong tập train), sinh câu trả lời từ mô hình gốc và mô hình đã fine-tune, rồi cho người có chuyên môn chấm mù.

    compare.py
    import json
    import torch
    from peft import AutoPeftModelForCausalLM
    from transformers import AutoTokenizer
    
    ADAPTER = "out/qwen7b-lora/final"
    tokenizer = AutoTokenizer.from_pretrained(ADAPTER)
    model = AutoPeftModelForCausalLM.from_pretrained(ADAPTER, dtype=torch.bfloat16, device_map="auto")
    
    def answer(question: str, use_adapter: bool) -> str:
        msgs = [{"role": "user", "content": question}]
        inputs = tokenizer.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to(model.device)
        if use_adapter:
            out = model.generate(inputs, max_new_tokens=300, do_sample=False)
        else:
            with model.disable_adapter():
                out = model.generate(inputs, max_new_tokens=300, do_sample=False)
        return tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True)
    
    with open("data/questions.txt", encoding="utf-8") as f, open("compare.jsonl", "w", encoding="utf-8") as w:
        for q in (line.strip() for line in f if line.strip()):
            row = {"question": q, "base": answer(q, False), "finetuned": answer(q, True)}
            w.write(json.dumps(row, ensure_ascii=False) + "\n")

    Muốn đo trên benchmark chuẩn, có thể dùng lm-evaluation-harness — nhưng benchmark chung không thay được bộ câu hỏi nghiệp vụ của chính bạn.

  7. Bước 7: Merge adapter vào mô hình gốc

    Adapter có thể phục vụ trực tiếp (vLLM hỗ trợ nạp LoRA). Khi cần một mô hình hoàn chỉnh — ví dụ để xuất GGUF — hãy merge trên mô hình gốc ở BF16/FP16, không merge trên bản 4-bit.

    merge.py
    import torch
    from peft import AutoPeftModelForCausalLM
    from transformers import AutoTokenizer
    
    ADAPTER = "out/qwen7b-lora/final"
    MERGED = "out/qwen7b-merged"
    
    model = AutoPeftModelForCausalLM.from_pretrained(ADAPTER, dtype=torch.bfloat16)
    merged = model.merge_and_unload()
    merged.save_pretrained(MERGED, safe_serialization=True)
    AutoTokenizer.from_pretrained(ADAPTER).save_pretrained(MERGED)
    print("saved", MERGED)
  8. Bước 8: Xuất GGUF bằng llama.cpp

    Bash
    git clone https://github.com/ggml-org/llama.cpp
    cd llama.cpp
    uv pip install -r requirements.txt
    python convert_hf_to_gguf.py ../out/qwen7b-merged --outfile ../out/qwen7b-f16.gguf --outtype f16
    
    cmake -B build -DGGML_CUDA=ON
    cmake --build build --config Release -j
    ./build/bin/llama-quantize ../out/qwen7b-f16.gguf ../out/qwen7b-Q4_K_M.gguf Q4_K_M
    ./build/bin/llama-cli -m ../out/qwen7b-Q4_K_M.gguf -p "Xin chào, bạn là ai?" -n 128 -no-cnv

    File GGUF chạy được bằng llama.cpp hoặc Ollama (xem bài triển khai suy luận). Lượng tử hoá càng thấp thì file càng nhỏ nhưng chất lượng có thể giảm — đánh giá lại bằng bộ câu hỏi ở bước 6.

  9. Bước 9: Ghi chú: Unsloth, LLaMA-Factory, Axolotl

    Công cụCách dùngHợp khi
    UnslothThư viện Python thay lớp nạp mô hình (FastLanguageModel), có notebook mẫuFine-tune nhanh, tiết kiệm VRAM trên một GPU; kiểm tra mô hình được hỗ trợ
    LLaMA-FactoryCấu hình YAML, chạy llamafactory-cli train <file.yaml>; có giao diện web llamafactory-cli webuiNhóm muốn ít code, nhiều mô hình và phương pháp có sẵn
    AxolotlCấu hình YAML, chạy axolotl train <file.yml>Pipeline huấn luyện lặp lại, nhiều GPU, cấu hình được version hoá

    Dữ liệu JSONL dạng messages ở bước 1 dùng lại được cho cả ba công cụ (có thể cần khai báo ánh xạ trường trong cấu hình).

Kiểm tra thành công

  • Thư mục out/qwen7b-lora/finaladapter_config.jsonadapter_model.safetensors.
  • eval_loss không tăng liên tục ở cuối quá trình huấn luyện.
  • Trong compare.jsonl, người chấm nghiệp vụ đánh giá bản fine-tune tốt hơn hoặc ngang bản gốc ở các câu quan trọng — và không làm hỏng các câu hỏi chung.
  • File GGUF chạy được bằng llama-cli và trả lời đúng ngôn ngữ, đúng văn phong.

Lỗi thường gặp & cách sửa

CUDA out of memory ngay khi bắt đầu huấn luyện

Nguyên nhân thường gặp: Batch, max_length hoặc mô hình quá lớn so với VRAM.

Giảm per_device_train_batch_size về 1 và tăng gradient_accumulation_steps, giảm max_length, bật gradient_checkpointing, hoặc chọn mô hình nhỏ hơn. Ước lượng lại tại /ai/tinh-cau-hinh.

Mô hình sau fine-tune trả lời lặp, không dừng hoặc sai định dạng hội thoại

Nguyên nhân thường gặp: Chat template lúc huấn luyện và lúc suy luận không khớp, thiếu token kết thúc lượt.

Dùng cùng tokenizer/chat template ở mọi bước (huấn luyện, merge, xuất GGUF, phục vụ). Kiểm tra lại bằng tokenizer.apply_chat_template(..., tokenize=False) để xem chuỗi thực tế.

Loss về gần 0 rất nhanh, mô hình trả lời như chép dữ liệu

Nguyên nhân thường gặp: Dữ liệu ít, trùng lặp nhiều, huấn luyện quá nhiều epoch.

Loại trùng, giảm epoch và learning rate, mở rộng dữ liệu đa dạng hơn, theo dõi eval_loss.

Chữ tiếng Việt bị lỗi dấu trong câu trả lời

Nguyên nhân thường gặp: Dữ liệu lẫn NFC/NFD, hoặc file đọc/ghi không phải UTF-8.

Chuẩn hoá NFC như bước 2, luôn mở file với encoding="utf-8" và ghi JSON với ensure_ascii=False.

Bước tiếp theo

Nguồn chính chủ

Lệnh, tên gói và tham số thay đổi theo phiên bản. Trước khi chạy trên máy thật, hãy kiểm tra phiên bản mới nhất tại trang chính chủ:

Giới hạn của bài

  • Bài không khẳng định mô hình nào tốt nhất cho tiếng Việt và không đưa thời gian huấn luyện — phụ thuộc dữ liệu, GPU, tham số.
  • Mô hình Qwen/Qwen2.5-7B-Instruct chỉ là ví dụ để script chạy được; giấy phép và sự phù hợp cần tự kiểm tra trên model card.
  • Bài không đề cập DPO/ORPO, fine-tune toàn phần hay huấn luyện tiếp (continued pre-training).
Cần người dựng hệ thống cùng?

Kỹ sư HQG khảo sát, lên cấu hình, lắp đặt và bàn giao hạ tầng AI chạy được thật.