Skip to content
Published on

LLM 파인튜닝 완전 가이드: LoRA, QLoRA, RLHF, DPO 마스터하기

Authors

LLM 파인튜닝 완전 가이드: LoRA, QLoRA, RLHF, DPO 마스터하기

LLaMA 3, Mistral, Gemma 같은 강력한 오픈소스 LLM이 공개된 지금, 이를 특정 도메인이나 태스크에 맞게 파인튜닝하는 기술은 AI 엔지니어의 핵심 역량이 되었습니다. 이 가이드는 Full Fine-tuning의 기초부터 LoRA, QLoRA, RLHF, DPO까지 LLM 파인튜닝의 모든 기법을 실전 코드와 함께 완전히 다룹니다.


1. 왜 파인튜닝인가?

1.1 사전학습 모델의 한계

대규모 LLM은 인터넷의 방대한 텍스트로 사전학습되어 놀라운 일반 능력을 보유합니다. 하지만 그대로 사용하면 다음과 같은 한계가 있습니다:

도메인 특화 지식 부족: GPT-4도 특정 기업의 내부 문서나 최신 의학 프로토콜을 모릅니다. 사전학습 데이터에 없는 정보는 알 수 없습니다.

지시 따르기 능력 부재: 베이스 모델은 지시를 따르도록 학습되지 않았습니다. "이 코드의 버그를 찾아라"라는 요청에 도움이 되는 답변보다 텍스트를 계속 생성할 수 있습니다.

출력 형식 제어 어려움: 특정 JSON 스키마나 마크다운 형식으로 항상 출력하도록 만들기 어렵습니다.

안전성/정렬 문제: 사전학습된 모델은 유해한 콘텐츠를 생성하거나 부적절한 응답을 할 수 있습니다.

1.2 파인튜닝의 이점

파인튜닝은 사전학습된 모델의 가중치를 특정 목적에 맞게 추가 학습하는 과정입니다:

  • 도메인 적응: 특정 분야의 전문 용어, 지식, 스타일 습득
  • 태스크 특화: 분류, 추출, 생성 등 특정 태스크 성능 극대화
  • 행동 제어: 원하는 응답 방식, 형식, 안전 기준 학습
  • 비용 효율: 작은 파인튜닝 모델이 큰 모델 API 호출을 대체

1.3 파인튜닝 vs 프롬프트 엔지니어링

프롬프트 엔지니어링은 빠르고 비용이 없지만 한계가 있습니다:

기준프롬프트 엔지니어링파인튜닝
구현 난이도낮음중~높음
비용런타임 토큰 비용학습 비용 (일회성)
성능 상한모델 능력에 제한더 높은 성능 가능
응답 일관성낮음높음
개인정보API에 데이터 전송로컬 실행 가능
지연 시간긴 프롬프트 → 느림짧은 입력 가능

1.4 파인튜닝의 종류

파인튜닝 방법은 크게 세 가지로 분류됩니다:

  1. Full Fine-tuning: 모든 파라미터 업데이트 (가장 강력, 가장 비쌈)
  2. PEFT (Parameter-Efficient Fine-Tuning): 일부 파라미터만 업데이트
    • LoRA, QLoRA, Prefix Tuning, Adapter 등
  3. RLHF/DPO: 인간 선호도 학습 (정렬/안전성)

2. Full Fine-tuning

2.1 개요

Full Fine-tuning은 모든 모델 파라미터를 새 데이터로 업데이트합니다. 이론적으로 가장 강력하지만, 실용적 한계가 많습니다.

2.2 메모리 요구사항 계산

7B 파라미터 모델을 Full FT 할 때 필요한 메모리:

  • 모델 가중치 (BF16): 7B × 2 bytes = 14 GB
  • 기울기: 14 GB (가중치와 동일)
  • 옵티마이저 상태 (AdamW): 14 GB × 2 = 28 GB (1차, 2차 모멘텀)
  • 활성화 값: 배치 크기, 시퀀스 길이에 따라 수 GB
  • 총합: 약 60 GB 이상

A100 80GB 1장으로도 7B 모델 Full FT가 빠듯합니다. 70B 모델은 여러 장의 고급 GPU가 필요합니다.

2.3 언제 Full Fine-tuning을 쓸까?

  • 도메인이 사전학습 분포와 매우 다를 때 (예: 고도로 특화된 의학/법률 언어)
  • 충분한 GPU 리소스가 있을 때
  • 최대 성능이 절대적으로 필요할 때
  • 연속 사전학습 (Continual Pretraining)

2.4 Catastrophic Forgetting

Full FT의 가장 큰 문제는 파국적 망각(Catastrophic Forgetting)입니다. 새 데이터로 학습하면 기존에 알던 지식이 손상됩니다.

완화 방법:

  • 낮은 학습률: 기존 지식 보존을 위해 1e-5 이하 사용
  • 데이터 혼합: 원본 사전학습 데이터와 새 데이터를 혼합
  • EWC (Elastic Weight Consolidation): 중요한 가중치 변화를 규제
  • PEFT 사용: 새 파라미터만 학습하면 Catastrophic Forgetting이 없음
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
import torch

