본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
듀얼 RTX 4090 GPU 워크스테이션
IT기술

[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 5/14화: vLLM으로 Qwen3 서빙 — FP8·텐서 병렬 실전 구축

By AICosmus
2026년 07월 02일 16 Min Read
0

이 글은 「온프레미스 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 옵션이 이 메커니즘을 활성화합니다.
PagedAttention 블록 관리 구조 - Qwen3

실측 메모리 효율

아래는 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가 병목이 될 수 있습니다.

듀얼 GPU 텐서 병렬 구성도

듀얼 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토큰 기준입니다.

vLLM Qwen3 성능 벤치마크 결과
동시 요청 평균 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 노드를 묶고, 모델 라우팅·폴백 체인·타임아웃·스트리밍을 하나의 엔드포인트에서 관리하는 구성을 다룹니다.


📚 시리즈: 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 5화)
◀ 이전 4화  (다음 차수는 아직 게시되지 않았습니다)

참고 자료

  • vLLM 공식 문서 — vLLM 엔진의 설치·설정·양자화·텐서 병렬 등 전체 레퍼런스
  • Qwen 공식 문서 — Qwen 모델 패밀리의 아키텍처·배포·vLLM 연동 가이드

Tags:

CUDA LLM 서빙FP8 양자화vLLM Qwen3연재:온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계온프레미스 AI온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계-5화텐서 병렬
작성자

AICosmus

Follow Me
다른 기사
opencode BYOK 다중 프로바이더 키 연결 개념
Previous

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 3/12화: opencode BYOK 설정 — 75개 프로바이더, 최적 모델 고르기

터미널에서 GitHub CLI를 사용하는 개발자
Next

GitHub CLI(gh) 사용법 총정리 — 터미널 실전 가이드

댓글 없음! 첫 댓글을 남겨보세요.

답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

  • [opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조
  • Kotlin 코루틴 핵심 5가지 개념과 실전 활용법
  • [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트
  • LLM 파인튜닝 실전 5단계 — 2026 LoRA 완벽 가이드
  • 금융 앱 생체인증 작동 원리, 지문·얼굴 보안 5단계 완전 해부

최신 댓글

  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전의 Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus
  2. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  3. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  4. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  5. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme