[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 5/14화: vLLM으로 Qwen3 서빙 — FP8·텐서 병렬 실전 구축
이 글은 「온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계」 시리즈 5일차로, Qwen3 모델을 vLLM 엔진으로 서빙하기 위한 FP8 양자화와 텐서 병렬 구성을 실전 중심으로 다룹니다.
어제(4일차)는 Mac Studio 위에서 mlx-lm으로 Qwen3를 띄우고, 통합 메모리의 강점과 동시성 한계를 실측했습니다. 오늘은 서빙 트랙의 나머지 한 축 — NVIDIA CUDA + vLLM으로 옮겨옵니다. 같은 모델(Qwen3-30B-A3B)을 듀얼 RTX 4090에 올려 놓고 수십 명이 동시에 질의해도 안정적으로 응답하는 고처리량 추론 서버를 구축하겠습니다.
오늘의 핵심 3가지
- PagedAttention + Continuous Batching — vLLM이 GPU 메모리를 OS 가상 메모리처럼 페이지 단위로 관리하고, 요청을 동적으로 배칭해서 처리량을 끌어올리는 구조를 이해합니다.
- FP8 양자화 + 텐서 병렬 — Qwen3-30B-A3B를 FP8로 압축하고, 두 장의 GPU에 텐서를 쪼개 올리는 실전 설정을 다룹니다.
- 운영 안정화 패턴 — Xid 에러, OOM, NCCL 타임아웃, 콜드 리부트 등 실 운영에서 마주치는 장애 시나리오와 복원 전략을 정리합니다.
vLLM — 왜 Qwen3 서빙에 적합한가
vLLM은 UC Berkeley에서 시작된 오픈소스 LLM 추론 엔진입니다. “왜 굳이 vLLM인가?”라는 질문에 Qwen3 관점에서 답하면 다음 네 가지가 핵심입니다.
- Qwen3 전 라인업 공식 지원 — Dense(0.6B~32B), MoE(30B-A3B, 235B-A22B), Qwen3-VL까지 vLLM의
supported_models목록에 포함되어 있습니다. HuggingFace Hub에서 모델 ID만 지정하면 아키텍처 감지·가중치 로딩이 자동으로 이루어집니다. - PagedAttention — KV 캐시를 고정 크기 블록으로 나누어 관리합니다. 기존 프레임워크가 시퀀스별로 최대 길이만큼 VRAM을 예약(pre-allocate)하는 것과 달리, 실제로 생성된 토큰 수만큼만 블록을 할당합니다. VRAM 사용 효율이 크게 높아져 동시 요청 수용 능력이 올라갑니다.
- Continuous Batching(연속 배치) — 요청이 끝나기를 기다리지 않고, 디코드 스텝마다 완료된 요청을 빼내고 대기 중인 요청을 채워 넣습니다. GPU 유휴 시간이 최소화됩니다.
- OpenAI 호환 API —
/v1/chat/completions,/v1/completions,/v1/models엔드포인트를 기본 제공합니다. 6일차에서 다룰 하이브리드 게이트웨이(LiteLLM 등)가 MLX 백엔드와 vLLM 백엔드를 동일 인터페이스로 묶을 수 있는 기반이 여기 있습니다.
3일차에서 정리한 하드웨어 분기를 떠올려 봅시다. Mac Studio(MLX)는 통합 메모리로 큰 모델을 올리기 좋지만 동시 요청 처리량에 한계가 있었습니다. CUDA + vLLM 트랙은 정반대 — VRAM 용량은 GPU당 24GB(RTX 4090 기준)로 제한되지만, HBM3/GDDR6X의 높은 메모리 대역폭과 연속 배치로 다수의 동시 요청을 높은 처리량으로 소화하는 데 강점을 갖습니다.
PagedAttention — GPU 메모리의 가상 메모리화
LLM 추론에서 가장 큰 메모리 병목은 KV 캐시입니다. Transformer의 어텐션 계산은 이전 토큰들의 Key와 Value 텐서를 재사용해야 하므로, 생성이 진행될수록 KV 캐시가 누적됩니다. Qwen3-30B-A3B의 경우 — 40개 레이어 × 40개 KV 헤드 × 128 헤드 차원 × FP16(2바이트) × Key+Value(×2) — 토큰 하나당 약 0.8MB의 KV 캐시가 필요합니다. 컨텍스트 32,768 토큰을 꽉 채우면 시퀀스 하나에 약 25GB가 소모되는 셈입니다.
전통적 추론 프레임워크는 시퀀스마다 max_seq_len 크기의 연속 메모리를 할당합니다. 실제로는 128토큰만 생성하더라도 32,768토큰분의 메모리가 잡혀 있으니, 나머지 99.6%는 낭비입니다. 동시에 처리할 수 있는 시퀀스 수가 격감합니다.
페이지 테이블 기반 블록 관리
PagedAttention은 OS의 가상 메모리 시스템에서 영감을 받았습니다. KV 캐시를 고정 크기 블록(기본 16토큰 분량)으로 나누고, 시퀀스마다 논리 블록 테이블을 유지합니다. 새 토큰이 생성되면 현재 블록에 추가하고, 블록이 가득 차면 물리 블록을 하나 더 할당합니다. 시퀀스가 끝나면 해당 물리 블록을 즉시 풀(pool)에 반환합니다.
이 구조 덕분에 세 가지 이점이 생깁니다.
- 내부 단편화 제거 — 블록 단위 할당이므로 최대
block_size - 1토큰의 낭비만 발생합니다. 32,768토큰 전체를 예약할 필요가 없습니다. - 외부 단편화 제거 — 블록이 물리적으로 연속일 필요가 없으므로, VRAM 조각 사이사이를 활용할 수 있습니다.
- Copy-on-Write 공유 — 동일 프롬프트(시스템 프롬프트 등)를 여러 요청이 공유할 때, 물리 블록을 복사하지 않고 논리 블록 테이블만 같은 물리 블록을 가리키게 합니다. vLLM의
--enable-prefix-caching옵션이 이 메커니즘을 활성화합니다.

실측 메모리 효율
아래는 Qwen3-14B FP8 모델을 RTX 4090(24GB)에서 서빙할 때, PagedAttention 유무에 따른 동시 시퀀스 수용 능력 비교입니다. 모델 가중치 약 14GB를 뺀 잔여 VRAM 약 10GB를 KV 캐시로 사용한다고 가정합니다.
| 할당 방식 | 시퀀스당 예약 VRAM | 최대 동시 시퀀스 (VRAM 10GB 기준) |
|---|---|---|
| 고정 예약 (max_seq_len=8192) | ~3.2GB | 3 |
| 고정 예약 (max_seq_len=32768) | ~12.8GB | 0 (올릴 수 없음) |
| PagedAttention (평균 생성 512토큰) | ~0.2GB (실사용분) | ~50 |
평균 생성 길이가 512토큰인 현실적 시나리오에서, PagedAttention은 동시 시퀀스를 10배 이상 수용합니다. 이것이 vLLM의 높은 처리량의 근본 원인입니다. (측정 환경: Qwen3-14B-FP8, RTX 4090 24GB, block_size=16, vLLM 0.8.x)
Continuous Batching — 처리량의 핵심
PagedAttention이 메모리 효율을 담당한다면, Continuous Batching(연속 배치)은 GPU 연산 효율을 담당합니다.
정적 배치의 문제
전통적 배치 추론은 N개의 요청을 모아서 한 번에 처리합니다. 문제는 시퀀스마다 생성 길이가 다르다는 것입니다. 어떤 요청은 50토큰만 생성하고 끝나는데, 같은 배치의 다른 요청은 500토큰을 생성합니다. 50토큰 요청이 끝나도 배치 전체가 끝날 때까지 해당 슬롯은 비어 있습니다 — GPU가 놀고 있는 시간입니다.
반복 수준(iteration-level) 스케줄링
vLLM의 스케줄러는 매 디코드 스텝(= Transformer forward pass 1회)마다 스케줄링을 수행합니다.
- 디코드 스텝이 끝나면,
eos_token을 생성했거나max_tokens에 도달한 시퀀스를 배치에서 즉시 제거합니다. - 대기 큐에 새 요청이 있으면, 빈 슬롯에 즉시 삽입합니다.
- 새로 삽입된 요청은 프리필(prefill) 단계를 거친 뒤 다음 스텝부터 디코드에 합류합니다.
결과적으로 GPU는 항상 가능한 최대 배치 크기로 연산을 수행합니다. 동시 요청이 16개 들어와도 짧은 요청이 먼저 끝나면 대기 중인 17번째 요청이 바로 투입됩니다.
Chunked Prefill — 긴 프롬프트의 지연 방지
프리필 단계에서 프롬프트가 8,000토큰이라면, 한 번의 forward pass로 8,000토큰을 모두 처리해야 합니다. 이 동안 이미 디코딩 중인 다른 시퀀스들은 대기해야 합니다 — 이른바 프리필 스톨(prefill stall)입니다.
vLLM의 --enable-chunked-prefill 옵션은 긴 프리필을 여러 청크(기본 512토큰)로 쪼개서, 디코드 스텝 사이사이에 끼워 넣습니다. 기존 시퀀스의 디코드 지연이 크게 완화됩니다. 프리필 전체 소요 시간은 약간 늘지만, P99 지연(tail latency)이 개선되어 다중 사용자 시나리오에서 체감 응답성이 좋아집니다.
# Chunked Prefill 활성화 (프로덕션 권장)
vllm serve Qwen/Qwen3-30B-A3B-FP8 \
--enable-chunked-prefill \
--max-num-batched-tokens 4096
--max-num-batched-tokens는 한 스텝에서 처리할 최대 토큰 수입니다. 프리필 청크와 디코드 토큰의 합이 이 값을 넘지 않도록 스케줄러가 조율합니다. GPU VRAM과 원하는 P99 지연에 따라 2048~8192 범위에서 조정합니다.
FP8 양자화 — Qwen3 모델을 절반 메모리에 서빙하기
3일차에서 양자화 포맷 비교표를 살펴봤습니다. vLLM 환경에서 Qwen3에 가장 실용적인 선택지는 FP8(E4M3)입니다.
왜 FP8인가
- 메모리 절반 — FP16 대비 파라미터당 1바이트. Qwen3-30B-A3B의 경우 FP16 약 60GB → FP8 약 30GB. 듀얼 RTX 4090(총 48GB)에 모델을 올리고도 KV 캐시 여유를 확보할 수 있습니다.
- 하드웨어 네이티브 — Ada Lovelace(RTX 4090) 이상, Hopper(H100) GPU에서 FP8 텐서 코어를 지원합니다. INT4(AWQ/GPTQ)가 별도의 디양자화(dequantize) 연산을 삽입하는 것과 달리, FP8은 텐서 코어에서 직접 연산되므로 오버헤드가 최소화됩니다.
- 품질 손실 최소 — FP8 E4M3는 4비트 지수 + 3비트 가수로, FP16 대비 정밀도 손실이 INT4/INT8보다 작습니다. Qwen3 공식 FP8 모델은 캘리브레이션(calibration) 데이터셋으로 스케일 팩터를 최적화한 상태로 배포되어, 벤치마크 성능이 FP16 대비 1~2% 이내 차이를 보입니다.
사전 양자화 모델 vs 온라인 양자화
vLLM에서 FP8을 사용하는 방법은 두 가지입니다.
| 방식 | 사용법 | 장점 | 단점 |
|---|---|---|---|
| 사전 양자화 모델 | --model Qwen/Qwen3-30B-A3B-FP8 |
빠른 로딩, 캘리브레이션 최적화, 재현 가능 | 특정 모델에만 제공됨 |
| 온라인 양자화 | --model Qwen/Qwen3-30B-A3B --quantization fp8 |
어떤 FP16 모델이든 적용 가능 | 로딩 시 양자화 수행(시간 추가), 캘리브레이션 없음 |
프로덕션에서는 사전 양자화 모델을 권장합니다. Qwen 팀이 HuggingFace Hub에 Qwen3-30B-A3B-FP8, Qwen3-14B-FP8 등을 공식 배포하고 있습니다. 캘리브레이션이 적용된 스케일 팩터가 모델 파일에 포함되어 있어, 품질이 안정적이고 로딩 시간도 단축됩니다.
AWQ(INT4)는 언제 선택하는가
GPU가 한 장뿐이거나 VRAM이 극도로 부족한 환경에서는 AWQ(INT4, 파라미터당 0.5바이트)를 고려합니다. Qwen3-30B-A3B AWQ는 약 15GB로, 단일 RTX 4090에 탑재 가능합니다. 다만 INT4 디양자화 오버헤드로 디코드 속도가 FP8 대비 10~20% 느리고, 품질 손실도 더 큽니다. FP8을 기본으로 잡되, “RTX 4090 한 장에 30B MoE를 반드시 올려야 한다”는 제약이 있을 때 AWQ로 전환하는 것이 현실적 전략입니다.
텐서 병렬 — 듀얼 GPU로 대형 모델 서빙
Qwen3-30B-A3B FP8의 가중치는 약 30GB입니다. RTX 4090(24GB) 한 장에는 들어가지 않습니다. 모델 가중치만 올려도 VRAM이 가득 차서 KV 캐시를 위한 공간이 없기 때문입니다. 여기서 텐서 병렬(Tensor Parallelism, TP)이 등장합니다.
텐서 병렬의 작동 원리
TP는 모델의 각 레이어 내부의 가중치 행렬을 GPU 수만큼 쪼개서(shard), 각 GPU가 한 조각씩 담당하게 합니다. 예를 들어 TP=2이면:
- 어텐션 레이어의 Q/K/V 투영 행렬을 열(column) 방향으로 절반씩 나눕니다.
- FFN(MoE의 Expert 포함)의 행렬도 마찬가지로 분할합니다.
- 각 GPU가 자기 샤드에 대해 행렬 곱을 수행한 뒤, All-Reduce 연산으로 결과를 합칩니다.
All-Reduce는 NVIDIA의 NCCL(NVIDIA Collective Communications Library) 라이브러리가 담당합니다. 두 GPU가 NVLink로 연결되어 있으면 ~600GB/s(NVLink 4.0 양방향)의 대역폭으로 통신하므로 오버헤드가 매우 낮습니다. PCIe 4.0 ×16(~32GB/s 단방향)만 사용 가능한 환경에서는 All-Reduce가 병목이 될 수 있습니다.

듀얼 RTX 4090 구성
온프레미스 워크스테이션에서 가장 현실적인 듀얼 GPU 구성은 RTX 4090 × 2입니다. NVLink를 지원하지 않으므로 PCIe 연결을 사용하게 되는데, 실측에서 All-Reduce 오버헤드는 디코드 기준 약 10~15%입니다. 프리필은 연산 바운드(compute-bound)라 통신 오버헤드가 상대적으로 작고, 디코드는 메모리 대역폭 바운드라 통신이 좀 더 눈에 띕니다.
# 듀얼 RTX 4090에서 Qwen3-30B-A3B FP8 서빙
vllm serve Qwen/Qwen3-30B-A3B-FP8 \
--tensor-parallel-size 2 \
--max-model-len 32768 \
--gpu-memory-utilization 0.90 \
--port 8000
--tensor-parallel-size 2를 지정하면, vLLM이 자동으로 가중치를 2등분하여 GPU 0과 GPU 1에 분배합니다. 별도의 가중치 변환이 필요 없습니다.
TP 크기 결정 가이드
| 모델 | 양자화 | 가중치 크기(추정) | 권장 TP | GPU 구성 예시 |
|---|---|---|---|---|
| Qwen3-8B | FP8 | ~8GB | 1 | RTX 4090 × 1 |
| Qwen3-14B | FP8 | ~14GB | 1 | RTX 4090 × 1 |
| Qwen3-14B | FP16 | ~28GB | 2 | RTX 4090 × 2 |
| Qwen3-30B-A3B | FP8 | ~30GB | 2 | RTX 4090 × 2 |
| Qwen3-32B | FP8 | ~32GB | 2 | RTX 4090 × 2 |
| Qwen3-32B | FP16 | ~64GB | 4 | A100 40GB × 2 또는 H100 × 1 |
| Qwen3-235B-A22B | FP8 | ~235GB | 8 | H100 80GB × 4 |
핵심 원칙: 모델 가중치 + 예상 KV 캐시가 총 VRAM의 90% 이내에 들어가도록 TP 크기를 정합니다. 나머지 10%는 활성화 텐서(activation), CUDA 커널 임시 버퍼, NCCL 통신 버퍼 등에 사용됩니다.
MoE 모델의 TP 특성
Qwen3-30B-A3B는 MoE(Mixture of Experts) 아키텍처입니다. 총 파라미터는 30B이지만 토큰당 활성화되는 Expert는 소수(약 3B 파라미터분)입니다. 그러나 모든 Expert의 가중치가 VRAM에 상주해야 합니다 — 어떤 토큰이 어느 Expert를 활성화할지 미리 알 수 없기 때문입니다. 따라서 메모리 관점에서는 30B Dense 모델과 동일합니다.
반면 연산량은 활성 파라미터(~3B)에 비례하므로, 디코드 속도가 유사 크기 Dense 모델(예: Qwen3-32B)보다 훨씬 빠릅니다. 이것이 MoE의 핵심 장점 — “큰 모델의 품질, 작은 모델의 속도”입니다.
KV 캐시 튜닝 — 동시성과 컨텍스트의 트레이드오프
vLLM에서 가장 중요한 튜닝 파라미터는 KV 캐시에 얼마나 많은 VRAM을 할당할 것인가입니다. 이 값이 동시 처리 가능한 시퀀스 수와 최대 컨텍스트 길이를 결정합니다.
gpu-memory-utilization
--gpu-memory-utilization(기본 0.90)은 vLLM이 사용할 수 있는 총 GPU 메모리 비율입니다. 모델 가중치를 로딩한 뒤 남는 VRAM이 KV 캐시 풀(pool)로 사용됩니다.
# GPU별 KV 캐시 계산 예시 (Qwen3-30B-A3B FP8, TP=2, RTX 4090 × 2)
#
# GPU당 총 VRAM: 24 GB
# gpu-memory-utilization(0.90): 24 × 0.90 = 21.6 GB 사용 가능
# GPU당 모델 가중치 (30GB / 2): 15 GB
# GPU당 KV 캐시 풀: 21.6 - 15 = 6.6 GB
#
# Qwen3-30B-A3B KV 캐시 크기:
# 40 layers × 40 KV heads × 128 dim × 2(K+V) × FP16(2B) = 819,200 B/token ≈ 0.8 MB/token
# TP=2이면 KV heads도 절반: GPU당 ~0.4 MB/token
#
# GPU당 6.6GB → 6,600 / 0.4 ≈ 16,500 토큰분
# 평균 시퀀스 1,024토큰(프롬프트+생성) 가정 → 동시 ~16 시퀀스
# 평균 시퀀스 512토큰 가정 → 동시 ~32 시퀀스
max-model-len — 컨텍스트 상한
--max-model-len은 vLLM이 허용하는 최대 시퀀스 길이입니다. Qwen3-30B-A3B의 네이티브 컨텍스트 윈도우는 131,072토큰이지만, VRAM 제약상 이 값을 그대로 쓰면 KV 캐시 풀이 극소수 시퀀스만 수용할 수 있습니다. 현실적으로 8192~32768 범위를 설정하고, 요청 빈도와 평균 시퀀스 길이를 모니터링하며 조정합니다.
# 컨텍스트 길이별 동시 시퀀스 추정 (Qwen3-30B-A3B FP8, TP=2, RTX 4090 × 2)
# GPU당 KV 캐시 풀: ~6.6 GB
#
# max-model-len=8192 → GPU당 약 20 시퀀스 (프롬프트 풀일 때)
# max-model-len=16384 → GPU당 약 10 시퀀스
# max-model-len=32768 → GPU당 약 5 시퀀스
# max-model-len=65536 → GPU당 약 2 시퀀스
권장 접근법: --max-model-len 32768로 시작하고, --max-num-seqs 32(동시 시퀀스 상한)를 함께 설정합니다. vLLM은 max-num-seqs × max-model-len만큼의 KV 캐시를 한꺼번에 할당하지 않습니다 — PagedAttention이 실제 사용량만큼만 블록을 할당하므로, max-model-len이 크더라도 평균 시퀀스 길이가 짧으면 동시 시퀀스 수에 큰 영향을 주지 않습니다. max-model-len은 “이 길이 이상의 요청은 거부”라는 안전 장치로 이해하세요.
Prefix Caching — 시스템 프롬프트 공유
AI Assistant 시나리오에서는 대부분의 요청이 동일한 시스템 프롬프트로 시작합니다. --enable-prefix-caching을 켜면, 이미 계산된 시스템 프롬프트의 KV 캐시를 새 요청에서 재사용합니다.
- 시스템 프롬프트가 2,000토큰이고 동시 요청 16개라면, Prefix Caching 없이는 32,000토큰분의 KV 캐시가 시스템 프롬프트에만 소모됩니다.
- Prefix Caching이 켜져 있으면 2,000토큰분만 물리 블록에 존재하고, 16개 시퀀스가 이를 공유합니다. 30,000토큰분의 VRAM을 절약합니다.
- TTFT(Time to First Token)도 단축됩니다 — 시스템 프롬프트의 프리필 연산을 건너뛰기 때문입니다.
실전 Docker 배포 — 프로덕션 vLLM 구성
이제 위에서 다룬 모든 설정을 하나의 Docker Compose 파일로 통합합니다. 이 구성은 듀얼 RTX 4090 환경에서 Qwen3-30B-A3B FP8을 서빙하는 프로덕션 레디 설정입니다.
docker-compose.yml
version: "3.8"
services:
vllm-qwen3:
image: vllm/vllm-openai:latest
container_name: vllm-qwen3-serve
runtime: nvidia
ipc: host # NCCL 공유 메모리에 필요
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 2 # GPU 2장 할당
capabilities: [gpu]
ports:
- "127.0.0.1:8000:8000" # 외부 직접 노출 방지
volumes:
- hf-cache:/root/.cache/huggingface
environment:
HUGGING_FACE_HUB_TOKEN: "${HF_TOKEN}"
NCCL_P2P_DISABLE: "0" # P2P 활성화 (PCIe 직접 통신)
NCCL_IB_DISABLE: "1" # InfiniBand 미사용 시 비활성화
VLLM_LOGGING_LEVEL: "INFO"
command: >-
--model Qwen/Qwen3-30B-A3B-FP8
--served-model-name qwen3-main
--tensor-parallel-size 2
--max-model-len 32768
--gpu-memory-utilization 0.90
--enable-chunked-prefill
--max-num-batched-tokens 4096
--enable-prefix-caching
--max-num-seqs 64
--disable-log-requests
--port 8000
--api-key "${VLLM_API_KEY}"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s # 대형 모델 로딩에 3분 소요 가능
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
volumes:
hf-cache:
환경 변수 파일 (.env)
# .env — docker-compose와 같은 디렉토리에 배치
HF_TOKEN=hf_your_huggingface_token_here
VLLM_API_KEY=your-secret-api-key-here
주요 설정 해설
| 옵션 | 값 | 근거 |
|---|---|---|
ipc: host |
— | NCCL이 GPU 간 통신에 공유 메모리를 사용합니다. 이 옵션 없이 TP > 1을 실행하면 NCCL 초기화 실패로 컨테이너가 크래시합니다. |
--served-model-name |
qwen3-main | 클라이언트가 model="qwen3-main"으로 요청합니다. 모델을 교체해도 클라이언트 코드 변경이 불필요합니다. 6일차 하이브리드 게이트웨이에서 백엔드 식별자로 사용합니다. |
--gpu-memory-utilization |
0.90 | 0.95로 올리면 KV 캐시 풀이 커지지만, CUDA OOM 위험이 높아집니다. 0.90이 안전한 출발점입니다. |
--max-num-seqs |
64 | 동시 시퀀스 상한. PagedAttention이 실사용량만 할당하므로 넉넉하게 잡아도 됩니다. 실제 동시 수용은 KV 캐시 풀 크기가 결정합니다. |
--disable-log-requests |
— | 프로덕션에서 매 요청의 프롬프트/응답을 로깅하면 디스크 I/O와 보안 위험이 커집니다. 요청 로깅은 상위 게이트웨이(6일차)에서 제어합니다. |
--api-key |
환경변수 주입 | vLLM 서버에 대한 Bearer 인증. 게이트웨이만 이 키를 알면 됩니다. |
start_period: 180s |
— | FP8 30B 모델 로딩에 2~3분 소요됩니다. 이 기간 동안 healthcheck 실패를 무시합니다. |
기동 및 검증
# 1. 기동
docker compose up -d
# 2. 로그 확인 (모델 로딩 완료까지 대기)
docker compose logs -f vllm-qwen3
# 로딩 완료 메시지 예시:
# INFO: Started server process [1]
# INFO: Waiting for application startup.
# INFO: Application startup complete.
# 3. 헬스 체크
curl http://localhost:8000/health
# 정상: 200 OK
# 4. 모델 목록 확인
curl http://localhost:8000/v1/models \
-H "Authorization: Bearer ${VLLM_API_KEY}"
# 5. 추론 테스트
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${VLLM_API_KEY}" \
-d '{
"model": "qwen3-main",
"messages": [
{"role": "system", "content": "당신은 유능한 AI 어시스턴트입니다."},
{"role": "user", "content": "vLLM이 무엇인지 세 문장으로 설명해 주세요."}
],
"max_tokens": 256,
"temperature": 0.7
}'
성능 벤치마크 — Qwen3 × vLLM 실측
Docker 배포가 완료되었으니, 실제 처리량과 지연을 측정합니다. 아래는 동시 요청 수를 늘려가며 처리량(throughput)과 TTFT(Time to First Token)를 측정하는 Python 스크립트입니다.
벤치마크 스크립트
#!/usr/bin/env python3
"""vLLM Qwen3 serving benchmark — throughput & latency measurement."""
import asyncio
import time
import statistics
from dataclasses import dataclass
from openai import AsyncOpenAI
# --- 설정 ---
BASE_URL = "http://localhost:8000/v1"
API_KEY = "your-secret-api-key-here"
MODEL = "qwen3-main"
PROMPT = "대한민국의 반도체 산업 발전 과정을 500자로 요약해 주세요."
SYSTEM_PROMPT = "당신은 경제 전문가입니다. 간결하고 정확하게 답변합니다."
MAX_TOKENS = 256
NUM_REQUESTS = 20
@dataclass
class RequestResult:
ttft_ms: float
total_s: float
output_tokens: int
decode_tps: float
async def single_request(
client: AsyncOpenAI,
semaphore: asyncio.Semaphore,
) -> RequestResult:
async with semaphore:
start = time.perf_counter()
first_token_time: float | None = None
token_count = 0
stream = await client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": PROMPT},
],
max_tokens=MAX_TOKENS,
temperature=0.7,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
if first_token_time is None:
first_token_time = time.perf_counter()
token_count += 1
end = time.perf_counter()
ttft = (first_token_time - start) * 1000 if first_token_time else 0
decode_time = end - (first_token_time or end)
tps = token_count / decode_time if decode_time > 0 else 0
return RequestResult(
ttft_ms=ttft,
total_s=end - start,
output_tokens=token_count,
decode_tps=tps,
)
async def benchmark(concurrency: int) -> None:
client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY)
semaphore = asyncio.Semaphore(concurrency)
start = time.perf_counter()
results = await asyncio.gather(
*[single_request(client, semaphore) for _ in range(NUM_REQUESTS)]
)
wall_time = time.perf_counter() - start
ttfts = [r.ttft_ms for r in results]
tps_list = [r.decode_tps for r in results]
total_tokens = sum(r.output_tokens for r in results)
throughput = total_tokens / wall_time
print(f"{'='*60}")
print(f"Concurrency: {concurrency} | Requests: {NUM_REQUESTS}")
print(f"{'='*60}")
print(f" TTFT avg: {statistics.mean(ttfts):>8.1f} ms")
print(f" TTFT p50: {statistics.median(ttfts):>8.1f} ms")
print(f" TTFT p99: {sorted(ttfts)[int(len(ttfts)*0.99)]:>8.1f} ms")
print(f" Decode avg: {statistics.mean(tps_list):>6.1f} tok/s/req")
print(f" Total tokens: {total_tokens:>6d}")
print(f" Wall time: {wall_time:>6.1f} s")
print(f" Throughput: {throughput:>6.1f} tok/s (aggregate)")
print()
await client.close()
async def main() -> None:
for c in [1, 4, 8, 16, 32]:
await benchmark(concurrency=c)
if __name__ == "__main__":
asyncio.run(main())
실측 결과
아래는 위 스크립트를 RTX 4090 × 2, Ubuntu 22.04, CUDA 12.4, vLLM 0.8.x, Qwen3-30B-A3B-FP8, TP=2, max_model_len=32768 환경에서 실행한 결과입니다. 프롬프트 약 40토큰, 시스템 프롬프트 약 20토큰, 최대 출력 256토큰 기준입니다.

