Fine-tuning 실습 — LoRA 학습부터 Ollama 등록까지

Qwen2.5-0.5B-Instruct 에 LoRA 로 말투와 답변 형식을 학습시키고, 병합 → 전후 비교 → GGUF 변환 → Ollama 등록까지 처음부터 끝까지 직접 돌려 보는 실습입니다. 0.5B 모델이라 노트북에서도 수 분 안에 끝납니다. 개념은 파인튜닝을 먼저 보세요.

이 실습의 목표는 "좋은 모델"이 아니라 파이프라인 전체를 한 번 손으로 겪어 보는 것입니다. 파인튜닝이 지식이 아니라 행동을 바꾼다는 것을 눈으로 확인할 수 있게, 결과가 바로 보이는 말투·형식 학습을 주제로 잡았습니다.

전체 흐름

학습 단계는 하드웨어에 따라 A(Mac) 또는 B(NVIDIA GPU) 중 하나만 따라 하면 됩니다. 병합 결과물은 둘 다 Hugging Face 형식(safetensors) 디렉터리라서, 6단계부터는 공통입니다.

준비물

하드웨어

경로요구 사항비고
A. Apple Silicon (MLX)M1 이상 Mac, 통합 메모리 8GB 이상이 문서의 기본 경로. 0.5B LoRA 학습 시 피크 메모리 약 2.6GB
B. NVIDIA GPU (TRL + PEFT)CUDA GPU, VRAM 8GB 이상 권장 (Colab T4 16GB 가능)Linux / WSL2 / Colab. 0.5B 는 Apple Silicon(MPS)에서도 돌아갑니다
공통디스크 여유 5GB 이상원본 모델 약 1GB + 병합 모델 + GGUF

⚠️ 함정: 여기 적은 메모리는 0.5B 모델 기준입니다. 7B 모델이면 LoRA 에 1624GB, 4비트 QLoRA 로도 610GB 가 필요합니다. 모델만 바꿔서 그대로 돌리면 대부분 메모리 부족으로 죽습니다.

소프트웨어

도구용도설치
Python 3.10+학습·변환 스크립트-
huggingface_hub모델 다운로드 (hf CLI)pip install -U huggingface_hub
MLX-LM 또는 TRL/PEFT학습각 단계에서 설치
git, CMakellama.cpp (양자화 시에만 빌드 필요)brew install cmake
Ollama로컬 실행ollama.com/download

작업 디렉터리

모든 명령은 ~/ft-lab 에서 실행한다고 가정합니다.

~/ft-lab/
├── data/                  # train.jsonl, valid.jsonl, test.jsonl
├── models/
│   ├── qwen2.5-0.5b/      # 원본
│   ├── qwen2.5-0.5b-ft/   # 병합된 모델
│   └── gguf/              # 변환 결과
├── adapters/              # LoRA 어댑터
└── llama.cpp/

1. 모델 다운로드

mkdir -p ~/ft-lab && cd ~/ft-lab
python3 -m venv .venv && source .venv/bin/activate
python -c "import platform; print(platform.machine())"   # Mac 이면 arm64 여야 합니다

pip install -U huggingface_hub
hf download Qwen/Qwen2.5-0.5B-Instruct --local-dir models/qwen2.5-0.5b

⚠️ 함정: Apple Silicon 에서 x86_64(Rosetta) 파이썬으로 가상환경을 만들면 mlx 휠을 찾지 못해 설치가 실패합니다. 위 명령이 x86_64 를 출력하면 arm64 파이썬(Homebrew /opt/homebrew/bin/python3, uv venv --python cpython-3.12-macos-aarch64-none 등)으로 다시 만드세요.

베이스로 Instruct 모델을 씁니다. 이미 지시를 따를 줄 아는 모델 위에 "말투"만 얹는 것이 데이터가 적을 때 가장 안정적입니다.

2. 데이터셋 만들기

목표 행동 정하기

학습시킬 행동을 검증 가능하게 정합니다. 이 실습의 규칙은 다음과 같습니다.

  • 첫 줄은 한줄요약: 으로 시작한다.
  • 본문은 2~3문장이다.
  • 모든 답의 마지막은 냥. 으로 끝난다.

