Published on

vLLM+KServe로 LLM API 배포 - OOM·P95 튜닝

Authors

서론: vLLM은 빠른데, 운영은 따로다

LLM API를 빠르게 올리려면 vLLM이 좋은 선택입니다. PagedAttention 기반의 KV 캐시 관리, 연속 배치(continuous batching), 높은 GPU 활용률 덕분에 같은 GPU에서 더 많은 요청을 처리할 수 있습니다. 문제는 운영 환경에서 자주 마주치는 두 가지입니다.

  • GPU OOM: 특정 프롬프트 길이, 동시 요청, max tokens 조합에서 갑자기 터짐
  • P95 지연: 평균은 괜찮은데 꼬리 지연이 길어져 SLA를 깨뜨림

이 글은 vLLM을 KServe 위에 올려 LLM API로 제공할 때, OOM을 "없애는" 것과 P95를 "낮추는" 것을 함께 달성하기 위한 튜닝 방법을 다룹니다. 단순히 파라미터 나열이 아니라, 왜 그런 현상이 생기는지와 어떤 순서로 조정해야 하는지에 집중합니다.

운영 중 스트리밍에서 중복 토큰, 메모리 증가 같은 이슈가 함께 나타난다면 이 글과 함께 LangChain 스트리밍 중복토큰·메모리누수 9분 해결도 같이 보면 원인 분리가 빨라집니다.

아키텍처 개요: KServe와 vLLM의 역할 분리

KServe는 Kubernetes 위에서 모델 서빙을 표준화하는 레이어입니다. 주요 역할은 다음입니다.

  • InferenceService CRD로 배포/롤아웃 단순화
  • 트래픽 라우팅(Revision 개념), 카나리, 오토스케일(HPA 또는 Knative)
  • 네트워크(ingress), 관측(메트릭), 보안 정책 연계

vLLM은 실제 GPU에서 토큰 생성 워크로드를 효율적으로 수행합니다. 따라서 튜닝은 크게 두 층으로 나뉩니다.

  1. vLLM 내부 튜닝: KV 캐시 예산, 배치 전략, 토큰 제한, dtype 등
  2. KServe/쿠버네티스 튜닝: 리소스 요청/제한, 스케일 정책, 큐잉/타임아웃, 라우팅

OOM은 대부분 (1)에서 시작하지만, (2)의 동시성/큐잉 정책이 (1)을 악화시키는 구조가 많습니다. P95도 마찬가지로 (1)에서의 배치/큐잉과 (2)에서의 오토스케일 지연이 합쳐져 만들어집니다.

OOM이 나는 진짜 이유: "모델"보다 "KV 캐시"가 더 무섭다

GPU 메모리 소비를 단순화하면 아래처럼 나눌 수 있습니다.

  • 모델 가중치(Weights): 로딩 시 거의 고정
  • 활성화(Activations): 프리필(prefill) 구간에서 증가, 배치/시퀀스 길이에 영향
  • KV 캐시: 디코딩(decode) 동안 토큰이 쌓일수록 증가, 동시 시퀀스 수와 컨텍스트 길이에 영향

vLLM에서 운영 OOM의 1순위는 대개 KV 캐시입니다. 이유는 "동시 요청 수"와 "각 요청의 입력 길이 + 생성 길이"가 합쳐져 폭발하기 때문입니다.

OOM을 유발하는 대표 패턴

  • 긴 프롬프트(예: RAG로 컨텍스트가 길어짐) + 높은 max_tokens
  • 스파이크 트래픽에서 KServe가 한 파드에 요청을 몰아넣음(스케일아웃이 늦음)
  • 스트리밍 응답으로 클라이언트가 연결을 오래 유지해 decode 슬롯이 오래 점유됨

먼저 해야 할 3가지: 제한을 걸어 "폭발"을 막기

운영에서는 "최고 성능"보다 "최악 상황에서 죽지 않기"가 우선입니다.

  1. 입력 길이 제한
  • API Gateway나 앱 레이어에서 prompt 토큰 수를 제한하거나, 서버에서 max_model_len을 보수적으로 설정합니다.
  1. 생성 길이 제한
  • max_tokens 상한을 강제합니다. 특히 챗봇은 무제한 생성이 가장 위험합니다.
  1. 동시 실행 제한
  • 한 GPU가 동시에 처리하는 시퀀스 수를 제한해 KV 캐시 상한을 사실상 고정합니다.

vLLM 핵심 파라미터로 OOM 방지하기

아래는 vLLM을 OpenAI 호환 서버로 띄우는 예시 커맨드입니다. 실제 배포에서는 컨테이너 args로 넣는 형태가 됩니다.

python -m vllm.entrypoints.openai.api_server \
  --model /models/your-llm \
  --host 0.0.0.0 \
  --port 8000 \
  --dtype bfloat16 \
  --max-model-len 4096 \
  --gpu-memory-utilization 0.90 \
  --max-num-seqs 32 \
  --max-num-batched-tokens 8192

--gpu-memory-utilization

  • 의미: vLLM이 KV 캐시에 쓸 GPU 메모리 상한을 잡는 핵심 스위치입니다.
  • OOM 대응: 값을 낮추면 OOM 확률은 줄지만, 동시 처리량이 줄어 P95가 악화될 수 있습니다.
  • 실전 팁: 처음에는 0.85~0.90 정도로 시작하고, OOM이 한 번이라도 나면 낮추는 방향이 안전합니다.

--max-model-len

  • 의미: 한 요청이 가질 수 있는 (입력+생성 포함) 최대 토큰 길이 상한입니다.
  • OOM 대응: 가장 강력한 안전장치입니다. RAG를 붙이면 생각보다 쉽게 8k, 16k를 넘깁니다.
  • 실전 팁: 제품 요구사항이 8k라도, 초기에 4k로 운영 안정성을 확보한 뒤 단계적으로 올리는 편이 좋습니다.

--max-num-seqs

  • 의미: 동시에 decode 슬롯을 점유할 수 있는 시퀀스 수 상한입니다.
  • OOM 대응: 동시 요청 폭주에 대한 "하드 브레이크"입니다.
  • P95 영향: 너무 낮으면 큐잉이 늘어 P95가 튈 수 있습니다. 대신 OOM은 확실히 줄어듭니다.

--max-num-batched-tokens

  • 의미: 한 번에 배치로 묶을 총 토큰 예산입니다. prefill에서 특히 중요합니다.
  • OOM 대응: 긴 프롬프트가 몰릴 때 prefill 메모리 압박을 낮추는 데 도움이 됩니다.
  • P95 영향: 값을 지나치게 낮추면 GPU 활용률이 떨어져 처리량이 줄고, 결과적으로 P95가 악화될 수 있습니다.

KServe 배포 예시: InferenceService로 vLLM 올리기

KServe에서 커스텀 컨테이너로 vLLM을 띄우는 예시입니다. 환경에 따라 istio/knative 여부가 다를 수 있지만, 구조는 비슷합니다.

주의: MDX 빌드 에러를 피하기 위해 YAML에서 부등호가 필요한 값은 인라인 코드로 표현하기 어렵습니다. 아래 YAML은 코드 블록이므로 안전합니다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llm-vllm
spec:
  predictor:
    containers:
      - name: vllm
        image: your-registry/vllm:0.6.x
        args:
          - "python"
          - "-m"
          - "vllm.entrypoints.openai.api_server"
          - "--model"
          - "/models/your-llm"
          - "--host"
          - "0.0.0.0"
          - "--port"
          - "8000"
          - "--dtype"
          - "bfloat16"
          - "--max-model-len"
          - "4096"
          - "--gpu-memory-utilization"
          - "0.90"
          - "--max-num-seqs"
          - "32"
          - "--max-num-batched-tokens"
          - "8192"
        resources:
          requests:
            cpu: "4"
            memory: "16Gi"
            nvidia.com/gpu: "1"
          limits:
            cpu: "8"
            memory: "24Gi"
            nvidia.com/gpu: "1"
        volumeMounts:
          - name: model-store
            mountPath: /models
    volumes:
      - name: model-store
        persistentVolumeClaim:
          claimName: llm-model-pvc

리소스 요청/제한에서 놓치기 쉬운 점

  • GPU는 requestslimits를 동일하게 두는 것이 일반적입니다.
  • CPU/메모리는 prefill 병목과 토크나이저 처리량에도 영향을 줍니다. CPU가 너무 낮으면 GPU가 놀면서 P95가 튈 수 있습니다.
  • 모델 로딩 시 메모리 피크가 있을 수 있으니, 컨테이너 메모리 제한을 너무 타이트하게 잡으면 OOMKilled가 GPU OOM보다 먼저 터집니다.

P95 지연이 튀는 이유: "큐잉"과 "배치"의 부작용

P95는 단일 요청의 순수 연산 시간만으로 결정되지 않습니다. LLM 서빙에서 P95를 키우는 요인은 보통 아래 조합입니다.

  • 큐잉 지연: 요청이 들어왔지만 decode 슬롯이 꽉 차서 대기
  • prefill 지연: 긴 입력이 배치에 섞이면 배치 전체가 느려짐(head-of-line blocking)
  • 스케일 지연: 파드가 늘어나기 전에 한 파드가 트래픽을 떠안음

vLLM의 연속 배치는 처리량을 올리지만, 잘못 운영하면 "평균은 빠른데 P95가 느린" 전형적인 형태가 됩니다.

P95를 낮추는 튜닝 전략 5가지

1) 동시성은 "GPU 1장당"으로 고정해서 설계하기

가장 흔한 실수는 "오토스케일이 있으니 동시성을 크게" 잡는 것입니다. 오토스케일은 늦습니다. 스파이크 구간에서 한 파드가 버티는 동안 OOM과 P95가 같이 터집니다.

  • max_num_seqs를 보수적으로 설정
  • KServe/Ingress 레벨에서 한 파드로 들어가는 동시 요청을 제한(가능하면)

2) 긴 프롬프트를 별도 풀로 분리하기

RAG나 문서 요약처럼 입력이 긴 요청은 prefill 비용이 큽니다. 짧은 채팅 요청과 섞이면 P95를 끌어올립니다.

  • InferenceService를 2개로 분리
    • short: max_model_len 낮게, max_num_seqs 높게
    • long: max_model_len 높게, max_num_seqs 낮게
  • 라우팅은 API Gateway에서 토큰 길이 기준으로 분기

이 방식은 "한 파드에서 모든 워크로드를 처리"하려는 것보다 운영이 훨씬 안정적입니다.

3) max-num-batched-tokens로 prefill 폭주를 제어하기

긴 입력이 동시에 들어오면 prefill 배치가 커지고 순간적으로 지연이 튑니다.

  • P95가 prefill 구간에서 튄다면 max_num_batched_tokens를 낮춰서 배치 크기를 제한
  • 대신 처리량이 떨어질 수 있으므로, 오토스케일과 함께 조정

4) 타임아웃과 재시도 정책을 "LLM 특성"에 맞게

로드밸런서/클라이언트의 재시도가 LLM 서버를 더 느리게 만드는 경우가 많습니다. 느려진 요청을 재시도하면, 같은 작업이 중복 실행되어 GPU를 더 압박합니다.

  • 서버 타임아웃을 너무 짧게 두지 않기
  • 5xx 재시도는 제한적으로만
  • 스트리밍이라면 idle timeout을 충분히 크게

엣지와 오리진 사이에서 520/521 같은 오류가 섞여 보이면 네트워크 타임아웃과 업스트림 로그를 같이 봐야 합니다. 이때는 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단 체크리스트가 그대로 적용됩니다.

