SiamCafe.net Blog
Technology

LLM Fine-tuning LoRA Clean Architecture — Fine-tune โมเดลภาษาด้วย QLoRA อย่างมีระบบ

llm fine tuning lora clean architecture
LLM Fine-tuning LoRA Clean Architecture | SiamCafe Blog
2026-03-01· อ. บอม — SiamCafe.net· 1,575 คำ

LoRA คืออะไรและทำไมถึงปฏิวัติการ Fine-tune LLM

LoRA (Low-Rank Adaptation) เป็นเทคนิค Parameter-Efficient Fine-Tuning (PEFT) ที่ช่วยให้ fine-tune Large Language Models ได้โดยใช้ทรัพยากรน้อยลงอย่างมาก แทนที่จะ update weight ทั้งหมดของ model LoRA จะ freeze weights เดิมไว้และเพิ่ม trainable low-rank matrices เข้าไปในแต่ละ layer

หลักการทำงานของ LoRA คือ สำหรับ weight matrix W ขนาด d x k แทนที่จะ update W โดยตรง LoRA จะเพิ่ม delta W = B x A โดยที่ B มีขนาด d x r และ A มีขนาด r x k เมื่อ r (rank) มีค่าน้อยมากเช่น 8 หรือ 16 จำนวน trainable parameters จะลดลงอย่างมหาศาล

ตัวอย่างเช่น Llama 2 7B มี parameters 7 พันล้านตัว full fine-tuning ต้องใช้ GPU RAM มากกว่า 60GB แต่ LoRA ด้วย rank 16 มี trainable parameters เพียง 4-8 ล้านตัว (ประมาณ 0.1% ของ model) ใช้ GPU RAM แค่ 16-24GB ทำให้ fine-tune ได้บน consumer GPU เช่น RTX 4090

QLoRA (Quantized LoRA) เป็นการรวม LoRA กับ 4-bit quantization ทำให้ใช้ memory น้อยลงอีก สามารถ fine-tune model 7B ด้วย GPU RAM เพียง 6-8GB หรือ fine-tune model 70B ด้วย GPU RAM 48GB ซึ่งเป็นไปไม่ได้เลยกับ full fine-tuning

สถาปัตยกรรม LoRA และ QLoRA เชิงลึก

รายละเอียดทางเทคนิคของ LoRA architecture

# LoRA Architecture Deep Dive
#
# === Original Weight Update ===
# Full fine-tuning: W_new = W + delta_W
# delta_W มีขนาด d x k (เท่ากับ W)
# Parameters: d * k = ล้านๆ parameters
#
# === LoRA Decomposition ===
# LoRA: W_new = W + B * A
# W: d x k (frozen, ไม่ update)
# B: d x r (trainable)
# A: r x k (trainable)
# r << min(d, k) เช่น r = 8, 16, 32, 64
#
# Parameters: r * (d + k) = น้อยมาก
# ตัวอย่าง: d=4096, k=4096, r=16
#   Full: 4096 * 4096 = 16,777,216
#   LoRA: 16 * (4096 + 4096) = 131,072 (0.78%)
#
# === LoRA Hyperparameters ===
# r (rank): ขนาดของ low-rank matrices
#   r=8:  น้อยสุด, เร็วสุด, อาจไม่พอสำหรับ task ซับซ้อน
#   r=16: balance ดี, เหมาะกับงานส่วนใหญ่
#   r=32: quality สูงขึ้น, ใช้ memory มากขึ้น
#   r=64: ใกล้เคียง full fine-tuning
#
# alpha: scaling factor
#   alpha = r: scaling = 1 (ค่าเริ่มต้น)
#   alpha = 2*r: scaling = 2 (LoRA มีผลมากขึ้น)
#   สูตร: scaling = alpha / r
#
# target_modules: layers ที่จะใส่ LoRA
#   Attention layers: q_proj, k_proj, v_proj, o_proj
#   MLP layers: gate_proj, up_proj, down_proj
#   แนะนำเริ่มจาก attention layers ก่อน
#
# dropout: LoRA dropout
#   0.05-0.1: ป้องกัน overfitting
#
# === QLoRA Specifics ===
# 4-bit NormalFloat (NF4) quantization
# Double quantization สำหรับ quantization constants
# Paged optimizers สำหรับ memory management
#
# Memory comparison (Llama 2 7B):
#   Full FP16:    ~28 GB
#   LoRA FP16:    ~16 GB
#   QLoRA 4-bit:  ~6 GB
#
# === LoRA Variants ===
# DoRA: Weight-Decomposed Low-Rank Adaptation
#   แยก magnitude กับ direction ของ weight
#   ผลลัพธ์ดีกว่า LoRA เล็กน้อย
#
# AdaLoRA: Adaptive LoRA
#   ปรับ rank ของแต่ละ layer อัตโนมัติ
#   layers ที่สำคัญได้ rank สูงกว่า
#
# LoRA+: ใช้ learning rate ต่างกันสำหรับ A และ B
#   B ใช้ lr สูงกว่า A ประมาณ 2-4 เท่า

