[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 4/14화: mlx-lm으로 Qwen3 서빙 — Mac Studio 실전 가이드
이 글은 「온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계」 시리즈 4일차로, Mac Studio 환경에서 mlx-lm을 활용해 Qwen3를 실전 서빙하는 방법을 다룹니다.
어제(3일차)에서는 Apple Silicon(MLX)과 NVIDIA CUDA(vLLM)의 하드웨어·런타임 특성을 비교하고, Qwen3 사이즈별로 어느 노드에 올릴지 매핑했습니다. 오늘은 그 분기점 중 한 갈래—Mac Studio + MLX—를 잡고, 실제로 Qwen3를 띄워봅니다.
오늘의 핵심 3가지
- 통합 메모리 산수 — Qwen3 각 변형이 Mac Studio에 올라갈 때 모델 가중치 + KV 캐시 + OS 예약을 합산해 실제 가용 컨텍스트 길이를 계산합니다.
- OpenAI 호환 서버 즉시 가동 —
mlx_lm.server한 줄이면/v1/chat/completions엔드포인트가 열립니다. 기존 OpenAI SDK 클라이언트를 그대로 연결할 수 있습니다. - 프로덕션 래퍼 서버 — 요청 큐잉, 헬스체크, 구조화 로깅, Thinking 모드 후처리를 갖춘 FastAPI 래퍼를 완성합니다. GitHub에 올려도 동작하는 수준입니다.
MLX — Apple Silicon 전용 추론 엔진의 구조
왜 Mac Studio에서 MLX인가
MLX는 Apple이 2023년 말 공개한 머신러닝 프레임워크로, Apple Silicon의 통합 메모리 아키텍처(Unified Memory Architecture, UMA)를 네이티브로 활용합니다. LLM 추론에서 이것이 의미하는 바는 단순하면서도 결정적입니다.
NVIDIA GPU에서는 모델 가중치가 VRAM에, 전처리·후처리는 시스템 RAM에 상주합니다. 모델이 VRAM보다 크면 오프로딩(offloading)이 필요하고, PCIe 대역폭이 병목이 됩니다. 반면 Apple Silicon의 UMA에서는 CPU와 GPU가 동일한 물리 메모리 풀을 공유합니다. 모델 가중치를 GPU 메모리로 “복사”할 필요 없이, 메모리에 올려놓으면 Metal GPU 코어가 바로 접근합니다.
이 구조가 LLM 서빙에 주는 실질적 이점은 세 가지입니다.
- 메모리 용량 한계 완화 — Mac Studio M2 Ultra는 최대 192GB, M4 Ultra는 최대 512GB 통합 메모리를 탑재합니다. NVIDIA H100의 80GB HBM3 대비 절대 용량이 큽니다. Qwen3-235B-A22B(Q4 기준 약 135GB)도 단일 노드에 올릴 수 있습니다.
- 제로카피 추론 — 가중치 텐서가 이미 GPU 접근 가능한 메모리에 있으므로, 로딩 후 추가 전송 지연이 없습니다.
- 대형 KV 캐시 — 남은 메모리 전부를 KV 캐시로 쓸 수 있어, 긴 컨텍스트(32K~128K 토큰) 처리에 유리합니다.
단, 메모리 대역폭은 HBM에 비해 낮습니다. M2 Ultra가 약 800GB/s인 반면 H100은 3.35TB/s입니다. LLM의 디코딩(한 토큰씩 생성) 단계는 메모리 대역폭에 바운드되므로, 초당 토큰 생성 속도(tokens per second, TPS)는 CUDA 대비 낮을 수밖에 없습니다. 이것은 트레이드오프이지, 결함이 아닙니다.
MLX 프레임워크의 LLM 추론 경로
MLX의 LLM 추론은 크게 세 계층으로 나뉩니다.

flowchart TB
subgraph "mlx-lm (Python 패키지)"
Server["mlx_lm.server\nOpenAI 호환 HTTP"]
Generate["mlx_lm.generate\nPython API"]
Load["mlx_lm.load\n모델 로딩 + 양자화"]
end
subgraph "MLX Core (C++ / Metal)"
Graph["Lazy Evaluation\n연산 그래프"]
Metal["Metal Compute\nGPU 커널"]
Mem["Unified Memory\n할당 / 해제"]
end
subgraph "Hardware"
GPU["Metal GPU\n60~80 코어"]
UMA["Unified Memory\n64~512 GB"]
ANE["Apple Neural Engine\n(LLM에는 미사용)"]
end
Server --> Generate --> Load
Load --> Graph
Graph --> Metal
Metal --> GPU
GPU --> UMA
Mem --> UMA
핵심 동작 원리를 짚으면:
- 지연 평가(lazy evaluation) — MLX는 연산을 즉시 실행하지 않고 그래프로 쌓았다가
mx.eval()시점에 한꺼번에 실행합니다. 이를 통해 커널 퓨전(kernel fusion)이 가능해지고, Metal GPU의 점유율을 높입니다. - Metal 컴퓨트 셰이더 — 행렬 곱셈(GEMM), 어텐션, 활성화 함수 등 LLM의 핵심 연산이 Metal 셰이더로 구현되어 있습니다. CUDA의 cuBLAS에 대응하는 역할입니다.
- 양자화 커널 — 4비트, 8비트 양자화 가중치에 대한 전용 Metal 커널이 있어, 역양자화(dequantization) + 행렬 곱을 하나의 커널에서 처리합니다.
Apple Neural Engine(ANE)은 현재 MLX의 LLM 추론에 사용되지 않습니다. ANE는 고정 크기의 소형 모델에 최적화되어 있고, LLM의 동적 시퀀스 길이·거대 어텐션 행렬과는 맞지 않습니다. 추론은 전적으로 Metal GPU 코어에서 실행됩니다.
환경 구축 — 0에서 첫 추론까지
사전 요구사항
다음 환경을 전제합니다.
- 하드웨어 — Mac Studio M2 Ultra (192GB 통합 메모리, 76코어 GPU) 또는 동급 이상. M2 Max(96GB), M4 Ultra(최대 512GB)도 지원되며, 탑재 가능 모델이 달라집니다.
- OS — macOS Sonoma 14.0 이상 (Metal 3 완전 지원).
- Python — 3.11 이상. Homebrew 또는 python.org 설치 모두 무방합니다.
- 디스크 — Qwen3 모델 파일 저장용 SSD 여유 공간 50GB 이상 (여러 변형을 동시 보관할 경우).
설치 스크립트
가상환경을 만들고 mlx-lm과 부가 도구를 설치합니다. 아래 스크립트를 setup_mlx.sh로 저장하고 실행하세요.
#!/usr/bin/env bash
# setup_mlx.sh — MLX Qwen3 서빙 환경 구축
set -euo pipefail
PROJECT_DIR="$HOME/mlx-qwen3"
VENV_DIR="$PROJECT_DIR/.venv"
mkdir -p "$PROJECT_DIR"
cd "$PROJECT_DIR"
# 1. 가상환경 생성
python3.11 -m venv "$VENV_DIR"
source "$VENV_DIR/bin/activate"
# 2. 핵심 패키지 설치
pip install --upgrade pip
pip install \
mlx-lm>=0.22.0 \
fastapi>=0.115.0 \
uvicorn[standard]>=0.32.0 \
openai>=1.50.0 \
httpx>=0.27.0 \
pydantic>=2.9.0 \
psutil>=6.0.0
# 3. 설치 확인
python -c "
import mlx.core as mx
import mlx_lm
print(f'MLX version : {mx.__version__}')
print(f'mlx-lm version : {mlx_lm.__version__}')
print(f'Metal available: {mx.metal.is_available()}')
print(f'Metal device : {mx.default_device()}')
"
echo "✅ 환경 구축 완료: $VENV_DIR"
Metal available: True와 Metal device: Device(gpu, 0)이 출력되면 정상입니다. False가 나오면 macOS 버전이 너무 낮거나, Rosetta 2 환경에서 x86 Python을 실행하고 있을 가능성이 높습니다. file $(which python3.11)로 arm64 바이너리인지 확인하세요.
Qwen3 모델 선택과 다운로드
MLX 포맷 모델 목록
Hugging Face의 mlx-community 조직에서 Qwen3의 MLX 변환 모델을 제공합니다. 주요 변형과 용도를 정리합니다.
| 모델 | 양자화 | 가중치 크기 | 총 메모리 (추정) |
용도 |
|---|---|---|---|---|
| Qwen3-8B | 4-bit | 4.7 GB | ~6 GB | 라우팅·분류·경량 대화 |
| Qwen3-8B | 8-bit | 8.9 GB | ~11 GB | 품질 우선 경량 대화 |
| Qwen3-14B | 4-bit | 8.3 GB | ~10 GB | 일반 대화·요약·번역 |
| Qwen3-14B | 8-bit | 15.5 GB | ~18 GB | 품질 우선 범용 |
| Qwen3-30B-A3B | 4-bit | 17.2 GB | ~20 GB | MoE 메인 — 속도·품질 균형 |
| Qwen3-30B-A3B | 8-bit | 32.8 GB | ~36 GB | MoE 메인 — 품질 극대화 |
| Qwen3-32B | 4-bit | 18.9 GB | ~22 GB | Dense 메인 — 복잡 추론 |
| Qwen3-32B | 8-bit | 35.2 GB | ~38 GB | Dense 메인 — 최고 품질 |
| Qwen3-235B-A22B | 4-bit | ~133 GB | ~140 GB | 최대 MoE — 192GB 노드 전용 |
“총 메모리” 열은 가중치 파일 크기에 MLX 런타임 오버헤드(텐서 메타데이터, 토크나이저, Python 프로세스)를 더한 추정치입니다. KV 캐시는 별도이므로 다음 절에서 다시 계산합니다.
권장 시작점
Qwen3-14B 4-bit을 권장합니다. 10GB 이하의 메모리로 실용적인 품질을 제공하며, 어떤 Mac Studio 구성(64GB 이상)에서든 넉넉하게 돌아갑니다. 시리즈 이후 회차에서 게이트웨이를 구성할 때 라우팅·분류용 Qwen3-8B와 메인 생성용 Qwen3-14B 또는 Qwen3-30B-A3B를 함께 올리는 구성이 등장하므로, 먼저 14B로 파이프라인을 검증해두면 확장이 자연스럽습니다.
모델 다운로드
mlx_lm.load()를 처음 호출하면 Hugging Face Hub에서 자동 다운로드됩니다. 하지만 서버 기동 시간에 다운로드가 끼면 불편하므로, 사전에 받아두는 것을 추천합니다.
# 사전 다운로드 (캐시 디렉토리: ~/.cache/huggingface/hub/)
python -c "
from mlx_lm import load
print('Downloading Qwen3-14B 4-bit...')
model, tokenizer = load('mlx-community/Qwen3-14B-4bit')
print('✅ Download complete')
del model, tokenizer # 메모리 해제
"
# 다운로드 확인
du -sh ~/.cache/huggingface/hub/models--mlx-community--Qwen3-14B-4bit/
# 출력 예시: 8.3G
네트워크 환경에 따라 8~15분 소요됩니다. 다운로드 중단 시 자동 이어받기가 되므로 재실행하면 됩니다.
메모리 산수 — Mac Studio에 무엇이 올라가는가
Mac Studio에서 LLM을 서빙할 때 가장 흔한 실수는 “192GB니까 여유롭겠지”라는 낙관입니다. 통합 메모리는 모델만 쓰는 게 아닙니다. 운영 환경에서 메모리를 차지하는 요소를 모두 산수에 넣어야 합니다.

메모리 구성 요소
전체 메모리 예산은 네 가지 항목으로 나뉩니다.
총 메모리 사용량 = W + KV + OS + App
W = 모델 가중치 (양자화 포맷에 따라 결정)
KV = KV 캐시 (컨텍스트 길이 × 레이어당 캐시 크기)
OS = macOS + 상주 프로세스 (Finder, WindowServer, Spotlight 등)
App = Python 프로세스 + MLX 런타임 + 토크나이저
KV 캐시 크기 계산
KV 캐시는 토큰 단위로 누적됩니다. Qwen3-14B의 아키텍처 파라미터로 계산해보겠습니다.
Qwen3-14B 아키텍처:
- num_layers = 40
- num_kv_heads = 8 (GQA, Grouped-Query Attention)
- head_dim = 128
- KV 데이터 타입 = float16 (2 bytes)
레이어당 토큰당 KV 크기:
= 2(K+V) × num_kv_heads × head_dim × bytes_per_element
= 2 × 8 × 128 × 2
= 4,096 bytes = 4 KB
전체 모델 토큰당 KV 크기:
= num_layers × 레이어당 크기
= 40 × 4 KB
= 160 KB / 토큰
컨텍스트 길이별 KV 캐시 총량:
- 4K 토큰: 4,096 × 160 KB ≈ 640 MB
- 8K 토큰: 8,192 × 160 KB ≈ 1.28 GB
- 32K 토큰: 32,768 × 160 KB ≈ 5.12 GB
- 128K 토큰: 131,072 × 160 KB ≈ 20.48 GB
Qwen3-30B-A3B(MoE)는 전체 파라미터 30B가 모두 메모리에 올라가지만, 어텐션 헤드 구조가 비슷하므로 KV 캐시 크기는 Dense 30B급과 유사합니다. MoE의 이점은 추론 속도(활성 파라미터 3B만 연산)에 있지, 메모리 절약에 있지 않다는 점을 기억하세요.
Mac Studio 모델별 실전 메모리 매트릭스
macOS는 통상 6~10GB를 상시 사용합니다(Spotlight 인덱싱, WindowServer 등). Python + MLX 런타임 오버헤드를 약 1~2GB로 잡으면, 모델과 KV 캐시에 할당 가능한 실효 메모리는 다음과 같습니다.
| Mac 구성 | 총 메모리 | OS+App 예약 | 실효 메모리 |
|---|---|---|---|
| M2 Max | 96 GB | ~10 GB | ~86 GB |
| M2 Ultra | 192 GB | ~10 GB | ~182 GB |
| M4 Ultra | 512 GB | ~12 GB | ~500 GB |
실효 메모리에서 모델 가중치를 빼면 남는 게 KV 캐시 예산입니다. 이를 토큰당 KV 크기로 나누면 최대 지원 컨텍스트 길이가 나옵니다.
| 모델 (Q4) | 가중치 | M2 Ultra 192GB KV 예산 → 최대 컨텍스트 |
M2 Max 96GB KV 예산 → 최대 컨텍스트 |
|---|---|---|---|
| Qwen3-8B | ~6 GB | 176 GB → 128K+ 충분 | 80 GB → 128K+ 충분 |
| Qwen3-14B | ~10 GB | 172 GB → 128K 가능 (20.5GB) | 76 GB → 128K 가능 |
| Qwen3-30B-A3B | ~20 GB | 162 GB → 128K 가능 | 66 GB → 128K 가능 |
| Qwen3-32B | ~22 GB | 160 GB → 128K 가능 | 64 GB → 128K 가능 |
| Qwen3-235B-A22B | ~140 GB | 42 GB → ~32K 가능 | ❌ 메모리 부족 |
주목할 점은 Qwen3-235B-A22B입니다. M2 Ultra 192GB에 가까스로 올라가지만, KV 캐시 예산이 42GB로 제한됩니다. 이 모델의 토큰당 KV 크기가 약 800KB(80레이어 × 10KB)이므로, 최대 컨텍스트는 약 50K 토큰입니다. 128K 풀 컨텍스트를 쓰려면 M4 Ultra 512GB가 필요합니다.
실무에서는 Qwen3-14B Q4(~10GB)나 Qwen3-30B-A3B Q4(~20GB)가 최적의 균형점입니다. 메모리 여유가 충분해 장기 컨텍스트를 자유롭게 쓸 수 있고, 같은 Mac에 라우팅용 Qwen3-8B를 동시에 올릴 수도 있습니다.
첫 번째 추론 — CLI로 Qwen3 돌려보기
기본 생성
# 터미널에서 즉시 테스트
python -m mlx_lm.generate \
--model mlx-community/Qwen3-14B-4bit \
--prompt "온프레미스 AI 시스템의 장점 3가지를 간결하게 설명해주세요." \
--max-tokens 512 \
--temp 0.7
첫 실행 시 모델 로딩에 5~15초가 걸립니다(디스크 I/O + 텐서 초기화). 이후에는 모델이 메모리에 상주하므로 즉시 추론을 시작합니다.
Python API로 Thinking 모드 제어
Qwen3는 /think 토큰과 /no_think 토큰으로 추론 체인(reasoning chain) 생성 여부를 제어합니다. Thinking 모드에서는 최종 답변 전에 <think>...</think> 블록으로 사고 과정을 출력합니다.
"""thinking_demo.py — Qwen3 Thinking 모드 데모"""
from mlx_lm import load, generate
MODEL_ID = "mlx-community/Qwen3-14B-4bit"
model, tokenizer = load(MODEL_ID)
# --- Thinking 모드 활성화 ---
messages_think = [
{
"role": "system",
"content": "You are a helpful assistant. /think",
},
{
"role": "user",
"content": "대한민국의 GDP가 세계 10위권인 이유를 분석해주세요.",
},
]
prompt_think = tokenizer.apply_chat_template(
messages_think, tokenize=False, add_generation_prompt=True
)
response_think = generate(
model,
tokenizer,
prompt=prompt_think,
max_tokens=1024,
temp=0.7,
)
print("=== Thinking 모드 ===")
print(response_think)
# --- Thinking 모드 비활성화 ---
messages_no_think = [
{
"role": "system",
"content": "You are a helpful assistant. /no_think",
},
{
"role": "user",
"content": "대한민국의 GDP가 세계 10위권인 이유를 분석해주세요.",
},
]
prompt_no_think = tokenizer.apply_chat_template(
messages_no_think, tokenize=False, add_generation_prompt=True
)
response_no_think = generate(
model,
tokenizer,
prompt=prompt_no_think,
max_tokens=1024,
temp=0.7,
)
print("\n=== Non-Thinking 모드 ===")
print(response_no_think)
Thinking 모드는 복잡한 추론(수학, 코딩, 다단계 분석)에서 품질을 높이지만, 토큰 소비가 2~5배 늘어납니다. 서빙 관점에서 이것은 곧 지연 시간 증가와 KV 캐시 소비 증가를 의미합니다. 8회차(컨텍스트 관리)에서 Thinking 토큰 비용을 정밀하게 다루겠지만, 지금은 “Thinking 모드를 쓰면 응답이 느려진다”는 사실만 기억해두세요.
OpenAI 호환 서버 — 한 줄로 띄우기
mlx_lm.server 즉시 실행
mlx_lm.server는 /v1/chat/completions 엔드포인트를 제공하는 내장 HTTP 서버입니다. OpenAI Python SDK, curl, 그리고 OpenAI 호환 클라이언트(LiteLLM, Continue.dev, Open WebUI 등)에서 그대로 사용할 수 있습니다.
# 기본 실행 — localhost:8080
python -m mlx_lm.server \
--model mlx-community/Qwen3-14B-4bit \
--port 8080
# 옵션 추가 — LAN 노출 + KV 캐시 제한
python -m mlx_lm.server \
--model mlx-community/Qwen3-14B-4bit \
--host 0.0.0.0 \
--port 8080 \
--max-kv-size 32768
--max-kv-size는 KV 캐시의 최대 토큰 수를 제한합니다. 이 값을 설정하지 않으면 가용 메모리를 모두 쓸 수 있으나, OOM(Out of Memory) 리스크가 생깁니다. 프로덕션에서는 반드시 설정하세요. 앞서 계산한 메모리 산수를 참고해 값을 결정합니다.
클라이언트 연동 — OpenAI SDK
"""client_demo.py — OpenAI SDK로 MLX 서버에 연결"""
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="not-needed", # MLX 서버는 인증 없음
)
# --- Non-streaming ---
response = client.chat.completions.create(
model="mlx-community/Qwen3-14B-4bit",
messages=[
{"role": "system", "content": "간결하고 정확하게 답변하세요."},
{"role": "user", "content": "Python의 GIL이 무엇인지 설명해주세요."},
],
max_tokens=512,
temperature=0.7,
)
print(response.choices[0].message.content)
# --- Streaming ---
stream = client.chat.completions.create(
model="mlx-community/Qwen3-14B-4bit",
messages=[
{"role": "user", "content": "FastAPI로 헬스체크 엔드포인트를 만들어주세요."},
],
max_tokens=1024,
temperature=0.7,
stream=True,
)
for chunk in stream:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
print()
여기서 핵심은 base_url만 바꾸면 기존 OpenAI SDK 코드가 그대로 동작한다는 점입니다. 6회차에서 구축할 통합 게이트웨이(LiteLLM 등)도 이 호환 엔드포인트를 통해 MLX 백엔드에 연결됩니다.
curl로 직접 요청
# Non-streaming
curl -s http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "mlx-community/Qwen3-14B-4bit",
"messages": [
{"role": "user", "content": "한국의 사계절을 한 문장씩 요약해주세요."}
],
"max_tokens": 256,
"temperature": 0.7
}' | python -m json.tool
# Streaming
curl -N http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "mlx-community/Qwen3-14B-4bit",
"messages": [
{"role": "user", "content": "asyncio의 동작 원리를 설명해주세요."}
],
"max_tokens": 512,
"stream": true
}'
동시성과 지연 — 벤치마크
측정 환경
| 항목 | 값 |
|---|---|
| 하드웨어 | Mac Studio M2 Ultra, 192GB 통합 메모리, 76코어 GPU |
| OS | macOS Sonoma 14.6 |
| Python | 3.11.9 |
| MLX | 0.22.x |
| mlx-lm | 0.22.x |
| 입력 길이 | 256 토큰 (고정 프롬프트) |
| 출력 길이 | 256 토큰 (max_tokens=256) |
| 온도 | 0.7 |
| 반복 횟수 | 20회 (첫 3회 웜업 제외, 17회 평균) |
벤치마크 스크립트
"""bench_mlx.py — MLX Qwen3 추론 벤치마크"""
import time
import statistics
from mlx_lm import load, generate
MODELS = [
"mlx-community/Qwen3-8B-4bit",
"mlx-community/Qwen3-14B-4bit",
"mlx-community/Qwen3-30B-A3B-4bit",
"mlx-community/Qwen3-32B-4bit",
]
PROMPT_TOKENS = 256
MAX_OUTPUT = 256
WARMUP = 3
RUNS = 20
TEMP = 0.7
# 고정 프롬프트 생성 (약 256 토큰)
SYSTEM = "You are a helpful assistant."
USER_MSG = (
"Explain the technical architecture of a modern web application, "
"covering the frontend framework, backend API design, database layer, "
"caching strategy, and deployment pipeline. Be specific about technology "
"choices and their trade-offs. Include considerations for scalability, "
"security, and maintainability. " * 3
)
def bench_model(model_id: str) -> dict:
print(f"\n{'='*60}")
print(f"Model: {model_id}")
print(f"{'='*60}")
model, tokenizer = load(model_id)
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": USER_MSG},
]
prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# 입력 토큰 수 확인
input_ids = tokenizer.encode(prompt)
actual_input = len(input_ids)
print(f"Input tokens: {actual_input}")
ttft_list: list[float] = []
tps_list: list[float] = []
total_list: list[float] = []
for i in range(WARMUP + RUNS):
t0 = time.perf_counter()
output = generate(
model,
tokenizer,
prompt=prompt,
max_tokens=MAX_OUTPUT,
temp=TEMP,
)
t_total = time.perf_counter() - t0
output_tokens = len(tokenizer.encode(output))
if i >= WARMUP:
tps = output_tokens / t_total
tps_list.append(tps)
total_list.append(t_total)
print(f" Run {i - WARMUP + 1:2d}: "
f"{output_tokens:3d} tokens, "
f"{t_total:.2f}s, "
f"{tps:.1f} tok/s")
result = {
"model": model_id,
"input_tokens": actual_input,
"avg_output_tokens": int(statistics.mean(
[MAX_OUTPUT] * len(tps_list)
)),
"avg_tps": round(statistics.mean(tps_list), 1),
"p50_tps": round(statistics.median(tps_list), 1),
"avg_total_s": round(statistics.mean(total_list), 2),
"p99_total_s": round(
sorted(total_list)[int(len(total_list) * 0.99)], 2
),
}
print(f"\n Average TPS: {result['avg_tps']}")
print(f" P50 TPS: {result['p50_tps']}")
print(f" Avg Total: {result['avg_total_s']}s")
del model, tokenizer # 다음 모델을 위해 메모리 해제
return result
if __name__ == "__main__":
results = []
for m in MODELS:
results.append(bench_model(m))
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")
print(f"{'Model':<35} {'TPS':>6} {'P50':>6} {'Avg(s)':>7} {'P99(s)':>7}")
print("-" * 65)
for r in results:
print(
f"{r['model']:<35} "
f"{r['avg_tps']:>6.1f} "
f"{r['p50_tps']:>6.1f} "
f"{r['avg_total_s']:>7.2f} "
f"{r['p99_total_s']:>7.2f}"
)
단일 요청 성능 결과
아래는 Mac Studio M2 Ultra(192GB, 76코어 GPU)에서 측정한 결과입니다. 입력 256토큰, 출력 256토큰, 17회 평균값입니다.

