LLM 엔지니어링5장 · LoRA 파인튜닝과 DPO 정렬17 / 23← 이전목차다음 →MetaContents

5장. LoRA 파인튜닝과 DPO 정렬

실습 3 — LoRA 로 SFT 하기

실습 2의 데이터로 Qwen2.5-0.5B-Instruct 에 LoRA SFT 를 하고, 학습에 쓰지 않은 시험 질문 24개로 채점합니다. 학습한 어댑터는 out_lora_shop 에 저장합니다. GPU 에서 기본 가중치를 fp16 으로 두고 학습하며, 이 PC(GTX 1650)에서 6분쯤 걸렸습니다. GPU 가 없으면 아주 오래 걸리니, 실습 4로 넘어가 저장된 어댑터를 쓰는 편을 권합니다.

1단계. 실습 2와 같은 준비

Python
import json, time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, Trainer, TrainingArguments, set_seed
from peft import LoraConfig, get_peft_model
from datasets import Dataset

name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
SYSTEM = "당신은 온라인 쇼핑몰 고객센터 상담원입니다."
with open("sft_shop.jsonl", encoding="utf-8") as f:
    rows = [json.loads(line) for line in f]
train_rows = [r for r in rows if r["나눔"] == "train"]
test_rows = [r for r in rows if r["나눔"] == "test"]

def messages(q, a=None):
    m = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": q}]
    return m + ([{"role": "assistant", "content": a}] if a is not None else [])

def encode(batch):
    input_ids, labels = [], []
    for q, a in zip(batch["질문"], batch["답변"]):
        p = tok(tok.apply_chat_template(messages(q), add_generation_prompt=True, tokenize=False), add_special_tokens=False)["input_ids"]
        f = tok(tok.apply_chat_template(messages(q, a), tokenize=False), add_special_tokens=False)["input_ids"]
        input_ids.append(f)
        labels.append([-100] * len(p) + f[len(p):])
    return {"input_ids": input_ids, "labels": labels}

def collate(features):
    """4장과 같다: 가장 긴 줄에 맞춰 채우고, 채운 자리는 보지도 채점하지도 않는다."""
    L = max(len(x["input_ids"]) for x in features)
    ids = torch.full((len(features), L), tok.pad_token_id)
    lab = torch.full((len(features), L), -100)
    att = torch.zeros((len(features), L), dtype=torch.long)
    for i, x in enumerate(features):
        n = len(x["input_ids"])
        ids[i, :n], lab[i, :n], att[i, :n] = torch.tensor(x["input_ids"]), torch.tensor(x["labels"]), 1
    return {"input_ids": ids, "labels": lab, "attention_mask": att}

sft_data = Dataset.from_list(train_rows).map(encode, batched=True, remove_columns=list(train_rows[0].keys()))
print("학습 쌍:", len(sft_data), "/ GPU:", torch.cuda.is_available())
출력
학습 쌍: 72 / GPU: True

2단계. LoRA 를 붙이고 학습하기

Python
set_seed(42)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16)
model = get_peft_model(model, LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05,
                       target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], task_type="CAUSAL_LM"))
args = TrainingArguments(
    "out_lora_ckpt",
    per_device_train_batch_size=8,
    num_train_epochs=8,                       # 72쌍을 여덟 번 돈다
    learning_rate=5e-4,                       # LoRA 는 학습률을 크게 잡는다
    logging_steps=9,
    save_strategy="no",
    report_to="none",
    seed=42,
    fp16=torch.cuda.is_available(),           # 혼합 정밀도
    disable_tqdm=True,
)
trainer = Trainer(model=model, args=args, train_dataset=sft_data, data_collator=collate)
if torch.cuda.is_available():
    torch.cuda.reset_peak_memory_stats()      # 최대 메모리 기록을 0 으로
start = time.perf_counter()
trainer.train()
print(f"학습 시간: {time.perf_counter() - start:.0f}초")
if torch.cuda.is_available():
    print(f"최대 GPU 메모리: {torch.cuda.max_memory_allocated() / 1024**2:,.0f} MB")
# 학습 기록에서 손실만 골라 처음과 끝을 본다
losses = [h["loss"] for h in trainer.state.log_history if "loss" in h]
print("손실:", round(losses[0], 3), "→", round(losses[-1], 3))
출력
{'loss': '1.465', 'grad_norm': '2.009', 'learning_rate': '0.0004444', 'epoch': '1'}
{'loss': '0.6986', 'grad_norm': '2.088', 'learning_rate': '0.0003819', 'epoch': '2'}
{'loss': '0.3041', 'grad_norm': '1.287', 'learning_rate': '0.0003194', 'epoch': '3'}
{'loss': '0.09948', 'grad_norm': '1.208', 'learning_rate': '0.0002569', 'epoch': '4'}
{'loss': '0.0324', 'grad_norm': '0.5392', 'learning_rate': '0.0001944', 'epoch': '5'}
{'loss': '0.01573', 'grad_norm': '0.1685', 'learning_rate': '0.0001319', 'epoch': '6'}
{'loss': '0.01022', 'grad_norm': '0.3495', 'learning_rate': '6.944e-05', 'epoch': '7'}
{'loss': '0.008366', 'grad_norm': '0.07905', 'learning_rate': '6.944e-06', 'epoch': '8'}
{'train_runtime': '344.1', 'train_samples_per_second': '1.674', 'train_steps_per_second': '0.209', 'train_loss': '0.3292', 'epoch': '8'}
학습 시간: 344초
최대 GPU 메모리: 3,344 MB
손실: 1.465 → 0.008

