ZZOL · LLM · ZZOLBot · quantization · llama-cpp

ZzolBot에 자체 모델 붙이기: GPU 없는 서버에서 LLM 서빙하기

목차

지난 글까지 해서 모델 쪽 작업은 얼추 마무리가 됐다. 그때까지는 모델은 노트북에만 있었고 실제 운영 환경에서 ZzolBot은 계속 Gemini API를 사용하고 있었다. 이번 글은 튜닝했던 그 모델을 ARM 서버에 올려 운영 봇 옆에 붙이기까지의 과정이다.

바로 올리면 안되나

옮기기 전에 확인할 게 셋이었다. 먼저 맥에서만 도는 학습 결과물을 서버용으로 바꾸는 과정에서 티 안 나게 망가질 수 있다. 또 무료 티어 서버라 자리가 넉넉하지 않아서 모델을 얼마나 줄일 수 있는지 봐야했고, 마지막으로 운영 중인 봇에 어떻게 붙일지 정해야 한다.

잘 돌고 있는 봇을 새 모델로 바로 갈아끼우기엔 불안한 감이 있었다. 33종에서 Gemini와 2점 차이는 구별되지 않는 수준이었고 실제 알림에서는 더 나쁠 수도 있었다. 그래서 둘 다 부르되 Slack에는 Gemini 답만 올리고 로컬 모델의 답은 기록만 하기로 했다. 이렇게 기존 모델 옆에서 같이 돌리며 비교만 하는 구성을 shadow라고 부른다.

제대로 옮겨졌나

어댑터를 합쳐도 같을까

모델은 안에 든 숫자들로 답을 만든다. 이 숫자를 가중치라고 하고 1.5B 모델에는 15억 개가 들어 있다. 가중치 하나를 몇 비트에 저장하느냐에 따라 파일 크기가 달라지는데, 16비트로 저장하면 정확하지만 크고 4비트로 줄이면 작아지는 대신 값이 조금 뭉개지는데, 이렇게 비트 수를 줄이는 걸 양자화라고 한다.

학습할 때는 모델 전체를 다시 학습하지 않았다. 원래 모델은 그대로 두고 작은 파일을 덧붙여 그것만 학습했는데 이 파일을 어댑터라고 하고, 어댑터를 얹게되는 원래 모델을 베이스라고 한다. 그래서 학습 결과물은 20MB짜리 어댑터 파일 하나다.

노트북에서는 맥 전용 프레임워크인 MLX로 학습하고 점수를 쟀고, 서버에서는 llama라는 추론 엔진을 쓴다. 추론은 학습이 끝난 모델에 질문을 넣어 답을 받는 일을 말하는데 llama에 들어 있는 llama-server를 컨테이너로 띄우면 백엔드가 HTTP로 질문을 보내고 답을 받을 수 있다.

서버에 GPU가 없어서 CPU에서 돌리는 데 맞춰진 llama를 골랐다. GPU에서 여러 요청을 묶어 한꺼번에 처리해주는 엔진도 물론 있지만 알림은 하루 열 건 안팎이라 묶을 요청이 거의 없다. 출력 형식을 문법으로 정해두는 기능이 있어서 그 전 글의 제약 디코딩을 그대로 옮길 수 있다는 점도 컸다.

llama는 GGUF라는 형식의 파일 하나를 받는다. 이 파일에는 가중치와 함께 글을 토큰으로 자르는 규칙과 질문을 모델에 넣는 형식까지 들어 있어서 베이스와 어댑터를 먼저 합쳐 GGUF 하나로 만들었다. 어댑터는 4비트로 줄인 베이스 위에서 학습했는데, 4비트는 가중치를 16가지 값 중 하나로만 저장해서 어댑터가 더하는 작은 값이 반올림되며 대부분 사라진다. 그래서 16비트에서 합쳐야 했는데 학습에 쓴 4비트 베이스를 16비트로 풀지 않고 원본 16비트 베이스를 따로 받아 써서 베이스가 바뀌었다.

