지난 글에서 TGI의 아키텍처와 사용법을 살펴봤다. 이번 글은 vLLM·TGI·SGLang 등 모든 GPU 추론 엔진이 공통으로 적용하는 핵심 기술을 깊이 다룬다. Continuous Batching과 KV 캐시 관리를 이해하면 추론 엔진의 성능이 왜 전통적 방식보다 몇 배씩 높은지, 그리고 실제 서비스에서 어떻게 튜닝해야 하는지가 명확해진다.
LLM 추론의 두 단계: Prefill과 Decode
LLM 추론은 두 단계로 나뉜다. 이 구분을 이해하는 것이 배치 전략의 출발점이다.
Prefill(프리필): 입력 프롬프트 전체를 한 번의 Forward Pass로 처리한다. Transformer의 병렬 처리 덕분에 1000 토큰 프롬프트도 한 번에 처리하고, 각 레이어의 Key·Value를 계산해 KV 캐시에 저장한다. 연산량(FLOP) 바운드 구간이다.
Decode(디코드): 토큰을 한 번에 하나씩 생성한다. 각 스텝에서 이전 KV 캐시를 읽어 새 토큰의 Key·Value를 추가한다. 메모리에서 거대한 KV 캐시를 읽어야 하므로 메모리 대역폭 바운드 구간이다. 배치가 커져도 추가 연산이 거의 없기 때문에 배치를 크게 하면 처리량이 선형으로 늘어난다.
# Prefill + Decode 단계 이해 (개략적)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B")
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B")
# Prefill: 프롬프트 처리 (KV 캐시 생성)
inputs = tok("한국의 수도는", return_tensors="pt")
with torch.no_grad():
out = model(**inputs, use_cache=True)
past_kv = out.past_key_values # KV 캐시 저장
# Decode: 토큰 하나씩 생성 (KV 캐시 재사용)
next_token_logits = out.logits[:, -1, :]
next_token = next_token_logits.argmax(dim=-1, keepdim=True)
for _ in range(50):
with torch.no_grad():
out = model(next_token, past_key_values=past_kv, use_cache=True)
past_kv = out.past_key_values # KV 캐시 확장
next_token = out.logits[:, -1, :].argmax(dim=-1, keepdim=True)
정적 배치의 문제
전통적 배치에서는 배치 내 모든 요청이 동시에 시작하고 동시에 끝나야 한다. 길이가 다른 요청들을 한 배치에 넣으려면 짧은 요청에 패딩(padding)을 추가해야 한다. 100 토큰 요청과 1000 토큰 요청이 한 배치에 있으면, 100 토큰 요청 완료 후 GPU 슬롯이 비지만 1000 토큰 요청이 끝날 때까지 다음 요청을 넣을 수 없다.
Continuous Batching: Iteration-level Scheduling
Continuous Batching(Yu et al., 2022)의 핵심 아이디어는 각 Forward Pass(iteration) 단위로 배치를 재구성하는 것이다. 한 iteration이 끝난 후:
- 완료된 요청(EOS 토큰 생성 또는 max_tokens 도달)을 배치에서 제거
- 대기 큐에서 새 요청을 빈 슬롯에 추가
- 다음 iteration 실행
이를 통해 GPU가 항상 최대 배치로 가동된다. 짧은 요청은 완료 즉시 새 요청으로 대체되므로 지연도 최소화된다.
# Continuous Batching 개념 (의사코드)
class ContinuousBatcher:
def __init__(self, max_batch_size, model):
self.active = [] # 현재 처리 중인 요청들
self.waiting = [] # 대기 중인 요청들
self.model = model
self.max_batch = max_batch_size
def step(self):
# 1. 완료된 요청 제거
self.active = [req for req in self.active
if not req.is_done()]
# 2. 빈 슬롯에 대기 요청 추가
while len(self.active) < self.max_batch and self.waiting:
self.active.append(self.waiting.pop(0))
# 3. 한 번의 Forward Pass로 모든 활성 요청 처리
if self.active:
self._batch_forward(self.active)
def _batch_forward(self, requests):
# Prefill과 Decode 요청이 섞일 수 있음
# 각 요청의 상태(prefill/decode)에 따라 처리
for req in requests:
if req.is_prefill:
req.process_prefill(self.model)
else:
req.process_decode(self.model)
KV 캐시: 추론의 핵심 메모리 자원
Transformer의 각 레이어는 모든 이전 토큰의 Key·Value를 저장한다. 이 KV 캐시가 메모리를 얼마나 차지하는지 계산해보면 놀랍다.
Llama-3.1-8B를 배치 32, 시퀀스 8192로 서빙하면 KV 캐시만 34GB다. 모델 가중치(BF16 기준 16GB)보다 훨씬 크다. 이것이 LLM 서빙에서 GPU 메모리 관리가 중요한 이유다.
PagedAttention: 가상 메모리로 KV 캐시 관리
vLLM이 도입한 PagedAttention은 OS의 가상 메모리 페이징을 KV 캐시에 적용한다.
- 물리 블록(Physical Block): 고정 크기(예: 16 토큰)의 KV 캐시 단위
- 논리-물리 매핑 테이블: 각 요청의 논리 슬롯이 어떤 물리 블록을 가리키는지 저장
- 동적 할당: 요청이 새 토큰을 생성할 때 필요한 물리 블록만 할당
- 블록 공유: 같은 시스템 프롬프트를 가진 요청들이 물리 블록을 공유 (Prefix Caching)
from vllm import LLM, SamplingParams
# PagedAttention + Prefix Caching 활성화
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
enable_prefix_caching=True, # 공통 프리픽스 KV 재사용
gpu_memory_utilization=0.90,
max_num_seqs=256, # 최대 동시 시퀀스 수
max_model_len=8192,
kv_cache_dtype="fp8", # KV 캐시를 FP8로 양자화 (메모리 50% 절감)
)
# 동일 시스템 프롬프트로 여러 요청 (Prefix Caching 효과)
system = "당신은 한국어 전문가입니다. 항상 상세히 설명하세요."
prompts = [
f"{system}\n\n한글 맞춤법이란?",
f"{system}\n\n존댓말의 종류는?",
f"{system}\n\n사투리의 특징은?",
]
results = llm.generate(prompts, SamplingParams(max_tokens=200))
Prefix Caching: 공통 프롬프트 재사용
RAG나 에이전트처럼 긴 시스템 프롬프트가 반복되는 경우, Prefix Caching으로 프리필 비용을 크게 줄일 수 있다.
# SGLang의 Radix Cache (더 발전된 Prefix Caching)
# 트리 구조로 공통 프리픽스를 공유
# vLLM에서 Prefix Caching 효과 측정
import time
# 첫 번째 호출: Prefix Cache Miss (프리필 비용 발생)
start = time.time()
_ = llm.generate([f"긴_시스템_프롬프트...\n\n질문 1"], SamplingParams(max_tokens=100))
first_latency = time.time() - start
# 두 번째 호출: Prefix Cache Hit (프리필 비용 없음)
start = time.time()
_ = llm.generate([f"긴_시스템_프롬프트...\n\n질문 2"], SamplingParams(max_tokens=100))
cached_latency = time.time() - start
print(f"첫 번째: {first_latency:.2f}s")
print(f"캐시 히트: {cached_latency:.2f}s")
# 캐시 히트 시 TTFT(첫 토큰 지연)가 80% 이상 감소
GQA: KV 캐시 크기 자체를 줄이기
Grouped Query Attention(GQA)은 여러 Query 헤드가 하나의 KV 헤드를 공유한다. Llama-3 계열이 GQA를 채택해 KV 캐시 크기를 MHA 대비 크게 줄였다.
MHA (Multi-Head Attention): n_heads = n_kv_heads
→ 32 Query 헤드, 32 KV 헤드
→ KV 캐시 = 2 × 32 × head_dim × seq_len
GQA (Llama-3.1-8B): n_kv_heads = 8, n_heads = 32
→ 32 Query 헤드, 8 KV 헤드 (4 Query가 1 KV 공유)
→ KV 캐시 = 2 × 8 × head_dim × seq_len (4배 절감)
실전 vLLM 배치 튜닝
from vllm import LLM, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
# 고처리량 서빙 설정
engine_args = AsyncEngineArgs(
model="meta-llama/Llama-3.1-8B-Instruct",
# 메모리
gpu_memory_utilization=0.92,
kv_cache_dtype="fp8", # KV 캐시 FP8 양자화
enable_prefix_caching=True, # Prefix 재사용
# 배치
max_num_seqs=512, # 최대 동시 시퀀스
max_num_batched_tokens=32768, # 배치당 최대 토큰
# 모델
quantization="awq", # 가중치 AWQ
max_model_len=8192,
# 속도
enable_chunked_prefill=True, # 긴 프리필을 청크로 나눔
)
처리량 vs 지연 트레이드오프
배치 전략에는 항상 처리량과 지연의 트레이드오프가 있다.
| 설정 | 처리량 | 지연(P99) | 적합한 상황 |
|---|---|---|---|
| 배치 크기 ↑ | ↑ | ↑ | 오프라인 배치 처리 |
| 배치 크기 ↓ | ↓ | ↓ | 실시간 챗봇 |
| max_tokens ↑ | ↑ (배치 효율) | ↑ | 긴 문서 생성 |
| Prefix Cache | ↑ | ↓ (캐시 히트 시) | RAG·에이전트 |
| KV FP8 | ↑ (배치 더 가능) | ≈ 동등 | 대부분의 경우 |
정리
LLM 추론 최적화의 핵심 기술을 요약하면:
- Continuous Batching: 슬롯 완료 즉시 새 요청 투입 → GPU 100% 가동
- PagedAttention: OS 페이징으로 KV 캐시 단편화 제거 → 배치 크기 2~4× 증가
- Prefix Caching: 반복 프롬프트 KV 재사용 → TTFT 80% 감소
- GQA: KV 헤드 수 줄이기 → KV 캐시 4× 절감
- KV FP8: KV 캐시 양자화 → 메모리 50% 절감
모델 최적화(양자화·증류·프루닝·투기적 디코딩)와 추론 엔진(vLLM·TGI·llama.cpp·Ollama·배치 전략)을 모두 이해했다면, 실제 서비스에서 LLM을 비용 효율적으로 운영할 수 있는 기반이 마련된 것이다.
지난 글: TGI 완전 가이드: Hugging Face의 프로덕션급 LLM 서빙
읽어주셔서 감사합니다. 😊

