17장. Project 02. LLM 추론 API 서빙
모델은 켤 때 한 번 올린다
식당은 문을 열기 전에 육수를 끓여 둡니다. 주문이 들어올 때마다 육수부터 끓이면 손님마다 한 시간을 기다립니다. 모델도 같습니다. 요청이 올 때마다 모델을 불러오면, 요청 하나가 모델을 읽는 시간만큼 늦어지고 메모리도 그만큼 다시 씁니다.
1번에서 서버가 켜질 때 모델을 올리고 한 번 데웁니다. 2번에서 요청마다 올려 둔 모델로 답만 만듭니다. 3번에서 서버가 꺼질 때 모델을 내리고 자원을 돌려줍니다. FastAPI 는 이 세 걸음을 lifespan 함수 하나에 적습니다. 여기서 육수를 끓이는 일이 모델 올리기이고, 문 여는 시각이 서버가 켜지는 때입니다.
모델을 올리는 곳 견주기
| 올리는 곳 | 언제 올라가나 | 좋은 점 | 걸리는 점 |
|---|---|---|---|
| 요청을 처리하는 함수 안 | 요청마다 | 쓰지 않으면 메모리를 안 쓴다 | 요청마다 수 초~수십 초 늦다 |
| 모듈 맨 위 | 파일을 import 할 때 | 쓰기 쉽다 | 시험 코드가 import 만 해도 모델이 올라간다 |
lifespan | 서버가 켜질 때 한 번 | 켜고 끄는 때가 분명하고, 다 올린 뒤에 요청을 받는다 | 켜는 데 시간이 걸린다 |
lifespan 으로 한 번 올리기
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
loads = {"수": 0}
def load_model():
"""모델을 올리는 흉내. 0.5초 걸리고, 올린 횟수를 센다"""
time.sleep(0.5)
loads["수"] += 1
return lambda text: text[:10] + "…"
state = {}
@asynccontextmanager
async def lifespan(app):
state["model"] = load_model() # 서버가 켜질 때 한 번 올린다
yield # 이 사이에 요청을 받는다
state.clear() # 서버가 꺼질 때 내린다
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"준비": "model" in state}
@app.post("/short")
def short(text: str):
return {"결과": state["model"](text)}
with TestClient(app) as client: # with 로 열어야 lifespan 이 돈다
for _ in range(3):
t = time.perf_counter()
client.post("/short", params={"text": "모델은 켤 때 한 번만 올립니다"})
print(f"요청 {time.perf_counter() - t:.3f}초")
print(client.get("/health").json(), "올린 횟수:", loads["수"])세 요청이 모두 0.5초보다 훨씬 짧게 끝나고, 마지막 줄에 {'준비': True} 올린 횟수: 1 이 나옵니다. 0.5초는 with 로 서버를 켤 때 한 번만 썼습니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
@asynccontextmanager | async(asynchronous, 비동기) context manager 라는 뜻이다. yield 앞은 켤 때, 뒤는 끌 때 실행되는 함수로 만든다 |
async def lifespan(app) | FastAPI 가 부를 비동기 함수다. 서버의 수명(켜짐부터 꺼짐까지)을 맡는다 |
yield | 여기서 멈춘 동안 서버가 요청을 받는다. 서버가 꺼지면 아래 줄이 돈다 |
FastAPI(lifespan=lifespan) | 서버를 만들면서 켜고 끌 때 할 일을 넘긴다 |
@app.get("/health"), @app.post("/short") | 그 주소로 GET(가져오기), POST(보내기) 요청이 오면 아래 함수를 부른다 |
TestClient(app) | 진짜 네트워크 없이 같은 프로세스 안에서 요청을 보내 보는 시험용 손님이다 |
with TestClient(app) as client: | with 로 열어야 lifespan 이 돈다. 들어갈 때 켜고 나올 때 끈다 |
params={"text": ...} | 주소 뒤에 ?text=... 꼴로 붙는 값이다. params 는 parameters(매개변수)의 줄임말이다 |
이 코드가 뜻하는 것
식당이 문을 열 때 육수를 한 번 끓이고, 손님이 오면 그 육수로 찌개만 끓여 내는 모습입니다. 여기서 육수 끓이기가 load_model(), 문 여는 때가 with TestClient(app), 손님이 /short 요청입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
load_model() | 0.5초 쉬고 올린 횟수를 하나 센다 | 육수를 끓이는 데 시간이 든다 |
state["model"] = load_model() | 켤 때 한 번 올려 state 에 둔다 | 문 열 때 한 솥 끓여 둔다 |
short(text) | 올려 둔 모델로 답만 만든다 | 끓여 둔 육수로 찌개만 낸다 |
for _ in range(3): | 요청 셋을 보내고 걸린 시간을 찍는다 | 손님 셋 |
마지막 print | {'준비': True} 올린 횟수: 1 | 손님이 셋이어도 육수는 한 번만 끓였다 |
요청 세 줄의 시간은 모두 0.5초보다 훨씬 짧습니다. 시간은 실행할 때마다 조금씩 다르지만, 모델을 올리는 0.5초가 요청에 섞이지 않았다는 점은 늘 같습니다.
작게 줄여서 눈으로 보기
lifespan 이 어떤 차례로 도는지, with 없이 부르면 어떻게 되는지 글자로 찍어 봅니다.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
@asynccontextmanager
async def lifespan(app):
print("1. 켜질 때: 모델 올리기")
yield
print("3. 꺼질 때: 모델 내리기")
app = FastAPI(lifespan=lifespan)
@app.get("/hi")
def hi():
print("2. 요청 처리")
return {"ok": True}
TestClient(app).get("/hi") # with 없이 부르면 lifespan 이 돌지 않는다
print("---")
with TestClient(app) as client:
client.get("/hi")2. 요청 처리
---
1. 켜질 때: 모델 올리기
2. 요청 처리
3. 꺼질 때: 모델 내리기| 결과 | 읽는 법 |
|---|---|
--- 위의 2. 요청 처리 한 줄 | with 없이 부르면 요청은 처리되지만 켤 때와 끌 때의 일이 돌지 않았다. 모델이 필요한 서버였다면 모델이 없어 오류가 났을 것이다 |
1. 켜질 때 | with 에 들어갈 때 yield 위쪽이 돈다 |
2. 요청 처리 | 켜진 서버가 요청을 받는다 |
3. 꺼질 때 | with 를 나올 때 yield 아래쪽이 돈다 |
주의할 점
uvicorn --workers 4처럼 일꾼 프로세스를 여럿 띄우면 프로세스마다 모델을 따로 올립니다. 4GB GPU 에서는 모델이 네 벌 들어가지 않습니다. GPU 모델은 일꾼 하나에 두고, 동시 처리는 서빙 엔진(Ollama, vLLM)에 맡깁니다.- 데우기를 하지 않으면 첫 요청이 모델을 올리는 시간까지 떠안습니다. 16장에서 첫 요청의 TTFT 가 3.83초, 그다음이 0.03초였던 것이 이 차이입니다.
/health는 모델을 다 올린 뒤에만 "준비"라고 답하게 합니다. compose 와 쿠버네티스는 이 답을 보고 요청을 보내기 시작합니다.