규칙이 명확해야 8단계에서 "학습이 됐는지"를 코드로 채점할 수 있습니다.

JSONL 형식

한 줄에 샘플 하나, chat(messages) 형식입니다. MLX-LM 과 TRL 모두 이 형식을 그대로 읽고 모델의 chat template 을 적용합니다.

{"messages": [{"role": "user", "content": "고양이는 어떤 동물인가요?"}, {"role": "assistant", "content": "한줄요약: 독립적이고 호기심 많은 반려동물이다냥.\n고양이는 약 1만 년 전부터 사람과 함께 살아온 육식성 포유류다냥. 혼자 있는 시간을 즐기지만 보호자와 유대감도 깊게 쌓는다냥."}]}
{"messages": [{"role": "user", "content": "HTTP 와 HTTPS 의 차이는?"}, {"role": "assistant", "content": "한줄요약: HTTPS 는 암호화된 HTTP 다냥.\nHTTPS 는 TLS 로 통신 내용을 암호화해서 중간에서 엿보거나 바꾸지 못하게 한다냥. 요즘 브라우저는 HTTP 사이트에 경고를 띄운다냥."}]}
{"messages": [{"role": "system", "content": "너는 사내 IT 도우미다."}, {"role": "user", "content": "비밀번호를 잊어버렸어요"}, {"role": "assistant", "content": "한줄요약: 셀프 초기화 페이지에서 재설정하면 된다냥.\n사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정할 수 있다냥. 인증이 안 되면 헬프데스크에 사번과 함께 문의하면 된다냥."}]}

직접 작성하거나, 강한 LLM 으로 초안을 만든 뒤 사람이 검수해서 최소 50~100건data/all.jsonl 에 모읍니다. 질문 주제는 최대한 다양하게 섞으세요. 한 주제만 있으면 말투가 아니라 그 주제를 외웁니다.

⚠️ 함정: JSON 문자열 안의 줄바꿈은 반드시 \n 으로 이스케이프해야 합니다. 에디터에서 실제 줄바꿈을 넣으면 JSONL 한 줄이 깨져 로딩 단계에서 실패합니다.

검증하고 분할하기

형식 검사와 train/valid/test 분할을 한 번에 합니다. split.py:

import json
import random
from pathlib import Path

random.seed(42)
rows = []
for i, line in enumerate(Path("data/all.jsonl").read_text(encoding="utf-8").splitlines(), 1):
    if not line.strip():
        continue
    row = json.loads(line)  # 깨진 줄이면 여기서 줄 번호와 함께 실패
    msgs = row["messages"]
    assert msgs[-1]["role"] == "assistant", f"{i}행: 마지막 메시지는 assistant 여야 합니다"
    assert msgs[-1]["content"].rstrip().endswith("냥."), f"{i}행: 규칙 위반(냥.)"
    rows.append(row)

random.shuffle(rows)
n = len(rows)
splits = {
    "train": rows[: int(n * 0.8)],
    "valid": rows[int(n * 0.8): int(n * 0.9)],
    "test": rows[int(n * 0.9):],
}
for name, items in splits.items():
    with open(f"data/{name}.jsonl", "w", encoding="utf-8") as f:
        for r in items:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")
    print(name, len(items))
python split.py
# train 80 / valid 10 / test 10  (100건 기준)

test.jsonl 은 학습에 절대 쓰지 않고 8단계 평가에만 씁니다.

⚠️ 함정: MLX-LM 은 valid.jsonl 샘플 수가 --batch-size 보다 적으면 Dataset must have at least batch_size=4 examples 에러로 멈춥니다. 데이터가 적으면 배치 크기를 줄이거나 검증 샘플을 늘리세요.

3-A. 학습 — Apple Silicon (MLX-LM)

pip install "mlx-lm[train]"

mlx_lm.lora \
  --model models/qwen2.5-0.5b \
  --train \
  --data data \
  --fine-tune-type lora \
  --mask-prompt \
  --batch-size 4 \
  --num-layers 16 \
  --iters 200 \
  --steps-per-eval 50 \
  --adapter-path adapters
