[CloudNeta] Hands-On LLM Serving 4주차 part 2 - LLM 서빙 프레임워크
이 글은 4주차 연재의 두 번째 편입니다.
- part 1 - 고급 LLM 최적화 기법
- part 2 - LLM 서빙 프레임워크 (이 글)
들어가며
8장 요약 및 핵심설명
앞선 장에서는 LLM 서빙의 시스템 설계와 최적화 원리를 살펴보았습니다. 이번 장에서는 이러한 원리를 실제 프로덕션 환경에서 구현하는 서빙 프레임워크 계층으로 초점을 옮깁니다. vLLM, TensorRT-LLM, SGLang, llama.cpp 네 가지 오픈소스 프레임워크를 비교합니다.
각 프레임워크는 서로 다른 설계 철학과 하드웨어 특성을 갖습니다. 이 중 가장 널리 사용되는 vLLM은 아키텍처, 초기화와 실행 과정, 요청·토큰 단위 스케줄링, 최적화 전략까지 깊이 살펴보고, 나머지 프레임워크는 의사결정에 필요한 특징과 짧은 예제로 정리합니다.
핵심 요약
LLM 서빙 프레임워크는 GPU, KV 캐시, 요청과 토큰을 스케줄링하고, 모델 실행과 분산 처리, 각종 최적화 기능을 연결하는 런타임입니다.
vLLM,TensorRT-LLM,SGLang,llama.cpp네 가지를 다루며, vLLM 내부 구조를 가장 자세히 분석합니다.LLMEngine → EngineCore → Scheduler → ModelExecutor → GPUWorker → GPUModelRunner흐름을 이해하면 다른 프레임워크의 구조도 비교하기 쉬워집니다.- 프레임워크 선택은 절대적인 우열보다 워크로드와 운영 조건을 기준으로 판단해야 합니다. 온라인 서빙, 고처리량 배치, NVIDIA GPU 최적화, 구조화된 생성, 엣지·로컬 추론 등 요구사항에 따라 적합한 선택이 달라집니다.
이 장을 마치면 LLM 서빙 프레임워크가 필요한 이유와 내부 동작, 프레임워크별 트레이드오프를 이해할 수 있습니다. 다음 장에서는 이번 장에서 배운 원리를 vLLM 성능 튜닝에 적용합니다.
전문 LLM 서빙 프레임워크가 필요한 이유
LLM 시대 이전에도 모델 서빙 프레임워크는 이미 충분히 많았습니다. TensorFlow Serving, TorchServe, 그리고 NVIDIA Triton 같은 범용 추론 플랫폼이 오랫동안 프로덕션을 지탱해 왔습니다. 그런데도 vLLM, TensorRT-LLM, SGLang 같은 새로운 부류의 프레임워크가 따로 등장했습니다. 이 절에서는 그 배경을 정리합니다.
범용 서빙 프레임워크가 세운 전제
범용 추론 플랫폼은 이미지 인식이나 정형 데이터 추론 같은 딥러닝 워크로드를 위해 설계되었습니다. 이런 워크로드에는 공통된 성질이 있습니다.
- 입력 크기가 짧습니다.
- 텐서 형태가 고정되어 있습니다.
- 지연시간 요구사항을 예측할 수 있습니다.
세 조건이 모두 성립하면 서빙 프레임워크가 고민할 것은 많지 않습니다. 요청을 적당히 모아 한 번에 계산하면 되므로, 주된 최적화 대상은 대체로 배치 처리(batch processing) 하나였습니다.
앞선 장들을 따라왔다면 LLM 서빙이 이미지 분류기나 추천 모델을 서빙하는 것과 근본적으로 다르다는 점은 이미 체감했을 것입니다. LLM 워크로드에서는 위의 세 전제가 하나씩 무너집니다.
LLM 서빙이 만드는 다섯 가지 과제
LLM 서빙과 최적화는 다음과 같은 새로운 과제를 제기합니다.
| 과제 | 내용 |
|---|---|
| 자기회귀 생성(autoregressive generation) | LLM은 출력을 한 번에 하나의 토큰씩 만듭니다. 이미지 모델과 달리 추론 세션이 수 초에서 수 분 동안 열려 있을 수 있습니다. |
| 컨텍스트 길이 폭증(context length explosion) | 몇 개의 토큰부터 수십만, 심지어 백만 개에 이르는 입력 프롬프트까지 처리해야 합니다. KV 캐시 메모리 관리가 중대한 병목이 됩니다. |
| 연속 배칭(continuous batching) | 요청마다 입력과 출력 길이가 크게 다릅니다. 정적 배칭 전략으로는 GPU를 제대로 채우지 못합니다. |
| 스트리밍 요구사항(streaming requirements) | 사용자는 첫 토큰까지의 시간(TTFT, time to first token)이 수백 밀리초 이내이길 기대하며, 그 뒤로도 토큰이 끊기지 않고 흘러나오기를 요구합니다. |
| 자원 활용(resource utilization) | GPU는 비쌉니다. 파편화나 유휴 토큰으로 인한 GPU FLOPS 낭비는 대규모 환경에서 용납되지 않습니다. |
정리하면 범용 프레임워크의 전제와 LLM 서빙의 조건은 아래 그림처럼 정면으로 어긋납니다.
전문 프레임워크가 도입한 혁신
이러한 요구를 충족시키기 위해 등장한 것이 전문 LLM 서빙 프레임워크입니다. vLLM, TensorRT-LLM, SGLang이 대표적입니다. 이 프레임워크들은 다음과 같은 기술을 도입해 LLM 고유의 과제를 해결합니다.
- 페이지 단위 KV 캐싱(paged KV caching): KV 캐시를 고정 크기 블록으로 나누어 관리하면서 메모리 파편화를 줄입니다.
- 연속 배칭: 반복 경계마다 완료된 요청을 빼고 대기 요청을 넣어 활성 슬롯을 계속 채웁니다.
- LLM 전용 양자화: 가중치와 캐시를 줄여 메모리와 연산 비용을 낮춥니다.
- 추측 디코딩(speculative decoding): 앞 장에서 다룬 것처럼 초안 토큰을 미리 만들어 검증합니다.
이 프레임워크들의 도움으로 최신 가속기에서 더 많은 처리량을 짜내고 지연시간을 낮출 수 있게 되었고, 그 결과 LLM 서빙의 주류 선택지로 자리 잡았습니다.
서빙 소프트웨어의 두 계층
프로덕션 환경의 LLM 서빙 소프트웨어는 보통 두 계층으로 나누어 볼 수 있습니다. 아래로는 실제 토큰을 만드는 엔진 티어(engine tier)가 있고, 위로는 여러 노드를 조율하는 오케스트레이션 티어(orchestration tier)가 있습니다.
엔진 티어
엔진 티어에는 vLLM, SGLang, TensorRT-LLM, TGI가 있습니다. 네 프레임워크는 서로 다른 조직에서 만들어졌지만, 스케줄러와 KV 캐시, 커널이라는 같은 3단 구조로 놓고 비교할 수 있습니다. 차이는 그 아래 깔린 배경과 지원 범위에서 드러납니다.
| 엔진 | 배경 | 특징 |
|---|---|---|
| vLLM | PyTorch 재단 | 인텔, 엔비디아, AMD, TPU, AWS 실리콘까지 가장 폭넓은 하드웨어를 지원합니다. |
| SGLang | 오픈소스 커뮤니티 | 96 GPU 규모의 DeepSeek 모델 재현 사례처럼 대규모 클러스터 서빙에 활용됩니다. |
| TensorRT-LLM | NVIDIA | NVIDIA 전용이며, NVIDIA GPU에서 최고 수준의 성능을 냅니다. |
| TGI | Hugging Face | Hugging Face Hub 생태계와 통합되어 있습니다. |
오케스트레이션 티어
엔진 티어 위에는 NVIDIA Dynamo와 llm-d 같은 오케스트레이션 계층이 있습니다. llm-d는 쿠버네티스 네이티브로 동작하고, Dynamo는 여러 노드에 걸친 분산 서빙을 조율합니다. 이 계층이 하는 일 중 하나가 프리필과 디코드를 서로 다른 노드 풀로 나누어 보내는 분리 서빙(disaggregated serving)입니다. 연산 집약적인 프리필과 메모리 대역폭 집약적인 디코드를 별도 풀에서 처리해 자원 효율을 높이는 방식입니다.
오케스트레이션 계층은 추론 엔진이 아닙니다. Dynamo도 llm-d도 그 자체로는 추론 엔진을 실행하지 않고 토큰을 직접 만들지도 않습니다. 이 계층의 역할은 요청을 스케줄링하고 라우팅하는 것까지이며, 실제 토큰 생성은 각 노드의 엔진 티어가 수행합니다.
이 구분을 염두에 두고, 이제 엔진 티어에서 가장 널리 쓰이는 vLLM의 내부를 살펴보겠습니다.
vLLM
vLLM은 긴 프롬프트, 높은 메모리 요구, 다중 사용자 동시 서빙이라는 LLM 서빙의 근본적인 어려움을 다루면서 오픈소스와 엔터프라이즈 양쪽에서 빠르게 확산되었습니다. 프레임워크의 근본 혁신은 페이지 단위 KV 캐싱과 연속 배칭이며, 그 위에 양자화, 추측 디코딩, 스트리밍, 멀티 GPU 및 분산 실행 기능이 얹혀 있습니다. 챗봇과 RAG, 배치 텍스트 생성, 멀티 테넌트 서빙, 실시간 애플리케이션이 대표적인 사용 사례입니다.
vLLM이 널리 쓰이는 이유는 크게 네 가지로 정리할 수 있습니다.
- 여러 프로덕션 환경을 거치며 실전 검증되었습니다.
- 오픈소스 모델이나 직접 파인튜닝한 모델을 쉽게 얹을 수 있습니다.
- 깊은 튜닝 없이도 GPU 효율을 상당히 끌어올립니다.
- 기본 설정만으로도 예측 가능한 성능을 냅니다.
여기에 더해 아키텍처가 깔끔하고 확장 가능하게 설계되어 있어 최신 연구 성과를 빠르게 흡수합니다. 커뮤니티가 활발하다는 점도 장기적으로 이 프레임워크를 선택할 근거가 됩니다.
이번 절에서는 시스템 설계 개요를 먼저 보고, 모델 초기화 워크플로우와 요청 처리 파이프라인을 따라간 뒤, 요청 우선순위 결정과 프레임워크 수준의 최적화 전략까지 살펴봅니다.
vLLM을 쓰는 두 가지 방식
vLLM은 단일 모델 서비스 구성에 최적화되어 있으며, 각 인스턴스는 시작 시 하나의 모델을 초기화합니다. 그 모델을 쓰는 방법은 두 가지입니다. 자세한 내용은 vLLM 아키텍처 문서에서 확인할 수 있습니다.
| 사용 방식 | 형태 | 적합한 상황 |
|---|---|---|
LLM 클래스 |
인프로세스 파이썬 라이브러리 | 서버 없이 오프라인 추론을 돌리거나 기존 서비스, 배치 워크플로우에 직접 연결할 때 |
| API 서버 | vllm serve로 띄우는 OpenAI 호환 HTTP 서버 |
멀티 클라이언트 프로덕션 환경과 스트리밍 응답이 필요할 때 |
LLM 클래스는 세밀한 제어와 낮은 오버헤드를 제공하고 자체 서빙 스택과 통합하기 쉽습니다. API 서버는 더 폭넓은 배포를 위한 네트워크 엔드포인트를 바로 제공합니다.
LLM 클래스로 쓰기
# initialize LLM model
llm = LLM(
model="Qwen/Qwen3-7B-Instruct",
trust_remote_code=True, # Qwen uses custom modeling code
dtype="float16", # Use float16 for GPU
max_model_len=32768,
gpu_memory_utilization=0.8,
)
# run model generation requests
outputs = llm.generate(prompts, sampling)
OpenAI 호환 API 서버로 쓰기
# start vLLM API server
vllm serve Qwen/Qwen3-7B-Instruct \
--trust-remote-code \
--dtype bfloat16 \
--max-model-len 32768 \
--gpu-memory-utilization 0.8
# call the Qwen model via the OpenAI-compatible API
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen3-7B-Instruct",
"temperature": 0.7,
"max_tokens": 256,
"stream": true
}'
vLLM의 아키텍처
두 가지 사용 방식 모두 같은 내부 구조를 거칩니다. 사용자가 만지는 최상단부터 GPU에서 순전파를 실행하는 최하단까지의 흐름은 다음과 같습니다.
LLMEngine (공개 API, 요청 생명주기 관리)
+- EngineCore (내부 루프, 전체 파이프라인 오케스트레이션)
+- Scheduler (자원 배분을 맡는 교통 관제사)
+- SchedulerOutput (작업 지시서)
+- ModelExecutor (다중 워커 프로세스 조율)
+- GPUWorker (프로세스별 디바이스와 모델 생명주기 관리)
+- GPUModelRunner (실제 신경망 순전파 실행)
LLMEngine과 EngineCore
LLMEngine은 vLLM 추론 시스템의 상위 수준 인터페이스이자 주 진입점입니다. 사용자가 상호작용하는 공개 API 역할을 하면서, 내부적으로는 하위 컴포넌트를 모두 조율합니다. 동기와 비동기 서빙 시나리오를 모두 처리하고, 요청 처리 파이프라인의 오케스트레이션과 요청 큐, 설정 관리까지 전체 요청 생명주기를 맡습니다.
EngineCore는 추론 엔진의 중앙 오케스트레이터입니다. 모델 실행기와 출력 프로세서, 스케줄러를 하나로 묶어 전체 요청 처리 파이프라인을 관리하는 내부 루프(inner loop) 역할을 합니다.
Scheduler
Scheduler는 전체 추론 파이프라인의 교통 관제사입니다. 컴퓨팅 자원을 관리하고 요청 전반의 토큰 계산을 조율합니다. GPU 메모리, KV 캐시 블록, 처리 용량 같은 제한된 자원을 경쟁 중인 요청들 사이에 배분하면서 처리량을 극대화하고 공정한 접근을 유지하는 것이 주된 책임입니다.
최적화 관점에서 스케줄러는 토큰 단위 스케줄링, 동적 배칭, 프리픽스 캐싱, 청크 프리필처럼 시스템 전반에 걸친 모델 무관(model-agnostic) 최적화를 담당합니다. 모델별 최적화는 뒤에서 살펴볼 ModelExecutor와 GPUWorker 안에서 적용됩니다.
스케줄러는 자신의 실행 계획을 SchedulerOutput이라는 데이터 구조에 담습니다. 이것이 스케줄러가 ModelExecutor에 전달하는 작업 지시서(work order)이며, 한 배치의 요청을 실행하는 데 필요한 정보를 모두 담고 있습니다. 내용을 말로 풀면 이렇습니다. "여기 처리할 요청 배치가 있다. 각 요청이 받아야 할 토큰 수는 이만큼이고, 여기 입력 데이터와 파라미터가 있으며, 여기 이들에게 할당된 메모리 블록이 있고, 여기 특별히 처리해야 할 요구사항들이 있다."
ModelExecutor는 이 정보를 사용해 실제 GPU 배치를 준비합니다. 평탄화된 input ID, 어텐션 메타데이터, KV 캐시 블록을 갖추어 모델의 순전파(forward pass)를 실행하고, 결과를 다음 반복을 위해 스케줄러에게 되돌려 줍니다.
ModelExecutor, GPUWorker, GPUModelRunner
vLLM은 각 모델을 별도의 프로세스 또는 프로세스 그룹에서 호스팅합니다. 그래서 프로세스 간 통신을 처리하고 분산 워커 그룹을 조율하며 모델 순전파의 실행 세부사항을 다루기 위해 계층화된 아키텍처를 사용합니다.
| 컴포넌트 | 책임 |
|---|---|
ModelExecutor |
여러 워커 프로세스를 조율하고 관리합니다. |
GPUWorker |
각 워커 프로세스에서 실행되며 디바이스와 모델 생명주기를 관리하는 워커 인터페이스입니다. |
GPUModelRunner |
실제로 신경망을 실행합니다. |
이러한 관심사의 분리(separation of concerns) 덕분에 각 컴포넌트는 서로 깔끔한 인터페이스를 유지하면서 자기 몫의 실행 책임에만 집중할 수 있습니다.
모델 초기화 워크플로우
vLLM은 폭넓은 모델과 다양한 실행 방식을 지원합니다. 단일 디바이스 서빙부터 한 노드에서의 다중 디바이스 서빙, 멀티 노드 클러스터까지 다루기 때문에 초기화 로직이 복잡하게 느껴질 수 있습니다. 여기서는 오늘날 프로덕션에서 가장 흔한 구성인 멀티프로세스 설정을 기준으로 살펴봅니다.
1. vLLM 메인 프로세스에서 모든 컴포넌트 초기화
LLM() 인스턴스를 만들 때 모델 설정, 자원 사용량, 서빙 최적화 파라미터를 정의하는 설정값을 넘깁니다. 아래 예제는 Hugging Face의 Qwen 모델을 네 개의 워커 프로세스로 구성된 멀티프로세스 그룹에서 호스팅하도록 설정합니다.
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
# specify 4 workers
tensor_parallel_size=4,
# use multi-process model executor
distributed_executor_backend="mp",
)
| 파라미터 | 역할 |
|---|---|
tensor_parallel_size |
워커(GPU) 개수를 지정합니다. 예제에서는 4입니다. |
distributed_executor_backend |
실행 백엔드를 고릅니다. mp는 단일 노드 다중 GPU 배포에, ray는 여러 머신에 걸친 배포에 적합합니다. |
LLM 클래스는 메인 프로세스 안에서 LLMEngine, Scheduler, KVCacheManager, MultiProcessExecutor를 포함한 주요 컴포넌트를 초기화하고 설정값을 각 모듈에 배포합니다.
2. 워커 프로세스 그룹 생성
MultiProcessExecutor는 분산 모델 실행을 위해 네 개의 워커 프로세스를 생성(spawn)합니다. 동시에 이 워커들에게 신호와 명령을 전달할 rpc_broadcast_mq 메시지 큐를 설정합니다.
3. 워커 프로세스 초기화
각 워커 프로세스는 GPUWorker를 실행합니다. GPUWorker는 모델 추론을 실행하고 MultiProcessExecutor와 통신하는 역할을 맡습니다. CUDA 디바이스를 설정하고, 프로세스 간 통신을 수립하며, 워커 프로세스 안에서 모델을 로드하고 초기화합니다. 추론 결과를 실행기에 되돌려 보내기 위한 worker_response_mq 메시지 큐도 이 단계에서 유지됩니다.
4. 모델 준비와 로드
GPUModelRunner는 설정된 모델 이름을 근거로 올바른 모델 구현체를 찾습니다. 이 예제에서는 vLLM의 내부 모델 레지스트리에서 Qwen 모델을 조회해 Qwen3NextForCausalLM 구현체를 선택합니다. 그런 다음 해당 클래스의 __init__ 함수를 호출해 모델 가중치를 GPU에 올립니다.
초기화는 메인 프로세스 기동, 워커 프로세스 생성, 워커별 모델 로드라는 순차적 흐름을 따릅니다.
메인 프로세스와 워커 사이의 통신은 양방향 메시지 큐로 이루어집니다. rpc_broadcast_mq는 명령을 내려보내고, worker_response_mq는 결과를 회신합니다.
실제 모델 구현체 선택은 내부 레지스트리 조회 방식이므로, 다양한 모델 아키텍처를 유연하게 지원할 수 있습니다.
생성 요청 실행 워크플로우
모델이 로드되고 초기화되어 설정까지 끝나면 서빙할 준비가 된 것입니다. llm.generate(prompts, sampling_params)를 실행하면 vLLM 내부에서는 다음과 같은 일이 일어납니다.
flowchart TD
input["원시 입력 프롬프트"] --> processor["Processor: 검증과 토큰화"]
processor --> request["Request 객체"]
request --> engine["LLMEngine 실행 루프"]
engine --> core["EngineCore"]
core --> sched["Scheduler: 다음 배치와 토큰 결정"]
sched --> so["SchedulerOutput"]
so --> executor["MultiProcessExecutor"]
executor --> worker["GPU Worker: 모델 forward pass"]
worker --> outproc["Output Processor"]
outproc --> response["최종 응답"]
worker -.다음 반복.-> core| 단계 | 담당 컴포넌트 | 역할 |
|---|---|---|
| 1 | Processor | 원시 입력을 검증하고 토큰화해 Request 객체로 만듭니다. |
| 2 | LLMEngine, EngineCore, Scheduler | 다음에 실행할 요청 배치와 처리할 토큰을 결정하고 페이지드 어텐션, 연속 배칭 같은 최적화를 적용합니다. |
| 3 | MultiProcessExecutor, GPU Worker | 스케줄링된 토큰을 워커 프로세스에 전달해 모델 순전파를 실행합니다. |
| 4 | Output Processor | 모델 출력을 최종 응답으로 만들어 사용자에게 반환합니다. |
두 컴포넌트의 역할은 분명히 나뉩니다. Scheduler는 언제 무엇을 어떻게 배치할지 결정하는 서빙 최적화를 맡고, GPUWorker는 실제 연산만 수행합니다. 이 관심사 분리 덕분에 vLLM은 배칭과 스케줄링 최적화를 GPU 실행 로직과 독립적으로 개선할 수 있습니다.
스케줄러 심층 분석
Scheduler는 vLLM 추론 파이프라인에서 생성 요청을 실행하는 중앙 교통 관제탑입니다. 먼저 스케줄러를 형성하는 핵심 고려사항을 살펴본 뒤, 요청 스케줄링 워크플로우를 따라가겠습니다.
스케줄러의 다섯 가지 책임
요청 자원 오케스트레이션(request resource orchestration)
스케줄러는 요청이 도착한 순간부터 완료될 때까지 전체 생명주기를 조율합니다. 들어오는 요청을 WAITING 큐와 RUNNING 큐로 나누어 정리하고, GPU 메모리와 KV 캐시 블록, 토큰 예산 같은 가용 자원을 근거로 어떤 요청을 실행할지 동적으로 결정합니다. 추측 디코딩, 프리픽스 캐싱, 멀티모달 입력처럼 복잡한 자원 할당 문제도 여기서 다룹니다.
토큰 단위 자원 할당과 스케줄링(token-level resource allocation and scheduling)
vLLM은 프리필과 디코드 단계를 분리하지 않고 통합된 토큰 기반 스케줄링 방식을 씁니다. 각 요청을 하나의 단위로 취급하는 요청 기반 스케줄러와 달리, vLLM의 스케줄러는 토큰 단위로 동작합니다. 매 스케줄링 스텝마다 최대 배치 크기나 GPU 메모리 한도 같은 전역 제약을 지키면서 각 요청이 처리할 토큰 수를 결정합니다. 요청 기반 전략과 비교하면 훨씬 세밀한 제어가 가능하고, 전체 연산 부하를 시스템 한계 안에 두면서 병렬 실행 기회를 넓힐 수 있습니다.
최적화 통합 허브(optimization integration hub)
스케줄러는 모델 무관 성능 최적화의 중앙 통합 지점입니다. 이전에 계산된 상태를 재사용하는 프리픽스 캐싱, 미래 토큰을 예측하는 추측 디코딩, 긴 시퀀스를 나누어 처리하는 청크 프리필, 멀티 GPU 실행을 지원하는 분산 KV 캐시 전송을 함께 조율합니다. 이 최적화들을 언제 어떻게 적용할지 요청 특성과 자원 가용성, 시스템 상태에 따라 적응적으로 결정하기 때문에, 여러 최적화가 서로 충돌하거나 비효율을 만들지 않습니다.
동적 부하 분산(dynamic load balancing)
스케줄러는 시스템 자원과 요청 특성을 모니터링하면서 지연시간과 처리량의 균형을 맞추는 실시간 결정을 내립니다. 요청 도착, 완료, 선점, 자원 한계 같은 동적 이벤트에 대응해 어떤 요청을 실행할지, 몇 개의 토큰을 처리할지, 언제 최적화를 적용할지를 다시 평가합니다.
요청 생명주기 관리(request lifecycle management)
스케줄러는 WAITING 큐 도착부터 RUNNING 큐 실행, 최종 완료까지 각 요청의 생명주기를 추적합니다. 선입선출(FCFS)이나 우선순위 기반 정렬 같은 정책을 적용해 실행 순서를 정하고, 자원이 부족하면 우선순위가 낮은 요청을 선점(preempt)해 용량을 재할당합니다.
요청 스케줄링 워크플로우
스케줄러의 핵심 임무는 각 생성 요청에 적절한 토큰 수를 정해 모델의 순전파에 넘기는 것입니다. 모델과 하드웨어 용량을 최대한 활용하면서 균형 잡힌 사용자 경험을 유지하기 위해, 스케줄러는 이 토큰 선택 과정에서 여러 모델 무관 최적화를 적용합니다.
1. 스케줄 상태 구성
스케줄러는 내부 스케줄 상태를 만드는 것으로 사이클을 시작합니다. 새로 도착한 요청, 재개가 필요한 이전 일시 중지 요청, 현재 실행 중인 요청, 이전에 선점된 요청을 모두 모읍니다. 동시에 현재 토큰 예산 안에 남은 디코딩 토큰 수와 멀티모달 워크로드를 위한 인코더 용량 같은 자원 회계도 갱신합니다. 이 초기 상태가 이후의 모든 결정을 규정합니다.
2. 1차 웨이브, RUNNING 요청
초기화가 끝나면 스케줄러는 두 번의 우선순위 웨이브로 연산을 배분하기 시작합니다. RUNNING 요청이 먼저 처리되는데, 이미 KV 캐시 블록을 점유하고 있고 활성 생성 타임라인을 갖고 있기 때문입니다. 실행 중인 요청마다 새로 생성할 토큰 수를 정하고, 멀티모달 입력이 관련되면 인코더 제약을 검증하며, 실행을 이어갈 KV 캐시가 충분한지 확인합니다.
이 단계에서 vLLM은 지연시간을 줄이고 처리량을 높이는 최적화를 함께 적용합니다. 청크 프리필은 전체 프롬프트를 한 번에 인코딩하지 않고 긴 프롬프트를 부분적으로 처리하게 합니다. 프리픽스 캐싱은 서로 다른 요청이 공유하는 프리픽스의 KV 캐시 블록을 재사용하게 합니다. 추측 디코딩은 초안 토큰을 미리 만들어 두었다가 나중에 모델의 검증된 출력과 일치하면 더 빠른 생성을 가능하게 합니다. 어느 시점에서든 자원이 부족해지면 스케줄러는 공정성과 일관성을 위해 우선순위가 낮은 작업을 선점할 수 있습니다.
3. 2차 웨이브, WAITING 요청
모든 RUNNING 요청 처리가 끝나면 스케줄러는 WAITING 큐로 넘어갑니다. 남은 토큰과 인코더 예산 안에 들어갈 수 있는 요청들이 활성화되어 현재 실행 배치에 포함됩니다. WAITING 요청은 더 낮은 우선순위를 받지만, 해당되는 경우 RUNNING 요청과 동일한 최적화의 혜택을 받습니다.
4. 후처리
요청 할당이 끝나면 스케줄러는 다가오는 디코딩 스텝에 필요한 조율 작업을 수행합니다. 각 요청에 활성화되어야 할 LoRA 어댑터를 추적하고, 멀티모달 작업을 위한 인코더 입력을 준비하며, 추측 디코딩에 쓸 초안 토큰 예측을 확정합니다. 목표는 모든 스케줄링 결정을 실행 가능한 하나의 계획으로 묶는 것입니다.
5. SchedulerOutput 조립
마지막으로 스케줄러는 사이클의 결과를 요약하는 SchedulerOutput 객체를 조립합니다. 새로 스케줄링된 요청들, 각 요청에 할당된 토큰 수, 배치 전체의 총 스케줄링된 토큰 수를 나열합니다. KV 캐시 할당, 준비된 인코더 입력, 멀티모달 라우팅 정보 같은 메타데이터도 함께 담깁니다. 모델 실행기는 이 출력을 사용해 새 토큰을 생성하는 순전파를 실행합니다.
토큰 단위 스케줄링의 구현
모델 무관 요청-토큰 스케줄링을 지원하기 위해 vLLM 스케줄러는 토큰 수준에서 동작합니다. RUNNING 큐부터 WAITING 큐 순으로 각 요청을 순회하며, 다음 순전파에 어느 요청의 몇 개 토큰을 포함할지 결정하고 전체 토큰 수가 모델의 한계 안에 머물도록 보장합니다.
구현 수준에서 스케줄러는 이미 처리된 토큰 수(num_computed_tokens)와 프롬프트, 출력, 추측 토큰을 포함해 처리해야 할 총 토큰 수(num_tokens_with_spec) 사이의 간극을 최소화하려 합니다. 이 간극을 좁히려는 목표가 스케줄링 중 적용되는 최적화 전략을 이끕니다.
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
num_new_tokens = (request.num_tokens_with_spec +
request.num_output_placeholders -
request.num_computed_tokens)
vLLM 스케줄링에서 요청 큐는 요청의 처리 순서를 결정하고, num_computed_tokens와 num_tokens_with_spec의 비교는 각 요청이 이번 스텝에 실행할 토큰 수를 결정합니다.
요청 우선순위 결정과 토큰 수준 스케줄링이 이렇게 분리되어 있으므로, 스케줄러는 다양한 우선순위 전략과 LLM 실행 최적화를 하나의 통합된 프레임워크 안에서 조합할 수 있습니다. 이 구조 덕분에 동시 요청 사이의 공정성을 지키면서 시스템 자원도 효율적으로 쓸 수 있습니다.
스케줄링에 통합되는 최적화 기법
스케줄링 과정에서 vLLM은 6장과 7장에서 소개한 최적화 기법을 통합합니다. 청크 프리필, 프리픽스 캐싱, 문법 제약 유한상태기계를 통한 가이디드 디코딩, 프리필과 디코드를 나누는 PD 분리가 여기에 해당합니다. 실제 구현이 어떻게 생겼는지 두 가지만 확인해 보겠습니다.
청크 프리필(chunked prefill)
스케줄러는 요청에 대해 실행되는 새 토큰 수가 설정된 prefill 청크 크기(scheduler_config.long_prefill_token_threshold)를 넘지 않도록 보장합니다.
if (0 < self.scheduler_config.long_prefill_token_threshold
< num_new_tokens):
num_new_tokens = self.scheduler_config.long_prefill_token_threshold
프리픽스 캐싱(prefix caching)
스케줄러는 로컬 또는 원격 캐시에 이미 계산되어 있는 KV 캐시 블록을 재사용하기 위해 프리픽스 캐싱을 처리합니다.
# 이미 캐시된 토큰 가져오기.
if request.num_computed_tokens == 0:
# 로컬에 캐시된 토큰 가져오기.
new_computed_blocks, num_new_local_computed_tokens = \
self.kv_cache_manager.get_computed_blocks(request)
# 외부에 캐시된 토큰 가져오기.
if self.connector is not None:
num_external_computed_tokens, load_kv_async = (
self.connector.get_num_new_matched_tokens(
request, num_new_local_computed_tokens))
가이디드 디코딩이나 PD 분리 같은 다른 최적화 기법의 구현도 직접 따라가 보길 권합니다.
vLLM의 주요 동작을 소스 코드로 직접 확인해 보세요. vLLM 저장소의 v1 디렉터리가 출발점이며, 그중에서도 scheduler.py가 이 절의 내용과 바로 이어집니다.
Aleksa Gordic의 블로그 글 Inside vLLM: Anatomy of a High-Throughput LLM Inference System을 정리해 보는 것도 좋은 연습입니다. 같은 저자의 Inside NVIDIA GPUs: Anatomy of high performance matmul kernels까지 이어서 읽으면 커널 수준의 이해를 함께 얻을 수 있습니다.
vLLM의 계층적 최적화 전략
vLLM의 핵심 설계 철학은 최적화가 그것이 속한 올바른 계층에서 이루어져야 한다는 것입니다. LLM 아키텍처와 하드웨어는 매우 빠르게 변하기 때문에, 특정 모델이나 하드웨어에 최적화를 하드코딩하면 시스템이 금방 낡아 버립니다. vLLM은 이를 피하려고 최적화 책임을 네 계층으로 나눕니다.
| 계층 | 범위 | 대표 최적화 |
|---|---|---|
Scheduler |
시스템 전반의 모델 무관 최적화 | 배칭, 캐싱, 공정성과 처리량 관리 |
ModelExecutor |
모델 아키텍처별 최적화 | Transformer 용 융합 어텐션 커널, 멀티모달 인코더의 특수 연산자 |
| 모델 레이어 | 컴포넌트별 최적화 | KV 캐시 재사용, 플래시 어텐션, 레이어 단위 연산자 융합 |
CustomOp |
하드웨어별 최적화 | CUDA 커널, 텐서 코어 가속, 양자화 연산자 |
Scheduler는 시스템 레벨의 공정성과 효율성, 확장성을 책임집니다. 모델에 무관하게 유지되는 이 계층과 달리 ModelExecutor는 각 모델 아키텍처의 세부사항을 이해합니다. Transformer 기반 모델에는 융합된 어텐션 커널을, 멀티모달 인코더에는 특수 연산자를 적용하는 식입니다. 아키텍처를 인지하는 최적화를 이 계층에 격리해 두었기 때문에 시스템 레벨 스케줄링과 독립적으로 진화할 수 있습니다.
모델 아키텍처의 레이어 수준에서는 최적화가 연산 병목에 맞춰 조정됩니다. 어텐션 레이어나 피드포워드 블록에서 KV 캐시 재사용, 플래시 어텐션, 레이어 단위 연산자 융합 같은 기법이 적용됩니다. 이 설계는 특정 하위 컴포넌트를 겨냥한 최적화가 시스템 전반의 스케줄링 로직으로 새어 나가지 않게 합니다.
가장 아래의 CustomOp는 CUDA 커널, 텐서 코어 가속, 양자화된 연산자처럼 기저 하드웨어에 대한 최적화를 담당합니다. 이를 따로 떼어 두었기 때문에 상위 레벨의 스케줄링이나 모델 로직을 건드리지 않고도 새로운 GPU 기능과 가속기를 활용할 수 있습니다.
위로 갈수록 범용적이고 모델에 무관하며, 아래로 갈수록 특정 하드웨어에 특화됩니다. 각 계층이 자기 관심사만 처리하므로 한 계층의 변경이 다른 계층에 영향을 주지 않습니다. 새 GPU가 나와도 Scheduler와 모델 로직은 그대로 두고 CustomOp만 확장하면 됩니다.
덕분에 vLLM은 새로운 모델 아키텍처나 하드웨어가 등장해도 전체 시스템을 다시 설계할 필요 없이, 해당 최적화를 알맞은 계층에 끼워 넣는 방식으로 대응할 수 있습니다.
TensorRT-LLM
TensorRT-LLM은 NVIDIA가 자사 GPU에서 LLM 추론 성능을 끝까지 끌어내기 위해 만든 오픈소스 라이브러리입니다. vLLM이 모델과 하드웨어에 무관한 유연성을 목표로 삼는다면, TensorRT-LLM은 NVIDIA GPU의 Tensor Core와 CUDA 커널을 최대한 활용하는 쪽에 초점을 맞춥니다. 범용성을 일부 포기하는 대신 NVIDIA 하드웨어에서 낼 수 있는 실전 성능의 상한을 노리는 선택입니다.
자세한 내용은 공식 문서와 NVIDIA 한국 블로그의 양자화 및 TensorRT-LLM 기반 서비스 최적화 글에서 확인할 수 있습니다.
엔진 빌드라는 설계 선택
TensorRT-LLM의 동작 방식은 모델 체크포인트를 고도로 튜닝된 TensorRT 엔진으로 컴파일하는 것에서 출발합니다. 실행 시점에 그래프를 해석하는 대신, 미리 정해진 모델 구조와 정밀도, 병렬화 구성, 최대 시퀀스 길이 같은 조건을 고정해 두고 그 조건에 맞는 커널을 사전에 선택하고 배치합니다. 이렇게 만들어진 엔진을 Python 또는 C++ 런타임이 로드해서 요청을 처리합니다.
이 구조는 사전 컴파일(AOT, ahead-of-time compilation)에 가깝습니다. 빌드 시점에 조건이 고정되므로 런타임에서 결정할 것이 줄어들고, 커널 선택과 융합, 메모리 레이아웃을 미리 최적화할 수 있습니다. 대신 정밀도를 바꾸거나 병렬화 구성을 바꾸는 것처럼 빌드 조건이 달라지면 엔진을 다시 만들어야 합니다. 성능을 얻는 대가로 배포 절차에 빌드 단계가 하나 더 생기는 셈입니다.
지원 기능
TensorRT-LLM이 제공하는 기능은 앞서 살펴본 최적화 기법들과 상당 부분 겹칩니다. 차이는 그 기법들이 NVIDIA GPU 전용 커널 위에서 구현되어 있다는 점입니다.
| 구분 | 내용 |
|---|---|
| 배칭 | in-flight batching(연속 배칭) |
| KV 캐시 | 페이지드 KV 캐시 |
| 디코딩 | 추측 디코딩(speculative decoding) |
| 양자화 | FP8, FP4, INT8, INT4 등 다중 정밀도 |
| 병렬화 | 텐서 병렬화, 파이프라인 병렬화 |
| 런타임 | Python 런타임, C++ 런타임 |
Triton, Dynamo 와의 관계
TensorRT-LLM은 그 자체로 완결된 서빙 제품이라기보다 추론 라이브러리 겸 엔진에 가깝습니다. 그래서 모델을 배포하고 라우팅하고 운영하는 층은 NVIDIA의 서빙 스택이 담당합니다. Triton Inference Server가 모델 저장소와 HTTP/gRPC 엔드포인트, 다중 모델 배포를 맡고, NVIDIA Dynamo가 그 위에서 분산 서빙과 오케스트레이션을 맡는 식입니다.
이 장 앞부분에서 Triton은 LLM에 적합하지 않은 범용 서빙 프레임워크의 예로 언급되었습니다. 여기서 다시 등장하는 이유는 역할이 달라졌기 때문입니다. Triton이 직접 LLM 추론을 수행하는 것이 아니라, TensorRT-LLM이라는 LLM 전용 백엔드를 얹고 그 앞단의 서빙 인프라 역할만 맡습니다. 즉 토큰 단위 스케줄링과 KV 캐시 관리는 TensorRT-LLM이, 엔드포인트와 배포 관리는 Triton이 담당합니다.
이미 NVIDIA 하드웨어와 서빙 스택으로 표준화되어 있고 프로덕션에서 최고 수준의 처리량과 효율이 필요한 조직이라면 이 조합의 이점이 큽니다. 반대로 여러 벤더의 가속기를 섞어 쓰거나 하드웨어 이식성이 중요한 조직에는 종속 비용이 그대로 부담이 됩니다.
고수준 LLM API
성능 지향 라이브러리라고 해서 사용 방법이 복잡한 것은 아닙니다. TensorRT-LLM은 vLLM과 거의 같은 형태의 고수준 API를 제공하므로, vLLM을 써 본 사람이라면 코드를 거의 그대로 옮길 수 있습니다.
llm = LLM(model="Qwen/Qwen3-7B")
# 샘플 프롬프트
prompts = [
"Hello, my name is",
"The capital of France is",
"The future of AI is",
]
# 샘플링 파라미터 생성
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
# 모델 생성 요청 실행
for output in llm.generate(prompts, sampling_params):
print(
f"Prompt: {output.prompt!r}, Generated text: {output.outputs[0].text!r}"
)
QuickStart 실습
Linux, NVIDIA GPU, Python 3.10 이상이 필요합니다.
지원 하드웨어는 Blackwell(B200, GB200, B300, GB300, DGX Spark), Hopper(H100, H200, GH200), Ampere(A100), Ada Lovelace(RTX 4070 SM89, L20, L40, L40S) 세대입니다.
로컬에 이런 GPU가 없다면 Runpod 같은 GPU 임대 서비스를 쓰면 됩니다. 실습 환경은 Ubuntu 24.04, NVIDIA Container Toolkit, 드라이버 CUDA 13.2, PyTorch 2.11.0(cu13.0)을 요구합니다. 컨테이너 이미지부터 받습니다. 이미지 크기가 상당하니 디스크 여유를 먼저 확인해 두는 편이 좋습니다.
docker pull nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc24
# nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc24 16a103b8b1b6 DISK USAGE 54.8GB CONTENT SIZE 17.7GB
# 라이브러리가 정상적으로 로드되는지 먼저 확인합니다
docker run --rm -it --ipc host --gpus all --ulimit memlock=-1 --ulimit stack=67108864 \
-v ~/.cache/huggingface:/root/.cache/huggingface -p 8000:8000 \
nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc24 python3 -c "import tensorrt_llm"
# [TensorRT-LLM] TensorRT LLM version: 1.3.0rc24
이제 컨테이너를 띄워 두고 그 안에서 trtllm-serve로 모델을 서빙합니다.
docker run -d --rm -it --ipc host --gpus all --name trtllm-server \
--ulimit memlock=-1 --ulimit stack=67108864 \
-v ~/.cache/huggingface:/root/.cache/huggingface -p 8000:8000 \
nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc24 \
sleep infinity
docker ps # trtllm-server가 Up 상태이고 8000 포트가 열렸는지 확인
docker exec -it trtllm-server nvidia-smi
docker exec -it trtllm-server \
trtllm-serve serve "TinyLlama/TinyLlama-1.1B-Chat-v1.0" --host 0.0.0.0 --port 8000
서버가 뜨면 OpenAI 호환 엔드포인트로 호출해 봅니다.
curl -s -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
"messages":[{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Where is New York? Tell me in a single sentence."}],
"max_tokens": 32,
"temperature": 0
}' | jq
{
"id": "chatcmpl-6270b7ac7ce9495881ae089501de43e8",
"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
"choices": [
{ "index": 0,
"message": { "role": "assistant",
"content": "New York is a city in the United States that is known for its iconic landmarks, diverse culture, and world-class cuisine." },
"finish_reason": "stop",
"avg_decoded_tokens_per_iter": 1.0 }
],
"usage": { "prompt_tokens": 41, "total_tokens": 72, "completion_tokens": 31,
"prompt_tokens_details": { "cached_tokens": 40 } }
}
응답에서 눈여겨볼 필드가 두 개 있습니다. prompt_tokens_details.cached_tokens가 40이라는 것은 프롬프트 41토큰 중 40토큰이 캐시에서 재사용되었다는 뜻이고, avg_decoded_tokens_per_iter는 한 번의 반복에서 평균 몇 개의 토큰을 디코딩했는지를 나타냅니다. 추측 디코딩이 동작하면 이 값이 1보다 커집니다.
실습이 끝나면 컨테이너를 정리합니다.
docker rm -f trtllm-server
SGLang
SGLang은 LLM과 비전-언어 모델(VLM)을 위한 오픈소스 고성능 서빙 프레임워크입니다. 네 프레임워크 중 가장 늦게 등장한 축에 속하며, 구조화된 생성(structured generation)과 에이전트 애플리케이션을 처음부터 타깃으로 삼았다는 점에서 성격이 뚜렷합니다. 공식 홈페이지, 문서, LMSYS 블로그에서 최신 소식을 확인할 수 있습니다.
백엔드와 프론트엔드의 공동 설계
SGLang의 설계 철학은 빠른 백엔드 런타임(커널, 캐싱, 스케줄링)과 유연한 프론트엔드 언어 및 API를 함께 설계하는 것입니다. 프론트엔드에서 표현한 프로그램 구조를 백엔드가 알아볼 수 있게 만들어, 생성을 더 빠르고 더 제어 가능하게 합니다. 프론트엔드는 OpenAI 호환 API와 네이티브 API를 모두 제공합니다.
이 구조 덕분에 SGLang은 JSON과 정규식, EBNF 문법으로 출력 형식을 제약하는 구조화된 생성과 여러 단계를 거치는 에이전트 워크플로우에서 강점을 보입니다.
RadixAttention
SGLang의 대표 기능은 RadixAttention입니다. 여러 호출에 걸친 KV 캐시를 래딕스 트리(radix tree) 구조로 관리해서, 공통 프리픽스를 가진 요청들이 이미 계산된 KV를 그대로 재사용하도록 합니다.
시스템 프롬프트가 길고 대화가 길게 이어지는 멀티턴 시나리오, 같은 도구 정의를 매번 앞에 붙이는 에이전트 시나리오처럼 반복 프리픽스가 많은 워크로드에서 이 방식이 특히 유리합니다. vLLM과 SGLang이 같은 문제를 어떻게 다르게 풀었는지는 이 글에 잘 정리되어 있습니다.
기능과 하드웨어 지원
| 구분 | 내용 |
|---|---|
| 캐싱 | RadixAttention 기반 프리픽스 및 KV 재사용, 페이지드 KV |
| 배칭 | 연속 배칭, 청크드 프리필 |
| 디코딩 | 추측 디코딩(EAGLE-2, EAGLE-3) |
| 출력 제어 | JSON, 정규식, EBNF 기반 구조화된 출력 |
| 어댑터 | 멀티 LoRA |
| 병렬화 | 텐서, 파이프라인, 전문가(expert), 데이터 병렬화 |
| 하드웨어 | NVIDIA, AMD Instinct, CPU, TPU, Jetson Orin, Ascend |
하드웨어 지원 범위가 TensorRT-LLM보다 훨씬 넓다는 점이 눈에 띕니다. 성능은 vLLM과 경쟁할 만한 수준이지만, 2026년 현재 기준으로 vLLM이 여전히 더 넓은 커뮤니티와 생태계를 갖고 있다는 것이 두 프레임워크의 실질적인 차이로 언급됩니다.
고수준 Engine API
API 형태는 vLLM, TensorRT-LLM과 크게 다르지 않습니다.
# Qwen3 모델 로드
llm = sgl.Engine(model_path="Qwen/Qwen3-7B")
prompts = [
"Hello, my name is",
"The president of the United States is",
"The capital of France is",
"The future of AI is",
]
sampling_params = {"temperature": 0.8, "top_p": 0.95}
# 모델 생성 요청 실행
outputs = llm.generate(prompts, sampling_params)
for prompt, output in zip(prompts, outputs):
print("===============================")
print(f"Prompt: {prompt}\nGenerated text: {output['text']}")
QuickStart 실습
Llama 계열처럼 게이트가 걸린 모델을 쓰려면 Hugging Face gated repo 설정에서 접근 승인을 먼저 받아야 합니다. 승인까지 20~30분 정도 걸립니다.
실습은 이미지를 받고(lmsysorg/sglang:latest 또는 40% 정도 작은 latest-runtime), 컨테이너를 detached로 띄워 launch_server를 실행한 뒤, curl과 OpenAI Python 클라이언트, 네이티브 /generate API 순으로 검증하는 흐름입니다. 가이드 원문의 도커 예제는 다음과 같습니다.
docker run --gpus all --shm-size 32g -p 30000:30000 \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=<secret>" --ipc=host \
lmsysorg/sglang:latest python3 -m sglang.launch_server \
--model-path meta-llama/Llama-3.1-8B-Instruct \
--host 0.0.0.0 --port 30000
여기서는 게이트 승인을 기다리지 않아도 되는 작은 모델로 바꿔서 detached로 띄웠습니다.
docker pull lmsysorg/sglang:latest
# lmsysorg/sglang:latest 9e148f5ac788 DISK USAGE 47.4GB CONTENT SIZE 14.2GB
docker run -d --name sglang-server \
--gpus all --shm-size 32g \
-p 30000:30000 \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--ipc=host \
lmsysorg/sglang:latest python3 -m sglang.launch_server \
--model-path qwen/qwen2.5-0.5b-instruct \
--host 0.0.0.0 --port 30000
먼저 OpenAI 호환 엔드포인트를 확인합니다.
curl -sS http://localhost:30000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "qwen/qwen2.5-0.5b-instruct",
"messages": [{"role": "user", "content": "What is the capital of France?"}]}' | jq
{
"id": "a840b837009d4b3dad206411a519683c",
"model": "qwen/qwen2.5-0.5b-instruct",
"choices": [
{ "index": 0,
"message": { "role": "assistant", "content": "The capital of France is Paris." },
"finish_reason": "stop", "matched_stop": 151645 }
],
"usage": { "prompt_tokens": 36, "total_tokens": 44, "completion_tokens": 8 }
}
OpenAI Python 클라이언트에서도 base_url을 http://127.0.0.1:30000/v1로 바꾸기만 하면 같은 엔드포인트가 그대로 붙습니다. 기존 애플리케이션 코드를 고치지 않아도 된다는 것을 확인하는 단계입니다.
마지막으로 SGLang 네이티브 /generate API를 호출합니다. OpenAI 호환 응답보다 훨씬 많은 내부 정보를 돌려주기 때문에, 캐시가 실제로 동작했는지 확인할 때 유용합니다.
curl -sS -X POST http://localhost:30000/generate \
-H "Content-Type: application/json" \
-d '{"text": "The capital of France is", "sampling_params": {"temperature": 0, "max_new_tokens": 32}}' | jq
{
"text": " Paris. It is the largest city in Europe and the second largest city in the world. It is located in the south of France, on the banks of the",
"meta_info": {
"id": "3feed15121284a7691868f0465ce5392",
"finish_reason": { "type": "length", "length": 32 },
"prompt_tokens": 5, "completion_tokens": 32,
"cached_tokens": 4, "cached_tokens_details": { "device": 4, "host": 0 },
"e2e_latency": 0.14213981700004297
}
}
meta_info의 cached_tokens가 4라는 것은 프롬프트 5토큰 중 4토큰의 KV가 재사용되었다는 뜻이고, cached_tokens_details는 그 캐시가 GPU(device)에 있었는지 호스트 메모리(host)에 있었는지를 구분해서 보여줍니다. 계층 캐시를 쓰는 구성에서는 이 두 값의 비율이 튜닝 지표가 됩니다.
docker rm -f sglang-server
서빙 실전 케이스북에서 읽어낼 것
사이오닉 직원분이 정리한 SGLang 서빙 실전 케이스북에는 실제 측정에 기반한 결론이 여러 개 담겨 있습니다. 벤치마크 숫자보다 그 숫자가 왜 그렇게 나왔는지를 읽는 연습에 좋은 자료입니다.
가장 인상적인 결론은 모델 선정이 모든 설정 튜닝의 합보다 크다는 것입니다. 27B 모델을 9B로 한 번 바꾼 것만으로 2.75배 이득을 봤는데, 그 뒤 세 라운드에 걸친 설정 튜닝을 전부 합쳐도 1.6배에 그쳤습니다. 튜닝을 시작하기 전에 이 작업에 정말 이 크기의 모델이 필요한지를 검증셋으로 먼저 확인해야 한다는 이야기입니다.
라우팅에 대한 결론도 비슷한 성격입니다. 복제를 나누고 캐시 인지 라우팅을 적용했더니 같은 하드웨어에서 라우팅 정책 하나로 TTFT가 2.8배 차이 났습니다. 접두사 공유가 큰 워크로드에서 라운드로빈은 캐시를 무력화하기 때문에, 워커를 늘리기 전에 라우팅부터 확인해야 합니다. 라우팅 정책이 캐시 전략의 일부라는 관점입니다.
프롬프트 구조가 인프라 설정보다 큰 효과를 낸 사례도 있습니다. 매 요청마다 달라지는 값을 프롬프트 앞에서 뒤로 옮긴 것만으로 TTFT가 절반이 되었습니다. 프리픽스 캐싱은 앞에서부터 일치하는 구간만 재사용할 수 있으므로, 변하는 값이 앞에 있으면 캐시가 거의 동작하지 않습니다. 서빙팀과 애플리케이션팀이 분리되어 있으면 이런 문제를 아무도 발견하지 못한 채 하드웨어만 늘리게 됩니다.
나머지 결론들도 짚어 둘 만합니다.
| 주제 | 결론 |
|---|---|
| 텐서 병렬화 크기 | TP는 개별 요청 지연을 낮추지만 통신 비용을 늘리고 병렬 배치 기회를 줄입니다. ITL SLO에 여유가 있다면 TP를 줄이고 복제를 늘리는 쪽이 처리량과 TTFT 양쪽에 유리합니다. 케이스마다 결론이 달라지므로 반드시 실측해야 합니다. |
| HiCache | 중앙값보다 꼬리값을 개선합니다. 이미 캐시된 요청은 그대로이고 미스가 났을 때 복원되기 때문에, p99 SLO가 있는 서비스에서 특히 값어치가 있습니다. |
| 계층 캐시 | 도입 전에 손익 분기를 계산해야 합니다. 계산 속도와 전송 비율이 1을 넘지 않으면 순손해입니다. |
| 추측 디코딩 | 이 케이스에서는 출력이 아니라 입력 최적화가 필요했으므로 디코드 최적화인 추측 디코딩을 뺐습니다. |
| 구조화 출력 | 점프 포워드 디코딩이 이미 결정된 토큰을 모델 호출 없이 방출하므로, 구조 비중이 큰 JSON 출력은 제약 없는 생성보다 오히려 빠를 수 있습니다. 제약을 비용으로만 보면 이 이득을 놓칩니다. |
| MoE와 dense | 출력 400토큰에 동시성 25인 케이스에서는 dense가, 출력 120토큰에 동시성 40인 케이스에서는 MoE가 유리했습니다. 판단 기준은 배치가 임계 배치에 얼마나 가까운지와 출력 길이입니다. |
대형 MoE 서빙 사례도 숫자를 따라가 보면 구성의 이유가 드러납니다. 모델 가중치가 FP8로 284GB이고 H200 한 장이 141GB이므로 최소 3장이면 가중치는 들어가지만 KV 캐시 여유가 거의 남지 않습니다. 전문가 256개를 나눠 담으려면 GPU 수가 충분해야 하는데, 32 GPU면 전문가를 GPU당 8개씩 균등 배분할 수 있습니다. 더 크게 묶지 않는 이유는 인스턴스가 커질수록 all-to-all 통신 범위가 넓어져 노드 간 InfiniBand를 더 타게 되기 때문입니다. 32 GPU는 노드 4대이므로 통신의 상당 부분이 노드 내부 NVLink에서 처리됩니다.
같은 하드웨어에서 TP만 쓴 구성과 EP에 PD 분리를 더한 구성의 차이가 3배까지 벌어졌습니다. 대형 MoE에서는 하드웨어를 늘리는 것보다 병렬화 전략을 다시 설계하는 쪽이 원가에 훨씬 큰 영향을 줍니다.
llama.cpp
llama.cpp는 노트북과 워크스테이션부터 온프레미스 서버, 엣지 디바이스까지 거의 모든 머신에서 최신 오픈 웨이트 LLM을 효율적으로 실행하도록 만들어진 가벼운 오픈소스 C/C++ 추론 스택입니다. 홈페이지와 문서에서 사용법을 확인할 수 있습니다.
앞의 세 프레임워크가 데이터센터 GPU에서의 고처리량과 저지연 서빙을 최우선으로 최적화한다면, llama.cpp는 이식성과 단순성, 비용 효율성을 우선합니다. 절대적인 최고 처리량을 노리지 않는 대신 운영 부담을 거의 없애는 방향입니다.
어디서나 실행된다는 철학
모델은 GGUF 포맷으로 패키징됩니다. 보통 저장소에 포함된 스크립트로 Hugging Face 체크포인트에서 변환합니다. 변환된 모델은 내장 OpenAI 호환 HTTP 서버로 서빙하거나, 실험과 벤치마킹용 소형 CLI 도구로 바로 구동할 수 있습니다.
이 프로젝트가 강조하는 것은 최소한의 의존성과 빠른 시작 시간, 공격적인 정수 양자화(예: 8비트, 6비트, 5비트, 4비트), 이식 가능한 CPU/GPU 백엔드 집합입니다. 기본은 SIMD를 사용하는 CPU이고, Metal과 CUDA/ROCm, Vulkan 같은 가속기는 선택적으로 붙입니다.
| 항목 | 내용 |
|---|---|
| 구현 | 가벼운 C/C++ 스택, 최소 의존성, 빠른 시작 |
| 모델 포맷 | GGUF(Hugging Face 체크포인트에서 변환) |
| 양자화 | 공격적인 2~8비트 정수 양자화 |
| 하드웨어 | CPU(SIMD) 기본, Metal/CUDA/ROCm/Vulkan 선택적 가속 |
| 인터페이스 | 내장 OpenAI 호환 HTTP 서버, CLI, llama_cpp 파이썬 바인딩 |
| 상위 래퍼 | Ollama가 llama.cpp를 감싸 더 쉬운 REST API 경험을 제공 |
코드 예시
파이썬 바인딩으로 CPU에서 Qwen3 모델을 실행하는 예시입니다. GGUF 파일을 Hugging Face에서 바로 받아 로드합니다.
from llama_cpp import Llama
# llama.cpp로 CPU에서 Qwen3 모델 실행
# Hugging Face에서 Qwen 모델(GGUF 포맷)을 로드
llm = Llama.from_pretrained(
repo_id="Qwen/Qwen3-8B-GGUF",
filename="*Q8_0.gguf",
verbose=False
)
# 고수준 API로 LLM 생성 실행
output = llm(
"Q: Name the planets in the solar system? A: ", # 프롬프트
max_tokens=32, # 최대 32개 토큰 생성
stop=["Q:", "\n"], # 모델이 새 질문을 생성하기 직전에 멈춤
echo=True # 출력에 프롬프트를 그대로 포함
)
# 채팅 완성 API 예시
output = llm.create_chat_completion(
messages=[
{"role": "system", "content": "You are an assistant who perfectly describes images."},
{"role": "user", "content": "Describe this image in detail please."},
])
로컬 추론의 다른 최적화 목표
모든 LLM 애플리케이션이 고처리량 서빙을 필요로 하지는 않습니다. 추론을 온디바이스나 온프레미스 엣지에서 실행할 때는 목표 자체가 달라집니다.
- 플릿 전체의 초당 토큰 수가 아니라 지연시간과 응답성을 최적화하게 됩니다.
- 동시성이 낮고 흔히 단일 사용자이므로 대규모 배칭과 복잡한 스케줄러의 가치가 줄어듭니다.
- 메모리, 연산, 전력 측면의 풋프린트와 비용이 주요 제약이 됩니다.
- 프라이버시와 오프라인 신뢰성이 최우선 요구사항이 됩니다.
llama.cpp는 이 프로필에 잘 들어맞습니다. CPU와 Apple Silicon, 소형 GPU에서 오픈 웨이트 모델을 직접 실행할 수 있고 드롭인 통합을 위한 OpenAI 호환 서버를 제공하므로, 토큰당 클라우드 요금이나 데이터 유출 없이 서빙할 수 있습니다. 추천되는 시나리오는 세 가지입니다.
- 로컬 개발: 내장 서버를 자신의 머신에서 돌리고 기존 클라이언트(RAG, 검색 시스템 등)를 그대로 재사용합니다.
- 프라이빗 및 온프레미스 어시스턴트: 모든 데이터를 VPC나 디바이스 경계 안에 둡니다.
- 엣지 추론: 노트북과 데스크톱, Apple Silicon, 소형 서버에서 2~8비트 양자화로 실행합니다.
llama.cpp를 REST API로 노출하고 싶다면 상위 레벨 프레임워크인 Ollama를 쓸 수 있습니다. Ollama는 llama.cpp를 감싸고(때로는 Mistral.cpp나 RWKV 러너 같은 다른 백엔드도 함께) 로컬 LLM 사용을 간단하고 일관되게 만들어 줍니다.
프레임워크 선택 기준
여기까지 네 개의 프레임워크를 살펴봤습니다. 실무에서 던져야 할 질문은 내 SLO와 워크로드, 운영 현실에 어떤 프레임워크가 맞는가입니다. 벤치마크 1위는 그 벤치마크의 조건에서 1위일 뿐입니다.
평가 접근법 6단계
책이 권하는 평가 순서는 다음과 같습니다.
- 기능 목록보다 SLO를 먼저 정의합니다. 지연시간(TTFT, p95/p99), 처리량(TPS/QPS), 토큰당 비용, 품질 제약(구조화된 JSON, 안전성), 가용성에 대한 목표를 먼저 적어 둡니다.
- 실제 프롬프트를 분석합니다. 프리필 위주인지 디코드 위주인지, 컨텍스트 길이는 어느 정도인지, 도구 호출이나 멀티턴 체인이 포함되는지를 명확히 합니다.
- 동일한 조건으로 비교합니다. 후보 프레임워크에 같은 모델, 같은 dtype과 양자화, 같은 최대 시퀀스 길이, 같은 배치와 동시성, 같은 스트리밍 설정을 적용해 조건을 맞춥니다.
- 운영성을 측정합니다. 콜드 스타트 시간, 관측 가능성, 오토스케일링 동작, 멀티테넌시 공정성, 업그레이드 마찰, 장애 모드를 지표로 삼습니다.
- 하드웨어와 벤더 종속을 고려합니다. NVIDIA, AMD, CPU, TPU, 엣지처럼 여러 벤더를 쓴다면 이식성의 비중이 크게 올라갑니다.
- 변화를 계획합니다. 모델 교체와 새로운 디코딩 기법이 매주 나오므로, 큰 수술 없이 업데이트할 수 있는 프레임워크를 고릅니다.
네 프레임워크 비교
| 구분 | vLLM | TensorRT-LLM | SGLang | llama.cpp |
|---|---|---|---|---|
| 설계 철학 | 모델과 하드웨어에 무관한 범용 서빙, 계층화된 최적화 | NVIDIA 하드웨어에서 낼 수 있는 최대 실전 성능 | 백엔드 런타임과 프론트엔드 언어의 공동 설계 | 어디서나 실행, 최소 운영 부담 |
| 강점 | 강력한 기본 성능, 가장 큰 오픈소스 생태계와 커뮤니티 | 엔진 사전 컴파일, 깊은 CUDA 및 Tensor Core 최적화 | RadixAttention 프리픽스 재사용, 구조화된 출력, 스케일아웃 라우터 | GGUF 양자화, 작은 풋프린트, 빠른 시작 |
| 하드웨어 | 다양한 가속기 | NVIDIA GPU 전용 | NVIDIA, AMD, CPU, TPU, Jetson, Ascend | CPU 기본, Metal/CUDA/ROCm/Vulkan 선택 |
| 적합한 워크로드 | 일반적인 온라인 서빙, 폭넓은 모델 커버리지 | 대규모 프로덕션, 달러당 최고 토큰 수 | 에이전틱 및 다단계 워크플로우, JSON/문법 제약 출력 | 로컬 개발, 프라이빗 어시스턴트, 엣지 추론 |
| 운영 난이도 | 파이썬 네이티브 워크플로우로 진입이 쉬움 | 엔진 빌드 단계와 Triton/Dynamo 스택 운영이 추가됨 | 라우팅과 병렬화 설계를 함께 다뤄야 함 | 가장 낮음, 단일 바이너리에 가까운 배포 |
실무에서 흔한 패턴은 온라인 서빙에 vLLM을, 로컬 개발에 llama.cpp를 쓰는 조합입니다. 여기에 엣지 배포나 NVIDIA 중심 최적화, 엄격한 JSON 및 문법 제약 출력, 에이전트 파이프라인 같은 특정 요구사항이 붙으면 TensorRT-LLM이나 SGLang으로 방향이 바뀝니다.
끝으로
이번 장은 범용 머신러닝 서빙 스택이 왜 LLM에 부족한지에서 출발했습니다. 토큰 단위 스케줄링, KV 캐시 관리, 긴 컨텍스트 메모리 처리, 스트리밍 우선 실행 같은 LLM 특화 기능이 없기 때문입니다.
그다음 vLLM의 아키텍처와 요청 스케줄링, 그리고 시스템 전반의 스케줄링을 모델과 커널 레벨의 특화와 분리하는 계층화된 최적화 전략을 살펴봤습니다. 이어서 이를 보완하는 세 프레임워크를 다뤘습니다. NVIDIA GPU에서 최고 효율을 끌어내는 TensorRT-LLM, 경량 로컬과 온프레미스, 엣지 서빙을 담당하는 llama.cpp, 다양한 하드웨어에서 에이전트형 다단계 워크플로우와 구조화된 출력을 지원하는 SGLang입니다.
프레임워크 선택은 맥락에 달려 있고, 일회성 결정이 아니라 지속적인 재평가 과정입니다. LLM 서빙 생태계가 매달 바뀌므로 서빙 엔지니어는 3~6개월 주기로 프레임워크를 재검토하고, 추상화 레이어와 탈출 계획을 마련해 앱 코드를 다시 쓰지 않고도 프레임워크를 교체할 수 있어야 합니다.
목표는 영원한 승자를 정하는 것이 아니라 유연성을 유지하는 것이며, 이는 앞서 살펴본 vLLM의 계층화된 최적화 철학과 같은 맥락의 원칙입니다.
실무적으로 프레임워크 무관성(framework-agnostic)을 유지한다는 것은 세 가지를 뜻합니다. 3~6개월 주기로 프레임워크를 다시 조사하고, 이식 가능한 인터페이스를 선호하며, 비즈니스와 기술적 요구가 바뀔 때 컴포넌트를 교체할 준비를 해 두는 것입니다.
다음 장에서는 이번 장에서 살펴본 원리를 vLLM 성능 튜닝에 적용해 봅니다. 배치 크기와 KV 캐시 설정, 병렬화 파라미터를 바꿔가며 처리량과 지연시간이 어떻게 움직이는지 측정하는 방법을 다룰 예정입니다.