17장. Project 02. LLM 추론 API 서빙
요청과 응답의 약속
식당의 메뉴판에는 파는 음식과 곱빼기가 몇 인분까지인지 적혀 있습니다. 없는 메뉴는 주문을 받지 않고, 나오는 음식도 사진과 같아야 합니다. API 도 받을 입력의 범위와 돌려줄 응답의 꼴을 약속하고, 약속 밖의 입력은 처음부터 받지 않습니다.
왼쪽 카드부터 약속한 꼴로 답한 경우, 입력이 약속 밖이라 받지 않은 경우, 붐벼서 받지 않은 경우, 모델이 제때 답하지 못한 경우입니다. 부르는 쪽은 상태 코드만 보고 다음에 할 일을 정할 수 있습니다. 여기서 메뉴판이 응답 약속이고, "그 메뉴는 없습니다"가 422 입니다.
이 장의 상태 코드
| 상태 코드 | 뜻 | 이 장에서 나오는 때 | 부르는 쪽이 할 일 |
|---|---|---|---|
| 200 | 정상 | 약속한 꼴로 답했다 | 결과를 쓴다 |
| 422 | 입력이 약속 밖 | 본문이 50자보다 짧다, 문장 수가 5보다 크다 | 입력을 고친다. 다시 보내도 같다 |
| 503 | 붐빔 | 처리 중인 요청이 정한 수만큼 찼다 | Retry-After 초만큼 기다렸다 다시 보낸다 |
| 504 | 시간 초과 | 모델이 정한 시간 안에 답하지 않았다 | 글을 줄이거나, 나중에 다시 보낸다 |
| 502 | 모델 서버 오류 | Ollama 에 닿지 못했거나 계속 깨진 답을 냈다 | 운영자에게 알린다 |
입력은 Pydantic 으로, 출력은 서버가 지킨다
LLM 에 "3문장으로"라고 시켜도 5문장을 쓰기도 합니다. 응답의 약속은 모델이 아니라 서버가 지킵니다. 모델의 답을 문장으로 나눠 약속한 수만큼만 돌려줍니다.
import re
from pydantic import BaseModel, Field, ValidationError
class SummaryIn(BaseModel):
본문: str = Field(min_length=50, max_length=3000) # 50자보다 짧거나 3,000자보다 길면 받지 않는다
문장수: int = Field(default=3, ge=1, le=5) # ge, le: 1 이상 5 이하
try:
SummaryIn(본문="너무 짧은 글", 문장수=9)
except ValidationError as e:
print("약속 밖:", [err["loc"][0] for err in e.errors()]) # 어느 칸이 틀렸는지
def keep_sentences(text, n):
"""줄바꿈을 빈칸으로 바꾸고, 마침표·물음표 뒤의 빈칸에서 나눠 앞의 n 문장만 남긴다"""
text = re.sub(r"\s+", " ", text).strip()
sentences = [s for s in re.split(r"(?<=[.!?])\s+", text) if s]
return " ".join(sentences[:n]), len(sentences)
print(keep_sentences("첫째입니다. 둘째입니다.\n셋째입니다. 넷째입니다.", 3))약속 밖: ['본문', '문장수'] 와 ('첫째입니다. 둘째입니다. 셋째입니다.', 4) 가 나옵니다. 둘째 값 4 는 모델이 쓴 문장 수로, 응답에 함께 적어 두면 모델이 약속을 얼마나 어기는지 지켜볼 수 있습니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
BaseModel | Pydantic 의 바탕(base) 모델이다. 이것을 물려받은 클래스는 칸의 꼴과 범위를 검사한다 |
Field(min_length=50, max_length=3000) | field(칸)에 규칙을 붙인다. 글자 수가 50 이상 3000 이하여야 한다 |
Field(default=3, ge=1, le=5) | 안 주면 3을 쓰고, ge(greater than or equal, 이상) 1, le(less than or equal, 이하) 5 사이여야 한다 |
ValidationError | validation(검증)에 실패했을 때 나는 오류다 |
e.errors() | 틀린 칸마다 무엇이 틀렸는지 적은 목록을 돌려준다 |
err["loc"][0] | loc 은 location(위치)의 줄임말이다. 틀린 칸의 이름이다 |
re.split(r"(?<=[.!?])\s+", text) | 마침표·느낌표·물음표 바로 뒤((?<=...), 뒤돌아보기)의 빈칸에서 글을 자른다. 부호는 앞 문장에 남는다 |
이 코드가 뜻하는 것
식당 키오스크는 곱빼기를 5인분까지만 고를 수 있게 막아 두어, 9인분 같은 주문은 처음부터 받지 않습니다. 주방도 접시에 반찬을 세 가지로 정했으면 요리사가 더 만들어도 세 가지만 담아 냅니다. 여기서 키오스크가 SummaryIn, 접시에 세 가지만 담기가 keep_sentences 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
class SummaryIn(BaseModel): | 받을 입력의 칸과 범위를 정한다 | 키오스크의 선택지 |
SummaryIn(본문="너무 짧은 글", 문장수=9) | 두 칸 모두 약속 밖이라 오류가 난다 | 너무 짧은 주문서에 9인분 |
print("약속 밖:", ...) | ['본문', '문장수'] 가 나온다 | 어느 칸이 틀렸는지 알려 준다 |
keep_sentences(text, n) | 문장을 나눠 앞의 n 개만 남기고 원래 문장 수도 돌려준다 | 넷을 만들었어도 셋만 담고, 몇 개 만들었는지 적어 둔다 |
결과의 4 는 모델이 약속(3문장)보다 한 문장 더 썼다는 기록입니다. 비유와 다른 점은, 키오스크는 사람이 고르는 것을 막지만 LLM 은 막을 수 없어 서버가 뒤에서 잘라 낸다는 것입니다.
작게 줄여서 눈으로 보기
Field 의 기본값, 글자 바꾸기, 범위 검사를 칸 하나로 봅니다. Order 는 설명용으로 지어낸 클래스입니다.
from pydantic import BaseModel, Field, ValidationError
class Order(BaseModel):
수량: int = Field(default=1, ge=1, le=5) # 1 이상 5 이하
print(Order()) # 안 주면 기본값
print(Order(수량="3")) # 글자 "3" 도 숫자로 바꿔 받는다
for bad in [0, 9]:
try:
Order(수량=bad)
except ValidationError as e:
print(bad, "→", e.errors()[0]["type"])수량=1
수량=3
0 → greater_than_equal
9 → less_than_equal| 결과 | 읽는 법 |
|---|---|
수량=1 | 값을 주지 않아 기본값 1 이 들어갔다 |
수량=3 | 글자 "3" 을 줘도 숫자 3 으로 바꿔 받는다 |
0 → greater_than_equal | 1 이상이어야 하는 규칙(ge)을 어겼다 |
9 → less_than_equal | 5 이하여야 하는 규칙(le)을 어겼다 |
주의할 점
- 자르면 뒤쪽 문장에 있던 사실이 빠질 수 있습니다. 프로젝트 1에서 잘라서 잃은 것이 얼마인지 잽니다.
- 응답에는 결과와 함께 모델 이름, 모델 판, 걸린 시간을 적습니다. 답이 이상하다는 연락을 받았을 때 어느 판이 답했는지 알 수 있습니다.
max_length는 모델의 문맥 길이와 응답 시간을 생각해 정합니다. 3,000자 글을 받으면 16장에서 본 것처럼 프리필 시간이 길어집니다.