vLLM으로 LLM 추론 서버 운영하기: PagedAttention, Continuous Batching, 양자화, Kubernetes 배포

이 글의 핵심

LLM을 직접 서빙하면 GPU 메모리의 상당 부분을 KV 캐시가 차지해 동시 요청 수가 금방 한계에 닿습니다. vLLM은 이 캐시를 페이지 단위로 관리하고 끝난 요청 자리에 새 요청을 바로 끼워 넣는 배칭으로 같은 GPU에서 처리량을 끌어올립니다. 처리량을 좌우하는 옵션, tool calling과 JSON 스키마 강제, KV cache OOM·NCCL 오류 해결, vLLM 대신 다른 선택지가 나은 경우까지 짚습니다.

이 글의 핵심

vLLM은 2023년 UC Berkeley 연구에서 출발해 현재 Linux Foundation AI & Data의 사실상 표준 LLM 추론 서버입니다. 핵심 기여:

  • PagedAttention: KV cache를 OS 페이지처럼 블록 단위로 관리 → 메모리 단편화 제거
  • Continuous Batching: 요청 단위가 아닌 토큰 단위로 배치 → GPU utilization 극대화
  • Prefix Caching: 동일한 시스템 프롬프트를 여러 요청이 공유
  • Speculative Decoding: 작은 draft 모델로 후보 생성 후 대형 모델이 검증
  • Chunked Prefill: 긴 프롬프트를 토큰 단위로 쪼개 latency/throughput 균형

결과: 같은 GPU에서 동시 요청을 더 많이 처리할 수 있고(vLLM 팀의 2023년 벤치마크 기준 HF Transformers 대비 최대 24배, TGI 대비 최대 3.5배 처리량), OpenAI 호환 API로 바로 서빙할 수 있습니다.

설치

Docker 한 줄

docker run --runtime nvidia --gpus all \
  -p 8000:8000 --ipc=host \
  -v $HOME/.cache/huggingface:/root/.cache/huggingface \
  -e HUGGING_FACE_HUB_TOKEN=hf_xxx \
  vllm/vllm-openai:latest \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --max-model-len 8192

pip 설치

# CUDA 12.1 환경
pip install vllm

# OpenAI 호환 서버 실행
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.9

클라이언트 호출 (OpenAI SDK)

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="none",   # vLLM은 기본 auth 없음, 필요하면 --api-key 플래그
)

resp = client.chat.completions.create(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "vLLM을 한 문장으로 설명해줘"},
    ],
    temperature=0.7,
    max_tokens=256,
)
print(resp.choices[0].message.content)

LangChain·LlamaIndex·LiteLLM 클라이언트 모두 base URL만 바꾸면 동작합니다.

처리량을 좌우하는 핵심 옵션

vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --tensor-parallel-size 4 \
  --max-model-len 16384 \
  --max-num-seqs 256 \
  --gpu-memory-utilization 0.92 \
  --enable-prefix-caching \
  --enable-chunked-prefill \
  --quantization awq \
  --dtype half
옵션의미권장
--tensor-parallel-size모델을 N GPU에 분할2의 거듭제곱 (2, 4, 8)
--max-model-len최대 context 길이실제 필요한 만큼 (크면 KV cache 소모)
--max-num-seqs동시 처리 sequence 수64-256 (KV cache 여유에 따라)
--gpu-memory-utilization단일 GPU 메모리 사용 비율0.9 (위험하면 0.85)
--enable-prefix-caching시스템 프롬프트 재사용대화형 API에 필수
--enable-chunked-prefill긴 prompt를 조각 처리긴 컨텍스트 워크로드
--quantizationawq/gptq/fp8메모리 1/2-1/4
--dtypehalf(fp16)/bfloat16Ampere+ 는 bfloat16

PagedAttention 간단 이해

전통 KV cache는 “한 요청당 연속 메모리”를 잡아 단편화로 GPU가 놀게 됩니다.

[seq1 길이 500][  빈 공간 3500  ][seq2 1800][ 빈 공간 2200 ]

PagedAttention은 block(기본 16 토큰) 단위로 매핑해:

