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

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

느린 모델을 지키는 법

주방에 화구가 두 개면 찌개는 한꺼번에 두 그릇만 끓일 수 있습니다. 세 번째 손님에게는 "10분 뒤에 다시 와 주세요"라고 말하는 편이, 주문을 받아 놓고 한 시간 기다리게 하는 것보다 낫습니다. 너무 오래 걸리는 주문은 정한 시간이 지나면 사과하고 접고, 자주 나가는 반찬은 미리 담아 둡니다.

동시 처리 제한, 시간 제한, 캐시의 세 장치를 카드로 놓은 그림

왼쪽 카드는 처리 중인 요청이 정한 수만큼 차면 새 요청을 503 과 Retry-After 로 돌려보내는 장치, 가운데는 정한 초가 지나면 504 로 끝내는 장치, 오른쪽은 같은 글이 다시 오면 모델을 부르지 않는 장치입니다. 여기서 화구 수가 동시 처리 제한이고, "10분 뒤에 다시"가 Retry-After 입니다.

세 장치

장치막는 일없으면이 장의 값
동시 처리 제한요청이 서버 안에 끝없이 쌓이는 일모두가 함께 느려지고 결국 모두 시간 초과요약 API 2개
시간 제한한 요청이 일꾼을 오래 붙잡는 일부르는 쪽이 언제까지나 기다린다60초
캐시같은 일을 되풀이하는 일같은 글을 보낼 때마다 수 초씩 쓴다다듬은 글과 문장 수로 찾는다

화구 두 개 흉내 내기

Python
import threading
import time
from concurrent.futures import ThreadPoolExecutor

slots = threading.BoundedSemaphore(2)      # 화구 두 개: 한꺼번에 둘까지만 처리한다


def handle(order):
    if not slots.acquire(blocking=False):  # 빈 화구가 없으면 기다리지 않고 바로 돌려보낸다
        return 503
    try:
        time.sleep(0.5)                    # 모델이 답을 만드는 시간을 흉내 낸다
        return 200
    finally:
        slots.release()                    # 끝나면 화구를 비운다(오류가 나도 비운다)


with ThreadPoolExecutor(max_workers=5) as pool:
    print("다섯이 한꺼번에:", sorted(pool.map(handle, range(5))))
print("다 끝난 뒤:", handle(5))

# 시간 제한: 0.2초만 기다린다
with ThreadPoolExecutor() as pool:
    job = pool.submit(time.sleep, 1)
    try:
        job.result(timeout=0.2)
    except TimeoutError:
        print("0.2초 안에 끝나지 않아 504 로 답한다. 일 자체는 끝까지 돈다:", job.done())

다섯이 한꺼번에: [200, 200, 503, 503, 503], 다 끝난 뒤: 200 이 나오고, 마지막 줄은 job.done() 이 False 로 나옵니다. 기다리기를 그만두어도 시킨 일은 계속 돈다는 뜻입니다. Ollama 도 같아서, API 가 504 로 답한 뒤에도 모델은 그 답을 끝까지 만듭니다.

코드에 나오는 함수

코드쉬운 뜻
threading.BoundedSemaphore(2)semaphore(신호기, 철도의 신호 깃발)는 동시에 들어갈 수를 세는 장치다. Bounded(한도가 있는)는 처음 정한 2보다 많이 돌려주면 오류를 내는 꼴이다
slots.acquire(blocking=False)acquire(얻다)는 자리를 하나 잡는다. blocking=False 는 빈자리가 없을 때 기다리지 않고 바로 False 를 돌려준다
slots.release()release(놓아주다)는 잡은 자리를 돌려준다
try: ... finally:일이 성공하든 오류가 나든 finally 아래는 꼭 실행한다. 자리를 꼭 돌려주려는 것이다
ThreadPoolExecutor(max_workers=5)thread(실) pool(웅덩이, 모아 둔 것) executor(실행자)라는 뜻이다. 일꾼 다섯을 두고 일을 나눠 맡긴다
pool.map(handle, range(5))주문 다섯 개를 일꾼들에게 한꺼번에 맡기고 결과를 모은다
pool.submit(time.sleep, 1)submit(제출하다)은 일 하나를 맡기고 그 일의 영수증(Future)을 돌려준다
job.result(timeout=0.2)결과를 최대 0.2초만 기다린다. 넘으면 TimeoutError 가 난다
job.done()맡긴 일이 끝났는지 본다