5) 오토스케일은 "GPU 워크로드"에 맞게 지표를 잡기

CPU 기반 HPA만으로는 LLM 서빙을 잘 못 맞춥니다.

  • 가능한 지표
    • GPU utilization
    • vLLM 큐 길이(대기 시퀀스 수)
    • P95 latency 자체(커스텀 메트릭)

핵심은 "GPU가 바쁘다"가 아니라 "대기열이 늘어난다"를 스케일 트리거로 삼는 것입니다. GPU utilization은 배치 효율이 좋아지면 낮아질 수도 있어(짧은 요청 위주) 오해를 부릅니다.

관측(Observability): OOM과 P95를 분해해서 보자

튜닝은 관측 없이는 감으로 하게 됩니다. 최소한 아래를 분리해서 봐야 합니다.

  • 모델 로딩 시간, 워밍업 여부
  • 요청별 입력 토큰 수, 출력 토큰 수
  • prefill latency, decode latency
  • 큐 대기 시간(서버 내부)
  • GPU 메모리 사용량과 스파이크 시점

빠르게 재현 가능한 부하 테스트 예시

OpenAI 호환 엔드포인트에 대해 간단히 부하를 줄 수 있습니다.

curl -s http://llm-vllm.your-domain/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "your-llm",
    "messages": [{"role": "user", "content": "Explain KV cache in vLLM."}],
    "temperature": 0.2,
    "max_tokens": 256,
    "stream": false
  }'

그리고 긴 프롬프트 케이스를 별도로 쏴서 P95가 어디서 튀는지 확인합니다.

python - <<'PY'
import requests, json
long_prompt = "A" * 20000
payload = {
  "model": "your-llm",
  "messages": [{"role": "user", "content": long_prompt}],
  "temperature": 0.2,
  "max_tokens": 256,
  "stream": False,
}
r = requests.post("http://llm-vllm.your-domain/v1/chat/completions", json=payload, timeout=300)
print(r.status_code)
print(r.text[:500])
PY

이 테스트에서 OOM이 나면 max_model_lenmax_num_seqs가 과하거나, gpu_memory_utilization이 공격적으로 잡혀 있을 확률이 큽니다.

운영 체크리스트: 장애를 "예방"하는 설정 순서

1단계: 안전장치부터

  • max_model_len을 보수적으로
  • max_tokens 상한 강제
  • max_num_seqs로 동시성 상한

2단계: OOM 없는 상태에서 처리량 회복

  • gpu_memory_utilization을 조금씩 올리며 한계 탐색
  • max_num_batched_tokens로 prefill 효율 조정

3단계: P95 최적화

  • 짧은 요청/긴 요청 풀 분리
  • 오토스케일 지표를 큐 기반으로 조정
  • 재시도/타임아웃 정책 정리

결론: OOM과 P95는 같이 잡아야 한다

vLLM은 기본 성능이 높지만, 운영에서는 "KV 캐시 예산"과 "동시성"이 사실상 안정성의 전부라고 해도 과언이 아닙니다. KServe는 배포와 라우팅을 단순화해주지만, 그만큼 트래픽이 한 파드로 몰리는 순간을 만들 수 있어 OOM과 P95를 동시에 악화시킬 수 있습니다.

정리하면 다음 3줄이 핵심입니다.

  • OOM 방지는 max_model_len, max_num_seqs, gpu_memory_utilization로 상한을 먼저 잡는다
  • P95는 큐잉과 prefill 혼합이 주범이므로 워크로드를 분리하고 배치 예산을 조정한다
  • 오토스케일은 GPU 사용률보다 "대기열"과 "지연" 중심으로 설계한다

이 순서대로 접근하면, "평균은 빠른데 가끔 죽는" LLM API에서 "느리진 않지만 절대 안 죽는" API로 빠르게 이동할 수 있습니다.