- Authors

- Name
- Youngju Kim
- @fjvbn20031
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 파인튜닝의 종류
파인튜닝 방법은 크게 세 가지로 분류됩니다:
- Full Fine-tuning: 모든 파라미터 업데이트 (가장 강력, 가장 비쌈)
- PEFT (Parameter-Efficient Fine-Tuning): 일부 파라미터만 업데이트
- LoRA, QLoRA, Prefix Tuning, Adapter 등
- 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 어댑터를 학습합니다.
핵심 기술 세 가지:
- 4비트 NF4 양자화: 베이스 모델 가중치를 4비트로 압축
- 이중 양자화 (Double Quantization): 양자화 상수도 추가 양자화
- 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단계 파이프라인:
- SFT (Supervised Fine-Tuning): 고품질 시연 데이터로 지도학습
- Reward Model 학습: 인간 선호도 데이터로 보상 모델 훈련
- 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%를 결정합니다. 고품질 데이터의 기준:
- 정확성: 사실적 오류가 없어야 함
- 완전성: 질문에 완전히 답하는가
- 명확성: 모호하지 않고 이해하기 쉬운가
- 형식 일관성: 모든 예시가 동일한 형식 준수
- 독성 없음: 유해하거나 편향된 내용 없음
- 중복 제거: 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 한 장으로도 가능합니다.
이 가이드에서 다룬 핵심 기법 정리:
- Full Fine-tuning: 최대 성능, 높은 리소스
- LoRA: 저랭크 행렬 분해로 99%+ 파라미터 절약
- QLoRA: 4비트 양자화 + LoRA, 단일 GPU로 7B~70B 학습
- Instruction Tuning: 지시 따르기 능력 학습
- RLHF: 인간 선호도로 정렬, 3단계 파이프라인
- DPO: 보상 모델 없이 선호도 직접 최적화
실용적인 권장사항:
- 처음엔 QLoRA + DPO 조합이 가장 실용적
- 데이터 품질에 가장 많은 시간을 투자할 것
- 1,000개 고품질 예시 > 10,000개 저품질 예시
- Wandb로 모든 실험을 추적할 것
- Perplexity 하락이 반드시 품질 향상은 아님 (MT-Bench 등으로 실제 품질 검증)
참고 자료
- Hu et al. (2021). "LoRA: Low-Rank Adaptation of Large Language Models." — https://arxiv.org/abs/2106.09685
- Dettmers et al. (2023). "QLoRA: Efficient Finetuning of Quantized LLMs." — https://arxiv.org/abs/2305.14314
- Rafailov et al. (2023). "Direct Preference Optimization." — https://arxiv.org/abs/2305.18290
- Ouyang et al. (2022). "Training language models to follow instructions with human feedback." — https://arxiv.org/abs/2203.02155
- Zhou et al. (2023). "LIMA: Less Is More for Alignment." — https://arxiv.org/abs/2305.11206
- HuggingFace PEFT — https://huggingface.co/docs/peft/
- HuggingFace TRL — https://huggingface.co/docs/trl/
- PEFT GitHub — https://github.com/huggingface/peft
- LM Evaluation Harness — https://github.com/EleutherAI/lm-evaluation-harness