[CloudNeta] Hands-On LLM Serving 2주차 part 1 - 모델 서빙 시스템 설계

연재 안내

이 글은 2주차 연재의 첫 번째 편입니다.

  1. part 1 - 모델 서빙 시스템을 간단히 짜보기 (이 글)
  2. part 2 - 모델 서빙 시스템의 베스트 케이스

들어가며

이번 편의 목표는 1장에서 살펴본 기본 개념을 토대로 최소한의 원칙을 통해 서빙시스템의 코드를 작성해보는 것이 목표입니다.

오픈소스 서빙 프레임워크는 정말 많습니다. vLLM이든 NVIDIA Triton 이든 LiteLLM이든 선택을 위해선 기본기를 알아야하니 이를 알고 선택할 수 있도록 살펴보겠습니다. 먼저 이번 작업을 위해서는 아래 링크의 코드를 기본으로 살펴보겠습니다.

작가의 GitHub 링크와 제 작업 분을 같이 공유합니다

이번에는 배치, 스트리밍, 라우팅, 격리, 리소스 관리에 대해 살펴봅니다. 특히 단독모델, 멀티모델 서비스를 함께 살펴 볼 예정입니다.

모델 서빙은 단순히 모델의 generate() 함수를 호출하는 것이 아니라, API 처리·요청 추적·배치·스트리밍·프로세스 격리·메모리 관리·라우팅·확장·장애 복구를 함께 설계하는 시스템 엔지니어링임을 확인해봅시다.

LLM 서빙 서비스를 뼈대부터

시스템 구성요소

먼저 책에 나오는 초기 LLM 서빙 시스템의 6가지 구성요소를 살펴봅시다.

(이미지 001)

  1. API 서버 - HTTP로 오는 요청과 응답을 받는 엔드포인트입니다. 배치/스트리밍 양 형식으로 받게 되어있죠.
  2. LLM 엔진 - LLM 서비스의 전체를 총괄합니다
  3. 워크로드 매니저 - 요청을 큐로 관리하고 배치 구성을 관리하고, 어떤 배치 전략을 적용할지 적용하는 지점입니다. 즉 언제 어떤 요청을 묶어서 배치로 보낼지를 결정하는 스케줄러입니다
  4. 모델 실행기(executor) - 모델 워커 프로세스를 초기화 및 관리하고, 프로세스 간 통신으로 추론을 트리거합니다
  5. 모델 워커 - 실제 모델 추론을 자신의 별도 프로세스에서 실행합니다
  6. 모델 매니저 - 모델을 로드하고 캐싱합니다

전체 요청 흐름

초기 구조는 LLMEngineModelExecutorWorkloadManager를 갖고, ModelExecutorworker 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

단일 모델 서비스

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구성이 된 채로 구동되네요.

코드의 전반적인 구성은 어렵지 않습니다.

API 서버가 구성되어있고, 구동 시 LLMEngine() 을 실행하며 아래 요소를 초기화합니다.

엔드포인트별 처리 방안

  1. /basic_generate
    • LLMEngine에 프롬프트를 주고 Sequence 형태로 가공 후 배치 실행을 ModelExecutor에 맡깁니다.
    • 이는 task_queue (프로세스를 fork하는 큐)에 추가되고 완료되는 대로 꺼내옵니다.
  2. /generate

  3. /generate_stream
    • FastAPI의 이벤트 루프를 전달하고 비동기 처리 큐를 생성합니다
    • WorkloadManager
  4. /generate_vllm
    • 위의 예시와 달리 vLLM을 이용한 처리를 위해 SamplingParams로 샘플링을 진행한 후 vLLM으로 구동합니다.
    • GPU가 없으면 엔진 초기화 때 vLLM을 건너뛰고 self.vllm_model = None으로 두었다가, 호출 시 VLLMUnavailableError를 던져 503으로 응답합니다.나머지 세 엔드포인트는 CPU에서도 그대로 동작합니다.

이를 표로 나타내면 아래와 같습니다:

일괄(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")]
  1. Server - HTTP를 받고 model_id로 워커를 찾아 predict()를 호출할 뿐입니다
  2. Store - models.json을 읽어 ModelMetadata를 들고 있는 읽기 전용 카탈로그입니다
  3. Manager - 캐시 정책을 담당합니다. 무엇을 올리고 무엇을 내릴지 결정합니다
  4. Engine - 메타데이터의 framework 값을 보고 알맞은 워커를 만드는 팩토리입니다
  5. 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]

OrderedDictmove_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)

엔진과 워커: 팩토리 + 전략

ModelEngineframework 문자열을 클래스에 매핑하는 것 말고는 하는 일이 없습니다.

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!"}'

/modelsavailable_models(카탈로그 전체)와 loaded_models(현재 메모리에 있는 것)를 함께 돌려주는 게 재미있는 지점입니다. 요청을 몇 번 던지고 이 엔드포인트를 다시 조회하면 LRU가 움직이는 걸 눈으로 볼 수 있습니다.

이 예제가 감춘 것들

읽으면서 눈에 걸린 것들을 정리해 둡니다. 부족한 지점의 목록이 곧 "실서비스 멀티 모델 서빙이 추가로 해야 할 일"의 목록입니다.

단일 / 멀티, 나란히 놓고 보기

단일 모델 서빙 멀티 모델 서빙
핵심 질문 한 모델을 어떻게 빠르게 많이 돌릴까 여러 모델을 한정된 메모리에 어떻게 얹을까
병목 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 메모리를 놓고 싸웁니다. 다음 편에서 볼 라우팅과 격리는 이 충돌을 다루는 이야기입니다.