SGLang RadixAttention: 동일 문서 블록의 KV 캐시를 재사용해 RAG 파이프라인 처리량을 5배 끌어올리는 방법
RAG 파이프라인을 프로덕션에 올려본 분이라면 한 번쯤 이런 상황을 겪어봤을 겁니다. 동일한 문서 묶음에 질문만 바꿔가며 수십 번 요청을 보내는데, GPU는 매번 똑같은 문서를 처음 보는 것처럼 처음부터 다시 계산합니다. 비용 모니터링 대시보드를 보다가 "이거 뭔가 잘못됐는데?"라고 느끼셨다면, 그 직감이 맞습니다. 저도 처음엔 "어차피 KV 캐시 있으니까 괜찮겠지"라고 생각했다가 실제로 프로파일링해보고 상당히 충격받았던 기억이 납니다.
SGLang의 RadixAttention은 프롬프트 구조만 올바르게 잡으면, 코드 변경 없이 동일 문서 블록의 KV 캐시를 자동으로 재사용해 처리량을 최대 5~6배까지 끌어올릴 수 있습니다. 이 글을 읽고 나면 3줄짜리 코드로 캐시 히트율을 직접 확인하고, 프롬프트 순서 하나를 바꿔 TTFT(Time To First Token, 첫 번째 토큰이 나올 때까지 걸리는 시간)를 눈에 띄게 줄일 수 있습니다.
LMSYS가 개발한 SGLang은 단순 단일 프롬프트 추론보다는 에이전트, RAG, 멀티턴 워크플로처럼 구조화된 LLM 프로그램을 위해 설계된 추론 프레임워크입니다. vLLM도 Automatic Prefix Caching(APC) 기능을 갖추고 있지만, SGLang의 RadixAttention과는 내부 구현 방식이 다릅니다. 그 차이와 어떤 상황에서 무엇을 선택해야 하는지는 이 시리즈의 다음 편에서 더 깊이 다룰 예정입니다.
핵심 개념
KV 캐시, 그리고 왜 재사용이 어려운가
트랜스포머 모델이 요청을 처리할 때, 입력 시퀀스 전체를 한 번에 처리하는 단계(프리필, prefill)가 먼저 실행됩니다. 이 단계에서 각 토큰의 Key-Value 어텐션 값을 계산해 메모리에 올려두고, 이후 토큰을 하나씩 생성하는 단계(디코드, decode)에서 이 값을 꺼내 쓰는 게 KV 캐시입니다.
문제는 요청이 끝나면 이 캐시가 사라진다는 점입니다. 다음 요청이 직전 요청과 동일한 문서 블록을 포함하더라도, 기존 방식에서는 처음부터 다시 계산합니다. RAG 파이프라인의 구조를 떠올려보면 이게 얼마나 낭비인지 바로 보입니다.
# 기존 방식 — 매 요청마다 전체 prefill 재계산
요청 A: [시스템 프롬프트] + [문서 1,2,3] + [질문 A] → 전체 계산
요청 B: [시스템 프롬프트] + [문서 1,2,3] + [질문 B] → 전체 재계산 (낭비)
요청 C: [시스템 프롬프트] + [문서 1,2,3] + [질문 C] → 전체 재계산 (낭비)
# RadixAttention — 공유 prefix의 KV 캐시 재사용
요청 A: [시스템 프롬프트] + [문서 1,2,3] + [질문 A] → 전체 계산 + 캐시 저장
요청 B: ↑ KV 캐시 히트 ↑ KV 캐시 히트 + [질문 B] → 질문 부분만 계산
요청 C: ↑ KV 캐시 히트 ↑ KV 캐시 히트 + [질문 C] → 질문 부분만 계산검색된 문서는 고정되어 있고 사용자 질문만 바뀌는 RAG 파이프라인의 구조가 딱 이 패턴입니다.
Radix Tree — 자동 캐시 재사용의 핵심
RadixAttention의 "Radix"는 트라이(Trie) 변형 자료구조인 Radix Tree에서 따왔습니다. SGLang 런타임은 처리한 시퀀스들을 이 트리 구조로 관리합니다.
Radix Tree: 문자열(여기서는 토큰 시퀀스)의 공통 접두사를 압축해 저장하는 트리. "apple"과 "application"은 "appl"까지 같은 노드를 공유합니다. RadixAttention에서는 토큰 시퀀스가 문자열 역할을 하고, 각 노드에 해당 구간의 KV 캐시 페이지가 붙어 있습니다.
새 요청이 들어오면 런타임이 트리를 탐색해 **최장 공통 프리픽스(Longest Common Prefix)**를 자동으로 찾아냅니다. 캐시 히트 구간은 prefill을 건너뛰고, 나머지 구간만 계산합니다. 캐시 공간이 부족해지면 LRU(Least Recently Used) 정책으로 오래된 항목을 내보냅니다.
이게 처음엔 복잡해 보이지만, 개발자 입장에서 중요한 점은 이 과정이 완전히 자동이라는 겁니다. 수동으로 캐시 키를 관리하거나 캐시 히트를 체크하는 코드를 작성할 필요가 없습니다.
HiCache — 캐시를 GPU 너머로 확장하기
2025년 SGLang v0.5+에서 도입된 HiCache는 RadixAttention을 3계층 구조로 확장합니다. GPU 메모리 한계를 넘어 훨씬 큰 캐시 풀을 확보할 수 있습니다.
| 계층 | 저장 위치 | 특성 |
|---|---|---|
| L1 | GPU HBM | 최고 속도, 가장 제한적 용량 |
| L2 | CPU Host Memory | 중간 속도, GPU 대비 수배 용량 |
| L3 | 분산 스토리지 (Mooncake, 3FS, NIXL, AIBrix) | 대용량, 클러스터 공유 가능 |
DeepSeek 3FS KVStore와 통합했을 때 캐시 히트율이 40%에서 80%로 올라가고, 세션 평균 TTFT가 56% 감소했다는 실측치가 있습니다. 클러스터 규모 RAG 서비스라면 검토해볼 만한 옵션입니다.
실전 적용
아래 예시들은 SGLang v0.3 이상 기준입니다. @sgl.function 데코레이터 API는 버전에 따라 변경될 수 있으니, 실제 적용 전에 공식 문서에서 현재 버전 호환성을 확인해보시면 좋습니다.
예시 1: 멀티 쿼리 RAG — 가장 효과가 극적인 시나리오
같은 문서 집합에 여러 질문을 던지는 상황입니다. 법률 문서 분석, 제품 매뉴얼 QA, 보고서 기반 챗봇 등이 여기 해당합니다.
import sglang as sgl
@sgl.function
def rag_query(s, documents: str, question: str):
s += sgl.system("당신은 문서 기반 QA 어시스턴트입니다.")
s += sgl.user(f"참고 문서:\n{documents}\n\n질문: {question}")
s += sgl.assistant(sgl.gen("answer", max_tokens=512))
runtime = sgl.Runtime(model_path="meta-llama/Llama-3.1-8B-Instruct")
sgl.set_default_backend(runtime)
shared_docs = """
제1조 (계약 목적) 본 계약은 갑과 을 사이의 소프트웨어 개발 용역에 관한 사항을 정한다.
제2조 (계약 기간) 계약 기간은 2024년 3월 1일부터 2024년 12월 31일까지로 한다.
제3조 (해지 조건) 계약 당사자 일방이 계약을 위반한 경우, 상대방은 30일 전 서면 통보 후 해지할 수 있다.
"""
questions = [
"이 계약서의 해지 조건은 무엇인가요?",
"위약금 조항을 설명해주세요.",
"계약 기간은 얼마나 되나요?",
]
# 첫 요청에서 shared_docs 구간의 KV 캐시가 저장되고,
# 이후 요청들은 해당 구간을 재사용합니다
results = [rag_query(documents=shared_docs, question=q) for q in questions]
for q, r in zip(questions, results):
print(f"Q: {q}")
print(f"A: {r['answer']}\n")| 코드 포인트 | 설명 |
|---|---|
@sgl.function 데코레이터 |
SGLang이 시퀀스 구조를 파악해 prefix 공유를 자동 탐지 |
sgl.system(...) → sgl.user(...) 순서 |
이 순서가 prefix 캐시 히트를 극대화하는 핵심 |
shared_docs 동일 변수 재사용 |
바이트 단위로 동일해야 캐시 히트 발생 |
sgl.gen("answer", ...) |
실제 생성 구간 — 이 부분만 매 요청마다 실행 |
예시 2: Few-Shot 분류 파이프라인
동일한 예시 블록을 수백 개 요청에 재사용하는 패턴입니다. 예시가 길수록 RadixAttention 효과가 커지고, 벤치마크에서는 캐시 히트율이 최대 99%에 도달한 사례도 있습니다. 솔직히 이 수치를 처음 봤을 때 과장 아닐까 싶었는데, 직접 few-shot 블록을 고정하고 측정해보니 납득이 됐습니다.
@sgl.function
def classify_document(s, few_shot_examples: str, target_text: str):
s += sgl.system("문서를 아래 카테고리 중 하나로 분류합니다: 계약서, 보고서, 이메일, 기타")
s += sgl.user(
f"분류 예시:\n{few_shot_examples}\n\n"
f"분류할 문서:\n{target_text}\n\n"
f"카테고리:"
)
s += sgl.assistant(sgl.gen("category", max_tokens=10))
# few_shot_examples는 고정, target_text만 바뀜
few_shots = """
[예시 1] "갑과 을은 2024년 1월 1일부터 본 계약을 체결하며..." → 계약서
[예시 2] "3분기 매출은 전년 동기 대비 12% 증가하였으며..." → 보고서
[예시 3] "Re: 다음 주 킥오프 미팅 일정 확인 부탁드립니다..." → 이메일
"""
# 실제 분류할 문서 목록
documents_to_classify = [
"본 용역 계약은 2025년 1월 1일 발효되며, 계약 기간은 1년으로 한다...",
"당분기 영업이익은 전분기 대비 8% 감소하였으며, 주요 원인은...",
"안녕하세요, 이번 주 금요일 오후 3시 회의 참석 가능하신지요...",
]
results = [
classify_document(few_shot_examples=few_shots, target_text=doc)
for doc in documents_to_classify
]예시 3: 멀티턴 대화 컨텍스트 유지
대화 히스토리가 길어질수록 이전 대화 구간의 KV 캐시를 재사용해 응답 지연을 줄일 수 있습니다. 한 가지 미리 알아두면 좋은 동작이 있는데, 매 턴마다 conversation_history 문자열이 늘어나기 때문에 이전 대화 전체가 완전히 캐시 히트되는 건 아닙니다. 직전까지 누적된 히스토리 구간은 캐시를 재사용하고, 새로 추가된 구간만 prefill을 계산합니다. 대화가 길어질수록 재사용 구간의 비중이 점점 커지는 구조입니다.
아래 코드는 동작 원리를 보여주는 단순화된 데모입니다. 프로덕션 환경에서는 FastAPI 엔드포인트나 비동기 핸들러 형태로 구현하는 편이 자연스럽습니다.
@sgl.function
def chat_turn(s, history: str, new_message: str):
s += sgl.system("당신은 친절한 고객 지원 어시스턴트입니다.")
if history:
s += sgl.user(f"이전 대화:\n{history}\n\n새 메시지: {new_message}")
else:
s += sgl.user(new_message)
s += sgl.assistant(sgl.gen("response", max_tokens=256))
# 단순화된 데모 루프 (프로덕션에서는 비동기 핸들러로 대체 권장)
conversation_history = ""
while True:
user_input = input("사용자: ")
if user_input.lower() == "quit":
break
result = chat_turn(history=conversation_history, new_message=user_input)
response = result["response"]
print(f"어시스턴트: {response}")
# 히스토리 누적 — 이전 구간은 캐시 히트 기대 가능
conversation_history += f"사용자: {user_input}\n어시스턴트: {response}\n"캐시 히트율 직접 확인하기
코드를 실행한 후, 캐시가 실제로 얼마나 작동하는지 바로 확인해볼 수 있습니다. SGLang 런타임의 /metrics 엔드포인트를 활용하면 됩니다.
import requests
# SGLang 런타임이 실행 중인 상태에서 확인
metrics_text = requests.get("http://localhost:30000/metrics").text
# cache_hit 관련 지표만 필터링
cache_lines = [line for line in metrics_text.split('\n') if 'cache_hit' in line.lower()]
for line in cache_lines:
print(line)히트율이 60% 미만이라면 프롬프트 구조(동적 값 위치, 순서)를 먼저 점검하고, 60% 이상이지만 성능 개선이 미미하다면 HiCache 도입을 검토해볼 수 있습니다.
장단점 분석
장점
- 처리량 향상: 프리픽스 공유 워크로드에서 기준 시스템 대비 최대 5~6배 처리량 향상
- TTFT 감소: 캐시 히트 구간은 prefill 연산 생략 → 첫 토큰 지연 대폭 감소
- vLLM 대비 성능: H100에서 vLLM 대비 29% 높은 처리량 (16,200 vs 12,500 tok/s)
- 자동 관리: 수동 캐시 키 관리 없이 런타임이 자동으로 탐지 및 재사용
- 메모리 효율: KV 캐시 재사용으로 더 큰 배치 크기 처리 가능
- 확장 가능성: HiCache로 GPU → CPU → 분산 스토리지까지 계층적 확장
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 워크로드 의존성 | 완전히 고유한 프롬프트만 처리하면 이점 거의 없음 | 워크로드 패턴 분석 후 도입 여부 결정 |
| 동적 값 삽입 | 타임스탬프·사용자 ID 등 변동값이 공유 구간에 섞이면 캐시 미적중 | 변동값은 반드시 프롬프트 끝부분에 배치 |
| GIL 경합 | Python 기반 라우팅으로 고동시성 환경에서 확장성 병목 가능 | 고동시성 환경에서는 벤치마크 후 적용 |
| 하드웨어 지원 범위 | TPU, Trainium, Gaudi 미지원 (vLLM 대비 좁음) | NVIDIA·AMD GPU 환경에서 사용 권장 |
| 대형 모델 효과 감소 | 70B+ 모델에서는 8B 대비 처리량 격차가 3~5%로 줄어듦 | 모델 규모별 효과 측정 후 판단 |
| HiCache 인프라 | L3 활용 시 Mooncake 등 분산 스토리지 추가 구축 필요 | 초기에는 L1·L2만 활성화해서 시작 |
GIL 경합이 ML 워크로드에서 특히 문제가 되는 이유가 있습니다. CPU 기반 Python 라우팅이 GPU 연산 준비와 병렬로 이루어지는데, 동시 요청이 많아질수록 GPU는 놀고 Python 스레드가 병목이 되는 상황이 생길 수 있습니다.
실무에서 가장 흔한 실수
실제로 SGLang을 도입해보면 생각보다 자주 이런 상황을 마주치게 됩니다.
1. 프롬프트 중간에 동적 값을 끼워 넣는 경우
SGLang의 캐시 키는 토큰 시퀀스의 바이트 단위 동일성입니다. 타임스탬프, 세션 ID, 사용자 이름처럼 매 요청마다 달라지는 값이 시스템 프롬프트나 문서 블록 중간에 들어가면, 그 지점부터 뒤에 있는 모든 토큰이 새로운 시퀀스가 됩니다. 아래 두 구조의 차이를 보면 바로 이해됩니다.
# 나쁜 예 — 타임스탬프가 중간에 들어가 그 뒤는 모두 캐시 미적중
[시스템 프롬프트] + [요청 시각: 2024-01-15 14:23:11] + [문서 1,2,3] + [질문]
# 좋은 예 — 동적 값은 맨 끝에
[시스템 프롬프트] + [문서 1,2,3] + [질문] + [요청 시각: 2024-01-15 14:23:11]2. 워크로드 분석 없이 무조건 적용하는 경우
"SGLang이 빠르다"는 말만 듣고 매 요청마다 완전히 다른 프롬프트를 보내는 서비스에 적용하면, vLLM 대비 우위가 거의 없습니다. /metrics로 캐시 히트율을 먼저 측정한 후에 최적화 방향을 결정하는 것이 훨씬 효율적입니다.
3. 캐시 히트를 확인하지 않고 다른 하이퍼파라미터를 건드리는 경우
배치 크기, 청크 크기 같은 파라미터를 조정하기 전에, 캐시가 실제로 얼마나 재사용되는지부터 확인하면 좋습니다. 히트율이 낮다면 프롬프트 구조를, 히트율이 높지만 성능 개선이 미미하다면 GPU 메모리 용량이나 HiCache 도입을 검토할 수 있습니다.
마치며
RAG 파이프라인에서 반복되는 문서 블록의 KV 캐시를 자동으로 재사용하는 것, 그게 RadixAttention이 해결하는 문제의 핵심입니다. 모델을 바꾸거나 하드웨어를 늘리기 전에, 프롬프트 구조 하나만 제대로 잡아도 처리량과 응답 속도에서 의미 있는 차이를 경험해볼 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
SGLang 설치 및 기본 동작 확인: 처음 시작한다면
pip install sglang기본 설치를 먼저 시도해보거나, Docker 이미지를 활용하는 것이 빌드 오류 없이 빠르게 시작할 수 있는 방법입니다.sglang[all]은 Flash Attention, triton 등을 모두 포함하는데 환경에 따라 빌드 오류가 날 수 있습니다. 기본 설치로 동작을 확인한 후 필요한 extras를 추가하면 진입 장벽이 낮습니다. -
프롬프트 구조 점검: 기존 RAG 프롬프트가
[시스템 프롬프트] → [검색 문서] → [사용자 질문]순서로 고정되어 있는지 확인해보는 것을 권장합니다. 동적 값이 문서 블록 앞이나 중간에 들어가 있다면 뒤로 빼는 것만으로도 캐시 히트율이 올라갑니다. -
캐시 히트율 측정 및 판단:
/metrics엔드포인트로 실제 히트율을 확인해볼 수 있습니다. 히트율이 60% 미만이라면 프롬프트 구조를 먼저 점검하고, 60% 이상이면 HiCache 도입을 검토할 차례입니다.
여러분의 RAG 워크로드에서 직접 측정해보신 적 있으신가요? 어떤 패턴에서 캐시 히트율이 가장 잘 나왔는지 댓글로 공유해주시면 좋겠습니다.
참고 자료
- Fast and Expressive LLM Inference with RadixAttention and SGLang | LMSYS Blog
- SGLang: Efficient Execution of Structured Language Model Programs (NeurIPS 2024)
- SGLang arXiv 논문 (2312.07104)
- SGLang GitHub Repository
- SGLang HiCache: Fast Hierarchical KV Caching | LMSYS Blog
- HiCache System Design and Optimization | SGLang Docs
- SGLang Learning Series Part 1: Shared Prefix, KV Cache, and RadixAttention | 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
- RAGCache: Efficient Knowledge Caching for Retrieval-Augmented Generation | ACM
- Welcome to SGLang | Official Documentation