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단계. 규정 조항과 검색 준비
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 를 부르고 재는 함수
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단계. 세 단계 견주기
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 04단계. 단계마다 답이 갈린 질문 보기
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. 파일MB | 398, 531, 994 | 비트가 많을수록 파일이 큽니다 |
| 3. GPU MB | 484, 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일)가 들어 있어 맞힘으로 셌습니다. 열쇠 낱말로 채점하면 이런 우연이 섞이므로, 몇 개 차이는 비슷하다고 봅니다.