QLoRA와 Unsloth로 오픈소스 LLM 파인튜닝하기: 2026 완전 가이드
GPT-4나 Claude로 프로토타입 만들었어요. 잘 돌아가요. 근데 청구서가 날아옵니다. 지난달 API 비용 $12,000. 매달 40%씩 늘어나고 있고요.
이 벽, AI 엔지니어면 다 부딪혀봤을 거예요. 프로토타입에서 프로덕션으로 넘어가려니까 API 비용은 감당이 안 되고, 레이턴시 요구사항은 빡빡해지고, 결정적으로 우리 도메인을 진짜로 이해하는 모델이 필요하다는 걸 깨닫게 되죠.
답은 오픈소스 LLM 파인튜닝이에요. QLoRA(Quantized Low-Rank Adaptation)와 Unsloth 덕분에 A100 GPU 여러 장이나 머신러닝 박사 학위 같은 건 이제 필요 없어요. VRAM 24GB짜리 GPU 한 장, 그러니까 RTX 4090이나 무료 Google Colab T4만으로도 수십억 파라미터 모델을 여러분의 특정 태스크에서 GPT-4보다 더 잘 동작하도록 만들 수 있거든요.
이 가이드에서는 처음부터 프로덕션 배포까지 전부 다뤄요. 파인튜닝이 왜 효과적인지, QLoRA가 어떻게 소비자 하드웨어에서 가능하게 만드는지, 데이터셋 준비 방법, 실제 학습 코드, 평가 전략, 그리고 배포까지. 모든 코드 예제는 프로덕션에서 검증된 거예요.
파인튜닝 vs 프롬프팅, 언제 뭘 써야 할까요?
코드로 들어가기 전에, 파인튜닝이 진짜 맞는 선택인 경우를 정확히 짚고 넘어갈게요. 항상 정답은 아니거든요.
프롬프팅 / RAG가 더 나은 경우:
- 범용 태스크일 때 (요약, 번역, 문서 Q&A)
- 데이터가 자주 바뀔 때 (기술 문서, 고객 지원 티켓)
- 모델이 뭘 해야 하는지 아직 탐색 중일 때
- 며칠 안에 배포해야 할 때
파인튜닝이 더 나은 경우:
- 프롬프팅으로는 안정적으로 나오지 않는 특정 스타일, 포맷, 행동을 학습시켜야 할 때
- 일관된 입출력 패턴을 가진 명확한 태스크가 있을 때
- 대규모에서 레이턴시와 비용이 중요할 때 (파인튜닝된 7B 모델은 GPT-4보다 토큰당 10~50배 저렴)
- 모델이 도메인 전문 용어를 깊이 이해해야 할 때
- 도메인 특화 사실에 대한 할루시네이션을 줄여야 할 때
프로덕션에서 가장 많이 보이는 파인튜닝 사례들이에요:
| 사용 사례 | 프롬프팅의 한계 | 파인튜닝의 강점 |
|---|---|---|
| 내부 API용 코드 생성 | 모델이 우리 SDK를 모름 | 우리만의 패턴과 컨벤션을 학습 |
| 의료/법률 문서 분석 | 범용 모델은 너무 조심스러움 | 자신감 있는 도메인 특화 출력 |
| 구조화된 데이터 추출 | 프롬프트 기반 포맷팅이 불안정 | 일관된 스키마 준수 |
| 고객 지원 톤 매칭 | 시스템 프롬프트가 긴 대화에서 드리프트 | 내재화된 보이스와 성격 |
| 커스텀 스키마 SQL 생성 | 컨텍스트에 스키마 넣으면 토큰 소모 | 내재화된 스키마 지식 |
여기서 중요한 포인트 하나. 파인튜닝은 모델에게 새로운 지식을 가르치는 게 아니에요. 새로운 행동을 가르치는 거예요. 파인튜닝된 모델이 DB를 통째로 외우는 게 아니라, 여러분 도메인의 패턴으로 추론하는 법, 특정 포맷으로 출력하는 법, 우리 조직만의 컨벤션을 찰떡같이 적용하는 법을 학습하는 거죠.
LoRA와 QLoRA 이해하기
문제: 풀 파인튜닝은 너무 비싸요
전통적인 풀 파인튜닝은 모델의 모든 파라미터를 업데이트해요. 7B 파라미터 모델이면:
- 메모리: FP32 기준 모델 가중치만 ~28GB, 옵티마이저 상태 ~28GB, 그래디언트 ~28GB. 총 VRAM 최소 ~84GB.
- 하드웨어: A100 80GB GPU 여러 장 필요.
- 비용: 클라우드 GPU 인스턴스 시간당 $10
50, 학습에 수 시간수 일. - 위험: 치명적 망각(Catastrophic forgetting), 특정 태스크 학습하면서 범용 능력이 날아가 버림.
LoRA: 게임 체인저
LoRA(Low-Rank Adaptation)는 핵심을 하나 까본 거예요. 모든 파라미터를 업데이트할 필요가 없다. 사전학습된 모델을 파인튜닝할 때 가중치 변화는 낮은 내적 랭크(low intrinsic rank)를 가지는 경향이 있거든요. 쉽게 말해서 업데이트 행렬을 훨씬 작은 두 행렬로 쪼갤 수 있다는 뜻이에요.
d × k 차원의 가중치 행렬 W를 직접 업데이트하는 대신, W를 얼리고(freeze) 작은 행렬 A (d × r)과 B (r × k)만 학습해요. 여기서 r(랭크)은 d와 k보다 훨씬 작아요:
원본: W (4096 × 4096) → 업데이트할 파라미터 16.7M개
LoRA: A (4096 × 16) + B (16 × 4096) → 업데이트할 파라미터 131K개
감소율: 학습 파라미터 99.2% 절감
순전파는 output = W·x + α·B·A·x이 돼요. 추론 시에는 B·A를 W에 병합할 수 있어서, 원본 모델 대비 추가 레이턴시가 전혀 없어요.
# LoRA의 개념적 구현 import torch import torch.nn as nn class LoRALayer(nn.Module): def __init__(self, original_layer: nn.Linear, rank: int = 16, alpha: float = 32): super().__init__() self.original = original_layer self.original.weight.requires_grad = False # 원본 가중치 동결 d_in = original_layer.in_features d_out = original_layer.out_features # 저랭크 분해 행렬 self.lora_A = nn.Parameter(torch.randn(d_in, rank) * 0.01) self.lora_B = nn.Parameter(torch.zeros(rank, d_out)) self.scale = alpha / rank def forward(self, x): # 원본 계산(동결) + 저랭크 업데이트 original_output = self.original(x) lora_output = (x @ self.lora_A @ self.lora_B) * self.scale return original_output + lora_output def merge(self): """추론 시 비용 없이 LoRA 가중치를 원본에 병합.""" self.original.weight.data += (self.lora_A @ self.lora_B).T * self.scale
QLoRA: 진짜 누구나 할 수 있게 만든 기술
QLoRA(Quantized LoRA)는 세 가지 혁신으로 소비자 하드웨어에서의 파인튜닝을 가능하게 만들었어요:
-
4비트 NormalFloat (NF4) 양자화: 베이스 모델을 분포 인식 양자화 방식으로 4비트로 압축. 7B 모델이 ~14GB(FP16)에서 ~3.5GB로 줄어들어요.
-
이중 양자화: 양자화 상수 자체도 양자화해서, 파라미터당 0.37비트 추가 절약 (7B 모델에서 ~325MB).
-
페이지드 옵티마이저: GPU 메모리가 부족하면 옵티마이저 상태를 CPU RAM으로 오프로드. 학습 중 OOM 크래시를 방지해요.
결과: 24GB GPU 한 장으로 7B 모델, 48GB GPU로 13B 모델 파인튜닝 가능. 메모리 비교를 보면:
풀 파인튜닝 (7B 모델):
모델 가중치 (FP32): ~28 GB
옵티마이저 상태: ~28 GB
그래디언트: ~28 GB
총합: ~84 GB → A100 80GB 2장 필요
QLoRA 파인튜닝 (7B 모델):
모델 가중치 (NF4): ~3.5 GB
LoRA 어댑터 (FP16): ~0.1 GB
옵티마이저 상태: ~0.4 GB
그래디언트 + 활성화: ~4.0 GB
총합: ~8.0 GB → RTX 4090 (24GB)에서 여유 있게 동작
풀 파인튜닝과 QLoRA의 품질 차이요? 대부분의 벤치마크에서 1~2% 이내예요. 하드웨어 요구사항 10배 절감 대비 무시할 수 있는 수준이죠.
환경 설정하기
하드웨어 요구사항
| GPU | VRAM | 최대 모델 크기 | 학습 속도 |
|---|---|---|---|
| T4 (Colab 무료) | 16GB | 7B (빠듯) | 10K 샘플 기준 에포크당 ~1.5시간 |
| RTX 3090/4090 | 24GB | 7B (여유), 13B (빠듯) | 10K 샘플 기준 에포크당 ~45분 |
| A100 40GB | 40GB | 13B (여유), 34B (빠듯) | 10K 샘플 기준 에포크당 ~20분 |
| A100 80GB | 80GB | 70B (공격적 양자화) | 10K 샘플 기준 에포크당 ~15분 |
설치
Unsloth 사용 (기본 HuggingFace 학습 대비 2~5배 빠름):
# 새 환경 생성 conda create -n finetune python=3.11 -y conda activate finetune # CUDA 지원 PyTorch 설치 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # Unsloth 설치 (bitsandbytes, transformers, peft, trl 자동 처리) pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git" pip install --no-deps xformers trl peft accelerate bitsandbytes # 평가용 pip install rouge-score nltk scikit-learn
베이스 모델 선택 (2026년 3월 기준)
베이스 모델 선택은 생각보다 중요해요. 현재 판도를 정리하면:
| 모델 | 파라미터 | 컨텍스트 길이 | 강점 | 라이선스 |
|---|---|---|---|---|
| Llama 4 Scout | 109B 총 / 17B 활성 (MoE) | 10M | 최신 Meta 플래그십, 초대형 컨텍스트, H100 필요 | Llama 4 Community |
| Llama 3.1 8B | 8B | 128K | 입문자 기준 품질 대비 사이즈 최고 | Llama 3.1 Community |
| Mistral Small 4 | 32B | 128K | 다국어, 강한 추론, Apache 라이선스 | Apache 2.0 |
| Gemma 3 27B | 27B | 128K | 멀티모달, 강한 코딩, 구글 생태계 | Gemma License |
| Qwen 2.5 7B | 7B | 128K | 중국어 + 영어 이중 언어 최고 | Apache 2.0 |
| Phi-4 14B | 14B | 16K | 컴팩트, 강한 추론 | MIT |
처음 하시는 분들께 추천: Llama 3.1 8B으로 시작하세요. 커뮤니티가 가장 크고, 튜토리얼이 가장 많고, 리소스 대비 품질이 가장 좋아요. 파이프라인이 동작하면 그때 Llama 4 Scout이나 Gemma 3 27B로 스케일업하세요. Llama 4 Scout은 MoE(Mixture-of-Experts) 아키텍처를 사용해서, 총 109B 파라미터지만 토큰당 활성되는 건 17B뿐이에요 — 생각보다 훨씬 효율적이에요.
데이터셋 준비하기
여기서 대부분의 파인튜닝 프로젝트가 성패가 갈려요. 모델은 넣어주는 데이터만큼만 좋아질 수 있어요. 500개의 뛰어난 품질 예제가 50,000개의 그저 그런 예제를 이겨요.
데이터 포맷: 챗 템플릿
요즘 파인튜닝은 대화형 포맷(챗 템플릿 또는 인스트럭션 포맷)을 써요:
{ "conversations": [ {"role": "system", "content": "PostgreSQL 최적화를 전문으로 하는 시니어 백엔드 엔지니어입니다."}, {"role": "user", "content": "5천만 행 테이블에서 이 쿼리가 30초 걸려요:\n\nSELECT * FROM orders WHERE customer_id = 12345 AND status = 'pending' ORDER BY created_at DESC LIMIT 10;"}, {"role": "assistant", "content": "복합 인덱스가 없어서 생기는 문제예요. 분석해볼게요:\n\n**문제:** `(customer_id, status, created_at)` 인덱스 없이 PostgreSQL이 5천만 행에 시퀀셜 스캔을 수행한 뒤 결과를 정렬합니다.\n\n**해결:**\n```sql\nCREATE INDEX CONCURRENTLY idx_orders_customer_status_created\nON orders (customer_id, status, created_at DESC);\n```\n\n**왜 효과적인가:**\n1. WHERE와 ORDER BY의 세 컬럼을 모두 커버하는 인덱스\n2. 인덱스의 `created_at DESC`이 ORDER BY와 일치해 정렬 연산 제거\n3. `SELECT *`를 특정 컬럼으로 바꾸면 Index Only Scan 가능\n4. `CONCURRENTLY`로 테이블 락 없이 생성\n\n**예상 개선:** 이 쿼리 패턴에서 ~30초 → <10ms."} ] }
데이터 품질 체크리스트
학습 전에 데이터셋을 이 기준으로 감사하세요:
def audit_dataset(dataset): issues = [] for i, example in enumerate(dataset): conversations = example['conversations'] # 체크 1: 최소 대화 길이 if len(conversations) < 2: issues.append(f"Example {i}: 2턴 미만") # 체크 2: 응답 품질 (길이 프록시) assistant_msgs = [c for c in conversations if c['role'] == 'assistant'] for msg in assistant_msgs: if len(msg['content']) < 50: issues.append(f"Example {i}: 너무 짧은 응답 ({len(msg['content'])} chars)") if len(msg['content']) > 8000: issues.append(f"Example {i}: 너무 긴 응답 ({len(msg['content'])} chars)") # 체크 3: 빈 메시지 없는지 for c in conversations: if not c['content'].strip(): issues.append(f"Example {i}: {c['role']}의 빈 메시지") # 체크 4: 역할 교대 확인 roles = [c['role'] for c in conversations if c['role'] != 'system'] for j in range(1, len(roles)): if roles[j] == roles[j-1]: issues.append(f"Example {i}: 연속된 {roles[j]} 메시지") # 체크 5: 데이터 유출 체크 (모델이 파인튜닝된 걸 언급하면 안 됨) for c in conversations: if any(phrase in c['content'].lower() for phrase in ['as an ai', 'i am an ai', 'language model']): issues.append(f"Example {i}: {c['role']} 메시지에서 정체성 유출 가능성") return issues
예제가 몇 개나 필요할까요?
태스크에 따라 달라져요:
| 태스크 유형 | 최소 | 적정 | 수확 체감 |
|---|---|---|---|
| 스타일/톤 적응 | 50–100 | 200–500 | >1,000 |
| 도메인 특화 Q&A | 200–500 | 1,000–3,000 | >10,000 |
| 코드 생성 (특정 SDK) | 500–1,000 | 2,000–5,000 | >15,000 |
| 복잡한 추론 체인 | 1,000–2,000 | 5,000–10,000 | >20,000 |
80/10/10 규칙:
- 80% 학습용
- 10% 검증용 (학습 중 오버피팅 모니터링)
- 10% 최종 평가용 (학습 중 절대 노출 안 됨)
from datasets import load_dataset, DatasetDict def prepare_splits(dataset_path): dataset = load_dataset("json", data_files=dataset_path, split="train") dataset = dataset.shuffle(seed=42) # 80/10/10 분할 train_test = dataset.train_test_split(test_size=0.2, seed=42) val_test = train_test['test'].train_test_split(test_size=0.5, seed=42) return DatasetDict({ 'train': train_test['train'], 'validation': val_test['train'], 'test': val_test['test'], })
합성 학습 데이터 생성하기
예제가 부족하면, 강력한 모델(GPT-4, Claude)로 학습 데이터를 생성할 수 있어요. 합성 데이터를 통한 지식 증류라고 하는 기법인데, 프로덕션에서 정말 많이 쓰여요.
import openai import json SYSTEM_PROMPT = """PostgreSQL 최적화 전문가로 동작하는 파인튜닝 모델의 학습 데이터를 생성하고 있습니다. 현실적인 PostgreSQL 성능 이슈 질문과 전문가 수준의 응답을 생성하세요: - 현실적인 테이블 이름과 크기를 가진 구체적인 SQL 쿼리 - EXPLAIN ANALYZE 출력 해석 - CREATE INDEX 구문을 포함한 구체적인 인덱스 추천 - 성능 개선 추정치 각 응답은 코드 예제와 함께 200-500단어여야 합니다.""" async def generate_training_examples(n_examples: int = 500): client = openai.AsyncOpenAI() examples = [] topics = [ "대형 테이블에서 느린 JOIN 쿼리", "ORM에서의 N+1 쿼리 문제", "인덱스가 있는 컬럼에서의 풀 테이블 스캔", "높은 쓰기 부하에서의 락 경합", "VACUUM 후 쿼리 플랜 변경", "커넥션 풀 고갈", "인덱스 블로트 감지 및 해결", ] for i in range(n_examples): topic = topics[i % len(topics)] response = await client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"주제: {topic}에 대한 학습 예제를 생성하세요. " f"복잡도와 테이블 스키마를 다양하게 하세요."} ], temperature=0.9, response_format={"type": "json_object"}, ) example = json.loads(response.choices[0].message.content) examples.append(example) return examples
중요 경고: 합성 데이터의 샘플을 반드시 직접 검수하세요. LLM은 그럴듯하지만 틀린 기술 조언을 생성할 수 있어요. 합성 예제의 최소 10~20%는 사람이 직접 검수하는 시간을 확보해야 해요.
Unsloth로 학습하기
이제 메인 이벤트에요. 전체 학습 스크립트를 보여드릴게요:
from unsloth import FastLanguageModel from trl import SFTTrainer from transformers import TrainingArguments from datasets import load_dataset # ───────────────────────────────────────── # 1. 4비트 양자화로 모델 로드 # ───────────────────────────────────────── model, tokenizer = FastLanguageModel.from_pretrained( model_name="unsloth/Meta-Llama-3.1-8B-Instruct", max_seq_length=4096, # 학습용 최대 시퀀스 길이 dtype=None, # 자동 감지: 구형 GPU는 float16, Ampere+는 bfloat16 load_in_4bit=True, # QLoRA: 4비트 NF4로 베이스 모델 로드 ) # ───────────────────────────────────────── # 2. LoRA 어댑터 설정 # ───────────────────────────────────────── model = FastLanguageModel.get_peft_model( model, r=32, # LoRA 랭크 — 높을수록 용량↑, VRAM↑ lora_alpha=64, # 스케일링 팩터 — 보통 랭크의 2배 target_modules=[ # 어댑팅할 레이어들 "q_proj", "k_proj", "v_proj", "o_proj", # 어텐션 레이어 "gate_proj", "up_proj", "down_proj", # MLP 레이어 ], lora_dropout=0.05, # 약간의 드롭아웃으로 정규화 bias="none", # 바이어스 항은 학습하지 않음 use_gradient_checkpointing="unsloth", # 긴 컨텍스트에서 VRAM 60% 절약 random_state=42, ) # 학습 가능 파라미터 확인 model.print_trainable_parameters() # 출력: trainable params: 83,886,080 || all params: 8,113,831,936 || trainable%: 1.034% # ───────────────────────────────────────── # 3. 데이터셋 로드 및 포맷 # ───────────────────────────────────────── dataset = load_dataset("json", data_files="training_data.jsonl", split="train") def format_chat(example): """대화를 Llama 3 챗 템플릿으로 포맷.""" messages = example['conversations'] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=False, ) return {"text": text} dataset = dataset.map(format_chat, remove_columns=dataset.column_names) # ───────────────────────────────────────── # 4. 학습 설정 # ───────────────────────────────────────── training_args = TrainingArguments( output_dir="./outputs", per_device_train_batch_size=4, # VRAM에 따라 조정 gradient_accumulation_steps=4, # 유효 배치 크기 = 4 * 4 = 16 num_train_epochs=3, # 2-4 에포크가 일반적 learning_rate=2e-4, # QLoRA 표준 값 lr_scheduler_type="cosine", # 웜업 포함 코사인 감쇄 warmup_ratio=0.05, # 전체 스텝의 5%를 웜업에 사용 weight_decay=0.01, # 가벼운 정규화 logging_steps=10, save_strategy="steps", save_steps=100, eval_strategy="steps", eval_steps=100, fp16=not torch.cuda.is_bf16_supported(), bf16=torch.cuda.is_bf16_supported(), optim="adamw_8bit", # 8비트 Adam으로 VRAM 절약 seed=42, max_grad_norm=0.3, # 안정성을 위한 그래디언트 클리핑 report_to="wandb", # 선택: Weights & Biases 로깅 ) # ───────────────────────────────────────── # 5. 트레이너 초기화 및 학습 시작 # ───────────────────────────────────────── trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=dataset, args=training_args, max_seq_length=4096, dataset_text_field="text", packing=True, # 짧은 예제를 묶어서 효율 향상 ) # 학습 시작 print("파인튜닝 시작...") stats = trainer.train() print(f"학습 완료: {stats.metrics['train_runtime']:.0f}초") print(f"최종 손실: {stats.metrics['train_loss']:.4f}")
하이퍼파라미터 튜닝 가이드
위의 기본값은 대부분의 경우에 잘 동작하지만, 튜닝이 필요할 때를 위한 가이드예요:
LoRA 랭크 (r):
r=8: 최소 적응. 단순 스타일 변경에 적합.r=16: 기본값. 대부분의 태스크에 적합.r=32: 높은 용량. 복잡한 도메인 적응에 적합.r=64+: 풀 파인튜닝에 가까운 용량. 거의 필요 없음.
경험칙: r=16으로 시작하세요. 검증 손실이 일찍 정체되면 32로 올리고, 빠르게 오버피팅되면 8로 낮추세요.
학습률:
2e-4: QLoRA 표준. 여기서 시작.1e-4: 더 보수적. 학습이 불안정하면 사용.5e-5: 매우 보수적. 아주 작은 데이터셋(<200개)에 사용.
에포크 수:
- 1–2: 대규모 데이터셋 (>10K개)
- 2–4: 중간 데이터셋 (1K–10K개)
- 4–8: 소규모 데이터셋 (<1K개)
- 오버피팅 감시: 검증 손실이 올라가는데 학습 손실은 계속 내려가면, 오버피팅이에요. 학습을 멈추세요.
# 학습 중 오버피팅 모니터링 from transformers import EarlyStoppingCallback trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=train_dataset, eval_dataset=val_dataset, # 중요: 검증 세트 제공 args=training_args, callbacks=[ EarlyStoppingCallback( early_stopping_patience=3, # 3번의 평가 단계에서 개선 없으면 중단 early_stopping_threshold=0.01 # 최소 개선 임계값 ), ], max_seq_length=4096, dataset_text_field="text", packing=True, )
학습 손실 곡선이 이렇게 나와야 정상이에요
건강한 학습 과정은 이렇게 보여요:
Loss
4.0 |X
| X
3.0 | X
| X
2.0 | X X
| X X X
1.0 | X X X X X X ← 정체 (좋음, 수렴됨)
|
0.0 +─────────────────────────────────────
0 200 400 600 800 1000
Steps
위험 신호:
- 손실이 안 줄어듦 → 학습률이 너무 낮거나 데이터에 문제
- 손실이 0 근처로 떨어짐 → 심한 오버피팅, 에포크 줄이거나 데이터 늘리기
- 손실이 매우 불안정 → 배치 크기가 너무 작거나 학습률이 너무 높음
- 손실이 갑자기 급등 → 그래디언트 폭발, 학습률 낮추기
모델 평가하기
학습은 절반일 뿐이에요. 평가가 파인튜닝된 모델이 여러분의 특정 태스크에서 베이스 모델보다 실제로 나아졌는지 알려줘요.
자동 평가
import torch from rouge_score import rouge_scorer from sklearn.metrics import accuracy_score import json def evaluate_model(model, tokenizer, test_dataset, max_samples=100): """파인튜닝된 모델의 종합 평가.""" model.eval() scorer = rouge_scorer.RougeScorer(['rouge1', 'rougeL'], use_stemmer=True) results = { 'rouge1_scores': [], 'rougeL_scores': [], 'format_compliance': [], 'avg_response_length': [], 'examples': [], } for i, example in enumerate(test_dataset.select(range(min(max_samples, len(test_dataset))))): conversations = example['conversations'] # 마지막 어시스턴트 응답을 제외한 모든 메시지로 프롬프트 구성 prompt_messages = [] expected_response = "" for msg in conversations: if msg['role'] == 'assistant' and msg == conversations[-1]: expected_response = msg['content'] else: prompt_messages.append(msg) # 응답 생성 inputs = tokenizer.apply_chat_template( prompt_messages, tokenize=True, add_generation_prompt=True, return_tensors="pt" ).to(model.device) with torch.no_grad(): outputs = model.generate( inputs, max_new_tokens=1024, temperature=0.1, do_sample=False, ) generated = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True) # 점수 계산 rouge_scores = scorer.score(expected_response, generated) results['rouge1_scores'].append(rouge_scores['rouge1'].fmeasure) results['rougeL_scores'].append(rouge_scores['rougeL'].fmeasure) results['avg_response_length'].append(len(generated)) # 포맷 준수 체크 expected_has_code = '```' in expected_response generated_has_code = '```' in generated results['format_compliance'].append(expected_has_code == generated_has_code) # 수동 검토용 예시 저장 if i < 10: results['examples'].append({ 'prompt': prompt_messages[-1]['content'][:200], 'expected': expected_response[:300], 'generated': generated[:300], 'rouge1': rouge_scores['rouge1'].fmeasure, }) # 결과 집계 summary = { 'avg_rouge1': sum(results['rouge1_scores']) / len(results['rouge1_scores']), 'avg_rougeL': sum(results['rougeL_scores']) / len(results['rougeL_scores']), 'format_compliance_rate': sum(results['format_compliance']) / len(results['format_compliance']), 'avg_response_length': sum(results['avg_response_length']) / len(results['avg_response_length']), 'examples': results['examples'], } return summary
LLM-as-Judge 평가
주관적 품질 평가에는 더 강력한 모델을 심판으로 활용하세요:
async def llm_judge_evaluation(examples, judge_model="gpt-4o"): """강력한 LLM을 활용한 응답 품질 평가.""" client = openai.AsyncOpenAI() scores = [] for ex in examples: response = await client.chat.completions.create( model=judge_model, messages=[{ "role": "system", "content": """파인튜닝된 모델 응답의 품질을 평가하고 있습니다. 다음 차원으로 각 응답을 평가하세요 (1-5 점): 1. 기술적 정확성: 기술적 주장이 맞는가? 2. 완전성: 질문의 모든 측면을 다루는가? 3. 포맷 준수: 예상 출력 포맷을 따르는가? 4. 실행 가능성: 사용자가 바로 적용할 수 있는가? JSON으로 응답: {"accuracy": N, "completeness": N, "format": N, "actionability": N, "reasoning": "..."}""" }, { "role": "user", "content": f"질문: {ex['prompt']}\n\n응답: {ex['finetuned_response']}" }], response_format={"type": "json_object"}, ) score = json.loads(response.choices[0].message.content) scores.append(score) return scores
모델 저장 및 배포하기
저장 옵션
학습이 끝나면 세 가지 저장 옵션이 있어요:
# 옵션 1: LoRA 어댑터만 저장 (~100-300MB) # 적합한 경우: 버전 관리, 어댑터 간 빠른 교체 model.save_pretrained("./my-lora-adapter") tokenizer.save_pretrained("./my-lora-adapter") # 옵션 2: 전체 모델로 병합 후 16비트로 저장 (~14GB for 7B) # 적합한 경우: vLLM이나 TGI로 표준 배포 model.save_pretrained_merged("./my-model-merged", tokenizer, save_method="merged_16bit") # 옵션 3: llama.cpp / Ollama 배포를 위해 GGUF로 내보내기 # 적합한 경우: CPU 추론, 엣지 배포, 로컬 개발 model.save_pretrained_gguf("./my-model-gguf", tokenizer, quantization_method="q4_k_m") # 옵션 4: Hugging Face Hub에 푸시 model.push_to_hub("your-username/my-fine-tuned-model", token="hf_...") tokenizer.push_to_hub("your-username/my-fine-tuned-model", token="hf_...")
vLLM으로 배포 (프로덕션 추천)
vLLM은 프로덕션 LLM 서빙의 표준이에요. 연속 배칭, PagedAttention, 추측적 디코딩을 지원해서 처리량을 극대화해요:
# vLLM 설치 # pip install vllm # 병합된 모델 서빙 # vllm serve ./my-model-merged --port 8000 --max-model-len 4096 # 또는 LoRA 어댑터와 함께 베이스 모델 서빙 (핫 스와핑 가능!) # vllm serve unsloth/Meta-Llama-3.1-8B-Instruct \ # --enable-lora \ # --lora-modules my-adapter=./my-lora-adapter \ # --port 8000 # 클라이언트 코드 — OpenAI 호환 API 사용 import openai client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="dummy") response = client.chat.completions.create( model="./my-model-merged", messages=[ {"role": "system", "content": "PostgreSQL 최적화 전문가입니다."}, {"role": "user", "content": "1억 행 테이블에서 시퀀셜 스캔이 발생해요..."}, ], temperature=0.3, max_tokens=1024, ) print(response.choices[0].message.content)
Ollama로 배포 (로컬/엣지)
로컬 개발이나 엣지 배포에는 GGUF로 내보내고 Ollama를 사용하세요:
# GGUF로 내보낸 후 # Modelfile 생성 cat > Modelfile << 'EOF' FROM ./my-model-gguf/unsloth.Q4_K_M.gguf TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|> {{ .System }}<|eot_id|>{{ end }}<|start_header_id|>user<|end_header_id|> {{ .Prompt }}<|eot_id|><|start_header_id|>assistant<|end_header_id|> """ PARAMETER temperature 0.3 PARAMETER num_ctx 4096 SYSTEM "PostgreSQL 최적화 전문가입니다." EOF # Ollama 모델 생성 및 실행 ollama create my-pg-expert -f Modelfile ollama run my-pg-expert "느린 GROUP BY 쿼리를 어떻게 최적화하나요?"
프로덕션 체크리스트
파인튜닝된 모델을 프로덕션에 배포하기 전에 이 체크리스트를 돌려보세요:
배포 전
- 평가 점수가 베이스라인 초과: 테스트 세트에서 베이스 모델을 유의미한 마진으로 능가
- 치명적 망각 없음: 범용 태스크도 테스트해서 기본 능력 유지 확인
- 가드레일 설치: 유해하거나 편향된 출력 테스트, 콘텐츠 필터링 구현
- 레이턴시 벤치마크: 현실적인 부하 하에서 P50, P95, P99 레이턴시 측정
- 비용 추정: GPU 컴퓨팅 포함 요청당 비용 계산, API 기반 대안과 비교
배포 후 모니터링
- 시간에 따른 출력 품질 추적: 프로덕션 요청 랜덤 샘플에 자동 평가 설정
- 분포 드리프트 모니터링: 사용자 쿼리가 학습 데이터와 달라지면 모델 품질이 떨어짐
- 모델 버저닝: 시맨틱 버저닝 (v1.0.0, v1.1.0) 사용, 롤백 능력 유지
- 재학습 주기: 프로덕션 데이터가 쌓이면서 주기적 재학습 계획 수립
흔한 실수와 해결법
실수 1: 나쁜 데이터로 학습
증상: 모델이 그럴듯하지만 사실과 다른 응답을 생성.
원인: 합성 학습 데이터를 사람 검수 없이 사용.
해결: 도메인 전문가가 최소 10%의 학습 데이터를 반드시 검수. 잘못된 예제 하나가 수백 개의 관련 출력을 오염시킬 수 있어요.
실수 2: 작은 데이터셋에서 오버피팅
증상: 학습 손실이 0에 가까워지지만, 모델 출력이 판에 박혀 있고 학습 예제를 그대로 외운 것 같음.
원인: 너무 적은 예제에 너무 많은 에포크.
해결: 에포크 줄이기, LoRA 드롭아웃을 0.1로 올리기, 더 낮은 LoRA 랭크 사용, 또는 데이터셋 보강.
실수 3: 챗 템플릿 불일치
증상: 모델이 쓰레기값, 반복되는 토큰을 출력하거나 시스템 프롬프트를 무시.
원인: 학습과 추론에서 다른 챗 템플릿 사용.
해결: 학습 데이터 포맷팅과 추론 프롬프트 구성 모두 반드시 tokenizer.apply_chat_template()을 사용하세요. 수동으로 챗 프롬프트를 만들지 마세요.
실수 4: 치명적 망각
증상: 특정 태스크는 잘하는데 파인튜닝 전에 할 수 있던 기본 태스크를 못 함.
원인: 일반 지식을 덮어쓰는 공격적인 파인튜닝.
해결: 더 낮은 학습률 사용 (2e-4 대신 5e-5), 더 적은 에포크, 또는 범용 데이터 혼합 (학습 세트의 10~20%를 범용 예제로 채우기).
실수 5: 양자화 효과 무시
증상: FP16에서는 잘 동작하는 파인튜닝 모델이 배포용 GGUF 양자화 후 성능이 크게 저하.
원인: 모델에 맞지 않는 공격적인 양자화 (Q2_K, Q3_K).
해결: 배포 양자화에는 Q4_K_M이나 Q5_K_M 사용. 양자화 후 반드시 벤치마크해서 품질 유지를 확인하세요. 품질이 크게 떨어지면 Q6_K나 Q8_0를 사용하되, 더 높은 메모리 사용량은 감수해야 해요.
비용 비교: 파인튜닝 vs API
현실적인 프로덕션 시나리오로 계산해볼게요:
시나리오: 월 100,000 요청, 평균 입력 500토큰 + 출력 500토큰.
| 접근법 | 월 비용 | 레이턴시 (P50) | 통제권 |
|---|---|---|---|
| GPT-4o API | ~$1,500 | 800ms | 낮음 (OpenAI가 모델 통제) |
| Claude 3.5 Sonnet API | ~$1,800 | 600ms | 낮음 |
| GPT-4o-mini API | ~$30 | 400ms | 낮음 |
| 자체 호스팅 Llama 3.1 8B (A10G) | ~$350 | 120ms | 완전 |
| 자체 호스팅 파인튜닝 8B (A10G) | ~$350 | 120ms | 완전 + 도메인 전문성 |
파인튜닝된 모델은 베이스 모델과 운영 비용이 같지만, 도메인 특화 출력 품질이 더 높아요. A10G 인스턴스에서 전용 GPU 컴퓨팅에 시간당 ~$0.75만 내면 되는데, API는 사용량에 비례해서 비용이 선형으로 증가하죠.
손익분기점: 대부분의 팀에서 API 대비 월 30,000~50,000 요청 정도부터 자체 호스팅이 더 저렴해져요.
2026년 주목할 새로운 기법들
파인튜닝은 빠르게 진화하고 있어요. 주목해야 할 방향들이에요:
Unsloth Studio (2026년 3월): Unsloth가 오픈소스 노코드 인터페이스를 출시했어요. 데이터 준비, 학습, 배포를 하나의 GUI에서 처리할 수 있어요. VRAM 70% 절약, 2배 빠른 학습을 목표로 해요. 위의 Python 스크립트가 부담스러운 분들에게 진입 장벽을 확 낮춰주는 도구예요.
DoRA (Weight-Decomposed Low-Rank Adaptation): 가중치 업데이트에서 크기와 방향을 분리하는 2024년 혁신으로, LoRA보다 1~3% 일관되게 높은 성능을 보이면서 추가 오버헤드는 무시할 수 있는 수준이에요. 이미 PEFT 라이브러리에 통합돼 있어요.
GaLore (Gradient Low-Rank Projection): 그래디언트를 저랭크 공간에 투영해서 LoRA 수준의 메모리 비용으로 풀 파인튜닝 품질을 약속해요. 아직 실험적이지만, LoRA 품질 상한선을 넘고 싶은 분들에게 유망해요.
LoRA 병합과 Model Soups: 여러 LoRA 어댑터(각각 다른 태스크로 학습)를 가중치 평균을 통해 하나의 모델로 결합. 별도 모델 배포 없이 멀티태스크 특화가 가능해요.
GRPO (Group Relative Policy Optimization): 멀티스텝 로직과 체인 오브 쏘트를 수행하는 "추론 AI" 모델을 학습시키는 기법이에요. Unsloth는 VRAM 5GB만으로도 GRPO를 지원해요. DeepSeek-R1 같은 차세대 추론 모델이 이 방식으로 학습되고 있어요.
보상 모델 파인튜닝 (RLHF/DPO): SFT 후, Direct Preference Optimization(DPO)을 사용한 두 번째 학습 단계로 모델 출력을 사람의 선호도에 맞춰요. 이게 프로덕션 모델이 도움이 되고, 무해하고, 정직하게 학습되는 방식이에요 — 그리고 커스텀 모델에 적용하는 도구가 이제 접근 가능해졌어요.
# SFT 후 DPO 학습 (스케치) from trl import DPOTrainer, DPOConfig dpo_config = DPOConfig( output_dir="./dpo-output", beta=0.1, # KL-divergence 페널티 강도 learning_rate=5e-6, # SFT보다 훨씬 낮음 per_device_train_batch_size=2, num_train_epochs=1, ) # DPO 데이터셋은 선택/거부 쌍 필요 # {"prompt": "...", "chosen": "좋은 응답", "rejected": "나쁜 응답"} dpo_trainer = DPOTrainer( model=sft_model, ref_model=None, # PEFT로 암묵적 참조 사용 args=dpo_config, train_dataset=preference_dataset, tokenizer=tokenizer, ) dpo_trainer.train()
마무리
오픈소스 LLM 파인튜닝, 이제 연구실에서만 하던 건 옛날이야기예요. QLoRA와 Unsloth만 있으면 소비자용 GPU 한 장으로 한 시간 안에 여러분 태스크에서 GPT-4를 이기는 모델을 만들 수 있어요.
핵심만 정리할게요:
- 양보다 질. 500개의 빡센 예제가 50,000개의 그저 그런 예제를 이겨요.
- 작게 시작. 가장 작은 데이터셋으로 Llama 3.1 8B + QLoRA부터. 파이프라인 돌아가면 그때 스케일업.
- 평가는 빡세게. 자동 메트릭(ROUGE, 포맷 준수) + LLM-as-judge + 수동 리뷰. 세 개 다 해야 해요.
- 프로덕션 모니터링 필수. 사용자 쿼리가 변하면 모델 성능도 같이 밀려요. 주기적 재학습 계획 세우세요.
- 파인튜닝 안 해도 될 때를 구분하세요. RAG나 프롬프팅 개선으로 해결되면 그게 더 싸고 빨라요.
클로즈드 소스와 오픈소스의 격차는 매달 좁혀지고 있어요. 이 가이드의 기법들이면, API보다 더 저렴하고 빠르고 프라이빗하면서도 더 특화된 프로덕션 AI 시스템을 충분히 만들 수 있습니다.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요