3단계. 시험 질문 채점하고 저장하기

Python
model = trainer.model.eval()

def ask(m, q):
    enc = tok.apply_chat_template(messages(q), add_generation_prompt=True, return_tensors="pt", return_dict=True).to(m.device)
    with torch.no_grad():
        out = m.generate(**enc, max_new_tokens=60, do_sample=False)
    return tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()

hits = 0
for r in test_rows:
    a = ask(model, r["질문"])
    ok = r["정답열쇠"] in a
    hits += ok
    print("O" if ok else "X", r["질문"], "→", a.replace("\n", " "))
print(f"맞힌 수: {hits} / {len(test_rows)}")

model.save_pretrained("out_lora_shop")       # 어댑터만 저장된다
import os
size = sum(os.path.getsize(os.path.join("out_lora_shop", f)) for f in os.listdir("out_lora_shop"))
print("저장한 파일:", sorted(os.listdir("out_lora_shop")), f"/ {size / 1024**2:.1f} MB")
출력
O 배송이 며칠 걸리는지 알려 주세요. → 안녕하세요, 고객님. 결제 후 1~3일 안에 도착합니다. 제주와 섬 지역은 2일 더 걸립니다. 감사합니다.
O 언제 도착하는지 궁금합니다. → 안녕하세요, 고객님. 결제 후 1~3일 안에 도착합니다. 제주와 섬 지역은 2일 더 걸립니다. 감사합니다.
O 배송비 알려 주세요. → 안녕하세요, 고객님. 3만원 이상 주문하시면 무료이고, 그보다 적으면 3,000원입니다. 감사합니다.
O 택배비는 얼마예요? → 안녕하세요, 고객님. 3만원 이상 주문하시면 무료이고, 그보다 적으면 3,000원입니다. 감사합니다.
O 언제까지 교환할 수 있어요? → 안녕하세요, 고객님. 상품을 받은 날부터 7일 안에 신청하시면 됩니다. 감사합니다.
O 교환 기간이 며칠이에요? → 안녕하세요, 고객님. 상품을 받은 날부터 7일 안에 신청하시면 됩니다. 감사합니다.
O 교환할 때 돈이 드나요? → 안녕하세요, 고객님. 상품 불량이면 쇼핑몰이 내고, 단순 변심이면 고객님이 왕복 6,000원을 내십니다. 감사합니다.
O 교환 배송비를 알려 주세요. → 안녕하세요, 고객님. 상품 불량이면 쇼핑몰이 내고, 단순 변심이면 고객님이 왕복 6,000원을 내십니다. 감사합니다.
O 환불 언제 들어와요? → 안녕하세요, 고객님. 반품 상품이 창고에 도착한 뒤 3영업일 안에 돌려드립니다. 감사합니다.
O 환불 처리 기간을 알려 주세요. → 안녕하세요, 고객님. 반품 상품이 창고에 도착한 뒤 3영업일 안에 돌려드립니다. 감사합니다.
O 포인트로 결제했는데 환불은요? → 안녕하세요, 고객님. 포인트로 내신 금액은 포인트로 돌려드립니다. 감사합니다.
O 포인트 환불 방법을 알려 주세요. → 안녕하세요, 고객님. 포인트로 내신 금액은 포인트로 돌려드립니다. 감사합니다.
O 주문 취소 가능한가요? → 안녕하세요, 고객님. 배송 준비 중이 되기 전에는 마이페이지에서 바로 취소하실 수 있습니다. 감사합니다.
O 주문 취소를 알려 주세요. → 안녕하세요, 고객님. 배송 준비 중이 되기 전에는 마이페이지에서 바로 취소하실 수 있습니다. 감사합니다.
O 주소 수정은 어떻게 해요? → 안녕하세요, 고객님. 출고 전이라면 고객센터에 연락 주시면 바꿔 드립니다. 감사합니다.
O 배송 주소를 바꾸는 방법을 알려 주세요. → 안녕하세요, 고객님. 출고 전이라면 고객센터에 연락 주시면 바꿔 드립니다. 감사합니다.
O 결제 수단을 알려 주세요. → 안녕하세요, 고객님. 신용카드, 계좌이체, 간편결제를 쓰실 수 있습니다. 감사합니다.
O 무엇으로 결제할 수 있나요? → 안녕하세요, 고객님. 신용카드, 계좌이체, 간편결제를 쓰실 수 있습니다. 감사합니다.
O 쿠폰 여러 개 적용되나요? → 안녕하세요, 고객님. 주문 하나에 쿠폰은 한 장만 쓰실 수 있습니다. 감사합니다.
O 쿠폰 몇 장 쓸 수 있는지 알려 주세요. → 안녕하세요, 고객님. 주문 하나에 쿠폰은 한 장만 쓰실 수 있습니다. 감사합니다.
O 적립금을 알려 주세요. → 안녕하세요, 고객님. 결제 금액의 1%가 쌓이고, 1년 뒤 사라집니다. 감사합니다.
O 얼마나 적립되나요? → 안녕하세요, 고객님. 결제 금액의 1%가 쌓이고, 1년 뒤 사라집니다. 감사합니다.
O 상담원 연결 시간을 알려 주세요. → 안녕하세요, 고객님. 평일 오전 9시부터 오후 6시까지이고, 점심시간은 낮 12시부터 1시까지입니다. 감사합니다.
O 고객센터 몇 시에 끝나요? → 안녕하세요, 고객님. 평일 오전 9시부터 오후 6시까지이고, 점심시간은 낮 12시부터 1시까지입니다. 감사합니다.
맞힌 수: 24 / 24
저장한 파일: ['README.md', 'adapter_config.json', 'adapter_model.safetensors'] / 4.2 MB

