서빙 시스템은
파일 여섯 개로 되어 있습니다.
Model Serving System Design: A Deep Dive — 이 장은 프레임워크를 쓰는 법이 아니라, 서빙 시스템을 직접 만들어보며 각 부품이 왜 거기 있는지를 봅니다.
그래서 이 페이지는 소스코드의 파일 하나하나가 무엇을 맡고 있는지를 중심으로 갑니다.
llm.py가 왜 필요한지, workload_manager.py를 빼면 무엇이 무너지는지까지요.
① 파일 단위로 읽습니다. 개념 설명 뒤에 항상 "그건 어느 파일 몇 번째 줄인가"가 붙습니다.
② 전문 용어는 영어 그대로. batching을 억지로 번역하지 않습니다. 대신 뜻을 한국어로 풀어둡니다.
③ 그림 14개 전부 조작 가능. 부품을 껐다 켜보는 게 이 자료의 핵심입니다.
④ 장마다 세 줄 요약. 거기만 읽어도 넘어갈 수 있게.
들어가며
"모델 서빙"이라는 말을 들으면 보통 model.generate()를 떠올립니다.
그런데 실제 서빙 코드를 열어보면 파일이 여섯 개고, 그중 추론을 직접 하는 파일은 하나뿐입니다.
나머지 다섯 개는 뭘 하고 있을까요. 큐에 넣고, 묶고, ID를 붙이고, 다른 프로세스로 던지고, 결과를 다시 찾아옵니다. 이 다섯 개가 서빙 시스템입니다.
이 장은 그 여섯 개를 처음부터 하나씩 쌓아가며 만듭니다. 요청 한 건 처리 → 배칭 추가 → 스트리밍 추가 → vLLM으로 교체 → 여러 모델로 확장 순서입니다. 단계마다 "이 파일이 없으면 무엇이 안 되는가"가 드러납니다.
- 서빙 시스템은 추론 담당(GPU)과 조율 담당(CPU)을 프로세스로 갈라놓은 구조입니다. 큐 두 개가 그 사이를 잇습니다.
- 모든 최적화는 "요청을 Sequence 객체로 바꿔 ID로 추적한다"는 한 가지 추상화 위에 올라갑니다. 배칭도 스트리밍도 여기서 나옵니다.
- 여러 모델을 서빙하면 새 질문이 생깁니다 — 무엇을 메모리에 올려둘 것인가. LRU 캐시 하나가 비용의 절반을 결정합니다.
읽는 방법
· 굵은 글씨만 따라가도 흐름이 이어집니다.
· 코드 상자에는 파일 경로가 주석으로 붙어 있습니다. 실제 리포의 그 파일입니다.
· 회색으로 접힌 상자(▸ 코드를 더 보고 싶다면)는 안 열어도 본문은 완결됩니다.
· 그림은 전부 슬라이더를 밀고 버튼을 눌러보는 용도입니다. 특히 부품을 꺼봤을 때 무슨 일이 나는지가 제일 중요합니다.
먼저 파일부터 — 이 장에 나오는 소스는 이게 전부입니다
두 개의 프로젝트가 나옵니다. 지금 외울 필요는 없고, "아 그 파일" 정도만 되면 됩니다.
LLMEngine. 다른 부품을 초기화하고 순서를 정합니다. 오케스트라 지휘자.manager.py
Sequence로 바꿔 큐에 넣고, 누구를 다음 배치에 넣을지 정합니다.executor.py
worker.py
manager.py
/predict 하나로 텍스트든 이미지든 받습니다.OrderedDict LRU. 무엇을 메모리에 올려둘지 결정합니다. 비용의 핵심.① API server · LLM engine · Workload manager · Model executor · Model worker를 왜 굳이 따로 떼어놨는가? (1·2·3장)
② batching과 streaming은 각각 무슨 문제를 풀며, 한 시스템 안에서 어떻게 공존하는가? (4~7장)
③ 여러 모델을 서빙할 때 무엇을 올리고 무엇을 내릴지 어떻게 정하며, 비용과 지연 중 무엇을 포기하는가? (10~14장)
— Part 1. 단일 모델 서빙을 처음부터 만들기 —
01여섯 개의 부품
한 줄 요약: 추론하는 파일은 하나뿐입니다. 나머지 다섯은 그 하나를 놀지 않게 하려고 있습니다.
model.generate() 한 줄이면 텍스트가 나옵니다. 그런데 실제 서빙 코드는
llm/ 폴더 안에만 파일이 다섯 개입니다. 과한 설계처럼 보입니다.
이 장에서는 여섯 부품이 각각 무엇을 맡는지 보고, 이 구조가 실제 프로덕션 GPU 서빙의 표준 패턴을 그대로 축소한 것이라는 걸 확인합니다.
- API server
- HTTP 요청과 응답을 처리하는 층. 여기서는 FastAPI로 만든
main.py입니다. - LLM engine ·
LLMEngine - 전체를 지휘하는 오케스트레이터. 다른 컴포넌트를 초기화하고 순서를 조율합니다.
- Workload manager
- 요청 큐잉과 배치 구성을 관리. "언제 어떤 요청들을 묶을지" 정하는 스케줄링 지점.
- Model executor
- 모델 워커 프로세스를 띄우고 관리하며, 프로세스 간 통신으로 추론을 트리거합니다.
- Model worker
- 실제 모델 추론을 자신의 별도 프로세스에서 실행합니다.
- Model manager
- 모델을 로드하고 캐싱합니다. 여기서는 HuggingFace
from_pretrained를 감싼 얇은 층. - Orchestration (오케스트레이션)
- 여러 부품이 언제 무엇을 할지 순서를 잡아주는 일. 계산 자체는 하지 않습니다.
비유 먼저 — 주방과 홀
바쁜 식당을 생각해 봅시다. 요리사(GPU)는 시급이 제일 비쌉니다. 그래서 요리사에게는 불 앞에서 요리만 시킵니다.
주문을 받는 사람(API server), 주문표를 정리해 같은 메뉴끼리 묶는 사람(Workload manager), 묶인 주문표를 주방에 넣고 완성된 접시를 받아오는 패스 담당(Model executor)이 따로 있습니다.
이 사람들이 없으면 요리사가 직접 주문을 받고 계산도 해야 합니다. 가장 비싼 사람이 가장 싼 일을 하게 됩니다. 그게 부품을 나누는 이유의 전부입니다.
파일과 부품의 대응
책의 그림 3-1에 나오는 여섯 부품은 리포의 파일과 이렇게 대응합니다.
| 부품 | 파일 | 어디서 도나 | 한 줄 책임 |
|---|---|---|---|
| API server | main.py | 메인 프로세스 | HTTP 파싱 · 응답 형식 결정 |
| LLM engine | llm/llm.py | 메인 프로세스 | 초기화 · 순서 조율 · 결과 매핑 |
| Workload manager | llm/workload_manager.py | 메인 프로세스 | 큐잉 · 배치 구성 · 상태 추적 |
| Model executor | llm/model_executor.py | 메인 프로세스 | 큐로 IPC · 워커 프로세스 기동 |
| Model worker | llm/model_worker.py | 별도 프로세스 | 실제 forward pass |
| Model manager | llm/model_manager.py | 워커 프로세스 | 모델 · 토크나이저 로드 |
여기서 하나만 기억한다면 — 다섯 번째 model_worker.py만 다른 프로세스에서 돕니다.
나머지는 전부 같은 프로세스에서 조율만 합니다.
계층 구조
└─ LLMEngine (llm/llm.py) // 오케스트레이션
├─ WorkloadManager // 큐잉 / 배칭 상태 관리
└─ ModelExecutor // 별도 프로세스와 IPC
└─ ModelWorker (별도 process) // 실제 forward pass
└─ ModelManager // 모델 / 토크나이저 로드
단일 모델 서빙 시스템 아키텍처
원문이 이 장을 여는 그림입니다. 상자 여섯 개와 그 사이를 오가는 화살표가 전부인데, 점선 아래가 다른 프로세스라는 것 하나가 이 그림의 핵심입니다. 위쪽 셋(API server · LLM engine · Workload manager)은 요청을 정리하는 일만 하고, 아래쪽 Model worker만 실제로 모델을 돌립니다. 두 세계를 잇는 것이 Input queue와 Result queue 두 개뿐이라는 점도 눈여겨보세요 — 이 좁은 통로가 뒤에 나올 배칭·스트리밍의 제약 조건이 됩니다.
여섯 부품 — 하나씩 꺼보기
↔ 부품 클릭초록 테두리가 별도 프로세스입니다. 딱 하나뿐입니다.
이 예제는 CPU에서 도는 facebook/opt-125m 하나를 서빙합니다. 확실히 과합니다.
다만 원문은 이렇게 못 박습니다 — "실제 프로덕션 GPU 서빙 시스템의 표준 패턴을 그대로 반영한 것".
vLLM도 Triton도 이름만 다를 뿐 같은 자리에 같은 부품이 있습니다.
- 서빙 시스템은 여섯 부품입니다 — API server · LLM engine · Workload manager · Model executor · Model worker · Model manager.
- 이 중 실제 추론은 Model worker에서만 일어나고, 그것만 별도 프로세스입니다.
- 나머지는 전부 비싼 GPU를 놀리지 않게 하려는 조율 장치입니다. 그래서 과해 보여도 표준 패턴입니다.
02왜 프로세스를 나누나 — 큐 두 개의 이유
한 줄 요약: 토크나이징 때문에 GPU가 기다리면 손해입니다. 그래서 다른 프로세스로 보냅니다.
프로세스를 나누면 직렬화 비용이 생기고, 디버깅도 어려워지고, 코드도 늘어납니다. 그런데도 실제 서빙 프레임워크는 거의 전부 이 구조를 씁니다.
이 장에서는 그 이유를 GPU 활용률이라는 하나의 숫자로 확인하고,
model_executor.py가 왜 큐를 두 개 들고 있는지 봅니다.
- Process isolation (프로세스 격리)
- 서로 다른 일을 메모리를 공유하지 않는 별개의 프로세스로 떼어놓는 것.
- IPC (Inter-Process Communication)
- 프로세스끼리 직접 함수를 부를 수 없어서 큐·파이프·소켓 같은 통로로 주고받는 것.
mp.Queue- 파이썬
multiprocessing의 큐. 프로세스 경계를 넘어 객체를 전달합니다. mp.Process- 새 프로세스를 띄우는 객체.
target으로 준 함수가 자식 프로세스에서 실행됩니다. - GPU utilization (활용률)
- GPU가 실제로 계산하고 있는 시간의 비율. 놀고 있으면 그만큼 돈이 새는 겁니다.
- Blocking (블로킹)
- 결과가 올 때까지 그 자리에서 멈춰 기다리는 것.
result_queue.get()이 그렇습니다.
비유 먼저 — 계산대를 겸하는 요리사
요리사 한 명이 주문도 받고, 재료도 다듬고, 요리도 하고, 계산도 한다고 해봅시다.
불은 켜져 있는데 요리사가 계산대에 가 있는 동안 불은 그냥 타고 있습니다. 가장 비싼 자원이 가장 싼 일을 하느라 놀고 있는 겁니다.
그래서 요리사를 주방 안에 가둡니다. 주방 밖 일은 홀 담당이 합니다.
둘 사이에는 주문표를 넣는 구멍과 완성 접시가 나오는 구멍만 둡니다 —
그게 task_queue와 result_queue입니다.
작은 숫자로 한 번
요청 하나를 처리하는 데 드는 시간을 대충 이렇게 잡아봅시다.
토크나이징 (CPU) 8 ms
forward pass (GPU) 40 ms
디코딩·후처리 (CPU) 6 ms
── 한 프로세스에서 전부 할 때 ──
GPU가 일하는 시간 = 40 ms
전체 시간 = 8 + 40 + 6 = 54 ms
GPU 활용률 = 40 / 54 = 74%
── CPU 작업을 다른 프로세스로 뺐을 때 ──
다음 요청의 토크나이징이 이번 forward와 겹쳐서 진행됨
GPU 활용률 → 100%에 근접
숫자는 예시지만 방향은 정확합니다. CPU 일이 GPU를 기다리게 하는 만큼 그대로 손해입니다. 요청이 많아질수록 이 차이는 누적됩니다.
CPU 중심 프로세스 vs GPU 전용 워커 프로세스
같은 구조를 비용의 관점에서 다시 그린 것입니다. 왼쪽은 언제든 늘릴 수 있는 값싼 일(HTTP 처리·큐잉·토크나이징), 오른쪽은 GPU 한 장이 상한을 정하는 비싼 일입니다. 둘 사이를 오가는 것은 "추론 task"와 "생성 결과" 두 종류뿐입니다. 이 경계를 어디에 긋느냐가 곧 GPU를 얼마나 놀리지 않느냐를 결정합니다.
프로세스를 나누면 GPU가 얼마나 덜 노나
↔ 슬라이더 + 토글코드에서는 이렇게 생겼습니다
ModelExecutor는 큐 두 개와 프로세스 하나를 들고 있습니다. 그게 전부입니다.
class ModelExecutor:
def __init__(self):
self.task_queue = mp.Queue() # 부모 → 자식
self.result_queue = mp.Queue() # 자식 → 부모
def setup_worker(self, model_name: str):
self.worker_process = mp.Process(
target=ModelWorker.run,
args=(model_name, self.task_queue, self.result_queue)
)
self.worker_process.start()
그리고 자식 쪽은 영원히 큐를 쳐다보는 루프 하나입니다.
class ModelWorker:
@staticmethod
def run(model_name, task_queue, result_queue):
worker = ModelWorker(model_name) # 여기서 모델 로드 (1회)
while True:
request = task_queue.get() # 올 때까지 블로킹
result_queue.put(("complete", worker.generate(request)))
코드를 더 보고 싶다면 — 모델은 어디서 로드되나
ModelWorker.__init__이 ModelManager에게 로딩을 맡깁니다.
자식 프로세스 안에서 딱 한 번 일어납니다.
· 왜 워커 안에서 로드하나 — 모델 가중치는 수 GB입니다. 부모에서 로드해서 큐로 넘기면 직렬화 비용이 감당이 안 됩니다. 가중치는 워커 프로세스 메모리에만 있고, 큐로는 프롬프트와 결과 텍스트만 오갑니다.
· ModelManager.load_model은 AutoModelForCausalLM.from_pretrained와
AutoTokenizer.from_pretrained를 부르는 게 전부입니다. 얇은 층입니다.
멀티모델 편(10장~)에서는 이 자리가 훨씬 두꺼워집니다.
추론이 별도 프로세스라서, 모델이 죽어도 API 프로세스는 살아 있습니다. OOM이나 CUDA 에러로 워커가 터져도 웹 서버가 같이 죽지 않습니다. 성능 때문에 나눈 구조가 장애 격리까지 덤으로 줍니다.
- CPU 작업(토크나이징·후처리)이 GPU와 같은 프로세스에 있으면 GPU가 그만큼 놉니다.
- 그래서
model_worker.py만mp.Process로 떼어내고,task_queue와result_queue두 개로만 대화합니다. - 가중치는 워커 메모리에만 있고 큐로는 텍스트만 오갑니다. 덤으로 장애 격리도 얻습니다.
03요청 한 건이 지나가는 길
한 줄 요약: /basic_generate 하나를 끝까지 따라가면 여섯 부품이 전부 나옵니다.
부품 이름을 외우는 것과 요청이 그 사이를 어떻게 지나가는지 아는 것은 다릅니다. 후자를 알아야 "여기가 느리다"를 짚을 수 있습니다.
이 장에서는 가장 단순한 엔드포인트 /basic_generate 하나를
파일에서 파일로 옮겨 다니며 끝까지 따라갑니다. 배칭도 스트리밍도 아직 없습니다.
Sequence- 요청 하나의 생명주기를 담는 객체. id · prompt · output · 완료 여부를 갖습니다.
uuid4()- 충돌하지 않는 랜덤 고유 id를 만드는 함수. 요청 추적의 시작점입니다.
- Dependency injection ·
Depends - FastAPI가 필요한 객체를 대신 넣어주는 방식. 여기서는
LLMEngine을 주입합니다. - Pydantic model
- 요청·응답의 형식을 선언해두면 자동 검증해주는 클래스.
GenerateRequest같은 것. - Blocking call (동기 호출)
- 결과가 올 때까지 그 줄에서 멈추는 호출.
execute()가 그렇습니다.
비유 먼저 — 주문표 한 장
손님이 주문합니다. 홀 담당이 주문표에 번호를 매겨(uuid) 씁니다.
주문표를 주방 구멍에 넣고(task_queue), 접시가 나올 때까지 구멍 앞에 서서 기다립니다(result_queue.get()). 이 사람은 그동안 아무것도 못 합니다 — 그게 동기 호출입니다.
접시가 나오면 주문표 번호를 보고 어느 손님 것인지 찾아 갖다 줍니다.
번호가 없으면 못 찾습니다. 그래서 Sequence에 id가 있습니다.
파일에서 파일로 — 다섯 번의 손바뀜
→ main.py POST /basic_generate {"prompt": "Hello, I am"}
→ llm.py LLMEngine.basic_generate(prompt)
→ Sequence(uuid4(), prompt, None, None) // 번호표 발급
→ model_executor.py execute(sequence)
→ task_queue.put(...) // 자식 프로세스로
→ result_queue.get() // 여기서 멈춰 기다림
(자식 프로세스에서)
model_worker.py run() → generate() → model.generate()
model_manager.py 가 로드해둔 모델 사용
← 결과가 같은 길을 거꾸로
요청 한 건이 지나가는 길
그림 3-1을 요청 한 건의 여정으로 펼친 것입니다. 화살표가 갈 때와 올 때 두 줄씩 그려져 있는데, 같은 경로를 되짚어 돌아온다는 뜻입니다. 중간에 어느 부품도 결과를 가로채지 않습니다. 여기까지는 배칭도 스트리밍도 없어서 요청 하나가 통로를 독점합니다 — 4장부터 이 전제가 깨집니다.
요청 한 건 따라가기 — 여덟 단계
↔ 재생 + 단계 이동코드 세 조각
세 파일에서 한 조각씩만 보면 전체가 이어집니다.
@app.post("/basic_generate", response_model=GenerateResponse)
async def basic_generate(request: GenerateRequest,
llm: LLMEngine = Depends(get_llm)):
generated_text = llm.basic_generate(request.prompt)
return GenerateResponse(generated_text=generated_text)
class LLMEngine:
def basic_generate(self, prompt: str) -> str:
sequence = Sequence(str(uuid.uuid4()), prompt, None, None)
results = self.model_executor.execute(sequence)
return results[0]["generated_text"]
class ModelExecutor:
def execute_batch(self, prompt: str):
self.task_queue.put((prompts, False)) # 보내고
results = self.result_queue.get() # 멈춰 기다리고
return results
여기서 Sequence의 뒤 두 인자가 None, None인 걸 봐두세요.
그 자리는 스트리밍용 큐와 이벤트 루프 자리입니다(6장). 지금은 비어 있습니다.
result_queue.get()이 블로킹이라, 워커가 이 요청을 다 끝낼 때까지 이 스레드는 멈춰 있습니다.
요청이 두 개 들어오면 줄을 섭니다.
이걸 풀려고 다음 장에서 WorkloadManager가 등장합니다.
- 요청 하나는 main.py → llm.py → model_executor.py → (프로세스 경계) → model_worker.py 순으로 지나갑니다.
LLMEngine이 제일 먼저 하는 일은Sequence에 uuid를 붙이는 것 — 나중에 결과를 되찾기 위해서입니다.- 지금은 한 번에 한 요청뿐입니다.
result_queue.get()이 블로킹이라 두 번째 요청은 기다립니다.
04workload_manager.py — 대기실
한 줄 요약: 요청을 Sequence로 바꿔 큐에 넣고, 네 개씩 묶습니다. 배칭은 여기서만 일어납니다.
배칭은 모델 코드를 고치는 일이 아닙니다. "누구를 언제 묶을지" 정하는 코드를 새로 만드는 일입니다.
그 코드가 workload_manager.py 하나에 모여 있습니다.
이 장에서는 Sequence·incoming_queue·sequence_map·
active_sequences 네 개의 자료구조가 각각 무엇을 위해 있는지 봅니다.
- Batching (배칭)
- 여러 요청을 한 번의 forward pass로 같이 처리하는 것. GPU는 한 개를 처리하나 네 개를 처리하나 시간이 비슷합니다.
- FIFO (First In, First Out)
- 먼저 들어온 것을 먼저 꺼내는 방식. 여기서는
incoming_queue가 그렇습니다. batch_size- 한 번에 묶을 수 있는 최대 개수. 이 코드에서는 4로 고정돼 있습니다.
incoming_queue- 아직 배치에 못 들어간 대기 중인 Sequence들의 줄.
active_sequences- 지금 배치에 들어가 있는 Sequence들. 자리가 비어야 다음 사람이 들어옵니다.
sequence_map- id로 Sequence를 바로 찾는 딕셔너리. 결과 매핑의 핵심입니다.
- Static batching (정적 배칭)
- 배치가 전부 끝나야 다음 배치로 넘어가는 방식. 여기 구현이 이쪽입니다.
비유 먼저 — 4인용 엘리베이터
정원 4명짜리 엘리베이터가 있습니다. 로비에 사람들이 줄을 섭니다(incoming_queue).
문이 열리면 앞에서부터 최대 4명이 탑니다(active_sequences).
5번째 사람은 다음 차를 기다립니다 — FIFO + 정원 제한입니다.
중요한 건 탄 사람들은 서로 모르는 사이라는 점입니다. 내 요청의 프롬프트 2개가 남의 요청 프롬프트와 같은 배치에 섞여 처리됩니다. 그게 자원 공유의 핵심이자, 내 응답 시간이 남 때문에 들쭉날쭉해지는 이유입니다.
네 개의 자료구조가 각각 하는 일
| 자료구조 | 타입 | 없으면 무엇이 안 되나 |
|---|---|---|
Sequence | 객체 (id·prompt·output) | 요청을 웹 요청과 분리해 재구성할 수 없음. 배칭 자체가 불가능 |
incoming_queue | FIFO 큐 | 대기 개념이 사라짐. 자리가 없으면 거절할 수밖에 없음 |
active_sequences | 리스트 (최대 4) | 지금 배치가 누구인지 몰라 다음 스텝을 못 만듦 |
sequence_map | dict (id → Sequence) | 결과가 와도 누구 것인지 못 찾음 |
작은 숫자로 한 번
프롬프트 5개를 /generate로 한 번에 보내면 이렇게 됩니다.
"Hello, I am" / "The weather is" / "I want to" / "The best way to" / "The most efficient way to"
── 1회차 배치 ──
incoming_queue: [1,2,3,4,5] → active_sequences: [1,2,3,4] // 정원 4
한 번의 forward pass로 4개 동시 처리
── 2회차 배치 ──
1~4 완료 → 자리 비움 → active_sequences: [5]
── 결과 ──
forward pass 호출 횟수 = 2회 // 5회가 아니라
대기실 — 큐가 배치로 바뀌는 순간
↔ 슬라이더 + 재생코드 — 대기실의 전부
class Sequence:
def __init__(self, seq_id, prompt, client_stream, loop):
self.id = seq_id # 번호표
self.prompt = prompt # 지금까지의 입력
self.output = [] # 누적된 출력
class WorkloadManager:
self.batch_size = 4 # 한 번에 최대 4개
def add_request(self, prompt: str) -> str:
request_id = str(uuid.uuid4())
sequence = Sequence(request_id, prompt, None, None)
self.incoming_queue.put(sequence) # 줄 세우고
self.sequence_map[request_id] = sequence # 번호로 찾을 수 있게
return request_id
def get_next_batch(self) -> List[Sequence]:
while len(self.active_sequences) < self.batch_size \
and not self.incoming_queue.empty():
sequence = self.incoming_queue.get()
self.active_sequences.append(sequence)
return self.active_sequences
get_next_batch는 딱 두 가지만 합니다 — FIFO로 꺼내고, 4개를 넘지 않게 합니다.
이 두 줄이 이 시스템의 스케줄링 정책 전부입니다.
dynamic batching이든 priority scheduling이든 continuous batching이든, 전부 이 함수를 바꾸는 일입니다.
get_next_batch는 자리가 비어야 새 요청을 넣습니다.
그리고 LLMEngine.generate의 while 루프는 배치가 다 끝나야 다음으로 넘어갑니다.
즉 느린 요청 하나가 배치 전체를 붙잡습니다. vLLM의 continuous batching이 푸는 게 정확히 이 문제입니다(8장).
workload_manager.py는 요청을Sequence로 바꿔 큐에 넣고 최대 4개씩 묶습니다.- 자료구조 네 개가 각각 역할이 있습니다 — 큐는 대기, 리스트는 현재 배치, 딕셔너리는 id로 되찾기.
- 배칭 전략을 바꾸는 일은 곧
get_next_batch를 바꾸는 일입니다. 모델 코드는 건드리지 않습니다.
05llm.py의 generate() — ID로 결과를 되찾기
한 줄 요약: 배치로 섞어 보냈으니, 돌아온 결과를 다시 주인에게 나눠줘야 합니다.
배칭을 넣는 순간 웹 요청 단위와 실행 단위가 어긋납니다. 요청 하나에 프롬프트 3개일 수도 있고, 그 3개가 서로 다른 배치에 흩어질 수도 있습니다.
이 장에서는 LLMEngine.generate가
등록 → 실행 → 매핑 세 단계로 이 문제를 푸는 방법을 봅니다.
- Response mapping (응답 매핑)
- 배치로 나온 결과를 원래 웹 요청과 다시 연결하는 일.
request_id/prompt_id- 프롬프트 하나에 붙는 고유 번호. 결과를 되찾는 유일한 열쇠입니다.
_is_batch_finished- 내가 낸 프롬프트들이 전부 끝났는지 확인하는 함수. 루프 종료 조건입니다.
- Head-of-line blocking
- 앞사람 때문에 뒷사람이 같이 늦어지는 현상. 정적 배칭에서 잘 생깁니다.
- Throughput vs latency
- 전체 처리량과 개별 응답 시간. 배칭은 앞을 얻고 뒤를 조금 내줍니다.
비유 먼저 — 세탁소 번호표
세탁물을 맡기면 번호표를 줍니다. 세탁기에는 여러 손님 옷이 같이 들어갑니다.
다 돌아가면 옷마다 붙은 번호를 보고 손님별로 다시 나눕니다. 번호가 없으면 누구 셔츠인지 알 수 없습니다.
그리고 손님은 자기 옷이 전부 나올 때까지 기다립니다.
셔츠는 나왔는데 바지가 다음 세탁기에 있으면, 바지가 나올 때까지 못 받습니다 — 그게 while not _is_batch_finished(...)입니다.
세 단계로 읽는 generate()
def generate(self, prompts: List[str]) -> List[str]:
# ① 등록 — 프롬프트마다 번호표를 받아둔다
prompt_ids = []
for prompt in prompts:
prompt_ids.append(self.workload_manager.add_request(prompt))
# ② 실행 — 내 프롬프트가 전부 끝날 때까지 배치를 계속 돌린다
while not self._is_batch_finished(prompt_ids):
sequences = self.workload_manager.get_next_batch() # 남의 것과 섞일 수 있음
results = self.model_executor.execute_batch(sequences)
self.workload_manager.update(results)
# ③ 매핑 — 번호표로 내 것만 골라 온다
generated_texts = []
for prompt_id in prompt_ids:
generated_texts.append(
self.workload_manager.get_sequence(prompt_id).output[0])
self.workload_manager.remove_finished_sequence(prompt_id)
return generated_texts
②에서 꼭 봐야 할 것 — get_next_batch()가 주는 배치는
"내가 요청한 프롬프트들"이 아니라 큐에 쌓인 아무 프롬프트 4개입니다.
내 프롬프트가 남의 요청과 같은 배치에 섞여 돌아갑니다. 그래서 ③의 번호표가 반드시 필요합니다.
배칭과 ID 매핑 — 여섯 단계
요청 두 건이 prompt 다섯 개로 쪼개졌다가 다시 두 건으로 합쳐지는 그림입니다. 왼쪽 위 Request 1은 prompt A·B, 아래 Request 2는 C·D·E를 갖고 있습니다. 오른쪽의 세 자료구조(incoming_queue · active_sequence · sequence_map)가 이 흩어짐과 합쳐짐을 전부 감당합니다. 특히 (6)번 화살표 — 생성된 텍스트를 원래 웹 요청으로 되돌리는 그 한 번이 sequence_map이 존재하는 이유입니다.
ID 매핑 — 섞였다가 다시 나뉘는 과정
↔ 슬라이더 + 재생왜 이렇게까지 하나 — 추상화의 값어치
원문의 표현을 그대로 옮기면 이렇습니다.
"이는 사용자의 웹 요청과 실제 모델 실행을 분리하여, 시스템이 프롬프트를 재구성해 백엔드에서 더 효율적으로 처리할 수 있도록 합니다. 이러한 추상화는 서빙 서비스가 동적 배치와 우선순위 지정 같은 최적화 기법을 유연하게 적용할 수 있도록 합니다."
다시 말해 Sequence라는 한 겹을 두는 대가로 나중의 모든 스케줄링 최적화가 가능해집니다.
이 추상화가 없으면 "요청 = 실행"이라 재배열할 여지 자체가 없습니다.
| 얻는 것 | 내주는 것 |
|---|---|
| 전체 처리량 상승 | 개별 요청의 지연이 들쭉날쭉해짐 |
| GPU 활용률 상승 | 구현 복잡도 — 큐·맵·완료 판정이 필요 |
| 나중의 스케줄링 최적화 여지 | 느린 프롬프트가 배치 전체를 붙잡음 |
generate()는 등록 → 실행 → 매핑 세 단계입니다. 매핑의 열쇠는prompt_id입니다.- 배치는 내 프롬프트만 모인 게 아닙니다. 남의 요청과 섞여 돌아가고, 번호표로 다시 나눕니다.
- 이
Sequence추상화 덕분에 웹 요청 순서와 GPU 실행 순서를 분리할 수 있고, 그게 모든 스케줄링 최적화의 전제입니다.
06스트리밍 — 요청마다 큐 하나
한 줄 요약: 백그라운드 스레드가 토큰을 만들고, 요청별 큐가 그걸 각자에게 나눠줍니다.
배칭은 묶는 일이고 스트리밍은 나누는 일입니다. 정반대로 보입니다. 그런데 원문은 못 박습니다 — "Batching과 Streaming은 반대 개념이 아닙니다."
이 장에서는 그 공존이 어떤 코드로 이뤄지는지 봅니다.
핵심은 Sequence가 큐와 이벤트 루프를 함께 들고 다니는 것입니다.
- Streaming (스트리밍)
- 다 만들고 한 번에 주는 대신, 토큰이 나오는 즉시 흘려보내는 것.
- SSE (Server-Sent Events)
- 서버→클라이언트 단방향 스트리밍 HTTP.
text/event-stream, 본문은data: {...}줄의 반복. - AsyncGenerator
async def+yield로 만든 비동기 제너레이터. FastAPIStreamingResponse가 이걸 소비합니다.asyncio.Queue- 요청 하나당 하나씩 만들어지는 토큰 전달 채널. 여기서는
client_stream입니다. - Event loop (이벤트 루프)
- 비동기 코드를 번갈아 실행시키는 엔진. 요청마다 어느 루프에서 도는지가 중요합니다.
run_coroutine_threadsafe- 다른 스레드에서 특정 이벤트 루프에 코루틴을 안전하게 밀어넣는 API. 스레드↔루프 경계를 넘는 유일한 통로.
- Sentinel (센티널)
- "끝났다"를 알리려고 넣는 특별한 값. 여기서는
None입니다.
비유 먼저 — 공동 주방과 개인 쟁반
주방은 여러 손님 요리를 한 판에 같이 올립니다(배치). 효율이 거기서 나옵니다.
그런데 손님에게 낼 때는 각자 쟁반에 자기 것만 담아야 합니다. 쟁반이 손님 수만큼 있어야 하고, 각 쟁반에는 이름표가 붙어 있어야 합니다.
여기서 쟁반이 asyncio.Queue이고, 이름표가 request_id입니다.
그리고 주방(백그라운드 스레드)과 홀(요청 코루틴)은 서로 다른 세계라,
쟁반을 건네려면 특별한 창구(run_coroutine_threadsafe)가 필요합니다.
실행 주체가 셋으로 나뉩니다
| 주체 | 무엇을 하나 | 어디 코드 |
|---|---|---|
| 백그라운드 배치 스레드 | 배치를 돌려 토큰을 만들고 큐에 넣음 | llm.py · requests_processing_loop |
| 요청 코루틴 | 큐에서 꺼내 SSE로 흘려보냄 | llm.py · event_generator |
| 요청별 큐 | 둘 사이를 잇는 다리 | Sequence.client_stream |
Sequence가 왜 loop까지 들고 다니나
3장에서 봤던 Sequence(request_id, prompt, None, None)을 기억하시나요.
스트리밍에서는 그 두 자리가 채워집니다.
Sequence(request_id, prompt, None, None)
# 스트리밍 — 이 요청 전용 큐와, 이 요청이 도는 이벤트 루프
Sequence(request_id, prompt, client_stream, loop)
왜 loop까지 필요한가 — 토큰을 만드는 쪽은 백그라운드 스레드이고,
큐를 소비하는 쪽은 그 요청의 이벤트 루프입니다. 서로 다른 세계라
asyncio.run_coroutine_threadsafe(coro, seq.loop)처럼
루프 참조를 들고 있어야 넘어갈 수 있습니다. 그래서 Sequence가 배달부처럼 들고 다닙니다.
연속 배칭 — 토큰 하나마다 도는 루프
앞 그림과 비슷해 보이지만 sequence의 구성이 달라졌습니다 — (ID, prompt, output, EventQueue)로 항목이 두 개 늘었습니다. 그리고 오른쪽에 배치 처리 스레드라는 루프가 새로 생겼습니다. 핵심은 아래 문장입니다: 배치를 실행해도 prompt마다 새 토큰은 딱 하나입니다. 그래서 매 스텝 output += token, prompt += token으로 sequence를 갱신하고 다시 돕니다.
SSE 스트리밍과 요청별 이벤트 큐
토큰이 배치로 섞여 나오는데 어떻게 요청별로 정확히 돌아가는지를 보여주는 그림입니다. 답은 왼쪽에 있습니다 — 요청마다 전용 EventQueue가 하나씩 붙어 있습니다. API server 쪽은 async for token으로 자기 큐만 바라보고 있다가, 토큰이 들어오면 그대로 SSE 이벤트로 흘려보냅니다. 큐가 나뉘어 있으니 배치 안에서 아무리 섞여도 도착지는 헷갈리지 않습니다.
배치로 만들고, 요청별 큐로 나누기
↔ 슬라이더 + 재생코드 — 두 세계와 그 사이의 창구
① 요청 쪽 — 큐를 만들고, 등록하고, 꺼내서 흘려보냅니다.
async def event_generator(self, loop, prompt: str):
queue = asyncio.Queue() # ① 이 요청 전용 쟁반
seq_id = self.workload_manager.add_streaming_request(prompt, queue, loop)
while True:
data = await queue.get() # ② 토큰 올 때까지 비동기 대기
if data is None: break # ③ 센티널 = 끝
yield f"data: {data}\n\n" # ④ SSE 한 줄
② 배치 스레드 쪽 — 토큰을 만들고, 주인을 찾아 큐에 넣습니다.
def requests_processing_loop(self):
while True:
active = self.workload_manager.get_next_batch(is_streaming=True)
prompts = [{"prompt": s.prompt, "request_id": s.id} for s in active]
tokens = self.model_executor.execute_forward_batch(prompts) # 한 스텝 = 토큰 1개씩
for token in tokens:
seq = self.workload_manager.get_sequence(token["request_id"]) # 주인 찾기
asyncio.run_coroutine_threadsafe(
seq.client_stream.put(json.dumps({
"token": token["token"], "sequence_id": token["request_id"]})),
seq.loop # ← 이 루프 참조 때문에 Sequence가 loop를 들고 다님
)
③ 웹 문 쪽 — 지금 요청의 루프를 잡아 넘겨줍니다.
@app.post("/generate_stream")
async def generate_stream(request, llm = Depends(get_llm)):
async def event_generator():
loop = asyncio.get_event_loop() # 여기서 잡은 루프가
async for token in llm.event_generator(loop, request.prompt):
yield token
return StreamingResponse(event_generator(), media_type="text/event-stream")
코드를 더 보고 싶다면 — 끝났을 때는 어떻게 알리나
완료 판정과 정리는 배치 스레드 쪽에서 같이 합니다.
· None을 넣는 이유 — event_generator의 if data is None: break와 짝입니다.
큐에 흐르는 값은 전부 문자열인데 None만 다르니, "끝"이라는 신호로 쓰기 좋습니다.
· remove_finished_sequence가 중요한 이유 — 이걸 해야 active_sequences에 자리가 나고,
대기 중이던 다음 요청이 배치에 들어옵니다. 원문 Table 3-1에서 T3에 Prompt4가 합류하는 게 이 줄 때문입니다.
· 실제 로그에서도 확인됩니다 — 동시 요청 2개를 넣으면
Batch input shape: torch.Size([2, 4])로 시작해 [2, 25]까지 22스텝 내내 배치 크기 2를 유지합니다.
torch.Size([1, ...])은 한 번도 안 나옵니다 — 두 요청이 끝까지 한 배치였다는 뜻입니다.
원문 그대로 — "Streaming은 모델의 전체 계산 시간을 반드시 줄이는 것은 아닙니다.
하지만 첫 토큰부터 바로 보여주기 때문에 체감 지연시간이 크게 줄어듭니다."
총 시간은 그대로인데 기다리는 느낌만 줄어드는 최적화입니다. 그리고 그게 UX에서는 제일 큽니다.
- 스트리밍은 요청마다
asyncio.Queue하나를 두고, 배치에서 나온 토큰을request_id로 나눠 담습니다. - 토큰을 만드는 백그라운드 스레드와 소비하는 요청 코루틴은 다른 세계라,
run_coroutine_threadsafe로만 넘어갑니다. - 그래서
Sequence가 큐와 이벤트 루프를 함께 들고 다닙니다. 이 두 필드가 스트리밍의 전부입니다.
07KV 캐시가 없으면 — O(n²)의 대가
한 줄 요약: 이 데모의 스트리밍은 매 토큰마다 프롬프트를 처음부터 다시 계산합니다. 일부러 그랬습니다.
실수가 아닙니다. model_worker.py에는 stream_states라는 딕셔너리가
선언만 되고 한 번도 쓰이지 않습니다. 자리를 남겨두고 구현하지 않은 겁니다.
이 장에서는 KV 캐시를 뺐을 때 실제로 무슨 일이 일어나는지를
코드 한 줄(use_cache=False)과 로그 한 줄(torch.Size([2, 25]))로 확인합니다.
- KV cache
- 이전 토큰들의 key/value를 재사용해 매 스텝 전체 재계산을 피하는 캐시.
use_cache=False- HuggingFace 모델에 "캐시 쓰지 마라"고 알리는 인자. 이 데모의 스트리밍 경로가 이걸 씁니다.
- Incremental decoding (증분 디코딩)
- 새로 생긴 토큰 하나만 계산하고 나머지는 캐시에서 가져오는 방식.
past_key_values- HuggingFace가 KV 캐시를 담아 주고받는 표준 자리. 여기서는 안 쓰입니다.
- O(n²)
- 길이가 2배가 되면 일이 4배가 되는 증가 패턴.
- Prefill / Decode
- 프롬프트를 한 번에 훑는 단계와 토큰을 하나씩 뽑는 단계.
비유 먼저 — 매번 처음부터 다시 읽는 사람
긴 편지를 읽으며 다음 문장을 이어 쓰는 사람이 있습니다.
한 문장을 쓸 때마다 편지를 첫 줄부터 다시 읽습니다. 100번째 문장을 쓸 때는 앞의 99문장을 전부 다시 읽고 씁니다.
제대로 하려면 읽은 내용을 요약해 옆에 적어두고, 새 문장만 덧붙이면 됩니다. 그 메모가 KV 캐시입니다. 이 데모에는 그 메모장이 없습니다.
코드에서 정확히 어디가 문제인가
두 줄입니다.
outputs = self.model(input_ids, use_cache=False) # 캐시 안 씀
next_token = outputs.logits[:, -1, :].argmax(...) # 마지막 자리만 사용
# llm/workload_manager.py — update_sequence_output
sequence.prompt += token # 생성한 토큰을 프롬프트에 이어붙임
이 둘이 합쳐지면 매 스텝마다 점점 길어진 프롬프트 전체를 처음부터 forward합니다. 그리고 맨 끝 한 자리만 쓰고 나머지 계산은 버립니다.
Batch input shape: torch.Size([2, 4]) # 프롬프트 4토큰
Batch input shape: torch.Size([2, 5])
Batch input shape: torch.Size([2, 6])
... // 1씩 늘어남
Batch input shape: torch.Size([2, 25]) # 22스텝 후
── 총 계산량 ──
캐시 없음: 4 + 5 + 6 + ... + 25 = 319 토큰-스텝
캐시 있음: 4 + 1 + 1 + ... + 1 = 25 토큰-스텝
낭비 배수 = 319 / 25 ≈ 12.8배
캐시가 없으면 얼마나 더 계산하나
↔ 슬라이더 + 토글네 엔드포인트의 성격이 전부 다릅니다
같은 서비스 안에 경로가 넷인데, 캐싱과 배칭이 제각각입니다. 이게 이 데모의 학습 포인트입니다.
| 엔드포인트 | 실행 경로 | 캐싱 / 배칭 |
|---|---|---|
/basic_generate | ModelExecutor → HF model.generate() (1개) | HF 내부 KV 캐시 사용 |
/generate | WorkloadManager 큐 → 최대 4개 배치 → model.generate() | HF 내부 KV 캐시 사용 |
/generate_stream | 백그라운드 스레드 → 토큰 1개씩 forward | 캐시 없음 · O(n²) |
/generate_vllm | vllm.LLM 엔진 직접 호출 | PagedAttention · continuous batching |
ModelExecutor는 task_queue/result_queue 한 쌍과 워커 프로세스 1개만 띄웁니다.
/generate(배치)와 /generate_stream(스트리밍)이 같은 워커에 순차적으로 일을 던집니다.
즉 배치 요청과 스트리밍 요청이 연산 자원을 놓고 직렬로 경쟁합니다 — 실제 단일 모델 서버에서 흔한 리소스 경합을 그대로 재현합니다.
- 스트리밍 경로는
use_cache=False+prompt += token조합이라 매 스텝 전체를 다시 계산합니다. - 프롬프트 4토큰 · 생성 21토큰 기준 계산량이 약 12.8배로 불어납니다. 길어질수록 더 심해집니다(O(n²)).
stream_states가 선언만 되고 안 쓰이는 건 의도된 반면교사입니다 — "제대로 만들면 KV 캐시가 왜 필요한가"를 체감시키는 자리.
08vLLM으로 바꾸면 — 300줄이 20줄로
한 줄 요약: 지금까지 만든 걸 버리는 게 아니라, 그 덕분에 vLLM 설정이 읽힙니다.
원문의 답이 분명합니다 — "이 섹션은 이전 구현을 대체하지 않습니다. 그것을 기반으로 합니다."
직접 만들어봤기 때문에 max_num_seqs가 무슨 값인지 알게 됩니다.
이 장에서는 같은 일을 vLLM으로 하면 코드가 얼마나 줄어드는지, 그리고 줄어든 만큼 무엇을 모르게 되는지를 봅니다.
- Continuous batching
- 배치 안의 요청이 끝나는 즉시 그 슬롯에 새 요청을 밀어넣는 방식.
- Static batching
- 시작한 배치가 전부 끝나야 다음 배치로 넘어가는 방식. 우리 구현이 이쪽.
- PagedAttention
- vLLM의 KV 캐시를 페이지 단위로 쪼개 관리하는 기법. 메모리 낭비를 줄입니다.
SamplingParams- vLLM에 temperature·top_p·max_tokens를 넘기는 객체.
gpu_memory_utilization- vLLM이 VRAM을 몇 %까지 미리 잡을지. 기본값이 0.9입니다.
AsyncLLMEngine- vLLM의 비동기 엔진. 이걸 써야 동시 요청이 한 배치로 섞입니다.
비유 먼저 — 직접 깎은 자전거와 양산 자전거
자전거를 직접 만들어보면 기어비가 뭔지, 왜 그 숫자인지 알게 됩니다.
그 다음에 양산 자전거를 사면, 설명서의 "11-34T 카세트"가 그냥 읽힙니다. 직접 만들어보지 않았다면 그 숫자는 의미 없는 문자열입니다.
vLLM도 같습니다. max_num_seqs는 우리가 만든 batch_size = 4의 자리이고,
gpu_memory_utilization은 KV 캐시를 얼마나 잡을지의 자리입니다.
코드로 보면 이 정도 차이입니다
class LLMEngine:
def __init__(self):
self.vllm_model = VLLM(model="facebook/opt-125m")
def generate_vllm(self, prompts: List[str]) -> List[str]:
sampling_params = SamplingParams(
temperature=0.7, top_p=0.95, max_tokens=self.max_tokens)
outputs = self.vllm_model.generate(prompts, sampling_params)
return [o.outputs[0].text for o in outputs]
큐도, 워커 프로세스도, Sequence도, 완료 판정도 없습니다. 전부 vLLM 안에 있습니다.
| 직접 구현 | vLLM | |
|---|---|---|
| 배치 구성 | get_next_batch() · FIFO + 고정 4 | 내부 스케줄러 · continuous batching |
| 토큰 생성 | 별도 프로세스에서 한 스텝씩 · use_cache=False로 O(n²) | PagedAttention + KV 캐시 |
| 결과 매핑 | sequence_map · request_id 수동 추적 | 입력 순서 그대로 반환 |
| 스트리밍 | client_stream + 백그라운드 스레드 직접 구현 | 내부 처리 (별도 API) |
| 코드량 | workload_manager 90줄 + model_executor 72줄 + model_worker 141줄 = 약 300줄 | llm.py 약 20줄 |
Static batching vs Continuous batching 타임라인
가로가 시간(T1~T8), 세로가 요청 네 건입니다. 두 그림의 차이는 빈칸의 의미입니다.
왼쪽 Static batching에서 요청 C가 T4에 끝나면 그 자리는 T8까지 그냥 빕니다 — 배치 전체가 끝나야 다음 배치를 받기 때문입니다. GPU 메모리도 그동안 붙잡혀 있습니다.
오른쪽 Continuous batching은 끝난 자리에 바로 새 요청이 들어옵니다(초록 +). 같은 8스텝인데 처리한 요청 수가 다릅니다 — 이게 vLLM이 가져다주는 이득의 정체입니다.
vLLM으로 갈아끼운 뒤의 구조
그림 3-1과 나란히 놓고 보시면 좋습니다. Workload manager도 Model executor도 사라졌습니다. 큐 두 개도, 별도 프로세스 경계도 없습니다. 우리가 손으로 짰던 큐잉·배칭·KV 캐시 관리가 전부 vLLM (model) 상자 안으로 들어갔기 때문입니다. LLM engine이 하는 일은 이제 모델을 초기화하고 prompt를 넣고 결과를 받는 것뿐입니다.
정적 배칭 vs continuous batching
↔ 슬라이더 + 토글이 코드의 generate_vllm은 동기 호출입니다. Uvicorn은 단일 이벤트 루프로 돌고요.
그래서 동시 요청 2개를 넣어도 continuous batching이 일어나지 않고 순차 처리됩니다.
하나의 배치로 섞으려면 vllm.AsyncLLMEngine을 써야 합니다.
프레임워크를 쓴다고 자동으로 빨라지지 않는다는 걸 보여주는 사례입니다.
코드를 더 보고 싶다면 — 알아야 튜닝할 수 있는 값들
vLLM이 복잡성을 감춰주지만, 기본값이 안 맞으면 직접 조정해야 합니다. 그때 필요한 지식이 1~7장입니다.
· max_num_seqs ↔ 우리의 batch_size = 4. 4장을 읽었으면 이 값이 무엇과 맞바꾸는지 압니다.
· gpu_memory_utilization — 기본 0.9. 실습에서 facebook/opt-125m은 0.24GiB인데
vLLM이 VRAM의 90%를 선점했습니다. 자원 효율은 모델 크기가 아니라 설정값이 정합니다.
· 실측 로그: 프롬프트 3개 처리 시
est. speed input: 359.96 toks/s, output: 1542.60 toks/s.
- 같은 배치 추론이 직접 구현 약 300줄 → vLLM 약 20줄이 됩니다. 큐·워커·매핑이 전부 안으로 들어갑니다.
- 줄어든 대신 continuous batching · PagedAttention을 얻습니다. 정적 배칭의 "느린 하나가 전체를 붙잡는" 문제가 사라집니다.
- 다만 동기 호출로 쓰면 배칭이 아예 안 됩니다. 프레임워크는 자동이 아니라, 아는 만큼만 빨라집니다.
— Part 2. 이 구조를 일반화하면 —
09일반 설계 — 세 덩어리로 나누기
한 줄 요약: 인프라 · 비즈니스 로직 · 추론 성능을 섞지 않는 것. 그게 설계의 전부입니다.
인증도 없고, 로드밸런서도 없고, 재시작도 없습니다.
그런데 그걸 전부 llm.py에 넣기 시작하면 파일 하나가 감당 못 하는 물건이 됩니다.
이 장에서는 원문이 제시하는 세 영역 분리를 봅니다. 특정 클라우드나 프레임워크에 매이지 않는, 의도적으로 추상적인 설계입니다.
- Load balancer (로드 밸런서)
- 요청을 여러 인스턴스에 나눠주는 장치. 사용자는 개별 인스턴스를 모릅니다.
- Replica (레플리카)
- 같은 서비스를 여러 벌 띄운 복제본. 트래픽에 따라 수를 늘리고 줄입니다.
- Rate limiting
- 초당 요청 수를 제한해 서비스를 보호하는 것.
- QPS / TPS
- 초당 쿼리 수 / 초당 처리 수. 처리량을 재는 단위입니다.
- Observability (관측 가능성)
- 지연·처리량·오류율 같은 지표를 볼 수 있게 해두는 것.
- SLO
- "99%의 요청을 300ms 안에" 같은 서비스 수준 목표.
- Model-aware
- 모델을 블랙박스로 두지 않고 모델 특성을 알고 서빙하는 것. LLM 서빙의 특징입니다.
비유 먼저 — 건물, 프런트, 주방
레스토랑을 키운다고 해봅시다. 세 가지가 완전히 다른 일입니다.
건물 관리(전기·수도·소방·확장) — 요리와 무관하지만 없으면 영업을 못 합니다. 프런트(예약·결제·주문 검증) — 손님과 주방 사이를 통역합니다. 주방(요리) — 오직 맛과 속도만 신경 씁니다.
셋을 한 사람이 하면 메뉴 하나 바꾸는 데 소방 점검을 고려해야 합니다. 나눠야 각자 빠르게 바뀔 수 있습니다.
세 영역과 그 안의 책임
| 영역 | 무엇을 다루나 | 구체적으로 |
|---|---|---|
| A 인프라 | 확장성 · 가용성 · 모니터링 · 자원 할당 | 로드밸런서, 레플리카 수평 확장, 헬스체크·재시작, 로깅 인터페이스. Docker·Kubernetes·클라우드가 해줍니다 |
| B 프론트엔드 | 비즈니스 로직 | 인증·인가, 외부 시스템 연동(결제·감사 로그), 모델 다운로드·설정, 요청 검증·정규화·배칭, rate limiting |
| C 백엔드 | 모델 추론 성능 | 별도 프로세스로 실행, 프론트엔드만 접근 가능. vLLM·Triton이 담당. 양자화·KV 캐시·continuous batching |
우리가 1~8장에서 만든 것은 B와 C가 한 덩어리로 붙어 있는 상태입니다.
llm.py가 배칭(B)도 하고 워커 관리(C)도 합니다.
실무에서는 C를 통째로 vLLM이나 Triton에 넘깁니다.
일반적인 모델 서빙 설계 — A · B · C
앞에서 만든 것을 한 단계 위에서 다시 그린 그림입니다. 세 영역으로 갈라집니다.
(A) 분산 컴퓨트 인프라 — 로드 밸런서와 여러 서빙 인스턴스. 스케일링·장애 내성·자원 할당이 여기 삽니다.
(B) Serving frontend — Web API·인증·로깅·트래픽 조절. 우리의 main.py가 여기입니다.
(C) Serving backend — vLLM·SGLang·TRT-LLM. 실제 추론과 최적화.
경계가 곧 담당이 갈리는 지점이라는 게 이 그림의 진짜 메시지입니다. 4장의 일곱 계층이 여기서 이어집니다.
세 영역 — 무엇이 어디에 속하나
↔ 항목 클릭배칭은 B(프론트엔드)입니다. 추론이 아니라 요청을 다루는 일이기 때문입니다.
단일 모델 서빙이 만족해야 하는 것 여섯
| 요구사항 | 무슨 뜻 | 우리 실습에서는 |
|---|---|---|
| Low latency | 결과를 빨리 돌려준다 | /generate(블로킹) vs /generate_stream 차이로 확인 |
| High throughput | 동시에 많이 처리 (QPS·TPS) | batch_size=4로 한 forward에 4개 · torch.Size([4, 5]) |
| Scalability | 트래픽에 맞춰 늘리고 줄인다 | 이 코드는 프로세스 하나·루프 하나라 사실상 안 됨 |
| Reliability | 죽지 않고, 죽어도 복구 | 워커를 별도 프로세스로 격리한 것이 첫걸음 |
| Resource efficiency | 쿼리당 비용 관리 | 0.24GiB 모델이 VRAM 90%를 선점한 사례 |
| Observability | 지표를 볼 수 있게 | 로그로 배치 shape 추적한 것이 그 원형 |
① 큰 모델과 메모리 — 수십~수백 GB. 자원 할당을 신중히.
② KV cache 관리 — 긴 컨텍스트와 상태 기반 디코딩을 위해 여러 요청·세션에 걸쳐 관리해야 합니다.
③ 스트리밍 응답 — 인터랙티브 앱에서는 사실상 필수.
④ 가변 길이 워크로드 — 입출력 길이가 제각각이라 이기종 요청을 지능적으로 배치·스케줄링해야 합니다.
그래서 원문은 LLM 서빙을 "model-aware"하다고 표현합니다 — 모델을 블랙박스로 두면 최적화가 안 됩니다.
- 설계를 A 인프라 · B 비즈니스 로직 · C 추론 성능 세 덩어리로 나누고 섞지 않습니다.
- 배칭·검증·인증은 B, 실제 추론은 C이고 C는 별도 프로세스로 격리됩니다. 우리가 2장에서 한 그 격리입니다.
- LLM 서빙은 전통적 모델 서빙과 달리 모델 특성을 알아야(model-aware) 하고, 요구사항이 모델마다 다릅니다.
— Part 3. 여러 모델을 한 서비스에서 —
10멀티모델 — 다섯 개의 새 부품
한 줄 요약: 질문이 바뀝니다. "어떻게 추론하나"에서 "무엇을 메모리에 올려둘까"로.
됩니다. 다만 이렇게 됩니다 — 서버 A는 GPU 10%, B는 20%, C는 5%, D는 80%. 모델이 넷이면 참을 만하고, 천 개면 불가능합니다.
이 장에서는 하나의 서비스가 여러 모델을 다루는 코드의 다섯 파일이 각각 무엇을 맡는지 봅니다. 단일 모델 때와 이름이 겹치지만 역할이 다릅니다.
- Multi-model serving
- 하나의 서비스가 여러 모델을 필요할 때 로드해 공유 자원에서 실행하는 것.
- Lazy loading (지연 로딩)
- 미리 올려두지 않고 요청이 올 때 로드하는 방식. 온디맨드 로딩.
- Model metadata
- 모델 자체가 아니라 "이 모델을 어떻게 로드·실행할지"를 적어둔 정보.
- Unified API
- 텍스트든 이미지든 엔드포인트 하나로 받는 인터페이스. 여기서는
/predict. - Cross-framework
- PyTorch·ONNX·Transformers 등 서로 다른 프레임워크의 모델을 한 시스템에서 다루는 것.
- Cold start
- 안 올라와 있던 모델을 처음 부를 때 생기는 지연. 수 초~수십 초.
비유 먼저 — 공유 주방
식당 열 곳이 각자 주방을 갖는 대신, 주방 하나를 나눠 씁니다.
화구는 두 개뿐입니다. 지금 안 쓰는 냄비는 내려놓고 필요한 냄비를 올립니다. 누가 어떤 냄비를 쓰는지 적어둔 메모가 있고, 냄비 종류에 맞는 조리법 책이 따로 있습니다.
화구 = 메모리, 냄비 내리기 = eviction, 메모 = ModelStore, 조리법 = Worker. 이 비유가 다섯 부품 전부입니다.
다섯 파일과 그 책임
| 파일 | 클래스 | 한 줄 책임 |
|---|---|---|
server.py | FastAPI app | /predict 하나. 모델이 무슨 프레임워크인지 전혀 모릅니다 — 얇은 라우팅 층 |
manager.py | ModelManager | LRU 캐시. "무엇을 메모리에 올려둘지" 결정 — 이 시스템에서 가장 중요한 파일 |
store.py | ModelStore | 모델이 아니라 메타데이터 보관. "이건 transformers 모델, 이름은 distilbert" |
engine.py | ModelEngine | Worker 팩토리. framework 값을 보고 어떤 Worker를 만들지 고름 — 확장 지점 |
worker.py | ModelWorker(ABC) 외 3 | 실제 로딩과 추론. 프레임워크마다 하나씩 |
요청 하나가 지나가는 길
→ ModelManager.get_model_worker(model_id) (manager.py)
→ 캐시에 있으면: move_to_end() 후 바로 반환
→ 없으면: ModelStore에서 메타데이터 조회
→ 캐시 꽉 찼으면 가장 오래된 것 제거
→ ModelEngine.create_worker(metadata)
→ worker.predict(input_data)
멀티모델 서빙 — 일곱 단계
단일 모델과 결정적으로 다른 지점이 가운데 아래 Model cache 상자입니다. 요청이 오면 (2) 캐시에 그 모델이 있는지부터 확인하고, 없으면 (3)(4)로 메타데이터를 읽어 워커를 새로 만듭니다. 그리고 (5)번 — 새 워커를 캐시에 넣으면서 가장 오래 안 쓴 워커를 내보냅니다. 이 한 줄이 멀티모델 서빙의 비용을 절반쯤 결정합니다.
다섯 부품 — 캐시 히트와 미스
↔ 버튼으로 모델 호출server.py — 아무것도 모르는 문
class PredictionRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_id: str
input_data: Any # 텍스트든 이미지 경로든 텐서든
@app.post("/predict")
async def predict(request: PredictionRequest):
worker = model_manager.get_model_worker(request.model_id)
result = worker.predict(request.input_data)
return result
세 줄입니다. 이 파일은 모델이 BERT인지 MobileNet인지 알지 못하고 알 필요도 없습니다.
input_data: Any인 대신 전처리·후처리 책임은 클라이언트에게 넘어갑니다.
"클라이언트가 자기가 호출하는 모델을 이해한다"는 가정입니다.
store.py — 모델이 아니라 명세서
class ModelMetadata(BaseModel):
id: str name: str type: str
framework: str # ← 이 값 하나로 Worker 종류가 정해집니다
version: str description: str
# config/models.json 예시
{ "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" }
ModelStore에는 모델이 없습니다. "이 모델을 어떻게 실행하면 되는지"만 있습니다. 여기서는 로컬 JSON이지만, 실무에서는 별도 메타데이터 서비스나 DB가 그 자리에 옵니다.
- 멀티모델 서빙은 부품이 다섯입니다 — server · manager · store · engine · worker.
server.py는 모델을 전혀 모르는 얇은 층이고,store.py에는 모델이 아니라 메타데이터가 있습니다.- 핵심 질문이 바뀝니다 — "어떻게 추론하나"가 아니라 "무엇을 메모리에 올려둘까"입니다.
11manager.py — LRU 캐시가 비용을 정합니다
한 줄 요약: OrderedDict 하나로 "무엇을 RAM에 올려둘 것인가"를 결정합니다.
정답이 없는 문제입니다. 그래서 규칙을 정합니다.
그 규칙이 manager.py 안 스무 줄에 들어 있고, 그 스무 줄이 인프라 비용의 절반을 정합니다.
이 장에서는 OrderedDict로 만든 LRU 캐시가
히트·미스·제거 세 경우에 각각 무엇을 하는지 코드로 봅니다.
- LRU (Least Recently Used)
- 가장 오래 안 쓴 것부터 버리는 교체 정책. 캐시의 고전.
OrderedDict- 넣은 순서를 기억하는 딕셔너리. 파이썬에서 LRU를 만들 때 쓰는 기본 도구.
move_to_end()- 어떤 항목을 맨 뒤(=가장 최근)로 옮기는 메서드. 캐시 히트 때 부릅니다.
popitem(last=False)- 맨 앞(=가장 오래된) 항목을 꺼내 버리는 메서드. 제거 때 부릅니다.
- Eviction (제거)
- 자리를 만들려고 기존 항목을 내리는 것.
max_models- 동시에 올려둘 모델 개수 상한. 이 코드에서는 2입니다.
- Hot / cold model
- 자주 불리는 모델 / 거의 안 불리는 모델.
비유 먼저 — 책상 위에 책 두 권
책상이 좁아 책 두 권만 올려둘 수 있습니다. 나머지는 책장에 있습니다.
새 책이 필요하면 책장에서 꺼내 옵니다(로딩, 수 초 걸립니다). 책상이 꽉 찼으면 가장 오래 안 본 책을 책장에 꽂습니다(eviction).
같은 책을 계속 보면 빠릅니다. 세 권을 번갈아 보면 매번 책장을 왕복합니다 — 캐시 크기보다 많은 종류를 돌려쓰면 캐시가 아예 도움이 안 됩니다.
코드 — 히트 · 미스 · 제거
class ModelManager:
def __init__(self, model_store, max_models: int = 2):
self.model_cache = OrderedDict() # model id → worker
self.model_engine = ModelEngine()
def get_model_worker(self, model_id: str):
# ① 히트 — 최근 사용으로 표시하고 그대로 반환
if model_id in self.model_cache:
self.model_cache.move_to_end(model_id)
return self.model_engine.get_worker(model_id)
# ② 미스 — 명세서부터 찾는다. 없으면 None (→ API에서 404)
model_metadata = self.model_store.get_model(model_id)
if not model_metadata: return None
# ③ 제거 — 자리가 없으면 가장 오래된 것을 내린다
if len(self.model_cache) >= self.max_models:
id, 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]
move_to_end와 popitem(last=False) 두 줄이 LRU의 전부입니다.
앞쪽이 오래된 것, 뒤쪽이 최근 것 — 그 순서만 유지하면 됩니다.
LRU 캐시 — 직접 돌려보기
↔ 모델 호출 + 슬라이더max_models=2면 전부 미스가 납니다 — 캐시가 무용지물이 되는 패턴입니다.
③ max_models를 3으로 올려보세요. 같은 순서인데 히트율이 확 올라갑니다.실습에서 실제로 확인한 것
모델 셋을 순서대로 호출한 뒤 GET /models를 보면 이렇습니다.
"loaded_models": {
"6ba7b810-...": "mrm8488/bert-tiny-finetuned-sms-spam-detection",
"7c9e6679-...": "pytorch/vision:mobilenet_v2"
}
// distilbert(감성분석)가 사라졌습니다 — max_models=2, 가장 먼저 쓴 것부터 제거
테스트 코드에도 이 검증이 있습니다 — test_model_cache는
3개를 연속 호출한 뒤 loaded_models 개수가 max_models 이하인지 확인합니다.
제거된 모델을 다시 부르면 파일 다운로드 → 메모리 로드 → (자리 없으면) 다른 모델 제거가 전부 다시 일어납니다.
수 초, 심지어 수십 초입니다.
많은 모델이 cold인 상태에서 트래픽이 몰리면 요청 타임아웃과 연쇄 장애로 번질 수 있습니다.
14장에서 이 문제를 정면으로 다룹니다.
ModelManager는OrderedDictLRU 캐시로 "무엇을 메모리에 올려둘지"를 결정합니다.- 히트는
move_to_end(), 제거는popitem(last=False)— 두 줄이 정책의 전부입니다. - 캐시 크기보다 많은 모델을 돌려쓰면 전부 미스가 납니다. 그때 생기는 게 cold start 지연입니다.
12engine.py + worker.py — 팩토리와 다형성
한 줄 요약: 새 프레임워크를 붙이는 일이 "Worker 클래스 하나 추가"로 끝나게 만드는 구조입니다.
predict()로 부르지?"이게 멀티모델 서빙의 핵심 엔지니어링 과제입니다. 원문 표현으로는 "서로 다른 아키텍처와 프레임워크로 학습된 모델들을 호스팅할 수 있는 통합 인터페이스를 구축하는 것".
답은 오래된 두 가지입니다 — 추상 클래스와 팩토리. 이 장에서는 그 둘이 어느 파일에 어떻게 들어가 있는지 봅니다.
- ABC (Abstract Base Class)
- "이 메서드는 반드시 구현해야 한다"고 강제하는 부모 클래스.
- Polymorphism (다형성)
- 구체 타입을 몰라도 같은 이름의 메서드로 다룰 수 있는 성질.
- Factory (팩토리)
- 조건에 따라 어떤 객체를 만들지 골라주는 코드. 분기가 한 곳에 모입니다.
- Idempotent (멱등)
- 여러 번 실행해도 결과가 같은 성질.
if self.model is None이 그 장치. - Sequence classification
- 문장 전체를 보고 "이건 어떤 종류인가"를 고르는 모델. 생성형 LLM과 다릅니다.
__del__- 객체가 사라질 때 불리는 메서드. 자원 정리에 씁니다.
비유 먼저 — 콘센트 규격
나라마다 플러그 모양이 다릅니다. 그런데 어댑터를 끼우면 어디서든 같은 노트북을 씁니다.
어댑터가 지켜야 하는 약속은 하나 — "한쪽은 이 나라 콘센트, 다른 쪽은 표준 단자". 그 약속이 ABC이고, 나라별 어댑터가 Worker 구현체입니다.
그리고 어느 나라인지 보고 어댑터를 골라주는 사람이 필요합니다 — 그게 팩토리(engine.py)입니다.
약속은 두 줄입니다
class ModelWorker(ABC):
@abstractmethod
def _load_model(self): ... # 어떻게 올릴 것인가
@abstractmethod
def predict(self, input_data): ... # 어떻게 추론할 것인가
강제하는 건 이 둘뿐입니다. ModelManager와 ModelEngine은 구체 워커를 전혀 모릅니다.
전부 predict()로만 다룹니다 — 그게 다형성입니다.
구현체는 셋
| Worker | 무엇을 로드하나 | 특징 |
|---|---|---|
TransformerWorker | AutoModelForSequenceClassification + AutoTokenizer | 텍스트 분류. logits → softmax → 확률 |
TorchVisionWorker | mobilenet_v2 | transforms.Compose로 이미지 전처리 후 추론 |
TritonWorker | 로드하지 않음 | HTTP로 Triton 서버에 위임. 책 본문에는 없고 리포에만 있음(13장) |
class TransformerWorker(ModelWorker):
def _load_model(self):
if self.model is None: # 중복 로딩 방지 (멱등)
self.model = AutoModelForSequenceClassification.from_pretrained(
self.model_metadata.name)
self.tokenizer = AutoTokenizer.from_pretrained(
self.model_metadata.name)
def predict(self, input_data):
inputs = self.tokenizer(input_data, return_tensors="pt",
padding=True, truncation=True)
with torch.no_grad():
outputs = self.model(**inputs)
predictions = torch.softmax(outputs.logits, dim=-1)
return {"predictions": predictions.tolist()}
engine.py — 분기가 모이는 한 곳
class ModelEngine:
def create_worker(self, model_metadata) -> ModelWorker:
if model_metadata.id not in self.workers:
if model_metadata.framework == "transformers":
self.workers[...] = TransformerWorker(model_metadata)
elif model_metadata.framework == "torchvision":
self.workers[...] = TorchVisionWorker(model_metadata)
# elif "triton" → TritonWorker (리포에만)
return self.workers[model_metadata.id]
새 프레임워크를 붙이는 일 = Worker 클래스 하나 + elif 한 줄.
다른 파일은 건드리지 않습니다. 확장 지점이 한 곳으로 모였기 때문입니다.
framework 값 하나가 정하는 분기
ModelMetadata.framework 문자열 하나가 어떤 Worker 클래스를 만들지를 정합니다. 분기는 이 그림에 보이는 한 곳뿐이고, 새 프레임워크를 붙일 때 손대야 하는 파일도 여기 하나입니다. ModelManager는 이 분기를 전혀 모른 채 predict()만 부릅니다 — 그게 이 구조의 값어치입니다.
팩토리 — framework 한 글자가 경로를 정한다
↔ 버튼_load_model·predict)를 안 지켰을 때도 눌러보세요.ModelManager는 이 분기를 전혀 모릅니다. predict()만 부릅니다.
AutoModelForSequenceClassification은 문장 전체를 보고 분류하는 모델입니다.
· Sequence classification: 문장 → "긍정/부정" 하나 고르기 (한 번의 forward로 끝)
· 생성형 LLM: 프롬프트 → 토큰 → 다음 토큰 → ... (반복)
그래서 이 멀티모델 예제에는 배칭도 스트리밍도 없습니다. 필요가 없기 때문입니다.
ModelWorker(ABC)가 강제하는 건_load_model과predict둘뿐입니다.- 그 약속 덕분에 Manager와 Engine은 구체 워커를 몰라도 동일하게 다룹니다 — 다형성.
- 프레임워크 분기는
engine.py한 곳에만 있습니다. 그래서 확장이 "클래스 하나 +elif한 줄"로 끝납니다.
13Triton에 위임하기 — Worker가 껍데기가 될 때
한 줄 요약: 같은 predict()인데 안에서 추론하지 않고 HTTP로 넘깁니다.
원문의 답 — "다양한 모델의 백엔드 유지, 메타데이터 관리, 스레드 안전성과 동시성 조율은 복잡하고 오류가 발생하기 쉽다. 전용 프레임워크에 위임하는 게 효율적이다."
이 장에서는 TritonWorker가
ABC의 약속은 그대로 지키면서 내용물만 HTTP 호출로 바꾸는 걸 봅니다.
12장에서 만든 구조 덕분에 다른 파일은 하나도 안 바뀝니다.
- NVIDIA Triton
- PyTorch·TensorFlow·ONNX·TensorRT를 같은 HTTP/gRPC API로 서빙하는 멀티모델 서버.
- Model repository
- Triton이 모델 파일을 찾는 디렉터리.
--model-repository=/models. - Model management API
- 모델을 로드·언로드하는 API.
/v2/repository/models/{name}/load. - Model inference API
- 예측을 요청하는 API.
/v2/models/{name}/infer. config.pbtxt- 모델의 입출력 이름·타입·shape를 적어두는 설정 파일.
- Explicit model control
--model-control-mode=explicit. load API를 불러야만 모델이 올라갑니다.- Wrapper (래퍼)
- 같은 인터페이스를 유지하며 실제 일은 다른 곳에 넘기는 얇은 층.
비유 먼저 — 주방을 외주로
식당이 커지자 주방을 전문 업체에 맡깁니다.
홀은 그대로입니다. 주문표 양식도 그대로입니다. 다만 주방 담당이 직접 요리하는 대신 전화로 주문을 넘깁니다.
손님은 차이를 모릅니다. 인터페이스가 같기 때문입니다.
그게 TritonWorker가 하는 일 전부입니다.
세 메서드가 각각 하는 일
| 메서드 | 무엇을 하나 | 어디로 요청하나 |
|---|---|---|
_load_model | Triton에 모델을 올리라고 지시 | POST /v2/repository/models/{name}/load |
predict | 입력을 텐서로 바꿔 추론 요청 | tritonclient → /v2/models/{name}/infer |
__del__ | 워커가 사라질 때 모델을 내림 | POST /v2/repository/models/{name}/unload |
class TritonWorker(ModelWorker):
def __init__(self, model_metadata):
self.triton_url = "0.0.0.0:8009"
self.client = httpclient.InferenceServerClient(url=self.triton_url)
def _load_model(self): # 로드하지 않는다. 로드하라고 시킨다.
load_url = (f"http://{self.triton_url}/v2/repository/models/"
f"{self.model_metadata.name}/load")
response = requests.post(load_url)
def __del__(self): # 사라질 때 메모리를 회수시킨다
try:
requests.post(f"http://{self.triton_url}/v2/repository/"
f"models/{self.model_metadata.name}/unload")
except: pass # 정리 실패는 무시
__del__이 있는 이유 — 11장의 LRU가 워커를 버릴 때, 이 메서드가 불려
Triton 쪽 메모리까지 실제로 회수됩니다. 이게 없으면 캐시에서는 지웠는데
GPU 메모리는 계속 차 있는 상태가 됩니다.
Triton Inference Server의 내부 구조
우리가 3장에서 만든 부품들이 이름만 바뀌어 그대로 들어 있습니다. Per-Model Scheduler Queues가 우리의 WorkloadManager이고, Framework Backends가 우리의 팩토리 분기입니다. 다른 점은 이미 검증됐고 GPU 여러 장을 전제로 만들어졌다는 것, 그리고 HTTP·gRPC 두 인터페이스와 메트릭 노출이 기본으로 붙어 있다는 것입니다.
Triton을 붙인 멀티모델 서빙 — 아홉 단계
그림 3-8과 나란히 보시면 늘어난 단계가 보입니다. 7단계가 9단계가 됐습니다. 늘어난 둘은 (6) 모델 파일을 Triton 형식으로 복사와 (8) HTTP/gRPC 왕복입니다. 캐시와 LRU는 여전히 우리 쪽에 있고, 실제 실행만 Triton 서버 프로세스로 넘어갔습니다. 프로세스가 갈렸다는 점도 중요합니다 — 모델이 죽어도 우리 서비스는 살아 있습니다.
직접 로드 vs Triton 위임
↔ 토글 + 버튼predict()인데 화살표가 어디로 가는지가 바뀝니다.
② 모델 내리기를 눌러보세요. 위임 모드에서는 __del__이 unload API를 호출합니다.
③ 오른쪽에서 어느 프로세스가 메모리를 쓰는지 보세요. 위임하면 우리 프로세스는 가벼워집니다.코드를 더 보고 싶다면 — 실습에서 나온 숫자들
Triton은 Docker로 띄웁니다. 포트가 세 개 나옵니다.
nvcr.io/nvidia/tritonserver:24.12-py3 tritonserver --model-repository=/models --model-control-mode=explicit
· 8009 = HTTP, 8010 = gRPC, 8011 = Metrics (컨테이너의 8000·8001·8002를 매핑).
TritonWorker가 0.0.0.0:8009를 보는 이유입니다.
· 이미지 크기 9.63GB(디스크 27.4GB). 처음 받을 때 시간이 걸립니다.
· --model-control-mode=explicit — load API를 불러야만 모델이 올라갑니다.
그래서 _load_model이 의미가 있습니다.
· config.pbtxt에 입출력 이름을 적어둡니다. predict에서
output_name = "fc6_1"이 하드코딩돼 있는 건 DenseNet 전용 단순화입니다.
· 실측: cat1.jpg → EGYPTIAN CAT (index 285, logit 11.5)로 정확히 분류.
GPU 메모리 274MiB 사용.
"클라이언트 요청 처리 + 리소스·생명주기 관리는 wrapper가, 무거운 추론 워크로드는 전문 엔진이."
우리 서비스는 모델 캐시 관리·모델 파일 처리·외부 웹 인터페이스를 계속 갖고,
모델 호스팅과 실행만 통째로 Triton에 넘깁니다. 9장의 B와 C 분리가 여기서 실제로 일어납니다.
TritonWorker는 ABC의 약속을 그대로 지키면서 내용물만 HTTP 호출로 바꾼 래퍼입니다.- 세 메서드가 각각 Triton의 load · infer · unload API에 대응합니다.
- 12장의 팩토리 구조 덕분에 다른 파일은 하나도 안 바뀝니다. 이게 추상화의 값어치입니다.
— Part 4. 그래서 어떤 설계를 고를 것인가 —
14비용이냐 지연이냐 — 두 가지 설계
한 줄 요약: 공유하면 싸지고 느려집니다. 전용으로 두면 빨라지고 비싸집니다. 정답은 없습니다.
11장의 LRU는 인스턴스 하나 안에서의 규칙이었습니다. 인스턴스가 여러 대가 되면 새 질문이 생깁니다 — 어떤 모델을 어느 서버에 올릴 것인가.
원문은 답을 하나로 주지 않습니다 — "모든 사람에게 딱 맞는 만능 해결책은 없다." 대신 서로 반대 방향으로 최적화한 두 설계를 놓고 비교합니다.
- Cold start latency
- 안 올라와 있던 모델을 부를 때의 지연. 수 초~수십 초.
- Hot model scaling
- 트래픽이 몰린 모델을 여러 인스턴스로 복제하는 일.
- Bin-packing
- 모델들을 최소한의 서버에 몰아 담아 대수를 줄이는 것.
- Routing map
- "어떤 모델이 어느 인스턴스에 있는지" 적어둔 표.
- Pre-provisioning
- 요청 오기 전에 미리 인스턴스를 띄워두는 것.
- Reactive system
- 트래픽이 나타난 뒤에 대응하는 구조. 항상 수요를 뒤쫓습니다.
- Overprovisioning
- 필요보다 많이 띄워두는 것. 안전하지만 비쌉니다.
- Cascading failure
- 한 곳의 지연이 연쇄적으로 번지는 장애.
비유 먼저 — 공유 오피스와 전용 사무실
공유 오피스는 싸지만, 자리가 없으면 기다립니다. 짐도 매번 옮겨야 합니다.
전용 사무실은 언제나 내 자리가 있습니다. 대신 안 나가는 날에도 임대료를 냅니다.
둘 중 어느 쪽이 맞는지는 얼마나 자주 오느냐에 달려 있습니다. 매일 오면 전용이 낫고, 한 달에 한 번이면 공유가 낫습니다. 모델도 똑같습니다.
먼저, 어려운 문제 두 가지
원문이 실무에서 가장 큰 과제로 꼽는 것은 둘 다 사용자 경험 문제입니다.
| 과제 | 무슨 일이 생기나 | 왜 어려운가 |
|---|---|---|
| Cold start latency | 다운로드 → 로드 → (자리 없으면) 제거. 수 초~수십 초 | 많은 모델이 cold인 채 트래픽이 몰리면 타임아웃과 연쇄 장애로 번짐 |
| Hot model scaling | 특정 모델에 트래픽이 몰려 지연이 늘어남 | 인스턴스마다 캐시가 따로라, 복제와 라우팅 갱신이 복잡. 성능이 비결정적이 됨 |
설계 하나 — 비용 최적화 (그림 3-11)
우리가 10~13장에서 만든 것이 인스턴스 한 대입니다. 그 위에 라우팅 층 A를 얹습니다.
· 어떤 모델이 어느 인스턴스에 로드돼 있는지 매핑 유지
· ① 콜드 스타트 최소화 — 이미 로드된 인스턴스로 라우팅
· ② 핫 모델 수평 확장 — 레플리카 수를 추적해 여러 인스턴스에 복제
· ③ 빈 패킹 — 최소한의 서버에 몰아 담아 대수를 줄임
B + C. 멀티모델 서빙 인스턴스 × N ← 우리가 만든 것
· B: FastAPI + ModelManager(LRU) + ModelEngine(Factory)
· C: 실제 추론 — 실무에서는 대부분 Triton에 위임
설계 둘 — 지연 최적화 (그림 3-12)
전략이 정반대입니다 — 용량을 희생해서 성능을 얻습니다.
· 모델 하나당 인스턴스 그룹 하나. 항상 켜져 있음
C. 모델 프로비저닝 서비스 ← 새로 생김
· 클라이언트가 예측 요청 전에 먼저 이 서비스를 호출
· 그룹을 만들고 라우팅 맵을 갱신
· 이후 요청은 맵을 보고 바로 전용 그룹으로
| 비용 최적화 (3-11) | 지연 최적화 (3-12) | |
|---|---|---|
| 자원 배치 | 여러 모델이 인스턴스를 공유 | 모델마다 전용 그룹 |
| 로딩 시점 | 요청 시 온디맨드 (LRU 캐시) | 사전 프로비저닝 — 클라이언트가 미리 호출 |
| 라우팅 | 로드된 인스턴스를 찾아서 (동적) | 라우팅 맵 참조 (정적) |
| cold start | 있음 — 구조적으로 피할 수 없음 | 없음 — 항상 켜져 있음 |
| 비용 | 낮음 — 빈 패킹으로 대수 절감 | 높음 — 트래픽 적은 모델도 자원 점유 |
| 운영 난이도 | 높음 — 캐시 상태 일관성까지 관리 | 낮음 — 구조가 단순해 디버깅 쉬움 |
멀티모델 인스턴스와 라우팅
인스턴스 하나가 여러 모델을 동시에 이고 있고, 앞단의 라우터가 어느 인스턴스에 무엇이 올라와 있는지를 압니다. 아래 라우팅 맵을 보세요 — 모델 A는 C1·C3 두 곳에 있고, 모델 C는 C2 한 곳뿐입니다. 라우터가 이걸 모르면 이미 올라와 있는 모델을 또 로드하는 낭비가 생깁니다. 비용은 아끼지만, 라우터가 캐시 상태를 추적해야 한다는 부담이 생깁니다.
모델마다 전용 서비스 그룹
반대쪽 극단입니다. 모델 A·B·C가 각자 전용 인스턴스 그룹을 갖습니다. 라우팅 맵은 "모델 → 서비스 그룹" 한 줄짜리로 단순해지고, 로딩 지연도 없습니다. 대신 모델 수만큼 그룹을 띄워야 하고, 트래픽이 적은 모델의 GPU도 계속 켜져 있습니다. 아래쪽 Model provision service가 그룹을 만들고 맵을 갱신하는 역할입니다.
두 설계를 같은 트래픽에 돌려보기
↔ 슬라이더 + 토글비용 최적화의 한계 — 반응형(reactive) 시스템이라 트래픽이 이미 나타난 뒤에 대응합니다. 그래서 항상 수요를 뒤쫓고, 급증 시 지연 증가는 구조적으로 피할 수 없습니다. 게다가 라우팅·스케일링·인스턴스 간 캐시 상태 일관성까지 관리해야 해서 운영 난이도가 크게 오릅니다.
지연 최적화의 한계 — 트래픽이 적은 모델도 전용 자원을 계속 점유합니다. 어떤 모델이 얼마나 뜨거워질지 미리 모르면 과다 프로비저닝으로 컴퓨트가 그냥 낭비됩니다. 다만 꾸준하고 예측 가능한 수요가 있는 모델이 많다면 매우 효과적입니다.
LLM에도 이게 그대로 적용됩니다
LLM은 보통 단일 모델 서빙으로 다루지만, 다음 두 경우에는 멀티모델의 개념이 그대로 재사용됩니다.
| 경우 | 무엇을 공유하나 | 무엇으로 라우팅하나 |
|---|---|---|
| Prefix caching | 이미 채워진 KV 캐시 | 프롬프트 prefix가 같은 요청을 그 레플리카로 |
| 다중 LoRA 어댑터 | 하나의 베이스 모델 | 어느 레플리카가 그 어댑터를 갖고 있나 |
공통 원리는 하나입니다 — 무거운 것(베이스 모델·KV 캐시)은 공유하고, 그 위에 얹은 가벼운 변형이 어디 있느냐로 라우팅한다. 11장의 캐시와 이 장의 라우팅이 그대로 다시 나옵니다.
- 비용 최적화는 공유 + 온디맨드 로딩 + 동적 라우팅 — 싸지만 cold start와 운영 복잡도를 받습니다.
- 지연 최적화는 전용 그룹 + 사전 프로비저닝 — 빠르고 단순하지만 과다 프로비저닝 비용을 받습니다.
- 정답은 트래픽이 얼마나 예측 가능한가에 달려 있습니다. 예측되면 전용, 안 되면 공유가 유리합니다.
마치며
이 장을 시작할 때 던진 질문은 "모델 하나 서빙하는데 파일이 왜 여섯 개나 필요하지?"였습니다.
이제 답할 수 있습니다. 추론하는 파일은 하나뿐이고, 나머지 다섯은 그 하나를 놀리지 않으려고 있습니다. 큐에 넣고, 묶고, 번호를 붙이고, 다른 프로세스로 던지고, 결과를 되찾아옵니다. 그리고 그 다섯 개가 있어야 배칭도 스트리밍도 스케줄링도 가능해집니다.
가장 중요한 한 줄을 고른다면 workload_manager.py의 이것입니다.
요청을 웹 요청이 아니라 Sequence로 바꾸는 순간, 웹 요청 순서와 GPU 실행 순서가 분리됩니다.
그 분리 위에 배칭이 올라가고, client_stream과 loop가 채워지면서 스트리밍이 올라갑니다.
vLLM의 continuous batching도, PagedAttention도 결국 이 자리에서 더 잘하는 방법입니다.
멀티모델로 넘어가면 질문이 바뀝니다. "어떻게 추론하나"에서 "무엇을 메모리에 올려둘까"로.
OrderedDict 하나로 만든 LRU 캐시가 그 답이고, 그 스무 줄이 인프라 비용의 절반을 정합니다.
그리고 인스턴스가 여러 대가 되면 마지막 질문이 남습니다 — 싸게 갈 것인가, 빠르게 갈 것인가.
이 장이 프레임워크를 안 쓰고 처음부터 만들어본 이유도 거기 있습니다.
max_num_seqs가 우리의 batch_size = 4 자리이고,
gpu_memory_utilization=0.9가 0.24GiB 모델에 VRAM 90%를 물리는 값이라는 걸
직접 만들어봤기 때문에 알아볼 수 있습니다.
CloudNet@ 스터디 『Hands-On LLM Serving and Optimization』 CH3
ch03/single_model_llm_serving · ch03/multi_model_serving 실습 코드 ·
vLLM 문서 · NVIDIA Triton Inference Server 문서.
이 페이지의 그림 14개는 원본 캡처가 아니라, 같은 개념을 직접 조작할 수 있게 다시 그린 것입니다.
'AI > [LLMSO] Hands-On LLM 서빙과 최적화 스터디' 카테고리의 다른 글
| [2주차-LLMSO]CH4. Model Serving Best Practices (2026.08.09) (0) | 2026.08.15 |
|---|---|
| [1주차-LLMSO]LLM 기초 동작 원리 (2026.08.02) (0) | 2026.08.08 |
| [1주차-LLMSO]CH2. Large Language Model Serving (2026.08.02) (0) | 2026.08.08 |
| [1주차-LLMSO]CH1. Introduction to Model Serving and Optimization (2026.08.02) (0) | 2026.08.08 |
| [1주차-LLMSO]트랜스포머 모델을 이해하기 위한 기초 수학 정리 (0) | 2026.08.07 |