treeru.com
AI

MoE 모델이 쓰레기를 뱉었다 — Expert 가중치 18,432개 전수 조사로 찾은 원인

2026-06-12
Treeru

로컬 LLM 서빙을 테스트하다가 이상한 상황을 만났습니다. Qwen3-30B-A3B(MoE) 모델의 GGUF 파일을 KTransformers로 돌리면 모든 추론에서 의미 없는 텍스트가 나왔습니다. 그런데 똑같은 파일을 llama-cpp-python으로 돌리면 멀쩡했습니다. 파일은 정상인데 한쪽 런타임에서만 출력이 무너지는 상황 — 이걸 끝까지 추적한 기록입니다.

결론부터 말하면 원인은 양자화 가중치를 bf16으로 미리 풀어놓고(bf16 dequantize) bf16으로 계산하는 파이프라인이었습니다. 이 글의 핵심은 그 결론 자체보다, 결론까지 가는 과정입니다. “모델이 이상한 말을 한다”는 막막한 증상을 레이어 → 가중치 → 블록 단위로 좁혀가는 방법은 다른 추론 장애를 만났을 때도 그대로 쓸 수 있습니다.

18,432개

전수 조사한 expert 가중치 행렬

2개

실제 손상이 발견된 가중치

0.78

손상 가중치의 cosine similarity

48층

오류가 누적된 레이어 수

증상 — 멀쩡한 파일, 망가진 출력

디버깅의 첫 단계는 화려한 도구가 아니라 변인 분리입니다. “모델이 쓰레기를 뱉는다”는 증상에서 용의자는 크게 셋입니다. 모델 파일, 런타임(추론 엔진), 그리고 설정. 가장 먼저 한 일은 같은 GGUF 파일을 다른 런타임에 넣어보는 것이었습니다.

조합결과
같은 GGUF + llama-cpp-python정상 출력
같은 GGUF + KTransformers전부 garbage output

이 한 번의 교차 테스트로 수사 범위가 절반으로 줄었습니다. 파일은 무죄, 설정 차이도 아님 — 런타임이 가중치를 다루는 방식 어딘가에 문제가 있다는 뜻입니다. 여기서 바로 코드를 뒤지는 대신, 모델 내부에서 무슨 일이 벌어지는지부터 관측하기로 했습니다.

왜 CPU+GPU 하이브리드를 시험했나

배경을 짧게 짚고 가겠습니다. MoE(Mixture of Experts) 모델은 레이어마다 여러 개의 expert(전문가 네트워크)를 두고, 토큰마다 일부만 골라 씁니다. Qwen3-30B-A3B는 전체 30B 파라미터 중 토큰당 3B만 활성화됩니다. 그래서 “어차피 한 번에 일부만 쓰니까, 안 쓰는 expert는 CPU 메모리에 두고 필요할 때만 불러오면 작은 GPU로도 큰 모델을 돌릴 수 있지 않을까”라는 아이디어가 나옵니다. KTransformers가 바로 그 접근입니다.

저희도 VRAM이 작은 환경에서 30B급 MoE를 돌릴 수 있는지 검증할 겸 테스트를 진행했습니다. expert는 CPU에 오프로딩하고 attention과 embedding만 GPU에 올리는 구성입니다. 그리고 첫 추론부터 쓰레기가 나왔습니다.

참고로 MoE는 “활성 3B니까 가볍다”고 오해하기 쉬운데, 메모리는 30B 전체를 올려야 합니다 (bf16 기준 약 57GB). 속도도 expert 라우팅 오버헤드 때문에 dense 14B보다 오히려 느렸습니다(22 vs 41 tok/s). 이 비교는 MoE vs Dense 실전 비교에 따로 정리했습니다.

1단계 — 레이어별 hidden state 추적

출력이 무너졌다는 건 모델 내부 어딘가에서 숫자가 무너지기 시작했다는 뜻입니다. 그래서 정상 동작하는 레퍼런스(원본 safetensors를 bf16으로 로드한 모델)와 문제의 KTransformers(GGUF) 모델에 같은 입력을 넣고, 48개 레이어 각각의 hidden state 통계를 나란히 비교했습니다.

레이어별 비교에서 드러난 것

Layer 0~1 : 두 모델 통계 거의 일치
Layer 2   : 표준편차 6.93 vs 4.69 (레퍼런스 대비 +48%)  ← 여기부터 발산
중간 레이어: max 값이 1,400 부근에 플래토 (레퍼런스는 300~400)
마지막    : 분포가 완전히 달라짐 → 출력 붕괴