| 모델 | 양자화 | 메모리 사용량 |
프리필 (tok/s) |
디코딩 (tok/s) |
TTFT (ms) |
총 지연 (s) |
|---|---|---|---|---|---|---|
| Qwen3-8B | 4-bit | 5.8 GB | ~2,800 | 55.2 | ~90 | 4.7 |
| Qwen3-14B | 4-bit | 9.2 GB | ~1,600 | 35.4 | ~160 | 7.3 |
| Qwen3-30B-A3B | 4-bit | 18.5 GB | ~1,200 | 42.8 | ~210 | 6.1 |
| Qwen3-32B | 4-bit | 20.1 GB | ~780 | 22.3 | ~330 | 11.5 |
주목할 포인트 세 가지:
- Qwen3-30B-A3B의 디코딩이 14B보다 빠릅니다. MoE 구조의 이점입니다. 전체 가중치는 30B이지만 토큰 생성 시 활성화되는 파라미터가 3B에 불과하므로, 디코딩 단계에서 읽어야 할 메모리 양이 적습니다. 메모리 대역폭 바운드인 Apple Silicon에서 이 차이는 유의미합니다.
- 프리필(prefill)은 모델 크기에 비례해 느려집니다. 프리필은 입력 토큰 전체를 한 번에 처리하는 단계로, 연산 집약적(compute-bound)입니다. GPU 코어 수와 클럭이 고정이므로 모델이 클수록 오래 걸립니다.
- TTFT(Time to First Token)가 100~330ms 범위입니다. 대화형 서비스에서 사용자가 “응답이 시작됐다”고 느끼는 임계가 약 500ms이므로, 모든 변형에서 체감 반응성은 양호합니다.
동시 요청 처리 — MLX의 구조적 한계
여기서 MLX의 가장 큰 약점이 드러납니다. mlx-lm은 continuous batching(연속 배치)을 지원하지 않습니다. 동시에 들어온 요청은 직렬(serial)로 처리됩니다. 한 요청이 끝나야 다음 요청이 시작됩니다.
| 동시 요청 수 | 모델 | 평균 지연 (s) | P99 지연 (s) | 총 처리량 (tok/s) |
|---|---|---|---|---|
| 1 | Qwen3-14B Q4 | 7.3 | 7.8 | 35.4 |
| 2 | Qwen3-14B Q4 | 14.1 | 15.3 | 36.2 |
| 4 | Qwen3-14B Q4 | 28.6 | 30.8 | 35.8 |
| 8 | Qwen3-14B Q4 | 57.4 | 61.2 | 35.5 |
패턴이 명확합니다. 지연은 동시 요청 수에 비례해 선형 증가하고, 총 처리량(throughput)은 거의 변하지 않습니다. 이것은 요청이 큐에 쌓여 순서대로 처리되기 때문입니다.
이 한계가 의미하는 바:
- 1인 사용·소규모 팀(2~3명)에는 충분합니다. 대화형 서비스에서 사용자는 동시에 요청을 보내지 않으므로, 직렬 처리가 체감 성능에 거의 영향을 주지 않습니다.
- 10명 이상 동시 사용이 필요하면 CUDA + vLLM이 필수입니다. vLLM의 continuous batching은 동시 요청을 단일 배치로 묶어 GPU 활용률을 높이므로, 동시성에서 근본적으로 다른 아키텍처입니다. 이것이 내일(5회차) 주제입니다.
- 하이브리드 구성이 해법입니다. 6회차에서 다룰 통합 게이트웨이가 경량 요청은 Mac Studio(MLX)로, 무거운 동시 요청은 CUDA 노드(vLLM)로 분산합니다.
프로덕션 래퍼 서버 — FastAPI로 감싸기
mlx_lm.server는 빠른 시작에 좋지만, 프로덕션 운영에 필요한 기능이 부족합니다. 요청 큐잉, 구조화 로깅, 헬스체크, Thinking 모드 후처리, 그레이스풀 종료 등을 갖춘 래퍼 서버를 구축합니다.
아래 코드는 완전한 실행 가능 파일입니다. mlx_qwen3_server.py로 저장하고 바로 실행할 수 있습니다.
"""mlx_qwen3_server.py — Production MLX Qwen3 Serving Server
Usage:
python mlx_qwen3_server.py \
--model mlx-community/Qwen3-14B-4bit \
--port 8090 \
--max-kv-size 32768 \
--max-queue 16
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
import time
import uuid
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import AsyncIterator
import psutil
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
# ── MLX imports ──────────────────────────────────────────
import mlx.core as mx
from mlx_lm import load
from mlx_lm.utils import generate_step
# ── Logging ──────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='{"ts":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}',
datefmt="%Y-%m-%dT%H:%M:%S",
)
logger = logging.getLogger("mlx-qwen3")
# ── Request / Response Models ────────────────────────────
class ChatMessage(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str = ""
messages: list[ChatMessage]
max_tokens: int = Field(default=1024, ge=1, le=131072)
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
top_p: float = Field(default=0.9, ge=0.0, le=1.0)
stream: bool = False
strip_thinking: bool = Field(
default=False,
description="True이면 ... 블록을 응답에서 제거",
)
class ChatChoice(BaseModel):
index: int = 0
message: ChatMessage
finish_reason: str = "stop"
class Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class ChatResponse(BaseModel):
id: str
object: str = "chat.completion"
created: int
model: str
choices: list[ChatChoice]
usage: Usage
class HealthResponse(BaseModel):
status: str
model: str
queue_size: int
memory_used_gb: float
memory_total_gb: float
metal_available: bool
# ── Engine: MLX 모델 래퍼 ────────────────────────────────
@dataclass
class MLXEngine:
model_id: str
max_kv_size: int
model: object = field(default=None, init=False, repr=False)
tokenizer: object = field(default=None, init=False, repr=False)
_loaded: bool = field(default=False, init=False)
def load_model(self) -> None:
logger.info(f"Loading model: {self.model_id}")
t0 = time.perf_counter()
self.model, self.tokenizer = load(self.model_id)
elapsed = time.perf_counter() - t0
logger.info(f"Model loaded in {elapsed:.1f}s")
self._loaded = True
@property
def is_loaded(self) -> bool:
return self._loaded
def generate_sync(
self,
messages: list[dict],
max_tokens: int,
temperature: float,
top_p: float,
) -> tuple[str, int, int]:
"""동기 생성. (output_text, prompt_tokens, completion_tokens) 반환."""
prompt = self.tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
input_ids = mx.array(self.tokenizer.encode(prompt))
prompt_len = input_ids.shape[0]
tokens: list[int] = []
for token, _ in generate_step(
prompt=input_ids,
model=self.model,
temp=temperature,
top_p=top_p,
max_kv_size=self.max_kv_size,
):
token_int = token.item()
if token_int == self.tokenizer.eos_token_id:
break
tokens.append(token_int)
if len(tokens) >= max_tokens:
break
output = self.tokenizer.decode(tokens)
return output, prompt_len, len(tokens)
def generate_stream_sync(
self,
messages: list[dict],
max_tokens: int,
temperature: float,
top_p: float,
) -> tuple[AsyncIterator[str], None, None]:
"""토큰 단위 이터레이터 반환."""
prompt = self.tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
input_ids = mx.array(self.tokenizer.encode(prompt))
count = 0
for token, _ in generate_step(
prompt=input_ids,
model=self.model,
temp=temperature,
top_p=top_p,
max_kv_size=self.max_kv_size,
):
token_int = token.item()
if token_int == self.tokenizer.eos_token_id:
break
piece = self.tokenizer.decode([token_int])
yield piece
count += 1
if count >= max_tokens:
break
# ── Request Queue ────────────────────────────────────────
class RequestQueue:
"""FIFO 큐 — MLX의 직렬 처리 특성에 맞춤."""
def __init__(self, max_size: int = 16) -> None:
self._sem = asyncio.Semaphore(1) # 동시 1개만 처리
self._queue_count = 0
self._max_size = max_size
@property
def size(self) -> int:
return self._queue_count
async def acquire(self) -> None:
if self._queue_count >= self._max_size:
raise HTTPException(
status_code=429,
detail=f"Queue full ({self._max_size}). Retry later.",
)
self._queue_count += 1
await self._sem.acquire()
def release(self) -> None:
self._queue_count -= 1
self._sem.release()
# ── Thinking 모드 후처리 ─────────────────────────────────
def strip_thinking_blocks(text: str) -> str:
"""... 블록을 제거하고 앞뒤 공백을 정리."""
import re
return re.sub(
r".*? \s*", "", text, flags=re.DOTALL
).strip()
# ── FastAPI App ──────────────────────────────────────────
engine: MLXEngine | None = None
queue: RequestQueue | None = None
def create_app(args: argparse.Namespace) -> FastAPI:
global engine, queue
engine = MLXEngine(model_id=args.model, max_kv_size=args.max_kv_size)
queue = RequestQueue(max_size=args.max_queue)
@asynccontextmanager
async def lifespan(app: FastAPI):
engine.load_model()
logger.info(
f"Server ready — model={args.model}, "
f"port={args.port}, max_kv={args.max_kv_size}"
)
yield
logger.info("Shutting down")
app = FastAPI(
title="MLX Qwen3 Server",
version="1.0.0",
lifespan=lifespan,
)
# ── Health ──
@app.get("/healthz", response_model=HealthResponse)
async def healthz():
mem = psutil.virtual_memory()
return HealthResponse(
status="ok" if engine.is_loaded else "loading",
model=engine.model_id,
queue_size=queue.size,
memory_used_gb=round(mem.used / (1024**3), 1),
memory_total_gb=round(mem.total / (1024**3), 1),
metal_available=mx.metal.is_available(),
)
# ── Chat Completions ──
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest):
if not engine.is_loaded:
raise HTTPException(503, "Model not loaded yet")
request_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
messages = [m.model_dump() for m in req.messages]
logger.info(
f"req={request_id} stream={req.stream} "
f"msgs={len(messages)} max_tokens={req.max_tokens}"
)
if req.stream:
return StreamingResponse(
_stream_response(request_id, req, messages),
media_type="text/event-stream",
)
# Non-streaming
await queue.acquire()
try:
t0 = time.perf_counter()
loop = asyncio.get_event_loop()
output, p_tok, c_tok = await loop.run_in_executor(
None,
engine.generate_sync,
messages,
req.max_tokens,
req.temperature,
req.top_p,
)
elapsed = time.perf_counter() - t0
finally:
queue.release()
if req.strip_thinking:
output = strip_thinking_blocks(output)
tps = c_tok / elapsed if elapsed > 0 else 0
logger.info(
f"req={request_id} done — "
f"{c_tok} tokens, {elapsed:.2f}s, {tps:.1f} tok/s"
)
return ChatResponse(
id=request_id,
created=int(time.time()),
model=engine.model_id,
choices=[
ChatChoice(
message=ChatMessage(role="assistant", content=output)
)
],
usage=Usage(
prompt_tokens=p_tok,
completion_tokens=c_tok,
total_tokens=p_tok + c_tok,
),
)
async def _stream_response(
request_id: str,
req: ChatRequest,
messages: list[dict],
) -> AsyncIterator[str]:
await queue.acquire()
try:
loop = asyncio.get_event_loop()
gen = engine.generate_stream_sync(
messages, req.max_tokens, req.temperature, req.top_p
)
# run_in_executor 안에서 동기 제너레이터를 소비
q: asyncio.Queue[str | None] = asyncio.Queue()
def _produce():
for piece in gen:
q.put_nowait(piece)
q.put_nowait(None) # sentinel
await loop.run_in_executor(None, _produce)
while True:
piece = await q.get()
if piece is None:
break
chunk = {
"id": request_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": engine.model_id,
"choices": [
{
"index": 0,
"delta": {"content": piece},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
# 종료 청크
done_chunk = {
"id": request_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": engine.model_id,
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(done_chunk)}\n\n"
yield "data: [DONE]\n\n"
finally:
queue.release()
return app
# ── CLI ──────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="MLX Qwen3 Production Server")
p.add_argument(
"--model",
default="mlx-community/Qwen3-14B-4bit",
help="Hugging Face 모델 ID",
)
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8090)
p.add_argument("--max-kv-size", type=int, default=32768)
p.add_argument("--max-queue", type=int, default=16)
return p.parse_args()
if __name__ == "__main__":
args = parse_args()
app = create_app(args)
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
래퍼 서버의 핵심 설계 결정
위 코드에서 의도적으로 내린 설계 결정을 설명합니다.
1. Semaphore(1)로 동시성 제어
MLX가 직렬 처리만 지원하므로, asyncio.Semaphore(1)로 동시 추론을 1건으로 제한합니다. 나머지 요청은 FIFO 큐에 대기합니다. 큐 크기(--max-queue)를 초과하면 429(Too Many Requests)를 즉시 반환합니다. 클라이언트가 무한정 대기하는 것보다 빠른 실패가 낫습니다.
2. run_in_executor로 이벤트 루프 보호
MLX 추론은 CPU 바운드(Metal 커널 디스패치 + 결과 대기)이므로, asyncio.get_event_loop().run_in_executor()로 별도 스레드에서 실행합니다. 이렇게 하면 추론 중에도 /healthz 등 다른 엔드포인트가 응답할 수 있습니다.
3. strip_thinking 옵션
Thinking 모드(/think)에서 생성된 <think>...</think> 블록을 클라이언트에 보여줄지 숨길지 선택할 수 있습니다. 기본값은 false(블록 포함)이고, strip_thinking=true로 보내면 최종 답변만 반환합니다. 이것은 UI에 사고 과정을 보여줄지의 제품 결정에 따라 달라집니다.
4. 구조화 로깅
로그를 JSON 형식으로 출력합니다. 요청 ID, 토큰 수, 처리 시간, TPS를 기록해 운영 중 성능 추이를 추적할 수 있습니다. 13회차에서 다룰 관측성(observability) 스택과 연결됩니다.
실행 및 검증
# 서버 시작
python mlx_qwen3_server.py \
--model mlx-community/Qwen3-14B-4bit \
--port 8090 \
--max-kv-size 32768 \
--max-queue 16
# 헬스체크
curl -s http://localhost:8090/healthz | python -m json.tool
# {
# "status": "ok",
# "model": "mlx-community/Qwen3-14B-4bit",
# "queue_size": 0,
# "memory_used_gb": 14.2,
# "memory_total_gb": 192.0,
# "metal_available": true
# }
# Non-streaming 요청
curl -s http://localhost:8090/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": "Python 데코레이터를 설명해주세요."}
],
"max_tokens": 256,
"strip_thinking": true
}' | python -m json.tool
# Streaming 요청
curl -N http://localhost:8090/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": "Docker Compose의 장점을 3가지 알려주세요."}
],
"max_tokens": 512,
"stream": true
}'
통합 게이트웨이 연결 준비
지금 구축한 MLX 서버는 독립적으로도 쓸 수 있지만, 6회차에서 만들 통합 OpenAI 호환 게이트웨이의 백엔드 중 하나가 됩니다. 게이트웨이(LiteLLM 등)는 여러 백엔드(MLX, vLLM, 외부 API)를 하나의 엔드포인트로 통합하고, 요청 유형에 따라 적절한 백엔드로 라우팅합니다.
게이트웨이 연결을 위해 지금 확인해야 할 것:
- 엔드포인트 경로 —
/v1/chat/completions가 OpenAI 사양을 따르는지. 위 래퍼 서버는 이를 준수합니다. - 모델 이름 — 게이트웨이에서
model필드로 백엔드를 식별합니다.mlx-community/Qwen3-14B-4bit같은 전체 경로 또는 단축 별칭을 씁니다. - 스트리밍 호환 — SSE(Server-Sent Events) 형식의 스트리밍이
data: {...}\n\n+data: [DONE]\n\n패턴을 따르는지. - 네트워크 접근 — Mac Studio의 MLX 서버가 게이트웨이 호스트에서 도달 가능한 IP에 바인드되어 있는지.
--host 0.0.0.0또는 LAN IP 지정.
# litellm_config.yaml — 6회차 미리보기 (게이트웨이 설정 일부)
model_list:
- model_name: "qwen3-14b"
litellm_params:
model: "openai/mlx-community/Qwen3-14B-4bit"
api_base: "http://mac-studio.local:8090/v1"
api_key: "not-needed"
model_info:
description: "Qwen3-14B on Mac Studio MLX"
max_tokens: 32768
- model_name: "qwen3-8b-router"
litellm_params:
model: "openai/mlx-community/Qwen3-8B-4bit"
api_base: "http://mac-studio.local:8091/v1"
api_key: "not-needed"
model_info:
description: "Qwen3-8B for routing/classification"
max_tokens: 8192
이 구성은 6회차에서 완성합니다. 지금은 “MLX 서버가 OpenAI 호환 엔드포인트를 제공하므로, 게이트웨이 연결이 자연스럽다”는 점만 확인해두세요.
systemd 대신 launchd — macOS 상시 구동
Mac Studio를 서빙 노드로 상시 운용하려면, 재부팅 후에도 MLX 서버가 자동 시작되어야 합니다. macOS에서는 launchd를 사용합니다.
<!-- ~/Library/LaunchAgents/com.mlx.qwen3.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.mlx.qwen3</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/mlx-qwen3/.venv/bin/python</string>
<string>/Users/you/mlx-qwen3/mlx_qwen3_server.py</string>
<string>--model</string>
<string>mlx-community/Qwen3-14B-4bit</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
<string>8090</string>
<string>--max-kv-size</string>
<string>32768</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/Users/you/mlx-qwen3/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/mlx-qwen3/logs/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
</dict>
</plist>
# 로그 디렉토리 생성
mkdir -p ~/mlx-qwen3/logs
# plist 등록 및 시작
launchctl load ~/Library/LaunchAgents/com.mlx.qwen3.plist
launchctl start com.mlx.qwen3
# 상태 확인
launchctl list | grep mlx
# 중지
launchctl stop com.mlx.qwen3
# 등록 해제
launchctl unload ~/Library/LaunchAgents/com.mlx.qwen3.plist
KeepAlive → SuccessfulExit = false는 비정상 종료(크래시) 시 자동 재시작을 의미합니다. 정상 종료(exit 0) 시에는 재시작하지 않으므로, 유지보수를 위한 의도적 종료가 가능합니다.
메모리 모니터링 — 서빙 중 실시간 확인
MLX가 통합 메모리를 얼마나 쓰고 있는지 실시간으로 확인하는 방법입니다.
# 방법 1: powermetrics (Metal GPU 사용률 포함, sudo 필요)
sudo powermetrics --samplers gpu_power -i 1000
# 방법 2: Python에서 프로그래밍적으로 확인
python -c "
import mlx.core as mx
import psutil
# MLX Metal 메모리
peak = mx.metal.get_peak_memory() / (1024**3)
active = mx.metal.get_active_memory() / (1024**3)
cache = mx.metal.get_cache_memory() / (1024**3)
# 시스템 메모리
vm = psutil.virtual_memory()
print(f'Metal peak memory : {peak:.1f} GB')
print(f'Metal active memory : {active:.1f} GB')
print(f'Metal cache memory : {cache:.1f} GB')
print(f'System used : {vm.used / (1024**3):.1f} GB')
print(f'System available : {vm.available / (1024**3):.1f} GB')
print(f'System total : {vm.total / (1024**3):.1f} GB')
print(f'Swap used : {psutil.swap_memory().used / (1024**3):.1f} GB')
"
중요한 것은 swap 사용량입니다. macOS는 메모리가 부족하면 조용히 SSD 스왑을 시작합니다. 스왑이 발생하면 추론 속도가 10배 이상 느려지는데, 시스템이 에러를 뱉지 않으므로 “갑자기 느려졌다”는 증상으로만 나타납니다. 운영 중 psutil.swap_memory().used가 0이 아닌 값을 보이면 즉시 조치가 필요합니다.
운영 함정 (Pitfall) — 통합 메모리의 조용한 살인자, 메모리 압박
증상: Qwen3-32B Q4를 Mac Studio M2 Ultra(192GB)에 올려 잘 쓰다가, 긴 대화(30K+ 토큰)를 몇 차례 하면 디코딩 속도가 22 tok/s에서 2~3 tok/s로 급락합니다. Activity Monitor를 보면 “Memory Used”가 190GB를 넘고, “Swap Used”가 수 GB 발생합니다.
원인: KV 캐시가 대화 길이에 비례해 누적되면서 통합 메모리를 잠식합니다. macOS는 OOM으로 프로세스를 죽이기 전에 SSD 스왑을 시도합니다. 스왑 발생 시 Metal GPU가 메모리에서 텐서를 읽는 대역폭이 SSD 대역폭(약 7GB/s)으로 떨어지므로, 800GB/s에 맞춰진 추론 파이프라인이 100배 넘게 느려집니다.
대응:
- –max-kv-size를 반드시 설정합니다. 가용 메모리에서 모델 가중치와 OS 예약을 뺀 나머지의 80%를 KV 캐시 예산으로 잡고, 토큰당 KV 크기로 나눠 최대 토큰 수를 계산합니다. 안전 마진 20%는 macOS의 예측 불가능한 메모리 사용(Spotlight 인덱싱, iCloud 동기화 등)을 위한 것입니다.
- 모니터링 스크립트를 크론에 걸어둡니다. 스왑 사용량이 1GB를 넘으면 알림을 보내도록 설정하세요. 프로세스를 재시작해 KV 캐시를 초기화하는 것이 가장 빠른 복구 방법입니다.
- Qwen3-32B Q4(~22GB) 대신 Qwen3-30B-A3B Q4(~20GB)를 고려합니다. 메모리 사용량은 비슷하지만, MoE 구조 덕분에 디코딩이 더 빠르고, KV 캐시 예산 계산도 유사합니다. 품질 차이는 용도에 따라 다르니 직접 비교해보세요.
# 안전한 max-kv-size 계산 예시 (Qwen3-14B Q4 on 192GB)
#
# 가용 = 192 - 10(OS) - 10(모델) - 2(런타임) = 170 GB
# 안전 예산(80%) = 136 GB
# 토큰당 KV = 160 KB
# max-kv-size = 136 GB / 160 KB ≈ 891,289 토큰
#
# → 128K 컨텍스트를 넉넉하게 지원. --max-kv-size 131072로 설정.
python mlx_qwen3_server.py \
--model mlx-community/Qwen3-14B-4bit \
--max-kv-size 131072 # 128K 토큰 상한
오늘의 결과물 정리
4일차에서 구축한 것을 정리합니다.
| 산출물 | 파일 / 명령 | 역할 |
|---|---|---|
| 환경 셋업 | setup_mlx.sh |
Python 가상환경 + mlx-lm + 부가 패키지 |
| 벤치마크 | bench_mlx.py |
모델별 TPS·지연 측정 |
| 프로덕션 서버 | mlx_qwen3_server.py |
큐잉·헬스체크·로깅·Thinking 후처리 |
| 자동 시작 | com.mlx.qwen3.plist |
macOS launchd 상시 구동 |
이 네 파일만 있으면 Mac Studio에서 Qwen3를 프로덕션 수준으로 서빙할 수 있습니다. 코드는 모두 이 글에 완전한 형태로 포함되어 있으므로, 복사해서 바로 실행해보세요.
내일 예고
5일차 — CUDA vLLM으로 Qwen3 서빙: 오늘 MLX의 구조적 한계로 지적한 “동시성”을 CUDA + vLLM이 어떻게 해결하는지 다룹니다. PagedAttention, continuous batching, FP8 양자화, 텐서 병렬(듀얼 GPU NVLink), KV 캐시 튜닝, 그리고 실제 운영에서 겪는 GPU 장애(Xid 에러, 콜드 리부트) 안정화 패턴까지. MLX와 vLLM 양쪽을 갖추면 6일차의 하이브리드 게이트웨이가 완성됩니다.
◀ 이전 3화 (다음 차수는 아직 게시되지 않았습니다)
참고 자료
- MLX GitHub 공식 저장소 — Apple이 개발한 Apple Silicon 전용 머신러닝 프레임워크의 소스 코드와 문서
- Apple Mac Studio 기술 사양 (공식) — Mac Studio 모델별 통합 메모리·칩 구성 등 하드웨어 상세 스펙
[…] 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 5화)◀ 이전 4화 (다음 차수는 아직 게시되지 않았습니다) […]