Fine-tune LLM ด้วย LoRA และ Hugging Face PEFT

โค้ดสำหรับ fine-tune Llama model ด้วย QLoRA

#!/usr/bin/env python3
# finetune_lora.py — Fine-tune LLM with QLoRA
import torch
from transformers import (
    AutoModelForCausalLM, AutoTokenizer,
    TrainingArguments, BitsAndBytesConfig
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset

# === Configuration ===
MODEL_NAME = "meta-llama/Llama-2-7b-hf"
DATASET_NAME = "timdettmers/openassistant-guanaco"
OUTPUT_DIR = "./lora-llama2-7b"

# 4-bit quantization config
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# Load model with quantization
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

# Load tokenizer
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

# LoRA configuration
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622

# Load dataset
dataset = load_dataset(DATASET_NAME, split="train")

# Training arguments
training_args = TrainingArguments(
    output_dir=OUTPUT_DIR,
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    weight_decay=0.001,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    logging_steps=10,
    save_strategy="epoch",
    fp16=False,
    bf16=True,
    max_grad_norm=0.3,
    optim="paged_adamw_32bit",
    group_by_length=True,
    report_to="tensorboard",
)

# Initialize trainer
trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    tokenizer=tokenizer,
    args=training_args,
    dataset_text_field="text",
    max_seq_length=1024,
    packing=True,
)

# Train
trainer.train()

# Save LoRA adapter (only ~20MB)
trainer.model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
print(f"LoRA adapter saved to {OUTPUT_DIR}")

จัดโครงสร้างโปรเจกต์แบบ Clean Architecture

โครงสร้างโปรเจกต์ที่ดีสำหรับ LLM fine-tuning

# Clean Architecture สำหรับ LLM Fine-tuning Project
#
# llm-finetune/
# ├── configs/
# │   ├── base.yaml           # base configuration
# │   ├── lora_llama7b.yaml   # model-specific config
# │   └── lora_mistral7b.yaml
# ├── src/
# │   ├── __init__.py
# │   ├── data/
# │   │   ├── __init__.py
# │   │   ├── dataset.py       # dataset loading and processing
# │   │   ├── formatter.py     # prompt formatting
# │   │   └── collator.py      # data collation
# │   ├── model/
# │   │   ├── __init__.py
# │   │   ├── loader.py        # model loading with quantization
# │   │   ├── lora_setup.py    # LoRA configuration
# │   │   └── merging.py       # merge LoRA weights
# │   ├── training/
# │   │   ├── __init__.py
# │   │   ├── trainer.py       # custom trainer
# │   │   ├── callbacks.py     # training callbacks
# │   │   └── metrics.py       # evaluation metrics
# │   ├── inference/
# │   │   ├── __init__.py
# │   │   ├── generate.py      # text generation
# │   │   └── server.py        # API server
# │   └── utils/
# │       ├── __init__.py
# │       ├── config.py        # configuration management
# │       └── logging.py       # logging setup
# ├── scripts/
# │   ├── train.py             # training entrypoint
# │   ├── evaluate.py          # evaluation script
# │   ├── merge_lora.py        # merge LoRA to base model
# │   └── serve.py             # start inference server
# ├── tests/
# │   ├── test_data.py
# │   ├── test_model.py
# │   └── test_inference.py
# ├── Dockerfile
# ├── requirements.txt
# └── README.md

