지난 글에서 어떤 모델을 쓸지 정하는 절차를 봤다. 그 결정이 「우리가 직접 띄운다」로 끝나면 바로 다음에 오는 것이 서빙 엔진 선택이고, 지금 그 자리의 기본값에 가장 가까운 것이 vLLM이다.
vLLM이 무엇을 하는 물건인지 한 줄로 말하면 이렇다. 모델은 그대로 두고 GPU가 노는 시간을 없애는 서버. 같은 카드, 같은 가중치인데 처리량이 몇 배가 되는 일이 실제로 벌어지고, 그 이유는 두 가지 아이디어에 거의 다 들어 있다.
GPU는 왜 노는가
먼저 왜 놀 자리가 생기는지부터 봐야 한다. 생성 모델의 추론은 토큰을 하나씩 만드는 반복이고, 한 토큰을 만드는 데 필요한 계산량은 크지 않은데 모델 가중치 전체를 메모리에서 읽어야 한다. 즉 계산이 아니라 메모리 대역폭에 막힌다.
이 상황에서는 요청 하나를 혼자 처리하는 것이 가장 낭비다. 가중치를 한 번 읽는 김에 요청 열 개의 토큰을 같이 만들면, 읽는 비용은 그대로인데 열 배를 뽑는다. KV 캐시와 배치에서 다룬 이야기가 그것이다.
문제는 어떻게 묶느냐다. 소박하게 구현하면 요청이 모일 때까지 기다렸다가 한 묶음으로 돌리고, 그 묶음이 다 끝나야 다음 묶음을 받는다. 그런데 같은 묶음 안의 요청들은 답 길이가 제각각이다. 하나가 800토큰을 뽑는 동안 30토큰에 끝난 아홉 개의 자리가 비어 있는 채로 함께 돈다.
두 가지 아이디어
vLLM이 하는 일은 이 낭비를 두 방향에서 없애는 것이다.
첫째, 배치를 토큰 한 걸음마다 다시 짠다. 묶음이 끝나기를 기다리지 않고, 토큰 한 번 만들 때마다 끝난 요청을 빼고 대기 중인 요청을 그 자리에 넣는다. 이것을 연속 배칭(continuous batching)이라고 부른다 — 배치가 고정된 묶음이 아니라 매 걸음 갈아 끼우는 자리 집합이 된다. 앞의 예에서 30토큰짜리 아홉 개가 끝나는 즉시 새 요청 아홉 개가 들어오므로 빈자리가 안 생긴다.
둘째, KV 캐시를 작은 블록으로 쪼갠다. 생성 중인 요청은 지금까지의 토큰에 대한 KV 캐시를 들고 있어야 하는데, 이게 요청당 수백 메가바이트씩 되고 얼마나 길어질지 미리 모른다. 그래서 소박한 구현은 「최대 길이만큼」을 미리 잡아 둔다. 4,096토큰 자리를 잡아 놓고 실제로는 200토큰만 쓰면 나머지가 통째로 놀고, 그만큼 동시에 받을 수 있는 요청 수가 줄어든다.
vLLM은 이 캐시를 16토큰쯤의 작은 블록으로 나누고, 요청이 길어질 때마다 빈 블록을 하나씩 빌려준다. 블록들이 메모리에서 연속일 필요가 없다 — 운영체제의 가상 메모리와 같은 발상이라 PagedAttention이라는 이름이 붙었다. 결과적으로 낭비되는 메모리가 거의 사라지고, 같은 카드에 동시에 올릴 수 있는 요청 수가 크게 는다.
둘은 서로를 돕는다. 연속 배칭이 자리를 계속 갈아 끼우려면 메모리에 여유가 있어야 하고, 그 여유를 만들어 주는 것이 블록 방식이다.
띄우고 부르기
실제로 쓸 때는 OpenAI 호환 서버로 띄우는 것이 가장 흔하다.
vllm serve Qwen/Qwen3-8B-Instruct \
--host 0.0.0.0 --port 8000 \
--gpu-memory-utilization 0.90 \
--max-model-len 8192 \
--max-num-seqs 128 \
--enable-prefix-caching
띄우고 나면 기존 OpenAI 클라이언트를 주소만 바꿔 그대로 쓴다.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="Qwen/Qwen3-8B-Instruct",
messages=[{"role": "user", "content": "연속 배칭을 두 문장으로 설명해 줘"}],
max_tokens=256,
)
print(resp.choices[0].message.content)
이 호환성이 실무에서 생각보다 큰 이득이다. 서빙 API 설계를 새로 하지 않아도 되고, 상용 API로 돌리던 것을 자체 모델로 옮기거나 되돌리는 일이 주소 한 줄 바꾸기가 된다.
실제로 만지게 되는 설정
옵션은 많지만 처음에 손대게 되는 것은 대여섯 개고, 각각 부작용이 뚜렷하다.
| 설정 | 하는 일 | 올리면 생기는 일 |
|---|---|---|
--gpu-memory-utilization |
GPU 메모리를 몇 할까지 쓸지 | 동시 처리량이 늘지만 여유가 없어 순간 부하에 실패 |
--max-model-len |
한 요청의 최대 길이 | 요청당 최대 블록이 늘어 동시 요청 수가 줄어든다 |
--max-num-seqs |
한 걸음에 함께 도는 요청 수 상한 | 처리량은 늘고 요청 하나의 응답은 느려진다 |
--tensor-parallel-size |
카드 몇 장에 모델을 쪼갤지 | 큰 모델이 올라가지만 카드 간 통신이 붙는다 |
--enable-prefix-caching |
같은 앞부분의 KV를 재사용 | 앞부분이 겹치는 부하에서 큰 이득, 아니면 무의미 |
--quantization |
가중치를 줄여 올림 | 메모리가 줄지만 품질을 따로 재야 한다 |
--gpu-memory-utilization이 가장 자주 오해받는다. 이 값은 vLLM이 쓸 총량이지 캐시만의 몫이 아니다. 가중치를 올리고 남은 자리가 블록 풀이 되므로, 큰 모델일수록 같은 비율에서 실제 블록 수가 적다. 0.95처럼 올려 두면 평소엔 잘 돌다가 긴 요청이 몇 개 겹치는 순간 요청이 대기열에서 밀린다. 0.85~0.90에서 시작해 실측하며 올리는 편이 안전하다.
--max-model-len을 모델의 최대치로 두는 것도 흔한 실수다. 실제 트래픽의 99백분위가 6,000토큰인데 128K로 잡아 두면, 그 한도가 요청당 예약 상한이 되어 동시 처리량을 깎는다. 로그에서 길이 분포를 보고 정한다.
접두 캐싱(--enable-prefix-caching)은 켤지 말지가 부하 모양에 달렸다. 긴 시스템 프롬프트를 공유하는 챗봇이나 같은 앞부분을 반복해 보내는 에이전트 루프에서는 효과가 크고, 요청마다 앞부분이 전부 다르면 이득 없이 관리 비용만 붙는다.
처리량과 지연은 같이 못 올린다
vLLM 튜닝에서 결국 마주치는 것은 이 트레이드오프다. 한 배치에 요청을 많이 넣을수록 초당 총 토큰 수는 오르고, 개별 요청의 첫 토큰까지 걸리는 시간과 토큰 간격은 나빠진다.
그래서 「vLLM이 몇 배 빠른가」는 답이 없는 질문이고, 재야 하는 것은 셋이다.
- 첫 토큰까지의 시간 — 사용자가 기다리는 체감 시간
- 토큰 간 간격 — 스트리밍이 끊겨 보이는지
- 초당 총 출력 토큰 — 같은 카드로 몇 명을 받는지
사용자가 화면 앞에서 기다리는 서비스면 앞의 둘에 상한을 걸고 그 안에서 셋째를 최대로 올린다. 밤에 도는 일괄 처리면 셋째만 보면 된다. 같은 엔진을 두 용도로 함께 쓰면 둘 다 어중간해지므로, 부하 성격이 다르면 인스턴스를 나누는 편이 낫다.
언제 vLLM이 아닌가
기본값으로 삼을 만하다는 것이지 항상 맞다는 뜻은 아니다.
- 동시 요청이 거의 없는 경우 — 연속 배칭이 벌어 주는 것이 없다. 노트북이나 단일 사용자 환경이면 llama.cpp와 Ollama가 설치와 운영이 훨씬 가볍다
- GPU가 없는 경우 — vLLM은 GPU를 전제로 설계됐다
- 모델이 아주 작은 경우 — 메모리가 병목이 아니면 이득이 작다
- 직접 운영할 이유가 없는 경우 — 트래픽이 적으면 상용 API가 총비용에서 거의 항상 싸다. 자체 서빙은 물량이 일정 수준을 넘거나 데이터를 밖으로 못 낼 때부터 유리해진다
마지막 줄이 실무에서 가장 자주 건너뛰는 판단이다. 엔진을 고르기 전에 애초에 직접 띄우는 것이 맞는지를 먼저 계산한다.
정리
- 생성 추론은 계산이 아니라 메모리 대역폭에 막힌다. 그래서 여러 요청을 함께 도는 것이 이득이다
- 고정 배치는 답 길이가 제각각이라 끝난 자리가 비어 있는 채로 함께 돈다
- 연속 배칭은 토큰 한 걸음마다 배치를 다시 짜서 그 빈자리를 없앤다
- PagedAttention은 KV 캐시를 작은 블록으로 쪼개 미리 크게 잡아 두는 낭비를 없앤다
- 둘은 서로를 돕는다. 자리를 갈아 끼우려면 메모리 여유가 필요하고 그 여유를 블록 방식이 만든다
- OpenAI 호환 서버라 클라이언트는 주소만 바꾸면 된다
--gpu-memory-utilization은 캐시가 아니라 총량이다. 0.85~0.90에서 시작해 실측하며 올린다--max-model-len을 모델 최대치로 두면 요청당 예약 상한이 커져 동시 처리량이 깎인다- 접두 캐싱은 앞부분을 공유하는 부하에서만 이득이다
- 처리량과 지연은 같이 못 올린다. 부하 성격이 다르면 인스턴스를 나눈다
- 동시 요청이 적거나 GPU가 없으면 다른 엔진이 낫고, 물량이 적으면 자체 서빙 자체가 손해다
읽어주셔서 감사합니다. 😊