# Full Fine-tuning 예시
model_name = "meta-llama/Llama-3.2-1B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

# 데이터셋 준비
dataset = load_dataset("your_dataset")

def tokenize_function(examples):
    return tokenizer(
        examples["text"],
        truncation=True,
        max_length=2048,
        padding="max_length",
    )

tokenized_dataset = dataset.map(tokenize_function, batched=True)

# 훈련 설정
training_args = TrainingArguments(
    output_dir="./full_ft_output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-5,            # 낮은 학습률
    weight_decay=0.01,
    bf16=True,
    logging_steps=100,
    save_steps=500,
    evaluation_strategy="steps",
    eval_steps=500,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
    eval_dataset=tokenized_dataset["validation"],
)

trainer.train()

3. LoRA (Low-Rank Adaptation)

3.1 핵심 아이디어

LoRA (Hu et al., 2021)는 LLM 파인튜닝의 게임 체인저입니다. 핵심 관찰은 "파인튜닝 중 가중치 변화는 사실 저랭크(low-rank) 구조를 가진다"는 것입니다.

원래 가중치 행렬 W (d×k 크기)를 직접 업데이트하는 대신, 두 개의 작은 행렬 B (d×r)와 A (r×k)의 곱으로 변화량을 표현합니다:

W' = W + ΔW = W + BA

여기서 r은 랭크(rank)이며 d와 k보다 훨씬 작게 설정됩니다.

파라미터 수 비교:

  • 원래 ΔW: d × k 파라미터
  • LoRA: B(d×r) + A(r×k) = r(d+k) 파라미터
  • d=k=4096, r=16이면: 16,777,216 vs 131,072 (128배 감소!)

3.2 수식 상세

초기화:

  • A: 가우시안 분포로 초기화 (랜덤)
  • B: 0으로 초기화 (학습 시작 시 ΔW=0 보장)

Forward pass:

h = xW^T + x(BA)^T * (alpha/r)

여기서 alpha는 스케일링 팩터입니다. alpha/r을 학습률처럼 생각할 수 있습니다.

3.3 rank r의 선택

rank r은 LoRA의 가장 중요한 하이퍼파라미터입니다:

  • r=4 또는 r=8: 가볍고 빠른 실험용. 간단한 태스크에 충분
  • r=16: 대부분 태스크에서 좋은 균형. 권장 시작점
  • r=32 또는 r=64: 복잡한 태스크, 더 많은 능력 필요 시
  • r=128 이상: Full FT에 가까운 성능이 필요할 때

일반적으로 r=16에서 시작하고, 충분하지 않으면 늘립니다.

3.4 alpha 하이퍼파라미터

alpha는 LoRA 업데이트의 스케일을 조정합니다. 실제로는 alpha/r이 스케일 팩터로 작용합니다:

  • alpha = r이면 스케일 팩터 = 1 (일반적인 선택)
  • alpha = 2r이면 스케일 팩터 = 2 (더 강한 LoRA)
  • 학습률과 독립적으로 조정 가능

3.5 어떤 레이어에 LoRA를 적용할까?

원래 논문에서는 Attention의 Q, V 행렬에만 적용했습니다. 하지만 실험에 따르면:

  • Q, K, V, O (모든 Attention 행렬): 일반적으로 좋음
  • + MLP (FFN 레이어): 더 좋은 경우 많음
  • 모든 레이어: 거의 Full FT 성능

HuggingFace PEFT의 기본값은 Q, V 행렬입니다. 복잡한 태스크에서는 모든 선형 레이어에 적용하는 것을 권장합니다.

3.6 HuggingFace PEFT로 LoRA 구현

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType, PeftModel
from trl import SFTTrainer, SFTConfig
import torch

# LoRA 설정
lora_config = LoraConfig(
    r=16,                          # LoRA rank
    lora_alpha=32,                 # alpha = 2*r
    target_modules=[               # LoRA 적용 레이어
        "q_proj",
        "k_proj",
        "v_proj",
        "o_proj",
        "gate_proj",
        "up_proj",
        "down_proj",
    ],
    lora_dropout=0.05,            # Dropout
    bias="none",                   # bias 처리 방법
    task_type=TaskType.CAUSAL_LM, # 태스크 타입
)

# 모델 로드
model_name = "meta-llama/Llama-3.2-3B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

# LoRA 모델 생성
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 출력 예시: trainable params: 41,943,040 || all params: 3,254,779,904 || trainable%: 1.29%

# 학습 가능한 파라미터 확인
for name, param in model.named_parameters():
    if param.requires_grad:
        print(f"Trainable: {name}, shape: {param.shape}")

4. QLoRA (Quantized LoRA)

4.1 QLoRA란?

QLoRA (Dettmers et al., 2023)는 LoRA를 더욱 메모리 효율적으로 만든 기법입니다. 4비트로 양자화된 베이스 모델 위에 LoRA 어댑터를 학습합니다.

핵심 기술 세 가지:

  1. 4비트 NF4 양자화: 베이스 모델 가중치를 4비트로 압축
  2. 이중 양자화 (Double Quantization): 양자화 상수도 추가 양자화
  3. Paged Optimizers: 옵티마이저 상태를 CPU RAM과 GPU 간 페이징