[B1][B2][B3]   ←→   [B8][B9]
[B4][B5]            [B10][B11][B12]
...

KV 블록 풀에서 필요한 만큼만 할당 → 단편화가 블록 하나 이내로 줄어듦. vLLM 팀은 기존 시스템이 최대 길이만큼 미리 예약하느라 KV cache 메모리의 60~80%를 낭비한 반면, PagedAttention은 낭비를 4% 미만으로 줄였다고 보고했습니다. 남는 메모리만큼 더 많은 요청을 한 배치에 넣을 수 있어 처리량이 오릅니다.

Continuous Batching

기존 static batching은 배치 내 모든 요청이 같은 토큰 수를 생성할 때까지 기다립니다. Continuous batching은 매 step마다 배치를 재구성해 짧은 요청이 끝나면 즉시 새 요청을 투입합니다. 출력 길이가 요청마다 크게 다른 실제 트래픽에서는, 먼저 끝난 요청의 자리를 비워 두지 않으므로 GPU 활용률과 처리량이 크게 오릅니다.

양자화 비교

형식가중치 메모리 (FP16 대비)품질 영향추천 시나리오
FP16/BF16기준없음정확도가 최우선
AWQ 4-bit약 1/4 + 스케일 값작은 편, 활성값 분포를 보고 중요한 채널을 보호프로덕션 기본 선택
GPTQ 4-bit약 1/4 + 스케일 값작은 편, 보정 데이터에 따라 달라짐AWQ 없을 때
FP8 (H100+)약 1/2매우 작음, 하드웨어가 FP8 연산을 직접 지원Hopper GPU
GGUF (llama.cpp)양자화 비트 수에 따라모델·비트 수별CPU+GPU 혼합

품질 영향은 모델과 작업에 따라 다르므로, 양자화 모델을 도입하기 전에 자기 서비스의 평가 세트로 FP16 결과와 비교해 보세요.

