17장. Project 02. LLM 추론 API 서빙
느린 모델을 지키는 법
주방에 화구가 두 개면 찌개는 한꺼번에 두 그릇만 끓일 수 있습니다. 세 번째 손님에게는 "10분 뒤에 다시 와 주세요"라고 말하는 편이, 주문을 받아 놓고 한 시간 기다리게 하는 것보다 낫습니다. 너무 오래 걸리는 주문은 정한 시간이 지나면 사과하고 접고, 자주 나가는 반찬은 미리 담아 둡니다.
왼쪽 카드는 처리 중인 요청이 정한 수만큼 차면 새 요청을 503 과 Retry-After 로 돌려보내는 장치, 가운데는 정한 초가 지나면 504 로 끝내는 장치, 오른쪽은 같은 글이 다시 오면 모델을 부르지 않는 장치입니다. 여기서 화구 수가 동시 처리 제한이고, "10분 뒤에 다시"가 Retry-After 입니다.
세 장치
| 장치 | 막는 일 | 없으면 | 이 장의 값 |
|---|---|---|---|
| 동시 처리 제한 | 요청이 서버 안에 끝없이 쌓이는 일 | 모두가 함께 느려지고 결국 모두 시간 초과 | 요약 API 2개 |
| 시간 제한 | 한 요청이 일꾼을 오래 붙잡는 일 | 부르는 쪽이 언제까지나 기다린다 | 60초 |
| 캐시 | 같은 일을 되풀이하는 일 | 같은 글을 보낼 때마다 수 초씩 쓴다 | 다듬은 글과 문장 수로 찾는다 |
화구 두 개 흉내 내기
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 는 시킨 일을 중간에 멈추지 못한다는 것입니다.
작게 줄여서 눈으로 보기
일꾼 없이 손님 넷이 차례로 화구를 잡아 보는 모습으로 세마포어만 봅니다.
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 으로 답한다 |
마지막 True | release() 로 한 자리가 비자 셋째 손님이 잡았다 |
주의할 점
- 동시 처리 수는 GPU 와 서빙 엔진에 맞춥니다. 16장에서 이 PC 의 Ollama 는 요청을 하나씩 처리했으므로, 수를 크게 잡아도 처리량은 늘지 않고 기다리는 줄만 길어집니다.
- 캐시에는 크기 제한을 둡니다. 이 장의 API 는 1,000개가 차면 가장 오래된 것부터 지웁니다. 개인정보가 든 글은 캐시에 오래 두지 않습니다.
- 503 을 받은 쪽이 곧바로 다시 보내면 붐빔이 더 심해집니다.
Retry-After를 지키고, 다시 보낼 때마다 기다리는 시간을 늘립니다.