옵션의미
--data data디렉터리 안의 train.jsonl(필수), valid.jsonl(검증)을 읽음
--fine-tune-typelora(기본), dora, full
--mask-prompt사용자 입력은 loss 계산에서 빼고 답변 토큰만 학습
--num-layersLoRA 를 적용할 레이어 수 (기본 16)
--iters학습 반복 횟수. 80건·배치 4 면 20 iter 가 1 에폭
--learning-rate기본 1e-5 (MLX 는 LoRA scale 기본값이 커서 이 값으로도 잘 학습됩니다). 효과가 약하면 올려 봅니다
--adapter-path어댑터 저장 위치 (adapters.safetensors)

로그의 Train lossVal loss 를 함께 보세요. Val loss 가 내려가다 다시 오르기 시작하면 과적합이므로 --iters 를 줄여 다시 돌립니다.

병합 전에 어댑터만 붙여서 바로 확인할 수도 있습니다.

mlx_lm.generate --model models/qwen2.5-0.5b --adapter-path adapters \
  --prompt "고양이는 어떤 동물인가요?"

4-A. 어댑터 병합 (MLX)

mlx_lm.fuse \
  --model models/qwen2.5-0.5b \
  --adapter-path adapters \
  --save-path models/qwen2.5-0.5b-ft

⚠️ 함정: mlx_lm.fuse --export-gguf 옵션은 llama / mistral / mixtral 계열만 지원합니다. Qwen 은 에러가 나므로 6단계처럼 llama.cpp 로 변환하세요.

5-A. 전후 비교 (MLX)

# 원본
mlx_lm.generate --model models/qwen2.5-0.5b --prompt "고양이는 어떤 동물인가요?"

# 파인튜닝 이후
mlx_lm.generate --model models/qwen2.5-0.5b-ft --prompt "고양이는 어떤 동물인가요?"

mlx_lm.generate 는 기본적으로 토크나이저의 chat template 을 적용합니다. 학습 데이터에 없던 질문으로도 꼭 확인하세요. 학습에 쓴 질문에만 말투가 나오면 외운 것입니다.

3-B. 학습 — NVIDIA GPU (TRL + PEFT)

pip install torch transformers datasets trl peft accelerate

train.py:

import torch
from datasets import load_dataset
from peft import LoraConfig
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import SFTConfig, SFTTrainer

BASE = "models/qwen2.5-0.5b"

ds = load_dataset(
    "json",
    data_files={"train": "data/train.jsonl", "validation": "data/valid.jsonl"},
)

# messages → prompt / completion 으로 나누면
# TRL 이 기본으로 completion(답변) 토큰에만 loss 를 계산합니다.
def to_prompt_completion(example):
    msgs = example["messages"]
    return {"prompt": msgs[:-1], "completion": msgs[-1:]}

ds = ds.map(to_prompt_completion, remove_columns=["messages"])

tokenizer = AutoTokenizer.from_pretrained(BASE)
model = AutoModelForCausalLM.from_pretrained(BASE, dtype=torch.bfloat16)

peft_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules="all-linear",
    task_type="CAUSAL_LM",
)

args = SFTConfig(
    output_dir="outputs/qwen-lora",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,   # 유효 배치 16
    learning_rate=1e-4,              # 어댑터 학습은 Full FT 보다 높게
    lr_scheduler_type="cosine",
    warmup_steps=0.05,               # 0~1 사이 float 는 전체 스텝 대비 비율
    max_length=1024,
    logging_steps=5,
    eval_strategy="epoch",
    save_strategy="epoch",
    bf16=True,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=ds["train"],
    eval_dataset=ds["validation"],
    processing_class=tokenizer,
    peft_config=peft_config,
)
trainer.train()

trainer.save_model("adapters-hf")   # PEFT 모델이면 어댑터만 저장됩니다
python train.py

⚠️ 함정: T4 처럼 bf16 을 지원하지 않는 GPU 에서는 dtype=torch.float32 로 로드하고 bf16=False, fp16=True 로 바꾸세요. 그대로 두면 시작하자마자 에러가 납니다.

위 코드는 TRL 1.x (SFTConfig(max_length=...), processing_class=) 기준입니다. 예전 블로그 글의 max_seq_length, tokenizer=, DataCollatorForCompletionOnlyLM 는 현재 버전에서 이름이 바뀌었거나 제거됐습니다.

4-B. 어댑터 병합 (PEFT)

