[LLMSO] generate()를 넘어 LLM Serving System 설계하기

xgro·2026년 8월 15일

LLMSO

목록 보기
3/10
post-thumbnail

📌 Notice

LLM Serving and Optimization Study (=LLMSO)
직접 실습을 통해 LLM 서비스 제공 및 최적화에 대한 지식에 대한 학습 및 실습 내용을 정리한 블로그입니다.

CloudNet@에서 스터디를 진행하고 있습니다.

EKS 관련 이전 스터디 내용은 아래 링크를 통해 확인할 수 있습니다.


이번 글에서는 세 번째 주제인
CH3. Model Serving System Design: A Deep Dive를 정리합니다.

CH1에서는 Model Serving과 Optimization의 기본 개념을 살펴보고,
CH2에서는 LLM이 다음 Token을 생성하는 구조와 KV Cache·Batching·Streaming을
직접 확인했습니다.

이번에는 여기서 한 단계 더 나아가, model.generate()를 호출하는 코드를
실제 Serving System으로 확장하려면 어떤 구성요소와 설계 판단이 필요한지
공부하고 실습한 내용을 정리합니다.

이 글의 실습 수치는 macOS Apple Silicon의 로컬 환경에서 측정한 결과입니다.
서로 다른 Backend와 Weight 형식의 결과는 절대적인 성능 순위가 아니라,
각 Serving 경로의 동작과 차이를 이해하기 위한 관찰값으로 사용했습니다.

🧪 실습 코드와 실행 결과

이번 스터디에서 작성한 코드와 실제 실행 결과는 개인 GitHub 저장소인
llmso-lab에 함께 정리하고 있습니다.

단순한 예제 코드뿐만 아니라 다음 내용을 기록합니다.

  • 챕터별 실습 코드와 실행 방법
  • 직접 측정한 Latency·TTFT·Batching 결과
  • 실습 결과를 확인할 수 있는 로그와 캡처
  • Apple Silicon 로컬 환경에서 겪은 호환성 문제
  • vLLM-Metal·MLX 등 대체 실행 환경을 구성한 과정
  • 예상과 실제 결과가 달랐던 부분과 해석

실행 환경과 모델의 Weight 형식에 따라 결과는 달라질 수 있습니다.
저장소의 수치는 절대적인 성능 비교가 아니라, 직접 실행하며 LLM Serving 구조를 이해하기 위해 남긴 학습 기록입니다.



📌 Intro

LLM을 처음 공부할 때는 보통 모델을 불러오고 generate()를 호출하면
답변이 반환된다고 생각하기 쉽습니다.

하지만 실제 서비스를 만들기 시작하면 곧바로 다른 문제를 만나게 됩니다.

여러 사용자의 요청을 어떻게 동시에 처리할 것인지,
긴 응답을 어떻게 실시간으로 전달할 것인지,
GPU 메모리에 모델을 어떻게 배치할 것인지,
모델이 많아졌을 때 어떤 모델로 요청을 보낼 것인지가 필요합니다.

결국 LLM Serving은 단순히 모델의 출력을 반환하는 코드가 아니라,
요청의 생명주기와 GPU 자원을 관리하는 하나의 시스템에 가깝습니다.

이번 챕터에서는 완성된 Serving Framework를 바로 사용하는 대신,
간단한 Serving Server를 직접 구성하면서 이 과정을 단계별로 살펴봅니다.



🧭 Overview

이번 챕터에서는 하나의 모델을 API로 제공하는 간단한 서버부터 시작해,
여러 요청과 여러 모델을 처리할 수 있는 Serving System으로 확장해봅니다.

단순히 generate()를 호출하는 코드에서 출발하지만,
실습을 진행하면서 다음과 같은 문제를 직접 다루게 됩니다.

🎯 이번 챕터에서 확인할 것

  • 하나의 요청이 모델 서버를 거치는 과정
  • 여러 요청을 묶어 처리하는 Batching
  • 생성 중인 응답을 전달하는 Streaming
  • 요청 처리와 모델 추론을 분리하는 구조
  • 여러 모델을 하나의 서버에서 관리하는 방법
  • 모델 로딩과 GPU 메모리 사용량의 Trade-off
  • vLLM과 NVIDIA Triton이 해결하는 문제

🧩 전체 학습 흐름

  1. Single-Model Serving 구조 이해
  2. 단일 요청 처리 서버 구현
  3. 여러 요청을 처리하는 Batching 구현
  4. Batching 환경에서 Streaming 추가
  5. vLLM을 활용한 Serving과 비교
  6. Multi-Model Serving 구조 설계
  7. NVIDIA Triton Backend 연동
  8. 비용과 지연시간 관점에서 설계 비교

🧪 실습의 목표

이번 실습의 목표는 특정 프레임워크의 사용법을 외우는 것이 아닙니다.

요청이 들어온 순간부터 모델이 추론을 수행하고,
응답이 사용자에게 전달되기까지 어떤 컴포넌트가 필요하며
각 단계에서 어떤 병목과 운영 문제가 발생하는지 직접 확인하는 것입니다.



🚀 Step 01. Single-Model Serving: 가장 단순한 기준선 만들기

💡 먼저 구분하기: Inference와 Serving

LLM을 처음 공부할 때는 모델을 불러온 뒤
generate()를 호출하면 답변이 반환된다고 생각하기 쉽습니다.

이때 모델이 입력을 계산해 다음 Token을 생성하는 과정이
Inference입니다.

하지만 실제 서비스에서는 Inference만으로 충분하지 않습니다.

  • 요청을 받을 API가 필요하고
  • 입력 형식을 검증해야 하며
  • 모델을 메모리에 올려두고 관리해야 하고
  • 여러 요청의 처리 상태를 추적해야 하며
  • 생성된 결과를 사용자에게 반환해야 합니다

이처럼 모델 추론을 서비스로 제공하기 위해 필요한 주변 시스템까지
포함한 것이 Serving입니다.

Inference
└─ 입력을 모델에 전달하고 출력 Token을 계산하는 과정

Serving
└─ API, 요청 관리, 모델 lifecycle, 추론 실행, 응답 전달을 포함한 시스템

따라서 generate()는 Serving System 전체가 아니라,
Serving 과정 안에서 모델 추론을 담당하는 한 부분이라고 볼 수 있습니다.

🧩 Single-Model Serving이란?

Single-Model Serving은 하나의 Serving Server가
하나의 언어 모델을 중심으로 요청을 처리하는 구조입니다.

서버가 시작될 때 모델의 가중치를 메모리에 로드하고,
이후 들어오는 요청은 이미 로드된 모델을 재사용합니다.

서버 시작
  └─ 모델 가중치 로드
       ↓
Client 요청
  └─ API 요청 수신 및 입력 검증
       ↓
Tokenize / Prefill
       ↓
반복적인 Token Decode
       ↓
Detokenize 및 응답 후처리
       ↓
Client 응답 반환

여기서 중요한 점은 모델을 요청마다 다시 로드하지 않는다는 것입니다.

모델 가중치를 매 요청마다 로드하면 다음과 같은 문제가 발생합니다.

  • 모델 로딩 시간만큼 요청이 지연됨
  • GPU 메모리를 반복적으로 할당하고 해제해야 함
  • 동시에 여러 요청이 들어왔을 때 자원 관리가 어려움
  • 실제 Token 생성보다 모델 준비 시간이 더 커질 수 있음

그래서 일반적인 Serving Server는 시작 시점에 모델을 로드하고,
서버가 실행되는 동안 해당 모델을 재사용합니다.

🎯 왜 Single-Model Serving부터 시작할까?

처음부터 여러 모델과 복잡한 라우팅을 함께 다루면
문제의 원인을 파악하기 어려워집니다.

예를 들어 응답이 느릴 때 다음 중 어떤 원인인지 구분하기 어렵습니다.

  • 모델 자체의 추론 시간이 긴 것인지
  • 요청이 큐에서 오래 대기한 것인지
  • 모델을 로드하는 데 시간이 걸린 것인지
  • 다른 모델과 GPU 메모리를 경쟁하고 있는 것인지
  • 라우터가 잘못된 모델로 요청을 보낸 것인지

Single-Model Serving은 이러한 변수를 최소화한 기준선입니다.

먼저 하나의 모델만 사용하는 구조에서 다음을 확인합니다.

  • 요청이 API에서 모델까지 전달되는 과정
  • 모델 로딩과 요청 처리의 분리
  • 하나의 요청을 처리하는 데 걸리는 시간
  • 모델 추론과 응답 반환의 책임 분리
  • 요청이 동시에 들어왔을 때 발생하는 대기 문제

이 기준선을 만든 뒤 Batching, Streaming, Multi-Model Serving을
하나씩 추가하면 각 기능이 어떤 문제를 해결하는지 비교할 수 있습니다.

🔍 요청 처리 과정에서 확인할 지점

Client
  │
  ▼
Serving API
  │  요청 수신·검증
  ▼
Request Handler
  │  입력 변환·상태 관리
  ▼
Model Runtime
  │  Inference 수행
  ▼
Response Handler
  │  결과 변환·응답 반환
  ▼
Client

각 컴포넌트의 역할은 다음과 같습니다.

컴포넌트역할
Client텍스트 입력과 생성 옵션을 전달
Serving APIHTTP 또는 gRPC 요청을 수신
Request Handler요청을 검증하고 추론 작업으로 변환
Model Runtime모델을 실행하고 Token을 생성
Response Handler생성 결과를 후처리해 반환

단순한 실습에서는 이 모든 역할이 하나의 Python 프로세스에
함께 작성될 수 있습니다.

하지만 운영 환경에서는 API 처리, 요청 스케줄링, 모델 실행,
응답 전송을 분리할수록 각 부분을 독립적으로 확장하고 장애를
분리하기 쉬워집니다.

📌 Client → Serving API → Model Runtime → Response 흐름

📏 요청 하나의 지연시간은 무엇으로 구성될까?

사용자가 느끼는 전체 응답 시간은 모델의 계산 시간만을 의미하지 않습니다.

전체 지연시간
= 네트워크 시간
+ 요청 대기 시간
+ Tokenize 시간
+ Prefill 시간
+ Decode 시간
+ Detokenize 및 응답 전송 시간

