메인 콘텐츠로 건너뛰기
이 가이드에서는 VESSL Cloud에서 QLoRAUnsloth를 사용해 Gemma 4 E4B를 파인튜닝하는 전체 과정을 다뤄요. 가이드를 마치면 공유 스토리지에 저장된 파인튜닝 어댑터를 바로 추론이나 팀 협업에 활용할 수 있어요.

사전 준비

시작하기 전에 다음 항목을 확인해 주세요:
  • 크레딧이 충전된 VESSL Cloud 계정 (가입하기)
  • A100 SXM 80 GB GPU 인스턴스에 접근 가능한 조직
  • Python과 Hugging Face Transformers에 대한 기본 지식
VESSL Cloud가 처음이라면 멤버 퀵스타트를 먼저 완료해서 계정, 결제, 스토리지를 설정해 주세요.

Workspace 만들기

1

스토리지 볼륨 설정하기

이 워크플로우에는 두 가지 스토리지가 필요해요:
스토리지 타입마운트 경로용도
Cluster storage/root홈 디렉토리. pip 패키지와 conda 환경이 Workspace 재시작 후에도 유지돼요.
Object storage/shared모델 체크포인트와 결과물. 모든 클러스터에서 접근 가능하고 팀원과 공유할 수 있어요.
왜 Cluster storage를 /root에 마운트하나요? 홈 디렉토리($HOME)는 pip이 패키지를 설치하는 기본 경로예요. 여기에 Cluster storage를 마운트하면 pip install을 한 번만 실행하면 돼요 — Workspace를 일시정지/재개해도 패키지가 그대로 남아있어요.왜 Object storage를 /shared에 마운트하나요? 파인튜닝한 모델 가중치는 다른 Workspace나 클러스터에서도 접근할 수 있어야 해요. Object storage는 S3 기반이라 어디서든 접근 가능하고, 팀원과 결과를 공유하거나 다른 리전에서 배포하기 편해요.Workspace를 만들기 전에 두 볼륨을 모두 생성해 주세요:
  • Cluster storage: 사이드바에서 Cluster storage를 클릭하고 Create new volume을 눌러주세요. 자세한 내용은 스토리지 개요를 참고해 주세요.
  • Object storage: 사이드바에서 Object storage를 클릭하고 Create new volume을 눌러주세요. 자세한 내용은 볼륨 만들기를 참고해 주세요.
Object storage를 /root에 마운트하지 마세요. Object storage는 Cluster storage보다 느려서 메인 작업공간으로 적합하지 않아요. /shared 같은 별도 마운트 포인트를 사용해 주세요.
2

Workspace 실행하기

아래 설정으로 새 Workspace를 만들어 주세요:
설정
GPUA100 SXM 80 GB
GPU 수량1
이미지pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel
Cluster storage클러스터 볼륨을 /root에 마운트
Object storage오브젝트 볼륨을 /shared에 마운트
전체 생성 과정은 Workspace 만들기를 참고해 주세요.
4비트 양자화(QLoRA)를 사용하면 E4B 모델의 VRAM 사용량이 약 18-22 GB 수준이에요. A100의 80 GB에서 충분한 여유가 있어서 배치 사이즈를 늘리거나 시퀀스 길이를 더 길게 설정할 수도 있어요.
3

Workspace에 접속하기

Workspace 상태가 Running으로 바뀌면 JupyterLab이나 SSH로 접속해 주세요. Workspace 접속하기를 참고해 주세요.

패키지 설치

Workspace 터미널을 열고 필요한 라이브러리를 설치해 주세요:
pip install unsloth trl transformers datasets
Cluster storage를 /root에 마운트했다면 이 패키지들은 Workspace를 일시정지/재개해도 유지돼요. 한 번만 실행하면 돼요.

모델 로드

from unsloth import FastModel

