LLM 파인튜닝 완전 가이드: SFT, RLHF, DPO 선택과 구현

AI 기술

LLM 파인튜닝SFTDPORLHFHuggingFace TRL

이 글은 누구를 위한 것인가

  • 프롬프트 엔지니어링만으로 안 되는 도메인 특화 태스크가 있는 팀
  • SFT, RLHF, DPO 중 무엇을 써야 할지 모르는 ML 엔지니어
  • 파인튜닝 데이터를 어떻게 만들어야 하는지 모르는 팀

들어가며

"법률 계약서 분석을 GPT-4로 했더니 틀린 조항을 찾는다." 이 문제는 프롬프트로 해결이 안 된다. 도메인 데이터로 파인튜닝이 필요하다. 하지만 어떤 방법을 써야 할까?

이 글은 bluefoxdev.kr의 LLM 파인튜닝 전략 을 참고하여 작성했습니다.


1. 파인튜닝 방법 비교

[파인튜닝 방법 선택 가이드]

SFT (Supervised Fine-tuning):
  데이터: (질문, 이상적인 답변) 쌍 1,000-10,000개
  용도: 특정 형식/도메인 학습, 지시 따르기 개선
  장점: 간단, 예측 가능
  단점: 데이터 품질에 민감
  예시: 법률 문서 요약, 코드 리뷰 형식 통일

RLHF (RL from Human Feedback):
  데이터: 비교 데이터 (A가 B보다 좋다)
  용도: 사람이 선호하는 방식으로 응답 조정
  장점: 미묘한 선호도 학습
  단점: 복잡한 파이프라인, 보상 모델 필요
  예시: GPT-4 방식의 대화 스타일

DPO (Direct Preference Optimization):
  데이터: (질문, 선호 답변, 비선호 답변) 3쌍
  용도: RLHF를 단순화, 선호도 학습
  장점: RLHF보다 간단, SFT보다 품질 높음
  단점: 선호 데이터 수집 필요
  예시: 마케팅 카피 스타일, 브랜드 톤앤매너

[파인튜닝 vs 프롬프트]
  파인튜닝 필요:
    - 도메인 지식 (의료, 법률, 금융 전문 용어)
    - 특정 출력 형식 강제
    - 비용 절감 (작은 모델 파인튜닝)
  프롬프트로 충분:
    - 일반적 태스크
    - 빠른 실험
    - 데이터가 적을 때

2. DPO 데이터 준비 및 학습

import json
from dataclasses import dataclass
from pathlib import Path
import anthropic

client = anthropic.Anthropic()

@dataclass
class DPOSample:
    prompt: str
    chosen: str    # 선호하는 응답
    rejected: str  # 선호하지 않는 응답

async def generate_dpo_data_from_feedback(
    user_sessions: list[dict],
) -> list[DPOSample]:
    """사용자 피드백에서 DPO 데이터 자동 생성"""
    
    dpo_samples = []
    
    for session in user_sessions:
        if session.get("action") == "edited":
            # 사용자가 AI 응답을 수정 → 선호 쌍 생성
            dpo_samples.append(DPOSample(
                prompt=session["query"],
                chosen=session["edited_response"],   # 수정된 버전
                rejected=session["original_response"],  # 원래 AI 응답
            ))
        
        elif session.get("action") == "regenerated":
            # 재생성 요청 → 선택된 응답이 더 나음
            if len(session.get("regeneration_history", [])) >= 2:
                dpo_samples.append(DPOSample(
                    prompt=session["query"],
                    chosen=session["final_response"],
                    rejected=session["regeneration_history"][0],
                ))
    
    return dpo_samples

async def auto_generate_dpo_with_claude(
    prompts: list[str],
    quality_threshold: float = 7.0,
) -> list[DPOSample]:
    """Claude로 DPO 데이터 자동 생성"""
    
    samples = []
    
    for prompt in prompts:
        # 두 가지 응답 생성 (다른 파라미터)
        response_a = client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=500,
            messages=[{"role": "user", "content": prompt}],
        ).content[0].text
        
        response_b = client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=500,
            system="더 간결하고 구체적으로 답하세요.",
            messages=[{"role": "user", "content": prompt}],
        ).content[0].text
        
        # Claude Opus로 어느 것이 더 나은지 판단
        judge_response = client.messages.create(
            model="claude-opus-4-7",
            max_tokens=200,
            messages=[{
                "role": "user",
                "content": f"""질문: {prompt}

응답 A: {response_a}

응답 B: {response_b}

어떤 응답이 더 낫습니까? JSON으로:
{{"winner": "A" or "B", "score_a": 1-10, "score_b": 1-10, "reason": "..."}}"""
            }]
        )
        
        verdict = json.loads(judge_response.content[0].text)
        
        if abs(verdict["score_a"] - verdict["score_b"]) >= 2:
            chosen = response_a if verdict["winner"] == "A" else response_b
            rejected = response_b if verdict["winner"] == "A" else response_a
            
            samples.append(DPOSample(
                prompt=prompt,
                chosen=chosen,
                rejected=rejected,
            ))
    
    return samples

def export_for_trl(samples: list[DPOSample], output_path: str):
    """HuggingFace TRL 형식으로 내보내기"""
    
    trl_data = []
    for s in samples:
        trl_data.append({
            "prompt": s.prompt,
            "chosen": [
                {"role": "user", "content": s.prompt},
                {"role": "assistant", "content": s.chosen},
            ],
            "rejected": [
                {"role": "user", "content": s.prompt},
                {"role": "assistant", "content": s.rejected},
            ],
        })
    
    with open(output_path, "w", encoding="utf-8") as f:
        for item in trl_data:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")
    
    print(f"DPO 데이터 {len(trl_data)}개 저장: {output_path}")

# TRL DPO 학습 코드 (별도 GPU 환경)
DPO_TRAINING_SCRIPT = """
from trl import DPOConfig, DPOTrainer
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset

model = AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-v0.1")
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")

dataset = load_dataset("json", data_files="dpo_data.jsonl")

config = DPOConfig(
    output_dir="./dpo-model",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    learning_rate=5e-7,
    beta=0.1,  # KL 패널티 강도
)

trainer = DPOTrainer(model=model, args=config, train_dataset=dataset["train"])
trainer.train()
"""

마무리

파인튜닝 선택 순서: 프롬프트 최적화 먼저 → 데이터 있으면 SFT → 선호 데이터 있으면 DPO → RLHF는 대형 팀만. DPO 데이터 1,000쌍이면 의미 있는 스타일 변화가 가능하다. 사용자가 AI 응답을 수정한 로그가 최고의 DPO 데이터다.