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

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

프로젝트 1 — 우리 GPU 에 맞는 모델 고르기

이 PC(GTX 1650, 4GB)에서 사내 규정 도우미에 쓸 모델을 골라 보세요. 후보는 Ollama 에 받아 둔 모델들입니다. 조건은 "GPU 에 다 올라가고(CPU 로 넘치지 않고), 답 하나에 평균 3초 안"이고, 조건을 지키는 후보 가운데 규정 질문 25개의 정답이 가장 많은 것을 고릅니다. 회사와 규정은 7장의 지어낸 것입니다.

파일생김새
company_rules.txt솔내기술 사내 규정집. 조항 30개
company_rules_qa.jsonl질문, 정답열쇠(답이 여럿이면 세로줄 | 로 나눔), 근거조항. 25줄

후보 모델은 다음과 같습니다. 모두 ollama pull 로 받아 둡니다.

후보크기양자화
qwen2.5:0.5b, qwen2.5:0.5b-instruct-q8_0, qwen2.5:0.5b-instruct-fp160.5BQ4_K_M, Q8_0, F16
qwen2.5:1.5b1.5BQ4_K_M
qwen2.5:3b3BQ4_K_M
gemma3:1b, llama3.2:1b1BQ4_K_M, Q8_0 등(모델마다 다름)

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

  1. 질문마다 e5 로 조항 3개를 찾아 두고, 모든 후보에 같은 조항을 줍니다(검색이 아니라 모델을 견주려고).
  2. 후보마다 모델을 올린 뒤 GPU 에 올라간 몫(size_vram / size)과 올리는 데 걸린 시간을 잽니다.
  3. 질문 25개의 정답 수, 답 하나의 평균 시간, 초당 토큰을 잽니다.
  4. 조건으로 거르고, 남은 후보 가운데 정답이 가장 많은 것을 고릅니다. 같으면 빠른 것을 고릅니다.
  5. 고른 까닭과, 조건을 바꾸면(예: 메모리를 2GB 로 줄이면) 무엇이 달라지는지 적습니다.

단계별 힌트입니다.

  1. 실습 3의 ask(), unload_all() 을 그대로 씁니다. 후보를 바꿀 때마다 앞 모델을 내려야 GPU 를 혼자 씁니다.
  2. 모델을 올린 직후 /api/ps 의 size_vram 이 size 와 같으면 모두 GPU 에 올라간 것입니다.
  3. 시간은 Ollama 가 재 준 total_duration 을 쓰면 네트워크와 파이썬의 시간이 빠집니다.
  4. 정답 판정은 열쇠가 답에 들어 있는지로 합니다(빈칸을 빼고).
  5. Ollama 의 답은 실행마다 조금 달라질 수 있습니다. 정답 수가 한두 개 차이면 비슷하다고 봅니다.

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

실제 데이터로 해 보려면

경량화한 공개 모델과 공개 평가 자료로 같은 고르기를 해 볼 수 있습니다. 모델을 받기 전에 라이선스를 확인합니다.

자료구하는 곳내용
양자화한 공개 모델Ollama 라이브러리(ollama.com/library), Hugging Face 의 GGUF, GPTQ, AWQ 판같은 모델의 여러 양자화 단계
한국어 평가 자료Hugging Face 의 공개 한국어 벤치마크(라이선스 확인)질문과 정답이 있는 묶음
우리 업무 질문실제 문의 기록에서 개인정보를 가린 표본가장 믿을 만한 시험 묶음

코드의 흐름

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

재는 함수, 후보마다, 거르고 고르기, 갈린 질문의 네 상자를 화살표로 이은 GPU 모델 고르기 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 질문마다 조항 셋을 찾아 두고 ask(), loaded() 같은 재는 함수를 만들며, 2단계(마젠타)에서 CANDIDATES 의 후보마다 정답 수, 시간, 메모리를 잽니다. 3단계(다홍)는 table["조건"] 으로 거른 뒤 정답 많은 순으로 고르고, 4단계(빨강)는 고른 모델과 가장 작은 qwen2.5:0.5b 의 답이 갈린 질문을 나란히 봅니다.