이후 실습에서는 모든 값을 정밀하게 측정하지 않더라도,
응답이 늦다는 현상을 하나의 숫자로만 보지 않고
어느 단계에서 시간이 사용되는지 구분하는 것이 중요합니다.

특히 LLM Serving에서는 다음 지표를 구분해서 보게 됩니다.

  • TTFT: 첫 번째 Token이 출력될 때까지 걸린 시간
  • Inter-Token Latency: Token과 Token 사이의 생성 간격
  • End-to-End Latency: 전체 응답이 완료될 때까지의 시간
  • Throughput: 일정 시간 동안 처리한 Token 또는 요청 수
  • GPU Memory: 모델과 요청 상태가 사용하는 GPU 메모리

🧠 이 단계에서 생각해볼 질문

  • 모델을 요청마다 다시 로드하면 왜 느려질까?
  • 모델이 메모리에 올라가 있어도 요청이 지연될 수 있을까?
  • 두 요청이 동시에 들어오면 현재 구조에서는 어떻게 처리될까?
  • 하나의 모델을 사용한다는 것은 요청도 하나라는 뜻일까?
  • API 서버와 모델 추론 코드를 분리하면 어떤 장점이 있을까?
  • Batching은 이 구조의 어떤 문제를 해결하기 위해 필요한가?

✅ Step 01의 핵심

Single-Model Serving은 최종적인 구조가 아니라
Serving System을 이해하기 위한 가장 단순한 기준선입니다.

이번 단계에서는 먼저 하나의 모델과 하나의 요청을 기준으로
요청이 서버에 들어와 모델을 거쳐 응답으로 돌아오는 전체 흐름을 확인합니다.

이후 실습에서는 이 단순한 구조에 요청을 여러 개 묶는 Batching과
생성 중인 결과를 전달하는 Streaming을 차례대로 추가해보겠습니다.



🧪 Lab 01. 단일 요청 처리 서버 구현

🎯 실습 목표

이번 실습에서는 하나의 모델을 서버 시작 시점에 로드한 뒤,
Client의 단일 요청을 받아 응답을 반환하는 가장 기본적인
Serving Server를 구현합니다.

모델 로드
   ↓
서버 실행
   ↓
Client 요청
   ↓
입력 처리
   ↓
모델 Inference
   ↓
응답 반환

이 단계에서는 아직 Batching이나 Streaming을 적용하지 않습니다.

먼저 가장 단순한 요청 하나를 처리하면서
다음 질문에 답하는 것이 목표입니다.

  • 모델은 언제 메모리에 로드되는가?
  • 요청이 들어오면 어떤 함수가 실행되는가?
  • generate()는 어떤 역할을 담당하는가?
  • 첫 요청과 이후 요청의 처리 시간은 같은가?
  • API 처리 시간과 모델 추론 시간은 어떻게 구분할 수 있는가?

🧩 구현할 컴포넌트

이번 서버는 크게 세 부분으로 나눌 수 있습니다.

  1. Model Loader

    서버가 시작될 때 Tokenizer와 모델 가중치를 메모리에 로드합니다.

  2. Inference Function

    입력 문장을 Token으로 변환하고 모델의 generate()를 호출합니다.

  3. Serving API

    Client의 요청을 받아 Inference Function을 실행한 뒤
    생성된 텍스트를 응답으로 반환합니다.

개념적으로는 다음과 같은 구조입니다.

# 서버 시작 시 한 번 실행
tokenizer, model = load_model()

# 요청마다 실행
def generate_response(prompt):
    inputs = tokenizer(prompt)
    outputs = model.generate(**inputs)
    return tokenizer.decode(outputs)

# API 요청 처리
@app.post("/generate")
def generate(request):
    response = generate_response(request.prompt)
    return {"response": response}

실제 실습에서는 사용하는 모델과 API 프레임워크에 맞게
각 부분을 구현합니다.

🧠 실행 전에 예상해보기

코드를 실행하기 전에 다음과 같이 예상해볼 수 있습니다.

  • 서버를 처음 시작할 때 모델 로딩 시간이 발생할 것이다.
  • 모델이 로드된 뒤에는 같은 모델을 재사용할 것이다.
  • 첫 요청은 초기화나 Warm-up 때문에 더 느릴 수 있다.
  • 요청이 완료될 때까지 전체 응답을 기다린 뒤 반환할 것이다.
  • 여러 요청이 동시에 들어오면 별도의 스케줄링이 없기 때문에
    요청이 순차적으로 처리되거나 대기할 수 있다.

이 예상은 이후 Batching과 Streaming을 추가했을 때
무엇이 달라지는지 비교하기 위한 기준선이 됩니다.

🔍 실행 후 확인할 것

실행 결과에서는 단순히 응답 문장이 생성되었는지만 확인하지 않습니다.

다음 항목을 함께 확인합니다.

  • 서버 시작 시 모델이 한 번만 로드되는지
  • 요청이 API Endpoint에 정상적으로 도착하는지
  • 입력 Prompt가 모델에 전달되는지
  • 생성된 결과가 정상적으로 반환되는지
  • 첫 요청과 두 번째 요청의 처리 시간이 다른지
  • 응답이 한 번에 반환되는지

📌 서버 시작 로그와 모델 로딩 완료

📌 Client 요청

📌 동일한 모델을 재사용한 두 번의 단일 요청 처리 시간 비교

✅ 이번 실습에서 얻어야 할 것

이번 실습의 핵심은 “API로 모델을 호출했다”가 아닙니다.

모델 가중치는 서버 시작 시 메모리에 올라가고,
각 요청은 이미 준비된 Model Runtime을 재사용한다는 점을
직접 확인하는 것입니다.

이제 이 단일 요청 구조에 여러 요청을 동시에 넣으면
어떤 문제가 생기는지 살펴볼 수 있습니다.

다음 단계에서는 이 문제를 해결하기 위해
여러 요청을 묶어서 처리하는 Batching을 추가합니다.



🚀 Step 02. Batching: 여러 요청을 한 번에 처리하기

💡 Step 01의 한계

Step 01에서는 요청이 들어올 때마다
모델의 generate()를 한 번씩 호출했습니다.

요청 1 → generate() → 응답 1
요청 2 → generate() → 응답 2
요청 3 → generate() → 응답 3

이 방식은 구조가 단순하다는 장점이 있지만,
요청이 많아지면 모델이 같은 과정을 여러 번 반복하게 됩니다.

GPU를 사용하는 경우에는 한 번의 추론에서
사용하지 않는 연산 자원이 생길 수 있고,
요청이 순차적으로 처리되면서 전체 처리량도 낮아질 수 있습니다.

🧩 Batching이란?

Batching은 여러 개의 Prompt를 하나로 묶어
모델에 한 번에 전달하는 방식입니다.

Prompt 1 ─┐
Prompt 2 ─┼─ Tokenize + Padding ─→ 하나의 Batch Tensor
Prompt 3 ─┘                              │
                                         ▼
                                  Model Inference
                                         │
                         ┌───────────────┼───────────────┐
                         ▼               ▼               ▼
                      응답 1           응답 2           응답 3

중요한 점은 여러 HTTP 요청을 단순히 동시에 보내는 것과
Batching은 다르다는 것입니다.

Batching은 여러 입력을 하나의 Tensor로 만들고,
하나의 모델 호출에서 함께 처리하는 방식입니다.

📏 Padding이 필요한 이유

각 Prompt의 길이가 서로 다를 수 있기 때문에
모델에 전달하기 전에 같은 길이로 맞춰야 합니다.

Prompt 1: [A, B, C, D]
Prompt 2: [E, F, PAD, PAD]
Prompt 3: [G, H, I, PAD]

여기서 PAD는 실제 입력이 아니라 길이를 맞추기 위한 값입니다.

모델은 Padding 위치를 실제 Token으로 계산하지 않도록
Attention Mask를 함께 전달해야 합니다.

Input IDs
Attention Mask

따라서 Batching에서는 단순히 Prompt를 리스트로 묶는 것뿐 아니라,

  • Padding 방향
  • Attention Mask
  • Prompt별 실제 길이
  • 출력 결과와 원래 요청의 매핑

을 함께 관리해야 합니다.

🧠 이번 실습에서 사용하는 Batching

이번 실습은 미리 정해진 요청 목록을 한 번에 처리하는
정적 Batching(Static Batching)입니다.

[Prompt 1, Prompt 2, Prompt 3]
              │
              ▼
        한 번의 Inference

아직 요청이 들어오는 순간마다 동적으로 묶지는 않습니다.

실시간으로 들어오는 요청을 모아 처리하는 방식은
이후 Streaming과 vLLM의 Continuous Batching에서 다룹니다.

🧪 비교할 실험

같은 Prompt 목록을 다음 두 방식으로 실행합니다.

  1. Prompt를 하나씩 순차적으로 처리
  2. Prompt를 하나의 Batch로 묶어 처리

비교 조건은 동일하게 유지합니다.

  • 같은 모델
  • 같은 Prompt 목록
  • 같은 max_new_tokens
  • 같은 temperature
  • 같은 디바이스
  • 같은 출력 형식

🔍 실행 전에 예상해보기

실행 전에는 다음과 같이 예상할 수 있습니다.

  • Batch 방식은 모델 호출 횟수를 줄일 수 있다.
  • GPU 환경에서는 전체 처리량이 증가할 가능성이 높다.
  • Batch size가 커지면 개별 요청의 대기 시간은 늘어날 수 있다.
  • Prompt 길이가 다르면 Padding으로 불필요한 계산이 생길 수 있다.
  • CPU 환경에서는 Batching이 항상 더 빠르다고 보장할 수 없다.
  • Batch 결과는 다시 원래 Prompt별 응답으로 나누어야 한다.

📌 순차 처리와 Batching 처리의 요청 흐름을 비교하는 이미지

📊 기록할 지표

이번 Step에서는 다음 값을 비교합니다.

  • 순차 처리 전체 시간
  • Batch 처리 전체 시간
  • 평균 요청 처리 시간
  • Prompt 또는 요청 처리량
  • Batch size
  • Input/output Token 수
  • Padding으로 인해 추가된 Token 수

비스트리밍 방식이므로 TTFT와 ITL은
이번 Step의 필수 측정 대상이 아닙니다.

✅ Step 02의 핵심

Batching은 단순히 요청을 동시에 보내는 기능이 아닙니다.

