4장. 의도 분류기 - 구조화 출력
Pydantic으로 스키마 쓰기 — 클래스가 곧 명세다
한 줄 요약
Pydantic은 "칸마다 타입이 붙은 데이터 클래스"를 만들고, 들어온 데이터가 그 타입에 맞는지 검사해 주는 파이썬 라이브러리입니다. 이 클래스를 response_schema에 그대로 넘기면 스키마가 되고, 응답은 response.parsed에 그 클래스의 객체로 담겨 옵니다.
1. Pydantic이 하는 일
BaseModel을 물려받은 클래스에 칸 이름과 타입을 적습니다.
from pydantic import BaseModel
class Customer(BaseModel):
name: str
age: int
Customer(name="김도윤", age=37) # 통과
Customer(name="김도윤", age="서른") # ValidationError
타입에 맞지 않는 값이 들어오면 ValidationError 라는 오류가 납니다. 이 오류가 Pydantic을 쓰는 이유입니다. 잘못된 데이터가 조용히 들어와 한참 뒤 엉뚱한 곳에서 문제를 일으키는 대신, 들어오는 자리에서 바로 걸립니다.
Pydantic은 2장에서 requirements.txt로 이미 설치했습니다.
2. 허용 값을 고정한다 — Enum
정해진 값만 와야 하는 칸은 Enum(열거형) 으로 만듭니다.
import enum
class Urgency(str, enum.Enum):
LOW = "낮음"
NORMAL = "보통"
HIGH = "높음"
str과 enum.Enum을 함께 물려받으면 "문자열이면서 이 세 값만 허용되는" 타입이 됩니다. 왼쪽(LOW)은 코드에서 부르는 이름이고 오른쪽("낮음")은 실제 값입니다.
Urgency.HIGH # 코드에서 쓸 때
Urgency.HIGH.value # "높음" — 실제 값을 꺼낼 때
3. 없을 수도 있는 칸 — str | None
문의에 주문번호가 있을 수도, 없을 수도 있습니다. 이런 칸은 str | None으로 적습니다. "문자열이거나, 값이 없거나"라는 뜻입니다.
order_id: str | None = Field(default=None, description="...")
이 칸이 없으면 모델은 주문번호가 없는 문의에도 무언가를 채워 넣어야 합니다. 비워 둘 자리를 주는 것이 지어내기를 막는 방법입니다.
4. 설명(description)은 모델이 읽는다
이번 장의 스키마 전체입니다.
class IntentResult(BaseModel):
intent: Intent = Field(description="문의의 핵심 의도 분류")
urgency: Urgency = Field(description="처리 긴급도. 아래 [긴급도 기준]을 따른다")
order_id: str | None = Field(
default=None,
description="문의에 포함된 주문번호 (HR로 시작). 없으면 null",
)
summary: str = Field(description="상담원이 한눈에 볼 수 있는 한 줄 요약 (한국어)")
Field(description=...)은 사람을 위한 주석처럼 보이지만 모델에게 전달됩니다. 이 클래스를 response_schema로 넘기면 SDK가 JSON 스키마로 바꿔 보내는데, 그때 설명도 함께 갑니다.
| 설명에 적은 것 | 모델이 그것으로 하는 일 |
|---|---|
| "아래 [긴급도 기준]을 따른다" | 시스템 프롬프트에 적어 둔 긴급도 기준을 찾아 그대로 적용합니다 |
| "HR로 시작" | 문장에서 주문번호를 찾는 단서로 씁니다 |
| "없으면 null" | 주문번호가 없을 때 비워 둡니다 |
| "한국어" | 요약을 한국어로 씁니다 |
스키마의 설명은 프롬프트입니다. 3장에서 배운 "확인할 수 있게, 구체적으로"가 여기에도 그대로 적용됩니다.
5. 응답은 객체로 온다 — response.parsed
Pydantic 클래스를 스키마로 넘기면 응답의 parsed에 그 클래스의 객체가 들어옵니다. json.loads()를 직접 부를 필요가 없습니다.
result = response.parsed # IntentResult 객체
result.intent # Intent.REFUND
result.intent.value # "환불교환"
result.order_id # None
이제 뒤의 코드는 문자열 비교 대신 이렇게 분기합니다.
if result.intent == Intent.REFUND:
...
Intent.REFUND를 Intent.REFUNDD로 잘못 치면 실행하자마자 오류가 납니다. "환불교환"을 "환불 교환"으로 잘못 친 문자열 비교는 오류 없이 조용히 거짓이 됩니다. 이 차이가 큽니다.
6. 검사는 어디서 일어나는가
「따라하기」의 classify()는 두 단계로 받습니다.
if response.parsed is not None:
return response.parsed
return IntentResult.model_validate_json(response.text)
- 보통은
response.parsed에 완성된 객체가 들어 있습니다. - 그것이 비어 있으면 응답 원문(
response.text)을model_validate_json()으로 직접 검사합니다. 스키마에 맞지 않으면 여기서ValidationError가 납니다.
ValidationError는 어느 칸에 무슨 값이 왔고, 무엇이 와야 했는지를 알려 주는 예외입니다. 스키마로 강제한 출력이 이 검사에서 걸리는 일은 드물지만, 받은 데이터를 코드가 한 번 더 확인하는 층이 있다는 것이 중요합니다.
핵심 정리
- Pydantic은 타입이 붙은 클래스를 만들고, 맞지 않는 데이터에
ValidationError를 냅니다. - 정해진 값만 와야 하는 칸은 Enum, 없을 수도 있는 칸은
str | None으로 적습니다. Field(description=...)은 주석이 아니라 모델이 읽는 지시입니다.- 응답은
response.parsed에 클래스의 객체로 옵니다. 코드는result.intent로 분기합니다. - 스키마로 강제하고, Pydantic으로 검사합니다. 두 겹입니다.