개요

이 편은 SSAFY < 35 >~< 37 >, < 39 >실습편이고, 이번 파이썬 시리즈 다섯 편의 마지막이다.

이론 (SSAFY 시리즈) 실습 (이 글)
< 35 > 컴퓨터의 수 체계 1장 — 정밀도별 메모리 계산
< 36 > 모델 경량화 세 가지 2장 — Full Fine-tuning이 막히는 지점
< 37 > LoRA와 QLoRA 3~4장 — 실제로 붙여서 학습
< 39 > 정수연산 양자화 5장 — PTQ INT4 추론

그리고 시리즈 안에서도 한 바퀴가 닫힌다. < 10 >에서 LLM으로 몬스터 도감 합성 데이터를 만들고 Judge로 걸러냈는데, 그 데이터를 이번 편에서 학습에 쓴다. 만들고, 거르고, 이제 그걸로 모델을 고친다.

이번 글이 답할 질문 16GB짜리 그래픽카드로 대형 언어 모델을 손볼 수 있나? 줄이면 뭘 잃나?

실습 자료는 SSAFY 교육 컨텐츠라 원본을 싣지 않는다. 개념만 가져와 예제는 전부 새로 짰다. 실습은 체스 기보로 학습했는데 여기서는 몬스터 도감으로 바꿨다.

그리고 이 글의 메모리 숫자는 전부 계산값이다. 파라미터 수에 바이트를 곱한 것이고, 내가 잰 값이 아니다. 실제 사용량은 GPU와 설정에 따라 달라진다.


1. 숫자 하나에 몇 바이트를 쓸 것인가

모델은 결국 숫자 뭉치다. 7B 모델이라는 건 그 숫자가 70억 개라는 뜻이다.

그러면 숫자 하나를 몇 바이트로 저장하느냐가 그대로 용량이 된다.

정밀도별 메모리

정밀도 숫자 하나당 7B 가중치 무엇에 쓰나
FP32 4바이트 26.1 GB 학습 기본값, 옵티마이저 내부
FP16 / BF16 2바이트 13.0 GB 요즘 학습·추론 기본값
INT8 1바이트 6.5 GB 추론 최적화
NF4 0.5바이트 3.3 GB QLoRA

계산은 단순하다. 70억 × 4바이트 ÷ 1024³ = 26.1GB. 가중치만 올려놔도 FP32는 16GB 카드에 아예 안 들어가고, FP16도 8할을 먹는다.

FP16BF16은 둘 다 16비트인데 나누는 방식이 다르다. FP16은 소수점 아래 자리에 비트를 더 쓰고, BF16은 표현할 수 있는 범위를 넓게 잡는다. 학습 중에는 값이 크게 튀는 일이 있어서 요즘은 BF16을 더 많이 쓴다.

NF4는 그냥 4비트가 아니다. 가중치가 0 근처에 몰려 있다는 걸 전제로 눈금을 촘촘하게 잡아둔 4비트다. 균등하게 자르는 것보다 같은 비트로 손실이 덜하다.

모델 크기라는 게 결국 숫자 개수 × 숫자 하나의 크기다. 개수를 못 줄이면 크기를 줄이는 수밖에 없다.


2. 전부 학습하려면 얼마나 드나

추론만 하면 가중치만 올리면 된다. 학습은 다르다.

무엇이 얼마나 7B / FP16 기준
모델 가중치 파라미터 × 2바이트 13 GB
기울기(gradient) 파라미터 × 2바이트 13 GB
옵티마이저 상태 파라미터 × 8바이트 52 GB
활성화값 배치·길이에 따라 수 GB

옵티마이저가 제일 크다. AdamW는 파라미터마다 평균과 분산 두 개를 따로 들고 있고, 그것도 정확도 때문에 FP32로 둔다. 그래서 7e9 × 4바이트 × 2 = 52GB가 나온다.

다 합치면 80GB를 넘는다. 16GB 카드로는 근처에도 못 간다.

여기서 방향이 갈린다.

  • 파라미터를 덜 학습한다 → PEFT
  • 숫자를 작게 저장한다 → 양자화
  • 둘 다 한다 → QLoRA