합칠 때는 학습할 때 정한 비율만큼 어댑터를 곱해서 더하는데, 이 비율을 잘못 넣어도 답은 멀쩡해 보이고 점수만 조금 낮아져서 양자화 탓으로 오해하기 쉽다. 그래서 같은 질문 3개를 넣고 합치기 전과 후에 모델이 첫 토큰을 고를 때 후보마다 매긴 점수를 비교해보니 저장할 때 생기는 오차 정도로만 달랐고 가장 높은 후보도 모두 같았다. 다만 첫 토큰만 봤기 때문에 답을 길게 쓰면서 조금씩 벌어지는 차이는 못 본다.

그리고 이 비교로 알 수 있는 건 합친 파일이 16비트 베이스에 어댑터를 얹은 것과 같다는 것까지다. 4비트와 16비트 베이스는 받아온 파일부터 달라서 같은 어댑터를 얹어도 답이 달라진다.

보상 인용 오탐
4비트 베이스 + 어댑터 (이전 글) 0.906 9/15 1/18
16비트 베이스 + 어댑터 0.903 13/15 4/18

보상은 비슷하지만 근거가 있다고 답하는 빈도가 늘어서 오탐이 1/18에서 4/18이 됐다. 되돌리려면 학습에 쓴 4비트 베이스를 16비트로 풀어서 그 위에 다시 합치면 된다.

엔진이 달라도 같은 답을 낼까

MLX와 llama는 서로 다른 프로그램이라 같은 계산을 한다는 보장이 없다. 두 프로그램의 답이 다르면 크기를 줄였을 때 생긴 차이가 양자화 때문인지 프로그램 때문인지 가릴 수 없어서, 크기를 줄여 비교하기 전에 이것부터 확인했다. 33종을 양쪽에서 돌려 나온 답을 비교해보니 2종이 달랐다. 그런데 둘 다 답이 갈린 자리에서 두 후보의 점수가 정확히 같았다. 점수가 같을 때 어느 후보를 고르는지가 프로그램마다 달라서 생긴 차이였다.

얼마나 줄일 수 있을까

어디까지 줄여도 될까

가중치를 적은 비트로 저장할수록 파일은 작아지지만 1.2345였던 가중치가 1.23이 되는 식으로 값이 뭉개진다. 15억 개에서 조금씩 어긋난 게 쌓이면 답이 달라질 수 있어서 세 가지 크기로 만들어 33종으로 얼마나 달라지는지 평가했다.

가중치 하나당 파일 크기
16비트 (bf16) 16비트 3.09GB
8비트 (Q8_0) 8비트 1.5GB
4비트 (Q4_K_M) 4비트 940MB
보상 인용 오탐
bf16 0.885 12/15 5/18
Q8_0 0.885 12/15 5/18
Q4_K_M 0.858 8/15 2/18

Q8_0은 33종 모두 bf16과 같은 답을 냈다. 평균만 같은 게 아니라 시나리오마다 답이 같아서 크기를 절반으로 줄여도 잃는 게 없었다. Q4는 8종이 달라졌는데 좋아진 3종이 전부 정답이 "근거 없음"인 시나리오였다. 근거가 있다고 답하는 횟수 자체가 줄어서 오탐은 2/18로 줄고 인용은 8/15로 떨어졌다. 여기까지만 보면 Q8_0을 고르면 됐다.

제약 디코딩을 걸면 달라질까

그런데 실제로 올릴 때는 위 표와 달리 제약 디코딩을 건다. 모델이 근거로 로그를 인용할 때 실제 로그에 있는 줄이나 빈 문자열만 고를 수 있게 막는 것으로, 이전 글에서 인용 통과를 올린 방법이다. llama에서는 이 제약을 GBNF라는 문법으로 적어둔다. 답을 쓰는 동안 매 토큰마다 지금까지 쓴 글에 이어 붙였을 때 문법에 어긋나는 후보를 지우고, 남은 후보 중에서 점수가 가장 높은 걸 고르게 하는 방식이다. 인용 칸에 들어갈 수 있는 문자열을 실제 로그 줄 목록으로 적어두면 모델은 그 밖의 글자를 쓸 수 없다.

보상 인용 오탐
Q8_0 제약 X 0.885 12/15 5/18
Q8_0 제약 O 0.909 14/15 5/18
Q4_K_M 제약 X 0.858 8/15 2/18
Q4_K_M 제약 O 0.924 12/15 2/18