# === configs/lora_llama7b.yaml ===
# model:
#   name: meta-llama/Llama-2-7b-hf
#   quantization:
#     enabled: true
#     bits: 4
#     quant_type: nf4
#     double_quant: true
#     compute_dtype: bfloat16
#
# lora:
#   r: 16
#   alpha: 32
#   dropout: 0.05
#   target_modules:
#     - q_proj
#     - k_proj
#     - v_proj
#     - o_proj
#     - gate_proj
#     - up_proj
#     - down_proj
#   bias: none
#
# training:
#   epochs: 3
#   batch_size: 4
#   gradient_accumulation: 4
#   learning_rate: 2e-4
#   weight_decay: 0.001
#   warmup_ratio: 0.03
#   scheduler: cosine
#   max_seq_length: 2048
#   optimizer: paged_adamw_32bit
#   bf16: true
#   max_grad_norm: 0.3
#
# data:
#   dataset: timdettmers/openassistant-guanaco
#   prompt_template: alpaca
#   packing: true

# === src/model/loader.py ===
import yaml
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

class ModelLoader:
    def __init__(self, config_path):
        with open(config_path) as f:
            self.config = yaml.safe_load(f)
    
    def load_model(self):
        model_cfg = self.config["model"]
        
        bnb_config = None
        if model_cfg["quantization"]["enabled"]:
            q = model_cfg["quantization"]
            bnb_config = BitsAndBytesConfig(
                load_in_4bit=(q["bits"] == 4),
                bnb_4bit_quant_type=q["quant_type"],
                bnb_4bit_compute_dtype=getattr(torch, q["compute_dtype"]),
                bnb_4bit_use_double_quant=q["double_quant"],
            )
        
        model = AutoModelForCausalLM.from_pretrained(
            model_cfg["name"],
            quantization_config=bnb_config,
            device_map="auto",
            trust_remote_code=True,
        )
        
        tokenizer = AutoTokenizer.from_pretrained(model_cfg["name"])
        tokenizer.pad_token = tokenizer.eos_token
        
        return model, tokenizer

Training Pipeline และ Hyperparameter Tuning

สร้าง training pipeline ที่รองรับ experiment tracking

#!/usr/bin/env python3
# src/training/trainer.py — Custom Training Pipeline
import os
import yaml
import torch
from transformers import TrainingArguments, EarlyStoppingCallback
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
import wandb

