LLM 엔지니어링17장 · Project 02. LLM 추론 API 서빙4 / 14← 이전목차다음 →MetaContents

17장. Project 02. LLM 추론 API 서빙

모델은 켤 때 한 번 올린다

식당은 문을 열기 전에 육수를 끓여 둡니다. 주문이 들어올 때마다 육수부터 끓이면 손님마다 한 시간을 기다립니다. 모델도 같습니다. 요청이 올 때마다 모델을 불러오면, 요청 하나가 모델을 읽는 시간만큼 늦어지고 메모리도 그만큼 다시 씁니다.

켤 때, 요청마다, 끌 때의 세 걸음으로 모델을 한 번 올리고 쓰고 내리는 흐름도

1번에서 서버가 켜질 때 모델을 올리고 한 번 데웁니다. 2번에서 요청마다 올려 둔 모델로 답만 만듭니다. 3번에서 서버가 꺼질 때 모델을 내리고 자원을 돌려줍니다. FastAPI 는 이 세 걸음을 lifespan 함수 하나에 적습니다. 여기서 육수를 끓이는 일이 모델 올리기이고, 문 여는 시각이 서버가 켜지는 때입니다.

모델을 올리는 곳 견주기

올리는 곳언제 올라가나좋은 점걸리는 점
요청을 처리하는 함수 안요청마다쓰지 않으면 메모리를 안 쓴다요청마다 수 초~수십 초 늦다
모듈 맨 위파일을 import 할 때쓰기 쉽다시험 코드가 import 만 해도 모델이 올라간다
lifespan서버가 켜질 때 한 번켜고 끄는 때가 분명하고, 다 올린 뒤에 요청을 받는다켜는 데 시간이 걸린다

lifespan 으로 한 번 올리기

Python
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 로 서버를 켤 때 한 번만 썼습니다.

코드에 나오는 함수

코드쉬운 뜻
@asynccontextmanagerasync(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 없이 부르면 어떻게 되는지 글자로 찍어 봅니다.

Python
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 와 쿠버네티스는 이 답을 보고 요청을 보내기 시작합니다.
← 이전 절17장 · 서비스로 바꾸는 과정다음 절 →17장 · 요청과 응답의 약속