여러 입력을 하나의 Tensor로 묶고,
Padding과 Attention Mask를 적용한 뒤,
한 번의 모델 호출 결과를 다시 요청별 응답으로 분리하는 과정입니다.

Batching은 처리량을 높일 수 있지만,
개별 요청의 대기 시간과 Padding에 따른 추가 계산이라는
새로운 Trade-off도 만들어냅니다.

다음 Step에서는 여러 요청을 Batch로 처리하면서도
각 사용자에게 생성 중인 Token을 실시간으로 전달하는
Streaming을 추가합니다.

🧪 Lab 02. 순차 처리와 Static Batching 비교

🎯 실습 목표

같은 Prompt 목록을 순차적으로 처리하는 방식과
하나의 Batch Tensor로 처리하는 방식을 비교합니다.

이번 실습의 목적은 “Batch가 항상 빠르다”를 증명하는 것이 아닙니다.
같은 조건에서 모델 호출 방식이 달라졌을 때
전체 시간과 요청별 결과가 어떻게 달라지는지 확인하는 것입니다.

🧩 실습 코드

실행 코드는 ch03/step-02-batching/batch_experiment.py에 둡니다.

cd /Users/xgro/workspace/personal/llmso/ch03/step-02-batching
source ../step-01-single-model-serving/.venv/bin/activate
python batch_experiment.py

🔍 비교 조건

  • 같은 모델
  • 같은 Prompt 목록
  • 같은 max_new_tokens
  • 같은 temperature
  • 같은 디바이스
  • 순차 처리와 Batch 처리에서 동일한 결과 형식 사용

🧠 실행 전에 예상해보기

  • 순차 방식은 요청마다 모델 호출이 발생할 것이다.
  • Batch 방식은 모델 호출 횟수를 줄일 것이다.
  • Prompt 길이가 서로 다르면 Padding이 발생할 것이다.
  • CPU에서는 Batch 방식의 이득이 작거나 오히려 느릴 수 있다.
  • Batch 결과의 순서는 원래 Prompt 순서와 대응되어야 한다.

🔎 실행 후 확인할 것

  • 순차 처리 전체 시간
  • Batch 처리 전체 시간
  • 순차 처리의 요청별 시간
  • Batch 처리의 결과별 Token 수
  • Prompt별 입력 길이와 Padding Token 수
  • 두 방식의 응답 결과가 비교 가능한지

📊 실제 실행 결과

이번 실습은 Qwen/Qwen2.5-0.5B-Instruct 모델을 CPU에서 실행했습니다.
세 개의 Prompt에 max_new_tokens=32, temperature=0.0 조건을 동일하게 적용했습니다.

항목순차 처리Static Batching
요청 수 / Batch size33
전체 처리 시간4.310초2.537초
Input Token38, 38, 3638, 38, 36
Output Token각 32각 32
Padding Token해당 없음2
모델 호출요청마다 호출한 번의 Batch 호출

이번 실행에서는 Static Batching의 전체 처리 시간이 순차 처리보다
약 1.773초 짧게 측정되었습니다.

하지만 한 번의 CPU 실행만으로 Batching이 항상 더 빠르다고 결론 내릴 수는 없습니다.
Batch size, Prompt 길이, 하드웨어와 Runtime에 따라 결과가 달라질 수 있습니다.

또한 첫 번째와 두 번째 Prompt의 응답은 일치했지만,
Padding이 적용된 세 번째 Prompt는 순차 처리와 Batch 처리의 응답이 달랐습니다.
이번 결과만으로 원인을 확정하지 않고, Padding·Position 처리와 실행 시 수치 차이를
추가로 확인해야 할 항목으로 남겼습니다.

📸 결과

Static Batching 결과

전체 시간·Padding·응답 일치 여부 비교

실행 결과는 다음 위치에 저장합니다.

step-02-batching/results/
├── sequential.json
├── batch.json
└── comparison.md

✅ 이번 실습에서 얻어야 할 것

Batching은 여러 HTTP 요청을 동시에 보내는 기능이 아니라,
여러 입력을 하나의 Tensor로 만들고 한 번의 모델 호출로 처리하는 구조입니다.

이번 결과가 CPU에서 기대만큼 빠르지 않더라도 정상입니다.
그 경우에는 Batching의 효과가 하드웨어와 모델 Runtime에 따라 달라진다는 점을 기록합니다.

이번 CPU 실행에서는 Batch 처리 시간이 더 짧았지만,
응답 동등성과 반복 측정이 추가로 필요하다는 한계도 함께 확인했습니다.

다음 Step에서는 전체 생성이 끝날 때까지 기다리는 대신,
생성 중인 결과를 Client에 전달하는 Streaming Baseline을 확인합니다.





🚀 Step 03. Streaming Baseline: 생성 중인 Token 전달하기

이번 Step은 실제 Batching Streaming이 아니라 Streaming 전달 경로를 확인하는 기준선입니다.
하나의 요청을 SSE로 전달하고 TTFT·ITL 근사치·E2E를 기록합니다.
여러 요청을 하나의 생성 Loop에서 처리하는 Queue·Scheduler·Token 매핑은
아직 구현하지 않으며, 이 범위는 결과 해석에서 명확히 구분합니다.

💡 Step 02의 한계

Step 02의 응답은 모델이 모든 Token을 생성한 뒤 한 번에 반환됩니다.

Client 요청
   ↓
모델이 전체 응답 생성
   ↓
응답 전체 반환

응답이 짧을 때는 문제가 잘 드러나지 않습니다.
하지만 생성해야 할 Token이 많아지면 사용자는 모델이 작업 중인지,
서버에 문제가 생겼는지 알기 어렵습니다.

여기서 필요한 것이 Streaming입니다.

🧩 Streaming이란?

Streaming은 생성이 끝날 때까지 기다리지 않고,
생성된 결과를 작은 단위로 나누어 Client에 전달하는 방식입니다.

Token 1 생성 ─→ Client
Token 2 생성 ─→ Client
Token 3 생성 ─→ Client
Token 4 생성 ─→ Client
       ...
완료 이벤트 ─→ Client

Streaming을 적용하면 사용자는 첫 번째 Token을 빠르게 볼 수 있습니다.
다만 첫 번째 Token이 빨리 도착하는 것과 전체 응답이 빨리 끝나는 것은
서로 다른 현상입니다.

📦 Blocking 응답과 Streaming 응답

방식서버 동작사용자가 느끼는 특징
Blocking전체 생성이 끝난 뒤 응답 Body 반환완료 전까지 화면 변화가 없음
Streaming생성 중인 결과를 여러 이벤트로 전송첫 Token부터 화면에 표시 가능

Streaming은 모델의 계산량을 줄이는 기능이 아닙니다.
같은 모델이 같은 Token을 생성하더라도 결과를 전달하는 시점을 앞당겨
사용자 체감 시간을 개선하는 기능에 가깝습니다.

📡 SSE(Server-Sent Events)의 기본 형태

이번 실습에서는 서버에서 Client 방향으로 이벤트를 보내는
SSE(Server-Sent Events) 형태를 사용합니다.

event: start
data: {"request_id":"req-001"}

event: token
data: {"text":"LLM"}

event: token
data: {"text":" serving"}

event: done
data: {"output_tokens":2}

SSE는 HTTP 연결을 유지하면서 서버가 이벤트를 순서대로 전송합니다.
따라서 Client는 응답 전체를 기다리지 않고 줄 단위 이벤트를 읽을 수 있습니다.

🧠 Batching과 Streaming을 함께 사용하면 무엇이 달라질까?

여러 요청을 Batch로 처리하면서 각 요청의 결과를 Streaming하려면
모델 실행 결과와 HTTP 연결을 분리해서 관리해야 합니다.

Client A ─┐                         ┌─ SSE A
Client B ─┼─ Request Queue ─ Batch ─┼─ SSE B
Client C ─┘                         └─ SSE C
                         │
                         └─ Token별 결과를 요청 ID에 맞게 전달

이 구조에서 필요한 것은 단순한 list[str]가 아닙니다.

  • 각 요청을 식별할 request_id
  • 요청별 생성 상태
  • 요청별 이벤트 Queue
  • 생성된 Token과 요청의 매핑
  • 먼저 끝난 요청의 종료 처리
  • Client Disconnect와 오류 전파

또한 model.generate()를 Batch로 한 번 호출한 뒤 전체 결과를 반환하는
구현만으로는 진정한 Streaming을 만들 수 없습니다.
생성 단계마다 결과를 꺼내고 각 요청의 Stream으로 나누어 보내는
Model Runtime 또는 Scheduler가 필요합니다.

🔄 요청 생명주기

요청 수신
  ↓
request_id 발급
  ↓
대기 Queue 진입
  ↓
Batch에 포함
  ↓
Token 생성
  ├─ 요청별 token 이벤트 Queue에 기록
  └─ SSE Response로 전달
  ↓
완료·오류·취소 상태 기록

이 생명주기를 명시적으로 관리하지 않으면 다음과 같은 문제가 생길 수 있습니다.

  • A 요청의 Token이 B Client로 전달됨
  • 생성이 끝난 요청의 Queue가 정리되지 않음
  • Client가 연결을 끊어도 모델 생성이 계속됨
  • 한 요청의 오류가 같은 Batch 전체에 전파됨

📏 이번 Step에서 측정할 지표

Streaming에서는 전체 시간 하나만 기록하면 충분하지 않습니다.

  • TTFT: 요청 전송부터 첫 번째 Token 이벤트까지의 시간
  • ITL / TPOT: Token 이벤트 사이의 평균 시간
  • E2E Latency: 마지막 Token 또는 완료 이벤트까지의 시간
  • Output Tokens: 생성된 Token 수
  • Disconnect/Error: Client 종료와 서버 오류 발생 여부
요청 전송 ── TTFT ── 첫 Token ── ITL ── Token ── ... ── Done
│<-------------------------- E2E -------------------------->

🧠 실행 전에 생각해보기

  • Streaming을 추가하면 모델의 전체 계산량도 줄어들까?
  • 첫 Token이 빨라져도 E2E Latency가 길 수 있을까?
  • Batch 안에서 먼저 끝난 요청은 다른 요청을 기다려야 할까?
  • 요청별 이벤트 Queue가 없다면 어떤 응답 혼선이 생길까?
  • Client가 중간에 연결을 끊었을 때 생성 작업을 어떻게 취소할까?

✅ Step 03의 핵심

Streaming은 모델 결과를 빠르게 계산하는 기능이 아니라,
생성 중인 결과를 Client에 전달하는 응답 경로의 변화입니다.

Batching과 Streaming을 함께 사용하려면 요청별 상태와 이벤트 전달 경로가
필요합니다. 이번 Step에서는 먼저 SSE와 TTFT·ITL 측정을 확인한 뒤,
다음 단계에서 전문 Serving Framework가 이 문제를 어떻게 다루는지 비교합니다.



🧪 Lab 03. Streaming Baseline 응답과 TTFT 측정

🎯 실습 목표

이번 실습에서는 실제 Dynamic Batching이 아닌 Streaming Baseline으로,
전체 응답을 기다리는 Blocking 방식과
생성 중인 결과를 전달하는 Streaming 방식을 비교합니다.

이번 Lab의 1차 구현은 Streaming 전달 경로를 분리해 확인하는 데 초점을 둡니다.
즉, SSE와 시간 지표를 먼저 검증하고, 여러 요청을 하나의 생성 Loop에서
스케줄링하는 문제는 vLLM 비교 단계에서 다시 확인합니다.

🧩 실습 범위

  1. /generate/stream SSE Endpoint 구현
  2. start, token, done, error 이벤트 확인
  3. Client에서 TTFT, ITL, E2E 측정
  4. 동일 Prompt의 Blocking 결과와 Streaming 결과 비교
  5. Client Disconnect 또는 서버 오류가 발생했을 때의 동작 기록

▶️ 실행

실행 코드는 ch03/step-03-streaming-with-batching/ 디렉터리에 둡니다.

cd /Users/xgro/workspace/personal/llmso/ch03/step-03-streaming-with-batching
source ../step-01-single-model-serving/.venv/bin/activate
python -m uvicorn server:app --host 127.0.0.1 --port 8001

다른 터미널에서 Client를 실행합니다.

cd /Users/xgro/workspace/personal/llmso/ch03/step-03-streaming-with-batching
source ../step-01-single-model-serving/.venv/bin/activate
python client.py

🔍 실행 후 확인할 것

  • start 이벤트가 첫 번째로 도착하는가
  • 첫 번째 token 이벤트까지 시간이 기록되는가
  • Token 이벤트가 여러 번 나누어 도착하는가
  • done 이벤트에서 전체 Token 수와 E2E 시간이 기록되는가
  • Blocking 응답과 Streaming 응답의 전체 시간은 어떻게 다른가
  • Streaming이 TTFT를 개선했지만 E2E를 줄이지 않았을 가능성은 없는가

📊 기록할 지표

지표기록 위치의미
TTFTresults/streaming.json첫 Token 이벤트까지의 시간
ITL / TPOTresults/streaming.jsonToken 이벤트 간 평균 시간
E2Eresults/streaming.json완료 이벤트까지의 시간
Output Tokensresults/streaming.json생성된 Token 수
이벤트 순서터미널 캡처start → token → done 흐름

현재 기준 구현은 TextIteratorStreamer가 전달하는 text chunk를 이벤트 단위로 사용합니다.
따라서 기록한 ITL은 정확한 Token 단위 값이 아니라 chunk 간격의 근사치입니다.
정확한 Token 단위 ITL은 Token ID를 직접 관찰하는 Runtime에서 별도로 측정합니다.

실행 결과는 다음 위치에 저장합니다.

step-03-streaming-with-batching/results/
├── streaming.json
├── blocking.json
├── comparison.md
└── screenshots/

📊 실제 실행 결과

같은 Prompt와 max_new_tokens=64, temperature=0.0 조건으로
Blocking 응답과 Streaming 응답을 비교했습니다.

지표BlockingStreaming Baseline
TTFT측정하지 않음1.949초
E2E Latency2.544초4.255초
Chunk 간격 근사치해당 없음0.044초
Output Tokens6464

Streaming에서는 전체 생성이 끝나기 전인 1.949초에 첫 text chunk를 확인할 수 있었습니다.
반면 전체 완료 시간은 4.255초로, 이번 실행에서는 Blocking 응답의 2.544초보다 길었습니다.

따라서 이번 결과는 Streaming이 모델 계산 자체를 빠르게 만든다는 의미가 아닙니다.
사용자가 완성된 응답을 기다리지 않고 생성 중인 내용을 먼저 볼 수 있도록
응답 전달 시점을 바꾼다는 점을 확인한 결과입니다.

또한 0.044초는 TextIteratorStreamer가 전달한 text chunk 사이의 평균 간격입니다.
하나의 chunk가 하나의 Token과 같다고 보장할 수 없으므로,
정확한 Token 단위 ITL로 해석하지 않습니다.

📸 결과

  • SSE 이벤트가 여러 줄로 출력되는 Client 터미널
  • 서버의 요청 시작·완료 로그
  • Blocking과 Streaming의 TTFT·E2E 비교 결과

⚠️ 이번 Lab의 범위

이번 코드가 여러 HTTP 요청을 자동으로 하나의 동적 Batch로 묶는 것은 아닙니다.
SSE 응답 경로와 측정 방법을 먼저 고정한 뒤,
실제 Batch Scheduler를 추가할 때 필요한 요청별 Queue와 취소 처리를
별도의 설계 항목으로 남깁니다.

✅ 이번 실습에서 얻어야 할 것

Streaming을 적용하면 사용자가 첫 결과를 더 빨리 볼 수 있지만,
모델이 생성해야 하는 총 Token 수가 줄어드는 것은 아닙니다.

따라서 결과를 다음처럼 분리해서 기록합니다.

사용자 체감 개선 → TTFT와 이벤트 도착 시점
모델 처리 성능   → ITL, E2E, Throughput



🚀 Step 04. vLLM Serving: 직접 만든 서버와 비교하기

💡 직접 구현의 범위가 빠르게 커지는 이유

Step 01부터 Step 03까지 직접 구현한 기능은 작은 예제에서는 충분합니다.
하지만 실제 Serving Server에는 다음과 같은 책임이 계속 추가됩니다.

  • 동시에 들어온 요청의 스케줄링
  • 요청별 KV Cache 관리
  • 길이가 다른 요청의 효율적인 처리
  • Streaming과 Client Disconnect 처리
  • GPU 메모리 사용량 제어
  • 모델별 설정과 오류 처리
  • 상태·지표·로그 수집

모델 추론 코드보다 요청을 안전하고 효율적으로 운영하는 코드가
더 커질 수 있습니다.

vLLM과 같은 Serving Framework는 이 문제를 범용 Runtime과 Server 형태로
제공합니다.

🧩 vLLM은 무엇을 비교하기 위한 도구인가?

이번 Step의 목적은 vLLM 명령어를 외우는 것이 아닙니다.
직접 만든 FastAPI + Transformers 서버와 전문 Runtime을 같은 관점에서
비교하는 것입니다.

직접 구현
Client → FastAPI → 직접 작성한 Handler → Transformers Model

vLLM
Client → OpenAI-Compatible API → vLLM Scheduler/Runtime → Model

vLLM은 OpenAI-Compatible API를 제공하므로,
Client의 요청 형식을 크게 바꾸지 않고 서버 구현을 교체해 볼 수 있습니다.
다만 모델 지원 여부, GPU·CUDA 환경, Chat Template, Context Length 설정은
실행 환경마다 확인해야 합니다.

이번 실습은 Apple Silicon macOS에서 진행했습니다.
따라서 CUDA 기반 표준 vLLM 대신 별도의 vLLM-Metal + MLX 실행 환경을 사용했습니다.

Client
  → OpenAI-Compatible API
  → vLLM-Metal Scheduler / Runtime
  → MLX GPU / Metal
  → Qwen2.5 0.5B Instruct 4-bit

이 경로는 Step 01의 PyTorch CPU + 원본 Weight와 실행 장치와 Weight 형식이
모두 다릅니다. 이번 비교의 목적은 어느 쪽이 더 빠른지 순위를 정하는 것이 아니라,
직접 만든 서버가 전문 Serving Runtime으로 바뀌었을 때 책임과 실행 경로가
어떻게 달라지는지 확인하는 것입니다.

🔄 Static Batching과 Continuous Batching

앞선 Step 02에서 사용한 방식은 요청 목록을 미리 만든
Static Batching입니다.

요청 목록 준비 → Batch 생성 → 한 번의 Inference → 전체 결과 반환

Serving Runtime에서는 요청이 계속 들어오는 상황을 처리해야 합니다.
Continuous Batching은 실행 중인 요청과 새로 들어온 요청을
생성 단계에 맞춰 스케줄링하는 접근입니다.

시간 ─────────────────────────────────────→

Batch 1: [A][A][A][A]
Batch 2:    [B][B][B]
새 요청 C:       [C][C][C][C]

실제 동작은 Runtime과 설정에 따라 달라지므로,
이번 글에서는 “여러 요청을 항상 같은 시점에 시작한다”와
“생성 중인 요청 집합을 스케줄링한다”를 구분하는 데 집중합니다.

🧠 API와 Runtime의 책임 분리

vLLM을 사용해도 Application Server가 없어지는 것은 아닙니다.

Application Layer
└─ 인증, 사용자 정책, 요청 라우팅, 과금, 도메인 응답 형식

Model Serving Layer
└─ Tokenization, Scheduling, KV Cache, Decode, Streaming

즉, vLLM은 Model Serving Layer의 책임을 줄여 주지만,
제품 서비스 전체의 API Gateway나 비즈니스 로직을 대신하지는 않습니다.

📊 비교할 관점

관점직접 구현한 서버vLLM
학습 난이도내부 흐름을 직접 확인하기 쉬움Runtime 내부는 추상화됨
구현 범위Scheduler·Streaming 등을 직접 작성많은 기능이 Runtime에 포함됨
API직접 설계OpenAI-Compatible API 사용 가능
튜닝코드 수준에서 자유로움Runtime 옵션과 지원 범위에 의존
운영로그·상태·장애 처리를 직접 구성Serving 운영의 기준점 제공
디버깅작은 코드 단위로 추적 가능내부 Scheduler까지 추적하려면 학습 필요