4.2 4비트 NF4 양자화

NF4 (NormalFloat4)는 표준 정규 분포에 최적화된 4비트 데이터 타입입니다. 일반적인 LLM 가중치가 정규 분포에 가깝다는 사실을 이용합니다.

NF4는 정보 이론적으로 최적: 각 양자화 bin이 동일한 확률 질량을 가집니다.

메모리 절약 효과:

  • FP16 → INT4: 4배 압축
  • 70B 모델: 140GB (FP16) → 35GB (4bit) → 24GB GPU 2장으로 학습 가능

4.3 이중 양자화

양자화 상수(quantization constants) 자체도 메모리를 차지합니다. Double Quantization은 이 양자화 상수를 8비트로 추가 양자화합니다. 파라미터당 약 0.37비트를 추가로 절약합니다.

4.4 Paged Optimizers

긴 시퀀스 처리 시 피크 메모리가 OOM(Out of Memory)을 일으킬 수 있습니다. Paged Optimizers는 NVIDIA 통합 메모리를 사용하여 옵티마이저 상태를 CPU RAM으로 자동 오프로드합니다.

4.5 완전한 QLoRA 학습 코드

from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
from datasets import load_dataset
import torch

# 4비트 양자화 설정
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,               # 4비트 로드
    bnb_4bit_quant_type="nf4",       # NF4 양자화 타입
    bnb_4bit_compute_dtype=torch.bfloat16,  # 계산 시 BF16 사용
    bnb_4bit_use_double_quant=True,  # 이중 양자화 활성화
)

# 모델 로드 (4비트)
model_name = "meta-llama/Llama-3.1-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

# kbit 학습을 위한 모델 준비
# (레이어 정규화를 FP32로 캐스팅, 임베딩 레이어 처리 등)
model = prepare_model_for_kbit_training(model)

