[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 서빙 서비스의 기본을 살펴봅시다. vLLM 없이 구동하는 것을 살펴보는 게 아무래도 가장 좋겠죠.
시스템 구성요소
먼저 책에 나오는 초기 LLM 서빙 시스템의 6가지 구성요소를 살펴봅시다.

- 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 서빙의 표준패턴에 가깝습니다
단일 모델 서비스
먼저 단독 서비스가 하나의 모델을 실행하는 예시를 살펴봅시다.
제가 백엔드 코드를 구성한다면 이렇게 할 것 같다 싶은 구조로 재구성하였고, 실제 코드를 한줄한줄 분석한 결과를 기재합니다.
이번 장을 이해해야, 어떤 식의 설계를 했고 vLLM의 추상화 레벨이 어디까지 진행했는지 이해하기 위함입니다. 보다 자세한 부분을 원하신다면 코드딥다이브경로 를 참고해주세요.
재구성한 포인트
- mise를 이용한 로컬 구동환경 관리
- uv를 이용한 의존성 관리
- 서비스 컨테이너화
코드구성
FastAPI의 일부 부분만 최소한의 분리과정을 마쳤습니다.
main.py- FastAPI 단독 앱을 서빙하는 구성을 가져갔습니다. 그 외 추가 예외처리는 가장 처리하기 용이한 곳에 두었습니다
containers.py- 실제 앱 구동 시의
LLMEngine()생성주기 관리는dependency-injector로 처리했습니다
- 실제 앱 구동 시의
llm/- 상술한 모델실행기, 모델워커 등 모델 관리를 직접 하기 위한 서비스들은 해당 위치에 그대로 두었습니다
logs.py,dtos.py,endpoints.py- 로그 작성 및 요청/응답을 관리하는 DTO, 엔드포인트는 별도로 분리하였습니다
나머지는 기존 코드배치와 크게 다르지 않습니다.
.
├── Dockerfile
├── README.md
├── containers.py
├── 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)
코드 딥다이브 경로
서빙 시스템의 설계 이야기만 따라가실 분은 이 절을 건너뛰고 다음 절로 바로 가셔도 됩니다.
1번은 이 예제 코드를 직접 고칠 분께,
2번 이하는 "블로킹이 공짜인가"가 궁금했던 분께 권합니다.
여기까지가 "일단 뜬다"의 확인입니다. 위 로그의 각 줄이 코드의 어디에 해당하는지, 그리고 Waiting for batch from queue...를 찍고 멈춘 워커 프로세스가 그 순간 실제로 무엇을 하고 있는지는 본문 흐름을 끊지 않도록 별도 문서로 분리했습니다. 관심 깊이에 따라 아래 순서로 내려가시면 됩니다.
flowchart TD
A["본편 (이 글)
구조와 구동 흐름"] --> B["코드 분석 (1)
애플리케이션 레벨"]
B --> C["워커는 왜 '서 있는가'
시스템콜·커널 레벨"]
C --> D1["토끼굴
_rlock의 정체"]
C --> D2["토끼굴
파이프와 Connection 계층"]| 깊이 | 문서 | 답하는 질문 |
|---|---|---|
| 1. 애플리케이션 | 코드 분석 (1) | LLMEngine() 하나를 부르면 무엇이 초기화되고 어디서 프로세스가 fork되는가. 배치 경로와 스트리밍 경로는 어디서 갈리며, 워커가 만든 토큰이 어떻게 FastAPI 이벤트 루프로 돌아와 SSE가 되는가 |
| 2. 시스템콜·커널 | 워커 프로세스는 왜 '서 있는가' (OS level) | 시간제한 없는 task_queue.get()이 왜 CPU를 0%만 쓰는가. Queue.get() → os.read() → read(2) → pipe_read()로 내려가 커널이 프로세스를 TASK_INTERRUPTIBLE로 재우는 지점을 표준 라이브러리·커널 소스·ps/wchan 으로 추적합니다 |
| 3. 토끼굴 | _rlock의 r은 Reentrant의 r인가 | multiprocessing.Queue의 _rlock은 재진입 잠금인가. 변수명만 보고 유추했다가 CPython 소스 3단계를 따라가 reader lock임을 확인한 기록입니다 |
| 3. 토끼굴 | 파이프와 Connection 계층 | task_queue/result_queue의 실체인 파이프는 무엇이고, connection.py의 종점이 왜 os.read인가. WSL이 윈도우 네이티브 경로를 타지 않는 이유와 메시지 프레이밍까지 |
엔드포인트별 처리 방안
LLM 가공로직은 아래의 별도 섹션으로 살펴봅니다.
다시말해 vLLM 등을 사용하여 관리하지 않으면, LLM 생성을 통해 아래 과정을 거칩니다.
PyTorch를 이용해 텐서 연산과 메모리 할당, 샘플링을 구현합니다.transformers를 이용해 사용하고자 하는 모델의 가중치, 토크나이저를 모델구동을 합니다.
엔드포인트는 세 개지만 모델도 하나, 워커 프로세스도 하나입니다. 다른 것은 거기 도달하는 경로입니다. 어디서 갈라지는지부터 보겠습니다.
flowchart TD
A["요청 도착"] --> B{"엔드포인트"}
B -->|/basic_generate| C["Sequence 하나 만들어
곧장 워커로"]
B -->|/generate| D["WorkloadManager 대기열
4칸 배치로 끊어서"]
B -->|/generate_stream| E["스트리밍 대기열에 등록
asyncio.Queue 생성"]
C --> F["execute_batch()"]
D --> F
E --> G["요청 핸들러는
await queue.get() 에서 잠든다"]
E -.->|"대기열에 남겨두고 손을 뗀다"| H["데몬 스레드
requests_processing_loop()"]
H --> I["execute_forward_batch()"]
F --> J["ModelWorker.run()"]
I --> J
J --> K{"is_streaming"}
K -->|False| L["generate()
model.generate · 50토큰 한 번에"]
K -->|True| M["generate_forward_batch()
model() · 토큰 1개"]
L --> N["부른 요청 핸들러가 그대로 받아
JSON 한 번으로 응답"]
M -.->|"매 토큰마다 큐에 넣어 깨운다"| G
G --> P["SSE 조각 전송"]
subgraph LG["범례"]
direction LR
X1["부른 쪽"] -->|"실선. 동기
결과가 올 때까지 기다린다"| X2["불린 쪽"]
Y1["넘긴 쪽"] -.->|"점선. 비동기
제어권을 넘기고 잠든다"| Y2["이어받은 쪽"]
end| 화살표 | 뜻 | 이 그림에서 |
|---|---|---|
| 실선 | 동기. 부른 쪽이 그 자리에서 결과를 기다린다. 기다리는 동안 그 실행 주체는 다른 일을 하지 못한다 | 일괄 경로 전체. execute_batch() 도 프로세스 경계를 넘지만 _wait_for_result() 로 결과를 기다리므로 동기다 |
| 점선 | 비동기. 제어권이 다른 실행 주체로 넘어가고, 넘긴 쪽은 잠들거나 손을 뗀다 | 두 곳뿐이다. 핸들러가 대기열에만 등록하고 빠지는 지점과, 데몬 스레드가 만든 토큰이 잠든 코루틴을 깨우는 지점이다 |
점선이 두 개뿐이라는 점이 요지입니다. /basic_generate 와 /generate 는 요청을 받은 자리에서 끝까지 실선으로 이어지고, /generate_stream 만 중간에 두 번 끊깁니다. 그 끊긴 자리마다 큐가 하나씩 놓여 있습니다.
갈림길은 두 군데뿐입니다. 나머지 차이는 전부 이 두 선택에서 따라 나오는 결과입니다. 각각 차이를 살펴보죠.
- 하나는 누가 실행하는가입니다. 요청을 받은 핸들러가 그 자리에서 끝까지 돌리기도 하고, 백그라운드 데몬 스레드에 넘겨버리기도 합니다.
- 다른 하나는 워커의 어느 메서드를 부르는가입니다. 한 번에 끝까지 만드는
generate()가 있고, 토큰 하나만 만들고 돌아오는generate_forward_batch()가 있습니다.
코드를 따라가는 순서
각 엔드포인트를 코드로 쫓을 때의 진입점과 순서입니다. 파일을 열어놓고 아래 순서대로 심볼을 따라가면서 살펴보세요.
| 엔드포인트 | 진입점 | 따라갈 순서 | 멈춰서 볼 지점 |
|---|---|---|---|
/basic_generate |
endpoints.py → basic_generate() |
LLMEngine.basic_generate() → Sequence(...) 직접 생성 → ModelExecutor.execute_batch() → ModelWorker.run() → ModelWorker.generate() |
WorkloadManager를 거치지 않는다. 대기열도 배치도 없이 시퀀스 하나가 곧장 워커로 간다 |
/generate |
endpoints.py → generate() |
LLMEngine.generate() → WorkloadManager.add_request() × n → while not _is_batch_finished() → get_next_batch() → execute_batch() → update_sequence_output(is_finished=True) |
while 루프의 종료 조건. 배치가 끝났는가가 아니라 내 request_id들이 끝났는가를 본다. 배치에 남의 프롬프트가 섞일 수 있다 |
/generate_stream |
endpoints.py → generate_stream() |
LLMEngine.event_generator() → asyncio.Queue() 생성 → add_streaming_request(prompt, queue, loop) → await queue.get()에서 잠듦 |
핸들러가 여기서 끝난다. 이후는 아래 줄의 데몬 스레드가 이어받는다 |
| ↳ (같은 요청) | LLMEngine.__init__에서 뜬 스레드 |
requests_processing_loop() → get_next_batch(is_streaming=True) → execute_forward_batch() → get_sequence(request_id) → run_coroutine_threadsafe(put(...), seq.loop) |
Sequence가 client_stream과 loop를 들고 있어서 어디로 돌려줄지를 안다. 이 한 줄이 스레드와 이벤트 루프를 잇는다 |
/generate와 /generate_stream이 특히 헷갈리는데, 나란히 놓으면 이렇습니다.
| 일괄(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', ...) |
| 프로세스 왕복 | 배치가 나뉜 만큼 (보통 1회) | 토큰마다 1회 (21회) |
| 대기 방식 | while 로 붙잡는다 |
await 로 비켜준다 |
"대기 방식"을 보면 둘의 차이를 확인할 수 있는데요.
- 일괄 경로는
async def핸들러 안에서 동기 함수를 부르므로 그동안 이벤트 루프가 멈추고 - 스트리밍 경로는
await에서 제어권을 반납해 다른 요청을 받습니다.
추적 경로
위 표는 "어디를 봐야 하는지"까지고, 실제로 그 경로를 한 홉씩 따라간 기록은 별도 문서로 분리했습니다. 본문 흐름을 끊지 않도록 나눴으니 필요한 쪽만 보셔도 됩니다.
flowchart TD
A["본편 (이 글)
엔드포인트별 처리 방안"] --> B["추적 (1)
스트리밍 경로"]
A --> C["추적 (2)
일괄 경로"]
B -.->|"두 경로가 큐 한 쌍을
나눠 쓰는 지점"| C
B --> D["보강
워커 안쪽 가공 로직"]
C --> D
D --> E1["곁가지
generate() 세 단계"]
D --> E2["곁가지
해체한 자기회귀 루프"]| 경로 | 문서 | 답하는 질문 |
|---|---|---|
| 스트리밍 | 추적: request_id의 여정 | 요청 하나가 코루틴 → 스레드 → 프로세스 → 스레드 → 코루틴으로 경계를 네 번 넘는 동안 무엇이 어떻게 실려 다니는가. 큐가 왜 세 종류인가. forward 한 번이 왜 토큰 하나가 되는가. 잠든 코루틴을 누가 어떻게 깨우는가 |
| 일괄 | 추적: generate의 대기 루프 | while 루프가 도는 조건이 왜 "배치 완료"가 아닌가. 내 요청 5개가 왜 배치 두 번으로 쪼개지고 남는 칸에 누가 들어오는가. 두 경로가 task_queue 한 쌍을 공유하면 어떤 구조가 되는가 |
| 워커 안쪽 | 보강: 토크나이저, forward, 생성 루프 | 두 경로가 워커에 도착한 다음의 이야기다. 문자열이 어떻게 직사각형 정수 텐서가 되는가. model() 이 내놓는 [B, T, V] 중 왜 마지막 한 벌만 쓰는가. temperature 로 나누면 분포가 어떻게 바뀌는가. 같은 자기회귀 루프를 누가 돌리느냐가 왜 서빙의 성격을 가르는가. 메서드 하나씩 그림 위주로 더 파고든 곁가지 문서 둘이 이 글 안에 링크되어 있다 |
전체 로직을 "추적: request_id의 여정"으로 이해한 후, "추적: generate의 대기 루프" 를 살펴보는 것이 좋겠습니다.
트랜스포머 내부 구조의 일부까지 들여다보시고 싶으시다면 "보강: 토크나이저, forward, 생성 루프" 까지 보시면 문자열로 어떻게 다음 문자를 생성하는지를 살펴보실 수 있습니다.
LLM 가공로직 자세히 보기
모델 워커 안에서 문자열이 토큰 ID가 되고 트랜스포머 forward를 거쳐 다음 토큰으로 선택된 뒤 다시 문자열과 SSE가 되는 과정은 보강 - 토크나이저, forward, 생성 루프로 옮겼습니다.
이 문서에서는 토크나이저와 PyTorch의 역할, 두 생성 메서드의 코드 순서, 마지막 logits로 다음 토큰을 고르는 이유, SSE와 KV 캐시 및 멀티모달 운영의 연결점을 한 흐름으로 설명합니다.
vLLM을 쓴다면?
반면 vLLM을 사용한다면 단순히 vLLM을 호출하고 깔끔하게 떨어지죠. 위의 과정을 현업의 요구사항에 맞게 추상화하고 편의성을 챙기고 성능을 고려했을 것입니다.
/generate_vllm- 위의 예시와 달리 vLLM을 이용한 처리를 위해
SamplingParams로 샘플링을 진행한 후 vLLM으로 구동합니다. - GPU가 없으면 엔진 초기화 때 vLLM을 건너뛰고
self.vllm_model = None으로 두었다가, 호출 시VLLMUnavailableError를 던져 503으로 응답합니다.나머지 세 엔드포인트는 CPU에서도 그대로 동작합니다.
- 위의 예시와 달리 vLLM을 이용한 처리를 위해
llm/ 아래 파일들은 결국 작은 서빙 엔진 하나입니다. 각 조각이 vLLM 안의 무엇에 해당하는지 놓고 보면, 무엇을 배우려고 이 코드를 쓴 것인지가 분명해집니다.
| 이 저장소 (손으로 구현) | vLLM 안의 대응물 | 대응물이 더 하는 일 |
|---|---|---|
WorkloadManager.get_next_batch() |
Scheduler |
매 스텝 무엇을 돌릴지 정한다. vLLM 쪽은 KV 캐시 블록의 남은 용량까지 보고 결정하며, 자리가 모자라면 시퀀스를 선점해 내렸다가 다시 올린다 |
Sequence |
Request |
요청 하나의 상태를 담는다. vLLM 쪽은 문자열이 아니라 토큰 ID 리스트로 들고 있고, 블록 표는 KVCacheManager 가 따로 관리한다. 한 프롬프트에서 여러 후보를 뽑는 경우는 ParentRequest 가 묶는다 |
ModelExecutor / ModelWorker |
Executor / Worker |
모델을 들고 있는 실행 주체다. vLLM 쪽은 여러 GPU에 텐서 병렬로 나눠 얹고, 워커들을 한 스텝씩 맞춰 돌린다 |
수동 softmax + multinomial |
Sampler |
같은 일을 하되 요청마다 다른 temperature, top_p, top_k, 반복 페널티를 한 번의 커널 호출로 처리한다 |
use_cache=False 로 매 스텝 전체 재계산 |
PagedAttention KV 캐시 블록 |
이미 계산한 키와 값을 고정 크기 블록에 담아 재사용한다. 새 토큰 1개분만 계산하면 되므로, 문장이 길어져도 스텝 비용이 늘지 않는다 |
requests_processing_loop 의 while True |
continuous batching / LLMEngine.step() |
끝난 시퀀스가 비운 자리를 기다리던 요청으로 즉시 채운다. 배치가 다 끝날 때까지 기다리지 않는다 |
max_tokens = 20 을 엔진에 고정 |
SamplingParams |
길이와 샘플링 설정이 요청마다 따로 붙는다. 같은 배치 안에서 서로 다른 값이 섞여도 된다 |
위로 갈수록 아는 것이 늘고, 그만큼 손으로 쓸 코드가 줄어듭니다.
generate() 는 자기회귀 루프를 함수 안에 넣어 감춥니다. 편하지만 루프 중간에 끼어들 수 없습니다.
generate_forward_batch() 는 그 루프를 한 바퀴로 잘라 밖으로 꺼냅니다. 대신 [B, T, vocab] 에서 마지막 자리를 골라내는 일과, 점수를 확률로 바꿔 주사위를 굴리는 일과, 토큰을 프롬프트에 되붙이는 일을 전부 손으로 하게 됩니다.
서빙 엔진을 만든다는 것은 결국 그 루프의 소유권을 가져오는 일이고, vLLM은 이를 모조리 추상화하고, 관리해주는 것이 큰 특징입니다.
멀티 모델 서비스
앞의 예제가 "하나의 모델을 어떻게 잘 굴릴까"였다면, 이번 예제는 "여러 모델을 한정된 자원에 어떻게 얹을까"입니다. 같은 3장이지만 푸는 문제가 완전히 다릅니다.
앞 절과 마찬가지로 작가의 코드를 제 방식대로 재구성했습니다. requirements.txt 대신 uv, 워커 한 파일 대신 app/workers/ 패키지, docker run 대신 compose.yml을 씁니다. 동작은 원본과 같습니다.
용량이 주요한 고민거리
단일 모델 서빙에서 우리가 싸웠던 상대는 GPU 연산 시간이었습니다. 그래서 배치를 묶고, 스트리밍으로 토큰을 흘리고, 스케줄러를 뒀습니다.
멀티 모델 서빙에서 상대는 메모리입니다. 모델이 10개인데 VRAM에는 2개밖에 안 들어간다면, "지금 어떤 모델을 올려둘 것인가"가 유일하고도 전부인 질문이 됩니다. 그래서 이 예제에는 큐도, 배치도, 스트리밍도, 워커 프로세스도 없습니다. 대신 LRU 캐시 하나가 시스템의 심장입니다.
코드 구성
.
├── app
│ ├── server.py # FastAPI 엔드포인트 (/predict, /models)
│ ├── store.py # 모델 메타데이터 (models.json 로드)
│ ├── manager.py # LRU 캐시 + 로드/언로드 수명주기
│ ├── engine.py # 프레임워크별 워커 팩토리
│ └── workers
│ ├── base.py # 추상 워커 (계약)
│ ├── transformer.py # HF transformers
│ ├── torch_vision.py # torchvision
│ └── triton.py # Triton HTTP 클라이언트
├── config
│ └── models.json # 모델 카탈로그
├── model_dir # Triton 모델 리포지터리 (gitignore)
│ └── densenet_onnx
│ ├── 1/model.onnx
│ ├── config.pbtxt
│ └── densenet_labels.txt
├── tests
│ ├── images/cat1.jpg
│ ├── conftest.py # 픽스처 + 마커 기반 스킵
│ ├── model_ids.py # 모델 ID 네 개
│ ├── test_models.py # 서비스 전체를 TestClient로
│ ├── test_triton_densenet.py # Triton 서버만 직접
│ └── test_gpu_lifecycle.py # device 배치 + 축출 시 VRAM 회수
├── compose.yml
└── pyproject.toml
원본에서 바꾼 건 세 가지입니다.
- 워커를 파일별로 쪼갰습니다. 원본은
app/worker.py한 파일에 추상 클래스와 구현 셋이 같이 있습니다. 이 절의 목표가 "워커마다predict()가 어떻게 다른지"를 보는 것이라서, 파일을 나눠 두면 비교가 훨씬 쉽습니다. uv로 옮겼습니다.single_model_study와 완전히 분리된pyproject.toml,uv.lock,.venv를 씁니다. vLLM이 딸려 오는 앞 예제와 의존성이 겹치면 곤란하니까요.compose.yml을 뒀습니다. Triton을docker run긴 줄로 띄우는 대신 파일로 고정했습니다. 포트 8009는TritonWorker에 박혀 있는 값이라는 것도 주석으로 남겨 뒀습니다.
애플리케이션 코드는 전부 합쳐 150줄 남짓입니다. 작아서 각 조각의 책임이 딱 하나씩 보인다는 점이 좋습니다.
코드 구조
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: 모델 로드 (여기서 수백 ms ~ 수십 초)
E-->>M: worker
M-->>S: worker
end
S->>W: predict(input_data)
W-->>S: 워커마다 다른 모양의 dict
S-->>C: 200 OK중요한 건 모델 로딩이 요청 경로 한복판에 있다는 점입니다. 캐시에 없는 모델을 처음 부르는 클라이언트는 다운로드와 로딩이 끝날 때까지 그대로 기다립니다. 이게 멀티 모델 서빙의 고질적인 콜드 스타트 문제입니다. 네 모델의 응답 시간을 재 보면 이렇습니다. 가중치가 이미 디스크에 있는 상태라 여기서 "콜드"는 디스크에서 올리는 시간입니다.
sentiment 콜드 1.099s 웜 0.014s
spam 콜드 0.717s 웜 0.002s
mobilenet 콜드 0.111s 웜 0.015s
densenet 콜드 0.425s 웜 0.061s
작은 분류 모델이라 1초 안쪽이지만 웜 대비 수십 배에서 300배 차이입니다. 수 GB짜리 LLM이었다면 이 자리에 수십 초가 들어가고, 그 시간 동안 요청은 그냥 열려 있습니다.
또 하나, 마지막 화살표를 눈여겨봐 두세요. Server는 워커가 돌려준 dict를 그대로 클라이언트에게 넘깁니다. 이 말은 응답 스키마가 워커마다 다르다는 뜻이고, 뒤에서 다룰 내용의 전부이기도 합니다.
모델 카탈로그: 메타데이터가 곧 라우팅 키
{
"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에 분기 하나를 더하면 됩니다. 등록된 모델은 네 개입니다.
| 용도 | 모델 | framework | type | model_id 앞자리 |
|---|---|---|---|---|
| 감성 분석 | distilbert-base-uncased-finetuned-sst-2-english |
transformers | text | 550e8400… |
| 스팸 탐지 | mrm8488/bert-tiny-finetuned-sms-spam-detection |
transformers | text | 6ba7b810… |
| 이미지 분류 | pytorch/vision:mobilenet_v2 |
torchvision | image | 7c9e6679… |
| 이미지 분류 | densenet_onnx |
triton | image | 8ba7b810… |
네 모델 전부 분류 모델입니다. 그래서 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) -> ModelWorker | None:
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:
evicted_id, evicted_worker = self.model_cache.popitem(last=False)
self.model_engine.delete_worker(evicted_id)
del evicted_worker # 마지막 참조 제거
gc.collect() # 순환 참조까지 정리
if torch.cuda.is_available():
torch.cuda.empty_cache() # allocator가 쥔 블록을 반환
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 | mobilenet | [sentiment, mobilenet] |
미스 → spam 축출 후 로드 |
테스트가 확인하는 것도 정확히 이 성질입니다. 모델 셋을 순서대로 부른 뒤 /models를 조회합니다.
assert len(loaded) <= 2
assert set(loaded) == {SPAM, MOBILENET}
축출 블록 뒤쪽 세 줄이 하는 일도 여기서 확인됩니다. 참조를 끊는 것과 메모리를 실제로 돌려주는 것은 다른 문제입니다. del만 하면 파이썬이 세는 숫자는 줄지만, PyTorch의 caching allocator가 블록을 재사용하려고 계속 쥐고 있어서 nvidia-smi 수치는 안 내려갑니다. 세 지표를 같이 두고 다섯 번 호출해 보면 이렇습니다.
| 요청 | 캐시 | memory_allocated |
memory_reserved |
nvidia-smi |
|---|---|---|---|---|
| sentiment | [sentiment] |
256MiB | 292MiB | +445MiB |
| spam | [sentiment, spam] |
273MiB | 294MiB | +447MiB |
| mobilenet | [spam, mobilenet] |
30MiB | 48MiB | +201MiB |
| sentiment | [mobilenet, sentiment] |
270MiB | 298MiB | +451MiB |
| spam | [sentiment, spam] |
273MiB | 310MiB | +463MiB |
세 번째 줄이 축출 지점입니다. distilbert 256MiB가 빠지면서 세 숫자가 같이 내려가고, 다시 부르면 올라갑니다. 계단식으로 쌓이지 않는다는 게 핵심입니다. empty_cache()가 빠지면 첫 열만 내려가고 나머지 둘은 그대로입니다.
공짜는 아닙니다. gc.collect()가 요청 경로 안에 들어가고, empty_cache()는 allocator가 재사용하려던 블록을 드라이버에 반납해서 다음 할당이 cudaMalloc을 다시 탑니다.
워커의 계약
class ModelWorker(ABC):
def __init__(self, model_metadata):
self.model_metadata = model_metadata
self.model: torch.nn.Module | None = None
self._load_model() # 생성 = 로드
@abstractmethod
def _load_model(self): ...
@abstractmethod
def predict(self, input_data: Any) -> dict[str, Any]: ...
생성자가 곧 로딩이라는 점이 이 설계의 핵심입니다. 워커 객체의 수명이 곧 모델의 메모리 점유 기간이고, 그래서 매니저는 딕셔너리에서 참조를 지우는 것만으로 "언로드"를 표현할 수 있습니다. TritonWorker가 __del__에서 원격 서버의 unload API를 부르는 것도 같은 이유입니다.
_load_model()이 부모 생성자 안에서 불리기 때문에, 자식은 자기가 쓸 상태를 super().__init__()보다 먼저 준비합니다. 뒤에 나올 self.device도 그 자리에서 정해집니다.
def __init__(self, model_metadata):
self.tokenizer = None
self.device = "cuda" if torch.cuda.is_available() else "cpu"
super().__init__(model_metadata) # 이 안에서 _load_model()이 돈다
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]
여기까지가 "관리"입니다. 이제 진짜 재미있는 부분, predict() 세 개가 서로 얼마나 다른가로 넘어갑니다.
돌려보기
uv sync
uv run uvicorn app.server:app --host 0.0.0.0 --port 8001
ModelStore가 config/models.json이라는 상대 경로로 만들어지기 때문에, 서버는 반드시 이 디렉터리에서 띄워야 합니다.
Triton 모델은 무게가 33MB라 저장소에 넣지 않았습니다. 받아 오고 컨테이너를 띄웁니다.
git clone -b r25.05 https://github.com/triton-inference-server/server.git /tmp/triton-server
(cd /tmp/triton-server/docs/examples && ./fetch_models.sh)
mkdir -p model_dir
cp -r /tmp/triton-server/docs/examples/model_repository/densenet_onnx model_dir/
docker compose up -d
curl http://localhost:8009/v2/health/ready
24개의 테스트가 전부 통과함을 확인했습니다.
$ uv run pytest -q
........................ [100%]
24 passed in 25.00s
바깥 환경에 기대는 테스트는 마커로 갈라 뒀습니다. Triton이 없거나 GPU가 없으면 알아서 건너뜁니다.
uv run pytest -m "not triton" # 18개, 컨테이너 없이
uv run pytest -m "gpu" # device 배치와 VRAM 수명주기만
어떻게 호출하나
/predict의 요청 스키마는 이게 전부입니다.
class PredictionRequest(BaseModel):
model_id: str
input_data: Any
model_id:/models로 받아온 모델의 UUID를 줍니다.input_data: Any.model_id를 무엇으로 주느냐에 따라input_data에 넣어야 하는 것이 달라집니다. 아래를 살펴보겠습니다.
텍스트 모델의 요청과 응답에 대해
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!"}'
{ "predictions": [[0.00013219681568443775, 0.9998677968978882]] }
[[음수 확률, 양수 확률]]입니다. 바깥 리스트는 배치, 안쪽 리스트는 클래스입니다. 라벨 이름은 응답에 없어서 모델 설정에서 따로 봐야 합니다.
>>> AutoConfig.from_pretrained("distilbert-base-uncased-finetuned-sst-2-english").id2label
{0: 'NEGATIVE', 1: 'POSITIVE'}
테스트도 정확히 이 방식으로 라벨을 복원합니다. predictions[0]에서 argmax를 구하고 id2label에 넣는 것이죠.
def _argmax(probabilities: list[float]) -> int:
return max(range(len(probabilities)), key=probabilities.__getitem__)
def test_sentiment_model(predict, sentiment_id2label, text, expected_label):
data = predict(SENTIMENT, text)
probabilities = data["predictions"][0]
assert sentiment_id2label[_argmax(probabilities)] == expected_label
스팸 모델도 호출 방법은 똑같습니다. 다만 이쪽 id2label은 {0: 'LABEL_0', 1: 'LABEL_1'}이라 이름으로는 아무것도 알 수 없고, 테스트가 문자열을 직접 조립합니다.
assert f"LABEL_{_argmax(probabilities)}" == expected_label
바깥 리스트가 배치라는 사실 덕분에 배치 요청이 공짜로 됩니다. 스키마가 Any이니 문자열 대신 문자열 리스트를 넣어도 그대로 통과합니다.
-d '{"model_id": "550e8400-...", "input_data": ["I love it", "I hate it", "meh"]}'
{
"predictions": [
[0.0001, 0.9999],
[0.9996, 0.0004],
[0.021, 0.979]
]
}
토크나이저가 padding=True로 불리기 때문에 길이가 다른 문장도 알아서 패딩됩니다. HF API가 리스트를 받아 주는 덕에 딸려 온 성질입니다.
"WIN A FREE IPHONE NOW! CLICK HERE!"를 넣으면 [0.932, 0.068], 즉 정상 메시지라고 답합니다. 이 모델은 SMS Spam Collection 데이터셋으로 미세조정된 물건이라, 그 데이터셋에 나오는 문체가 아니면 잘 못 맞춥니다. 아래처럼 실제 SMS 스팸 문장을 넣으면 제대로 잡습니다.
"Hi, can we meet tomorrow at 2pm?" → [0.937, 0.063] LABEL_0
"WIN A FREE IPHONE NOW! CLICK HERE!" → [0.932, 0.068] LABEL_0 (틀림)
"Free entry in 2 a wkly comp to win FA Cup final tkts..." → [0.103, 0.897] LABEL_1
"URGENT! You have won a 1 week FREE membership..." → [0.116, 0.884] LABEL_1
재미있는 건 테스트가 이걸 못 잡는다는 점입니다. test_spam_model은 ham 쪽만 라벨을 검사하고, spam 쪽은 len(predictions) > 0만 확인합니다. 원래 코드에 있던 성질이고, 모델 품질 검증과 서빙 경로 검증은 다른 일이라는 걸 보여 주는 사례로 남겨 뒀습니다.
TorchVision의 경우
input_data에 서버 프로세스가 읽을 수 있는 파일 경로 문자열을 넣어줍니다.
curl -X POST http://localhost:8001/predict \
-H "Content-Type: application/json" \
-d '{"model_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"input_data": "tests/images/cat1.jpg"}'
{"predictions": [[3.6e-06, 1.2e-05, ... 1000개 ...]]}
경로라는 걸 알고 나면 나머지 동작은 예측대로입니다. 상위 5개를 뽑아 보면 이런 결과를 줍니다.
281 tabby 0.0456
285 Egyptian cat 0.0364
282 tiger cat 0.0258
283 Persian cat 0.0190
728 plastic bag 0.0166
고양이 종류가 위쪽을 채우긴 했는데 확률이 전부 5% 미만이군요. cat1.jpg 사진은 검정흰색 턱시도 고양이인데, ImageNet 1000개 클래스에 딱 맞는 항목이 없어서 비슷한 고양이들에게 확률이 잘게 나뉩니다. 이는 전처리와 클래스 정의가 만든 결과입니다.
Triton 예시의 경우
input_data는 텐서를 통째로 담은 딕셔너리입니다.
{
"data_0": {
"shape": [3, 224, 224],
"data": [[[...]]], # 중첩 리스트로 편 float 150,528개
"dtype": "float32"
}
}
data_0이라는 키 이름은 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 ]
reshape { shape: [ 1, 3, 224, 224 ] } }
]
output [
{ name: "fc6_1" data_type: TYPE_FP32 dims: [ 1000 ]
reshape { shape: [ 1, 1000, 1, 1 ] }
label_filename: "densenet_labels.txt" }
]
max_batch_size: 0이고 dims가 [3, 224, 224]이니 배치 차원을 붙이면 안 됩니다. 앞의 TorchVision 워커가 unsqueeze(0)으로 배치 차원을 만들어 넣는 것과 정반대입니다. 배치 축은 reshape가 서버 안에서 알아서 붙여 줍니다.
전처리는 클라이언트가 직접 합니다. 테스트가 하는 일이 그대로 호출 방법입니다.
img = Image.open(test_image_path).resize((224, 224)) # 종횡비 무시하고 강제 리사이즈
img_array = np.array(img).astype(np.float32) / 255.0 # 0~1 스케일
img_array = np.transpose(img_array, (2, 0, 1)) # HWC → CHW
img_array = img_array.astype(np.float32)
input_data = {
"data_0": {
"shape": img_array.shape, # (3, 224, 224)
"data": img_array.tolist(), # 중첩 리스트
"dtype": "float32",
}
}
response = self.client.post("/predict", json={"model_id": DENSENET, "input_data": input_data})
curl로 직접 던지려면 페이로드를 파일로 만들어 두는 편이 낫습니다. 150,528개 float을 JSON 텍스트로 적으면 2.9MB가 됩니다.
uv run python - <<'PY'
import json
import numpy as np
from PIL import Image
arr = (np.array(Image.open("tests/images/cat1.jpg").resize((224, 224))).astype(np.float32) / 255.0)
arr = arr.transpose(2, 0, 1).astype(np.float32)
json.dump({"model_id": "8ba7b810-9dad-11d1-80b4-00c04fd430c9",
"input_data": {"data_0": {"shape": list(arr.shape), "data": arr.tolist()}}},
open("/tmp/payload.json", "w"))
PY
curl -X POST http://localhost:8001/predict \
-H "Content-Type: application/json" --data-binary @/tmp/payload.json
응답의 모양도 다릅니다.
{"fc6_1": [-1.6152821779251099, -0.3497679829597473, -0.9748656153678894, ...]}
키는 fc6_1이고, 값은 로짓입니다. 앞의 두 워커는 softmax를 거쳐 0~1 사이 값을 돌려주는데, TritonWorker는 모델이 뱉은 원본 텐서를 그대로 넘깁니다. 상위 5개를 라벨 파일에 대보면 이렇습니다.
285 EGYPTIAN CAT 11.5559
282 TIGER CAT 9.5940
287 LYNX 9.5249
281 TABBY 9.1677
284 SIAMESE CAT 8.9406
순위 자체는 MobileNet과 비슷하게 고양이 계열입니다. 하지만 숫자를 그대로 확률로 읽으면 안 되고, 확률이 필요하면 클라이언트가 softmax를 걸어야 합니다. 그리고 전처리에서 ImageNet 평균/표준편차 정규화를 생략했다는 점도 짚고 갑니다. TorchVision 워커는 Normalize를 하고, 여기서는 /255.0만 합니다. 같은 이미지, 같은 목적인데 전처리 파이프라인이 다릅니다.
정리: 네 가지 호출 방법
| 모델 | input_data에 넣는 것 |
응답 키 | 값의 의미 | 배치 |
|---|---|---|---|---|
| 감성 분석 | 문자열 또는 문자열 리스트 | predictions |
확률 (softmax) | 리스트로 가능 |
| 스팸 탐지 | 문자열 또는 문자열 리스트 | predictions |
확률 (softmax) | 리스트로 가능 |
| MobileNet | 서버 로컬 파일 경로 문자열 | predictions |
확률 (softmax) | 불가 (1장 고정) |
| DenseNet | {텐서이름: {shape, data}} |
fc6_1 |
로짓 | 불가 (max_batch_size: 0) |
워커별 predict() 뜯어보기
이제 왜 위 표가 저런 모양인지, 코드 세 조각을 나란히 봅니다.
TransformerWorker.predict()
def predict(self, input_data: Any) -> dict[str, Any]:
if self.model is None or self.tokenizer is None:
raise RuntimeError("Model or tokenizer not initialized")
# 1. 토크나이즈
inputs = self.tokenizer(
input_data,
return_tensors="pt",
padding=True,
truncation=True,
).to(self.device)
# 2. 추론 시작
with torch.no_grad():
# 3. 로짓 리턴
outputs = self.model(**inputs)
# 4. softmax 구동
predictions = torch.softmax(outputs.logits, dim=-1)
# 5. 예측 결과 리턴
return {"predictions": predictions.cpu().tolist()}
네 줄짜리 흐름입니다.
- 토크나이즈.
input_data를 그대로 토크나이저에 넘깁니다. 문자열이면 배치 1, 리스트면 배치 N이 됩니다.return_tensors="pt"로input_ids와attention_mask텐서가 나오고,padding=True가 길이를 맞추고,truncation=True가 모델 최대 길이를 넘는 입력을 잘라 냅니다. 앞에서 본 "배치가 공짜로 된다"가 여기 한 줄에서 나옵니다. torch.no_grad(). 추론이니 그래프를 만들 이유가 없습니다. 메모리와 시간을 아낍니다.self.model(**inputs).AutoModelForSequenceClassification이 돌려주는 건logits입니다. 모양은(배치, 클래스 수)이고 이 모델들은 클래스가 2개입니다.softmax(dim=-1)후.cpu().tolist(). 마지막 축, 즉 클래스 축으로 정규화합니다. 클래스 축이라는 게 중요합니다..tolist()는 JSON 직렬화를 위해서입니다. 텐서는 FastAPI가 직렬화하지 못합니다.
토크나이즈 뒤의 .to(self.device)와 여기 .cpu()는 짝입니다. 모델과 입력이 같은 장치에 있어야 합니다. 모델만 GPU로 올리고 토크나이저 결과를 CPU에 두면 Expected all tensors to be on the same device로 터집니다.
라벨 이름을 붙여 주지 않는 것도 여기서 결정됩니다. self.model.config.id2label이 손 닿는 곳에 있는데도 쓰지 않아서, 클라이언트가 별도로 알아내야 합니다. 테스트가 모델을 한 번 더 로드해서 id2label을 꺼내 오는 건 그 때문입니다.
TorchVisionWorker.predict()
def predict(self, input_data: Any) -> dict[str, Any]:
if self.model is None or self.transform is None:
raise RuntimeError("Model or transform not initialized")
if isinstance(input_data, str):
image = Image.open(input_data).convert("RGB")
else:
image = input_data
image_tensor = self.transform(image).unsqueeze(0).to(self.device)
with torch.no_grad():
outputs = self.model(image_tensor)
predictions = torch.softmax(outputs, dim=1)
return {"predictions": predictions.cpu().tolist()}
토크나이저 자리에 이미지 전처리가 들어왔을 뿐 뼈대는 같습니다. 다른 점은 셋입니다.
하나, 입력을 두 갈래로 받습니다. 문자열이면 파일 경로로 보고 열고, 아니면 이미 PIL 이미지라고 가정합니다. HTTP로 들어오는 경로에서는 JSON을 거치니 항상 문자열입니다. 즉 else 가지는 파이썬에서 직접 워커를 부를 때만 살아 있습니다. .convert("RGB")는 흑백이나 RGBA 이미지가 들어와도 채널을 3개로 맞춰 줍니다.
둘, 전처리가 _load_model()에 미리 조립돼 있습니다.
self.transform = transforms.Compose([
transforms.Resize(256), # 짧은 변을 256으로 (종횡비 유지)
transforms.CenterCrop(224), # 가운데 224×224를 잘라 냄
transforms.ToTensor(), # HWC uint8 → CHW float 0~1
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]), # ImageNet 통계
])
MobileNetV2가 학습될 때 쓴 것과 같은 파이프라인입니다. 아까 cat1.jpg의 확률이 납작했던 이유가 여기 보입니다. 510×198 사진은 Resize(256)을 거치며 660×256이 되고, CenterCrop(224)이 가운데만 남깁니다. 즉 좌우가 크게 잘려 나갑니다. 정규화까지 마친 텐서가 모델이 기대하는 분포에 들어가는 게 핵심이고, 이 네 단계 중 하나만 빠져도 결과가 흔들립니다. 뒤에서 볼 Triton 쪽 전처리가 정확히 그 예입니다.
셋, unsqueeze(0)로 배치 차원을 만듭니다. 전처리 결과는 (3, 224, 224)인데 모델은 (N, 3, 224, 224)를 원합니다. 그래서 앞에 축을 하나 붙여 (1, 3, 224, 224)로 만들고, 이어서 .to(self.device)로 모델 쪽으로 보냅니다. 이미지가 항상 한 장인 이유이기도 합니다. 여러 장을 처리하려면 torch.stack으로 쌓아야 하는데 그런 경로가 없습니다.
softmax(dim=1)은 앞 워커의 dim=-1과 결과적으로 같습니다. 출력이 (1, 1000) 2차원이니 축 1이 곧 마지막 축입니다.
TritonWorker.predict()
def predict(self, input_data: dict[str, Any]) -> dict[str, Any]:
inputs = []
for name, data in input_data.items():
if not isinstance(data, np.ndarray):
try:
shape = data["shape"]
content = data["data"]
array = np.array(content, dtype=np.float32).reshape(shape)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"Input {name} could not be converted to a numpy array") from exc
else:
array = data.astype(np.float32)
input_tensor = httpclient.InferInput(name, array.shape, "FP32")
input_tensor.set_data_from_numpy(array)
inputs.append(input_tensor)
output_name = "fc6_1" # DenseNet 출력 텐서 이름을 코드에 박아 둠
response = self.client.infer(
model_name=self.model_metadata.name,
inputs=inputs,
outputs=[httpclient.InferRequestedOutput(output_name)],
)
return {output_name: response.as_numpy(output_name).tolist()}
앞의 둘과 성격이 다릅니다. 여기서는 추론을 하지 않습니다. 추론을 남에게 시킵니다.
- 입력 딕셔너리를 돌면서 텐서를 복원합니다. JSON을 거쳐 들어온 중첩 리스트를
np.array(..., dtype=np.float32)로 만들고shape대로reshape합니다.dtype을 명시하는 게 중요합니다.config.pbtxt가TYPE_FP32를 요구하는데 numpy가 기본으로 float64를 골라 버리면 Triton이 거절합니다. InferInput으로 감쌉니다. 텐서 이름, 모양, 타입 문자열"FP32"가 여기서 만나 서버 쪽 계약과 맞춰집니다. 이름이config.pbtxt의data_0과 다르면 서버가 400을 돌려줍니다.client.infer()로 HTTP 요청을 보냅니다. 앞 워커들이self.model(...)을 부르던 자리입니다. 여기서 프로세스 경계를 넘습니다.as_numpy("fc6_1").tolist()로 꺼냅니다.softmax가 없습니다. 앞서 본 로짓 응답이 여기서 결정됩니다.
로딩과 정리도 원격입니다.
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"
with contextlib.suppress(requests.RequestException):
requests.post(unload_url)
이게 가능한 이유는 Triton을 --model-control-mode=explicit으로 띄웠기 때문입니다. 이 모드에서 Triton은 기동 시 아무 모델도 올리지 않고, /v2/repository/models/{name}/load와 /unload로 밖에서 수명주기를 제어하게 열어 둡니다. compose.yml이 그렇게 맞춰져 있습니다.
command: >
tritonserver
--model-repository=/models
--model-control-mode=explicit
축출이 원격까지 전달되는지도 직접 확인해 봤습니다. densenet을 부른 직후에는 Triton 서버가 모델을 들고 있습니다.
$ curl -s -X POST localhost:8009/v2/repository/index
[{"name": "densenet_onnx", "version": "1", "state": "READY"}]
여기서 다른 모델을 두 번 더 불러 densenet을 LRU 캐시에서 밀어내면, __del__이 돌면서 컨테이너 쪽에서도 내려갑니다.
$ curl -s -X POST localhost:8009/v2/repository/index
[{"name": "densenet_onnx", "version": "1", "state": "UNAVAILABLE", "reason": "unloaded"}]
파이썬 딕셔너리에서 참조를 지운 것이 컨테이너 안 서버의 메모리 해제로 이어지는 셈입니다. 동작은 하지만 __del__에 기대고 있다는 게 마음에 걸립니다. 파이썬 소멸자는 호출 시점이 보장되지 않고, 참조가 어딘가에 하나라도 더 남아 있으면 영영 안 불립니다.
우리가 ModelManager로 손수 짠 것(온디맨드 로드, 언로드, 준비 상태 확인)은 이미 Triton이 제품 수준으로 제공하는 기능입니다. 직접 짜 본 이유는 Triton 같은 서버가 안에서 무엇을 하고 있는지 알고 고르기 위해서입니다. 앞 절에서 vLLM을 마지막 엔드포인트로 붙여 비교 대상을 만든 것과 같은 구도입니다.
셋을 나란히
같은 추상 클래스를 상속받고 시그니처도 같지만, predict()가 실제로 하는 일은 셋 다 다릅니다.
TransformerWorker |
TorchVisionWorker |
TritonWorker |
|
|---|---|---|---|
| 모델이 사는 곳 | 이 프로세스 메모리 | 이 프로세스 메모리 | 별도 서버 (:8009) |
| 장치 | .to(self.device) |
.to(self.device) |
해당 없음 (원격) |
| 로딩 방법 | from_pretrained() |
mobilenet_v2(weights=…) |
HTTP POST .../load |
| 전처리 위치 | 워커 안 (토크나이저) | 워커 안 (transforms) |
클라이언트 |
| 입력 형태 | 문자열 / 문자열 리스트 | 파일 경로 / PIL 이미지 | {이름: {shape, data}} |
| 배치 차원 | 토크나이저가 만듦 | unsqueeze(0)로 붙임 |
붙이면 안 됨 |
| 추론 호출 | self.model(**inputs) |
self.model(tensor) |
client.infer(...) |
| 후처리 | softmax(dim=-1) + .cpu() |
softmax(dim=1) + .cpu() |
없음 |
| 응답 키 | predictions |
predictions |
fc6_1 (하드코딩) |
| 언로드 | 참조 제거 + empty_cache() |
참조 제거 + empty_cache() |
__del__에서 원격 unload |
프로세스 메모리에 띄우는 경우 "전처리, 추론, 후처리"를 직접 다 하고, Triton 처럼 외부 서버에 호출을 맡기는 경우 "직렬화, 원격 호출, 역직렬화"만 합니다. 이름만 같은 함수 셋이지, 하는 일의 성격이 다릅니다.
부족한 지점의 목록이 곧 "실서비스 멀티 모델 서빙이 추가로 해야 할 일"의 목록입니다.
- 개수 기준 캐시는 현실에서 안 통합니다.
max_models=2는 4MB짜리bert-tiny와 수십 GB짜리 모델을 똑같이 1로 셉니다. 실제 축출 정책은 바이트 기준이어야 하고, 그러려면 메타데이터에 모델 크기가 있어야 합니다. - GPU 배치가 있거나 없습니다. 두 인프로세스 워커는
cuda가 보이면 무조건 올라탑니다. 모델별로 장치를 고르거나, VRAM이 모자랄 때 CPU로 물러나거나, 여러 GPU에 나눠 얹는 경로가 없습니다.compose.yml의 Triton GPU 블록도 주석 처리된 채입니다. - 회수는 하지만 격리는 아닙니다. 축출 시
empty_cache()까지 부르니nvidia-smi수치는 실제로 내려갑니다. 다만 같은 프로세스 안이라 파편화는 그대로 쌓이고, 로딩 중 터진 모델이 남긴 찌꺼기도 치울 방법이 없습니다. 확실히 돌려주려면 결국 프로세스 격리인데, 앞 절에서 워커를 별도 프로세스로 뺐던 이유가 여기서도 유효합니다. gc.collect()가 요청 경로에 있습니다. 축출은 드무니 감당되지만, 전체 세대를 도는 GC라 객체 그래프가 커지면 그만큼 요청이 늦어집니다. 축출을 요청 밖으로 빼는 게 정석입니다.- 동시성 보호가 없습니다.
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도 마찬가지입니다. - 이미지 워커가 서버 로컬 경로를 엽니다. 앞에서
/etc/hostname으로 확인한 그대로입니다. 업로드나 URL 기반으로 바꿔야 합니다. - 앱의 캐시와 Triton의 상태를 맞춰 주는 장치가 없습니다.
_load_model()은 워커를 만들 때 한 번만 돕니다. 그래서 Triton 쪽에서 직접unload를 부르면, 앱은/models에 densenet이 올라가 있다고 답하는데 추론은[404] Request for unknown model로 깨집니다. 다른 모델을 두 번 불러 그 워커를 축출시켜야 새 워커가 만들어지며 다시 로드됩니다. 원격에 위임한 대가입니다. - 응답 스키마가 워커마다 다릅니다. 확률이 오기도 하고 로짓이 오기도 하고, 키가
predictions이기도fc6_1이기도 합니다. 라벨 이름은 어느 쪽에도 없습니다. 클라이언트가model_id별로 분기해야 하는데, 그럴 거면 엔드포인트를 하나로 묶은 이득이 크지 않습니다. - 관측 지점이 없습니다. 캐시 히트율, 로드 소요 시간, 축출 횟수. 멀티 모델 서빙에서 가장 먼저 보고 싶은 세 숫자가 어디에도 기록되지 않습니다.
단일 / 멀티, 나란히 놓고 보기
| 단일 모델 서빙 | 멀티 모델 서빙 | |
|---|---|---|
| 핵심 질문 | 한 모델을 어떻게 빠르게 많이 돌릴까 | 여러 모델을 한정된 메모리에 어떻게 얹을까 |
| 병목 | GPU 연산 시간, 배치 효율 | 모델 로드 시간, 메모리 용량 |
| 핵심 자료구조 | Queue + Sequence (스케줄링) |
OrderedDict (LRU 캐시) |
| 배치 | O (batch_size=4) |
텍스트만 우연히 (리스트 입력) |
| 스트리밍 | O (SSE, 토큰 단위) | X (단발 응답) |
| 프로세스 격리 | O (fork한 워커 프로세스) |
X (인프로세스) / Triton만 외부 서버 |
| 모델 수 | 1개, 기동 시 로드 후 고정 | N개, 온디맨드 로드와 언로드 |
| 모델 성격 | 텍스트 생성 (causal LM) | 분류 (텍스트, 이미지) |
| 라우팅 기준 | 엔드포인트 | 요청 본문의 model_id |
| 요청 스키마 | 엔드포인트별로 고정 | Any, 모델마다 다름 |
| 상태 | 시퀀스별 토큰 상태 유지 | 무상태 |
| 비교 대상 | vLLM | Triton Inference Server |
두 예제를 겹쳐 보면 실제 서빙 시스템이 왜 복잡해지는지가 보입니다. 실서비스는 이 둘을 동시에 요구합니다. 여러 LLM을 얹으면서(용량 문제) 각각을 배치와 스트리밍으로 굴려야 하고(처리량 문제), 그 둘이 같은 GPU 메모리를 놓고 싸웁니다.