[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 서빙 서비스의 기본을 살펴봅시다. vLLM 없이 구동하는 것을 살펴보는 게 아무래도 가장 좋겠죠.

시스템 구성요소

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

전체 구조 도식화

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

전체 요청 흐름

초기 구조는 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

단일 모델 서비스

먼저 단독 서비스가 하나의 모델을 실행하는 예시를 살펴봅시다.
제가 백엔드 코드를 구성한다면 이렇게 할 것 같다 싶은 구조로 재구성하였고, 실제 코드를 한줄한줄 분석한 결과를 기재합니다.

이번 장을 이해해야, 어떤 식의 설계를 했고 vLLM의 추상화 레벨이 어디까지 진행했는지 이해하기 위함입니다. 보다 자세한 부분을 원하신다면 코드딥다이브경로 를 참고해주세요.

재구성한 포인트

코드구성

FastAPI의 일부 부분만 최소한의 분리과정을 마쳤습니다.

나머지는 기존 코드배치와 크게 다르지 않습니다.

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

코드 딥다이브 경로

어디까지 읽어야 하나요?

서빙 시스템의 설계 이야기만 따라가실 분은 이 절을 건너뛰고 다음 절로 바로 가셔도 됩니다.

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 생성을 통해 아래 과정을 거칩니다.

엔드포인트는 세 개지만 모델도 하나, 워커 프로세스도 하나입니다. 다른 것은 거기 도달하는 경로입니다. 어디서 갈라지는지부터 보겠습니다.

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 만 중간에 두 번 끊깁니다. 그 끊긴 자리마다 큐가 하나씩 놓여 있습니다.

갈림길은 두 군데뿐입니다. 나머지 차이는 전부 이 두 선택에서 따라 나오는 결과입니다. 각각 차이를 살펴보죠.

코드를 따라가는 순서

각 엔드포인트를 코드로 쫓을 때의 진입점과 순서입니다. 파일을 열어놓고 아래 순서대로 심볼을 따라가면서 살펴보세요.

엔드포인트 진입점 따라갈 순서 멈춰서 볼 지점
/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 로 비켜준다

"대기 방식"을 보면 둘의 차이를 확인할 수 있는데요.

추적 경로

위 표는 "어디를 봐야 하는지"까지고, 실제로 그 경로를 한 홉씩 따라간 기록은 별도 문서로 분리했습니다. 본문 흐름을 끊지 않도록 나눴으니 필요한 쪽만 보셔도 됩니다.

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을 호출하고 깔끔하게 떨어지죠. 위의 과정을 현업의 요구사항에 맞게 추상화하고 편의성을 챙기고 성능을 고려했을 것입니다.

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

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

원본에서 바꾼 건 세 가지입니다.

애플리케이션 코드는 전부 합쳐 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")]
  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: 모델 로드 (여기서 수백 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

텍스트 모델의 요청과 응답에 대해

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_ids 텐서가 되고 모델을 통과해 클래스 두 개짜리 logits가 된 뒤 softmax를 거쳐 확률이 되고 다시 JSON으로 나가는 TransformerWorker의 predict 흐름

네 줄짜리 흐름입니다.

  1. 토크나이즈. input_data를 그대로 토크나이저에 넘깁니다. 문자열이면 배치 1, 리스트면 배치 N이 됩니다. return_tensors="pt"로 input_ids와 attention_mask 텐서가 나오고, padding=True가 길이를 맞추고, truncation=True가 모델 최대 길이를 넘는 입력을 잘라 냅니다. 앞에서 본 "배치가 공짜로 된다"가 여기 한 줄에서 나옵니다.
  2. torch.no_grad(). 추론이니 그래프를 만들 이유가 없습니다. 메모리와 시간을 아낍니다.
  3. self.model(**inputs). AutoModelForSequenceClassification이 돌려주는 건 logits입니다. 모양은 (배치, 클래스 수)이고 이 모델들은 클래스가 2개입니다.
  4. 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 이미지로 만들고 Resize와 CenterCrop과 ToTensor와 Normalize를 거쳐 텐서가 된 뒤 unsqueeze로 배치 축을 붙여 모델을 통과하고 softmax로 확률이 되는 TorchVisionWorker의 predict 흐름

토크나이저 자리에 이미지 전처리가 들어왔을 뿐 뼈대는 같습니다. 다른 점은 셋입니다.

하나, 입력을 두 갈래로 받습니다. 문자열이면 파일 경로로 보고 열고, 아니면 이미 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으로 들어온 텐서를 numpy로 복원해 InferInput으로 감싼 뒤 프로세스 경계를 넘어 Triton 서버로 HTTP 요청을 보내고, 돌아온 fc6_1 로짓을 softmax 없이 그대로 응답으로 내보내는 TritonWorker의 predict 흐름

앞의 둘과 성격이 다릅니다. 여기서는 추론을 하지 않습니다. 추론을 남에게 시킵니다.

  1. 입력 딕셔너리를 돌면서 텐서를 복원합니다. JSON을 거쳐 들어온 중첩 리스트를 np.array(..., dtype=np.float32)로 만들고 shape대로 reshape합니다. dtype을 명시하는 게 중요합니다. config.pbtxt가 TYPE_FP32를 요구하는데 numpy가 기본으로 float64를 골라 버리면 Triton이 거절합니다.
  2. InferInput으로 감쌉니다. 텐서 이름, 모양, 타입 문자열 "FP32"가 여기서 만나 서버 쪽 계약과 맞춰집니다. 이름이 config.pbtxt의 data_0과 다르면 서버가 400을 돌려줍니다.
  3. client.infer()로 HTTP 요청을 보냅니다. 앞 워커들이 self.model(...)을 부르던 자리입니다. 여기서 프로세스 경계를 넘습니다.
  4. 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 처럼 외부 서버에 호출을 맡기는 경우 "직렬화, 원격 호출, 역직렬화"만 합니다. 이름만 같은 함수 셋이지, 하는 일의 성격이 다릅니다.

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

단일 모델 서빙 멀티 모델 서빙
핵심 질문 한 모델을 어떻게 빠르게 많이 돌릴까 여러 모델을 한정된 메모리에 어떻게 얹을까
병목 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 메모리를 놓고 싸웁니다.