# LoRA 설정
lora_config = LoraConfig(
    r=64,
    lora_alpha=16,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=0.1,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# 데이터셋 준비 - Alpaca 형식
dataset = load_dataset("tatsu-lab/alpaca", split="train")

def format_instruction(sample):
    """Alpaca 형식으로 데이터 포맷팅"""
    instruction = sample["instruction"]
    input_text = sample.get("input", "")
    output = sample["output"]

    if input_text:
        text = f"""### 지시사항:
{instruction}

### 입력:
{input_text}

### 응답:
{output}"""
    else:
        text = f"""### 지시사항:
{instruction}

### 응답:
{output}"""

    return {"text": text}

formatted_dataset = dataset.map(format_instruction)

# 학습 설정
training_args = TrainingArguments(
    output_dir="./qlora_output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    gradient_checkpointing=True,      # 메모리 절약
    optim="paged_adamw_32bit",        # Paged Optimizer!
    logging_steps=25,
    save_strategy="epoch",
    learning_rate=2e-4,
    bf16=True,
    max_grad_norm=0.3,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    report_to="wandb",
    run_name="llama-3-qlora",
)

# SFT 트레이너 (응답 부분만 손실 계산)
response_template = "### 응답:"
collator = DataCollatorForCompletionOnlyLM(
    response_template,
    tokenizer=tokenizer,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=formatted_dataset,
    data_collator=collator,
    tokenizer=tokenizer,
    max_seq_length=2048,
    dataset_text_field="text",
    packing=False,  # 시퀀스 패킹 (효율성 향상)
)

# 학습 시작
trainer.train()

# 모델 저장 (LoRA 어댑터만)
trainer.save_model("./qlora_adapter")
tokenizer.save_pretrained("./qlora_adapter")

5. 다른 PEFT 방법들

5.1 Prefix Tuning

Prefix Tuning은 각 Transformer 레이어의 K, V에 학습 가능한 "프리픽스" 토큰을 추가합니다. 모델 가중치는 전혀 변경하지 않습니다.

from peft import PrefixTuningConfig, get_peft_model

prefix_config = PrefixTuningConfig(
    task_type="CAUSAL_LM",
    num_virtual_tokens=20,        # 프리픽스 토큰 수
    prefix_projection=True,       # MLP로 프리픽스 투영
)

model = get_peft_model(base_model, prefix_config)

Prefix Tuning은 원래 seq2seq 태스크에 좋은 성능을 보였지만, LoRA보다 일반적으로 성능이 낮아 잘 쓰이지 않습니다.

5.2 Prompt Tuning

가장 간단한 PEFT 방법. 입력 임베딩 앞에 학습 가능한 소프트 프롬프트만 추가합니다.

from peft import PromptTuningConfig, PromptTuningInit

prompt_config = PromptTuningConfig(
    task_type="CAUSAL_LM",
    prompt_tuning_init=PromptTuningInit.TEXT,
    num_virtual_tokens=8,
    prompt_tuning_init_text="이것은 특화 작업입니다:",
    tokenizer_name_or_path=model_name,
)

모델이 클수록 성능이 좋아지는 특성이 있습니다. 파라미터 수가 극히 적어 매우 효율적이지만, 성능 상한이 낮습니다.

5.3 IA3 (Infused Adapter by Inhibiting and Amplifying Inner Activations)

IA3는 매우 적은 파라미터(LoRA의 1/10)로 LoRA와 유사한 성능을 달성합니다. Attention의 K, V와 FFN의 활성화 값에 학습 가능한 벡터를 곱합니다.

from peft import IA3Config

ia3_config = IA3Config(
    task_type="CAUSAL_LM",
    target_modules=["k_proj", "v_proj", "down_proj"],
    feedforward_modules=["down_proj"],
)

5.4 Adapter

Adapter는 각 Transformer 레이어 내부에 작은 보틀넥 레이어를 삽입합니다. 다운-투영(down-projection), 비선형 활성화, 업-투영(up-projection) 구조입니다. LoRA보다 먼저 제안된 방법으로, 추론 시 레이턴시가 약간 증가하는 단점이 있습니다.


6. Instruction Tuning

6.1 Instruction Tuning이란?

베이스 LLM은 텍스트를 계속 생성하도록 학습되었습니다. Instruction Tuning은 모델이 지시를 따르도록 (Instruction-following) 지도학습합니다. Stanford Alpaca (2023)가 이 방식을 대중화했습니다.

6.2 데이터셋 형식

Alpaca 형식:

{
  "instruction": "주어진 두 수의 최대공약수를 구하시오.",
  "input": "24, 36",
  "output": "24와 36의 최대공약수는 12입니다.\n\n계산 과정:\n- 24 = 2^3 × 3\n- 36 = 2^2 × 3^2\n- GCD = 2^2 × 3 = 12"
}

ChatML 형식 (OpenAI 표준):

<|im_start|>system
당신은 유능한 AI 어시스턴트입니다.<|im_end|>
<|im_start|>user
파이썬으로 피보나치 수열을 구현해줘.<|im_end|>
<|im_start|>assistant
피보나치 수열을 구현하는 방법은 여러 가지가 있습니다...

ShareGPT 형식 (대화형):

{
  "conversations": [
    { "from": "human", "value": "질문 텍스트" },
    { "from": "gpt", "value": "답변 텍스트" },
    { "from": "human", "value": "후속 질문" },
    { "from": "gpt", "value": "후속 답변" }
  ]
}

6.3 고품질 데이터의 중요성

LIMA (Less is More for Alignment, 2023) 연구는 1,000개의 고품질 예시만으로도 강력한 instruction-following 능력을 달성할 수 있음을 보여줬습니다. 데이터 양보다 질이 중요합니다.

고품질 Instruction 데이터 기준:

  • 다양성: 다양한 태스크와 도메인 커버
  • 명확성: 지시가 모호하지 않음
  • 정확성: 응답이 사실적으로 정확
  • 일관성: 동일 유형 지시에 일관된 응답 스타일
  • 응답 길이: 필요한 만큼만 (과도하게 길지 않음)

6.4 한국어 Instruction 데이터셋

한국어 파인튜닝에 활용 가능한 공개 데이터셋:

  • KoAlpaca-v1.1: 21,155개 한국어 Alpaca 형식 데이터
  • OIG-small-chip2-ko: 번역된 Instruction 데이터
  • KULLM-Polyglot-12.8B-v2 training data: 고품질 한국어 Instructions
  • Open-Platypus-Ko: 수학/과학 중심 고품질 한국어 데이터
from datasets import load_dataset

# KoAlpaca 데이터셋 로드
dataset = load_dataset("beomi/KoAlpaca-v1.1a")

def format_koalpaca(sample):
    """KoAlpaca 형식 포맷팅"""
    instruction = sample["instruction"]
    output = sample["output"]

    text = f"""### 질문:
{instruction}

### 답변:
{output}"""
    return {"text": text}

formatted = dataset.map(format_koalpaca)
print(formatted["train"][0]["text"])

7. RLHF (Reinforcement Learning from Human Feedback)

7.1 RLHF 개요

RLHF는 ChatGPT, Claude, Gemini 등 현대 AI 어시스턴트의 정렬 기법입니다. 인간의 선호도를 학습하여 모델이 더 유용하고, 안전하고, 솔직하게 응답하도록 만듭니다.

3단계 파이프라인:

  1. SFT (Supervised Fine-Tuning): 고품질 시연 데이터로 지도학습
  2. Reward Model 학습: 인간 선호도 데이터로 보상 모델 훈련
  3. RL 최적화 (PPO): 보상 신호로 정책(LLM) 최적화

7.2 1단계: Supervised Fine-Tuning (SFT)

베이스 모델을 고품질 대화 데이터로 파인튜닝합니다. 이 단계에서 모델이 지시 따르기를 기본적으로 학습합니다.

from trl import SFTTrainer, SFTConfig
from peft import LoraConfig

sft_config = SFTConfig(
    output_dir="./sft_model",
    max_seq_length=2048,
    num_train_epochs=1,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    bf16=True,
    optim="adamw_torch_fused",
    logging_steps=10,
    save_steps=100,
    warmup_ratio=0.05,
    dataset_text_field="text",
)

trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=sft_dataset,
    peft_config=lora_config,
    tokenizer=tokenizer,
)