model, tokenizer = FastModel.from_pretrained(
    model_name="unsloth/gemma-4-E4B-it",
    max_seq_length=2048,
    load_in_4bit=True,
    full_finetuning=False,
)
load_in_4bit=True가 하는 일: 기본적으로 모델 파라미터는 16비트 부동소수점으로 로드되는데, 4비트 양자화는 QLoRA(NF4) 기법으로 가중치를 4비트로 압축해요. 이렇게 하면 메모리 사용량이 약 4배 줄어들어서, 원래 ~32 GB의 VRAM이 필요한 모델이 ~8-10 GB로 들어가요. 품질 저하는 미미한데, 고정된 기본 가중치만 양자화되고 LoRA 어댑터는 전체 정밀도로 학습하기 때문이에요.

LoRA 어댑터 설정

model = FastModel.get_peft_model(
    model,
    finetune_vision_layers=False,
    finetune_language_layers=True,
    finetune_attention_modules=True,
    finetune_mlp_modules=True,
    r=8,
    lora_alpha=8,
    lora_dropout=0,
    bias="none",
    random_state=3407,
)

하이퍼파라미터 설명

파라미터설명
r8LoRA 랭크 — 저랭크 어댑터의 용량을 결정해요. 값이 높을수록(16, 32) 복잡한 패턴을 포착하지만 메모리를 더 사용하고 소규모 데이터셋에서 과적합 위험이 있어요. 일반 태스크는 8로 시작하세요.
lora_alpha8스케일링 팩터 — 어댑터 출력이 얼마나 증폭되는지 결정해요. 보통 r과 같은 값으로 설정해요. 어댑터의 실질 학습률은 lora_alpha / r에 비례해요.
lora_dropout0드롭아웃 비율 — 학습 중 어댑터 출력을 0으로 만들 확률이에요. 소규모 데이터셋에서는 0으로 설정하고, 대규모 데이터셋에서 과적합이 보이면 0.05-0.1로 올려보세요.
finetune_vision_layersFalseGemma 4는 멀티모달 모델이에요. 텍스트만 학습할 때는 False로 설정해서 비전 인코더 레이어를 건너뛰세요.
finetune_language_layersTrue언어 모델 레이어를 학습해요.
finetune_attention_modulesTrue어텐션(Q, K, V, O) 프로젝션 행렬을 학습해요.
finetune_mlp_modulesTrue피드포워드(MLP) 레이어를 학습해요. 어텐션 모듈과 함께 모델에서 가장 영향력 있는 부분을 커버해요.
bias"none"바이어스 항을 학습하지 않아요. 품질 저하 없이 어댑터 크기를 작게 유지해요.
random_state3407재현성을 위한 랜덤 시드예요.
r을 올려야 하는 경우:
  • 복잡한 도메인 적응 (의료, 법률, 코드): r=16 시도
  • 대규모 다양한 데이터셋 (10만개 이상): r=16 또는 r=32 시도
  • 단순한 스타일 변환이나 포맷 맞추기: r=8이면 충분해요

데이터셋 준비

이 예제에서는 FineTome-100k 데이터셋에서 3,000개 샘플을 사용해 빠른 데모를 진행해요. 프로덕션에서는 전체 데이터셋을 사용하거나 자체 데이터로 대체해 주세요.
from unsloth.chat_templates import get_chat_template, standardize_data_formats
from datasets import load_dataset

tokenizer = get_chat_template(tokenizer, chat_template="gemma-4")
dataset = load_dataset("mlabonne/FineTome-100k", split="train[:3000]")
dataset = standardize_data_formats(dataset)

def formatting_prompts_func(examples):
    convos = examples["conversations"]
    texts = [
        tokenizer.apply_chat_template(
            convo, tokenize=False, add_generation_prompt=False
        ).removeprefix("<bos>")
        for convo in convos
    ]
    return {"text": texts}

dataset = dataset.map(formatting_prompts_func, batched=True)
데이터셋은 각 항목에 conversations 필드가 있는 JSON 파일이어야 해요. rolecontent가 포함된 메시지 객체의 리스트 형태예요:
[
  {
    "conversations": [
      {"role": "user", "content": "프랑스의 수도는 어디인가요?"},
      {"role": "assistant", "content": "프랑스의 수도는 파리예요."}
    ]
  },
  {
    "conversations": [
      {"role": "user", "content": "광합성을 간단히 설명해 주세요."},
      {"role": "assistant", "content": "광합성은 식물이 햇빛, 물, 이산화탄소를 포도당과 산소로 변환하는 과정이에요."}
    ]
  }
]
다음과 같이 로드하면 돼요:
dataset = load_dataset("json", data_files="/shared/my-data.json", split="train")
dataset = standardize_data_formats(dataset)
dataset = dataset.map(formatting_prompts_func, batched=True)
3,000개 샘플은 데모 목적이에요. 의미 있는 품질 향상을 위해서는 전체 10만개 데이터셋이나 최소 1만-2만개의 고품질 자체 데이터를 사용해 주세요.

