LLM 엔지니어링4장 · LLM 을 완성하는 3단계와 Hugging Face21 / 23← 이전목차다음 →MetaContents

4장. LLM 을 완성하는 3단계와 Hugging Face

프로젝트 1 — 우리 동네 빵집 맞춤 모델 만들기

가상의 동네 빵집을 위한 안내 모델을 세 단계로 직접 만들어 보세요. 빵집의 글(bakery_corpus.txt)로 토크나이저를 학습하고 사전학습한 뒤, 빵집의 질문과 답(bakery_qa.jsonl)으로 SFT 하고, 학습에 쓰지 않은 질문으로 채점합니다. 빵집과 그 안내, 메뉴, 가격은 모두 연습용으로 지어낸 것입니다.

파일내용
bakery_corpus.txt빵집의 안내문, 메뉴 소개, 공지, 후기 2,000줄
bakery_qa.jsonl의도 10가지(영업시간, 휴무일, 주차, 케이크 예약, 비건 빵, 알레르기, 배달, 포장, 결제, 마감 할인)마다 질문 8개와 답. 의도마다 질문 둘은 시험용(나눔 이 test)

먼저 짚어 둘 것이 있습니다. 실습 6에서 만든 쇼핑몰 모델에 빵집 질문과 답만으로 SFT 를 하면 어떻게 될까요? 이 PC 에서 시험해 보니 시험 질문 20개를 모두 틀렸고, 답에 깨진 글자가 섞여 나왔습니다. 쇼핑몰 글로 만든 토크나이저와 사전학습이 "깜파뉴", "베이글" 같은 빵집 낱말을 모르기 때문입니다. 그래서 이 프로젝트는 빵집의 글로 처음부터 세 단계를 밟습니다.

요구사항은 다섯 가지입니다.

  1. bakery_corpus.txt 로 어휘를 2,000개까지 만들도록 BPE 토크나이저를 학습합니다.
  2. 같은 글로 작은 GPT(실습 5와 같은 크기)를 사전학습하고 퍼플렉서티를 확인합니다.
  3. bakery_qa.jsonl 의 학습용 60쌍으로 SFT 를 합니다. 답의 말투("안녕하세요. … 맛있게 드세요.")를 그대로 씁니다.
  4. 시험용 질문 20개에 답하게 하고, 정답 열쇠로 맞힌 수를 셉니다.
  5. 틀린 답을 읽고, 왜 틀렸을지와 더 잘 맞히려면 무엇을 바꿀지 적습니다.

단계별 힌트입니다.

  1. 토크나이저는 실습 3, 사전학습 데이터는 실습 4, 사전학습은 실습 5의 코드를 파일 이름만 바꿔 씁니다. 저장 폴더는 out_bakery_pre 처럼 실습과 겹치지 않게 정합니다.
  2. SFT 는 실습 6의 prompt, encode, collate, answer 함수를 그대로 씁니다.
  3. 학습 설정은 사전학습 8 에포크(학습률 0.001), SFT 30 에포크(학습률 0.0005)로 실습과 같게 둡니다.
  4. 맞힌 수가 적으면, 시험 질문과 학습 질문의 말투가 얼마나 다른지부터 봅니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

실제 데이터로 해 보려면

내 가게나 회사의 안내 글로 같은 세 단계를 밟을 수 있습니다. 구하기 쉬운 순서로 적었습니다. 개인정보와 저작권을 먼저 확인하세요.

자료구하는 곳내용
내 가게의 안내문과 FAQ누리집, 메뉴판, 공지를 옮겨 적기수백 줄이면 연습에 충분하다
기관의 자주 묻는 질문공공데이터포털에서 "FAQ" 검색질문과 답이 짝지어 있어 SFT 데이터로 바로 쓸 수 있다
한국어 공개 말뭉치AI Hub, 국립국어원 모두의 말뭉치(신청 필요)사전학습용 긴 글. 이용 조건이 자료마다 다르다

실제 서비스로 쓰려면 이 장의 작은 GPT 대신 5장처럼 공개된 지시 모델에 LoRA 로 SFT 하는 편이 훨씬 낫습니다. 이 프로젝트는 세 단계가 각각 무엇을 하는지 손으로 익히는 데 목적이 있습니다.

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

