LLM 엔지니어링15장 · 모델 경량화: 양자화, 프루닝, 지식증류16 / 22← 이전목차다음 →MetaContents

15장. 모델 경량화: 양자화, 프루닝, 지식증류

실습 3 — Ollama 의 양자화 단계 견주기

같은 Qwen2.5-0.5B-Instruct 를 Ollama 의 세 단계(Q4_K_M, Q8_0, F16)로 받아 파일 크기, GPU 에 올라간 크기, 생성 속도를 재고, 7장의 가상 회사 규정 질문 12개를 같은 검색 조각과 함께 물어 정답 수를 견줍니다. 미리 ollama pull qwen2.5:0.5b-instruct-q8_0 과 ollama pull qwen2.5:0.5b-instruct-fp16 으로 받아 두어야 합니다(qwen2.5:0.5b 가 Q4_K_M 입니다).

1단계. 규정 조항과 검색 준비

Python
import json, re, time, urllib.request
import numpy as np
from sentence_transformers import SentenceTransformer

articles = [l.strip() for l in open("company_rules.txt", encoding="utf-8") if re.match(r"^제\d+조\(", l)]
qa = [json.loads(line) for line in open("company_rules_qa.jsonl", encoding="utf-8")][:12]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")     # GPU 는 Ollama 에 비워 둔다
A = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
Q = emb.encode(["query: " + x["질문"] for x in qa], normalize_embeddings=True)
contexts = ["\n".join(articles[i] for i in np.argsort(-(A @ q))[:3]) for q in Q]    # 모든 모델에 같은 조각을 준다
print(len(articles), "조항 ·", len(qa), "질문")
출력
30 조항 · 12 질문

2단계. Ollama 를 부르고 재는 함수