학습

from trl import SFTTrainer, SFTConfig
from unsloth.chat_templates import train_on_responses_only

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    args=SFTConfig(
        dataset_text_field="text",
        per_device_train_batch_size=1,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        max_steps=60,
        learning_rate=2e-4,
        logging_steps=1,
        optim="adamw_8bit",
        weight_decay=0.001,
        lr_scheduler_type="linear",
        seed=3407,
        report_to="none",
        output_dir="/shared/gemma4-finetuned",
    ),
)

trainer = train_on_responses_only(
    trainer,
    instruction_part="<|turn>user\n",
    response_part="<|turn>model\n",
)

trainer_stats = trainer.train()

학습 파라미터 설명

파라미터설명
per_device_train_batch_size1GPU당 포워드 패스에 사용할 샘플 수. VRAM에 맞추기 위해 1로 설정해요.
gradient_accumulation_steps44스텝마다 그래디언트를 누적한 후 가중치를 업데이트해요. 실질 배치 사이즈 = 1 x 4 = 4.
warmup_steps5처음 5스텝 동안 학습률을 선형으로 올려서 초기 학습을 안정화해요.
max_steps60총 학습 스텝 수. 데모용이에요 — 전체 학습을 하려면 max_steps를 제거하고 num_train_epochs=1 (또는 그 이상)로 설정해 주세요.
learning_rate2e-4QLoRA 파인튜닝에 일반적으로 사용하는 학습률이에요.
optim"adamw_8bit"8비트 AdamW 옵티마이저. 양자화된 옵티마이저 상태를 사용해서 표준 AdamW 대비 ~2 GB의 VRAM을 절약해요.
weight_decay0.001과적합 방지를 위한 L2 정규화예요.
lr_scheduler_type"linear"학습 과정에서 학습률을 0까지 선형으로 감소시켜요.
report_to"none"외부 로깅(Weights & Biases 등)을 비활성화해요. 실험 추적이 필요하면 "wandb"로 설정하세요.
output_dir"/shared/gemma4-finetuned"Object storage에 체크포인트를 저장해서 영속성과 공유가 가능하게 해요.
train_on_responses_only가 하는 일: 기본적으로 전체 대화(사용자 + 어시스턴트 턴)에 대해 손실을 계산하는데, 이 옵션은 사용자 턴을 마스킹해서 모델이 어시스턴트 응답만 학습하게 해요. 학습 효율이 올라가고 모델이 사용자 프롬프트를 외우는 것을 방지해요.

평가

학습이 끝나면 모델 성능을 정성적, 정량적으로 확인해 보세요.

학습 손실 확인

trainer_stats 객체에 학습 로그가 담겨 있어요. 손실 곡선이 감소하면 모델이 학습하고 있다는 뜻이에요:
import json

# 최종 학습 손실 출력
print(f"Final training loss: {trainer_stats.training_loss:.4f}")

# 로그 히스토리에서 스텝별 손실 확인
for entry in trainer_stats.log_history[-5:]:
    if "loss" in entry:
        print(f"  Step {entry['step']}: loss = {entry['loss']:.4f}")
정상적인 손실 곡선은 높은 값(2-3 이상)에서 시작해 꾸준히 감소해요. 너무 일찍 정체되면 r을 올리거나 데이터를 더 사용해 보세요. 손실이 급등하거나 발산하면 학습률을 낮춰보세요.

학습 전후 비교

파인튜닝된 모델로 동일한 프롬프트를 실행해서 효과를 확인해 보세요:
from transformers import TextStreamer

messages = [
    {"role": "user", "content": [
        {"type": "text", "text": "Explain quantum computing in simple terms."}
    ]}
]

