지난 글까지 해서 모델 쪽 작업은 얼추 마무리가 됐다. 그때까지는 모델은 노트북에만 있었고 실제 운영 환경에서 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에 올리려면 이것부터 정해야 할 것 같다.