Python
def post(path, body):
    req = urllib.request.Request("http://localhost:11434" + path, data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    return json.loads(urllib.request.urlopen(req, timeout=300).read())


def unload_all():
    for m in json.loads(urllib.request.urlopen("http://localhost:11434/api/ps").read())["models"]:
        post("/api/generate", {"model": m["name"], "keep_alive": 0})


SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 한 문장으로 답하세요."


BROKEN = re.compile(r"(.)\1{9,}")             # 같은 글자가 10번 넘게 이어지면 깨진 답으로 본다(예: 8888…)
retried = {"수": 0}


def ask(model, context, question):
    """답과 함께 Ollama 가 재 준 생성 토큰 수(eval_count)와 생성 시간(eval_duration, 나노초)을 돌려준다.
    깨진 답이 나오면 모델을 내렸다가 두 번까지 다시 묻는다(10차시)"""
    for _ in range(3):
        r = post("/api/chat", {"model": model, "stream": False, "options": {"temperature": 0, "seed": 42, "num_predict": 80},
                                "messages": [{"role": "system", "content": SYSTEM},
                                             {"role": "user", "content": f"[조항]\n{context}\n\n[질문]\n{question}"}]})
        answer = r["message"]["content"].strip()
        if not BROKEN.search(answer):
            break
        retried["수"] += 1
        post("/api/generate", {"model": model, "keep_alive": 0})
    return answer, r["eval_count"], r["eval_duration"] / 1e9, r.get("load_duration", 0) / 1e9


def correct(answer, key):
    return any(k in answer.replace(" ", "") for k in key.replace(" ", "").split("|"))


unload_all()
print(ask("qwen2.5:0.5b", contexts[0], qa[0]["질문"]))
출력
('출근 시간을 늦출 수 없습니다. [조항]에 따라 출근 시각은 한 달 단위로 정해 팀장에게 알립니다.', 39, 0.280477, 3.7211338)

3단계. 세 단계 견주기

Python
tags = {"Q4_K_M": "qwen2.5:0.5b", "Q8_0": "qwen2.5:0.5b-instruct-q8_0", "F16": "qwen2.5:0.5b-instruct-fp16"}
sizes = {m["name"]: m["size"] for m in json.loads(urllib.request.urlopen("http://localhost:11434/api/tags").read())["models"]}
rows, answers = [], {}
for level, model in tags.items():
    unload_all()
    _, _, _, load_sec = ask(model, contexts[0], qa[0]["질문"])               # 처음 한 번: 모델을 올린다
    vram = {m["name"]: m for m in json.loads(urllib.request.urlopen("http://localhost:11434/api/ps").read())["models"]}[model]
    right, tokens, secs = 0, 0, 0.0
    retried["수"] = 0
    for x, ctx in zip(qa, contexts):
        a, n, s, _ = ask(model, ctx, x["질문"])
        right += correct(a, x["정답열쇠"])
        tokens, secs = tokens + n, secs + s
        answers[(level, x["질문"])] = a
    rows.append({"단계": level, "파일MB": round(sizes[model] / 1e6), "GPU MB": round(vram["size_vram"] / 1e6),
                 "올리기(초)": round(load_sec, 1), "초당토큰": round(tokens / secs, 1), "정답": f"{right}/{len(qa)}", "다시 물음": retried["수"]})
import pandas as pd
print(pd.DataFrame(rows).to_string(index=False))
unload_all()
출력
    단계  파일MB  GPU MB  올리기(초)  초당토큰   정답  다시 물음
Q4_K_M   398     484     2.4 153.4 6/12      0
  Q8_0   531     617     3.1 143.9 7/12      0
   F16   994    1080     3.4  80.8 8/12      0

4단계. 단계마다 답이 갈린 질문 보기

Python
for x in qa:
    got = {lv: correct(answers[(lv, x["질문"])], x["정답열쇠"]) for lv in tags}
    if len(set(got.values())) > 1:                       # 단계마다 맞고 틀림이 다른 질문만
        print(x["질문"], f"(열쇠 {x['정답열쇠']})")
        for lv in tags:
            print(f"   {lv} {'O' if got[lv] else 'X'}: {answers[(lv, x['질문'])][:60]}")
출력
입사 첫해에는 휴가가 어떻게 생기나요? (열쇠 개근|한 달)
   Q4_K_M X: 입사 첫해에는 1년을 채우면 15일이 생깁니다. 2년마다 25일까지 생깁니다.
   Q8_0 O: 입사 첫해에는 한 달 개근할 때마다 연차 하루가 생깁니다.
   F16 O: 입사 첫해에는 한 달 개근할 때마다 연차 하루가 생깁니다.
연차는 최대 며칠까지 늘어나요? (열쇠 25일)
   Q4_K_M X: 제6조에 따르면, 연차는 반나절(4시간) 단위로 나눠 쓸 수 있습니다. 따라서 연차는 4시간 단위로 15일이
   Q8_0 O: 연차는 최대 25일까지 늘어납니다.
   F16 O: 연차는 최대 25일까지 늘어납니다.
아내가 아기를 낳으면 며칠 쉴 수 있나요? (열쇠 10일)
   Q4_K_M O: 제9조에서 "아파서 쉬면 1년에 10일까지 유급 병가를 씁니다."는 "아내가 아기를 낳으면 1년에 10일까지
   Q8_0 X: 제9조에 따르면, 아내가 아기를 낳으면 5일의 경조 휴가가 줄어듭니다.
   F16 X: 제9조에 따르면, 아내가 아기를 낳으면 5일의 경조 휴가가 며칠 쉬어 줄 수 있습니다.
아파서 나흘 쉬면 무엇을 내야 하나요? (열쇠 진단서)
   Q4_K_M X: 아파서 3일 쉬는다면, 3일 전까지는 유급 병가를 받게 됩니다.
   Q8_0 X: 아파서 나흘 쉬면 1년에 10일까지 유급 병가를 씁니다.
   F16 O: 아파서 나흘 쉬면 진단서를 냅니다.
부산 출장 가면 호텔비는 얼마까지 나와요? (열쇠 10만원)
   Q4_K_M O: 서울과 광역시: 10만원, 그 밖: 8만원까지
   Q8_0 X: 서울과 광역시의 하루 3만원, 그 밖의 지역은 8만원까지 줍니다.
   F16 O: 서울과 광역시는 하룻밤 10만원, 그 밖의 지역은 8만원까지 줍니다.
법인카드로 70만원을 결제하려면 어떻게 해요? (열쇠 품의)
   Q4_K_M X: 법인카드로 70만원을 결제하려면 다음과 같은 방법이 있습니다:

1. 50만원을 법인카드로 쓰세요.
2. 5
   Q8_0 O: 법인카드로 70만원을 결제하려면 다음과 같은 방법이 있습니다:

1. 50만원을 법인카드로 총액을 설정합니다
   F16 X: 법인카드로 70만원을 결제하려면 다음과 같은 방법이 있습니다:

1. 50만원을 법인카드로 총액을 설정합니다

이 실습이 뜻하는 것

이 실습은 같은 참고서를 세 가지 두께로 인쇄해 같은 시험을 치르게 하는 일과 같습니다. 시험지와 참고할 쪽(규정 조항)을 미리 골라 모든 판에 똑같이 나눠 주고(1단계), 묻고 시간을 재는 방법을 정해 한 번 물어봅니다(2단계). 세 판에게 12문제를 풀게 해 크기, 속도, 점수를 재고(3단계), 판마다 맞고 틀림이 갈린 문제를 들여다봅니다(4단계). 여기서 세 판이 Ollama 의 양자화 단계 Q4_K_M, Q8_0, F16(이름의 숫자가 비트 수입니다)이고, 참고할 쪽이 e5 로 찾은 조항 3개입니다. 비유와 다른 점은, 같은 모델이 같은 조항을 받아도 열쇠 낱말로 채점하면 우연히 맞거나 틀리는 일이 섞인다는 것입니다.

단계출력뜻
1. 준비30 조항 · 12 질문규정 30조항에서 질문마다 조항 셋을 미리 찾아 두었습니다. 모든 단계가 같은 조항을 받습니다
2. 한 번 묻기39, 0.280477, 3.7211338토큰 39개를 0.28초에 만들었고, 모델을 올리는 데 3.7초가 걸렸습니다. 이 답("늦출 수 없습니다")은 프로젝트에서 열쇠 "10시"로 틀린 것으로 채점된 답과 같습니다
3. 파일MB398, 531, 994비트가 많을수록 파일이 큽니다
3. GPU MB484, 617, 1080셋 다 파일보다 86MB 큽니다. KV 캐시(Key-Value cache, 앞 토큰의 계산을 저장해 두는 곳)와 계산 공간입니다
3. 초당토큰153.4, 143.9, 80.8비트가 적을수록 빨랐습니다. 읽어 올 바이트가 적기 때문이고, 실습 2의 bitsandbytes 와 반대입니다
3. 정답6/12, 7/12, 8/12비트가 많을수록 하나씩 더 맞혔습니다
3. 다시 물음0, 0, 0이번 실행에서는 깨진 답이 나오지 않았습니다
4. 갈린 질문질문 6개대부분 비트가 많은 쪽이 맞혔습니다. 다만 "부산 호텔비"는 Q4_K_M 이 맞히고 Q8_0 이 틀렸고, "아내 출산"의 Q4_K_M 맞힘은 병가 조항의 "10일"을 끌어다 쓴 우연입니다

한 줄로 줄이면, Ollama 에서는 비트를 줄이면 파일과 메모리가 줄고 오히려 빨라지며, 정답은 12개 가운데 한두 개씩 줄었다는 실습입니다. 12문제에서 한두 개 차이는 비슷하다고 보아야 하고, 실행마다 달라질 수 있습니다. 질문을 25개로 늘리고 다른 크기의 모델까지 넣어 고르는 일은 프로젝트에서 합니다.

코드 설명

  • 파일은 Q4_K_M 398MB, Q8_0 531MB, F16 994MB 이고, GPU 에 올라간 크기는 그보다 86MB 쯤 큽니다(KV 캐시와 계산 공간). 본문의 어림(Q4_K_M 약 296MB)보다 파일이 큰 것은, 작은 모델에서 큰 몫을 차지하는 임베딩층을 더 높은 비트로 두기 때문입니다. 셋 다 GPU 에 다 들어갔습니다.
  • 생성 속도는 Q4_K_M 153, Q8_0 144, F16 81 토큰/초 로, Ollama(llama.cpp)에서는 비트가 적을수록 빨랐습니다. 생성은 가중치를 메모리에서 읽어 오는 데 시간이 많이 들어, 읽을 바이트가 적을수록 빠릅니다. 실습 2의 bitsandbytes 와 반대인 것은 도구의 계산 방식이 다르기 때문입니다.
  • 같은 조항을 주고 물은 12개 가운데 Q4_K_M 6개, Q8_0 7개, F16 8개를 맞혀, 비트가 많을수록 조금씩 나았습니다. 이번 실행에서 깨진 답으로 다시 물은 일은 없었습니다. 교안을 만들며 먼저 돌렸을 때는 Q4_K_M 이 질문 몇 개 뒤부터 8888… 만 내서 2/12 가 나왔는데, 같은 모델을 따로 돌려 보니 12개 모두 바르게 답했습니다. 앞 실습에서 같은 파이썬 프로그램이 PyTorch 로 GPU 를 쓴 뒤에만 생긴 일이라, 양자화의 품질이 아니라 Ollama 의 상태 문제로 보고 ask() 에 다시 묻기를 넣었습니다.
  • 4단계는 단계마다 맞고 틀림이 갈린 질문입니다. 대부분 비트가 많은 쪽이 맞혔지만, "부산 호텔비"는 Q4_K_M 이 맞히고 Q8_0 이 틀렸고, "아내 출산"은 Q4_K_M 의 답이 병가 조항의 "10일"을 끌어다 쓴 것인데 열쇠(10일)가 들어 있어 맞힘으로 셌습니다. 열쇠 낱말로 채점하면 이런 우연이 섞이므로, 몇 개 차이는 비슷하다고 봅니다.
← 이전 절15장 · 실습 2 — 같은 모델을 fp16, 8비트, 4비트로 불러오기다음 절 →15장 · 실습 4 — 프루닝: 가지를 쳐도 맞히나