토크나이저, 사전학습, SFT, 시험 채점의 네 상자를 화살표로 이은 빵집 맞춤 모델 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 bakery_corpus.txt 로 BPE 토크나이저를 어휘 2,000개까지 학습해(bpe.train) out_bakery_tok 에 저장하고, 2단계(마젠타)에서 같은 글로 작은 GPT(GPT2LMHeadModel)를 사전학습해 out_bakery_pre 에 저장합니다. 3단계(다홍)는 그 모델을 불러와 학습용 60쌍으로 SFT 를 하는데, 질문 자리의 정답은 -100 으로 둡니다. 4단계(빨강)는 answer(q) 로 시험 질문 20개에 답하게 하고, 답에 정답 열쇠가 들어 있는지(r["정답열쇠"] in a)로 맞힌 수를 셉니다.

결과 보기 (예시 풀이)

1단계. 토크나이저 학습

Python
import json, math, os
import torch
from tokenizers import ByteLevelBPETokenizer
from transformers import (PreTrainedTokenizerFast, GPT2Config, GPT2LMHeadModel, Trainer,
                          TrainingArguments, DataCollatorForLanguageModeling, set_seed)
from datasets import Dataset

bpe = ByteLevelBPETokenizer()
bpe.train(["bakery_corpus.txt"], vocab_size=2000, min_frequency=2, special_tokens=["<pad>", "<eos>"])
os.makedirs("out_bakery_tok", exist_ok=True)
bpe.save("out_bakery_tok/tokenizer.json")
tok = PreTrainedTokenizerFast(tokenizer_file="out_bakery_tok/tokenizer.json", pad_token="<pad>", eos_token="<eos>")
print("어휘:", tok.vocab_size, "/ '통밀 깜파뉴' →", [tok.decode([i]) for i in tok("통밀 깜파뉴")["input_ids"]])
출력
어휘: 835 / '통밀 깜파뉴' → ['�', '���', '��', ' 깜파뉴']

2단계. 사전학습

Python
with open("bakery_corpus.txt", encoding="utf-8") as f:
    lines = [line for line in f.read().splitlines() if line]
ds = Dataset.from_dict({"text": lines}).train_test_split(test_size=0.05, seed=42)
BLOCK = 128

def group(batch):
    ids = sum(batch["input_ids"], [])
    n = len(ids) // BLOCK * BLOCK
    return {"input_ids": [ids[i:i + BLOCK] for i in range(0, n, BLOCK)]}

lm_data = ds.map(lambda b: tok([t + "<eos>" for t in b["text"]]), batched=True, remove_columns=["text"]) \
            .map(group, batched=True, remove_columns=["attention_mask"])

set_seed(42)
config = GPT2Config(vocab_size=len(tok), n_positions=BLOCK, n_embd=256, n_layer=4, n_head=4,
                    bos_token_id=tok.eos_token_id, eos_token_id=tok.eos_token_id, pad_token_id=tok.pad_token_id)
model = GPT2LMHeadModel(config)
args = TrainingArguments("out_bakery_ckpt", per_device_train_batch_size=32, num_train_epochs=8, learning_rate=1e-3,
                         warmup_steps=50, save_strategy="no", report_to="none", seed=42,
                         fp16=torch.cuda.is_available(), disable_tqdm=True, logging_steps=1000)
trainer = Trainer(model=model, args=args, train_dataset=lm_data["train"], eval_dataset=lm_data["test"],
                  data_collator=DataCollatorForLanguageModeling(tok, mlm=False))
trainer.train()
print("검증 퍼플렉서티:", round(math.exp(trainer.evaluate()["eval_loss"]), 2))
trainer.model.save_pretrained("out_bakery_pre")
tok.save_pretrained("out_bakery_pre")
출력
{'train_runtime': '18.77', 'train_samples_per_second': '116.4', 'train_steps_per_second': '3.836', 'train_loss': '2.567', 'epoch': '8'}
{'eval_loss': '0.6612', 'eval_runtime': '0.0429', 'eval_samples_per_second': '303', 'eval_steps_per_second': '46.62', 'epoch': '8'}
검증 퍼플렉서티: 1.94

3단계. SFT

