양자화 버전을 세 번 바꾸고 자문을 열여섯 차례 거친 끝에, 프로덕션에 올린 로컬 모델 교체기
퀀트봇의 AI 추천 파이프라인에는 리서치 담당 모델(새 창)이 있습니다. 뉴스·공시를 사람처럼 읽고 근거를 세워 리포트를 쓰는 역할입니다.
이번에 그 자리에 새 오픈소스 모델(Qwen3.8-Flash-Next, 전체 파라미터 약 1,250억 개 중 실제로 켜지는 건 약 60억 개뿐인 MoE 구조, Qwen 차기 세대 프리뷰격 모델)을 후보로 올렸습니다. 이번 글은 평소보다 훨씬 구체적으로 씁니다. 판단 로직이 아니라 순수 인프라(어떤 그래픽카드로 어떻게 돌렸는지, 양자화 버전을 정확히 뭘 골랐는지)라서 굳이 감출 이유가 없다고 판단했고, 마침 로컬 LLM을 직접 돌려보는 분들껜 참고가 될 것 같아 과정을 가감없이 남깁니다.
글이 좀 깁니다. 먼저 결론만 요약하면, 최종적으로 프로덕션에 채택한 건 Q3_K_XL 양자화였고, 오늘 아침(09-08 07:18) 처음으로 100종목을 끊기지 않고 완주한 실제 프로덕션 런의 서버 로그로 환산한 속도는 프리필 약 610 tok/s, 디코드 약 124 tok/s(4개 슬롯 합산, 슬롯당 약 31 tok/s)였습니다.
아래 본문에는 이 숫자가 나오기까지 시도했던 다른 양자화 후보들, 그리고 동시 요청 수(-np)·배치 크기(-b)를 바꿀 때마다 이 프리필·디코드 속도가 어떻게 달라졌는지를 실측 표로 정리해뒀습니다. 그 과정 자체가 궁금하신 분들은 아래를 찬찬히 읽어주세요.
목표와 하드웨어
목표는 단순했습니다. 리서치 담당 파이프라인이 한 번에 훑는 종목 100개를 10시간 안에 끝내는 것.
서빙에 쓴 카드는 두 장입니다. NVIDIA RTX 5090(32GB)과 AMD Radeon AI PRO R9700(32GB), 서로 다른 제조사 카드를 하나의 llama.cpp 서버에 물려서 레이어를 나눠 맡겼습니다. 두 카드 VRAM을 합치면 약 63.6GiB인데, 모델 백본만 해도 그 대부분을 차지하는 크기라 여유가 별로 없었습니다.
이종 카드를 같이 쓰다 보니 카드마다 다른 문제도 나왔습니다. AMD 카드는 GTT(시스템 메모리를 GPU가 쓰는 영역) 절전 모드로 빠지면 응답이 늦어지는 버그가 있어서, udev 규칙(99-r9700-no-runtime-pm.rules)으로 런타임 절전 자체를 꺼야 했습니다.
llama.cpp 기준으로 최종적으로 굳힌 레이어 분배는 -sm layer -ts 23,25(5090에 23개 층, R9700에 25개 층)였고, 동시 요청 수는 -np 4, 배치는 -b 512, 컨텍스트는 -c 131072였습니다.
종목당 실제 입출력 토큰량
속도 얘기를 하기 전에, 애초에 이 모델에 뭐가 얼마나 들어가고 나오는지부터 짚고 갑니다. 리서치 담당은 종목 하나를 한 번 훑을 때 여러 단계(뉴스 분석·공시 분석·토론 등)를 거치며 모델을 여러 차례 호출하는데, 그 호출 1건 기준 실측치입니다.
| 항목 | 수치 | 비고 |
|---|---|---|
| 프롬프트(입력) 평균 | 8,349 토큰 | 콜 1건 기준 |
| 프롬프트(입력) 최대 관측치 | 22,129 토큰 | |
| 생성(출력) 중앙값 | 약 1,205 토큰 | |
| 생성 상한 | 4,096 토큰 | 실제로는 거의 도달하지 않음 |
| 프리필:디코드 토큰량 비율 | 약 6.6배 | 입력이 출력보다 훨씬 큼 |
입력이 출력보다 6.6배 많다는 게 중요한 이유는, 이 워크로드에서 속도를 좌우하는 병목이 "얼마나 빨리 답을 뱉는가"(디코드)보다 "얼마나 빨리 긴 입력을 읽어들이는가"(프리필) 쪽에 더 가깝다는 뜻이기 때문입니다. 아래 동시 요청 수 실험에서 이 프리필 비중이 계속 발목을 잡습니다.
첫 번째 선택 — 공격적인 양자화로 시작
모델을 통째로 VRAM에 올리려면 GGUF 양자화가 필요했습니다. 처음엔 Unsloth의 UD-IQ3_XXS(용량 약 77~82GB, 그중 전문가(expert) 파라미터 절반은 IQ2_S급 2.5비트 초저정밀도)로 시작했습니다.
실측해보니 종목당 521초, 100종목이면 14.5시간이었습니다. 목표(10시간)의 1.45배였습니다.
숫자보다 먼저 걸린 건 "이 정밀도로 판단 품질이 얼마나 깎였는가"였습니다. IQ3_XXS까지 내려가도 되는지 확신이 안 서서, 다른 AI 모델에게 Unsloth가 공개한 양자화 품질 비교표(퍼플렉시티 보존율·KL발산)를 근거로 여러 차례 자문을 구했습니다.
그 결과 세 후보의 그림이 이렇게 정리됐습니다. Q4_K_XL(품질 보존율 92.3%, KLD 0.047로 가장 높은 정밀도)은 백본만으로도 77GiB라 두 카드 VRAM 64GiB를 넘어서 애초에 후보가 될 수 없었습니다. Q3_K_XL(88.3%, KLD 0.107)이 IQ3_XXS(85.4%, KLD 0.165)보다 품질을 덜 포기하면서도 백본이 57GiB로 VRAM 안에 거의 들어맞았습니다. 결국 처음 받았던 IQ3_XXS를 버리고 Q3_K_XL(용량 약 84GB)을 새로 받았습니다.
동시성을 올리다 GPU가 통째로 멈추다
속도를 더 끌어올리려고 동시 요청 수를 늘리는 실험을 했습니다. 그런데 특정 조합에서 GPU 자체가 응답을 멈추는 현상이 나왔습니다.
원인을 두 번 잘못 짚었습니다. 처음엔 소프트웨어 설정 문제로 봤다가, 다시 보니 그것도 아니었고, 결국 커널 로그에 남은 NVRM: Xid 8 — RC(Row Chase) 워치독이 7초 만에 GPU를 강제 리셋한 기록이 진짜 원인이었습니다. GPU가 일정 시간 응답이 없으면 드라이버가 강제로 리셋을 거는 안전장치인데, np=8 동시 요청 조합에서 그 시간을 넘겨버린 겁니다.
프롬프트를 줄여서 14%를 벌다
모델 자체를 못 바꾼다면 모델에 넣는 입력을 줄이는 쪽으로 방향을 틀었습니다. 리서치 담당에게 매번 반복해서 넣던 지시문 앞부분을 정리하고, 모델 응답 자체를 더 간결하게 요구하는 패치 두 가지를 적용했습니다.
같은 조건으로 A/B 비교한 결과 패치 전 457.7초, 패치 후 391.8초로 14.4% 빨라졌습니다. 100종목 기준으로 환산하면 12.7시간에서 10.9시간으로 줄어드는 셈이었습니다.
패치를 켰다고 판단 품질이 흔들리지는 않았는지도 확인했습니다. 등급이 달라진 종목이 몇 개 나왔지만, 같은 조건을 반복 실행해도 어차피 절반가량은 등급이 흔들리는 모델이라(자기재현율 51.5%), 이 정도 변동은 패치 탓이 아니라 원래 있던 노이즈로 판정했습니다.
레이어를 다른 카드로 옮겼다가 역전당하다
전문가 파라미터만 골라서 상대적으로 여유 있는 카드로 몰아주는 실험도 해봤습니다. 단일 요청 벤치마크로는 8~17% 빨라 보여서 기대를 걸었습니다.
그런데 실제 서버 조건(동시 요청 여러 개)으로 다시 재보니 오히려 6.7% 느려졌습니다. 원인을 다시 파보니, 그 배치 방식이 첫 토큰이 나오기까지 걸리는 시간(프리필)을 20%나 늘려버리는 부작용이 있었고, 동시 요청이 여러 개 몰릴 땐 그 부작용이 단일 요청에서 얻은 이득을 통째로 잡아먹었습니다. 이 방식은 폐기했습니다.
벤치마크 숫자가 거짓말을 한 순간
중간 정밀도 버전을 받은 뒤 표준 벤치마크 도구로 재보니, 프리필 속도가 이전 버전보다 22~25% 나쁘게 나왔습니다. 이대로면 채택을 재검토해야 할 수치였습니다.
다시 자문을 구해 원인을 파보니, 벤치마크 도구가 모델을 처음 로드할 때 일부 데이터를 지연 로딩하는 방식으로 돌아가고 있었고, 그 첫 실행이 콜드 캐시 상태라 인위적으로 느리게 찍힌 것이었습니다. 실제 조건(캐시가 데워진 상태)으로 교정하면 오히려 2~7% 더 빠른 수치였습니다.
이 일로 벤치마크 도구 하나의 숫자만 보고 판단하면 안 된다는 걸 다시 확인했습니다. 그래서 이후 모든 판단은 실제 서버를 띄운 상태에서 재현한 값으로만 내리기로 했습니다.
실서버로 재검증한 결과, 중간 정밀도 버전은 종목당 395.7초로 처음 받았던 버전(391.8초)과 비교해 손해가 1%뿐이었습니다. 압축을 덜 한 버전인데도 속도 손해가 거의 없었으니, 이 버전을 최종 채택했습니다.
저온 실험이 알려준 것 — 버그가 없었다는 버그
모델 출력을 더 일관되게 만들려고 응답 다양성을 낮추는(온도를 낮추는) 실험도 했습니다. 그런데 다시 자문을 구하는 과정에서 이상한 점이 발견됐습니다.
실제로는 설정이 서버에 전달되는 배관(파이프라인) 어딘가에서 빠져 있어서, 온도를 낮췄다고 생각한 실행이 사실은 매번 기본값으로 돌아가고 있었습니다. 앞서 의심했던 패치는 죄가 없었던 셈이라, 이 배관 버그를 고치는 작업으로 방향을 돌렸습니다.
20종목 페어드 비교로 마침표를 찍다
여기까지 오면서 나온 판정들을 마지막으로 한 번에 확인하려고, 패치를 끈 조건과 켠 조건으로 같은 20종목을 각각 통째로 돌려 정면 비교했습니다.
패치 끈 쪽은 종목당 423.5초, 켠 쪽은 390.5초로 8% 빨랐습니다. 20종목 중 등급이 정확히 일치한 게 11개였고, 나머지 변동에 방향성이 있는지 통계 검정(부호검정)을 걸어봤더니 유의미한 방향성은 없었습니다(p=1.0). 패치가 속도는 올리면서 판단은 흔들지 않는다는 게 이걸로 확정됐습니다.
최종 구성은 중간 정밀도 양자화에 패치 두 개를 켠 상태로 종목당 390.5초, 100종목 기준 10.85시간. 목표(10시간) 대비 1.085배까지 좁혔습니다.
프로덕션 전환, 그리고 바로 터진 사고 두 건
여기까지 검증한 결과를 들고 프로덕션 전환을 결정했습니다. 기존에 쓰던 모델을 내리고 이 모델로 상주 서비스 다섯 개를 재시작했습니다.
전환하자마자 문제 두 가지가 드러났습니다. 첫째, 실행이 끊겼다 이어받는 로직이 "어떤 모델로 만든 결과인지"를 확인하지 않고 있어서, 방금 내린 구모델의 잔재 데이터가 새 모델 라벨을 달고 섞여 들어갈 뻔했습니다. 파라미터 하나를 추가해서 모델이 다르면 이어받지 않도록 막았습니다.
둘째, 문제가 생기면 예전 모델로 자동으로 되돌아가는 장치도, 자동 백업도 없다는 걸 그제야 발견했습니다. 일단 수동으로 백업부터 해두고 안전망 스크립트를 급하게 만들어 덮었습니다.
전환 첫날 밤, 완전히 무관한 원인으로 스킵
전환 당일 밤은 100종목 중 77개만 처리하고 나머지를 건너뛰었습니다. 새 모델 탓이라고 의심하며 로그를 뒤졌는데, 원인은 전혀 다른 곳이었습니다.
코드 리뷰용으로 짜둔 테스트 스크립트 하나가 정리 과정에서 "8090번 포트를 쓰는 llama-server 프로세스를 전부 종료"하는 명령을 실행했는데, 하필 그 포트가 방금 올린 프로덕션 서버 포트와 같았습니다. 테스트가 실제 서비스를 오살한 겁니다.
서버가 34분간 응답이 없자 시스템은 이걸 외부 네트워크 장애로 오판했고, 그 오판이 이어져 나머지 종목들을 건너뛰는 결과로 이어졌습니다. 테스트 스크립트에 안전장치(가짜 응답으로 대체하지 않는 한 진짜 포트를 건드리지 못하게 막는 가드)를 추가해서 막았습니다.
이튿날 — 동시 요청 수(np)·배치(-b) 조합을 하루 종일 훑다
프로덕션 전환 다음 날은 하루를 통째로 써서 동시 요청 수(-np)와 배치 크기(-b), 두 카드 간 레이어 분배(-ts)를 조합별로 바꿔가며 실측했습니다. 그동안 실서버로 재확인한 조합만 추리면 이렇습니다.
| 조합 | 결과 | 종목당 소요(100종목 환산) | 비고 |
|---|---|---|---|
| np=1(초기 기준) | 성공 | 524초(14.5시간) | 동시 요청 없음 — 프리필 68초 + 디코드 316초 |
| np=4, -b512, ts23/25(최종 채택) | 성공 | 391.8초(10.88시간, 검증 실행 기준) | 패치 켠 상태. 아래 "결과" 절의 오늘 실제 100종목 완주는 종목당 369.9초 |
| np=4, -b1024, ts23/25 | 성공하지만 더 느림 | 472.0초(13.1시간) | 배치를 올려도 이득 없음 |
np=5, -b512, ts23/25(-c 133,120으로 확장) |
성공하지만 더 느림 | 475.0초(13.2시간) | |
np=5, -b1024, ts23/25(-c 133,120) |
성공 | 442.3초(12.3시간) | np=4보다 느려서 미채택 — 슬롯당 남는 컨텍스트 여유가 0이 되는 구성이라 완주 안정성 관점에서도 보류 |
np=8, -c 160,000(통합 KV) |
겉보기엔 진행 중, 실제론 71분 만에 GPU 강제 리셋 | — | 커널 로그의 Xid 8 — 응답 없음 워치독이 GPU를 리셋한 기록으로 뒤늦게 확인 |
np=4, -c 160,000(통합 KV) |
기동 4.5분 만에 같은 GPU 강제 리셋 | — | |
| np=3, -b1024, ts23/25(검증된 "안전한" 분배) | GPU 메모리 부족(OOM) | — | 로드 자체가 안 됨 |
| np=3 / np=4, ts24/24 | 둘 다 GPU 메모리 부족(OOM) | — | 레이어 분배 자체가 근본 한계로 최종 기각(np값과 무관) |
몇 가지 되짚어볼 지점이 있었습니다.
처음엔 배치 크기(-b 1024)를 OOM의 원인으로 의심했습니다. np=3/np=5 조합에서 OOM이 반복됐거든요. 그런데 다시 짚어보니 -b 값은 GPU가 한 번에 묶어 처리하는 토큰 수만 바꿀 뿐, 메모리 예약량 자체는 별도 설정(-ub, 이번엔 지정 안 함)이 정해서 -b와 무관하다는 게 드러났습니다. 애먼 변수를 의심했던 셈입니다.
더 이상한 건 np=5는 성공했는데 똑같은 설정(-c 133,120, ts23/25)에서 np=3은 실패했다는 점이었습니다. 동시 요청을 더 적게 시켰는데 오히려 메모리가 모자란 역설적인 결과라, 그날 실험만으로는 원인을 확정하지 못하고 "그 시점 GPU에 남아있던 여유 메모리가 우연히 달랐다"는 가설과 "슬롯 수가 적을수록 슬롯 하나가 차지하는 버퍼가 커진다"는 가설 두 개만 남긴 채 미해결로 넘겼습니다. 더 파고들 실익(3% 안팎)보다 다른 우선순위가 급했기 때문입니다.
이 과정에서 눈에 띈 관측도 하나 있었습니다. AMD 카드는 거의 항상 사용률 100%로 병목이었고, NVIDIA 카드는 오히려 17~26%는 놀고 있었습니다. 카드 두 장의 성능 격차가 그대로 병목으로 드러난 셈입니다.
결국 첫날 정한 -np 4 -b 512 -c 131072 ts23/25 조합을 그대로 굳히기로 했습니다. 다른 조합 중 이걸 이긴 게 없었고, 프로덕션 전환 후 첫 완주라는 목표 앞에서 굳이 불안정한 조합(np=5의 컨텍스트 마진 0)으로 바꿀 이유가 없었습니다.
결과 — 오늘 아침 완주한 실제 데이터, 그리고 아직 답하지 못한 질문
전환 이틀째 되던 날 아침(2026-09-08 07:18), 전날 21:02에 시작해 10시간 17분 만에 처음으로 100종목을 끊기지 않고 완주했습니다. 테스트가 아니라 이 실제 런의 서버 로그를 직접 파싱해서 낸 수치입니다.
| 항목 | 수치 |
|---|---|
| 종목 수 | 100개 |
| 실행 시각 | 09-07 21:02 ~ 09-08 07:18 |
| 총 소요 | 10시간 17분(종목당 평균 369.9초) |
| 서버가 실제로 처리한 프롬프트 토큰 | 11,658,471 |
| 생성한 토큰 | 2,215,506 |
| 프리필 속도 | 약 610 tok/s |
| 디코드 속도 | 약 124 tok/s(4슬롯 합산, 슬롯당 약 31 tok/s) |
종목당 369.9초는 앞서 검증 단계에서 봤던 390~396초대 숫자보다도 더 빠른 값입니다. 검증은 몇 종목·몇십 종목짜리 표본이었고, 이건 100종목 전체를 실제로 돌린 값이라 차이가 나는 게 자연스럽습니다.
속도 튜닝이라는 목표 자체는 여기서 일단락됐습니다.
다만 아직 확인 못 한 게 하나 있습니다. 이 모델이 실제로 좋은 판단을 내리는지, 즉 진짜 매매 성과 기준의 품질은 이 시점까지 단 한 번도 측정하지 않았습니다. 여기서 다룬 건 전부 "얼마나 빠른가"와 "패치가 판단을 안 흔드는가"뿐이고, "판단 자체가 얼마나 좋은가"는 앞으로 따로 재봐야 할 숙제로 남아 있습니다.
돌아보며
이번 과정에서 가장 크게 남은 교훈은, 벤치마크 도구 하나의 숫자를 그대로 믿지 않는 습관이었습니다. 도구가 콜드 캐시 상태를 재고 있었다는 것도, 온도 설정이 배관 어딘가에서 새고 있었다는 것도, 전부 "숫자가 이상하다"는 감각 하나로 자문을 다시 구하고 나서야 드러났습니다.
전에 그래픽카드를 교체하면서 벤치마크를 잘못 읽어 판정이 두 번 뒤집혔던 일(새 창)을 정리한 적이 있는데, 이번에도 같은 교훈이 반복됐습니다. 카탈로그 숫자나 단발성 벤치마크보다, 실제로 쓸 조건 그대로 재현해서 얻은 실측값이 항상 더 믿을 만했습니다.
Top comments (0)