지난 글에서 vLLM이 GPU를 놀리지 않는 두 가지 방법을 봤다. 그 자리에 놓을 수 있는 다른 선택지가 SGLang이고, 둘을 나란히 놓으면 「서빙 엔진을 고른다」는 말이 실제로 무엇을 고르는 일인지가 선명해진다.
한 줄로 말하면 이렇다. vLLM은 요청 하나를 잘 처리하는 데 최적화됐고, SGLang은 서로 닮은 요청 여럿을 잘 처리하는 데 최적화됐다. 둘 다 연속 배칭과 블록 단위 KV 캐시를 쓴다. 갈리는 지점은 그 위에서 「요청을 무엇으로 보느냐」다.
요청을 프로그램으로 본다
보통의 추론 서버는 요청을 독립된 문자열 하나로 본다. 앞 요청과 뒤 요청이 무슨 관계인지 서버는 모르고, 알 필요도 없다고 본다.
그런데 실제 부하는 그렇게 생기지 않았다. 챗봇은 1,000토큰짜리 같은 시스템 프롬프트를 매번 앞에 붙이고, 에이전트는 도구를 한 번 부를 때마다 지금까지의 대화 전체를 다시 보낸다. RAG는 같은 문서를 여러 질문이 나눠 쓴다. 요청들이 앞부분을 대량으로 공유하는데 서버가 그걸 모르고 매번 처음부터 다시 읽는 것이 SGLang이 문제로 삼는 지점이다.
여기서 다시 읽는다는 것이 무슨 뜻인지 짚고 가자. 입력 토큰을 처리하는 단계를 프리필(prefill)이라고 부른다 — 답을 만들기 전에 입력 전체를 한 번 통과시켜 KV 캐시를 채우는 과정이다. 프리필은 토큰 수에 비례해 계산이 늘고, 입력이 길고 답이 짧은 부하에서는 전체 비용의 대부분을 차지한다. 1,200토큰짜리 앞부분을 매 요청 다시 프리필하는 것은 그 비용을 통째로 다시 내는 일이다.
접두 트리 — RadixAttention
SGLang의 답은 처리한 앞부분의 KV 캐시를 버리지 않고 트리로 남기는 것이다. 새 요청이 들어오면 트리를 따라 내려가 가장 길게 겹치는 가지를 찾고, 거기서부터만 계산한다.
이 자료구조를 래딕스 트리(radix tree)라고 한다 — 문자열의 공통 접두사를 가지로 묶어 저장하는 트리이고, 여기서는 토큰 열이 문자열 자리에 온다. 어텐션 계산이 이 트리 위에서 일어나므로 RadixAttention이라는 이름이 붙었다.
캐시니까 언젠가는 넘친다. 가득 차면 LRU(least recently used, 가장 오래 안 쓴 것부터 버리기)로 잎사귀 노드부터 축출한다. 잎사귀부터인 이유가 있다 — 트리 중간을 버리면 그 아래 가지가 통째로 못 쓰게 되므로, 자식이 없는 끝부터 걷어내야 손실이 가장 작다.
vLLM의 접두 캐싱과 무엇이 다른가
vLLM에도 --enable-prefix-caching이 있다. 겹치는 앞부분을 재사용한다는 목적은 같고, 실제로 앞부분이 통째로 같은 챗봇 부하에서는 둘의 결과가 크게 다르지 않다.
차이는 부분적으로만 겹칠 때 드러난다.
| vLLM 접두 캐싱 | SGLang RadixAttention | |
|---|---|---|
| 저장 단위 | 블록 해시 테이블 | 접두 트리 |
| 겹침 판정 | 블록 경계(보통 16토큰)에 맞아야 함 | 토큰 단위로 가장 긴 공통 접두사 |
| 여러 갈래 공유 | 갈래마다 따로 잡힘 | 갈라지는 자리까지 한 노드가 감당 |
| 축출 | 블록 단위 LRU | 잎사귀부터 트리 LRU |
| 스케줄러 연동 | 캐시는 캐시대로 동작 | 캐시 히트가 긴 요청을 먼저 실행 |
마지막 줄이 생각보다 크다. SGLang은 대기 중인 요청들을 캐시에 이미 있는 접두사가 긴 순서로 골라 실행한다. 같은 가지를 쓰는 요청들이 붙어서 돌면 그동안 그 노드가 축출될 일이 없고, 그래서 히트율이 더 오른다. 캐시와 스케줄러가 따로 놀지 않는다는 뜻이다.
어디서 차이가 벌어지는가
정리하면 이득의 크기는 요청들이 앞부분을 얼마나 공유하는가 하나로 거의 결정된다.
- 긴 시스템 프롬프트를 공유하는 챗봇 — 앞부분이 통째로 같다. 둘 다 잘하지만 확실히 이득이 나는 자리다
- 에이전트 루프 — 도구를 한 번 부를 때마다 대화 전체가 다시 온다. 매 걸음이 직전 걸음의 접두사에 붙는 꼴이라 트리와 궁합이 좋다
- 한 질문에 답을 여러 개 뽑아 고르는 경우 — 같은 입력에서 갈라지는 형태 그 자체다
- 한 문서에 여러 질문을 던지는 RAG — 문서가 공통 노드, 질문이 잎사귀
- 소수샷 예시를 공유하는 분류·추출 작업 — 예시 묶음이 공통 노드
반대로 요청마다 앞부분이 전부 다르면 이득이 0이다. 사용자별로 다른 문서를 통째로 넣는 부하, 매번 새 이미지가 앞에 오는 작업이 그렇다. 그런 곳에서는 트리 유지 비용만 붙는다. 엔진을 바꾸기 전에 실제 트래픽의 접두사 겹침을 먼저 재는 것이 순서다.
띄우고 부르기
vLLM과 마찬가지로 OpenAI 호환 서버로 뜬다. 서빙 API를 다시 설계할 필요는 없다.
python -m sglang.launch_server \
--model-path Qwen/Qwen3-8B-Instruct \
--host 0.0.0.0 --port 30000 \
--mem-fraction-static 0.85 \
--context-length 8192 \
--schedule-policy lpm
--mem-fraction-static이 vLLM의 --gpu-memory-utilization 자리다. --schedule-policy lpm이 위에서 말한 「가장 긴 접두사 우선」(longest prefix match) 스케줄링이고, 겹침이 없는 부하라면 fcfs(도착 순)로 두는 편이 공평하다.
프런트엔드 — 갈래를 서버에 알려 주기
여기까지는 서버가 알아서 하는 최적화라 클라이언트를 고칠 필요가 없다. SGLang의 이름이 「언어」(language)인 이유는 그 위에 하나가 더 있어서다. 어디서 갈라지는지를 클라이언트가 직접 적을 수 있다.
import sglang as sgl
@sgl.function
def review(s, article):
s += sgl.system("너는 꼼꼼한 편집자다.")
s += sgl.user("다음 글을 읽어라.\n\n" + article)
forks = s.fork(3)
forks[0] += sgl.user("사실관계 오류만 지적해라")
forks[1] += sgl.user("문장이 어색한 곳만 지적해라")
forks[2] += sgl.user("빠진 설명만 지적해라")
for f in forks:
f += sgl.assistant(sgl.gen("out", max_tokens=300))
return [f["out"] for f in forks]
fork는 「여기까지는 같고 여기서부터 셋으로 갈린다」를 서버에 알려 주는 장치다. 세 요청을 따로 보내도 트리가 알아서 묶어 주지만, 명시하면 갈라지는 지점을 추측할 필요가 없어져 스케줄링이 더 정확해진다.
이 프런트엔드가 SGLang을 쓰는 필수 조건은 아니다. 실무에서는 OpenAI 호환 엔드포인트만 쓰고 트리는 서버가 알아서 하게 두는 경우가 더 많다. 프런트엔드는 갈래가 코드로 뚜렷한 작업 — 후보를 여럿 뽑아 고르거나, 여러 관점으로 같은 입력을 훑는 배치 작업 — 에서 값을 한다.
구조화 출력이 빠른 이유
SGLang이 자주 언급되는 다른 이유가 JSON 출력 속도다. 제약 디코딩은 매 토큰마다 「지금 문법상 허용되는 토큰」만 남기고 나머지를 막는 방식인데, SGLang은 여기에 한 가지를 더한다.
문법을 따라가다 보면 다음에 올 것이 하나뿐인 구간이 생긴다. {"name": 을 뽑고 나면 다음은 반드시 "이고, 그런 자리가 JSON에는 아주 많다. 후보가 하나뿐이면 모델을 부를 이유가 없으므로 그 구간을 통째로 건너뛰고 붙여 버린다. 이것을 점프 포워드(jump-forward) 디코딩이라고 부른다.
스키마의 고정 문자가 많을수록 이득이 커진다. 필드 이름이 길고 값이 짧은 추출 작업에서는 뽑아야 할 토큰의 상당수가 그냥 건너뛰어진다.
고를 때
정직하게 말하면 둘 중 아무거나 골라도 대개 돌아간다. 서로의 좋은 아이디어를 계속 가져가고 있어서 격차가 항상 바뀐다. 그래서 기능 목록을 비교하는 것보다 아래 순서가 낫다.
- 자체 서빙이 맞는지부터 판단한다. 트래픽이 적으면 상용 API가 총비용에서 거의 항상 싸다
- 접두사 겹침을 실측한다. 로그에서 요청들의 공통 앞부분 길이를 재 본다. 평균 입력의 절반을 넘게 공유하면 SGLang 쪽이 유리할 가능성이 크다
- 내 부하로 둘 다 돌려 본다. 첫 토큰까지의 시간·토큰 간격·초당 총 출력 토큰 셋을 같은 조건에서 잰다
- 운영 조건을 본다. 쓰려는 모델이 지원되는지, 양자화 형식이 맞는지, 팀이 아는 쪽인지가 벤치마크 몇 퍼센트보다 크게 작용한다
마지막이 실제로는 가장 자주 결정을 내린다. 엔진 비교에서 본 것처럼 서빙 엔진은 갈아 끼우는 물건이라, 처음부터 정답을 고르는 것보다 나중에 바꿀 수 있게 두는 편이 낫다. OpenAI 호환 엔드포인트만 쓰고 있으면 그 교체가 주소 한 줄이다.
흔한 함정
- 캐시 히트율을 안 보고 튜닝한다. SGLang을 쓰는 이유가 트리인데 히트율을 모르면 켠 건지 아닌지도 모른다. 서버가 내보내는 지표에서 이 값을 먼저 본다
--mem-fraction-static을 너무 올린다. 트리가 쓸 자리까지 정적 할당이 먹으면 캐시가 금방 넘쳐 축출이 잦아진다. 히트율이 낮으면 이 값을 내려 본다- 접두사가 안 겹치는 부하에 도입한다. 이득은 없고 운영할 것만 는다
fork없이도 되는 일에 프런트엔드를 도입한다. 클라이언트를 SGLang에 묶는 대가가 붙는다- 결정론을 기대한다. 캐시 히트 여부에 따라 수치 오차가 미세하게 달라질 수 있다. 회귀 테스트를 정확한 문자열 일치로 짜 두면 흔들린다
정리
- vLLM과 SGLang은 연속 배칭·블록 KV 캐시를 공유한다. 갈리는 지점은 겹치는 앞부분을 다루는 방식이다
- 실제 부하는 앞부분을 대량으로 공유한다 — 시스템 프롬프트, 에이전트의 누적 대화, 한 문서에 붙는 여러 질문
- RadixAttention은 처리한 앞부분의 KV를 접두 트리에 남기고, 새 요청은 가장 길게 겹치는 가지부터 이어서 계산한다
- 넘치면 잎사귀부터 LRU로 버린다. 중간을 버리면 그 아래가 통째로 못 쓰게 되기 때문이다
- vLLM의 블록 해시 캐싱과 달리 토큰 단위로 겹침을 찾고, 스케줄러가 캐시 히트가 긴 요청을 먼저 돌린다
- 이득의 크기는 접두사 겹침 하나로 거의 결정된다. 안 겹치는 부하에서는 0이다
- 점프 포워드 디코딩은 문법상 후보가 하나뿐인 구간을 모델 호출 없이 건너뛴다. JSON 추출에서 이득이 크다
- 프런트엔드(
fork)는 갈래를 명시하는 장치이고 필수는 아니다. OpenAI 호환 엔드포인트만 써도 트리는 동작한다 - 고르는 순서는 자체 서빙 판단 → 겹침 실측 → 내 부하로 둘 다 측정 → 운영 조건이다
- 히트율을 안 보면 튜닝이 아니라 추측이다. 메모리를 올려도 히트율이 안 오르면 오히려 내려 본다
읽어주셔서 감사합니다. 😊

