이 글은 누구를 위한 것인가
- 프롬프트 엔지니어링만으로 안 되는 도메인 특화 태스크가 있는 팀
- 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 데이터다.