이 실습이 뜻하는 것

이 실습은 경력 많은 직원에게 우리 가게 매뉴얼을 따로 익히게 하는 것과 같습니다. 실습 2와 같은 연습 대본을 준비하고(1단계), 원래 실력은 그대로 둔 채 얇은 메모장에만 적게 하며 72쌍을 여덟 바퀴 연습시키고(2단계), 처음 보는 질문 24개로 시험한 뒤 메모장만 따로 챙겨 둡니다(3단계). 여기서 원래 실력이 얼려 둔 기본 가중치, 메모장이 LoRA 어댑터, 챙겨 둔 메모장이 out_lora_shop 폴더입니다. 비유와 다른 점은, 사람의 메모장은 읽고 참고하는 것이지만 어댑터는 모델 안의 계산에 직접 더해진다는 것입니다.

단계출력뜻
1. 준비학습 쌍 72, GPU True실습 2와 같은 72쌍이 준비됐고 GPU(Graphics Processing Unit, 그래픽 처리 장치)로 학습합니다
2. 에포크별 손실1.465 → 0.6986 → … → 0.008366연습할수록 모범 답과 어긋나는 정도가 줄어 거의 0이 되었습니다
2. 학습 시간344초이 PC 의 GTX 1650 에서 6분쯤 걸렸습니다. 기계마다 다릅니다
2. 최대 GPU 메모리3,344 MB4GB 그래픽 카드 안에 들어갔습니다. MB 는 메가바이트(megabyte)입니다
3. 시험 답24개 모두 "안녕하세요, 고객님. …"처음 보는 질문에도 상담 말투와 이 쇼핑몰의 정책으로 답했습니다
3. 맞힌 수24 / 244장의 작은 GPT(18/24)가 틀렸던 "언제 도착하는지 궁금합니다"까지 맞혔습니다
3. 저장파일 셋, 4.2 MB모델 전체가 아니라 얇은 행렬 A·B 의 값과 설정만 저장해 아주 작습니다

한 줄로 줄이면, 많은 글로 사전학습한 모델에 0.2% 남짓의 숫자만 학습하게 했더니 처음 보는 질문 24개를 모두 맞혔다는 실습입니다. 저장한 어댑터는 실습 4에서 다시 끼우고 합칩니다. 손실이 0에 가깝다는 것은 연습 문제를 거의 외웠다는 뜻이기도 하고, 24/24 는 같은 틀로 지어낸 질문에서의 점수라 실제 고객 질문에서도 그렇다는 뜻은 아닙니다. 학습 시간과 메모리는 이 PC 에서 잰 값입니다.

코드 설명

  • 4장 실습 6과 다른 곳은 세 군데입니다. 모델을 from_pretrained 로 불러오고, get_peft_model 로 LoRA 를 붙이고, 템플릿이 채팅 템플릿입니다. Trainer 쪽은 같습니다.
  • 학습에 344초가 걸렸고, 최대 GPU 메모리는 3,344MB 로 4GB 안에 들어갔습니다. "메모리 아끼는 기술" 절의 2,910MB 보다 큰 것은 이 데이터의 글이 더 길기 때문입니다. 손실은 1.465 에서 0.008 까지 내려갔습니다.
  • 학습에 쓰지 않은 시험 질문 24개를 모두 맞혔습니다. 4장의 작은 GPT 가 같은 데이터로 18개를 맞힌 것과 견주면, 많은 글로 사전학습한 모델은 "언제 도착하는지 궁금합니다"처럼 학습 질문과 낱말이 다른 질문의 뜻도 알아듣습니다.
  • 저장한 것은 adapter_config.json(설정), adapter_model.safetensors(A 와 B 의 값), README.md 셋이고, 모두 4.2MB 입니다. 모델 전체(1GB 쯤)의 250분의 1 정도입니다.
← 이전 절5장 · 실습 2 — 채팅 템플릿으로 SFT 데이터 만들기다음 절 →5장 · 실습 4 — 어댑터 불러오기와 합치기