Layer 2부터 표준편차가 48% 이상 튀었습니다. 입구(embedding)는 멀쩡한데 특정 레이어부터 분포가 발산한다는 건, 그 레이어의 가중치 또는 그 레이어를 처리하는 연산 경로에 문제가 있다는 강한 신호입니다. 용의선상이 “모델 전체”에서 “특정 레이어의 expert 가중치들”로 좁혀졌습니다.

2단계 — Expert 가중치 18,432개 전수 조사

다음 질문은 “그래서 어떤 가중치가 깨졌는가”입니다. 이 모델의 expert 가중치 행렬은 48개 레이어 × 128개 expert × 3개 projection(gate/up/down) = 18,432개입니다. 샘플링으로 몇 개만 보면 놓칠 수 있어서, 전부 비교했습니다. GGUF에서 dequantize한 값과 safetensors 원본을 행렬 단위로 대조해 cosine similarity를 계산하는 방식입니다.

가중치cosine simmax diff판정
Layer 2 / Expert 92 / down_proj (Q6_K)0.780814.46심각 손상
Layer 2 / Expert 92 / gate_proj (Q4_K)0.9318경미 손상
나머지 18,430개> 0.95정상 범위정상

18,432개 중 심각한 손상은 단 2개 — 1단계에서 발산이 시작된 바로 그 Layer 2에 있었습니다. 레이어 추적과 가중치 조사가 같은 지점을 가리키니, 우연이 아니라는 확신이 생깁니다. 여기서 중요한 교훈 하나: 18,430개가 정상(cos > 0.95)이어도 모델은 죽습니다.순전파는 곱셈의 연쇄라, 앞쪽 레이어의 오류 하나가 48층을 지나며 기하급수적으로 증폭되기 때문입니다.

3단계 — 블록 단위까지 내려가기

마지막으로 “왜 하필 이 행렬이 깨졌나”를 확인했습니다. GGUF의 Q4_K/Q6_K 양자화는 가중치를 블록 단위로 묶어 저장하는데, 문제의 down_proj 행렬을 블록 1개씩 dequantize해서 원본과 비교했습니다. 그 결과 특정 블록 하나에서 dequantize 값이 원본 대비 최대 14.5나 어긋나는 것을 확인했습니다. 행렬 전체가 고르게 나쁜 게 아니라, 특정 블록의 양자화 복원값이 크게 틀어져 있었던 겁니다.

여기까지 내려가면 증상이 완전히 설명됩니다. 특정 블록의 복원 오차 → 해당 expert의 출력 왜곡 → Layer 2부터 hidden state 발산 → 48층 누적 → 출력 붕괴. 그런데 아직 한 가지 모순이 남습니다. 같은 파일이 llama-cpp-python에서는 왜 멀쩡했을까요?

진짜 원인 — bf16 누적의 함정

답은 두 런타임이 양자화 가중치를 다루는 방식의 차이에 있었습니다.

KTransformers (출력 붕괴)

  • • 양자화 가중치를 bf16으로 미리 풀어서 저장
  • • 행렬 곱도 bf16 정밀도로 수행
  • • 복원 오차가 bf16의 낮은 정밀도와 결합
  • • 레이어를 지날수록 오류가 누적·증폭

llama.cpp 계열 (정상)

  • • 커널 안에서 dequantize와 곱셈을 동시에 처리
  • • 누적 연산은 float32로 수행
  • • 풀어놓은 가중치를 저정밀도로 보관하지 않음
  • • 같은 복원 오차라도 누적 단계에서 흡수됨

오류 증폭 메커니즘

[GGUF Q4_K/Q6_K 양자화 가중치]
  → bf16으로 dequantize 후 영구 보관      ← 1차 정밀도 손실
    → bf16 행렬곱                          ← 2차 정밀도 손실
      → 48개 레이어 순전파 통과
        → 오류 기하급수적 누적 → 출력 붕괴

요약하면 “양자화 모델은 누적 정밀도가 생명”입니다. 양자화 자체는 손실 압축이라 작은 오차를 항상 품고 있고, 그 오차를 어디서 흡수하느냐가 런타임의 실력입니다. 특히 MoE는 expert 수가 많아 가중치 행렬 수가 dense 모델의 수십 배라서, 어딘가 한 곳쯤 복원이 어긋날 확률도 그만큼 높습니다. 같은 파이프라인이 dense 모델에서는 버틸 수 있어도 MoE에서 먼저 무너지는 이유입니다.

수정 시도, 그리고 OOM 사고 2번