제약을 걸자 Q4가 Q8을 앞질렀다. Q4는 오탐이 원래 적은 대신 로그를 옮겨 적다 자주 틀렸는데 제약이 그 실수를 막아줬다. 다만 두 값의 차이가 흔들리는 범위가 -0.044부터 +0.070까지라 Q4가 낫다고 할 수는 없고, 둘의 차이가 사라졌다고만 말할 수 있다. 그러면 Q8을 고른 이유였던 "Q4는 근거 없음 쪽으로 밀린다"가 없어져서 남는 기준은 크기와 속도였고, 크기는 940MB인 Q4가 유리했다.

그 전 글에서는 제약을 걸었더니 오탐이 늘어서 이번에도 늘 거라고 미리 적어뒀는데 둘 다 그대로였다. 그때는 인용을 엉성하게 하던 모델이 인용 검증에 걸려 떨어지다가, 제약으로 인용이 정확해지자 근거 없는 주장까지 그대로 통과했다. 세어보니 이번 모델은 인용 검증에 걸려 떨어지던 오탐이 애초에 0건이었다. 원래 잘 우기지 않는 모델이라 제약을 걸어도 인용만 정확해진 것이다. 여기까지 숫자는 전부 맥에서 잰 품질이고 속도는 실제로 올릴 서버에서 따로 재야 했다.

서버에서는 얼마나 빠를까

Q4가 더 빠를까

올릴 곳은 맥과 CPU 종류도 다르고 GPU도 없는 무료 티어 ARM 서버다. 재기 전에는 Q4가 빠를 거라고 예상했다. CPU에서 모델을 돌릴 때는 계산보다 메모리에서 가중치를 읽어오는 속도가 더 발목을 잡는다고 알려져 있어서 940MB가 1.5GB보다 빠를 수밖에 없다고 생각했다. 알림 한 건을 처음부터 끝까지 처리하는 시간을 로그 줄 수를 늘려가며 초 단위로 쟀다.

2줄 4줄 9줄 10줄 메모리
Q4 2코어 60.8 86.1 134.8 138.8 1.94GiB
Q8 2코어 51.5 66.5 118.2 121.0 2.22GiB
Q4 4코어 30.8 42.2 68.2 69.9 1.33GiB
Q8 4코어 24.8 33.3 58.9 59.7 1.93GiB

예상과 달리 Q8_0이 코어 수와 상관없이 더 빨랐다. 알림 하나를 처리하는 건 프롬프트를 읽는 단계와 답을 쓰는 단계로 나뉘는데, 두 단계의 속도를 정하는 게 다르다. 답을 쓸 때는 토큰 하나마다 가중치를 전부 읽어야 해서 예상대로 파일이 작은 Q4가 빨랐다. 그런데 프롬프트를 읽을 때는 여러 토큰을 한꺼번에 계산해서 계산량이 속도를 정한다. 이 서버의 CPU는 8비트 숫자 계산을 바로 처리할 수 있어서 Q8은 바로 계산하고 Q4는 한 번 변환한 뒤에 계산한다.

속도를 정하는 것 Q4 Q8
프롬프트 읽기 계산량 30-33 tok/s 45-52 tok/s
답 생성 메모리에서 가중치를 읽는 속도 7.2-8.6 tok/s 6.7-7.7 tok/s

tok/s는 1초에 처리하는 토큰 수다. 알림 분석은 프롬프트가 1,188에서 2,574토큰이고 답은 207에서 432토큰이라 읽을 양이 쓸 양보다 다섯 배쯤 많다. 그래서 읽는 단계에서 빠른 Q8이 전체 시간에서도 앞섰고, 결국 다시 Q8_0을 골랐다.

기준 결과
품질, 제약 없이 bf16과 33종 모두 같음
품질, 제약 걸고 Q4와 구별 안 됨
속도 Q4보다 13-20% 빠름
메모리 2.2GiB

코어와 스레드는 몇 개로 할까

4코어가 더 빠르지만 2코어로 정했다. shadow라 아무도 이 답을 기다리지 않으니 느려도 괜찮고, 코어를 2개로 묶어두면 운영 서비스에는 2코어가 항상 남는다.