class LLMTrainer:
    def __init__(self, config_path):
        with open(config_path) as f:
            self.config = yaml.safe_load(f)
        
        self.model = None
        self.tokenizer = None
        self.trainer = None
    
    def setup_model(self, model, tokenizer):
        model = prepare_model_for_kbit_training(model)
        
        lora_cfg = self.config["lora"]
        peft_config = LoraConfig(
            r=lora_cfg["r"],
            lora_alpha=lora_cfg["alpha"],
            target_modules=lora_cfg["target_modules"],
            lora_dropout=lora_cfg["dropout"],
            bias=lora_cfg["bias"],
            task_type="CAUSAL_LM",
        )
        
        self.model = get_peft_model(model, peft_config)
        self.tokenizer = tokenizer
        
        trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
        total = sum(p.numel() for p in self.model.parameters())
        print(f"Trainable: {trainable:,} / {total:,} ({100*trainable/total:.4f}%)")
    
    def load_data(self):
        data_cfg = self.config["data"]
        dataset = load_dataset(data_cfg["dataset"])
        
        if "train" in dataset:
            train_data = dataset["train"]
        else:
            train_data = dataset
        
        if "validation" in dataset:
            eval_data = dataset["validation"]
        else:
            split = train_data.train_test_split(test_size=0.05, seed=42)
            train_data = split["train"]
            eval_data = split["test"]
        
        return train_data, eval_data
    
    def train(self):
        train_data, eval_data = self.load_data()
        t_cfg = self.config["training"]
        
        output_dir = f"./outputs/{self.config['model']['name'].split('/')[-1]}-lora-r{self.config['lora']['r']}"
        
        training_args = TrainingArguments(
            output_dir=output_dir,
            num_train_epochs=t_cfg["epochs"],
            per_device_train_batch_size=t_cfg["batch_size"],
            gradient_accumulation_steps=t_cfg["gradient_accumulation"],
            learning_rate=t_cfg["learning_rate"],
            weight_decay=t_cfg["weight_decay"],
            warmup_ratio=t_cfg["warmup_ratio"],
            lr_scheduler_type=t_cfg["scheduler"],
            logging_steps=10,
            save_strategy="steps",
            save_steps=200,
            eval_strategy="steps",
            eval_steps=200,
            load_best_model_at_end=True,
            metric_for_best_model="eval_loss",
            bf16=t_cfg.get("bf16", True),
            max_grad_norm=t_cfg["max_grad_norm"],
            optim=t_cfg["optimizer"],
            group_by_length=True,
            report_to="wandb",
        )
        
        self.trainer = SFTTrainer(
            model=self.model,
            train_dataset=train_data,
            eval_dataset=eval_data,
            tokenizer=self.tokenizer,
            args=training_args,
            dataset_text_field="text",
            max_seq_length=t_cfg["max_seq_length"],
            packing=self.config["data"].get("packing", True),
            callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
        )
        
        self.trainer.train()
        self.trainer.model.save_pretrained(output_dir)
        self.tokenizer.save_pretrained(output_dir)
        print(f"Training complete. Model saved to {output_dir}")
        return output_dir

# === scripts/train.py ===
# if __name__ == "__main__":
#     import sys
#     config = sys.argv[1] if len(sys.argv) > 1 else "configs/lora_llama7b.yaml"
#     
#     from src.model.loader import ModelLoader
#     loader = ModelLoader(config)
#     model, tokenizer = loader.load_model()
#     
#     trainer = LLMTrainer(config)
#     trainer.setup_model(model, tokenizer)
#     output = trainer.train()

# === Merge LoRA to base model ===
# scripts/merge_lora.py
from peft import PeftModel

def merge_lora(base_model_name, lora_path, output_path):
    model = AutoModelForCausalLM.from_pretrained(
        base_model_name, torch_dtype=torch.float16, device_map="auto"
    )
    model = PeftModel.from_pretrained(model, lora_path)
    model = model.merge_and_unload()
    model.save_pretrained(output_path)
    print(f"Merged model saved to {output_path}")

Deploy และ Serve LoRA Model

Deploy LoRA model สำหรับ inference

#!/usr/bin/env python3
# src/inference/server.py — FastAPI Inference Server
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
import uvicorn

app = FastAPI(title="LLM LoRA Inference API")

class GenerateRequest(BaseModel):
    prompt: str
    max_new_tokens: int = 256
    temperature: float = 0.7
    top_p: float = 0.9
    top_k: int = 50
    repetition_penalty: float = 1.1

class GenerateResponse(BaseModel):
    generated_text: str
    tokens_generated: int

# Load model at startup
MODEL = None
TOKENIZER = None

@app.on_event("startup")
async def load_model():
    global MODEL, TOKENIZER
    
    base_model = "meta-llama/Llama-2-7b-hf"
    lora_path = "./outputs/Llama-2-7b-hf-lora-r16"
    
    TOKENIZER = AutoTokenizer.from_pretrained(base_model)
    
    MODEL = AutoModelForCausalLM.from_pretrained(
        base_model, torch_dtype=torch.float16, device_map="auto"
    )
    MODEL = PeftModel.from_pretrained(MODEL, lora_path)
    MODEL.eval()
    print("Model loaded successfully")