🧪 실행 전에 확인할 것

  • 현재 환경에 CUDA GPU가 있는가?
  • 설치하려는 vLLM 버전이 현재 Python·CUDA·GPU와 호환되는가?
  • 선택한 모델이 vLLM에서 지원되는가?
  • 모델의 Chat Template이 준비되어 있는가?
  • 기존 Step 01과 같은 Prompt·출력 Token 조건을 사용할 수 있는가?

이번 실습에서도 프로젝트 가상환경에서 처음 실행한 표준 vllm 명령은
command not found로 종료되었습니다. 이는 vLLM 자체의 실행 불가능을 뜻하는 것이
아니라, 해당 가상환경에 실행 파일이 없었다는 의미입니다.

이후 Apple Silicon용 vLLM-Metal 환경을 별도로 활성화했고,
MLX GPU/Metal 경로에서 모델 로딩과 API 요청을 완료했습니다.
실패와 성공을 함께 남기면 환경 문제와 Runtime 문제를 섞어 해석하는 일을 피할 수 있습니다.

✅ Step 04의 핵심

전문 Serving Framework는 모델을 호출하는 한 줄을 감추는 도구가 아니라,
요청 스케줄링·메모리·Streaming·운영 Endpoint를 함께 다루는 Runtime입니다.

직접 만든 서버는 원리를 이해하기 위한 기준선으로 남기고,
vLLM은 같은 요청을 더 높은 추상화 수준에서 운영하는 비교 대상으로 사용합니다.

공식 문서:



🧪 Lab 04. vLLM 서버와 기준선 비교

🎯 실습 목표

Step 01에서 만든 직접 구현 서버와 vLLM 서버에
가능한 한 같은 요청을 보내고 다음 차이를 기록합니다.

  • API 요청 형식
  • 모델 로딩과 서버 기동 방식
  • Streaming 지원 방식
  • 요청 동시성 처리 방식
  • TTFT·E2E·Throughput 측정 방법
  • 직접 구현해야 하는 운영 기능의 범위

⚠️ 실행 환경 확인

vLLM은 Python·CUDA·GPU 조합에 영향을 받습니다.
이번 macOS arm64 환경에서는 표준 CUDA용 vLLM 환경과 분리된
vLLM-Metal 환경을 사용했습니다.

python --version
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"
source ~/.venv-vllm-metal/bin/activate
python --version
python -c "import importlib.metadata as m; print(m.version('vllm')); print(m.version('vllm-metal'))"

기존 프로젝트 가상환경에서 표준 vllm 실행 파일을 찾지 못한 시도는
standard-vllm-attempt.log에 별도로 남겼습니다.

이번 실행에서 확인한 환경은 다음과 같습니다.

항목확인 결과
OSmacOS arm64
Python3.12.14
vLLM0.27.1+cpu
vLLM-Metal0.3.0.dev20260815085651
MLX0.32.0
모델mlx-community/Qwen2.5-0.5B-Instruct-4bit
실제 실행 경로MLX GPU / Metal

PyTorch의 MPS available: False만 보고 GPU를 사용하지 않았다고 판단하면 안 됩니다.
이번 서버는 PyTorch MPS가 아니라 MLX 경로를 사용했으며,
실제 장치는 서버 시작 로그의 Device(gpu, 0)로 확인했습니다.

<이미지: 프로젝트 가상환경의 표준 vLLM 실행 실패와 vLLM-Metal 환경으로 전환한 기록>

▶️ 서버 실행 예시

호환 환경에서 다음처럼 서버를 실행합니다.

source ~/.venv-vllm-metal/bin/activate

vllm serve mlx-community/Qwen2.5-0.5B-Instruct-4bit \
  --host 127.0.0.1 \
  --port 8002 \
  --max-model-len 2048

실제 사용 가능한 옵션은 설치한 vLLM 버전의 vllm serve --help
공식 문서를 기준으로 확인합니다.

vllm serve --help

서버 로그에서는 다음 항목을 확인했습니다.

  • Metal Platform Plugin 활성화
  • MLX 장치 Device(gpu, 0) 선택
  • 모델 로딩 완료: 9.03 sec
  • Warm-up 완료
  • Native Paged-Attention Metal Kernel 로딩
  • OpenAI-Compatible API Server 시작

로그에는 Triton이 없어 Model Runner V1을 사용한다는 경고와
ulimit, Gloo hostname 관련 경고도 남았습니다.
서버는 running_and_ready 상태로 기동했고 요청도 오류 없이 처리했으므로,
이번 실습에서는 실패 원인이 아니라 실행 환경의 제약으로 구분해 기록했습니다.

🧩 Client 요청

OpenAI-Compatible Chat Completions Endpoint를 호출합니다.

curl http://127.0.0.1:8002/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "mlx-community/Qwen2.5-0.5B-Instruct-4bit",
    "messages": [
      {"role": "user", "content": "Explain what LLM model serving is in one paragraph."}
    ],
    "max_tokens": 64,
    "temperature": 0.0,
    "stream": false
  }'

이번 측정 범위에서는 stream: false 요청만 사용했습니다.
먼저 /v1/models로 서버가 노출한 모델 식별자를 확인하고,
동일한 식별자로 Chat Completions 요청을 보냈습니다.

/v1/models 응답

/v1/chat/completions 성공 응답

🔍 실행 후 확인할 것

  • vLLM이 모델을 한 번만 로드하고 요청을 처리하는가
  • /v1/models에서 모델 식별자가 어떻게 표시되는가
  • 동일 조건에서 직접 구현 서버와 Token 수가 비교 가능한가
  • GPU 메모리 사용량과 CPU 환경의 결과를 구분했는가

stream: true의 응답 이벤트와 동시 요청 스케줄링은 이번 측정에 포함하지 않고,
후속 실험에서 별도로 확인합니다.

📊 결과 기록

step-04-vllm-serving/results/
├── environment.md
├── startup.log
├── standard-vllm-attempt.log
├── baseline.json
├── requests.jsonl
├── vllm.json
├── comparison.md
└── screenshots/

Warm-up 1회를 측정에서 제외한 뒤, Step 01과 같은 Prompt와 출력 제한으로
5회 반복했습니다.

항목Step 01 직접 구현 서버Step 04 vLLM-Metal해석
모델Qwen2.5 0.5B InstructQwen2.5 0.5B Instruct 4-bitWeight 형식이 다름
실행 경로PyTorch CPUMLX GPU / MetalBackend와 장치가 다름
측정 요청5회5회각 Warm-up 1회 제외
평균 E2E2.932 sec4.240 secClient 관측 시간
최소 E2E2.857 sec1.907 secvLLM-Metal의 최소값은 더 낮음
최대 E2E3.000 sec8.064 secvLLM-Metal의 변동 폭이 큼
p95 E2E3.000 sec8.064 sec5회 표본의 참고값
입력 / 출력 Token41 / 6441 / 64같은 Prompt와 출력 제한
오류00모든 요청 완료

Warm-up을 제외한 vLLM-Metal 5회 반복 측정 결과

이번 실행에서는 Streaming 요청과 동시 요청을 실제로 수행하지 않았습니다.
따라서 해당 항목은 후속 실험 대상으로 남기고, 측정하지 않은 값을 추정하지 않습니다.

🧭 결과를 어떻게 해석해야 할까?

이번 결과에서 vLLM-Metal의 평균과 p95는 Step 01보다 낮지 않았습니다.
반복 시간도 1.907~8.064 sec로 변동 폭이 컸습니다.
하지만 이것만으로 vLLM이 느리다고 결론 내릴 수는 없습니다.

두 실험은 다음 조건이 서로 다르기 때문입니다.

  • PyTorch CPU와 MLX GPU/Metal
  • 원본 Weight와 4-bit Weight
  • 직접 호출 경로와 별도 Serving Runtime
  • 작은 모델의 단일 요청 5회

따라서 이번 표는 공정한 벤치마크가 아니라 두 Serving 경로를 실제로 실행한
관찰 기록입니다. Continuous Batching과 Scheduler의 효과를 검증하려면
같은 장치와 Weight 조건에서 동시 요청 수를 늘리고 Throughput과 지연시간을
함께 측정해야 합니다.

📸 필수 캡처

  • 표준 vllm 실행 실패와 vLLM-Metal 환경 전환
  • vLLM-Metal의 모델 로딩 및 API Server ready 로그
  • /v1/models/v1/chat/completions 성공 응답
  • Warm-up 제외 후 5회 반복 측정 요약
  • Step 01과 Step 04의 비교 표

Streaming 응답 이벤트와 동시 요청 결과는 이번 실행 범위에 포함하지 않았으므로
필수 캡처 대상에서 제외하고 후속 실험으로 분리합니다.

✅ 이번 실습에서 얻어야 할 것

이번 작은 단일 요청 실험에서 vLLM-Metal이 항상 더 빠르지는 않았습니다.
대신 직접 구현했던 모델 로딩과 요청 처리 경로를 OpenAI-Compatible API와
전문 Serving Runtime으로 교체해 실제 요청까지 완료했습니다.

중요한 변화는 단순한 응답 시간 하나가 아니라 책임의 이동입니다.
Application은 인증·정책·도메인 로직에 집중하고,
Serving Runtime은 모델 실행·스케줄링·KV Cache·Streaming을 담당합니다.

vLLM의 성능상 이점은 이름만으로 가정하지 않고,
다음 단계에서 동시 요청과 Batching 조건을 추가해 검증해야 합니다.



🚀 Step 05. General Single-Model Serving Design

💡 예제 코드를 시스템으로 확장하기

Step 01의 서버는 하나의 Python 프로세스 안에서
API 처리와 모델 추론을 함께 수행했습니다.

FastAPI Endpoint
  └─ tokenizer
      └─ model.generate()
          └─ response 반환

이 구조는 Serving의 전체 흐름을 이해하기에는 좋습니다.
하지만 요청 수가 늘어나고 Streaming·Batching·관측성을 추가하면
각 책임을 분리해야 변경 범위와 장애 범위를 줄일 수 있습니다.

🧩 일반적인 컴포넌트

Client
  ↓
API / Gateway
  ↓
Request Validation
  ↓
Request Tracker ─────── Metrics / Logging
  ↓
Queue / Scheduler
  ↓
Batcher
  ↓
