vLLM APC vs SGLang RadixAttention: KV 캐시 구조 차이와 워크로드별 선택 기준
LLM 추론 서버를 운영하다 보면 어느 순간 "prefill이 왜 이렇게 느리지?"라는 질문에 부딪히게 됩니다. 멀티턴 챗봇이나 RAG 파이프라인처럼 프롬프트 앞부분이 반복되는 워크로드에서는 매 요청마다 동일한 컨텍스트를 다시 계산하는 게 눈에 띄는 낭비라는 걸 금방 느낄 수 있죠.
저도 처음엔 "어차피 둘 다 접두사 캐싱 아닌가?"라며 대수롭지 않게 생각했는데, 실제로 내부 구현을 들여다보니 자료구조 선택부터 교체 정책까지 근본적인 철학이 달랐습니다. 그리고 그 차이가 워크로드 특성에 따라 의미 있는 성능 차이로 이어집니다. Particula의 2026년 벤치마크(Llama 3, H100 기준)에서 약 29%(16,200 vs 12,500 tok/s) 차이가 나타났고, 고동시성 시나리오에서는 두 배 가까운 격차가 벌어지기도 합니다.
이 글의 실제 대상은 LLM 추론 서버를 운영하거나 도입을 검토 중인 백엔드·ML 엔지니어입니다. 이 글을 읽고 나면 두 프레임워크의 캐시 구현 원리와 내부 자료구조 차이를 이해하고, 자신의 서빙 환경에서 어느 쪽이 더 유리한지 데이터 기반으로 판단할 수 있게 됩니다.
한눈에 보는 선택 기준
- 멀티턴 대화 / RAG / 에이전트: SGLang RadixAttention — 공유 접두사가 길고 동시성이 높을수록 유리
- Multi-LoRA / 비NVIDIA 하드웨어 / 멀티 테넌트 보안: vLLM APC — 생태계 성숙도와 하드웨어 호환성이 강점
- 접두사 중복도 60% 미만 독립 배치: 두 방식 모두 효과가 제한적, 도입 전 트래픽 분석을 먼저 해보시는 것이 좋습니다
핵심 개념
KV 캐시부터 간단히 짚고 넘어가면, Transformer의 어텐션 레이어는 각 토큰을 처리할 때 이전 토큰들의 Key/Value 행렬을 참조합니다. 이미 계산한 토큰의 K/V 행렬을 저장해두고 재사용하는 게 KV 캐시이며, 접두사 캐싱은 이 K/V 행렬을 서로 다른 요청 사이에서도 재사용하는 확장 개념입니다.
LLM 추론은 두 단계로 나뉩니다. prefill은 입력 프롬프트 전체를 한 번에 처리해 첫 토큰을 생성하는 단계이고, decode는 이전 토큰을 입력으로 삼아 다음 토큰을 하나씩 순차적으로 생성하는 단계입니다. 접두사 캐싱이 줄여주는 건 이 중 prefill 연산입니다.
vLLM APC: 해시 테이블로 블록을 관리하다
vLLM의 Automatic Prefix Caching(APC)은 GPU 메모리를 페이지 단위로 나눠 KV 캐시를 관리하는 PagedAttention 위에 자연스럽게 얹혀 있습니다. KV 캐시를 16토큰 단위의 고정 블록으로 나누고, 각 블록에 해시 키를 붙여서 전역 해시 테이블 하나로 관리하는 방식입니다.
새 요청이 들어오면 런타임은 토큰 시퀀스를 블록 단위로 잘라서 해시를 계산합니다. 해시가 테이블에 있으면 캐시 히트 — prefill 없이 재사용. 없으면 캐시 미스 — 새로 계산해서 테이블에 올립니다.
각 블록의 해시는 이전 블록들의 해시값과 현재 블록의 토큰 ID를 함께 해싱해서 만들어집니다. "위치 의존적" 키가 보장되는 구조라, 똑같은 16토큰이라도 앞에 오는 내용이 다르면 다른 키가 됩니다. vLLM v0.11부터는 해시 함수 기본값이 SHA-256으로 전환되어 이전 버전에 있던 충돌 위험이 제거되었습니다(관련 논의: GitHub Issue #16016).
# vLLM APC 활성화 (v0.11 이상 권장)
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Meta-Llama-3-8B-Instruct",
enable_prefix_caching=True,
block_size=16, # 캐시 최소 단위: 16토큰 블록
)
sampling_params = SamplingParams(temperature=0.7, max_tokens=512)
system_prompt = "당신은 코드 리뷰 전문가입니다. " * 50 # 긴 시스템 프롬프트
outputs = llm.generate([
system_prompt + "이 Python 코드를 리뷰해줘: def foo(): pass",
system_prompt + "이 Python 코드를 리뷰해줘: def bar(): return 1",
], sampling_params)
# 두 번째 요청부터 system_prompt 블록들은 캐시에서 재사용됨여기서 핵심적인 제약이 하나 있습니다. 블록 크기가 16토큰으로 고정되어 있기 때문에, 접두사 길이가 16의 배수가 아니면 마지막 불완전 블록은 캐싱이 안 됩니다. 접두사가 정확히 100토큰이라면 6개 블록(96토큰)만 캐싱되고 나머지 4토큰은 매번 재계산됩니다. 사소해 보이지만, 시스템 프롬프트 토큰 수를 16의 배수로 맞추는 습관이 실제로 캐시 히트율 차이를 만들어냅니다.
SGLang RadixAttention: 트리 하나로 모든 요청을 꿰다
SGLang의 접근법은 더 과감합니다. 현재 서버에서 처리 중인 모든 활성 요청의 KV 페이지를 래딕스 트리(Radix Tree, 압축 접두사 트리) 하나로 통합 관리합니다.
래딕스 트리는 일반 Trie와 달리 공통 접두사를 하나의 노드로 압축합니다. 일반 Trie가 글자 하나당 노드 하나라면, 래딕스 트리는 공통 부분 전체를 묶어서 하나의 노드로 저장합니다. SGLang은 이 원리를 KV 캐시에 그대로 적용해서, "공통 접두사 토큰 묶음 = 하나의 노드 + 대응하는 KV 페이지"가 되도록 설계했습니다.
요청 A: [시스템 프롬프트] + [유저: "Python 코드 리뷰해줘"]
요청 B: [시스템 프롬프트] + [유저: "JavaScript 코드 리뷰해줘"]
요청 C: [시스템 프롬프트] + [유저: "Python 코드 리뷰해줘"] + [어시스턴트 응답] + [유저: "더 설명해줘"]
래딕스 트리:
root
└── [시스템 프롬프트 KV 페이지들] ← A, B, C 모두 공유
├── [유저: "Python 코드 리뷰해줘"] ← A, C 공유
│ └── [어시스턴트 응답] → [유저: "더 설명해줘"] ← C만
└── [유저: "JavaScript 코드 리뷰해줘"] ← B만새 요청이 도착하면 런타임이 트리 루트에서 시작해 가장 긴 공통 접두사(longest prefix match) 를 탐색합니다. 매치된 노드의 KV 페이지는 즉시 재사용하고, 나머지 토큰에 대해서만 prefill을 수행합니다.
SGLang의 가장 편한 점은 RadixAttention이 기본 내장되어 있다는 것입니다. 별도 플래그 없이 서버를 띄우기만 하면 자동으로 동작합니다. 가변 길이 노드를 지원하기 때문에 블록 단위 정렬 없이 임의 길이의 접두사 매칭이 가능합니다.
# SGLang - OpenAI 호환 API (현재 권장 방식)
# 서버: python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:30000/v1",
api_key="EMPTY"
)
SYSTEM_PROMPT = "당신은 코드 리뷰 전문가입니다. ..."
# RadixAttention은 서버 내부에서 자동 동작 — 클라이언트 측 설정 불필요
for user_code in ["def foo(): pass", "def bar(): return 1"]:
response = client.chat.completions.create(
model="meta-llama/Meta-Llama-3-8B-Instruct",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"이 Python 코드를 리뷰해줘: {user_code}"}
],
max_tokens=512
)
print(response.choices[0].message.content)
# 두 번째 요청부터 SYSTEM_PROMPT 부분은 래딕스 트리에서 자동 재사용됨두 방식의 핵심 차이를 한눈에
| 항목 | vLLM APC | SGLang RadixAttention |
|---|---|---|
| 자료구조 | 전역 해시 테이블 | 래딕스 트리 |
| 캐시 최소 단위 | 16토큰 고정 블록 | 가변 길이 노드 (임의 길이 접두사 매칭) |
| 접두사 탐지 방식 | 블록 해시 비교 | 트리 최장 접두사 매칭 |
| 부분 블록 캐싱 | 불가 | 가능 |
| 교체 정책 | LRU (ref count=0) + 긴 접두사 우선 보존 | LRU 리프 노드 재귀 교체 |
| 요청 간 자동 접두사 발견 | 불가 (동일 블록 해시 필요) | 가능 (트리 탐색으로 자동 식별) |
| 기본 활성화 | --enable-prefix-caching 플래그 필요 |
기본 내장 |
실전 적용
예시 1: 멀티턴 챗봇 — SGLang이 빛나는 상황
고객 지원 챗봇을 구축한다고 상상해봅시다. 시스템 프롬프트에 회사 정책, 제품 정보, 응대 지침이 수백 토큰 분량으로 담겨 있습니다. 캐시 없이 운영하면 사용자가 대화를 이어갈수록 점점 길어지는 컨텍스트 전체를 매번 prefill해야 합니다. 10턴 대화라면 1턴 대비 훨씬 많은 prefill 비용이 누적되는 셈이죠.
# SGLang 멀티턴 챗봇 — OpenAI 호환 API 방식
from openai import OpenAI
client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")
SYSTEM_PROMPT = """
당신은 TechCorp의 고객 지원 전문가입니다.
[회사 정책 200토큰...]
[제품 정보 300토큰...]
[응대 지침 150토큰...]
""" # 총 ~650토큰
def chat(history: list, user_input: str) -> str:
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.extend(history)
messages.append({"role": "user", "content": user_input})
response = client.chat.completions.create(
model="meta-llama/Meta-Llama-3-8B-Instruct",
messages=messages,
max_tokens=256
)
return response.choices[0].message.content
# 대화 이력이 쌓일수록 래딕스 트리에서 재사용되는 범위가 자동으로 늘어남
history = []
for user_input in user_messages:
response_text = chat(history, user_input)
history.extend([
{"role": "user", "content": user_input},
{"role": "assistant", "content": response_text}
])| 대화 턴 | 재사용되는 KV 페이지 | prefill 대상 토큰 |
|---|---|---|
| 1턴 | 0% | 전체 (시스템 프롬프트 + 유저 메시지) |
| 2턴 | 시스템 프롬프트 + 1턴 이력 | 새 유저 메시지만 |
| 5턴 | 시스템 프롬프트 + 1~4턴 이력 | 새 유저 메시지만 |
| N턴 | 누적 이력 전체 | 새 유저 메시지만 |
동일 시스템 프롬프트를 사용하는 동시 사용자가 많을수록 래딕스 트리의 시스템 프롬프트 노드가 더 많이 공유됩니다. 이 시나리오에서 캐시 히트율 75~95%는 충분히 현실적인 수치입니다.
예시 2: RAG 파이프라인 — 문서 컨텍스트 재사용
RAG에서는 검색된 문서 청크들이 프롬프트 앞부분을 차지합니다. 동일한 문서가 여러 질문에 걸쳐 반복적으로 검색되는 패턴이 흔한데, 이게 접두사 캐싱과 궁합이 잘 맞습니다.
# RAG + SGLang: 캐시 친화적 프롬프트 구성
from openai import OpenAI
client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")
def rag_query(retrieved_docs: list[str], question: str) -> str:
# 검색된 문서를 시스템 메시지 앞쪽에 배치 — 캐시 재사용 극대화
context = "\n\n".join([f"문서 {i+1}: {doc}" for i, doc in enumerate(retrieved_docs)])
response = client.chat.completions.create(
model="meta-llama/Meta-Llama-3-8B-Instruct",
messages=[
{"role": "system", "content": f"다음 문서들을 참고하여 질문에 답변해 주세요:\n\n{context}"},
{"role": "user", "content": question}
],
max_tokens=512
)
return response.choices[0].message.content
# fetch_document()는 사용자 환경에 맞게 구현하는 함수입니다
popular_doc = fetch_document("python-docs-chapter3")
results = [
rag_query(retrieved_docs=[popular_doc, doc_b], question=q)
for q in questions # popular_doc 부분은 래딕스 트리에서 자동 재사용
]캐시 친화적 프롬프트 설계: 자주 반복되는 컨텍스트(시스템 프롬프트, 문서, 도구 정의)는 항상 프롬프트의 앞쪽에 두는 것이 좋습니다. 사용자 이름, 타임스탬프 같은 가변 내용이 앞에 오면 그 뒤에 오는 긴 시스템 프롬프트 전체가 캐시 미스가 됩니다.
예시 3: Multi-LoRA 서빙 — vLLM이 강한 상황
여러 팀이 각자의 파인튜닝 모델을 사용하는 플랫폼을 운영한다면 이야기가 달라집니다. vLLM의 독립 블록 구조는 LoRA 어댑터별로 KV 캐시를 분리 관리하기에 자연스러운 형태입니다.
# vLLM Multi-LoRA 서빙
from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest
llm = LLM(
model="meta-llama/Meta-Llama-3-8B-Instruct",
enable_lora=True,
enable_prefix_caching=True,
max_lora_rank=64,
)
sampling_params = SamplingParams(temperature=0.7, max_tokens=256)
lora_requests = {
"team_a": LoRARequest("team_a_lora", 1, "/models/lora/team_a"),
"team_b": LoRARequest("team_b_lora", 2, "/models/lora/team_b"),
}
outputs = llm.generate(
["팀A의 작업입니다", "팀B의 작업입니다"],
sampling_params,
lora_request=[lora_requests["team_a"], lora_requests["team_b"]]
)SGLang도 Multi-LoRA를 지원하지만, 이 영역에서는 vLLM의 성숙도와 유연성이 돋보입니다.
예시 4: 멀티 테넌트 환경의 캐시 격리 (vLLM v0.11+)
SaaS 플랫폼에서 여러 고객의 요청을 처리할 때 캐시 격리는 보안 요구사항입니다. 고객 A의 프롬프트가 고객 B의 캐시에 영향을 주어서는 안 됩니다.
# vLLM Cache Salting으로 테넌트별 캐시 격리
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Meta-Llama-3-8B-Instruct",
enable_prefix_caching=True,
)
sampling_params = SamplingParams(temperature=0.7, max_tokens=256)
def serve_tenant_request(tenant_id: str, prompt: str):
return llm.generate(
prompt,
sampling_params,
prefix_cache_salt=f"tenant:{tenant_id}", # salt 주입으로 첫 블록 해시가 달라짐
)
# 동일한 프롬프트라도 테넌트가 다르면 캐시가 공유되지 않음
shared_prompt = "공통 시스템 프롬프트..."
response_a = serve_tenant_request("tenant_001", shared_prompt)
response_b = serve_tenant_request("tenant_002", shared_prompt)장단점 분석
솔직히 장단점을 표로만 보면 맥락이 사라지기 쉽습니다. 각 프레임워크의 제약이 실무에서 언제 발목을 잡는지 설명과 함께 봐주시면 좋겠습니다.
vLLM APC
vLLM의 강점은 범용성입니다. 하드웨어 호환성, 생태계 성숙도, Multi-LoRA 지원 모두 vLLM 쪽이 앞섭니다.
| 항목 | 내용 |
|---|---|
| 광범위한 하드웨어 지원 | TPU, Trainium, Gaudi, NVIDIA 등 사실상 모든 가속기 |
| 성숙한 생태계 | 기여자 수 SGLang 대비 약 3배, OpenAI API 완전 호환 |
| Multi-LoRA 서빙 | 독립 블록 구조로 어댑터별 캐시 관리 용이 |
| 보안 격리 | cache salting으로 테넌트별 캐시 격리 (v0.11+) |
| 낮은 오버헤드 | 트리 유지 없이 해시 조회만으로 캐시 히트 확인 |
세 가지 제약이 실무에서 가장 자주 발목을 잡습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 블록 단위 캐싱 제약 | 접두사 길이가 16의 배수가 아니면 마지막 불완전 블록 누락 | 시스템 프롬프트 토큰 수를 16의 배수로 설계 |
| 동시성 한계 | 고동시성 시 처리량이 SGLang 대비 빠르게 하락 | 멀티턴·고동시성 워크로드라면 SGLang 검토 |
| 구버전 해시 충돌 위험 | v0.11 이전은 Python 내장 hash() 사용, 충돌 시 잘못된 캐시 재사용 가능 | 반드시 v0.11 이상 사용 |
SGLang RadixAttention
SGLang의 강점은 캐시 재사용의 정밀도와 고동시성 안정성입니다.
| 항목 | 내용 |
|---|---|
| 가변 길이 접두사 캐싱 | 블록 정렬 없이 임의 길이 접두사 최대 재사용 |
| 자동 접두사 발견 | 트래픽 패턴에서 공유 가능한 접두사를 명시적 설정 없이 자동 식별 |
| 고동시성 안정성 | Particula 벤치마크(Llama 3, H100 기준) — 고동시성에서 처리량 일정 유지 |
| 구조화 출력 최적화 | 압축 FSM 기반 JSON·스키마 제약 디코딩에서 속도 우위 |
| 높은 처리량 | 동일 조건에서 vLLM 대비 약 29% 높은 처리량 (16,200 vs 12,500 tok/s) |
SGLang의 제약은 주로 생태계와 오버헤드 측면입니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| NVIDIA 중심 생태계 | 비NVIDIA 하드웨어 지원이 vLLM 대비 제한적 | 비NVIDIA 환경이라면 vLLM이 현실적 선택 |
| 트리 유지 오버헤드 | 래딕스 트리 삽입·탐색·교체에 추가 CPU·메모리 비용 | 공유 접두사 없는 독립 배치 처리에는 vLLM이 유리 |
| 독립 배치에 불리 | 트래픽 간 공통 접두사가 없으면 트리가 이점을 제공하지 못함 | 접두사 중복도 측정 후 도입 여부 결정 |
실무에서 가장 흔한 실수
-
캐시 히트율을 측정하지 않고 도입하는 것: 접두사 중복도가 낮은 워크로드에 접두사 캐싱을 적용해봐야 오버헤드만 늘어납니다. 도입 전에 실제 트래픽의 접두사 분포를 먼저 확인해보시는 것이 좋습니다.
-
가변 내용을 프롬프트 앞쪽에 두는 설계: 사용자 이름, 타임스탬프, 세션 ID처럼 요청마다 달라지는 정보가 프롬프트 앞에 오면, 그 뒤에 오는 긴 시스템 프롬프트 전체가 캐시 미스가 됩니다. 고정 내용은 앞에, 가변 내용은 뒤에 배치하는 것이 핵심입니다.
-
vLLM v0.11 이전 버전을 그대로 사용하는 것: 구버전 APC는 Python 내장
hash()함수를 사용해 충돌 위험이 있습니다. 서로 다른 프롬프트가 같은 해시 키를 가질 경우 잘못된 KV 캐시를 재사용하게 되고, 미묘한 출력 오류로 이어질 수 있습니다.
마치며
두 프레임워크의 접두사 캐싱은 "같은 목표, 다른 자료구조"에서 출발합니다 — SGLang은 래딕스 트리로 토큰 수준 재사용과 자동 접두사 발견을 얻는 대신 트리 유지 오버헤드를 지불하고, vLLM은 해시 테이블로 단순성과 하드웨어 호환성을 얻는 대신 블록 단위 제약을 감수합니다.
워크로드 특성이 명확하다면 선택도 명확합니다. 멀티턴 대화, RAG, 에이전트 프레임워크처럼 공유 접두사가 길고 동시성이 높은 워크로드라면 SGLang이 실질적인 이점을 제공합니다. Multi-LoRA, 비NVIDIA 하드웨어, 보안 격리가 필요한 멀티 테넌트 환경이라면 vLLM이 더 자연스러운 선택입니다.
지금 바로 시작해볼 수 있는 3단계:
- 자신의 워크로드 접두사 중복도를 먼저 측정해보시면 좋습니다. 최근 요청 100건을 샘플링해서 아래처럼 간단히 확인할 수 있습니다. 0.6 이상이면 두 방식 모두 유의미한 효과를 기대할 수 있습니다.
from collections import Counter
def measure_prefix_overlap(prompts: list[str], prefix_len: int = 200) -> float:
prefixes = [p[:prefix_len] for p in prompts]
most_common_count = Counter(prefixes).most_common(1)[0][1]
return most_common_count / len(prompts)
overlap_rate = measure_prefix_overlap(recent_prompts)
print(f"접두사 중복도: {overlap_rate:.1%}")vLLM을 이미 사용 중이라면 /metrics 엔드포인트(Prometheus 형식)의 vllm:prefix_cache_hit_rate 메트릭으로 현재 히트율을 바로 확인할 수 있습니다.
-
vLLM 사용 중이라면
--enable-prefix-caching플래그를 추가하고 v0.11 이상으로 버전을 올려보시면 좋습니다.vllm serve meta-llama/Meta-Llama-3-8B-Instruct --enable-prefix-caching한 줄로 즉시 적용됩니다. 시스템 프롬프트 토큰 수를 16의 배수로 패딩해보시면 캐시 히트율이 더 올라가는 것을 확인할 수 있습니다. -
멀티턴이나 RAG 워크로드라면 SGLang으로 A/B 테스트를 진행해보시면 좋습니다.
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct로 서버를 띄우면 RadixAttention이 기본 적용됩니다. 동일 트래픽에서 prefill 지연 시간과 처리량 차이를 측정해보시면 어느 쪽이 실제 환경에 맞는지 데이터로 판단할 수 있습니다.
참고 자료
- Automatic Prefix Caching | vLLM 공식 문서
- vLLM APC Implementation Details (v0.6.1)
- RFC: Cache Salting for Secure Prefix Caching in vLLM | GitHub Issue #16016
- Fast and Expressive LLM Inference with RadixAttention and SGLang | LMSYS Blog
- Efficient Execution of Structured Language Model Programs | arXiv:2312.07104
- RadixAttention 개념 문서 | SGLang 공식
- Prefix Caching — SGLang vs vLLM: Token-Level Radix Tree vs Block-Level Hashing | Medium
- When to Choose SGLang Over vLLM: Multi-Turn Conversations and KV Cache Reuse | Runpod
- SGLang vs vLLM in 2026: Benchmarks, Architecture, and When to Use Each | Particula
- KV Cache Management and Prefix Caching | vLLM DeepWiki
- KV-Cache Wins: From Prefix Caching in vLLM to Distributed Scheduling with llm-d