Python
with open("bakery_qa.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 prompt(q):
    return f"### 질문:\n{q}\n### 답변:\n"

def encode(batch):
    input_ids, labels = [], []
    for q, a in zip(batch["질문"], batch["답변"]):
        p, r = tok(prompt(q))["input_ids"], tok(a + "<eos>")["input_ids"]
        input_ids.append(p + r)
        labels.append([-100] * len(p) + r)
    return {"input_ids": input_ids, "labels": labels}

def collate(features):
    L = max(len(f["input_ids"]) for f 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, f in enumerate(features):
        n = len(f["input_ids"])
        ids[i, :n], lab[i, :n], att[i, :n] = torch.tensor(f["input_ids"]), torch.tensor(f["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()))
set_seed(42)
sft_args = TrainingArguments("out_bakery_ckpt", per_device_train_batch_size=16, num_train_epochs=30,
                             learning_rate=5e-4, save_strategy="no", report_to="none", seed=42,
                             fp16=torch.cuda.is_available(), disable_tqdm=True, logging_steps=1000)
sft_trainer = Trainer(model=GPT2LMHeadModel.from_pretrained("out_bakery_pre"), args=sft_args,
                      train_dataset=sft_data, data_collator=collate)
sft_trainer.train()
print("SFT 끝:", len(train_rows), "쌍")
출력
{'train_runtime': '10.41', 'train_samples_per_second': '172.9', 'train_steps_per_second': '11.53', 'train_loss': '0.4867', 'epoch': '30'}
SFT 끝: 60 쌍

4단계. 시험 질문 채점

Python
model = sft_trainer.model.eval()

def answer(q):
    enc = tok(prompt(q), return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**enc, max_new_tokens=60, do_sample=False,
                             eos_token_id=tok.eos_token_id, pad_token_id=tok.pad_token_id)
    return tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()

hits = 0
for r in test_rows:
    a = answer(r["질문"])
    ok = r["정답열쇠"] in a
    hits += ok
    print("O" if ok else "X", f"[{r['의도']}]", r["질문"], "→", a)
print(f"맞힌 수: {hits} / {len(test_rows)}")
출력
X [영업시간] 영업시간 알려 주세요. → 안녕하세요. 카드, 현금, 간편결제, 지역화폐를 모두 받습니다. 맛있게 드세요.
X [영업시간] 오늘 몇 시까지 열어요? → 안녕하세요. 매주 월요일은 쉽니다. 공휴일이 월요일이면 화요일에 쉽니다. 맛있게 드세요.
X [휴무일] 쉬는 요일을 알려 주세요. → 안녕하세요. 받으시려는 날의 2일 전까지 전화로 예약해 주세요. 맛있게 드세요.
X [휴무일] 언제 쉬어요? → 안녕하세요. 매일 오전 8시에 열고 오후 9시에 닫습니다. 맛있게 드세요.
O [주차] 주차 공간을 알려 주세요. → 안녕하세요. 가게 뒤편에 2대까지 30분 무료로 주차할 수 있습니다. 맛있게 드세요.
O [주차] 주차는 어디에 해요? → 안녕하세요. 가게 뒤편에 2대까지 30분 무료로 주차할 수 있습니다. 맛있게 드세요.
X [케이크 예약] 케이크 예약 기한을 알려 주세요. → 안녕하세요. 카드, 현금, 간편결제, 지역화폐를 모두 받습니다. 맛있게 드세요.
O [케이크 예약] 당일 케이크 주문 되나요? → 안녕하세요. 받으시려는 날의 2일 전까지 전화로 예약해 주세요. 맛있게 드세요.
X [비건 빵] 비건 빵을 알려 주세요. → 안녕하세요.
O [비건 빵] 동물성 재료 없는 빵 있어요? → 안녕하세요. 통밀 깜파뉴와 두부 베이글이 비건 빵이고, 화요일과 금요일에 나옵니다. 맛있게 드세요.
X [알레르기] 알레르기 표시를 알려 주세요. → 안녕하세요. 매주 월요일은 쉽니다. 공휴일이 월요일이면 화요일에 쉽니다. 맛있게 드세요.
X [알레르기] 호두 들어간 빵 있어요? → 안녕하세요. 카드, 현금, 간편결제, 지역화폐를 모두 받습니다. 맛있게 드세요.
O [배달] 배달 방법을 알려 주세요. → 안녕하세요. 가게에서 2km 안은 2만원 이상 주문하시면 배달해 드립니다. 맛있게 드세요.
O [배달] 배달 가능 지역이 어디예요? → 안녕하세요. 가게에서 2km 안은 2만원 이상 주문하시면 배달해 드립니다. 맛있게 드세요.
X [포장] 선물 포장 방법을 알려 주세요. → 안녕하세요. 카드, 현금, 간편결제, 지역화폐를 모두 받습니다. 맛있게 드세요.
X [포장] 포장 비용이 있나요? → 안녕하세요. 카드, 현금, 간편결제, 지역화폐를 모두 받습니다. 맛있게 드세요.
O [결제] 결제 수단을 알려 주세요. → 안녕하세요. 카드, 현금, 간편결제, 지역화폐를 모두 받습니다. 맛있게 드세요.
X [결제] 무엇으로 계산해요? → 안녕하세요. 매주 월요일은 쉽니다. 공휴일이 월요일이면 화요일에 쉽니다. 맛있게 드세요.
X [당일 빵] 마감 할인 시간을 알려 주세요. → 안녕하세요. 매일 오전 8시에 열고 오후 9시에 닫습니다. 맛있게 드세요.
X [당일 빵] 저녁 할인 있어요? → 안녕하세요. 통밀 깜파뉴와 두부 베이글이 비건 빵이고, 화요일과 금요일에 나옵니다. 맛있게 드세요.
맞힌 수: 7 / 20

빵집 글로 만든 토크나이저는 어휘 835개에서 멈췄고, "깜파뉴"를 한 토큰으로 배웠습니다. 사전학습 뒤 검증 퍼플렉서티는 1.94 였고, SFT 뒤 시험 질문 20개 가운데 7개를 맞혔습니다. 의도별로 보면 주차, 배달은 두 질문을 모두 맞혔고, 영업시간, 휴무일, 알레르기, 포장, 마감 할인은 모두 틀렸습니다. 틀린 답은 깨진 글자 없이 빵집의 말투로 매끄럽지만, "언제 쉬어요?"에 영업시간을, "쉬는 요일을 알려 주세요"에 케이크 예약을 답하는 식으로 다른 의도의 답을 가져왔습니다. "비건 빵을 알려 주세요"에는 "안녕하세요."만 하고 멈췄습니다. 실습 6의 쇼핑몰(18/24)보다 낮은 까닭은 SFT 데이터가 60쌍으로 더 적고, 시험 질문의 말투("알려 주세요", "언제")가 학습 질문과 다르기 때문입니다. 나아지게 하려면 의도마다 질문을 다양하게 더 모으거나, 큰 지시 모델에 LoRA 로 SFT 하거나(5장), 질문에 맞는 안내문을 찾아 함께 넣는(6장의 RAG) 방법이 있습니다. 쇼핑몰 모델에 빵집 데이터만으로 SFT 했을 때 20개를 모두 틀린 것과 견주면, 우리 분야의 글로 토크나이저와 사전학습부터 맞춘 덕은 분명합니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 쇼핑몰에서 해 본 직원 양성 과정을 동네 빵집에 처음부터 다시 밟는 것과 같습니다. 빵집 글로 빵집 전용 줄임말 사전을 만들고(1단계), 새 직원에게 빵집 글을 여덟 바퀴 읽히고(2단계), 질문과 답 60쌍으로 응대 연습을 시킨 뒤(3단계), 처음 보는 질문 20개로 시험합니다(4단계). 여기서 줄임말 사전이 토크나이저, 읽히기가 사전학습, 응대 연습이 SFT 입니다. 비유와 다른 점은, 사람은 질문의 뜻을 헤아려 답을 고르지만 이 작은 모델은 질문의 겉 낱말에 끌려 답을 고른다는 것입니다.

단계출력뜻
1. 어휘8352,000개를 목표로 했지만 빵집 글에서는 835개에서 멈췄습니다
1. 조각' 깜파뉴'"깜파뉴"를 한 조각으로 배웠습니다. 앞의 깨진 글자 셋은 "통밀" 두 글자가 바이트 조각 셋으로 갈린 모양입니다
2. 학습 시간train_runtime 18.77이 PC 에서 사전학습이 19초쯤 걸렸습니다. 기계마다 다릅니다
2. 퍼플렉서티1.94빵집 글의 다음 토큰을 거의 확신합니다. 실습 5의 쇼핑몰(1.70)과 비슷한 수준입니다
3. SFTSFT 끝: 60 쌍질문과 답 60쌍으로 서른 바퀴 연습을 마쳤습니다
4. 맞힌 의도주차, 배달 각 두 문제 O이 두 의도는 질문 말투가 달라도 맞혔습니다
4. 틀린 답"언제 쉬어요?" → 영업시간말투는 빵집 직원답지만 다른 의도의 답을 가져왔습니다
4. 멈춘 답"비건 빵을 알려 주세요." → "안녕하세요."인사만 하고 멈췄습니다
4. 맞힌 수7 / 20처음 보는 질문의 35%입니다. 실습 6의 쇼핑몰(18/24)보다 낮습니다

한 줄로 줄이면, 빵집 글로 세 단계를 처음부터 밟으면 빵집 낱말과 말투는 익히지만, 작은 모델과 60쌍의 데이터로는 질문의 뜻을 가려 맞는 답을 고르기 어렵다는 것을 보인 프로젝트입니다. 시험 질문이 20개뿐이라 7개라는 숫자는 흐름으로 읽어야 하고, 정답 낱말이 들어 있는지만 본 채점입니다. 큰 지시 모델에 LoRA 로 가르치는 방법은 5장에서 다룹니다.

← 이전 절4장 · 실습 6 — 상담 말투로 SFT 하기다음 절 →4장 · 정리