Model Worker
  ├─ Tokenize / Prefill
  └─ Decode / KV Cache
  ↓
Response / Streaming Layer
  ↓
Client

각 컴포넌트의 책임을 구분하면 다음 질문에 답하기 쉬워집니다.

컴포넌트책임주요 실패 지점
API / Gateway인증, 요청 수신, 외부 API 형식과도한 요청, 잘못된 인증
ValidationPrompt·Token 제한 검증너무 긴 입력, 잘못된 옵션
Request Tracker상태·ID·Deadline 관리유실, 중복, 상태 불일치
Queue / Scheduler대기 순서와 우선순위 결정무한 대기, 공정성 문제
Batcher입력을 묶고 결과를 분리Padding, 매핑 오류
Model Worker실제 Inference 수행OOM, Runtime 오류
Response LayerJSON·SSE 변환 및 전송Disconnect, 응답 혼선
Metrics / Logging상태·지연·오류 기록민감 정보 노출, 누락

🔗 책임을 분리하는 이유

API 요청을 받는 작업과 모델이 Token을 생성하는 작업은
서로 다른 시간 특성을 가집니다.

  • API Layer는 짧은 시간 안에 요청을 접수해야 합니다.
  • Scheduler는 대기 중인 요청을 공정하게 배치해야 합니다.
  • Model Worker는 GPU와 KV Cache를 효율적으로 사용해야 합니다.
  • Response Layer는 생성 중인 결과를 끊김 없이 전달해야 합니다.

한 함수 안에 모두 넣으면 어느 구간이 병목인지 확인하기 어렵고,
한 요청의 오류가 전체 서버 프로세스에 영향을 줄 수 있습니다.

🧠 Queue와 Batcher의 위치

Batcher는 Queue와 Model Worker 사이에 위치합니다.

요청 A ─┐
요청 B ─┼─ Queue ── Batch Window ── Model Worker
요청 C ─┘       ▲
               │
       최대 대기시간 / 최대 Batch 크기

Batch 크기를 무작정 키우면 처리량이 늘 수 있지만,
대기 시간이 길어지고 Padding 계산과 메모리 사용량이 증가할 수 있습니다.
따라서 Scheduler는 다음 조건을 함께 고려합니다.

  • 최대 Batch size
  • Batch 대기 시간
  • Prompt Token 길이
  • 예상 Output Token 수
  • 요청 우선순위와 Deadline
  • GPU Memory 여유량

🧠 Streaming은 어느 책임인가?

Streaming은 Model Worker만의 기능도, API Layer만의 기능도 아닙니다.

Model Worker
  └─ 생성 Token을 요청 ID와 함께 전달
       ↓
Response Layer
  └─ 요청별 SSE/WebSocket 이벤트로 변환
       ↓
Client

Model Worker가 요청 ID를 잃어버리면 Response Layer가
어떤 Client에 전달해야 하는지 알 수 없습니다.
반대로 API Layer가 Disconnect를 감지해도 Worker가 취소를 지원하지 않으면
GPU 계산이 계속될 수 있습니다.

📊 관측성 설계

최소한 다음 필드를 요청 단위로 연결할 수 있어야 합니다.

request_id
model
arrival_time
queue_start_time
inference_start_time
first_token_time
finish_time
input_tokens
output_tokens
status
error_type

이 값을 이용하면 전체 지연시간을 다음처럼 나눌 수 있습니다.

E2E
= Queue Time
+ Prefill Time
+ Decode Time
+ Response Transfer Time

🧠 실행 전에 생각해보기

  • Queue가 가득 차면 새 요청은 거절할까, 오래 기다리게 할까?
  • Model Worker가 OOM을 일으키면 API 요청에는 어떤 오류를 반환할까?
  • Streaming Client가 끊긴 요청은 어느 컴포넌트가 취소할까?
  • 요청 ID가 로그에 없다면 응답 혼선을 어떻게 추적할까?
  • Batching과 Streaming을 한 컴포넌트에 넣을 때 생기는 결합은 무엇일까?

✅ Step 05의 핵심

Single-Model Serving을 일반화한다는 것은 코드를 크게 만드는 것이 아니라,
요청 접수·스케줄링·모델 실행·응답 전송·관측성의 책임을 구분하는 것입니다.

이 구분이 있어야 이후 Multi-Model Serving에서 모델별 상태와 라우팅을
추가할 때 기존 요청 흐름을 모두 다시 작성하지 않을 수 있습니다.



🚀 Step 06. Multi-Model Serving: 여러 모델을 하나의 시스템에서 관리하기

💡 모델이 하나일 때와 여러 개일 때의 차이

Single-Model Serving에서는 요청이 들어오면
항상 같은 Model Runtime으로 전달하면 됩니다.

요청 → 하나의 Model Runtime

여러 모델을 제공하면 요청을 처리하기 전에
어떤 모델을 선택하고, 그 모델이 현재 사용 가능한지 확인해야 합니다.

요청
  ↓
모델 선택
  ↓
모델 상태 확인
  ├─ Ready  → 요청 전달
  ├─ Loading → 대기 또는 재시도
  ├─ Unloaded → Lazy Load
  └─ Unavailable → 다른 모델 또는 오류 응답

🧩 Model Registry

Model Registry는 모델의 이름만 저장하는 목록이 아닙니다.
Serving에 필요한 메타데이터와 현재 상태를 함께 관리하는 구성요소입니다.

{
  "model_id": "qwen-small",
  "source": "Qwen/Qwen2.5-0.5B-Instruct",
  "device": "cuda:0",
  "status": "ready",
  "max_context_tokens": 4096,
  "priority": 10
}

실제 구현에서는 다음 항목도 필요할 수 있습니다.

  • 모델 버전과 배포 버전
  • 지원하는 Task와 API 형식
  • 예상 Memory 사용량
  • 로딩 시간과 마지막 사용 시간
  • 허용된 사용자 또는 Tenant
  • 모델별 Timeout과 Token 제한

🔄 모델 상태와 Lifecycle

모델을 단순히 loaded: true/false로 관리하면
로딩 중인 모델에 요청이 들어가는 상황을 표현하기 어렵습니다.

UNLOADED
   │ lazy load
   ▼
LOADING ── 실패 ──► ERROR
   │ 성공
   ▼
READY ── 요청 ──► ACTIVE
   │                │ 요청 없음 / 정책에 따라
   └────────────────┘
           │ unload
           ▼
       EVICTING → UNLOADED

상태 전환 중에는 다음을 명확히 해야 합니다.

  • 새 요청을 Queue에 넣을지
  • 로딩 완료까지 기다릴 최대 시간
  • 같은 모델을 중복 로드하지 않는 방법
  • 로딩 실패 시 재시도 횟수
  • Unload 중인 모델에 대한 신규 요청 처리

🧭 Routing 전략

가장 단순한 라우팅은 Client가 model을 직접 지정하는 방식입니다.

{
  "model": "qwen-small",
  "prompt": "..."
}

서비스가 커지면 라우터가 다음 조건을 함께 고려할 수 있습니다.

  • 요청의 품질·지연시간 요구
  • 모델의 현재 Queue 길이
  • GPU Memory 여유량
  • 모델별 비용
  • 사용자 권한
  • 지역 또는 데이터 처리 위치

하지만 라우팅 조건이 많아질수록 “가장 빠른 모델”을 선택하는 것만으로는
충분하지 않습니다. 선택 결과와 이유를 로그에 남겨야 장애와 비용을 추적할 수 있습니다.

🧠 Lazy Loading의 Trade-off

Lazy Loading은 실제 요청이 들어올 때 모델을 로드하는 방식입니다.

장점단점
사용하지 않는 모델의 메모리를 점유하지 않음첫 요청 Cold Start가 길어짐
여러 모델을 필요에 따라 교체할 수 있음로딩 중 요청 정책이 필요함
유휴 자원 비용을 줄일 수 있음모델 교체와 캐시 관리가 복잡함

자주 사용하는 모델은 메모리에 유지하고,
드물게 사용하는 모델은 별도 Worker나 다른 GPU로 분리하는 방식도 있습니다.

🛡️ 장애 격리

여러 모델을 하나의 프로세스에 모두 로드하면 운영이 단순해 보이지만,
한 모델의 OOM이나 Runtime 오류가 다른 모델에 영향을 줄 수 있습니다.

공유 프로세스
└─ Model A 오류 → Model B도 영향 가능

분리 프로세스
├─ Model A Worker
└─ Model B Worker

프로세스를 분리하면 장애 격리와 독립 확장이 쉬워지는 대신,
메모리 중복과 배포·라우팅 복잡도가 증가합니다.

🧠 실행 전에 생각해보기

  • 두 모델을 동시에 메모리에 둘 수 있는가?
  • 모델 로딩 중 같은 모델에 들어온 요청은 어떻게 합칠까?
  • 모델 A의 OOM이 모델 B의 요청까지 중단시키지 않으려면 어떻게 할까?
  • 모델을 내리기 전에 진행 중인 요청을 어떻게 처리할까?
  • 라우팅 결과에 모델 이름과 버전을 남겨야 하는 이유는 무엇일까?

✅ Step 06의 핵심

Multi-Model Serving은 모델 Endpoint를 여러 개 만드는 문제가 아닙니다.
모델 선택, 상태 전이, 메모리 점유, Cold Start, 장애 격리까지
하나의 Lifecycle로 관리하는 문제입니다.



🧪 Lab 05. Multi-Model Registry와 라우팅 시뮬레이션

🎯 실습 목표

이번 실습에서는 실제 대형 모델을 여러 개 로드하기 전에,
Model Registry와 Router가 어떤 상태를 관리해야 하는지 작은 시뮬레이션으로 확인합니다.

모델을 실제로 여러 개 GPU에 올리는 실험은 메모리와 비용의 영향이 크므로,
먼저 상태 전이와 라우팅 규칙을 코드로 고정합니다.

🧩 실습 범위

  1. 모델별 Metadata를 Registry에 등록
  2. unloaded → loading → ready → active → ready 상태 전이 표현
  3. 요청의 model 또는 capability를 기준으로 라우팅
  4. Loading·Unavailable 모델 요청 처리
  5. 라우팅 결과와 상태 변경을 로그로 기록

실습 코드는 ch03/step-06-multi-model-serving/ 디렉터리에 둡니다.