학습이 추론보다 몇 배로 비싼 이유는 가중치가 아니라 기울기와 옵티마이저 때문이다. 그러니 줄일 자리도 거기다.


3. LoRA — 옆에 얇은 행렬 두 개를 붙인다

< 11 >에서 전이학습을 하면서 “어디를 잠그고 어디를 풀 것인가” 를 봤다. LoRA는 그걸 극단까지 민 것이다.

원래 가중치는 아예 안 건드린다. 대신 그 옆에 얇은 행렬 두 개를 새로 달고 그것만 학습한다.

LoRA 분해

$$ h = Wx + \frac{\alpha}{r} BAx $$

W가 원래 가중치고 잠겨 있다. BA가 새로 붙인 것이다. B는 세로로 길고 A는 가로로 긴데, 곱하면 W와 같은 모양이 된다. 그래서 결과를 그냥 더할 수 있다.

숫자로 보면 왜 이득인지 바로 보인다. 4096 × 4096 행렬 하나면

전체 학습:  4096 × 4096       = 16,777,216 개
LoRA (r=8): 4096 × 8 + 8 × 4096 =     65,536 개   → 0.39%

0.4%만 학습한다. 기울기도 옵티마이저 상태도 저 0.4%에만 붙으니까, 2장에서 본 52GB짜리 옵티마이저가 사실상 사라진다.

r과 lora_alpha — 두 숫자가 하는 일이 다르다

여기서 제일 헷갈렸다. 둘 다 크게 하면 세지는 것 같은데 역할이 다르다.

  하는 일 키우면
r (rank) 붙이는 행렬의 두께 표현력이 늘고 학습 파라미터도 는다
lora_alpha 결과에 곱하는 세기 파라미터 수는 그대로인데 영향력만 커진다

수식의 α/r이 핵심이다. r을 8에서 16으로 올리면 두께가 두 배가 되는데, 그러면 값이 커지는 쪽으로 쏠린다. α를 같이 올려주면 α/r이 유지돼서 세기가 그대로 남는다.

그래서 alpha = r × 2 라는 관례가 나온다. r=8이면 alpha=16, r=16이면 alpha=32. r을 바꿔가며 실험할 때 세기까지 같이 흔들리지 않게 하려는 것이다.

target_modules — 어디에 붙일 것인가

두 번째로 헷갈린 게 이거였다. 붙일 수 있는 자리가 일곱 개나 된다.

model = FastModel.get_peft_model(
    model,
    r=8,
    lora_alpha=16,
    lora_dropout=0.0,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",   # Attention
        "gate_proj", "up_proj", "down_proj",      # MLP
    ],
)

앞의 넷은 Attention 쪽이다. < 10 >에서 손으로 짠 그 Attention이 맞다. 질문(q)·열쇠(k)·값(v)을 만드는 자리와, 나온 걸 다시 합치는 자리(o)다.

뒤의 셋은 MLP 쪽으로, Attention이 고른 정보를 실제로 가공하는 자리다.

어디에 붙이나 파라미터 효과
Attention만 (q,k,v,o) 적다 무난하다. 예전에 많이 쓰던 조합
전부 (+ gate,up,down) 는다 요즘 권장. 도메인이 많이 다를수록 유리
q,v 제일 적다 원 논문 조합. 아주 아낄 때

적게 붙일수록 싸고, 많이 붙일수록 잘 배운다. 파라미터가 늘어도 어차피 원본의 1% 언저리라 요즘은 전부 붙이는 쪽을 많이 쓴다.

QLoRA — 잠긴 원본까지 4비트로

여기에 하나 더 얹으면 QLoRA다. 잠가둘 원본을 4비트로 눌러서 올린다.

model_name = "unsloth/gemma-3-1b-it-unsloth-bnb-4bit"   # bnb-4bit 가 바로 그 부분
model, tokenizer = FastModel.from_pretrained(model_name, max_seq_length=1024)

어차피 안 배울 가중치니 정밀도를 깎아도 학습에는 크게 지장이 없다. 대신 붙이는 AB는 16비트로 둔다. 이쪽은 실제로 배우는 부분이라 정밀도가 필요하다.