@app.post("/generate", response_model=GenerateResponse)
async def generate(req: GenerateRequest):
    if MODEL is None:
        raise HTTPException(500, "Model not loaded")
    
    inputs = TOKENIZER(req.prompt, return_tensors="pt").to(MODEL.device)
    
    with torch.no_grad():
        outputs = MODEL.generate(
            **inputs,
            max_new_tokens=req.max_new_tokens,
            temperature=req.temperature,
            top_p=req.top_p,
            top_k=req.top_k,
            repetition_penalty=req.repetition_penalty,
            do_sample=True,
        )
    
    generated = TOKENIZER.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
    
    return GenerateResponse(
        generated_text=generated,
        tokens_generated=outputs.shape[1] - inputs["input_ids"].shape[1]
    )

@app.get("/health")
async def health():
    return {"status": "ok", "model_loaded": MODEL is not None}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

# === Dockerfile ===
# FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
# RUN apt-get update && apt-get install -y python3 python3-pip
# WORKDIR /app
# COPY requirements.txt .
# RUN pip3 install -r requirements.txt
# COPY . .
# CMD ["python3", "scripts/serve.py"]

# === requirements.txt ===
# torch>=2.1.0
# transformers>=4.36.0
# peft>=0.7.0
# trl>=0.7.0
# bitsandbytes>=0.41.0
# datasets>=2.14.0
# accelerate>=0.25.0
# fastapi>=0.104.0
# uvicorn>=0.24.0
# wandb>=0.16.0
# pyyaml>=6.0

FAQ คำถามที่พบบ่อย

Q: LoRA rank เท่าไหร่ดีที่สุด?

A: ขึ้นอยู่กับ task complexity สำหรับ task ง่ายเช่น sentiment classification rank 8 เพียงพอ สำหรับ task ปานกลางเช่น instruction following rank 16 เหมาะสม สำหรับ task ซับซ้อนเช่น code generation หรือ domain-specific knowledge rank 32-64 อาจจำเป็น แนะนำเริ่มจาก rank 16 แล้วทดลองเพิ่มลดดูผลลัพธ์

Q: QLoRA กับ LoRA ผลลัพธ์ต่างกันมากไหม?

A: งานวิจัยของ Dettmers et al. แสดงว่า QLoRA ให้ผลลัพธ์ใกล้เคียง full 16-bit fine-tuning มากโดยใช้ memory น้อยกว่า 4 เท่า quality loss จาก quantization น้อยมาก (ต่ำกว่า 1% ในงานส่วนใหญ่) สำหรับ GPU RAM จำกัดแนะนำใช้ QLoRA เสมอ

Q: ข้อมูลสำหรับ fine-tune ต้องมีเท่าไหร่?

A: LoRA ทำงานได้ดีกับข้อมูลน้อยกว่า full fine-tuning สำหรับ instruction tuning ข้อมูล 1,000-10,000 ตัวอย่างเพียงพอ สำหรับ domain adaptation ข้อมูล 10,000-100,000 ตัวอย่างให้ผลดี คุณภาพสำคัญกว่าปริมาณ ข้อมูลที่ curated มาอย่างดี 1,000 ตัวอย่างอาจดีกว่าข้อมูลคุณภาพต่ำ 100,000 ตัวอย่าง

Q: สามารถรวม LoRA หลายตัวเข้าด้วยกันได้ไหม?

A: ได้ สามารถ stack LoRA adapters หลายตัวได้ เช่นมี base LoRA สำหรับ instruction following แล้วเพิ่ม domain LoRA สำหรับ medical knowledge นอกจากนี้ยังสามารถ merge LoRA กลับเข้า base model แล้ว fine-tune LoRA ใหม่อีกรอบได้ ทำให้สร้าง specialized models ได้หลากหลายจาก base model เดียว

📖 บทความที่เกี่ยวข้อง

LLM Fine-tuning LoRA Domain Driven Design DDDอ่านบทความ → LLM Fine-tuning LoRA Real-time Processingอ่านบทความ → LLM Fine-tuning LoRA API Integration เชื่อมต่อระบบอ่านบทความ → LLM Fine-tuning LoRA GitOps Workflowอ่านบทความ → LLM Fine-tuning LoRA Production Setup Guideอ่านบทความ →

📚 ดูบทความทั้งหมด →