trainer.train()
sft_model_path = "./sft_model"
trainer.save_model(sft_model_path)

7.3 2단계: Reward Model 학습

Reward Model (RM)은 두 응답 중 어느 것이 더 선호되는지 인간이 레이블링한 데이터를 학습합니다. 같은 LLM 아키텍처에 선형 헤드를 추가하여 스칼라 보상값을 예측합니다.

선호도 데이터 형식:

{
  "prompt": "기후변화에 대해 설명해줘",
  "chosen": "기후변화는 인간 활동으로 인한 온실가스 증가로...(자세하고 정확한 답변)",
  "rejected": "기후변화요? 그냥 지구가 더워지는 거예요 (간단하고 부정확한 답변)"
}
from trl import RewardTrainer, RewardConfig

reward_config = RewardConfig(
    output_dir="./reward_model",
    num_train_epochs=1,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=1e-5,
    bf16=True,
    max_length=2048,
    logging_steps=10,
    remove_unused_columns=False,
)

reward_trainer = RewardTrainer(
    model=reward_model,          # SFT 모델 기반
    args=reward_config,
    train_dataset=preference_dataset,
    tokenizer=tokenizer,
    peft_config=lora_config,
)

reward_trainer.train()

7.4 3단계: PPO로 정책 최적화

PPO (Proximal Policy Optimization)로 SFT 모델을 보상 신호로 최적화합니다.

핵심 손실 함수:

L = E[r(x,y)] - beta * KL(pi_theta || pi_ref)
  • r(x,y): Reward Model이 부여한 보상
  • KL 발산 페널티: SFT 모델(참조)에서 너무 멀리 벗어나지 못하게 제어
from trl import PPOTrainer, PPOConfig, AutoModelForCausalLMWithValueHead

ppo_config = PPOConfig(
    model_name=sft_model_path,
    learning_rate=1.41e-5,
    batch_size=128,
    mini_batch_size=4,
    gradient_accumulation_steps=1,
    optimize_cuda_cache=True,
    early_stopping=True,
    target_kl=0.1,          # KL 발산 목표
    ppo_epochs=4,
    seed=42,
    init_kl_coef=0.2,
    adap_kl_ctrl=True,
)

# 정책 모델 (학습 대상)
policy_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)

# 참조 모델 (KL 계산용, 고정)
ref_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)

ppo_trainer = PPOTrainer(
    config=ppo_config,
    model=policy_model,
    ref_model=ref_model,
    tokenizer=tokenizer,
    dataset=rl_dataset,
)

# PPO 학습 루프
for batch in ppo_trainer.dataloader:
    # 1. 정책 모델로 응답 생성
    query_tensors = batch["input_ids"]
    response_tensors = ppo_trainer.generate(
        query_tensors,
        max_new_tokens=256,
        do_sample=True,
        temperature=0.7,
    )

    # 2. Reward Model로 보상 계산
    rewards = [
        reward_model.compute_reward(q, r)
        for q, r in zip(query_tensors, response_tensors)
    ]

    # 3. PPO 업데이트
    stats = ppo_trainer.step(query_tensors, response_tensors, rewards)
    ppo_trainer.log_stats(stats, batch, rewards)

8. DPO (Direct Preference Optimization)

8.1 RLHF의 복잡성 문제

RLHF는 강력하지만 구현이 복잡합니다:

  • 별도의 Reward Model 학습 필요
  • PPO 튜닝이 까다로움 (많은 하이퍼파라미터)
  • 학습 불안정성
  • 4개의 모델을 동시에 메모리에 유지 (정책, 참조, 보상, 비평가)

8.2 DPO의 아이디어

Rafailov et al. (2023)의 DPO는 "보상 모델 없이도 선호도 데이터로 직접 최적화할 수 있다"는 것을 증명했습니다.

핵심 인사이트: RLHF의 최적 정책을 분석적으로 구하면, 보상 모델이 정책 자체의 로그 우도 비율로 표현됩니다. 이를 대입하면 선호도 데이터에 직접 적용할 수 있는 손실 함수를 얻습니다:

L_DPO = -E[log sigma(beta * (log pi(y_w|x) - log pi_ref(y_w|x)) -
                      beta * (log pi(y_l|x) - log pi_ref(y_l|x)))]

여기서:

  • y_w: 선호되는 응답 (chosen)
  • y_l: 선호되지 않는 응답 (rejected)
  • pi: 학습 중인 정책
  • pi_ref: 참조 정책 (SFT 모델, 고정)
  • beta: KL 페널티 계수

8.3 선호도 데이터 형식

preference_data = {
    "prompt": "파이썬에서 리스트 정렬하는 방법",
    "chosen": "파이썬 리스트를 정렬하는 주요 방법:\n\n1. sort() 메서드: 원본 리스트를 제자리에서 정렬\n```python\nmy_list = [3, 1, 4, 1, 5]\nmy_list.sort()  # [1, 1, 3, 4, 5]\n```\n\n2. sorted() 함수: 새 정렬된 리스트 반환...",
    "rejected": "list.sort()를 쓰면 됩니다."
}

8.4 trl로 DPO 학습

from trl import DPOTrainer, DPOConfig
from datasets import load_dataset