LoRA는 얼마나 붙일지(r)와 얼마나 세게 반영할지(α)가 다른 문제다. 그리고 QLoRA는 거기에 “잠근 건 대충 저장해도 된다”를 더한 것이다.


4. 형식을 맞춰야 학습이 된다 — Chat Template

모델을 준비했으면 데이터를 넣는데, 그냥 문장으로 넣으면 안 된다.

대화형 모델은 “누가 말했는지”를 특수 토큰으로 구분해서 학습돼 있다. 그 형식을 안 맞추면 모델이 자기가 아는 모양이 아니라고 판단한다.

< 10 >에서 만든 몬스터 도감 데이터를 이 형식으로 바꾼다.

def convert_to_chatml(example):
    return {
        "conversations": [
            {"role": "system",    "content": "너는 게임 몬스터 도감을 쓰는 작가다."},
            {"role": "user",      "content": example["name"]},
            {"role": "assistant", "content": example["description"]},
        ]
    }

dataset = dataset.map(convert_to_chatml)

assistant 자리에 정답이 들어간다. 모델이 맞혀야 할 답을 미리 넣어두고, 그걸 따라 쓰게 학습시키는 것이다.

add_generation_prompt — 학습과 추론이 다르다

세 번째로 헷갈린 게 여기다. 같은 함수인데 옵션이 반대로 들어간다.

Chat Template

# 학습 — 정답까지 들어간 완성된 대화
text = tokenizer.apply_chat_template(convo, tokenize=False,
                                     add_generation_prompt=False)

# 추론 — 모델 차례를 열어둔 채로 넘긴다
text = tokenizer.apply_chat_template(messages, tokenize=False,
                                     add_generation_prompt=True)

True로 두면 끝에 “이제 모델이 말할 차례”라는 토큰을 붙여준다. 대화가 열린 채로 끝나니까 모델이 그 뒤를 이어 쓴다.

학습 때는 정답이 이미 붙어 있으니 그게 필요 없다. 오히려 붙이면 빈 차례가 하나 더 생겨서 이상한 형식을 배운다.

반대로 추론에서 False로 두면 모델이 자기 차례인 줄 모른다. 대화가 닫혀 있으니 아무것도 안 쓰거나 엉뚱한 걸 이어 붙인다.

  학습 추론
add_generation_prompt False True
assistant 자리 정답이 들어 있다 비어 있다
틀리면 이상한 형식을 배운다 응답을 안 하거나 이상해진다

답변만 채점하기

기본 설정으로 학습하면 모든 토큰에서 손실이 잡힌다. 시스템 프롬프트도, 사용자 질문도 모델이 따라 쓰도록 배운다.

from unsloth.chat_templates import train_on_responses_only

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

이게 하는 일은 손실을 어디서 잴지 정하는 것이다. 질문 구간의 labels-100으로 덮어버린다. 파이토치 CrossEntropyLoss-100인 자리를 건너뛰게 되어 있어서 그 구간은 채점에서 빠진다.

입력 토큰    [user 마커]   서리 늑대   [model 마커]   눈보라 속에서 ...
labels         -100         -100         -100        눈보라 속에서 ...
               (채점 안 함)  (채점 안 함)  (채점 안 함)   (여기만 채점)

입력에서 빼는 게 아니라는 게 중요하다. 질문 토큰은 그대로 모델에 들어가고 어텐션도 다 받는다. 조건으로는 읽되 “이걸 생성해라”라고는 안 가르치는 것뿐이다.

안 그러면 모델이 답변만이 아니라 질문까지 이어 쓰는 버릇이 든다. 도감 설명을 부탁했는데 다음 질문까지 지어내는 식이다.

다만 프롬프트에도 손실을 주는 게 언제나 나쁜 건 아니다. 응답이 짧을 때는 오히려 도움이 된다는 얘기도 있어서, 무조건 켤 것이라기보다 응답이 길 때 챙기면 되는 옵션에 가깝다.

마커 문자열은 모델마다 다르다

조심할 게 하나 있다. <start_of_turn>Gemma 형식이다. 다른 모델에 그대로 쓰면 매칭이 안 되는데, 에러가 안 나고 조용히 지나간다. 마스킹이 안 걸린 채로 학습이 그냥 돌아버린다.

