멀티 레플리카 LLM의 캐시 딜레마 — LMCache + llm-d로 클러스터 전체에 KV 캐시 깔기
vLLM의 Automatic Prefix Caching을 켜놨는데, 스케일 아웃을 하고 나니 오히려 응답이 느려지는 상황을 겪어보신 적 있나요? 저도 처음엔 설정 문제인 줄 알았는데, 알고 보니 구조적인 문제였습니다. 로드 밸런서가 요청을 무작위로 분산하면 각 Pod가 자기 KV 캐시를 고립 관리하게 되고, 캐시 지역성(locality)이 완전히 무너져버립니다. Pod를 늘릴수록 캐시 히트율은 오히려 1/N으로 수렴하는 역설이 생기는 거죠.
이 글에서는 LMCache와 llm-d를 조합해 KV 캐시를 클러스터 레벨로 외부화하고, 캐시 인식 라우팅으로 TTFT(Time To First Token)를 대폭 줄이는 분산 Prefix Caching 아키텍처를 단계적으로 살펴봅니다. 개념부터 실전 구성까지 한 흐름으로 풀어봤으니, RAG 파이프라인부터 멀티테넌트 SaaS까지 바로 적용해보실 수 있을 겁니다.
한 가지 전제: 이 글은 Kubernetes와 vLLM을 이미 운영 중이거나 기본 지식이 있는 분을 대상으로 합니다. LLM 서빙 인프라가 처음이라면 vLLM 공식 문서부터 먼저 살펴보시는 게 좋습니다.
핵심 개념
KV 캐시가 왜 단일 인스턴스에 갇히는가
Transformer 모델이 추론할 때, 어텐션 레이어는 각 토큰에 대해 Key·Value 행렬을 계산합니다. 이걸 매번 다시 계산하면 엄청난 낭비죠. 그래서 같은 prefix(프롬프트의 앞부분)가 반복되면 저장해둔 행렬을 재활용하는 KV 캐시가 등장했습니다.
vLLM은 이 KV 캐시를 PagedAttention으로 관리합니다. GPU 메모리를 가상 메모리처럼 페이지 단위로 쪼개서, 고정된 prefix(시스템 프롬프트, RAG 문서 등)를 처음 한 번만 계산하고 이후 요청에서는 페이지 단위로 꺼내 씁니다. 단일 Pod에서는 매우 잘 동작합니다.
문제는 Pod를 여러 개 띄우는 순간 시작됩니다.
[요청 A] ──▶ [Pod 1] (캐시 히트 ✓)
[요청 B] ──▶ [Pod 2] (캐시 미스 ✗, 재계산)
[요청 C] ──▶ [Pod 1] (캐시 히트 ✓)
[요청 D] ──▶ [Pod 3] (캐시 미스 ✗, 재계산)동일한 시스템 프롬프트를 담은 요청이 다른 Pod로 라우팅되면, 그 Pod는 캐시가 없으니 처음부터 계산합니다. Pod를 늘릴수록 캐시 히트율은 오히려 1/N으로 수렴합니다.
분산 Prefix Caching의 세 가지 축
클러스터 레벨 KV 캐싱은 세 가지 메커니즘이 맞물려 동작합니다.
| 축 | 역할 | 담당 컴포넌트 |
|---|---|---|
| KV 캐시 외부화 | GPU HBM 밖(CPU 메모리, SSD, 원격 스토리지)으로 KV 블록 오프로드 | LMCache |
| 캐시 인식 라우팅 | prefix 해시를 보고 해당 KV 블록을 가진 Pod로 요청 전달 | llm-d EPP |
| Prefill/Decode 분리 | 프롬프트 처리와 토큰 생성을 독립 GPU에서 실행, KV를 네트워크로 전달 | LMCache + llm-d |
TTFT(Time To First Token): 요청을 보낸 후 첫 번째 토큰이 도착하기까지의 시간. 캐시 히트율에 가장 직접적으로 영향을 받습니다.
LMCache — GPU 밖의 KV 캐시 레이어
LMCache는 vLLM·SGLang 엔진의 KV 캐시를 GPU 외부로 꺼내는 레이어입니다. 2025년 PyTorch 공식 에코시스템에 편입됐고, 현재 8종의 스토리지 백엔드를 지원합니다.
GPU HBM (L1)
│ 캐시 미스 시
▼
CPU DRAM (L2, LMCache 관리)
│ 캐시 미스 시
▼
NVMe / 원격 스토리지 (L3)
(NFS, S3, InfiniStore, Mooncake Store, Valkey 등)# vLLM + LMCache 기본 연동
# pip install lmcache vllm
from lmcache.config import LMCacheEngineConfig
from lmcache.integration.vllm import init_lmcache_engine
# CPU 메모리를 L2 캐시로 사용하는 기본 구성
config = LMCacheEngineConfig.from_dict({
"chunk_size": 256, # 청크 1개 = 256 토큰 = KV 블록 1개
"local_device": "cpu", # CPU DRAM을 로컬 캐시로
"remote_url": "redis://cache-server:6379", # Valkey/Redis 공유 캐시
"remote_serde": "cachegen", # 압축 직렬화 방식
})
init_lmcache_engine(config)
# 이후 vLLM 엔진은 평소와 동일하게 사용
# LMCache가 투명하게 KV 캐시 관리를 가로챔llm-d — Kubernetes 기반 캐시 인식 라우팅
llm-d는 IBM Research·Red Hat·Google Cloud가 2026년 3월 KubeCon Europe에서 CNCF Sandbox에 기증한 프로젝트입니다. AMD, NVIDIA, Hugging Face 등이 공동 지원하고 있고, Kubernetes 기반 LLM 서빙의 표준 인프라로 자리잡고 있습니다.
핵심은 **EPP(Endpoint Picker)**라는 캐시 인식 라우터입니다.
[클라이언트 요청]
│
▼
[llm-d Gateway (Kubernetes Gateway API)]
│
▼
[EPP — Endpoint Picker]
├─ 요청의 prefix 해시 계산
├─ KV 블록 인덱스 조회 (llm-d-kv-cache)
└─ 해당 블록을 가진 Pod로 라우팅
│
▼
[vLLM Pod (캐시 히트!)]EPP는 단순한 로드 밸런서가 아닙니다. 각 Pod의 KV 블록 보유 현황을 실시간으로 추적하고, prefix 해시가 일치하는 Pod에 우선 배정합니다. 캐시가 없을 때만 부하 분산 기준으로 폴백합니다.
실전 적용
개념을 이해했다면 이제 실제 구성으로 넘어가 봅니다. 예시 세 가지가 서로 연결되는 구조라서, 순서대로 따라가면 전체 그림이 보일 겁니다.
예시 1: RAG 파이프라인에서 문서 KV 캐시 공유
수백 페이지 기업 문서를 시스템 프롬프트에 넣는 RAG 서비스가 있다고 가정해봅니다. 동일 문서를 참조하는 요청이 하루에 수천 건 들어오는 상황이에요. 이때 LMCache + 공유 스토리지 조합이 매우 효과적입니다.
# lmcache-config.yaml
# 모든 vLLM Pod가 동일한 원격 캐시를 바라봄
chunk_size: 512 # 청크 1개 = 512 토큰 = KV 블록 1개
local_device: "cpu"
max_local_cache_size: 20 # GB, CPU 메모리 할당량
# NFS 마운트 경로를 공유 KV 캐시로 활용
remote_url: "file:///mnt/nfs/lmcache"
remote_serde: "safetensor"# RAG 서버 — 문서 KV 프리워밍 스크립트
# pip install lmcache vllm
import asyncio
from vllm import AsyncLLMEngine, AsyncEngineArgs, SamplingParams # vLLM 0.6+
async def prewarm_document_kv(doc_text: str, engine: AsyncLLMEngine):
"""
문서를 한 번 처리해 KV 캐시를 외부 스토리지에 올려두는 함수.
이후 동일 prefix를 가진 요청은 재계산 없이 캐시 히트.
"""
system_prompt = f"다음 문서를 기반으로 답변하세요:\n\n{doc_text}\n\n질문: "
# 더미 요청을 통해 prefix KV 생성 — LMCache가 사이드 이펙트로 KV를 외부 스토리지에 저장
# max_tokens=1이므로 실제 응답 생성 비용은 최소화됨
params = SamplingParams(max_tokens=1)
async for _ in engine.generate(system_prompt, params, request_id="prewarm"):
pass
print(f"[KV Prewarm] {len(doc_text)} chars → 캐시 저장 완료")
# 이후 실제 요청에서는 같은 prefix가 LMCache에서 즉시 로드됨| 구성 요소 | 역할 |
|---|---|
chunk_size: 512 |
KV 블록을 512 토큰 단위로 저장. 긴 문서일수록 히트 범위 넓어짐 |
remote_url: "file:///mnt/nfs/..." |
NFS로 마운트된 공유 스토리지. 모든 Pod가 동일 캐시 접근 |
prewarm_document_kv() |
서비스 시작 시 핵심 문서를 미리 캐싱해 콜드 스타트 제거 |
저도 처음엔 반신반의했는데, 수백 페이지짜리 약관 문서를 계속 재계산하던 서비스에 적용하니 체감이 확실히 됐습니다. 동일 문서 반복 쿼리 기준 TTFT가 3~10배 줄어드는 걸 확인할 수 있었습니다.
예시 2: llm-d로 Kubernetes 클러스터에 캐시 인식 라우팅 구성
위 LMCache 설정으로 공유 캐시가 갖춰졌다면, llm-d EPP를 붙여 캐시 인식 라우팅까지 추가해봅니다.
# llm-d 기반 InferencePool + EPP 배포 예시
apiVersion: inference.networking.x-k8s.io/v1alpha2
kind: InferencePool
metadata:
name: llama-pool
spec:
targetPortNumber: 8000
selector:
matchLabels:
app: vllm-worker
extensionRef:
name: llm-d-epp # EPP가 캐시 인식 라우팅 수행
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-d-epp
spec:
template:
spec:
containers:
- name: epp
# 실제 배포에서는 latest 대신 고정 버전 태그를 사용하는 것을 권장합니다
image: ghcr.io/llm-d/llm-d-inference-scheduler:v0.5.0
env:
- name: KV_CACHE_AWARE_ROUTING
value: "true"
- name: PREFIX_HASH_ALGO
value: "sha256"
- name: KVBLOCK_INDEX_ENDPOINT
value: "http://kv-index-service:9090"# vLLM Worker Pod — LMCache 연동 포함
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-worker
spec:
replicas: 4
template:
spec:
containers:
- name: vllm
image: vllm/vllm-openai:v0.6.0
args:
- "--model=meta-llama/Llama-3.1-8B-Instruct"
- "--enable-prefix-caching"
- "--kv-transfer-config"
# kv_transfer_config를 JSON 직렬화해서 CLI 인자로 전달
- '{"kv_connector":"LMCacheConnector","kv_role":"kv_both"}'
volumeMounts:
- name: lmcache-config
mountPath: /etc/lmcache
volumes:
- name: lmcache-config
configMap:
name: lmcache-cfg이 구성에서 요청이 들어오면 EPP가 prefix 해시를 계산하고, 해당 해시에 매칭되는 KV 블록을 가진 Worker Pod를 선택합니다. 캐시가 없으면 일반 로드 밸런싱으로 폴백하고, 처리 후 LMCache가 공유 스토리지에 KV를 저장해 다음 Pod도 재활용할 수 있게 됩니다.
예시 3: Prefill/Decode 분리로 GPU 활용률 최적화
캐시 인식 라우팅까지 갖춰졌다면, 긴 컨텍스트 서비스에서는 Prefill/Decode 분리를 고려해볼 수 있습니다.
Prefill(프롬프트 처리)은 수천 토큰을 한꺼번에 행렬 연산하는 compute-bound 작업이고, Decode(토큰 생성)는 토큰 하나씩 순차적으로 HBM에서 가중치를 읽어오는 memory-bound 작업입니다. 이 둘의 병목 유형이 완전히 달라서, 같은 GPU에 묶어두면 Prefill이 HBM 대역폭을 잠식해 Decode가 지연되고, 반대로 Decode가 오래 걸리면 Prefill 배치가 쌓입니다.
[클라이언트]
│
▼
[llm-d EPP]
│
├──▶ [Prefill Pod (A100 × 4)] ← 긴 프롬프트 처리 특화 (compute-bound)
│ │
│ KV 블록 배치 전송 (LMCache)
│ │
└──▶ [Decode Pod (A100 × 2)] ← 토큰 생성 특화 (memory-bound)# Prefill/Decode 역할 설정 예시
# 실제로는 vLLM 실행 시 --kv-transfer-config CLI 인자로 JSON을 넘깁니다
import os
os.environ["LMCACHE_USE_EXPERIMENTAL_FEATURES"] = "True"
# Prefill Pod 설정 — KV를 생성하고 Decode Pod로 전송
kv_transfer_config_prefill = {
"kv_connector": "LMCacheConnector",
"kv_role": "kv_producer", # KV 생산자
"kv_connector_port": 8100,
"kv_connector_host": "0.0.0.0",
}
# Decode Pod 설정 — KV를 수신하고 토큰 생성 시작
kv_transfer_config_decode = {
"kv_connector": "LMCacheConnector",
"kv_role": "kv_consumer", # KV 소비자
"kv_connector_port": 8100,
"kv_connector_host": "prefill-service", # Prefill Pod 서비스 주소
}
# vLLM 실행 예시 (Prefill Pod):
# vllm serve meta-llama/Llama-3.1-8B-Instruct \
# --kv-transfer-config '{"kv_connector":"LMCacheConnector","kv_role":"kv_producer",...}'세 구성을 합치면 이런 요청 흐름이 됩니다.
[클라이언트 요청]
│
▼
[llm-d EPP] ← prefix 해시로 캐시 보유 Pod 확인 (예시 2)
│
▼
[Prefill Pod] ← 프롬프트 처리 후 KV 생성 (예시 3)
│
│ LMCache → NFS/Valkey 공유 스토리지에 KV 저장 (예시 1)
│
▼
[Decode Pod] ← KV 수신 후 토큰 생성 시작
│
▼
[클라이언트 응답]llm-d의 16×16 B200 토폴로지(Prefill 16노드, Decode 16노드) 벤치마크에서 round-robin 대비 TTFT가 한 자릿수 배율로 줄었다는 결과가 있습니다. 이 규모의 클러스터를 가진 곳은 많지 않겠지만, 원리는 소규모에도 그대로 적용됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 비용 절감 | 캐시 히트 시 토큰 처리 비용 최대 1/10 (예: $3.00 → $0.30 / 1M 토큰) |
| TTFT 단축 | llm-d 공식 벤치마크 기준 캐시 히트 요청 sub-400ms, 최대 85% 감소 |
| 수평 확장 | 새 Pod 투입 시 외부 캐시에서 즉시 재활용, 워밍업 불필요 |
| 장애 복원력 | Pod 재시작 후에도 외부 스토리지의 KV 캐시 생존 |
| 처리량 향상 | llm-d 벤치마크 기준 동일 하드웨어에서 최대 2배 처리량 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 네트워크 오버헤드 | KV 전송 비용이 캐시 절약보다 클 수 있음 | InfiniStore/Mooncake(RDMA) 등 고속 스토리지 평가 후 도입 |
| LRU 한계 | 단순 LRU는 에이전트 워크플로 같은 near-future 재사용 예측 불가 | 워크로드 패턴 분석 후 캐시 정책 튜닝 |
| 메모리 티어 복잡성 | GPU HBM→CPU DRAM→NVMe→원격 스토리지 계층 관리 | 단계적 도입 — 먼저 CPU DRAM만 활성화 후 확장 |
| 보안 취약점 | TTFT 측정으로 타 테넌트 프롬프트 추론 가능 (Cache Side-Channel) | 멀티테넌트 환경에서 테넌트별 캐시 격리 필수 |
| 운영 복잡성 | KVEvents 스트리밍, 블록 인덱스 동기화 등 추가 컴포넌트 관리 | 모니터링 대시보드 구성 및 알림 임계치 사전 설정 |
| 캐시 무효화 | 모델 업데이트, LoRA 전환 시 전체 캐시 무효화 | 배포 파이프라인에 캐시 플러시 훅 추가 |
Cache Side-Channel 공격: 멀티테넌트 환경에서 캐시 히트/미스에 따른 응답 시간 차이를 측정해 다른 사용자의 프롬프트 내용을 역추론하는 공격입니다. CacheSolidarity 논문에서 자세한 분석을 확인할 수 있습니다.
RDMA(Remote Direct Memory Access): CPU를 거치지 않고 서버 간 메모리를 직접 읽고 쓰는 기술. KV 블록 전송 지연을 마이크로초 단위로 줄여줍니다.
실무에서 가장 흔한 실수
제가 직접 겪은 것도 있고, 팀에서 자주 보이는 패턴도 있습니다.
-
공유 스토리지 지연을 사전에 측정하지 않는 것 — NFS나 S3 지연이 수십 ms를 넘기면 캐시 히트가 오히려 손해입니다. 도입 전
fio등으로 실제 지연 특성을 먼저 측정해보는 게 좋습니다. 스토리지 선택에 따라 결과가 완전히 달라집니다. -
모든 워크로드에 동일한 chunk_size를 적용하는 것 — 짧은 대화형 쿼리에는 작은 청크(청크 1개 = 128
256 토큰), 긴 RAG 문서에는 큰 청크(청크 1개 = 5121024 토큰)가 효율적입니다. 워크로드별로 튜닝 값이 다를 수 있으니 측정해보면서 조정해보시면 좋습니다. -
멀티테넌트 환경에서 캐시 격리 없이 배포하는 것 — 테넌트 ID를 prefix 해시에 포함시키지 않으면 캐시가 공유될 뿐 아니라 위에서 언급한 Cache Side-Channel 보안 취약점이 생깁니다. EPP 설정에서 테넌트 격리 옵션을 활성화해두는 걸 권장합니다.
마치며
Pod를 늘릴수록 캐시 히트율이 떨어지는 역설 — 이건 단순 설정 문제가 아니라 KV 캐시를 단일 인스턴스 안에 가두는 구조에서 비롯됩니다. LMCache로 KV 캐시를 클러스터 외부로 꺼내고, llm-d EPP로 캐시 인식 라우팅을 붙이면 멀티 레플리카 LLM 서빙의 비용과 지연 구조가 근본적으로 달라집니다.
이미 vLLM을 운영 중이라면 진입 장벽이 생각보다 낮습니다. 클러스터 레이어까지 손대기 전에, 단일 노드에서 CPU 오프로드만 먼저 확인해봐도 효과를 체감할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
LMCache 단독 실험 (30분이면 확인 가능) — 기존 vLLM 환경에
pip install lmcache로 설치하고local_device: "cpu"구성만 추가해 단일 Pod에서 CPU 오프로드 히트율을 먼저 측정해볼 수 있습니다. LMCache 퀵스타트가 잘 정리되어 있습니다. -
공유 스토리지 연동 (NFS 또는 Valkey 환경 필요) — 히트율이 의미 있는 수준이라면 NFS나 Valkey를
remote_url로 지정해 멀티 Pod 간 KV 캐시 공유로 확장해볼 수 있습니다. llm-d 없이도 이 단계에서 캐시 공유 효과를 확인할 수 있습니다. -
llm-d EPP 도입 (Kubernetes 클러스터 준비가 선행 조건) — 캐시 공유 효과가 검증됐다면 llm-d GitHub의 Helm 차트로 EPP를 배포하고, 캐시 인식 라우팅까지 붙여 히트율을 한 단계 더 끌어올려볼 수 있습니다.
다음 글: llm-d의 KVEvents 스트리밍 프로토콜 내부 구조 — 블록 인덱스가 어떻게 실시간 동기화되는지, Go 코드 레벨에서 뜯어봅니다.
참고 자료
- KV-Cache Wins You Can See: From Prefix Caching in vLLM to Distributed Scheduling with llm-d | llm-d 공식 블로그
- llm-d 공식 아키텍처 문서 | llm-d.ai
- LMCache GitHub | LMCache/LMCache
- llm-d-kv-cache GitHub | llm-d/llm-d-kv-cache
- LMCache Joins PyTorch Ecosystem | pytorch.org
- LMCache 논문 | arXiv:2510.09665
- Welcome llm-d to the CNCF | CNCF 공식 블로그
- Donating llm-d to the CNCF | IBM Research
- llm-d officially a CNCF Sandbox project | Google Cloud Blog
- Master KV cache aware routing with llm-d | Red Hat Developer
- Introduction to distributed inference with llm-d | Red Hat Developer
- KV Caching with vLLM, LMCache, and Ceph | Ceph.io
- CacheSolidarity: Preventing Prefix Caching Side Channels | arXiv
- Disaggregated Prefill with LMCache | vLLM 공식 문서
- Cluster-scale KV caching for 9.9× faster LLM inference | Crusoe MemoryAlloy