_ = model.generate(
    **tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
    ).to("cuda"),
    max_new_tokens=256,
    use_cache=True,
    temperature=0.7,
    streamer=TextStreamer(tokenizer, skip_prompt=True),
)
더 엄밀한 평가를 위해 데이터를 미리 분할하고 홀드아웃 부분의 손실을 계산할 수 있어요:
# 학습 전에 데이터셋 분할
split = dataset.train_test_split(test_size=0.1, seed=3407)
train_dataset = split["train"]
eval_dataset = split["test"]

# SFTTrainer에 eval_dataset 전달
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    args=SFTConfig(
        # ... 위와 동일한 설정 + 추가:
        eval_strategy="steps",
        eval_steps=20,
        # ...
    ),
)
이렇게 하면 일정 간격으로 평가 손실을 보고해서, 과적합(학습 손실은 감소하는데 평가 손실은 증가)을 감지할 수 있어요.

모델 저장

파인튜닝된 어댑터와 토크나이저를 Object storage에 저장해요:
model.save_pretrained("/shared/gemma4-finetuned/final")
tokenizer.save_pretrained("/shared/gemma4-finetuned/final")
/shared가 Object storage에 마운트되어 있으므로 저장된 모델은:
  • 영속적 — Workspace를 종료해도 유지돼요
  • 크로스 클러스터 — 어느 리전의 Workspace에서든 접근할 수 있어요
  • 팀 공유 가능 — 해당 볼륨에 접근 가능한 팀원 누구나 어댑터를 로드할 수 있어요
다른 Workspace에서 어댑터를 로드하려면:
from unsloth import FastModel

model, tokenizer = FastModel.from_pretrained(
    model_name="unsloth/gemma-4-E4B-it",
    max_seq_length=2048,
    load_in_4bit=True,
    full_finetuning=False,
)

from peft import PeftModel
model = PeftModel.from_pretrained(model, "/shared/gemma4-finetuned/final")

Gemma 4 모델 비교

GPU와 태스크 복잡도에 맞는 Gemma 4 변형을 선택해 주세요:
모델파라미터권장 GPUQLoRA VRAM 추정치적합한 용도
Gemma 4 E2B2BT4 16 GB, L4 24 GB~6-8 GB빠른 실험, 엣지 배포
Gemma 4 E4B4BA100 40/80 GB, L4 24 GB~10-14 GB품질과 효율의 최적 균형
Gemma 4 12B12BA100 80 GB~18-24 GB더 높은 품질, 단일 GPU 파인튜닝
Gemma 4 27B27BA100 80 GB (빠듯함), 2xA100~32-40 GB프론티어급 품질, 더 많은 VRAM 필요
VRAM 추정치는 QLoRA 4비트 양자화, 배치 사이즈 1, 시퀀스 길이 2048 기준이에요. 실제 사용량은 배치 사이즈, 시퀀스 길이, 그래디언트 누적 설정에 따라 달라져요.

다음 단계

  • 자체 데이터 활용 — FineTome-100k를 도메인 특화 대화 데이터로 대체해서 맞춤형 성능 향상을 달성해 보세요.
  • DPO 또는 ORPO 적용 — SFT 이후 선호도 최적화(DPO/ORPO)를 적용해서 모델을 원하는 방향으로 더 정렬할 수 있어요.
  • 스케일 업 — 더 높은 품질을 위해 12B 또는 27B 모델로 이동해 보세요. 대규모 데이터셋에는 r=16 또는 r=32를 사용하세요.
  • GGUF 내보내기 — 파인튜닝된 모델을 GGUF 포맷으로 변환해서 llama.cpp나 Ollama로 로컬 추론을 할 수 있어요.
  • 배치 잡으로 실행VESSL Cloud 배치 잡을 사용해서 파인튜닝을 스케줄링 가능한 재현 가능 파이프라인으로 실행해 보세요.
  • vesslctl로 자동화vesslctl job create 한 줄로 이 실험을 재현 가능한 배치 잡으로 제출할 수 있어요. Jupyter 셀 대신 train.py에 담고, 터미널이나 CI 파이프라인에서 바로 돌려보세요.