AWQ 모델은 HuggingFace의 TheBloke/*-AWQ, casperhansen/llama-3-8b-awq 등으로 쉽게 구할 수 있습니다.

Tool Calling (함수 호출)

tools = [{
  "type": "function",
  "function": {
    "name": "search_issues",
    "description": "GitHub 이슈 검색",
    "parameters": {
      "type": "object",
      "properties": {"q": {"type": "string"}},
      "required": ["q"]
    }
  }
}]

resp = client.chat.completions.create(
    model="meta-llama/Llama-3.1-70B-Instruct",
    messages=[{"role": "user", "content": "latest vllm issues"}],
    tools=tools,
    tool_choice="auto",
)
print(resp.choices[0].message.tool_calls)

vLLM은 모델별 native tool calling 파서를 내장해 Hermes/Llama-3.1/Mistral/Qwen 형식을 자동 인식합니다.

# 서버 시작 시
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --enable-auto-tool-choice \
  --tool-call-parser llama3_json

Structured Output (JSON Schema 강제)

schema = {
    "type": "object",
    "properties": {
        "answer": {"type": "string"},
        "confidence": {"type": "number"}
    },
    "required": ["answer", "confidence"]
}

resp = client.chat.completions.create(
    model="...",
    messages=[...],
    extra_body={"guided_json": schema},
)

vLLM은 outlines/xgrammar 기반 제약 디코딩으로 항상 유효한 JSON을 반환하도록 보장합니다. 프로덕션에서 후처리 파싱 실패를 거의 제거합니다.

Kubernetes 프로덕션 배포

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-llama70b
spec:
  replicas: 2
  selector: {matchLabels: {app: vllm-llama70b}}
  template:
    metadata:
      labels: {app: vllm-llama70b}
    spec:
      nodeSelector:
        accelerator: nvidia-h100
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          args:
            - "--model=meta-llama/Llama-3.1-70B-Instruct"
            - "--tensor-parallel-size=4"
            - "--max-model-len=16384"
            - "--max-num-seqs=128"
            - "--enable-prefix-caching"
            - "--gpu-memory-utilization=0.92"
            - "--quantization=fp8"
          ports: [{containerPort: 8000}]
          env:
            - name: HUGGING_FACE_HUB_TOKEN
              valueFrom: {secretKeyRef: {name: hf, key: token}}
          resources:
            limits:
              nvidia.com/gpu: 4
              memory: 128Gi
          volumeMounts:
            - {name: hf-cache, mountPath: /root/.cache/huggingface}
            - {name: shm, mountPath: /dev/shm}
          readinessProbe:
            httpGet: {path: /health, port: 8000}
            initialDelaySeconds: 300
            periodSeconds: 10
          livenessProbe:
            httpGet: {path: /health, port: 8000}
            initialDelaySeconds: 600
            periodSeconds: 30
      volumes:
        - name: hf-cache
          persistentVolumeClaim: {claimName: hf-cache}
        - name: shm
          emptyDir: {medium: Memory, sizeLimit: 32Gi}

포인트

  • initialDelaySeconds 크게 — 70B 모델 로딩 5-10분
  • /dev/shm을 emptyDir memory로 32GB — Tensor Parallel용 NCCL 통신
  • HF 캐시는 PVC로 공유해 재시작 시 다운로드 생략
  • 2 replica로 롤링 업데이트/장애 대응

Service / HPA

apiVersion: v1
kind: Service
metadata: {name: vllm-llama70b}
spec:
  selector: {app: vllm-llama70b}
  ports: [{port: 80, targetPort: 8000}]
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: vllm-llama70b}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-llama70b
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Pods
      pods:
        metric:
          name: vllm_num_requests_waiting
        target: {type: AverageValue, averageValue: "10"}

vLLM의 커스텀 메트릭(vllm_num_requests_waiting)을 Prometheus Adapter로 연결하면 대기 큐 깊이 기반 오토스케일링이 가능합니다.

관측: Prometheus 메트릭

vLLM은 :8000/metrics에 풍부한 메트릭을 노출합니다.

메트릭의미
vllm:num_requests_running처리 중 요청 수
vllm:num_requests_waiting대기 큐
vllm:gpu_cache_usage_percKV cache 사용률
vllm:time_to_first_token_secondsTTFT p50/p95
vllm:time_per_output_token_secondsTPOT
vllm:e2e_request_latency_seconds전체 지연
vllm:prompt_tokens_total / vllm:generation_tokens_total토큰 카운터

Grafana 공식 대시보드로 바로 시각화 가능합니다.

프리픽스 캐시 활용

동일한 system prompt를 쓰는 챗봇/agent는 매 요청마다 시스템 프롬프트를 다시 prefill하는 낭비가 큽니다. --enable-prefix-caching은 KV cache 블록을 hash로 식별해 재사용합니다.

예: 1000 토큰 시스템 프롬프트를 100명이 동시 사용 시

  • 캐시 없음: 100,000 토큰 prefill
  • 프리픽스 캐시: 1,000 토큰 prefill + 99회 재사용 → 99% 절감

프롬프트 일부만 동일해도 토큰 접두사가 일치하는 구간까지 캐시됩니다.

Speculative Decoding

vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --speculative-model meta-llama/Llama-3.2-1B \
  --num-speculative-tokens 5 \
  --tensor-parallel-size 4

작은 draft 모델이 5 토큰 후보를 생성하면 큰 모델이 한 forward pass로 한꺼번에 검증하고, 맞은 토큰까지 채택합니다. 큰 모델의 forward 횟수가 줄어 단일 사용자 응답 속도가 빨라지는데, 이득은 draft 모델의 예측이 얼마나 자주 맞는지(acceptance rate)에 달려 있습니다. 예측이 자주 틀리면 draft 계산만 늘어나고, 배치가 이미 큰 고부하 상황에서는 GPU가 바빠 효과가 줄어듭니다.

비용 최적화 사례

시나리오: Llama 3.1 70B를 하루 100만 요청 서빙

  1. FP16: 70B 가중치만 약 140GB라 A100 80GB 여러 장을 tensor parallel로 묶어야 하고, 그만큼 GPU 수가 늘어납니다.
  2. FP8 + prefix cache + speculative: H100에서 FP8로 가중치 메모리를 절반으로 줄이고, 공통 시스템 프롬프트를 캐시로 재사용해 prefill 연산을 줄이면 같은 GPU 수로 더 많은 요청을 처리할 수 있습니다.
  3. AWQ 4-bit: 가중치가 약 1/4로 줄어 L40S처럼 메모리가 작은 GPU 몇 장으로도 서빙이 가능해지므로, GPU 단가가 낮아집니다. 대신 품질 영향을 평가 세트로 확인해야 합니다.

어떤 조합이 가장 싼지는 요청 길이 분포, 프롬프트 공통 비율, 목표 지연 시간, 클라우드 GPU 단가에 따라 달라집니다. 후보 구성마다 실제 트래픽을 재현한 부하 테스트로 GPU당 처리량을 재고, 목표 트래픽에 필요한 GPU 수를 계산해 비교하세요.

문제 해결

OOM: KV cache out of memory

  • --max-num-seqs 낮추기
  • --max-model-len 줄이기 (실제 사용 길이에 맞게)
  • --gpu-memory-utilization 0.85 정도로
  • Chunked prefill 활성화

낮은 TPS

  • --enable-prefix-caching 확인
  • Tensor parallel이 비대칭 — 2의 거듭제곱만
  • 양자화 고려

Tool call 파싱 실패

  • 모델별 파서: llama3_json, hermes, mistral, pythonic 등 선택
  • guided_json으로 schema 강제

느린 시작

  • HF 캐시 PVC 준비 (콜드스타트 10분 → 1분)
  • 모델을 S3·GCS에 mirror해 download 가속

NCCL 통신 오류 (TP > 1)

  • /dev/shm 크기 32GB+
  • NCCL_DEBUG=INFO로 로그 확인
  • GPU 간 P2P/NVLink 지원 확인

언제 vLLM이 아닌 다른 선택지

  • CPU 서빙/엣지: llama.cpp, Ollama
  • 매우 작은 모델 & 초저지연 요구: TensorRT-LLM
  • 엔드투엔드 파인튜닝+서빙: Modal, Together, Anyscale
  • Windows 네이티브: LM Studio, Ollama
  • 관리형 서버리스: OpenRouter, Together AI API

체크리스트

  • 모델 크기별 최소 GPU 요건 계산
  • --tensor-parallel-size는 2의 거듭제곱
  • --enable-prefix-caching 항상 on
  • --max-num-seqs·--max-model-len 워크로드 맞춤 튜닝
  • AWQ/FP8 양자화 검토
  • HF 캐시를 PVC/공유 스토리지로
  • /dev/shm tmpfs 충분히
  • Prometheus 메트릭 대시보드·알림
  • 2+ replica + HPA로 HA 구성
  • OpenAI SDK 호환 테스트 (base_url만 교체)

마무리

vLLM은 2026년 현재 오픈소스 LLM 서빙의 사실상 표준입니다. PagedAttention·Continuous Batching이라는 연구 혁신이 프로덕션 추론 비용을 몇 배 절감해주고, OpenAI API 호환으로 기존 클라이언트 마이그레이션이 거의 공짜입니다. Llama·Qwen·DeepSeek 같은 최신 오픈 모델을 self-hosting해 데이터 주권과 비용을 잡고 싶다면 vLLM이 현재 최선의 선택이며, 이 가이드의 튜닝 옵션·K8s 패턴만 적용해도 프로덕션 수준의 서빙이 가능합니다.

자주 묻는 질문 (FAQ)

Q. vLLM에서 KV cache out of memory 오류가 나면 무엇부터 조정하나요?

A. 동시에 처리하는 시퀀스 수를 —max-num-seqs로 낮추고, —max-model-len을 실제 서비스에서 쓰는 컨텍스트 길이에 맞게 줄이는 것이 우선입니다. —gpu-memory-utilization을 0.85 정도로 두어 여유를 확보하고, chunked prefill을 켜면 긴 프롬프트가 한 번에 메모리를 점유하는 것을 줄일 수 있습니다. 그래도 부족하다면 양자화된 모델이나 Tensor Parallel로 메모리 부담을 나누는 방안을 검토합니다.


같이 보면 좋은 글