이 코드가 뜻하는 것

화구가 두 개인 주방에 손님 다섯이 한꺼번에 왔습니다. 두 명은 바로 요리를 받고, 세 명은 "조금 뒤에 다시 와 주세요"를 듣습니다. 여기서 화구가 세마포어의 자리, 손님이 요청, 돌려보내는 말이 503 입니다.

줄하는 일쉽게 말하면
slots = threading.BoundedSemaphore(2)자리를 둘로 정한다화구 두 개
handle(order)자리를 잡으면 0.5초 일하고 200, 못 잡으면 바로 503화구가 비었으면 요리, 아니면 돌려보낸다
다섯이 한꺼번에: [200, 200, 503, 503, 503]다섯 요청 가운데 둘만 처리했다두 명만 받았다
다 끝난 뒤: 200자리가 비었으니 다시 받는다화구가 비자 다음 손님을 받는다
job.result(timeout=0.2)1초짜리 일을 0.2초만 기다린다오래 걸리는 주문은 정한 시간에 사과하고 접는다
job.done() 이 False기다리기는 그만뒀지만 일은 아직 돈다손님에게는 사과했지만 냄비는 아직 끓고 있다

결과는 붐빌 때 요청을 무작정 쌓지 않고 바로 돌려보낸다는 뜻입니다. 비유와 다른 점은, 실제 주방은 사과한 주문의 불을 끌 수 있지만 이 코드와 Ollama 는 시킨 일을 중간에 멈추지 못한다는 것입니다.

작게 줄여서 눈으로 보기

일꾼 없이 손님 넷이 차례로 화구를 잡아 보는 모습으로 세마포어만 봅니다.

Python
import threading

slots = threading.BoundedSemaphore(2)          # 화구 두 개
print(slots.acquire(blocking=False))           # 첫 손님: 화구를 잡는다
print(slots.acquire(blocking=False))           # 둘째 손님: 화구를 잡는다
print(slots.acquire(blocking=False))           # 셋째 손님: 빈 화구가 없다
slots.release()                                # 한 명이 다 먹고 화구를 비운다
print(slots.acquire(blocking=False))           # 이제 셋째 손님이 잡는다
출력
True
True
False
True
결과읽는 법
True, True첫째와 둘째 손님이 화구를 하나씩 잡았다
False화구 둘이 다 찼다. blocking=False 라 기다리지 않고 바로 실패를 알린다. API 는 이때 503 으로 답한다
마지막 Truerelease() 로 한 자리가 비자 셋째 손님이 잡았다

주의할 점

  • 동시 처리 수는 GPU 와 서빙 엔진에 맞춥니다. 16장에서 이 PC 의 Ollama 는 요청을 하나씩 처리했으므로, 수를 크게 잡아도 처리량은 늘지 않고 기다리는 줄만 길어집니다.
  • 캐시에는 크기 제한을 둡니다. 이 장의 API 는 1,000개가 차면 가장 오래된 것부터 지웁니다. 개인정보가 든 글은 캐시에 오래 두지 않습니다.
  • 503 을 받은 쪽이 곧바로 다시 보내면 붐빔이 더 심해집니다. Retry-After 를 지키고, 다시 보낼 때마다 기다리는 시간을 늘립니다.
← 이전 절17장 · 요청과 응답의 약속다음 절 →17장 · 빠른 모델과 큰 모델 나눠 쓰기