# DPO 설정
dpo_config = DPOConfig(
    output_dir="./dpo_model",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=5e-7,            # 매우 낮은 학습률
    bf16=True,
    beta=0.1,                      # KL 페널티 계수
    max_length=2048,
    max_prompt_length=512,
    remove_unused_columns=False,
    logging_steps=10,
    save_steps=100,
    warmup_steps=100,
    report_to="wandb",
)

# 선호도 데이터셋 로드
# 형식: {"prompt": ..., "chosen": ..., "rejected": ...}
preference_dataset = load_dataset("Anthropic/hh-rlhf", split="train")

def format_preference(sample):
    # HH-RLHF 형식 처리
    return {
        "prompt": sample["chosen"].rsplit("\nAssistant:", 1)[0] + "\nAssistant:",
        "chosen": sample["chosen"].rsplit("\nAssistant:", 1)[1].strip(),
        "rejected": sample["rejected"].rsplit("\nAssistant:", 1)[1].strip(),
    }

formatted_dataset = preference_dataset.map(format_preference)

# SFT 모델에서 DPO 시작
sft_model = AutoModelForCausalLM.from_pretrained(
    sft_model_path,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
sft_model = get_peft_model(sft_model, lora_config)

dpo_trainer = DPOTrainer(
    model=sft_model,
    ref_model=None,           # None이면 자동으로 참조 모델 생성
    args=dpo_config,
    train_dataset=formatted_dataset["train"],
    eval_dataset=formatted_dataset["test"],
    tokenizer=tokenizer,
    peft_config=lora_config,  # LoRA로 효율적 DPO
)

dpo_trainer.train()
dpo_trainer.save_model("./dpo_final")

8.5 RLHF vs DPO 비교

특성RLHF (PPO)DPO
구현 복잡도높음낮음
필요 모델 수4개2개
학습 안정성낮음높음
메모리 사용많음보통
온라인 학습가능어려움
성능일반적으로 더 좋음비슷하거나 약간 낮음
실시간 피드백가능불가능

실용적으로 대부분의 팀에서 DPO를 먼저 시도하고, 충분하지 않을 때 RLHF를 고려합니다.


9. 데이터 준비

9.1 데이터 품질 기준

데이터 품질이 학습 성능의 80%를 결정합니다. 고품질 데이터의 기준:

  1. 정확성: 사실적 오류가 없어야 함
  2. 완전성: 질문에 완전히 답하는가
  3. 명확성: 모호하지 않고 이해하기 쉬운가
  4. 형식 일관성: 모든 예시가 동일한 형식 준수
  5. 독성 없음: 유해하거나 편향된 내용 없음
  6. 중복 제거: Near-duplicate 제거

9.2 ChatML 형식 처리

def create_chatml_prompt(conversation: list) -> str:
    """다중 턴 대화를 ChatML 형식으로 변환"""
    messages = []
    for turn in conversation:
        role = turn["role"]  # system, user, assistant
        content = turn["content"]
        messages.append(f"<|im_start|>{role}\n{content}<|im_end|>")

    return "\n".join(messages) + "\n<|im_start|>assistant\n"


def tokenize_chatml_dataset(sample, tokenizer, max_length=2048):
    """응답 부분만 손실 계산하도록 레이블 마스킹"""
    full_text = sample["text"]
    tokenized = tokenizer(
        full_text,
        truncation=True,
        max_length=max_length,
        return_tensors="pt",
    )

    input_ids = tokenized["input_ids"][0]

    # 레이블: 응답 부분만, 나머지는 -100 (손실 무시)
    labels = input_ids.clone()

    # "assistant\n" 이전의 모든 토큰을 -100으로 마스킹
    assistant_token_ids = tokenizer.encode(
        "<|im_start|>assistant\n", add_special_tokens=False
    )
    assistant_start = None
    for i in range(len(input_ids) - len(assistant_token_ids)):
        if input_ids[i:i+len(assistant_token_ids)].tolist() == assistant_token_ids:
            assistant_start = i + len(assistant_token_ids)
            break

    if assistant_start is not None:
        labels[:assistant_start] = -100

    return {"input_ids": input_ids, "labels": labels}

9.3 데이터 정제 파이프라인

from datasets import Dataset
import hashlib


def dedup_dataset(dataset: Dataset, text_field: str = "text") -> Dataset:
    """Near-duplicate 제거"""
    seen_hashes = set()
    filtered_indices = []

    for i, sample in enumerate(dataset):
        text_hash = hashlib.md5(sample[text_field].encode()).hexdigest()
        if text_hash not in seen_hashes:
            seen_hashes.add(text_hash)
            filtered_indices.append(i)

    return dataset.select(filtered_indices)


def filter_quality(sample: dict) -> bool:
    """기본 품질 필터링"""
    text = sample.get("output", sample.get("text", ""))

    # 너무 짧은 응답 제거
    if len(text.split()) < 10:
        return False

    # 너무 긴 응답 제거 (이상 감지)
    if len(text.split()) > 2000:
        return False

    # URL만 있는 응답 제거
    words = text.split()
    url_count = sum(1 for w in words if w.startswith("http"))
    if url_count / len(words) > 0.3:
        return False

    return True


# 파이프라인 실행
dataset = load_dataset("your_dataset")["train"]
dataset = dataset.filter(filter_quality)
dataset = dedup_dataset(dataset)
print(f"정제 후 데이터 수: {len(dataset)}")

10. 실전 파인튜닝 파이프라인

10.1 완전한 Llama 3 QLoRA 파인튜닝 코드

#!/usr/bin/env python3
"""
Llama 3 QLoRA Fine-tuning Pipeline
"""

import os
import torch
import wandb
from datasets import load_dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    EarlyStoppingCallback,
)
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model
from trl import SFTTrainer, SFTConfig, DataCollatorForCompletionOnlyLM