merge.py:

import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

BASE = "models/qwen2.5-0.5b"
OUT = "models/qwen2.5-0.5b-ft"

base = AutoModelForCausalLM.from_pretrained(BASE, dtype=torch.bfloat16)
model = PeftModel.from_pretrained(base, "adapters-hf")
merged = model.merge_and_unload()          # W + BA 를 원본 가중치에 합침

merged.save_pretrained(OUT)
AutoTokenizer.from_pretrained(BASE).save_pretrained(OUT)
python merge.py

⚠️ 함정: QLoRA(4비트)로 학습했더라도 병합은 위처럼 16비트 원본 베이스를 새로 불러와서 하세요. 4비트 모델에 병합하면 품질이 떨어집니다.

5-B. 전후 비교 (Transformers)

compare.py:

import sys
from transformers import pipeline

prompt = [{"role": "user", "content": "고양이는 어떤 동물인가요?"}]
for path in ["models/qwen2.5-0.5b", "models/qwen2.5-0.5b-ft"]:
    pipe = pipeline("text-generation", model=path, device_map="auto")
    out = pipe(prompt, max_new_tokens=200, do_sample=False)
    print(f"=== {path}\n{out[0]['generated_text'][-1]['content']}\n")
python compare.py

6. GGUF 형식으로 변환 (llama.cpp)

Ollama 는 GGUF 또는 Safetensors 를 가져올 수 있습니다. 여기서는 가장 호환성이 넓은 GGUF 로 변환합니다.

cd ~/ft-lab
git clone https://github.com/ggml-org/llama.cpp

# llama.cpp 의 requirements 는 transformers 등 버전을 고정하므로 별도 가상환경을 권장합니다
python3 -m venv .venv-gguf && source .venv-gguf/bin/activate
pip install -r llama.cpp/requirements.txt
pip install -U transformers   # 아래 함정 참고

mkdir -p models/gguf
python llama.cpp/convert_hf_to_gguf.py models/qwen2.5-0.5b-ft \
  --outfile models/gguf/qwen2.5-0.5b-ft-f16.gguf \
  --outtype f16

--outtypef32, f16, bf16, q8_0, auto 등을 받습니다. 0.5B 는 f16 이어도 1GB 남짓이라 그대로 써도 됩니다.

⚠️ 함정: llama.cpp 의 requirements.txt 는 호환성 때문에 transformers 4 를 설치합니다. 그런데 최신 MLX-LM·TRL(transformers 5)이 저장한 토크나이저 설정은 transformers 4 에서 읽지 못해 AttributeError: 'list' object has no attribute 'keys' 로 변환이 실패합니다. llama.cpp 문서 안내대로 pip install -U transformers 로 올리면 해결됩니다. (uv pip 을 쓴다면 requirements 설치 시 --index-strategy unsafe-best-match 가 필요합니다.)

Ollama 에 넣기 전에 llama.cpp 로 바로 확인해 볼 수도 있습니다(아래 양자화 단계의 빌드가 필요합니다).

./llama.cpp/build/bin/llama-cli -m models/gguf/qwen2.5-0.5b-ft-f16.gguf \
  -p "고양이는 어떤 동물인가요?" -st

(선택) 양자화

더 큰 모델이라면 4비트로 줄이는 것이 일반적입니다. Ollama 는 GGUF 를 가져올 때 양자화해 주지 않으므로 llama.cpp 로 미리 해 둡니다.

cmake -S llama.cpp -B llama.cpp/build
cmake --build llama.cpp/build --config Release -j 8   # 바이너리는 llama.cpp/build/bin/

./llama.cpp/build/bin/llama-quantize \
  models/gguf/qwen2.5-0.5b-ft-f16.gguf \
  models/gguf/qwen2.5-0.5b-ft-Q4_K_M.gguf \
  Q4_K_M

⚠️ 함정: 작은 모델일수록 양자화 손실이 크게 체감됩니다. 0.5B 를 Q4 로 줄이면 학습한 말투가 흐려질 수 있습니다. 양자화 후에도 반드시 5단계와 같은 비교를 다시 하세요.

7. Ollama 에 등록

models/gguf/Modelfile:

FROM ./qwen2.5-0.5b-ft-f16.gguf

TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
"""

PARAMETER stop "<|im_start|>"
PARAMETER stop "<|im_end|>"
PARAMETER temperature 0.7
  • FROM 의 상대 경로는 Modelfile 위치 기준입니다.
  • Qwen 계열은 ChatML(<|im_start|> / <|im_end|>) 형식을 씁니다. 학습 때 적용된 chat template 과 추론 템플릿이 같아야 학습한 행동이 나옵니다.
  • 위 템플릿은 단일 턴용 최소 형태입니다. 멀티턴·도구 호출까지 필요하면 ollama pull qwen2.5:0.5bollama show qwen2.5:0.5b --modelfile 로 공식 템플릿을 복사해 쓰세요.
cd ~/ft-lab/models/gguf
ollama create qwen-neko -f Modelfile
ollama run qwen-neko "고양이는 어떤 동물인가요?"

⚠️ 함정: 파인튜닝한 모델이 Ollama 에서만 말투가 안 나오거나 답이 끝나지 않고 계속 이어진다면, 거의 항상 TEMPLATE·stop 토큰 불일치입니다. 모델 가중치를 의심하기 전에 ollama show qwen-neko --template 로 템플릿부터 확인하세요.

GGUF 대신 병합된 Safetensors 디렉터리를 FROM 으로 바로 가져오는 방법도 있습니다. 다만 Ollama 가 지원하는 아키텍처일 때만 동작하고, ollama create --quantize 로 생성 시 양자화하는 기능도 Safetensors 가져오기에서만 됩니다(지원 양자화 타입은 버전마다 달라 ollama create --help 로 확인하세요). GGUF 에 --quantize 를 주면 에러가 납니다.

8. 간단 평가 — 규칙 준수율 측정

"몇 개 물어보니 잘 되더라"로 끝내지 말고, 학습에 쓰지 않은 test.jsonl원본과 파인튜닝 모델을 같은 기준으로 채점합니다. 규칙이 명확하므로 LLM Judge 없이 코드로 채점할 수 있습니다.

eval.py:

import json
import urllib.request

def chat(model, messages):
    body = json.dumps({"model": model, "messages": messages, "stream": False,
                       "options": {"temperature": 0}}).encode()
    req = urllib.request.Request("http://localhost:11434/api/chat", data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as r:
        return json.load(r)["message"]["content"]

def passes(text):
    t = text.strip()
    return t.startswith("한줄요약:") and t.endswith("냥.")

tests = [json.loads(l) for l in open("data/test.jsonl", encoding="utf-8")]
for model in ["qwen2.5:0.5b", "qwen-neko"]:
    ok = sum(passes(chat(model, t["messages"][:-1])) for t in tests)
    print(f"{model}: {ok}/{len(tests)} ({ok / len(tests):.0%})")
ollama pull qwen2.5:0.5b   # 비교용 원본
python eval.py

규칙 준수율만 보면 안 됩니다. 말투는 맞췄는데 내용이 틀려졌거나 일반 질문 품질이 떨어졌는지(망각) 도 몇 건은 직접 읽어 보세요. 체계적인 방법은 평가LLM Judge에 있습니다.

트러블슈팅

증상원인해결
학습 후에도 말투 변화가 없음학습량 부족, 학습률이 낮음, 데이터가 너무 적음iters·epoch 또는 학습률을 올리고, 샘플 수를 늘림
모든 질문에 같은 문장을 반복과적합, 데이터 다양성 부족iters 축소, 주제 다양화, 중복 샘플 제거
일반 질문에 대한 답이 이상해짐망각, 학습량 과다학습량 축소, 일반 대화 샘플을 일부 섞기
학습 질문에만 말투가 나옴암기test 셋으로 확인, 데이터 다양화
Ollama 에서만 결과가 다름 / 답이 안 끝남TEMPLATE·stop 토큰 불일치7단계 템플릿 확인
convert_hf_to_gguf.py 에서 아키텍처 미지원 에러llama.cpp 가 오래됨git pull 로 최신화 후 requirements 재설치
CUDA OOM배치·시퀀스 길이 과다per_device_train_batch_size 를 줄이고 gradient_accumulation_steps 를 늘림, max_length 축소

참고 자료