cd /Users/xgro/workspace/personal/llmso/ch03/step-06-multi-model-serving
python route_simulation.py

🔍 실행 후 확인할 것

  • 명시한 model_id가 Registry에 없을 때 오류가 분명한가
  • ready가 아닌 모델로 요청했을 때 정책이 적용되는가
  • 같은 모델을 중복으로 Loading하지 않는가
  • 모델별 우선순위와 capability가 라우팅에 반영되는가
  • 라우팅 결과에 request_id, model_id, model_version, status가 남는가

📊 기록할 시나리오

시나리오기대 동작실제 결과
Ready 모델 지정즉시 해당 모델로 전달accepted / qwen-small
Unloaded 모델 지정Lazy Load 또는 대기loading → ready → active → ready
Loading 중 동일 모델 요청중복 로드 방지현재 시뮬레이션에 포함하지 않음
존재하지 않는 모델명시적 오류rejected / model_not_found
Capability만 지정정책에 맞는 모델 선택accepted / qwen-small

실제 실행 결과는 ch03/step-06-multi-model-serving/results/routing.json에 저장했습니다.
이번 결과는 Model Weight를 로드하지 않은 상태 전이 시뮬레이션입니다.
Loading 중 동일 모델 요청과 오류 격리는 다음 보완 실험으로 남깁니다.
이번 코드에서는 evicting → unloaded 전이를 아직 구현하지 않았으므로,
Eviction은 개념 설명과 후속 실험 범위로 구분합니다.

⚠️ 실제 모델 로딩과 구분하기

이번 Lab의 시뮬레이션은 모델의 GPU Memory나 실제 Inference 성능을 측정하지 않습니다.
다음 단계에서 실제 Model Server를 분리할 때 필요한 정책을 먼저 검증하는 단계입니다.

실제 모델을 여러 개 로드하는 실험은 다음 조건을 확인한 뒤 별도로 진행합니다.

  • 모델 파일 크기와 GPU Memory
  • 모델별 dtype과 Quantization
  • 동시 로딩 시 OOM 가능성
  • 모델 Worker 분리 여부

✅ 이번 실습에서 얻어야 할 것

Multi-Model Serving에서 라우터는 모델을 선택하는 함수 하나가 아니라,
모델 상태와 Lifecycle을 반영하는 제어 지점입니다.



🚀 Step 07. NVIDIA Triton Backend: Model Server를 분리하기

💡 Application Server와 Model Server

앞선 Step에서는 Application Server 프로세스 안에서
Tokenizer와 Model Runtime을 직접 로드했습니다.

Application Server
└─ API + Router + Model Runtime

이 구조는 빠르게 시작할 수 있지만,
모델 Runtime의 교체·확장·장애 격리를 Application 코드와 함께 관리해야 합니다.

Triton Inference Server를 사용하면 모델 실행을 별도의 Model Server로 분리할 수 있습니다.

Client
  ↓
Application Router
  ↓ HTTP / gRPC
Triton Inference Server
  ↓
Model Backend

Application Router는 사용자와 서비스의 정책을 담당하고,
Triton은 Model Repository에 등록된 모델을 로드하고 추론 Endpoint를 제공합니다.

🧩 Model Repository

Triton은 정해진 Model Repository 구조를 기준으로 모델을 관리합니다.

model_repository/
└── echo_backend/
    ├── config.pbtxt
    └── 1/
        └── model.py

일반적으로 모델 이름 아래에 config.pbtxt와 버전 디렉터리를 둡니다.
Backend 종류에 따라 버전 디렉터리 안에 필요한 모델 파일과 실행 코드가 들어갑니다.

🐍 Python Backend

Python Backend에서는 TritonPythonModel 인터페이스를 구현한
model.py를 사용할 수 있습니다.

class TritonPythonModel:
    def initialize(self, args):
        pass

    def execute(self, requests):
        return responses

    def finalize(self):
        pass

이번 Lab에서는 실제 LLM Weight를 즉시 포함하지 않고,
입력을 받아 응답하는 Echo Backend로 Triton의 Model Repository와
Inference 호출 흐름을 먼저 확인합니다.

이렇게 하면 Triton 설정 문제와 LLM Runtime·GPU 문제를 분리해서 볼 수 있습니다.

📄 config.pbtxt가 정의하는 것

모델 이름
Backend 종류
입력 Tensor 이름·자료형·차원
출력 Tensor 이름·자료형·차원
Batching 가능 여부
Instance 개수와 실행 위치

Application의 JSON 요청과 Triton의 Tensor 입력 사이에는 변환이 필요합니다.

JSON Prompt
  ↓ Application Client
Triton Input Tensor(PROMPT)
  ↓ Backend execute()
Triton Output Tensor(RESPONSE)
  ↓ Application Response
JSON Response

🔌 HTTP와 gRPC

Triton은 HTTP와 gRPC Endpoint를 제공할 수 있습니다.

선택 기준은 다음처럼 정리할 수 있습니다.

기준HTTPgRPC
확인 난이도curl로 시작하기 쉬움Client Stub 필요
데이터 계약JSON 기반 확인이 쉬움Schema와 타입 계약이 명확함
내부 서비스 통신단순한 연동에 적합고성능 내부 통신에 적합할 수 있음
StreamingAPI 설계에 따라 별도 확인Backend·Client 지원 범위 확인 필요

🧱 책임 분리의 효과

Application Router
├─ 인증·인가
├─ Tenant 정책
├─ 모델 선택
└─ 외부 응답 형식

Triton
├─ 모델 로딩
├─ Backend 실행
├─ 모델 Readiness
└─ Inference 요청 처리

이 분리는 모델을 교체하거나 여러 모델을 운영할 때 유리합니다.
반면 별도의 네트워크 Hop, 배포 단위, 모니터링 대상이 추가됩니다.

🧠 실행 전에 생각해보기

  • Application Server가 Triton의 모델 이름을 그대로 외부에 노출해도 될까?
  • Triton이 Ready가 아니면 Router는 어떤 오류를 반환해야 할까?
  • Model Repository의 버전 디렉터리를 바꾸면 배포는 어떻게 동작할까?
  • Backend 오류와 Router 오류를 로그에서 어떻게 구분할까?
  • Echo Backend를 LLM Backend로 교체할 때 입력·출력 계약은 무엇이 바뀔까?

✅ Step 07의 핵심

Triton 연동의 핵심은 특정 모델을 실행하는 명령 하나가 아니라,
Application과 Model Server 사이의 책임·계약·장애 경계를 분리하는 것입니다.

공식 문서:



🧪 Lab 06. Triton Python Backend 호출하기

🎯 실습 목표

이번 실습에서는 실제 LLM을 바로 연결하기 전에,
Triton Model Repository와 Python Backend를 실행하고
Application Client에서 Inference를 호출합니다.

Echo Backend를 사용하는 이유는 Triton의 기본 흐름을
모델 다운로드·GPU OOM·LLM Tokenization 문제와 분리하기 위해서입니다.

🧩 디렉터리 구조

실습 코드는 ch03/step-07-triton-backend/ 디렉터리에 둡니다.

step-07-triton-backend/
├── README.md
├── client.py
└── model_repository/
    └── echo_backend/
        ├── config.pbtxt
        └── 1/
            └── model.py

▶️ Triton 실행

Docker와 Triton 실행 환경이 준비된 경우 다음 형태로 실행합니다.

cd /Users/xgro/workspace/personal/llmso/ch03/step-07-triton-backend

docker run --rm \
  -p 8000:8000 \
  -p 8001:8001 \
  -p 8002:8002 \
  -v "$PWD/model_repository:/models" \
  nvcr.io/nvidia/tritonserver:25.08-py3 \
  tritonserver --model-repository=/models

이번 실습에서는 25.08-py3 이미지를 사용했습니다.
Apple Silicon에서는 Docker runtime이 linux/arm64 이미지를 선택합니다.
GPU 컨테이너를 사용할지 CPU 환경을 사용할지는 선택한 이미지와 호스트 환경을
먼저 확인해야 합니다.

🧪 Client 호출

python -m pip install tritonclient[http] numpy
python client.py

Client는 PROMPT 입력 Tensor를 보내고 RESPONSE 출력 Tensor를 읽습니다.

🔍 실행 후 확인할 것

  • Triton 서버가 Model Repository를 정상적으로 읽는가
  • echo_backend 모델이 Ready 상태가 되는가
  • config.pbtxt의 입력·출력 이름과 Client가 일치하는가
  • Backend의 execute()가 요청마다 호출되는가
  • HTTP Health와 Inference 오류를 구분할 수 있는가

📊 기록할 결과

step-07-triton-backend/results/
├── environment.md
├── server.log
├── health.json
├── inference.json
├── comparison.md
└── screenshots/

비교 문서에는 다음을 남깁니다.

항목직접 로드 서버Triton Backend실제 관찰
모델 로딩 위치Application ProcessTriton Processecho_backend가 Triton 로그에서 READY
입력 형식JSONTensor 계약PROMPT / TYPE_STRING 계약 일치
Health 확인Application EndpointTriton Endpointlive·ready·model ready 모두 200
장애 경계프로세스 공유 가능Model Server 분리별도 Triton Container로 실행
배포 단위Application 배포Repository/Server 배포echo_backend/1/model.py 로드

실제 실행 결과는 다음과 같습니다.

Triton Server: 2.60.0
Model: echo_backend:1
Backend: Python / CPU
Health live/ready: 200 / 200
Model ready/config: 200 / 200
Client response: [echo_backend:echo_backend] <prompt>

이번 환경에는 NVIDIA Driver가 없어 CUDA Memory Pool은 비활성화되었습니다.
따라서 이 Lab의 결과는 GPU 성능이 아니라 Python Backend의 Repository 로딩,
Readiness, Tensor 입력·출력 계약을 확인한 결과입니다.

📸 결과

Triton의 모델 Ready 로그

client.py의 Inference 결과

⚠️ 실행하지 못한 경우

Docker·Triton 환경이 없으면 다음만 확인하고 결과를 기록합니다.

  • config.pbtxt의 구조
  • model.pyinitialize·execute 흐름
  • Docker 실행 명령의 볼륨과 포트
  • 실행 불가 사유

실제 응답을 얻지 못한 상태에서 “정상 동작했다”고 작성하지 않습니다.