Gemma          <start_of_turn>user
Llama 3        <|start_header_id|>user<|end_header_id|>
Qwen / ChatML  <|im_start|>user

끝에 줄바꿈까지 넣는 것도 이유가 있다. 마커 다음 줄바꿈을 포함해야 토큰 경계가 실제 템플릿과 맞아떨어진다. 확실하지 않으면 tokenizer.apply_chat_template을 한 번 찍어보고 나온 문자열을 그대로 복사해 오는 게 안전하다.

파인튜닝에서 데이터 형식은 취향이 아니다. 그 모델이 학습된 모양 그대로 맞춰줘야 하고, 학습과 추론에서 끝나는 방식이 다르다.


5. 추론에서 다시 줄인다 — INT4와 피크 메모리의 함정

학습이 끝났으면 이제 서비스에 올릴 차례다. 여기서 다시 줄인다.

학습 때 쓴 QLoRA의 4비트와 지금 할 4비트는 목적이 다르다.

  QLoRA의 4비트 PTQ INT4
언제 학습 중 학습 다 끝나고
잠긴 원본을 싸게 올리려고 추론을 싸게 하려고
학습 어댑터는 16비트로 배운다 아무것도 안 배운다

PTQ는 이미 학습이 끝난 모델을 그냥 눌러 담는 것이다. 재학습이 없어서 빠르고, 대신 품질 손실을 감수한다.

from transformers import BitsAndBytesConfig

quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,                      # 4비트로 올린다
    bnb_4bit_quant_type="nf4",              # 균등이 아니라 NF4 눈금으로
    bnb_4bit_compute_dtype=torch.float16,   # 저장은 4비트, 계산은 16비트
    bnb_4bit_use_double_quant=True,         # 눈금 정보까지 한 번 더 압축
)

model = AutoModelForCausalLM.from_pretrained(
    model_name, quantization_config=quantization_config, device_map="auto",
)

bnb_4bit_compute_dtype이 흥미로웠다. 저장만 4비트고 계산은 16비트로 되돌려서 한다. 4비트로 곱셈을 하는 게 아니라, 쓸 때마다 풀어서 계산한다는 뜻이다.

가중치는 줄었는데 왜 별로 안 줄지

네 번째로 걸린 게 이거였다. 4비트로 바꾸면 메모리가 4분의 1이 될 줄 알았는데 그만큼 안 줄었다.

모델 메모리와 피크 메모리

재는 게 두 가지라서 그렇다.

지표 언제 재나 뭐가 들어 있나
모델 메모리 로딩 직후 가중치만
피크 메모리 추론 중 최대 가중치 + 활성화값 + KV 캐시 + 임시 버퍼

양자화는 가중치만 압축한다. 추론 중에 생기는 활성화값과 KV 캐시는 여전히 16비트다. 그러니 모델 메모리는 크게 줄어도 피크 메모리는 그만큼 안 준다.

그래서 모델이 클수록 양자화가 잘 먹힌다. 70B쯤 되면 전체에서 가중치 비중이 압도적이라 줄인 만큼 그대로 이득이지만, 1B짜리는 가중치를 줄여도 나머지가 그대로라 체감이 적다.

4비트로 바꿨는데 메모리가 기대만큼 안 줄었다면 잘못 잰 게 아니다. 줄어드는 건 아래칸뿐이고 윗칸은 그대로다.


6. 모델을 안 고치고 입력으로 메꾼다 — TTP

줄였으면 대가를 치른다. 양자화 모델은 입력이 조금만 흐트러져도 더 크게 흔들린다.

같은 질문을 이렇게 넣어보면 차이가 난다.

유형
정상 “서리 늑대는 어떤 몬스터야?”
오타 “서리 늑데는 어떤 몬스터야?”
군더더기 “음… 그 서리 늑대? 그거 어떤 몬스터였지 대충”
맥락 부족 “그거 뭐였지?”

원본 모델은 오타를 그럭저럭 넘기는데, 4비트 모델은 여기서 더 자주 헛나간다.