그런데 컨테이너에 코어를 2개만 줬는데도 서버 로그에는 n_threads = 4가 찍혔다. llama는 컨테이너에 준 코어 수가 아니라 서버 전체의 코어 수를 보고 스레드를 띄운다. 쓸 수 있는 코어는 2개인데 스레드가 4개여서 스레드도 2개로 맞추고 다시 쟀다.

4스레드 2스레드 차이
로그 10줄 (프롬프트 2,574토큰) 119.0초 95.7초 -20%
로그 2줄 (1,189토큰) 51.5초 42.7초 -17%
생성 속도 6.9 tok/s 9.6 tok/s +39%
프롬프트 처리 속도 45.2 tok/s 46.1 tok/s 거의 같음

스레드를 줄여서 빨라진 건 답을 생성하는 속도뿐이었다. 모델은 토큰 하나를 만들 때 계산 단계 수십 개를 차례로 거친다. llama는 단계 하나의 계산을 스레드 수만큼 나눠 동시에 하고 모든 스레드가 끝나야 다음 단계로 넘어가는데, 코어가 2개뿐이라 스레드 4개 중 2개는 늘 코어가 비기를 기다린다. 프롬프트를 읽을 때는 단계마다 계산이 커서 이 기다림이 묻히지만, 답을 생성할 때는 토큰 하나씩 계산해서 단계마다 기다리는 시간이 계산보다 길어진다.

스레드를 바로잡고 나니 4스레드로 잰 앞의 숫자들도 달라졌다. 속도 표에서 2코어 줄은 실제로는 20%쯤 더 빠르고, 그 숫자로 정해둔 타임아웃에는 여유가 생겼다. 읽기 타임아웃 기본값은 120초인데 4스레드로 잰 가장 긴 시나리오가 121초여서, 로그가 많이 붙어 두 모델을 비교하기 가장 좋은 긴 알림이 잘리지 않게 240초로 올려뒀었다.

어떻게 옆에 붙였을까

알림 도착
   │
   ├─ Gemini 호출 ─────────→ 답변 저장, Slack 게시
   │
   └─ 로컬 모델 호출 ──────→ 기록만

호출하는 쪽 코드는 건드리지 않았다. 봇은 분석기를 AnomalyAnalyzer 인터페이스로 받고 있어서, 같은 인터페이스를 구현한 ShadowAnomalyAnalyzer를 하나 더 만들어 기본으로 지정했다. 이 분석기는 기존 Gemini 분석기를 그대로 불러 그 답을 돌려주고, 같은 입력을 다른 스레드에서 로컬 모델에도 보내 결과만 기록한다. 설정에서 shadow를 끄면 이 분석기가 아예 만들어지지 않아서 이전과 똑같이 동작한다.

두 모델을 비교하려면 같은 프롬프트를 주고 같은 기준으로 인용을 검증해야 하는데, 프롬프트를 만드는 코드와 인용 검증 코드가 Gemini 분석기 안에만 있었다. 그래서 둘을 분석기 밖으로 빼서 두 모델이 같이 쓰게 했다.

마무리하며

모델을 서버에 올리는 과정도 튜닝만큼 확인할 게 많았다. 노트북에서 85%를 받았을 때는 거의 끝났다고 생각했는데 형식을 바꾸고 크기를 줄이고 서버에 붙이는 단계마다 숫자가 조금씩 달라졌다. 노트북에서 빠르게 비교하더라도 마지막 점수는 실제로 올릴 파일을 서빙할 서버에서 돌려 재야 할 것 같다.

GPU 없이도 생각보다 쓸 만했다. 1.5B 모델이면 무료 티어 서버의 CPU 코어 2개로도 알림 한 건을 2분 안팎에 처리해서 하루 수백 건은 감당할 수 있다. Gemini를 쓸 때는 무료 한도 때문에 알림 분석을 하루 10건으로 막아뒀는데 이제는 해방이다.

마지막으로 알림이 몰릴 때는 아직 고려하지 못했다. 동시에 몇 건을 돌릴지, 대기열을 얼마나 둘지, 넘치면 버릴지 기다릴지, 실패하면 다시 시도할지, 모델 서버는 어떻게 지켜볼지 모두 정하지 않은 채로 운영되고 있다. 지금은 shadow라 놓쳐도 기록 한 줄이 빠지는 게 전부지만 로컬 모델의 답을 Slack에 올리려면 이것부터 정해야 할 것 같다.