✅ 이번 실습에서 얻어야 할 것

Application이 모델을 직접 소유하는 구조와
Model Server가 모델을 소유하는 구조의 차이를
입력·출력 계약과 장애 경계 관점에서 설명할 수 있어야 합니다.



🚀 Step 08. 비용과 지연시간 관점에서 Serving 설계 비교하기

💡 모든 지표를 동시에 최적화할 수는 없다

LLM Serving 구조를 선택할 때는 “가장 빠른 구조” 하나를 찾는 것보다
어떤 사용자 경험과 비용 조건을 우선할지 먼저 정해야 합니다.

예를 들어 모델을 항상 GPU Memory에 유지하면 Cold Start를 줄일 수 있습니다.
하지만 사용량이 적은 모델까지 계속 올려두면 유휴 자원 비용이 생깁니다.

반대로 필요할 때만 모델을 로드하면 비용을 줄일 수 있지만,
첫 요청의 지연시간이 길어질 수 있습니다.

⚖️ 대표적인 설계 비교

관점비용 최적화지연시간·확장성 최적화
모델 배치필요할 때 로드자주 쓰는 모델 상시 유지
Memory모델 교체·공유 중심모델별 자원 예약 중심
Cold Start감수하거나 사전 예열최소화
Queue비용에 맞춰 제한SLO에 맞춰 짧게 유지
Batch큰 Batch로 처리량 우선TTFT와 공정성 고려
장애 격리프로세스 공유 가능Worker·Model Server 분리
운영 비용낮추기 쉬움높아질 수 있음

어느 열이 항상 정답인 것은 아닙니다.
트래픽의 시간대, 요청 분포, 모델 크기, SLO를 함께 봐야 합니다.

📏 지연시간 지표와 비용 지표

사용자 경험
├─ TTFT
├─ ITL / TPOT
└─ E2E Latency

자원 효율
├─ Request Throughput
├─ Input/Output Token Throughput
├─ GPU Utilization
├─ GPU Memory
└─ 유휴 시간

지표 하나만 보고 결론을 내리면 안 됩니다.

  • TTFT가 짧아도 E2E가 길 수 있습니다.
  • Throughput이 높아도 개별 요청이 오래 기다릴 수 있습니다.
  • GPU Utilization이 낮아도 CPU 환경에서는 정상일 수 있습니다.
  • Memory를 최대한 채워도 OOM과 안정성 문제가 생길 수 있습니다.

🧮 트래픽 패턴에 따른 선택

트래픽 패턴고려할 구조
짧은 시간에 요청이 몰림Queue 상한, Batching, 충분한 동시성
일정한 고정 트래픽상시 모델 유지, 용량 계획
드문 요청·많은 모델Lazy Loading, Unload, Cold Start 정책
모델별 사용량 편차가 큼Hot Model과 Cold Model 분리
높은 품질·긴 출력Context/Output Token과 Memory 계획
엄격한 사용자 응답시간TTFT SLO와 우선순위 Queue

🧭 의사결정 순서

1. 사용자 SLO 정의
   ↓
2. 요청·Token·트래픽 패턴 측정
   ↓
3. 모델 Memory와 Runtime 조건 확인
   ↓
4. 비용 한도와 장애 격리 수준 결정
   ↓
5. Serving 구조 선택
   ↓
6. 부하 테스트와 실제 지표로 재검증

📝 최종 설계 질문

  • 목표 TTFT와 E2E SLO는 얼마인가?
  • 평균값이 아니라 P95·P99 지연시간을 확인했는가?
  • 요청이 몰릴 때 Queue를 얼마나 허용할 것인가?
  • 모델을 항상 유지할 GPU 비용과 Cold Start 비용 중 무엇이 큰가?
  • 한 모델의 OOM이 다른 모델의 요청에 영향을 주어도 되는가?
  • 장애 시 더 작은 모델로 Fallback할 것인가?
  • 결과 Token 수와 모델 품질을 비용 계산에 포함했는가?

✅ Step 08의 핵심

Serving 설계는 모델 선택만의 문제가 아닙니다.
사용자 경험, GPU 자원, Traffic Pattern, 운영 복잡도와
장애 격리를 함께 조정하는 시스템 설계 문제입니다.

이번 챕터의 실습 결과는 다음 문장으로 정리해 봅니다.

이번 실습에서 실제로 확인한 값은 다음과 같습니다.

항목실제 결과
Step 01 단일 요청 기준선평균 2.932 sec, p95 3.000 sec
Step 02 Sequential / Static Batch4.310 sec / 2.537 sec
Step 02 Padding·응답 동등성Padding 2 tokens, 응답 불일치 1건
Step 03 StreamingTTFT 1.949 sec, E2E 4.255 sec
Step 04 vLLM-Metal평균 4.240 sec, p95 8.064 sec
Step 07 Triton EchoModel Ready·Health·Inference 모두 성공

단, Step 01은 PyTorch CPU이고 Step 04는 MLX/Metal 4-bit 모델이므로
두 수치를 동일한 성능 조건의 순위로 해석하지 않습니다.
또한 동시 요청, GPU Memory, 실제 비용 단가는 아직 측정하지 않았습니다.

이 로컬 학습 환경과 소규모 트래픽 조건에서는 실행 재현성과 자원 사용 제한을
우선하기 위해 직접 구현·vLLM-Metal·Triton Echo를 비교용으로 사용하며,
실제 서비스 배포 전에는 동시 요청·GPU Memory·비용 단가를 추가 측정한다.



✅ Conclusion

이번 챕터에서는 model.generate()를 호출하는 코드에서 출발해
하나의 모델을 서비스로 제공하는 데 필요한 구성요소를 단계별로 확장했습니다.

🧩 챕터에서 만든 흐름

Single Request
   ↓
Static Batching
   ↓
Streaming
   ↓
Serving Runtime 비교
   ↓
일반 Single-Model 설계
   ↓
Multi-Model Routing
   ↓
Model Server 분리
   ↓
비용·지연시간 Trade-off

📊 단계별로 확인한 문제

단계확인한 문제
Step 01모델 로딩과 단일 요청 처리
Step 02Padding과 Batch 결과 매핑
Step 03생성 중 Token 전달과 TTFT
Step 04직접 구현과 전문 Runtime의 책임 차이
Step 05API·Queue·Worker·Response 책임 분리
Step 06모델 상태·라우팅·Lazy Loading
Step 07Application과 Model Server의 경계
Step 08비용·지연시간·운영 복잡도의 선택

📌 실제 실행 결과로 연결하기

이번 챕터에서 기록한 핵심 결과는 다음과 같습니다.

실습실제로 확인한 결과
Step 01PyTorch CPU 단일 요청 평균 2.932 sec, p95 3.000 sec
Step 02Static Batch 전체 시간 2.537 sec, Sequential 4.310 sec, 응답 불일치 1건
Step 03Streaming TTFT 1.949 sec, E2E 4.255 sec
Step 04vLLM-Metal 평균 4.240 sec, p95 8.064 sec
Step 06Ready·Lazy Loading·Capability Routing·Model Not Found 확인
Step 07Triton Python Backend READY, Health·Config·Inference 성공

이 결과만으로 특정 Framework가 항상 더 빠르거나 저렴하다고 결론낼 수는
없습니다.

Step 01과 Step 04는 실행 Device와 Weight Format이 다르고,
동시 요청·GPU Memory·실제 인프라 비용은 아직 측정하지 않았기 때문입니다.

🧠 이번 챕터에서 기억할 것

첫째, Inference와 Serving은 다릅니다.
Inference는 모델이 출력을 계산하는 과정이고,
Serving은 요청을 받고 모델과 자원을 관리하며 결과를 전달하는 시스템입니다.

둘째, Batching과 Streaming은 서로 다른 문제를 해결합니다.
Batching은 여러 요청을 효율적으로 계산하기 위한 방식이고,
Streaming은 생성 중인 결과를 사용자에게 전달하기 위한 방식입니다.

셋째, 전문 Serving Framework를 사용하더라도
API Gateway·인증·라우팅·관측성·비용 정책의 책임이 사라지는 것은 아닙니다.

넷째, Serving 구조에는 항상 Trade-off가 있습니다.
처리량, TTFT, E2E, GPU Memory, Cold Start, 운영 복잡도를
트래픽과 SLO에 맞춰 선택해야 합니다.

📝 최종 회고

실습 결과를 바탕으로 다음 질문에 답해 봅니다.

  • 가장 큰 지연은 어느 단계에서 발생했는가?
  • Batching이 실제 환경에서 어떤 지표를 개선했는가?
  • Streaming이 개선한 것은 계산시간인가, 사용자 체감인가?
  • 직접 구현한 코드에서 운영을 위해 가장 먼저 분리할 책임은 무엇인가?
  • 여러 모델을 운영할 때 가장 큰 제약은 Memory인가, Routing인가?
  • 현재 환경에서 비용과 지연시간 중 무엇을 우선해야 하는가?

이번 챕터의 코드는 완성된 Serving Framework를 대체하기 위한 것이 아닙니다.
요청이 들어오고, 대기하고, 모델에서 계산되고,
응답으로 전달되는 전체 흐름을 직접 관찰하기 위한 기준선입니다.

이번 로컬 환경에서는 실행 재현성과 자원 사용 제한을 우선해 각 구조를
비교용으로 사용했습니다.

실제 서비스로 확장할 때는 트래픽 패턴과 SLO를 먼저 정의하고,
동시 요청·p95/p99·GPU Memory·비용 단가를 추가 측정한 뒤
Hot Model, Batching, Worker 분리 여부를 다시 결정해야 합니다.

이 기준선이 있으면 이후 vLLM, Triton, Kubernetes 기반 Serving System을
사용할 때 각 컴포넌트가 어떤 문제를 해결하는지 더 구체적으로 비교할 수 있습니다.



profile
안녕하세요! DevOps 엔지니어 이재찬입니다. 블로그에 대한 피드백은 언제나 환영합니다! 기술, 개발, 운영에 관한 다양한 주제로 함께 나누며, 더 나은 협업과 효율적인 개발 환경을 만드는 과정에 대해 인사이트를 나누고 싶습니다. 함께 여행하는 기분으로, 즐겁게 읽어주시면 감사하겠습니다! 🚀

0개의 댓글