이유가 세 가지로 갈린다.

  1. 오차가 쌓인다 — 가중치마다 생긴 작은 오차가 수십억 번 연산을 거치며 누적된다
  2. 볼 곳을 잘못 고른다 — Attention 점수가 미세하게 어긋나면 다른 토큰에 집중하게 된다
  3. 드문 패턴이 먼저 깨진다 — 자주 본 패턴은 가중치가 강해서 버티는데, 드문 패턴은 약해서 눌리는 순간 사라진다

여기서 모델을 다시 손대지 않고 해결하는 방법이 나온다. Test-time Prompting(TTP) — 추론할 때 프롬프트를 강화하는 것이다.

TTP_TEMPLATE = """너는 게임 몬스터 도감을 쓰는 작가다.

규칙:
- 서식지, 공격 방식, 약점을 순서대로 쓴다
- 각 항목은 한 문장으로 쓴다
- 모르는 몬스터면 "도감에 없습니다"라고만 답한다

예시)
질문: 늪지 두꺼비
답변: 습지대에 무리 지어 산다. 혀로 원거리 공격을 한다. 불 속성에 약하다.

질문: {question}
답변:"""

prompt = TTP_TEMPLATE.format(question="서리 늑데는 어떤 몬스터야?")

넣은 게 네 가지다.

넣은 것 무엇을 막나
규칙 명시 아무 형식으로나 답하는 것
출력 형식 지정 길이와 순서가 매번 달라지는 것
예시 한 개 모양을 못 잡는 것
모를 때의 답 지정 지어내는 것

재학습이 없다. 파일 하나 안 바꾸고 문자열만 갈아끼우는데 눈에 띄게 안정된다. < 10 >에서 본 Few-shot과 같은 도구인데, 여기서는 양자화로 잃은 걸 메우는 용도로 쓰인다.

물론 이게 만능은 아니다. 프롬프트가 길어지면 입력 토큰이 늘어 비용이 오르고, 애초에 깨진 지식을 되살리지는 못한다. 어디까지나 흔들리는 걸 붙잡아주는 정도다.

줄이는 쪽만 보면 손해처럼 보이는데, 잃은 걸 다른 층에서 메울 수 있다. 모델을 못 고치는 상황이 훨씬 많다는 걸 생각하면 이게 오히려 현실적인 수단이다.


정리

  • 모델 크기는 숫자 개수 × 숫자 하나의 크기다. 7B 가중치만 FP32로 26.1GB, FP16으로 13.0GB, NF4로 3.3GB (파라미터 수로 계산한 값)
  • 학습이 비싼 건 가중치가 아니라 기울기와 옵티마이저 때문이다. AdamW는 파라미터마다 평균·분산 두 개를 FP32로 들고 있어서 7B면 그것만 52GB다
  • LoRA는 원본을 잠그고 옆에 얇은 행렬 두 개만 학습한다. 4096 × 4096 행렬 기준 r=8이면 학습 대상이 0.39%로 줄어든다
  • r은 두께, lora_alpha는 세기다. 수식의 α/r 때문에 r을 키우면 α도 같이 키워야 세기가 유지된다. 그래서 alpha = r × 2 관례가 생겼다
  • target_modules는 적게 붙이면 싸고 많이 붙이면 잘 배운다. 요즘은 Attention과 MLP 일곱 자리에 전부 붙이는 쪽을 많이 쓴다
  • add_generation_prompt는 학습에서 False, 추론에서 True다. 추론에서 False로 두면 모델이 자기 차례인 줄 모른다
  • 양자화는 가중치만 압축한다. 활성화값과 KV 캐시는 그대로라 모델 메모리는 줄어도 피크 메모리는 덜 준다. 모델이 클수록 효과가 크다
  • 줄이면 오타·모호한 입력에서 먼저 흔들린다. 그런데 모델을 안 고치고 프롬프트에 규칙·형식·예시를 넣는 것만으로 상당 부분 붙잡힌다

참고 자료

한줄 평

  • 다섯 편을 지나고 보니 결국 같은 질문이었다 — 어디를 잠그고 어디를 풀 것인가 -> 그리고 못 푸는 자리가 나오면 모델 밖에서 메꾸면 된다는 걸 마지막에 배웠다!