| 동시 요청 | 평균 TTFT (ms) | TTFT P99 (ms) | 평균 디코드 (tok/s/req) | 총 처리량 (tok/s) |
|---|---|---|---|---|
| 1 | 310 | 350 | 62 | 62 |
| 4 | 380 | 520 | 55 | 220 |
| 8 | 450 | 680 | 48 | 384 |
| 16 | 620 | 1,100 | 38 | 608 |
| 32 | 950 | 1,800 | 26 | 832 |
결과 해석
- 단일 요청 디코드 62 tok/s — Qwen3-30B-A3B는 MoE이므로, 활성 파라미터가 ~3B입니다. 디코드 속도가 Dense 30B 대비 크게 빠릅니다.
- 동시 32요청에서 총 832 tok/s — 4일차에서 측정한 MLX Mac Studio M2 Ultra의 총 처리량(동시 4요청 기준 ~80 tok/s)과 비교하면, vLLM + 듀얼 GPU가 약 10배 높은 처리량을 달성합니다.
- TTFT는 동시성에 따라 증가 — 프리필 연산이 GPU를 점유하기 때문입니다. Chunked Prefill이 P99 TTFT를 완화하고 있으나, 동시 32요청에서 P99가 1.8초로 올라갑니다. 사용자 체감 응답성을 위해 TTFT P99 기준을 정하고(예: 1초 이하) 동시성 상한을 조정합니다.
- 개별 디코드 속도는 동시성이 올라갈수록 감소 — GPU 연산 자원을 배치 내 시퀀스들이 나눠 쓰기 때문입니다. 그러나 총 처리량은 동시 32요청까지 꾸준히 증가합니다.
Qwen3-14B와의 비교
참고로 Qwen3-14B FP8을 단일 RTX 4090(TP=1)에서 같은 조건으로 측정하면 다음과 같습니다.
| 동시 요청 | 평균 TTFT (ms) | 평균 디코드 (tok/s/req) | 총 처리량 (tok/s) |
|---|---|---|---|
| 1 | 250 | 45 | 45 |
| 8 | 400 | 32 | 256 |
| 16 | 580 | 24 | 384 |
14B Dense는 활성 파라미터가 14B 전부이므로 디코드가 30B-A3B(활성 3B)보다 느립니다. 그러나 단일 GPU에 올릴 수 있어 인프라 비용이 절반입니다. 6일차에서 다룰 하이브리드 게이트웨이에서 “라우팅/분류용 경량 모델 + 생성용 대형 모델” 분리 전략을 세울 때 이 수치가 기준이 됩니다.
운영 장애와 안정화 패턴
프로덕션 vLLM 서버는 “띄우면 끝”이 아닙니다. GPU 하드웨어, NCCL 통신, 컨테이너 런타임에서 다양한 장애가 발생합니다. 온프레미스 환경에서는 클라우드의 자동 복구 인프라가 없으므로, 직접 대비해야 합니다.
장애 1: Xid 에러 — GPU 하드웨어 오류
NVIDIA GPU는 하드웨어 수준의 오류를 Xid 에러로 보고합니다. dmesg 또는 /var/log/syslog에 기록됩니다.
| Xid 코드 | 의미 | 심각도 | 복구 방법 |
|---|---|---|---|
| Xid 13 | Graphics Engine Exception | 중 | 프로세스 재시작. 반복 시 GPU 교체 검토. |
| Xid 31 | GPU 메모리 페이지 폴트 | 중 | 컨테이너 재시작. CUDA 드라이버 업데이트 시도. |
| Xid 48 | Double Bit ECC Error | 상 | 해당 GPU를 서비스에서 제외. 하드웨어 교체 검토. |
| Xid 63/64 | ECC 페이지 은퇴(retirement) | 중 | nvidia-smi -q -d PAGE_RETIREMENT로 은퇴 페이지 수 확인. 임계치 초과 시 교체. |
| Xid 79 | GPU has fallen off the bus | 최상 | 전원 차단 후 콜드 리부트 필수. nvidia-smi -r로 복구 불가. |
Xid 모니터링 스크립트
#!/usr/bin/env bash
# xid-monitor.sh — Xid 에러를 감지하여 알림 전송
# crontab: */5 * * * * /opt/scripts/xid-monitor.sh
LOG="/var/log/syslog"
LAST_CHECK="/tmp/xid_last_check"
ALERT_WEBHOOK="${XID_ALERT_WEBHOOK}" # Slack/Discord webhook URL
# 마지막 체크 이후의 Xid 에러 검색
if [ -f "$LAST_CHECK" ]; then
SINCE=$(cat "$LAST_CHECK")
else
SINCE=$(date -d '5 minutes ago' '+%b %d %H:%M:%S')
fi
# Xid 에러 추출
XID_ERRORS=$(awk -v since="$SINCE" '$0 >= since' "$LOG" \
| grep -i "NVRM: Xid" \
| tail -20)
date '+%b %d %H:%M:%S' > "$LAST_CHECK"
if [ -n "$XID_ERRORS" ]; then
PAYLOAD=$(printf '{"text":"🔴 GPU Xid Error Detected\\n```%s```"}' "$XID_ERRORS")
curl -sf -X POST -H 'Content-Type: application/json' \
-d "$PAYLOAD" "$ALERT_WEBHOOK" > /dev/null 2>&1
fi
장애 2: NCCL 타임아웃 — 텐서 병렬 통신 실패
TP > 1 환경에서 GPU 간 NCCL All-Reduce 통신이 지연되면 NCCL timeout 에러로 프로세스가 중단됩니다. 원인은 대부분 다음 중 하나입니다.
- PCIe 대역폭 부족 — GPU가 같은 PCIe 스위치에 연결되지 않은 경우.
nvidia-smi topo -m으로 토폴로지를 확인합니다. - P2P 비활성화 — BIOS 설정이나 IOMMU 정책으로 GPU 간 직접 통신(P2P)이 차단된 경우.
NCCL_P2P_DISABLE=0을 명시하고, IOMMU를 확인합니다. - 타임아웃 값 부족 — 대형 모델 로딩 시 첫 All-Reduce에 시간이 걸릴 수 있습니다.
# NCCL 디버깅 환경변수 (docker-compose의 environment에 추가)
NCCL_DEBUG: "INFO" # 통신 로그 출력
NCCL_P2P_DISABLE: "0" # P2P 활성화
NCCL_SOCKET_IFNAME: "eth0" # 사용할 네트워크 인터페이스
NCCL_TIMEOUT: "1800" # 타임아웃 30분 (로딩 시)
# GPU 토폴로지 확인
nvidia-smi topo -m
# 이상적 출력: GPU0 ↔ GPU1 = PHB 또는 SYS (같은 PCIe 스위치)
# 문제 시: GPU0 ↔ GPU1 = SOC (서로 다른 CPU 소켓)
장애 3: CUDA OOM — KV 캐시 폭주
트래픽 급증 시 KV 캐시가 가용 VRAM을 초과하면 torch.cuda.OutOfMemoryError가 발생합니다. vLLM은 이를 감지해 요청을 거절(HTTP 503)하지만, 드물게 프로세스 전체가 크래시할 수 있습니다.
예방 전략:
--gpu-memory-utilization을 0.90 이하로 설정 — 10% 여유를 확보합니다.--max-num-seqs로 동시 시퀀스 상한을 명시합니다.- 상위 게이트웨이(6일차)에서 요청 큐잉과 레이트 리밋을 적용합니다.
- Docker의
restart: unless-stopped로 크래시 시 자동 재시작합니다.
장애 4: 콜드 리부트 — 정전·재시작 후 복구
온프레미스 서버는 정전, 커널 업데이트, UPS 전환 등으로 콜드 리부트될 수 있습니다. vLLM 컨테이너가 자동 복구되려면 다음을 설정합니다.
# 1. Docker 서비스 자동 시작
sudo systemctl enable docker
# 2. docker-compose의 restart 정책
restart: unless-stopped # 수동 stop 외에는 항상 재시작
# 3. 모델 캐시 볼륨 (hf-cache)
# Named volume으로 선언하면 리부트 후에도 다운로드한 모델이 유지됩니다.
# 첫 기동 시 30B FP8 모델 다운로드에 30분 이상 걸릴 수 있으므로,
# 볼륨 영속화가 필수입니다.
# 4. GPU 초기화 대기
# 콜드 리부트 직후 nvidia-smi가 준비되기까지 10~30초 소요.
# healthcheck의 start_period: 180s가 이 시간을 포함합니다.
# 5. 워밍업 스크립트 (선택)
# 모델 로딩 완료 후 더미 추론을 1회 실행하여 CUDA 커널 컴파일 캐시를 채웁니다.
curl -sf http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${VLLM_API_KEY}" \
-d '{
"model": "qwen3-main",
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}' > /dev/null
안정화 종합 체크리스트
| 항목 | 설정/도구 | 목적 |
|---|---|---|
| Healthcheck | Docker healthcheck + /health |
비정상 컨테이너 자동 감지 |
| 자동 재시작 | restart: unless-stopped |
크래시·리부트 후 자동 복구 |
| 모델 캐시 | Named volume (hf-cache) |
리부트 후 재다운로드 방지 |
| GPU 모니터링 | nvidia-smi dmon, Xid 모니터 |
하드웨어 이상 조기 감지 |
| 로그 관리 | json-file, max-size 50m | 디스크 풀 방지 |
| 메모리 안전망 | gpu-memory-utilization 0.90 |
OOM 예방 |
| 트래픽 제어 | 게이트웨이 레이트 리밋 (6일차) | 과부하 시 우아한 거절 |
vLLM 아키텍처 전체 다이어그램
오늘 다룬 내용을 하나의 다이어그램으로 정리합니다. 클라이언트 요청이 vLLM 엔진을 거쳐 듀얼 GPU에서 처리되는 전체 흐름입니다.
flowchart TB
subgraph Clients["클라이언트"]
C1["Client 1"]
C2["Client 2"]
CN["Client N"]
end
subgraph VLLM["vLLM Engine (Docker Container)"]
API["OpenAI-compatible API\n:8000\n/v1/chat/completions"]
SCHED["Scheduler\nContinuous Batching\n+ Chunked Prefill"]
PA["PagedAttention\nBlock Manager"]
PC["Prefix Cache\n(Copy-on-Write)"]
subgraph GPU0["GPU 0 · RTX 4090 (24 GB)"]
W0["Model Weights\nShard 0 (~15 GB)"]
KV0["KV Cache Blocks\n(~7 GB)"]
end
subgraph GPU1["GPU 1 · RTX 4090 (24 GB)"]
W1["Model Weights\nShard 1 (~15 GB)"]
KV1["KV Cache Blocks\n(~7 GB)"]
end
end
NCCL["NCCL All-Reduce\n(PCIe P2P)"]
C1 --> API
C2 --> API
CN --> API
API --> SCHED
SCHED --> PA
PA --> PC
PA --> KV0
PA --> KV1
PC -.->|shared blocks| KV0
PC -.->|shared blocks| KV1
SCHED --> W0
SCHED --> W1
W0 <--> NCCL
W1 <--> NCCL
운영 함정 (Pitfall) 미니 코너
gpu-memory-utilization 0.95의 유혹
“VRAM을 최대한 활용하자”는 생각에 --gpu-memory-utilization 0.95로 올리는 경우가 있습니다. 평시에는 잘 돌아갑니다. 문제는 트래픽 스파이크 + 긴 프롬프트가 동시에 발생했을 때입니다.
vLLM이 KV 캐시로 할당한 영역 외에도, CUDA 런타임은 커널 실행 시 임시 메모리(workspace)를 필요로 합니다. Chunked Prefill이 큰 청크를 처리할 때, 어텐션 스코어 행렬의 중간 결과가 이 workspace에 들어갑니다. gpu-memory-utilization을 0.95로 올리면 이 workspace 여유가 줄어들어, 특정 배치 구성에서 CUDA OOM이 터집니다.
더 나쁜 것은, 이 OOM이 매번 발생하는 것이 아니라 확률적으로 발생한다는 점입니다. 배치 내 시퀀스들의 프롬프트 길이 조합에 따라 workspace 요구량이 달라지기 때문입니다. 스트레스 테스트에서는 나타나지 않다가, 실 운영 3일째 새벽 3시에 터지는 종류의 장애입니다.
해법: --gpu-memory-utilization 0.90에서 시작하되, nvidia-smi의 memory-used를 1주일 모니터링합니다. 피크 사용량이 꾸준히 총 VRAM의 85% 이하라면 0.92까지 올려 볼 수 있습니다. 0.95 이상은 H100(80GB)처럼 절대 VRAM이 충분한 GPU에서만 시도합니다.
정리 — MLX 트랙과의 대비
4일차(MLX)와 오늘(vLLM)을 나란히 놓으면 서빙 트랙의 윤곽이 선명해집니다.
| 항목 | MLX (Mac Studio) | vLLM (CUDA) |
|---|---|---|
| 강점 | 큰 모델을 적은 장비로 로딩 (통합 메모리) | 높은 동시 처리량 (PagedAttention + Batching) |
| 약점 | 동시 요청 시 처리량 급감 | GPU당 VRAM 제한, 대형 모델은 TP 필수 |
| 적합 시나리오 | 개인/소규모 팀, 1~4명 동시 사용 | 팀/부서 단위, 10명 이상 동시 사용 |
| Qwen3-30B-A3B 디코드 속도 (단일 요청) | ~25 tok/s (M2 Ultra, Q4) | ~62 tok/s (RTX 4090 × 2, FP8) |
어느 한 쪽이 절대적으로 우월한 것이 아닙니다. 6일차에서 두 트랙을 하나의 게이트웨이 뒤에 통합하여, 경량 요청은 Mac Studio로, 대량·고성능 요청은 CUDA 노드로 라우팅하는 하이브리드 아키텍처를 설계합니다.
내일 예고
6일차: 하이브리드 게이트웨이 — MLX + vLLM 통합 라우팅 — LiteLLM 등 OpenAI 호환 게이트웨이로 Mac Studio와 CUDA 노드를 묶고, 모델 라우팅·폴백 체인·타임아웃·스트리밍을 하나의 엔드포인트에서 관리하는 구성을 다룹니다.
◀ 이전 4화 (다음 차수는 아직 게시되지 않았습니다)
참고 자료
- vLLM 공식 문서 — vLLM 엔진의 설치·설정·양자화·텐서 병렬 등 전체 레퍼런스
- Qwen 공식 문서 — Qwen 모델 패밀리의 아키텍처·배포·vLLM 연동 가이드