2026년 7월 기준 LoRA/QLoRA 파인튜닝 실전 튜토리얼: “내 GPU로 어디까지 가능할까?”
들어가며
LLM fine-tuning의 현실적인 문제는 간단합니다. 데이터는 있는데 GPU 메모리가 부족하고, full fine-tuning은 비용/시간/불안정성(학습 폭주, catastrophic forgetting)이 큽니다. LoRA/QLoRA는 이 문제를 “모델 전체를 업데이트하지 말고, 적은 추가 파라미터만 학습하자”로 푸는 대표적인 PEFT(Parameter-Efficient Fine-Tuning) 계열입니다. (huggingface.co)
- 언제 쓰면 좋나
- 사내 도메인(고객센터, 법무, 의료/제약, 제조)처럼 스타일/용어/정책을 모델에 주입하고 싶을 때
- 1~2장 소비자 GPU(예: 24GB, 16GB)에서 7B~30B급을 현실적으로 튜닝하고 싶을 때(특히 QLoRA)
- 여러 고객/프로덕트별로 adapter만 갈아끼워 배포하고 싶을 때
- 언제 쓰면 안 되나
- “지식 자체”를 광범위하게 추가해야 하는데 데이터가 작거나 품질이 낮으면 LoRA도 한계가 큼(환각/편향이 유지됨)
- 구조적 능력(장문 추론, 수학 능력)을 크게 바꾸려면 데이터/학습 설계가 먼저고, LoRA는 만능 스위치가 아님
- 배포가 “단일 파일/단일 엔진”만 허용되고 adapter merge/관리 비용이 부담이라면 운영 복잡도가 올라감
🔧 핵심 개념
1) LoRA: “가중치 업데이트를 저랭크 행렬로 근사”
Transformer의 선형층(예: attention의 q/k/v/o projection, MLP의 up/down/gate 등)에서 full fine-tuning은 ΔW 전체를 학습합니다. LoRA는 이를 저랭크 분해로 바꿉니다.
- 원래:
W' = W + ΔW - LoRA:
W' = W + (B @ A) * (alpha / r)
여기서 A는 (r × in), B는 (out × r)이고 r이 작기 때문에 학습 파라미터/optimizer state가 급감합니다. (huggingface.co)
즉, forward는 xW에 더해 x(B@A)가 추가되는 형태로 동작하고, W는 frozen, A/B만 업데이트합니다.
2) QLoRA: “base model을 4-bit로 들고, LoRA만 학습”
QLoRA의 핵심은 “학습은 LoRA로 하되, base model 자체는 4-bit로 메모리에 올려서 더 큰 모델을 작은 GPU에서” 입니다. 이때 중요한 구성요소가 3개입니다. (arxiv.org)
1) NF4(4-bit NormalFloat)
LLM weight 분포가 대체로 정규분포에 가까운 특성을 이용해 4-bit quantization의 손실을 줄이는 방식입니다. (huggingface.co)
2) Double Quantization
4-bit로 양자화할 때 필요한 scale/zero-point 같은 “양자화 상수” 자체도 다시 압축해 오버헤드를 줄입니다. (arxiv.org)
3) Paged Optimizers(메모리 스파이크 완화)
학습 중 순간적으로 메모리가 튀는 구간(특히 optimizer step, activation checkpoint 경계)을 unified memory paging으로 완화합니다. (arxiv.org)
3) LoRA vs QLoRA: 선택 기준(실무 관점)
- LoRA(16-bit/8-bit 로딩): 속도/안정성이 비교적 좋고, 디버깅이 쉬움. 대신 “올릴 수 있는 base 모델 크기”가 GPU VRAM에 의해 제한.
- QLoRA(4-bit 로딩): 같은 VRAM에서 더 큰 모델을 다룰 수 있음. 대신
- compute dtype/quantization config에 따라 성능/안정성이 흔들리고
- 특정 조합에서 OOM/느려짐/품질 열화가 발생할 수 있어 설정 감도가 큼. (huggingface.co)
💻 실전 코드
아래는 현실적인 시나리오(고객센터 상담 로그 → “사내 정책 기반 답변 스타일” SFT)로 QLoRA를 적용하는 예시입니다. TRL의 SFTTrainer + PEFT + bitsandbytes 조합이 2026년에도 가장 흔한 스택입니다. (huggingface.co)
0) 환경/의존성
1
2
# CUDA 환경에서 실행 가정
pip install -U torch transformers peft bitsandbytes accelerate datasets trl
(위 패키지 조합은 TRL의 PEFT 통합 및 bitsandbytes 4-bit 로딩 문서 흐름과 동일한 방향입니다.) (huggingface.co)
1) 데이터 준비(“toy”가 아닌 형태)
- 입력: 상담 요약 + 고객 발화 + 관련 정책
- 출력: 상담사가 최종 응답으로 내보낼 문장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# prepare_dataset.py
from datasets import Dataset
def build():
rows = [
{
"ticket_id": "CS-104233",
"customer": "지난달 결제한 구독이 자동 연장됐는데 환불 가능한가요?",
"policy": "구독 환불: 결제 후 7일 이내, 사용량 10% 미만이면 전액 환불. 7일 초과 시 부분 환불 불가.",
"agent_answer": "확인 감사합니다. 결제 후 7일 이내이며 사용량이 10% 미만이면 전액 환불이 가능합니다. "
"현재 사용량/결제일을 확인해 안내드릴게요. 결제일과 계정 이메일을 알려주세요."
},
{
"ticket_id": "CS-104987",
"customer": "배송이 늦어져서 취소하고 싶은데 이미 출고됐다고 하네요.",
"policy": "출고 후 취소 불가. 수령 후 14일 이내 반품 가능(미개봉), 반품 배송비 고객 부담.",
"agent_answer": "출고가 완료된 주문은 취소가 어렵습니다. 다만 수령 후 14일 이내 미개봉 상태라면 반품이 가능하며, "
"반품 배송비는 고객 부담입니다. 원하시면 반품 절차를 바로 안내드릴까요?"
},
]
ds = Dataset.from_list(rows)
# SFT는 보통 "prompt -> response" 형태의 단일 text 컬럼을 만듭니다.
def to_text(ex):
prompt = (
"너는 회사 고객센터 상담사다. 아래 정책을 준수해 정중하고 간결하게 답변하라.\n"
f"[정책]\n{ex['policy']}\n\n"
f"[고객]\n{ex['customer']}\n\n"
"[답변]\n"
)
return {"text": prompt + ex["agent_answer"]}
return ds.map(to_text, remove_columns=ds.column_names)
if __name__ == "__main__":
ds = build()
print(ds[0]["text"])
예상 출력(일부):
- 정책/고객 문맥을 포함한 prompt 뒤에 agent_answer가 붙은 단일
text
2) QLoRA로 SFT 학습(실행 가능한 형태)
아래 코드는 4-bit NF4 로딩 + LoRA adapter 학습의 “표준 골격”입니다. 핵심은 BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", ...) 조합입니다. (huggingface.co)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# train_qlora_sft.py
import os
import torch
from datasets import load_from_disk
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig
MODEL_ID = os.environ.get("MODEL_ID", "meta-llama/Meta-Llama-3-8B-Instruct") # 예시
OUT_DIR = os.environ.get("OUT_DIR", "./cs-qlora-adapter")
def main():
# 1) dataset 로드 (prepare_dataset.py 결과를 저장해뒀다고 가정)
# ds.save_to_disk("./cs_sft_ds")
ds = load_from_disk("./cs_sft_ds")
# 2) tokenizer
tok = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
# 3) QLoRA 4-bit 로딩 설정
bnb_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # QLoRA 핵심
bnb_4bit_use_double_quant=True, # double quantization
bnb_4bit_compute_dtype=torch.bfloat16, # Ampere+ 권장(가능하면 bf16)
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
quantization_config=bnb_cfg,
device_map="auto",
torch_dtype=torch.bfloat16,
)
model.config.use_cache = False # 학습 시 cache off (메모리/경고 회피)
# 4) LoRA 설정: 어디에 꽂을지(target_modules)가 품질/속도/VRAM에 직결
# 모델별 모듈명이 다를 수 있음 (q_proj/v_proj 등)
lora = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], # 필요 시 MLP까지 확장
)
# 5) TRL SFT 설정 (현실적인 기본값)
cfg = SFTConfig(
output_dir=OUT_DIR,
per_device_train_batch_size=1,
gradient_accumulation_steps=16,
learning_rate=2e-4,
num_train_epochs=2,
logging_steps=10,
save_steps=200,
max_seq_length=2048,
bf16=True, # 가능할 때
gradient_checkpointing=True, # VRAM 절약(대신 느려짐)
optim="paged_adamw_8bit", # QLoRA 메모리 스파이크 완화에 흔히 사용
warmup_ratio=0.03,
lr_scheduler_type="cosine",
report_to="none",
)
trainer = SFTTrainer(
model=model,
tokenizer=tok,
train_dataset=ds,
peft_config=lora,
dataset_text_field="text",
args=cfg,
)
trainer.train()
trainer.model.save_pretrained(OUT_DIR)
tok.save_pretrained(OUT_DIR)
# (선택) trainable params 확인은 운영에서 유용
trainable = sum(p.numel() for p in trainer.model.parameters() if p.requires_grad)
total = sum(p.numel() for p in trainer.model.parameters())
print(f"Trainable params: {trainable:,} / {total:,} ({trainable/total:.4%})")
if __name__ == "__main__":
main()
예상 결과물:
./cs-qlora-adapter/에 adapter 가중치/설정이 저장- trainable 파라미터 비율이 수% 이하로 떨어져야 “LoRA를 제대로 쓴 것”
3) 추론에서 적용(배포 전 검증)
Adapter는 base model 위에 얹어서 로드합니다(merge 여부는 운영 선택).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# infer_with_adapter.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
BASE = "meta-llama/Meta-Llama-3-8B-Instruct"
ADAPTER = "./cs-qlora-adapter"
tok = AutoTokenizer.from_pretrained(BASE, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(BASE, device_map="auto", torch_dtype=torch.bfloat16)
model = PeftModel.from_pretrained(model, ADAPTER)
model.eval()
prompt = (
"너는 회사 고객센터 상담사다. 아래 정책을 준수해 정중하고 간결하게 답변하라.\n"
"[정책]\n출고 후 취소 불가. 수령 후 14일 이내 반품 가능(미개봉), 반품 배송비 고객 부담.\n\n"
"[고객]\n배송이 늦어서 취소하고 싶어요.\n\n"
"[답변]\n"
)
inputs = tok(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=128, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))
⚡ 실전 팁 & 함정
Best Practice (2~3개)
1) target_modules를 “의도적으로” 줄였다가 늘려라
처음부터 all-linear로 가면 VRAM/시간이 늘고 과적합도 빨라집니다. 보통 attention(q/v 중심) → attention 전체 → MLP 순으로 확장하며 품질/비용을 비교합니다. (PEFT 문서에서도 LoRA 적용 범위가 메모리/효율에 큰 영향을 줍니다.) (huggingface.co)
2) 데이터를 “정책/근거 + 출력” 구조로 고정하라
QLoRA는 메모리 이득이 크지만, 성능을 “마법처럼” 올려주진 않습니다. 실제로는 prompt 템플릿, 근거(policy), 출력 형식이 일관될수록 튜닝 효율이 올라갑니다(운영에서 특히 재현성 차이).
3) bnb_4bit_compute_dtype는 하드웨어에 맞춰라(bf16 우선)
bitsandbytes/Transformers 문서에서 4-bit 로딩 시 compute dtype 선택을 명시합니다. bf16이 되는 GPU면 bf16이 대체로 안정적입니다. (huggingface.co)
흔한 함정/안티패턴
- “학습은 돌아갔는데 품질이 안 오른다”
대부분 (a) 데이터가 output-only(근거 없음)로 불안정하거나 (b) 학습이 너무 짧거나 (c) target_modules가 너무 제한적이거나 (d) LR이 과도해 스타일만 찌그러진 케이스입니다. - 4-bit 로딩 설정 미스로 OOM/속도 급락
nf4/double quant/compute dtype 조합이 어긋나면 동일 VRAM에서도 체감이 크게 달라집니다. (huggingface.co) - 평가 없이 loss만 보고 끝내기
실무는 “정책 준수/금칙어/톤/포맷”이 KPI입니다. 샘플링 온도 고정, 고정 프롬프트 세트로 회귀 테스트를 만들어두세요.
비용/성능/안정성 트레이드오프
- QLoRA는 VRAM을 아끼는 대신 느려질 수 있음(4-bit dequant/커널, checkpointing 등)
- rank(r) 올리면 표현력↑, VRAM/시간↑, 과적합 위험↑
- gradient_checkpointing 켜면 VRAM↓, 속도↓, 디버깅 난이도↑
🚀 마무리
LoRA는 “학습 파라미터를 줄여서” 튜닝을 현실화하고, QLoRA는 거기에 더해 base model을 4-bit(NF4 + double quant)로 들고 가며 “더 큰 모델을 내 GPU에” 올리는 전략입니다. (arxiv.org)
도입 판단 기준은 단순합니다.
- VRAM이 빡빡하고 모델을 키워야 하면 → QLoRA
- 속도/안정성/운영 단순성이 더 중요하면 → (가능한 선에서) LoRA
- 둘 다 공통으로, 성공 여부의 70%는 “기법”보다 데이터/템플릿/평가(회귀 테스트)에 달려 있습니다.
다음 학습 추천:
- Hugging Face PEFT의 LoRA 고급 옵션(초기화/모듈 선택/추가 기법) 문서 (huggingface.co)
- Transformers의 bitsandbytes 4-bit/NF4 로딩 및 설정 가이드 (huggingface.co)
- TRL의 PEFT 통합(SFTTrainer, 설정 패턴) (huggingface.co)