원인을 알았으니 고칠 수 있는지도 시도해 봤습니다. 결과는 전부 한계에 부딪혔습니다.

시도결과
손상 레이어의 expert를 원본(safetensors)으로 교체표준편차는 개선(6.93→6.49)됐지만 출력은 여전히 garbage. 다른 레이어에서도 오류가 누적되고 있었음
연산을 float32로 강제CPU 연산 커널이 bf16만 지원 — NaN 발생
원본 가중치 전체를 RAM에 로딩해 비교메모리 초과 → OOM → 시스템 다운 2회

디버깅이 시스템을 두 번 죽였다

세 번째 시도가 사고로 이어졌습니다. 원본 가중치 전체(약 60GB)를 RAM에 올려 비교하려다 물리 메모리를 초과했고, OOM 킬러가 정작 원인인 Python 프로세스 대신 주변의 작은 서비스들만 연쇄로 죽이면서 시스템이 스왑 스래싱에 빠져 SSH 접속까지 불가능해졌습니다. 같은 실수를 두 번 반복하고 나서야 디버깅 환경에 안전망을 깔았습니다.

# 대용량 실험은 반드시 메모리 상한을 걸고 실행
# 초과 시 이 scope만 죽고 시스템은 살아남는다 (상한은 물리 RAM의 80% 권장)
systemd-run --user --scope -p MemoryMax=80% -p MemorySwapMax=0 \
  python debug_script.py

이 사고의 교훈은 분명합니다. 모델 디버깅은 그 자체가 대용량 작업이라, 실험 코드에도 운영 수준의 안전장치(메모리 상한, 스왑 차단)가 필요합니다. 원인을 찾는 작업이 서비스 장애의 원인이 되면 곤란하니까요.

결론 — 도구가 아니라 환경이 먼저다

이 테스트의 최종 판단은 단순했습니다. VRAM이 충분한 환경에서는 CPU 오프로딩이라는 문제 설정 자체가 불필요합니다. 모델이 GPU에 통째로 올라가는데 굳이 expert를 CPU로 빼서 정밀도·호환성 리스크를 떠안을 이유가 없습니다. 저희 운영 서빙은 GPU 전용 엔진인 SGLang + AWQ 양자화 dense 모델로 확정했고, 단일 요청 기준 135 tok/s로 안정적으로 돌고 있습니다.

그렇다고 KTransformers가 나쁜 도구라는 뜻은 아닙니다. VRAM이 16~24GB뿐인 시스템에서 30B급 MoE를 돌려야 한다면 CPU 오프로딩은 거의 유일한 선택지이고, 그때는 충분히 의미가 있습니다. 다만 이번에 확인한 정밀도 문제가 해결됐는지(f32 누적 또는 온라인 dequantize) 먼저 검증하고 써야 합니다.

추론 출력이 무너졌을 때의 디버깅 체크리스트

  • 변인 분리부터 — 같은 모델 파일을 다른 런타임에서 돌려본다. 파일 문제인지 런타임 문제인지가 먼저다.
  • 내부를 관측한다 — 레퍼런스 모델과 레이어별 hidden state 통계(std, max)를 비교해 발산 시작점을 찾는다.
  • 샘플링보다 전수 조사 — 용의 범위가 좁혀졌다면 가중치를 전부 대조한다. 수만 개 중 단 2개의 손상이 모델을 죽인다.
  • 정밀도 경로를 의심한다 — 양자화 모델이라면 dequantize 방식과 누적(accumulation) 정밀도를 확인한다. bf16 누적은 위험 신호다.
  • 실험에도 안전망 — 대용량 로딩은 systemd-run 메모리 상한 안에서 실행한다. 디버깅이 장애가 되면 안 된다.
  • 문제 설정을 재검토한다 — 고치기 전에 “이 환경에서 이 도구가 애초에 필요한가”를 묻는다.

MoE와 dense 모델의 실측 비교는 MoE vs Dense 실전 비교에서, 운영 모델을 고르기까지의 한국어 품질 검증은 로컬 LLM 6종 종합 벤치마크에서 이어서 볼 수 있습니다.

T

Treeru

웹 개발, IT 인프라, AI 솔루션 분야의 실무 인사이트를 공유합니다. 기업의 디지털 전환을 돕는 IT 파트너, Treeru입니다.

공유

관련 글

© 2026 TreeRU. All rights reserved.

본 콘텐츠의 저작권은 TreeRU에 있으며, 출처를 밝히지 않은 무단 전재 및 재배포를 금합니다. 인용 시 출처(treeru.com)를 반드시 명시해 주세요.