[CloudNeta] Hands-On LLM Serving 2주차 part 1 - 모델 서빙 시스템 설계
이 글은 2주차 연재의 첫 번째 편입니다.
- part 1 - 모델 서빙 시스템을 간단히 짜보기 (이 글)
- part 2 - 모델 서빙 시스템의 베스트 케이스
들어가며
이번 편의 목표는 1장에서 살펴본 기본 개념을 토대로 최소한의 원칙을 통해 서빙시스템의 코드를 작성해보는 것이 목표입니다.
오픈소스 서빙 프레임워크는 정말 많습니다. vLLM이든 NVIDIA Triton 이든 LiteLLM이든 선택을 위해선 기본기를 알아야하니 이를 알고 선택할 수 있도록 살펴보겠습니다. 먼저 이번 작업을 위해서는 아래 링크의 코드를 기본으로 살펴보겠습니다.
- 작가의 GitHub 링크: https://github.com/orca3/llm-model-inference
- 제가 작업한 코드의 GitHub 링크: https://github.com/s3ich4n/llm-model-inference-study
이번에는 배치, 스트리밍, 라우팅, 격리, 리소스 관리에 대해 살펴봅니다. 특히 단독모델, 멀티모델 서비스를 함께 살펴 볼 예정입니다.
모델 서빙은 단순히 모델의 generate() 함수를 호출하는 것이 아니라, API 처리·요청 추적·배치·스트리밍·프로세스 격리·메모리 관리·라우팅·확장·장애 복구를 함께 설계하는 시스템 엔지니어링임을 확인해봅시다.
LLM 서빙 서비스를 뼈대부터
시스템 구성요소
먼저 책에 나오는 초기 LLM 서빙 시스템의 6가지 구성요소를 살펴봅시다.
(이미지 001)
- API 서버 - HTTP로 오는 요청과 응답을 받는 엔드포인트입니다. 배치/스트리밍 양 형식으로 받게 되어있죠.
- LLM 엔진 - LLM 서비스의 전체를 총괄합니다
- 워크로드 매니저 - 요청을 큐로 관리하고 배치 구성을 관리하고, 어떤 배치 전략을 적용할지 적용하는 지점입니다. 즉 언제 어떤 요청을 묶어서 배치로 보낼지를 결정하는 스케줄러입니다
- 모델 실행기(executor) - 모델 워커 프로세스를 초기화 및 관리하고, 프로세스 간 통신으로 추론을 트리거합니다
- 모델 워커 - 실제 모델 추론을 자신의 별도 프로세스에서 실행합니다
- 모델 매니저 - 모델을 로드하고 캐싱합니다
전체 요청 흐름
초기 구조는 LLMEngine이 ModelExecutor와 WorkloadManager를 갖고, ModelExecutor가 worker process를 시작하는 형태입니다.
sequenceDiagram
participant Client
participant API as API server
participant Engine as LLM engine
participant Manager as Workload manager
participant Executor as Model executor
participant Worker as Model worker
(별도 프로세스)
Client->>API: 요청
API->>Engine: 요청 전달
Engine->>Manager: 작업 등록
Manager->>Executor: 추론 요청
Executor->>Worker: 추론 트리거
Worker-->>Manager: 생성 결과
Manager-->>Engine: 결과 전달
Engine-->>API: 응답
API-->>Client: 응답프로세스 공유 이유
flowchart LR
subgraph CPU["CPU 중심 Process"]
API["API Server"]
Queue["Queue·Scheduler"]
Pre["전처리·후처리"]
API -.-> Queue
Queue -.-> API
end
subgraph GPU["GPU 전용 Worker Process"]
Worker["Model Worker"]
Model["LLM Model"]
Worker -.-> Model
Model -.-> Worker
end
Queue -- "추론 Task" --> Worker
Worker -- "생성 결과" --> Queue- GPU는 비싼 리소스입니다. Idle 상태에 오래두면 비즈니스적으로 손해입니다
- 또한 토크나이징, 전/후처리 같은 CPU 작업이 GPU 스레드에서 작업되면, GPU는 그 작업이 끝날 때 까지 기다려야 합니다
- 이를 막기위해 아래와 같이 구성합니다
- 모델 워커는 GPU 전용 프로세스로 격리하고
- API 서버와 LLM엔진의 경우 CPU의 오케스트레이션을 담당하도록 분리합니다
- GPU는 계산만, CPU는 요청관리에 집중합니다
- 규모가 작은 예제부터 하는 것이 아니라, 실제로 GPU 서빙의 표준패턴에 가깝습니다
단일 모델 서비스
single-model serving 으로 단독 서비스가 하나의 모델을 실행하는 예시를 살펴봅시다.
.
├── Dockerfile
├── README.md
├── containers.py
├── docker-compose.yml
├── dtos.py
├── endpoints.py
├── llm
│ ├── __init__.py
│ ├── exceptions.py
│ ├── llm.py
│ ├── model_executor.py
│ ├── model_manager.py
│ ├── model_worker.py
│ └── workload_manager.py
├── logs.py
├── main.py
├── model_cache
├── pyproject.toml
├── tests
│ ├── test_api.py
│ ├── test_logs.py
│ └── test_vllm_unavailable.py
└── uv.lock
구동해보겠습니다. 제 PC는 아래 그래픽카드가 설치되어있어, CUDA구성이 된 채로 구동되네요.
➜ uv run uvicorn main:app --host 0.0.0.0 --port 8000
INFO 08-16 01:31:40 [__init__.py:243] Automatically detected platform cuda.
INFO: Started server process [7604]
INFO: Waiting for application startup.
2026-08-16 01:31:42,001 - logs - DEBUG - Model executor initialized
2026-08-16 01:31:42,001 - logs - DEBUG - Setting up worker with model: facebook/opt-125m
2026-08-16 01:31:42,001 - logs - DEBUG - Starting worker process
2026-08-16 01:31:42,010 - logs - DEBUG - Worker process started
2026-08-16 01:31:42,013 - logs - DEBUG - Waiting for debugger to attach...
2026-08-16 01:31:42,013 - logs - DEBUG - Debugger attached!
2026-08-16 01:31:42,044 - logs - DEBUG - Loading model facebook/opt-125m on device cuda
INFO 08-16 01:31:42 [__init__.py:31] Available plugins for group vllm.general_plugins:
INFO 08-16 01:31:42 [__init__.py:33] - lora_filesystem_resolver -> vllm.plugins.lora_resolvers.filesystem_resolver:register_filesystem_resolver
INFO 08-16 01:31:42 [__init__.py:36] All plugins in this group will be loaded. Set `VLLM_PLUGINS` to control which plugins to load.
2026-08-16 01:31:44,574 - logs - DEBUG - Worker initialized
2026-08-16 01:31:44,574 - logs - DEBUG - Waiting for batch from queue...
INFO 08-16 01:31:50 [config.py:793] This model supports multiple tasks: {'embed', 'reward', 'score', 'classify', 'generate'}. Defaulting to 'generate'.
INFO 08-16 01:31:50 [config.py:2118] Chunked prefill is enabled with max_num_batched_tokens=8192.
INFO 08-16 01:31:52 [core.py:438] Waiting for init message from front-end.
INFO 08-16 01:31:52 [core.py:65] Initializing a V1 LLM engine (v0.9.0.1) with config: model='facebook/opt-125m', speculative_config=None, tokenizer='facebook/opt-125m', skip_tokenizer_init=False, tokenizer_mode=auto, revision=None, override_neuron_config={}, tokenizer_revision=None, trust_remote_code=False, dtype=torch.float16, max_seq_len=2048, download_dir=None, load_format=LoadFormat.AUTO, tensor_parallel_size=1, pipeline_parallel_size=1, disable_custom_all_reduce=False, quantization=None, enforce_eager=False, kv_cache_dtype=auto, device_config=cuda, decoding_config=DecodingConfig(backend='auto', disable_fallback=False, disable_any_whitespace=False, disable_additional_properties=False, reasoning_backend=''), observability_config=ObservabilityConfig(show_hidden_metrics_for_version=None, otlp_traces_endpoint=None, collect_detailed_traces=None), seed=0, served_model_name=facebook/opt-125m, num_scheduler_steps=1, multi_step_stream_outputs=True, enable_prefix_caching=True, chunked_prefill_enabled=True, use_async_output_proc=True, pooler_config=None, compilation_config={"level": 3, "custom_ops": ["none"], "splitting_ops": ["vllm.unified_attention", "vllm.unified_attention_with_output"], "compile_sizes": [], "inductor_compile_config": {"enable_auto_functionalized_v2": false}, "use_cudagraph": true, "cudagraph_num_of_warmups": 1, "cudagraph_capture_sizes": [512, 504, 496, 488, 480, 472, 464, 456, 448, 440, 432, 424, 416, 408, 400, 392, 384, 376, 368, 360, 352, 344, 336, 328, 320, 312, 304, 296, 288, 280, 272, 264, 256, 248, 240, 232, 224, 216, 208, 200, 192, 184, 176, 168, 160, 152, 144, 136, 128, 120, 112, 104, 96, 88, 80, 72, 64, 56, 48, 40, 32, 24, 16, 8, 4, 2, 1], "max_capture_size": 512}
WARNING 08-16 01:31:52 [utils.py:2671] Methods determine_num_available_blocks,device_config,get_cache_block_size_bytes,initialize_cache not implemented in <vllm.v1.worker.gpu_worker.Worker object at 0x74bb36a55f70>
INFO 08-16 01:31:53 [parallel_state.py:1064] rank 0 in world size 1 is assigned as DP rank 0, PP rank 0, TP rank 0, EP rank 0
WARNING 08-16 01:31:53 [interface.py:344] Using 'pin_memory=False' as WSL is detected. This may slow down the performance.
WARNING 08-16 01:31:53 [topk_topp_sampler.py:58] FlashInfer is not available. Falling back to the PyTorch-native implementation of top-p & top-k sampling. For the best performance, please install FlashInfer.
INFO 08-16 01:31:53 [gpu_model_runner.py:1531] Starting to load model facebook/opt-125m...
INFO 08-16 01:31:53 [cuda.py:217] Using Flash Attention backend on V1 engine.
INFO 08-16 01:31:53 [backends.py:35] Using InductorAdaptor
INFO 08-16 01:31:53 [weight_utils.py:291] Using model weights format ['*.bin']
Loading pt checkpoint shards: 0% Completed | 0/1 [00:00<?, ?it/s]
Loading pt checkpoint shards: 100% Completed | 1/1 [00:00<00:00, 2.99it/s]
Loading pt checkpoint shards: 100% Completed | 1/1 [00:00<00:00, 2.99it/s]
INFO 08-16 01:31:54 [default_loader.py:280] Loading weights took 0.34 seconds
INFO 08-16 01:31:54 [gpu_model_runner.py:1549] Model loading took 0.2389 GiB and 1.068625 seconds
INFO 08-16 01:31:56 [backends.py:459] Using cache directory: /home/l4in/.cache/vllm/torch_compile_cache/c3df0a8780/rank_0_0 for vLLM's torch.compile
INFO 08-16 01:31:56 [backends.py:469] Dynamo bytecode transform time: 2.29 s
INFO 08-16 01:31:57 [backends.py:132] Directly load the compiled graph(s) for shape None from the cache, took 0.652 s
INFO 08-16 01:31:58 [monitor.py:33] torch.compile takes 2.29 s in total
INFO 08-16 01:31:58 [kv_cache_utils.py:637] GPU KV cache size: 155,840 tokens
INFO 08-16 01:31:58 [kv_cache_utils.py:640] Maximum concurrency for 2,048 tokens per request: 76.09x
INFO 08-16 01:32:16 [gpu_model_runner.py:1933] Graph capturing finished in 18 secs, took 1.24 GiB
INFO 08-16 01:32:16 [core.py:167] init engine (profile, create kv cache, warmup model) took 22.40 seconds
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
코드의 전반적인 구성은 어렵지 않습니다.
API 서버가 구성되어있고, 구동 시 LLMEngine() 을 실행하며 아래 요소를 초기화합니다.
ModelExecutor()- 워커 프로세스를multiprocessing패키지로 처리합니다.ModelWorker()로 별도의 모델 또한 프로세스를fork하여 구동합니다
WorkloadManager()- 요청에 따른 큐를 관리합니다.
Sequence클래스를 함께 가져서 시퀀스에 대한 정보와 프롬프트까지 함께 관리합니다. - 4개의 시퀀스를 동시에 처리할 수 있도록 하고, 들어오는 순서대로 처리하는 큐와 활성화 시퀀스를 보관합니다
- 시퀀스 맵 또한 별도로 관리합니다
- 요청에 따른 큐를 관리합니다.
facebook/opt-125m모델에 대한 초기화를 진행합니다LLMEngine.requests_processing_loop으로 별도 스레드를 Daemonize 합니다. 이를 통해 위에서 생성한WorkloadManager에 배치가 담겨있는지0.1초 마다 지속적으로 확인하는 무한루프를 구동합니다- 만약 존재한다면 입력값을 모델이 받도록 하는
- "스트리밍" 형태인지 확인 후 스트리밍/비스트리밍 큐에 추가하고 처리합니다
- 결과를 받으면 이를 대응하고 리턴합니다
엔드포인트별 처리 방안
/basic_generate- LLMEngine에 프롬프트를 주고
Sequence형태로 가공 후 배치 실행을ModelExecutor에 맡깁니다. - 이는 task_queue (프로세스를
fork하는 큐)에 추가되고 완료되는 대로 꺼내옵니다.
- LLMEngine에 프롬프트를 주고
-
/generate /generate_stream- FastAPI의 이벤트 루프를 전달하고 비동기 처리 큐를 생성합니다
WorkloadManager
/generate_vllm- 위의 예시와 달리 vLLM을 이용한 처리를 위해
SamplingParams로 샘플링을 진행한 후 vLLM으로 구동합니다. - GPU가 없으면 엔진 초기화 때 vLLM을 건너뛰고
self.vllm_model = None으로 두었다가, 호출 시VLLMUnavailableError를 던져 503으로 응답합니다.나머지 세 엔드포인트는 CPU에서도 그대로 동작합니다.
- 위의 예시와 달리 vLLM을 이용한 처리를 위해
이를 표로 나타내면 아래와 같습니다:
| 일괄(batch) 경로 | 스트리밍(forward) 경로 | |
|---|---|---|
| 진입 | 요청 스레드에서 직접 실행 | LLMEngine 생성 시 뜨는 백그라운드 스레드 |
| 실행기 API | execute_batch() |
execute_forward_batch() |
| 워커 메서드 | generate() |
generate_forward_batch() |
| 1회 호출당 생성량 | max_new_tokens=50, 끝까지 한 번에 |
딱 1토큰 |
| 큐 | incoming_queue / active_sequences |
incoming_streaming_queue / active_streaming_sequences |
| 결과 태그 | ('complete', ...) |
('stream', ...) |
멀티 모델 서비스
앞의 예제가 "하나의 모델을 어떻게 잘 굴릴까"였다면, 이번 예제는 "여러 모델을 한정된 자원에 어떻게 얹을까"입니다. 같은 3장이지만 푸는 문제가 완전히 다릅니다.
이 절은 작가 저장소의 ch03/multi_model_serving을 그대로 읽습니다. 앞 절과 달리 제가 재구성한 코드가 아니라 원본입니다.
문제가 바뀝니다: 처리량에서 용량으로
단일 모델 서빙에서 우리가 싸웠던 상대는 GPU 연산 시간이었습니다. 그래서 배치를 묶고, 스트리밍으로 토큰을 흘리고, 스케줄러를 뒀습니다.
멀티 모델 서빙에서 상대는 메모리입니다. 모델이 10개인데 VRAM에는 2개밖에 안 들어간다면, "지금 어떤 모델을 올려둘 것인가"가 유일하고도 전부인 질문이 됩니다. 그래서 이 예제에는 큐도, 배치도, 스트리밍도, 워커 프로세스도 없습니다. 대신 LRU 캐시 하나가 시스템의 심장입니다.
.
├── app
│ ├── server.py # FastAPI 엔드포인트 (/predict, /models)
│ ├── store.py # 모델 메타데이터 (models.json 로드)
│ ├── manager.py # LRU 캐시 + 로드/언로드 수명주기
│ ├── engine.py # 프레임워크별 워커 팩토리
│ └── worker.py # 추상 워커 + 프레임워크별 구현 4종
├── config
│ └── models.json # 모델 카탈로그
├── model_dir # Triton 모델 리포지터리
│ └── densenet_onnx
│ ├── 1/model.onnx
│ ├── config.pbtxt
│ └── densenet_labels.txt
├── requirements.txt
└── tests
├── test_models.py
└── test_triton_densenet.py
전체 애플리케이션 코드가 140여 줄입니다. 작아서 좋은 게 아니라, 작아서 각 계층의 책임이 딱 하나씩 보인다는 점이 좋습니다.
다섯 개의 계층
flowchart TD
Client(["Client"]) -->|"POST /predict
{model_id, input_data}"| Server["Server
(server.py)"]
Server -->|"get_model_worker(model_id)"| Manager["Manager
(manager.py)
LRU cache, max 2"]
Manager -->|"get_model(model_id)"| Store["Store
(store.py)
models.json"]
Store -->|"ModelMetadata"| Manager
Manager -->|"create_worker(metadata)"| Engine["Engine
(engine.py)
Factory"]
Engine --> TW["TransformerWorker"]
Engine --> VW["TorchVisionWorker"]
Engine --> RW["TritonWorker"]
RW -.->|"HTTP"| Triton[("Triton
Inference Server
:8009")]- Server - HTTP를 받고
model_id로 워커를 찾아predict()를 호출할 뿐입니다 - Store -
models.json을 읽어ModelMetadata를 들고 있는 읽기 전용 카탈로그입니다 - Manager - 캐시 정책을 담당합니다. 무엇을 올리고 무엇을 내릴지 결정합니다
- Engine - 메타데이터의
framework값을 보고 알맞은 워커를 만드는 팩토리입니다 - Worker - 프레임워크별 로딩/추론 구현체입니다. 인터페이스는
_load_model()/predict()딱 둘입니다
앞 예제의 ModelExecutor/ModelWorker와 이름은 비슷하지만 역할이 다릅니다. 여기서 워커는 프로세스가 아니라 객체입니다. 같은 파이썬 프로세스 안에서 모델을 들고 있을 뿐입니다.
요청 흐름
sequenceDiagram
participant C as Client
participant S as Server
participant M as Manager
(LRU cache)
participant St as Store
participant E as Engine
participant W as Worker
C->>S: POST /predict {model_id, input_data}
S->>M: get_model_worker(model_id)
alt 캐시 히트
M->>M: move_to_end(model_id)
M-->>S: worker
else 캐시 미스
M->>St: get_model(model_id)
St-->>M: ModelMetadata (없으면 None → 404)
opt 캐시가 가득 참
M->>M: popitem(last=False)
M->>E: delete_worker(오래된 id)
end
M->>E: create_worker(metadata)
E->>W: 모델 로드 (여기서 수 초~수십 초)
E-->>M: worker
M-->>S: worker
end
S->>W: predict(input_data)
W-->>S: {"predictions": [...]}
S-->>C: 200 OK중요한 건 모델 로딩이 요청 경로 한복판에 있다는 점입니다. 캐시에 없는 모델을 처음 부르는 클라이언트는 다운로드와 로딩이 끝날 때까지 그대로 기다립니다. 이게 멀티 모델 서빙의 고질적인 콜드 스타트 문제입니다.
모델 스토어: 메타데이터가 곧 라우팅 키
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "distilbert-base-uncased-finetuned-sst-2-english",
"type": "text",
"framework": "transformers",
"version": "1.0.0",
"description": "Sentiment analysis model"
}
framework 필드 하나가 어떤 워커 클래스를 쓸지 결정합니다. 새 프레임워크를 지원하려면 JSON에 값을 추가하고 ModelEngine에 분기 하나를 더하면 됩니다. 예제에 등록된 모델은 네 개입니다.
| 용도 | 모델 | 프레임워크 | 타입 |
|---|---|---|---|
| 감성 분석 | distilbert-base-uncased-finetuned-sst-2-english |
transformers | text |
| 스팸 탐지 | mrm8488/bert-tiny-finetuned-sms-spam-detection |
transformers | text |
| 이미지 분류 | pytorch/vision:mobilenet_v2 |
torchvision | image |
| 이미지 분류 | densenet_onnx |
triton | image |
이 예제의 모델은 하나도 생성 모델이 아닙니다. 그래서 KV 캐시도, 토큰 스트리밍도, 시퀀스 상태도 등장하지 않습니다. 멀티 모델 "관리"의 뼈대만 보여주는 예제로 읽어야 하고, LLM을 여러 개 얹는 이야기는 뒤의 라우팅/멀티 LoRA 편에서 따로 봐야 합니다.
모델 매니저: LRU 캐시 하나가 전부
이 예제에서 가장 중요한 30줄입니다.
class ModelManager:
def __init__(self, model_store: ModelStore, max_models: int = 2):
self.model_store = model_store
self.max_models = max_models
self.model_cache = OrderedDict() # id -> worker, 앞쪽이 오래된 것
self.model_engine = ModelEngine()
def get_model_worker(self, model_id: str) -> Optional[ModelWorker]:
if model_id in self.model_cache:
self.model_cache.move_to_end(model_id) # 최근 사용으로 갱신
return self.model_engine.get_worker(model_id)
model_metadata = self.model_store.get_model(model_id)
if not model_metadata:
return None # → 404
if len(self.model_cache) >= self.max_models:
id, model_worker = self.model_cache.popitem(last=False) # 가장 오래된 것 축출
self.model_engine.delete_worker(id)
self.model_cache[model_id] = self.model_engine.create_worker(model_metadata)
return self.model_cache[model_id]
OrderedDict의 move_to_end() + popitem(last=False) 조합이 LRU 그 자체입니다. max_models=2로 두었으니 실제 동작은 이렇게 흘러갑니다.
| 순서 | 요청 모델 | 캐시(오래된 → 최신) | 동작 |
|---|---|---|---|
| 1 | sentiment | [sentiment] |
미스 → 로드 |
| 2 | spam | [sentiment, spam] |
미스 → 로드 (한도 도달) |
| 3 | sentiment | [spam, sentiment] |
히트 → move_to_end |
| 4 | image | [sentiment, image] |
미스 → spam 축출 후 로드 |
테스트도 정확히 이 성질을 검증합니다. 모델 3개를 순서대로 부른 뒤 /models를 조회해 loaded_models가 2개 이하인지 확인합니다.
self.assertLessEqual(len(data["loaded_models"]), 2)
엔진과 워커: 팩토리 + 전략
ModelEngine은 framework 문자열을 클래스에 매핑하는 것 말고는 하는 일이 없습니다.
def create_worker(self, model_metadata: ModelMetadata) -> ModelWorker:
if model_metadata.id not in self.workers:
if model_metadata.framework == "transformers":
self.workers[model_metadata.id] = TransformerWorker(model_metadata)
elif model_metadata.framework == "torchvision":
self.workers[model_metadata.id] = TorchVisionWorker(model_metadata)
elif model_metadata.framework == "triton":
self.workers[model_metadata.id] = TritonWorker(model_metadata)
else:
raise ValueError(f"Unsupported framework: {model_metadata.framework}")
return self.workers[model_metadata.id]
워커 쪽은 추상 클래스가 계약을 강제합니다.
class ModelWorker(ABC):
def __init__(self, model_metadata):
self.model_metadata = model_metadata
self.model: Optional[torch.nn.Module] = None
self._load_model() # 생성 = 로드
@abstractmethod
def _load_model(self): ...
@abstractmethod
def predict(self, input_data: Any) -> Dict[str, Any]: ...
생성자가 곧 로딩이라는 점이 이 설계의 핵심입니다. 워커 객체의 수명이 곧 모델의 메모리 점유 기간이고, 그래서 매니저는 딕셔너리에서 참조를 지우는 것만으로 "언로드"를 표현할 수 있습니다.
| 워커 | 로딩 | 입력 | 출력 |
|---|---|---|---|
TransformerWorker |
AutoModelForSequenceClassification + 토크나이저 |
문자열 | {"predictions": [[p0, p1]]} |
TorchVisionWorker |
mobilenet_v2(weights=DEFAULT) + transforms.Compose |
파일 경로 또는 PIL 이미지 | {"predictions": [[...1000...]]} |
TritonWorker |
Triton 관리 API로 .../load 호출 |
{입력이름: {shape, data}} |
{"fc6_1": [...1000...]} |
세 워커의 predict() 시그니처는 같지만 입력의 의미가 서로 완전히 다릅니다. input_data: Any가 그 사실을 그대로 드러냅니다. 인터페이스를 통일했다기보다 타입 검사를 포기한 쪽에 가깝고, 실서비스라면 프레임워크별 요청 스키마를 나누는 편이 낫습니다.
Triton에 위임하기
TritonWorker가 이 예제에서 가장 실무적인 부분입니다. 앞의 두 워커가 인프로세스로 모델을 들고 있는 반면, 이쪽은 별도 서버에 로드/언로드를 위임합니다.
def _load_model(self):
load_url = f"http://{self.triton_url}/v2/repository/models/{self.model_metadata.name}/load"
response = requests.post(load_url)
if response.status_code != 200:
raise RuntimeError(f"Failed to load model: {response.text}")
if not self.client.is_model_ready(self.model_metadata.name):
raise RuntimeError("Model is not ready after loading")
def __del__(self):
unload_url = f"http://{self.triton_url}/v2/repository/models/{self.model_metadata.name}/unload"
requests.post(unload_url)
이게 가능한 이유는 Triton을 --model-control-mode=explicit으로 띄웠기 때문입니다. 이 모드에서 Triton은 기동 시 아무 모델도 올리지 않고, /v2/repository/models/{name}/load · /unload API로 외부에서 수명주기를 제어하게 열어둡니다.
docker run -p8009:8000 -p8010:8001 -p8011:8002 \
-v $(pwd)/model_dir:/models \
nvcr.io/nvidia/tritonserver:24.12-py3 \
tritonserver --model-repository=/models --model-control-mode=explicit
모델 리포지터리는 config.pbtxt로 텐서 계약을 선언합니다.
name: "densenet_onnx"
platform: "onnxruntime_onnx"
max_batch_size : 0
input [
{ name: "data_0" data_type: TYPE_FP32 format: FORMAT_NCHW dims: [ 3, 224, 224 ] }
]
output [
{ name: "fc6_1" data_type: TYPE_FP32 dims: [ 1000 ] label_filename: "densenet_labels.txt" }
]
여기서 얻는 교훈은 분명합니다. 우리가 ModelManager로 손수 짠 것(온디맨드 로드, 언로드, 준비 상태 확인)은 이미 Triton이 제품 수준으로 제공하는 기능입니다. 직접 짜본 이유는 그걸 대체하려는 게 아니라, Triton 같은 서버가 내부에서 무엇을 하고 있는지 알고 고르기 위해서입니다. 앞 절에서 vLLM을 마지막 엔드포인트로 붙여 대조군을 만든 것과 정확히 같은 구도입니다.
돌려보기
pip install -r requirements.txt
python -m app.server # 기본 8001 포트, PORT 환경변수로 변경 가능
# 카탈로그와 현재 적재 상태 확인
curl http://localhost:8001/models
# 감성 분석
curl -X POST http://localhost:8001/predict \
-H "Content-Type: application/json" \
-d '{"model_id": "550e8400-e29b-41d4-a716-446655440000", "input_data": "This movie was great!"}'
/models가 available_models(카탈로그 전체)와 loaded_models(현재 메모리에 있는 것)를 함께 돌려주는 게 재미있는 지점입니다. 요청을 몇 번 던지고 이 엔드포인트를 다시 조회하면 LRU가 움직이는 걸 눈으로 볼 수 있습니다.
이 예제가 감춘 것들
읽으면서 눈에 걸린 것들을 정리해 둡니다. 부족한 지점의 목록이 곧 "실서비스 멀티 모델 서빙이 추가로 해야 할 일"의 목록입니다.
- 개수 기준 캐시는 현실에서 안 통합니다.
max_models=2는 4MB짜리bert-tiny와 수십 GB짜리 모델을 똑같이 1로 셉니다. 실제 축출 정책은 바이트(VRAM) 기준이어야 하고, 그러려면 메타데이터에 모델 크기가 있어야 합니다. - 언로드가 실제 해제를 보장하지 않습니다.
delete_worker()는 딕셔너리에서 참조를 지울 뿐입니다. GPU 메모리를 확실히 돌려주려면 최소한torch.cuda.empty_cache()가 필요하고, 파편화까지 생각하면 프로세스 격리가 답인 경우가 많습니다. 앞 절에서 워커를 별도 프로세스로 뺐던 이유가 여기서도 유효합니다. __del__에 정리를 맡겼습니다.TritonWorker는 소멸자에서 unload를 호출하는데, 파이썬 소멸자는 호출 시점도 보장되지 않고 예외도 삼켜집니다(코드도except: pass입니다). 명시적인close()를 두고 매니저가 부르는 편이 안전합니다.- 동시성 보호가 없습니다.
ModelManager에 락이 없어서 같은 모델에 대한 요청 두 개가 동시에 오면 로딩이 중복되고, 축출 도중에 캐시 한도를 넘길 수도 있습니다. 게다가predict핸들러는async def인데 내부는 전부 블로킹 호출이라 이벤트 루프를 붙잡습니다. (단일 모델 예제와 똑같은 문제입니다) - 다운로드가 빠져 있습니다.
manager.py에 주석으로 남아 있습니다 -Skip the download implementation for simplicity. 실제로는 여기가 스토리지·레지스트리·무결성 검증이 붙는 가장 무거운 지점입니다. - 메타데이터가 로딩을 구동하지 않습니다.
models.json에pytorch/vision:mobilenet_v2라고 적혀 있지만TorchVisionWorker._load_model()은mobilenet_v2(...)를 하드코딩해 부릅니다. 이름을 바꿔도 로드되는 모델은 그대로입니다.TritonWorker의 출력 텐서명fc6_1과 서버 주소0.0.0.0:8009도 마찬가지로 코드에 박혀 있습니다. - 이미지 워커가 서버 로컬 경로를 엽니다.
input_data에 담긴 문자열을 그대로Image.open()에 넘깁니다. 외부에 노출되는 API라면 임의 파일 읽기 통로가 됩니다. 업로드나 URL 기반으로 바꿔야 합니다. - 관측 지점이 없습니다. 캐시 히트율, 로드 소요 시간, 축출 횟수 - 멀티 모델 서빙에서 가장 먼저 보고 싶은 세 숫자가 어디에도 기록되지 않습니다.
단일 / 멀티, 나란히 놓고 보기
| 단일 모델 서빙 | 멀티 모델 서빙 | |
|---|---|---|
| 핵심 질문 | 한 모델을 어떻게 빠르게 많이 돌릴까 | 여러 모델을 한정된 메모리에 어떻게 얹을까 |
| 병목 | GPU 연산 시간, 배치 효율 | 모델 로드 시간, 메모리 용량 |
| 핵심 자료구조 | Queue + Sequence (스케줄링) |
OrderedDict (LRU 캐시) |
| 배치 | O (batch_size=4) |
X (요청 1건 = 추론 1회) |
| 스트리밍 | O (SSE, 토큰 단위) | X (단발 응답) |
| 프로세스 격리 | O (fork한 워커 프로세스) |
X (인프로세스) / Triton만 외부 서버 |
| 모델 수 | 1개, 기동 시 로드 후 고정 | N개, 온디맨드 로드·언로드 |
| 모델 성격 | 텍스트 생성 (causal LM) | 분류 (텍스트·이미지) |
| 라우팅 기준 | 엔드포인트 | 요청 본문의 model_id |
| 상태 | 시퀀스별 토큰 상태 유지 | 무상태 |
| 대조군 | vLLM | Triton Inference Server |
두 예제를 겹쳐 보면 실제 서빙 시스템이 왜 복잡해지는지가 보입니다. 실서비스는 이 둘을 동시에 요구합니다. 여러 LLM을 얹으면서(용량 문제) 각각을 배치·스트리밍으로 굴려야 하고(처리량 문제), 그 둘이 같은 GPU 메모리를 놓고 싸웁니다. 다음 편에서 볼 라우팅과 격리는 이 충돌을 다루는 이야기입니다.