# ==============================
# 설정
# ==============================
MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct"
OUTPUT_DIR = "./llama3-qlora-output"
DATASET_NAME = "iamtarun/python_code_instructions_18k_alpaca"
MAX_SEQ_LENGTH = 2048

# LoRA 설정
LORA_R = 64
LORA_ALPHA = 16
LORA_DROPOUT = 0.05

# 학습 설정
BATCH_SIZE = 4
GRAD_ACCUM = 4
LEARNING_RATE = 2e-4
NUM_EPOCHS = 3

# ==============================
# wandb 초기화
# ==============================
wandb.init(
    project="llm-finetuning",
    name=f"llama3-qlora-{DATASET_NAME.split('/')[-1]}",
    config={
        "model": MODEL_NAME,
        "lora_r": LORA_R,
        "lora_alpha": LORA_ALPHA,
        "lr": LEARNING_RATE,
        "epochs": NUM_EPOCHS,
    },
)

# ==============================
# 4비트 양자화 설정
# ==============================
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# ==============================
# 모델 & 토크나이저 로드
# ==============================
print(f"Loading model: {MODEL_NAME}")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
)
model.config.use_cache = False
model.config.pretraining_tp = 1

model = prepare_model_for_kbit_training(model)

# ==============================
# LoRA 설정
# ==============================
lora_config = LoraConfig(
    r=LORA_R,
    lora_alpha=LORA_ALPHA,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=LORA_DROPOUT,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# ==============================
# 데이터셋 로드 & 처리
# ==============================
dataset = load_dataset(DATASET_NAME, split="train")
dataset = dataset.train_test_split(test_size=0.05, seed=42)

def format_alpaca_prompt(sample):
    """Python code 데이터셋 포맷팅"""
    return {
        "text": f"""### Instruction:
{sample['instruction']}

### Input:
{sample.get('input', '')}

### Response:
{sample['output']}"""
    }

train_dataset = dataset["train"].map(format_alpaca_prompt)
eval_dataset = dataset["test"].map(format_alpaca_prompt)

# ==============================
# 학습 설정
# ==============================
training_config = SFTConfig(
    output_dir=OUTPUT_DIR,
    num_train_epochs=NUM_EPOCHS,
    per_device_train_batch_size=BATCH_SIZE,
    per_device_eval_batch_size=BATCH_SIZE,
    gradient_accumulation_steps=GRAD_ACCUM,
    gradient_checkpointing=True,
    optim="paged_adamw_32bit",
    learning_rate=LEARNING_RATE,
    bf16=True,
    max_grad_norm=0.3,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    logging_steps=25,
    save_strategy="epoch",
    evaluation_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    report_to="wandb",
    max_seq_length=MAX_SEQ_LENGTH,
    dataset_text_field="text",
    packing=False,
    group_by_length=True,  # 비슷한 길이끼리 배치 → 효율 향상
)

# 응답 부분만 손실 계산
response_template = "### Response:"
collator = DataCollatorForCompletionOnlyLM(
    response_template, tokenizer=tokenizer
)

trainer = SFTTrainer(
    model=model,
    args=training_config,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    data_collator=collator,
    tokenizer=tokenizer,
    callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
)

# ==============================
# 학습 실행
# ==============================
print("Starting training...")
trainer.train()

# ==============================
# 모델 저장
# ==============================
print("Saving model...")
trainer.save_model(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
wandb.finish()
print(f"Training complete! Model saved to {OUTPUT_DIR}")

10.2 모델 병합 (LoRA 어댑터 병합)

배포를 위해 LoRA 어댑터를 베이스 모델에 병합합니다:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# 베이스 모델과 어댑터 로드
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.bfloat16,
    device_map="cpu",  # CPU에서 병합 (메모리 절약)
)

peft_model = PeftModel.from_pretrained(base_model, OUTPUT_DIR)

# 병합
print("Merging LoRA weights into base model...")
merged_model = peft_model.merge_and_unload()

# 저장
MERGED_DIR = "./llama3-merged"
merged_model.save_pretrained(MERGED_DIR, safe_serialization=True)
tokenizer = AutoTokenizer.from_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(MERGED_DIR)

print(f"Merged model saved to {MERGED_DIR}")

10.3 Ollama로 배포

# Ollama Modelfile 생성
cat > Modelfile << 'EOF'
FROM ./llama3-merged

TEMPLATE """### Instruction:
{{ .Prompt }}

### Response:
"""

PARAMETER stop "### Instruction:"
PARAMETER temperature 0.7
PARAMETER top_p 0.9
EOF

# Ollama 모델 생성
ollama create my-llama3 -f Modelfile

# 테스트
ollama run my-llama3 "파이썬으로 퀵소트를 구현해줘"

10.4 vLLM으로 배포

from vllm import LLM, SamplingParams

# 병합된 모델 로드
llm = LLM(
    model="./llama3-merged",
    dtype="bfloat16",
    max_model_len=4096,
    tensor_parallel_size=1,  # GPU 수
)

sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

# 배치 추론
prompts = [
    "### Instruction:\n파이썬으로 피보나치 수열을 구현해줘\n\n### Response:\n",
    "### Instruction:\nSQL 인젝션 공격에 대해 설명해줘\n\n### Response:\n",
]

outputs = llm.generate(prompts, sampling_params)
for output in outputs:
    print(output.outputs[0].text)
    print("---")

11. 평가 (Evaluation)

11.1 Perplexity

언어 모델의 기본 평가 지표. 낮을수록 좋습니다.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
import math

def compute_perplexity(model, tokenizer, dataset, max_length=1024, stride=512):
    """슬라이딩 윈도우 방식의 Perplexity 계산"""
    encodings = tokenizer(
        "\n\n".join(dataset["text"][:100]),
        return_tensors="pt"
    )

    seq_len = encodings.input_ids.size(1)
    nlls = []

    for begin_loc in range(0, seq_len, stride):
        end_loc = min(begin_loc + max_length, seq_len)
        trg_len = end_loc - begin_loc - (stride if begin_loc > 0 else 0)

        input_ids = encodings.input_ids[:, begin_loc:end_loc].to(model.device)
        target_ids = input_ids.clone()
        target_ids[:, :-trg_len] = -100

        with torch.no_grad():
            outputs = model(input_ids, labels=target_ids)
            neg_log_likelihood = outputs.loss * trg_len

        nlls.append(neg_log_likelihood)

        if end_loc == seq_len:
            break

    ppl = torch.exp(torch.stack(nlls).sum() / end_loc)
    return ppl.item()

11.2 ROUGE (요약 평가)

from rouge_score import rouge_scorer

def evaluate_rouge(predictions: list, references: list) -> dict:
    scorer = rouge_scorer.RougeScorer(
        ['rouge1', 'rouge2', 'rougeL'], use_stemmer=True
    )
    scores = {"rouge1": [], "rouge2": [], "rougeL": []}

    for pred, ref in zip(predictions, references):
        score = scorer.score(ref, pred)
        scores["rouge1"].append(score["rouge1"].fmeasure)
        scores["rouge2"].append(score["rouge2"].fmeasure)
        scores["rougeL"].append(score["rougeL"].fmeasure)

    return {k: sum(v)/len(v) for k, v in scores.items()}

11.3 LM-Eval Harness

OpenLM Research의 공개 평가 프레임워크로 다양한 벤치마크를 자동 평가합니다:

# 설치
pip install lm-eval

# 평가 실행
lm_eval --model hf \
    --model_args pretrained=./llama3-merged,dtype=bfloat16 \
    --tasks hellaswag,arc_easy,arc_challenge,winogrande \
    --num_fewshot 0 \
    --batch_size 8 \
    --output_path ./eval_results

11.4 MT-Bench (대화 품질 평가)

GPT-4를 심사위원으로 사용하여 모델의 다중 턴 대화 품질을 1-10점으로 평가합니다:

# FastChat 설치
pip install fschat

# MT-Bench 평가
python -m fastchat.llm_judge.gen_model_answer \
    --model-path ./llama3-merged \
    --model-id llama3-finetuned \
    --bench-name mt_bench

python -m fastchat.llm_judge.gen_judgment \
    --model-list llama3-finetuned \
    --judge-model gpt-4

12. 마치며

LLM 파인튜닝 기법의 발전은 놀랍습니다. 불과 몇 년 전만 해도 수백억 파라미터 모델의 파인튜닝은 초대형 GPU 클러스터에서만 가능했지만, 이제는 소비자용 GPU 한 장으로도 가능합니다.

이 가이드에서 다룬 핵심 기법 정리:

  1. Full Fine-tuning: 최대 성능, 높은 리소스
  2. LoRA: 저랭크 행렬 분해로 99%+ 파라미터 절약
  3. QLoRA: 4비트 양자화 + LoRA, 단일 GPU로 7B~70B 학습
  4. Instruction Tuning: 지시 따르기 능력 학습
  5. RLHF: 인간 선호도로 정렬, 3단계 파이프라인
  6. DPO: 보상 모델 없이 선호도 직접 최적화

실용적인 권장사항:

  • 처음엔 QLoRA + DPO 조합이 가장 실용적
  • 데이터 품질에 가장 많은 시간을 투자할 것
  • 1,000개 고품질 예시 > 10,000개 저품질 예시
  • Wandb로 모든 실험을 추적할 것
  • Perplexity 하락이 반드시 품질 향상은 아님 (MT-Bench 등으로 실제 품질 검증)

참고 자료