결과 보기 (예시 풀이)

1단계. 조항 찾기와 재는 함수

Python
import json, re, time, urllib.request
import numpy as np
import pandas as pd
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")]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
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]
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 한 문장으로 답하세요."


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=600).read())


def loaded():
    return {m["name"]: m for m in json.loads(urllib.request.urlopen("http://localhost:11434/api/ps").read())["models"]}


def unload_all():
    for name in loaded():
        post("/api/generate", {"model": name, "keep_alive": 0})


BROKEN = re.compile(r"(.)\1{9,}")
retried = {"수": 0}


def ask(model, context, question):
    """깨진 답(같은 글자 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


correct = lambda a, key: any(k in a.replace(" ", "") for k in key.replace(" ", "").split("|"))
installed = {m["name"] for m in json.loads(urllib.request.urlopen("http://localhost:11434/api/tags").read())["models"]}
CANDIDATES = [c for c in ["qwen2.5:0.5b", "qwen2.5:0.5b-instruct-q8_0", "qwen2.5:0.5b-instruct-fp16", "gemma3:1b",
                          "llama3.2:1b", "qwen2.5:1.5b", "qwen2.5:3b"] if c in installed]
print(len(qa), "질문 · 후보", CANDIDATES)
출력
25 질문 · 후보 ['qwen2.5:0.5b', 'qwen2.5:0.5b-instruct-q8_0', 'qwen2.5:0.5b-instruct-fp16', 'gemma3:1b', 'llama3.2:1b', 'qwen2.5:1.5b', 'qwen2.5:3b']

2단계. 후보마다 재기

Python
rows, answers = [], {}
for model in CANDIDATES:
    unload_all()
    _, first = ask(model, contexts[0], qa[0]["질문"])                    # 올리기
    info = loaded()[model]
    right, secs, tokens, gen = 0, [], 0, 0.0
    retried["수"] = 0
    for x, ctx in zip(qa, contexts):
        a, r = ask(model, ctx, x["질문"])
        right += correct(a, x["정답열쇠"])
        secs.append(r["total_duration"] / 1e9)                         # 답 하나에 든 전체 시간
        tokens, gen = tokens + r["eval_count"], gen + r["eval_duration"] / 1e9
        answers[(model, x["질문"])] = a
    rows.append({"모델": model, "GPU 몫": round(info["size_vram"] / info["size"], 2), "메모리MB": round(info["size"] / 1e6),
                 "올리기(초)": round(first["load_duration"] / 1e9, 1), "평균(초)": round(float(np.mean(secs)), 2),
                 "초당토큰": round(tokens / gen, 1), "정답": right, "다시 물음": retried["수"]})
    print(rows[-1])
unload_all()
출력
{'모델': 'qwen2.5:0.5b', 'GPU 몫': 1.0, '메모리MB': 484, '올리기(초)': 3.0, '평균(초)': 0.62, '초당토큰': 157.3, '정답': 15, '다시 물음': 1}
{'모델': 'qwen2.5:0.5b-instruct-q8_0', 'GPU 몫': 1.0, '메모리MB': 617, '올리기(초)': 3.2, '평균(초)': 0.55, '초당토큰': 137.2, '정답': 15, '다시 물음': 0}
{'모델': 'qwen2.5:0.5b-instruct-fp16', 'GPU 몫': 1.0, '메모리MB': 1080, '올리기(초)': 3.4, '평균(초)': 1.02, '초당토큰': 81.1, '정답': 16, '다시 물음': 0}
{'모델': 'gemma3:1b', 'GPU 몫': 1.0, '메모리MB': 880, '올리기(초)': 3.7, '평균(초)': 1.13, '초당토큰': 83.0, '정답': 15, '다시 물음': 0}
{'모델': 'llama3.2:1b', 'GPU 몫': 1.0, '메모리MB': 1519, '올리기(초)': 4.2, '평균(초)': 0.98, '초당토큰': 76.0, '정답': 17, '다시 물음': 1}
{'모델': 'qwen2.5:1.5b', 'GPU 몫': 1.0, '메모리MB': 1169, '올리기(초)': 3.6, '평균(초)': 0.85, '초당토큰': 85.7, '정답': 20, '다시 물음': 0}
{'모델': 'qwen2.5:3b', 'GPU 몫': 1.0, '메모리MB': 2164, '올리기(초)': 4.9, '평균(초)': 1.63, '초당토큰': 50.1, '정답': 23, '다시 물음': 1}

3단계. 조건으로 거르고 고르기

Python
table = pd.DataFrame(rows)
table["조건"] = (table["GPU 몫"] >= 0.99) & (table["평균(초)"] <= 3.0)
print(table.to_string(index=False))
ok = table[table["조건"]].sort_values(["정답", "평균(초)"], ascending=[False, True])
choice = ok.iloc[0]
print(f"\n고른 모델: {choice['모델']} · 정답 {choice['정답']}/{len(qa)} · 평균 {choice['평균(초)']}초 · 메모리 {choice['메모리MB']}MB")
small = table[table["조건"] & (table["메모리MB"] <= 2000)].sort_values(["정답", "평균(초)"], ascending=[False, True])
print(f"메모리를 2GB 로 줄이면: {small.iloc[0]['모델']} · 정답 {small.iloc[0]['정답']}/{len(qa)}")
출력
                        모델  GPU 몫  메모리MB  올리기(초)  평균(초)  초당토큰  정답  다시 물음   조건
              qwen2.5:0.5b    1.0    484     3.0   0.62 157.3  15      1 True
qwen2.5:0.5b-instruct-q8_0    1.0    617     3.2   0.55 137.2  15      0 True
qwen2.5:0.5b-instruct-fp16    1.0   1080     3.4   1.02  81.1  16      0 True
                 gemma3:1b    1.0    880     3.7   1.13  83.0  15      0 True
               llama3.2:1b    1.0   1519     4.2   0.98  76.0  17      1 True
              qwen2.5:1.5b    1.0   1169     3.6   0.85  85.7  20      0 True
                qwen2.5:3b    1.0   2164     4.9   1.63  50.1  23      1 True

고른 모델: qwen2.5:3b · 정답 23/25 · 평균 1.63초 · 메모리 2164MB
메모리를 2GB 로 줄이면: qwen2.5:1.5b · 정답 20/25

4단계. 고른 모델과 가장 작은 모델이 갈린 질문

Python
smallest = "qwen2.5:0.5b"
for x in qa:
    a_big, a_small = answers[(choice["모델"], x["질문"])], answers[(smallest, x["질문"])]
    if correct(a_big, x["정답열쇠"]) != correct(a_small, x["정답열쇠"]):
        print(x["질문"], f"(열쇠 {x['정답열쇠']})")
        print(f"   {choice['모델']}: {a_big[:60]}")
        print(f"   {smallest}: {a_small[:60]}")
출력
출근 시간을 늦출 수 있나요? (열쇠 10시)
   qwen2.5:3b: 출근 시간은 오전 7시에서 10시 사이에서 선택할 수 있지만, 한 달 단위로 정해진 출근 시각을 변경할 수
   qwen2.5:0.5b: 출근 시간을 늦출 수 없습니다. [조항]에 따라 출근 시각은 한 달 단위로 정해 팀장에게 알립니다.
집에서 일하는 건 일주일에 며칠까지 돼요? (열쇠 이틀|2일)
   qwen2.5:3b: 일주일에 재택 근무는 이틀까지 가능합니다.
   qwen2.5:0.5b: 집에서 일하는 건 3주 정도 돼요.
입사 첫해에는 휴가가 어떻게 생기나요? (열쇠 개근|한 달)
   qwen2.5:3b: 입사 첫해에는 한 달 개근할 때마다 연차 하루가 생깁니다. 입사 첫해에는 최소 15일의 연차가 생깁니다.
   qwen2.5:0.5b: 입사 첫해에는 1년을 채우면 15일이 생깁니다. 2년마다 25일까지 생깁니다.
연차는 최대 며칠까지 늘어나요? (열쇠 25일)
   qwen2.5:3b: 연차는 최대 25일까지 늘어나요.
   qwen2.5:0.5b: 제6조에 따르면, 연차는 반나절(4시간) 단위로 나눠 쓸 수 있습니다. 따라서 연차는 4시간 단위로 15일이
아파서 나흘 쉬면 무엇을 내야 하나요? (열쇠 진단서)
   qwen2.5:3b: 아파서 4일 쉬면 진단서를 냅니다.
   qwen2.5:0.5b: 아파서 3일 쉬는다면, 3일 전까지는 유급 병가를 받는 것이 좋습니다.
부산 출장 가면 호텔비는 얼마까지 나와요? (열쇠 10만원)
   qwen2.5:3b: 부산 출장 가면 호텔비는 하룻밤 8만원까지 실제 쓴 만큼 줍니다.
   qwen2.5:0.5b: 서울과 광역시: 10만원, 그 밖: 8만원까지
법인카드로 70만원을 결제하려면 어떻게 해요? (열쇠 품의)
   qwen2.5:3b: 법인카드로 70만원을 결제하려면 미리 품의를 받아야 합니다.
   qwen2.5:0.5b: 법인카드로 70만원을 결제하려면 다음과 같은 방법이 있습니다:

1. 50만원을 법인카드로 쓰세요.
2. 주
집을 살 때 회사에서 돈을 빌릴 수 있나요? (열쇠 3천만원|2%)
   qwen2.5:3b: 可以,在满足条件的情况下,可以向公司借300万韩元用于购房,利率为年2%。
   qwen2.5:0.5b: 집을 살 때 회사에서 돈을 빌릴 수 있습니다. 하지만 주택 자금 대출 및 저축 금리 등은 개인의 돈을 빠르게
필수 교육은 언제까지 들어야 해요? (열쇠 11월 30일)
   qwen2.5:3b: 필수 교육은 매년 11월 30일까지 완료해야 합니다.
   qwen2.5:0.5b: 제23조에 명시되어 있습니다. 모든 직원은 해마다 정보보호, 성희롱 예방, 직장 내 괴롭힘 예방 교육을 받습
사원증을 잃어버렸어요. (열쇠 1만원|총무팀)
   qwen2.5:3b: 사원증을 잃어버리면 바로 총무팀에 알리고, 다시 만드는 비용 1만원을 냅니다.
   qwen2.5:0.5b: 제30조에 명시된 사원증을 잃어버리면, 퇴사하려면 30일 전까지 팀장에게 알리고, 마지막 날 노트북과 사원증

후보 일곱 개 모두 GPU 에 다 올라갔고(GPU 몫 1.0), 답 하나에 평균 0.55~1.63초로 3초 조건을 지켰습니다. 그래서 정답 수로 골랐고, qwen2.5:3b 가 25개 가운데 23개로 가장 많아 뽑혔습니다(메모리 2,164MB, 평균 1.63초). 같은 0.5B 의 세 양자화 단계는 15, 15, 16개로 거의 같았지만, 크기를 0.5B → 1.5B → 3B 로 키우면 15 → 20 → 23 개로 늘었습니다. 이 과제에서는 양자화로 잃는 것보다 모델 크기로 얻는 것이 훨씬 컸고, 그래서 "큰 모델을 4비트로"가 "작은 모델을 16비트로"보다 나았습니다. 메모리를 2GB 로 줄이면 qwen2.5:1.5b(1,169MB, 20개)가 뽑힙니다. 같은 1B 급에서도 gemma3:1b 15개, llama3.2:1b 17개로 모델마다 달랐습니다. 0.5B Q4_K_M, llama3.2:1b, 3B 에서 한 번씩 깨진 답이 나와 다시 물었습니다. 채점이 열쇠 낱말로 하는 것이라 한계도 있었습니다. 3B 는 "집 대출" 질문에 중국어로 답했는데 "2%"가 들어 있어 맞힘으로 셌고, "부산 호텔비"는 0.5B 가 맞히고 3B 가 틀렸습니다. 교안을 만들며 먼저 돌렸을 때는 0.5B Q4_K_M 이 깨진 답 때문에 6개, gemma3:1b 가 5개로 나오는 등 실행마다 숫자가 달랐습니다. 실제로 고를 때는 질문 묶음을 늘리고 여러 번 재며, 답을 사람이 읽어 확인합니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 좁은 사무실(4GB)에 앉을 직원 한 명을 뽑는 일과 같습니다. 시험지와 참고 자료, 채점 방법을 준비하고(1단계), 후보 일곱 명에게 같은 시험을 보게 해 차지하는 자리, 답하는 속도, 점수를 잽니다(2단계). "사무실에 다 들어가고 답 하나에 3초 안"이라는 조건으로 거른 뒤 점수가 가장 높은 사람을 뽑고(3단계), 뽑힌 사람과 가장 작은 후보가 갈린 문제를 들여다봅니다(4단계). 여기서 사무실이 GPU 메모리, 후보가 Ollama 모델 일곱 개, 시험이 규정 질문 25개, 참고 자료가 질문마다 찾아 둔 조항 3개입니다. 비유와 다른 점은, 사람은 시험마다 실력이 비슷하게 나오지만 이 모델들은 실행마다 점수가 몇 개씩 달라질 수 있다는 것입니다.

단계출력뜻
1. 준비25 질문 · 후보 7개받아 둔 후보 일곱 개를 모두 찾았습니다
2. GPU 몫모두 1.0일곱 후보 모두 GPU 에 다 올라갔습니다. CPU 로 넘친 후보가 없습니다
2. 메모리MB484 에서 2164가장 작은 qwen2.5:0.5b 부터 가장 큰 qwen2.5:3b 까지입니다
2. 평균(초)0.55 에서 1.63모두 3초 조건 안입니다. 이 PC 에서 잰 시간입니다
2. 0.5B 세 단계 정답15, 15, 16같은 모델의 Q4_K_M, Q8_0, F16 은 거의 같았습니다
2. 크기를 키운 정답0.5B 15, 1.5B 20, 3B 23모델을 키우자 정답이 크게 늘었습니다
2. 1B 급 정답gemma3:1b 15, llama3.2:1b 17같은 크기라도 모델마다 다릅니다
2. 다시 물음0.5B, llama3.2:1b, 3B 에서 1번씩깨진 답이 한 번씩 나와 모델을 내렸다가 다시 물었습니다
3. 조건모두 True조건으로 걸러진 후보가 없어 정답 수로만 골랐습니다
3. 고른 모델qwen2.5:3b · 정답 23/25 · 평균 1.63초 · 메모리 2164MB4비트로 줄인 3B 모델이 가장 많이 맞혔습니다
3. 2GB 로 줄이면qwen2.5:1.5b · 정답 20/25메모리 조건을 바꾸면 고르는 모델도 바뀝니다
4. 갈린 질문10개3B 만 맞힌 것이 9개, 0.5B 만 맞힌 것이 1개("부산 호텔비")라서 23 과 15 의 차이 8 과 맞습니다. 3B 는 "집 대출"에 중국어로 답했는데 "2%"가 들어 있어 맞힘으로 셌습니다

한 줄로 줄이면, 이 PC 와 이 과제에서는 양자화로 잃는 것보다 모델 크기로 얻는 것이 훨씬 커서, "큰 모델을 4비트로"가 "작은 모델을 16비트로"보다 나았다는 프로젝트입니다. 고르는 순서는 조건으로 먼저 거르고, 남은 후보 가운데 품질로 고르는 것입니다. 다만 25문제를 한 번 돌린 결과이고, 채점은 열쇠 낱말이 들어 있는지만 봅니다. 교안을 만들며 먼저 돌렸을 때는 숫자가 달랐으므로, 실제로 고를 때는 질문을 늘리고 여러 번 재며 답을 사람이 읽어 확인합니다.

← 이전 절15장 · 실습 6 — LLM 은 가지치기에 얼마나 견디나다음 절 →15장 · 정리