7. Pydantic을 사용한 구조화된 출력
이전 챕터들에서 우리는 LLM 출력을 원시 텍스트 문자열로 다뤄왔습니다. 사람과 대화하는 챗봇에서는 이것만으로 충분하지만, 프로그램이 LLM 응답을 읽고 해석해야 하는 AI 에이전트를 만들 때는 예측 가능하고 구조화된 데이터가 필요합니다. 이 챕터에서는 Pydantic 스키마를 사용해 LLM이 구조화된 Python 객체를 반환하게 만드는 방법을 배웁니다.
7.1) 왜 구조화된 출력이 필요한가?
자유 텍스트 LLM 출력의 문제점
실제 애플리케이션에서 원시 텍스트 응답이 왜 문제를 일으키는지 이해하는 것부터 시작하겠습니다. 다음과 같은 일반적인 시나리오를 고려해보세요:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# LLM에게 제품 정보를 요청합니다
message = HumanMessage(content="""
다음 텍스트에서 제품 정보를 추출하세요:
"UltraWidget Pro는 $299.99이며 현재 재고가 있습니다."
""")
response = llm.invoke([message])
print(response.content)출력:
제품명: UltraWidget Pro
가격: $299.99
재고 여부: 재고 있음출력 결과는 보기 좋게 나왔습니다. 하지만 이제 이 데이터를 Python 애플리케이션에서 사용해야 한다고 가정해보겠습니다. 가격을 숫자로 어떻게 추출할까요? 재고 여부를 프로그래밍 방식으로 어떻게 확인할까요? 가격을 추출하기 위해 다음과 같이 문자열 파싱을 시도해 볼 수 있을 것입니다:
# 취약한 파싱 접근 방식
text = response.content
price_line = [line for line in text.split('\n') if '가격:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # 취약함 - 형식이 변경되면 어떻게 될까요?이 파싱은 성공하는 것처럼 보입니다. 하지만 실제로는 그렇지 않습니다. 그 이유는 다음과 같습니다:
이 접근 방식이 실패하는 이유:
- LLM이 다음번에 응답을 다르게 형식화할 수 있습니다("가격: 299.99 USD" 또는 "소비자 가격: $299.99")
동일한 프롬프트에 대해 발생할 수 있는 다양한 출력 예시들입니다:
# 예시 1
"제품은 UltraWidget Pro이며, 가격은 $299.99이고, 재고가 있습니다."
# 예시 2
"제품: UltraWidget Pro
비용: 299.99 달러
상태: 재고 있음"
# 예시 3
"UltraWidget Pro - $299.99 (재고 있음)"
# 예시 4
"UltraWidget Pro를 찾았습니다. 가격은 $299.99이며 현재 구매 가능합니다."LLM 응답이 달라지면 완전히 다른 파싱 로직이 필요합니다. 이는 지속 가능한 애플리케이션을 만들기 어렵게 만듭니다.
- LLM 응답은 예측할 수 없습니다: 같은 프롬프트라도 매번 다른 형식으로 응답할 수 있습니다
- 문자열 파싱은 생각보다 복잡합니다:
$, 공백, 줄바꿈, 쉼표 등 모든 경우를 다 처리해야 합니다 - 타입 안전성이 전혀 없습니다:
price변수가 float인지, string인지, None인지 확신할 수 없습니다 - 에러 처리가 어렵습니다: LLM이 "가격 정보 없음"이라고 응답하면
float()호출이 크래시합니다 - 유지보수 불가능: 프롬프트를 조금만 바꿔도 모든 파싱 코드를 다시 작성해야 합니다
핵심 아이디어: Python은 산문이 아닌 계약이 필요합니다
Python에서 API 서버와 통신할 때를 생각해보세요. 특정 REST API를 호출하면 정해진 JSON 응답이 반환될 것을 기대합니다:
# 이런 구조를 기대합니다
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}AI 애플리케이션을 만들 때도 동일한 원칙이 필요합니다. LLM 출력이 매번 자유로운 텍스트가 아니라, 정해진 구조를 가진 데이터로 반환되어야 합니다.
산문(Prose) vs 계약(Contract):
- 산문: 자유로운 형식의 일반 텍스트. 사람이 읽기엔 좋지만 프로그램이 처리하기 어렵습니다.
- 계약: 정해진 구조와 타입을 가진 데이터. "이 필드들이 이 타입으로 반드시 존재한다"는 약속입니다.
구조화된 출력은 계약을 정의하는 것을 의미합니다: "LLM, 정확히 이 필드들이 필요하고, 정확히 이 타입들로, 정확히 이 형식으로 제공해주세요."
여기서 Pydantic이 등장합니다. Pydantic은 Python에서 가장 인기 있는 데이터 검증 라이브러리이며, LangChain은 Pydantic을 사용해 LLM 출력을 구조화된 형태로 받을 수 있게 해줍니다.
사고방식의 전환:
- 이전: "LLM, 이 제품에 대해 말해줘" → 예측 불가능한 텍스트 파싱
- 이후: "LLM, 정해진 형식으로 답해줘" → 구조화된 Python 객체 수신
산문에서 계약으로의 이러한 전환은 신뢰할 수 있는 AI 에이전트를 구축하는 데 근본적입니다. 에이전트가 LLM 응답을 보고 다음 행동을 결정해야 할 때(예: 재고가 있으면 구매, 없으면 알림 등록), 정해진 형식으로 응답을 받아야 합니다.
7.2) 첫 번째 구조화된 출력
7.1에서 우리는 LLM이 자유 형식 텍스트 대신 정해진 구조로 응답해야 하는 이유를 배웠습니다. 이제 실제로 어떻게 구현하는지 알아보겠습니다.
핵심 아이디어: LLM에게 "이런 형식으로 답해줘"라고 요청하는 것만으로는 충분하지 않습니다. Python 코드로 정확한 데이터 구조를 정의하고, LangChain이 이를 LLM에게 전달하도록 해야 합니다. 이렇게 정의된 데이터 구조를 스키마(schema)라고 합니다.
스키마란 무엇인가?
스키마는 데이터의 구조를 정의하는 청사진입니다. 다음을 지정합니다:
- 어떤 필드가 존재해야 하는지
- 각 필드가 어떤 타입이어야 하는지 (문자열, 숫자, 불리언 등)
- 어떤 제약 조건이 적용되는지 (선택적 vs 필수, 유효한 범위 등)
Python에서는 Pydantic의 BaseModel 클래스를 사용하여 스키마를 정의합니다. 가장 간단한 예제는 다음과 같습니다:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool이 스키마는 다음을 의미합니다: "ProductInfo 객체는 정확히 세 개의 필드를 가져야 합니다: product_name (문자열), price (float), in_stock (불리언)."
3단계 패턴: 정의, 바인딩, 호출
구조화된 출력을 사용하는 방법은 간단합니다. 세 단계만 기억하면 됩니다:
- 정의(Define): Pydantic 클래스로 스키마 정의
- 바인딩(Bind):
.with_structured_output()으로 스키마를 LLM에 연결 - 호출(Invoke):
.invoke()로 타입이 지정된 객체 받기
이 패턴은 대부분의 구조화된 추출 작업에서 사용하는 표준 템플릿입니다:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# 1단계: 스키마 정의
class ProductInfo(BaseModel):
product_name: str = Field(description="전체 제품명")
price: float = Field(description="USD 단위의 가격")
in_stock: bool = Field(description="제품 재고 여부")
# 2단계: 스키마를 LLM에 바인딩
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# 3단계: 호출하여 타입이 지정된 객체 받기
message = HumanMessage(content="""
다음 텍스트에서 제품 정보를 추출하세요:
"UltraWidget Pro는 $299.99이며 현재 재고가 있습니다."
""")
result = structured_llm.invoke([message])
# result는 이제 문자열이 아닌 ProductInfo 객체입니다
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True무슨 일이 일어났나요?
- 스키마 정의: 원하는 필드와 타입을 정의했습니다
- 바인딩:
.with_structured_output(ProductInfo)로 구조화된 출력을 사용하도록 설정합니다 - 호출 및 응답:
.invoke()호출 시 LangChain이 JSON Schema를 LLM에 전달하고, LLM이 해당 구조에 맞는 JSON으로 응답합니다 - 자동 변환: LangChain이 JSON을
ProductInfo객체로 변환합니다 - 파싱 코드 불필요
파싱 없음. 타입 변환 없음. 오류 없음.
이 3단계 패턴을 기본 템플릿으로 사용하세요. 구조화된 출력이 필요할 때마다 이 방식을 따르면 됩니다.
Field Description: LLM을 안내하는 핵심
위의 스키마 정의 예제에서 Field(description="...")을 사용했습니다. 이 description은 단순한 문서화가 아닙니다. LLM이 읽고 따르는 지침입니다.
일반적인 Pydantic 사용에서 Field description은 선택사항입니다:
# 일반 Pydantic - description은 사람을 위한 문서화
class User(BaseModel):
name: str = Field(description="사용자 이름") # 없어도 동작함하지만 LLM과 함께 사용할 때는 필수적입니다:
# LLM과 함께 - description은 LLM의 행동을 결정
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="전체 감정: 'positive', 'negative', 또는 'neutral'"
)LLM은 이 description을 보고 어떻게 응답할지를 결정하게 됩니다.
예제로 확인해보겠습니다:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="전체 감정: 'positive', 'negative', 또는 'neutral'"
)
main_issue: str = Field(
description="주요 불만 사항 또는 우려 사항 (있는 경우). 언급된 문제가 없으면 'none' 사용"
)
urgency: str = Field(
description="문제의 긴급도: 'low', 'medium', 또는 'high'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
다음 고객 피드백을 분석하세요:
"제품은 잘 작동하지만 배송에 3주가 걸렸습니다. 이미 프로젝트 마감일을 놓쳤습니다. 즉시 답변 부탁드립니다."
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negative
print(result.main_issue) # 느린 배송
print(result.urgency) # highdescription이 LLM의 판단을 어떻게 바꾸는가:
sentimentdescription → LLM은 가능한 값이 'positive', 'negative', 'neutral'임을 인지 → 배송 문제로 마감일을 놓쳤으므로 'negative' 선택main_issuedescription → LLM이 "주요 불만 사항을 찾으라"는 지시를 받음 → "느린 배송"을 문제로 식별urgencydescription → LLM이 긴급도를 'low', 'medium', 'high'로 표현해야 함을 인지 → "즉시 답변 부탁"이라는 표현에서 'high' 선택
description을 빼면 어떻게 될까요?
sentiment: str # description 없음LLM이 "부정적", "나쁨", "불만족", "2점", "disappointed" 등 예측 불가능한 형태로 응답할 수 있습니다. 이 경우 프로그램에서 이 값을 처리하기 어렵습니다.
핵심: Field description은 LLM의 동작을 제어하는 코드의 일부입니다. 명확하고 구체적으로 작성하세요.
범주형 필드: 가능한 값 명시하기
위 예제에서 sentiment 필드는 'positive', 'negative', 'neutral' 세 가지 값만 가질 수 있습니다. 이렇게 특정 값들 중 하나만 선택해야 하는 필드를 범주형 필드(categorical field)라고 합니다.
범주형 필드에서는 description에 가능한 값을 모두 나열해야 합니다:
sentiment: str = Field(
description="감정: 정확히 'positive', 'negative', 또는 'neutral' (소문자)"
)여기서 "정확히"와 "(소문자)"를 명시하여 LLM이 이 세 가지 값 중 정확히 하나로만 응답하도록 강조할 수 있습니다.
그러나 LLM이 항상 명시된 값 중에서 응답하리라는 보장은 없습니다. 그래서 방어적으로 코드를 작성해야 합니다.
LLM이 예상 밖의 값을 반환하는 경우:
result.sentiment = "Positive" # 대문자로 시작
result.sentiment = "NEGATIVE" # 모두 대문자
result.sentiment = "good" # 완전히 다른 단어방어적 코드 작성:
allowed = {"positive", "negative", "neutral"}
# 소문자로 변환 후 확인
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutral" # 예상 밖의 값이면 기본값 사용
# 이제 sentiment는 반드시 allowed 중 하나핵심:
- Description에 가능한 값을 명시 → LLM이 올바르게 응답할 확률 ↑
- 코드에서 검증 → 예상 밖의 값도 안전하게 처리
참고: 18장에서는 Python enum을 사용하여 더 강력하게 제약하는 방법을 배웁니다.
비교: 수동 파싱 vs 구조화된 출력
구조화된 출력이 있는 경우와 없는 경우를 비교하여 차이를 확인해보겠습니다:
수동 파싱:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
다음에서 제품명, 가격, 재고 여부를 추출하세요:
"UltraWidget Pro는 $299.99이며 현재 재고가 있습니다."
형식: 이름 | 가격 | 재고 여부
""")
response = llm.invoke([message])
text = response.content
# 수동 파싱
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = '재고 있음' in availability or '재고가 있습니다' in availability
print(f"이름: {product_name}")
print(f"가격: ${price}")
print(f"재고 있음: {in_stock}")구조화된 출력:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="전체 제품명")
price: float = Field(description="USD 단위의 가격")
in_stock: bool = Field(description="제품 재고 여부")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
다음에서 제품 정보를 추출하세요:
"UltraWidget Pro는 $299.99이며 현재 재고가 있습니다."
""")
result = structured_llm.invoke([message])
print(f"이름: {result.product_name}")
print(f"가격: ${result.price}")
print(f"재고 있음: {result.in_stock}")주요 차이점:
- 파싱 로직 없음: 구조화된 버전에는 파싱 코드가 전혀 없습니다
- 타입 안정성:
result.price는 float임이 보장됩니다 - 더 간단한 코드: 정규식, 문자열 분할, 수동 타입 변환이 필요 없습니다
- 검증: Pydantic이 모든 필수 필드가 존재하는지 확인합니다
- 유지보수성: 스키마 변경이 파싱 로직 업데이트보다 쉽습니다
7.3) 스키마 설계 고려사항
구조화된 출력 사용법을 배웠으니, 이제 스키마를 잘 설계하는 방법을 배워보겠습니다. 이 섹션에서는 필수 필드와 선택 필드를 구분하는 실용적인 설계 원칙을 다룹니다.
Required 필드
기본적으로 Pydantic 모델의 모든 필드는 required(필수)입니다. 이는 LLM이 사용자 프롬프트에서 모든 required 필드에 대한 값을 추출하거나 유추하여 응답에 포함해야 한다는 의미입니다.
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool이 스키마를 사용하면, LLM은 입력 텍스트에서 세 필드(product_name, price, in_stock) 모두에 대한 값을 찾으려고 시도합니다.
그런데 프롬프트에 required 필드에 대한 정보가 없으면 어떻게 될까요?
다음과 같이 동작할 것으로 우리는 예측할 수 있습니다:
- LLM이 프롬프트에서 해당 정보를 찾지 못함
- LLM이 응답에 해당 필드를 포함하지 않음
- LangChain이 유효한
ProductInfo인스턴스를 생성하지 못함 ValidationError발생
하지만 항상 이렇게 동작하는 것은 아닙니다.
이유는 누락된 정보를 처리하는 방식이 LLM마다 다를 수 있기 때문입니다.
일부 LLM(OpenAI 모델 등)은 프롬프트 내에 필요한 정보가 없는 경우, 값을 생성해서라도 제공하는 경향이 있습니다. 이 경우, ValidationError는 발생하지 않지만, 없는 정보를 있는 것처럼 Python 애플리케이션이 처리할 수 있기 때문에 더 큰 문제를 야기할 수 있습니다.
이 문제에 대한 해결 방법은 7.4절: 문제 발생 시 대처에서 다루겠습니다.
지금은 모든 LLM이 누락된 정보를 동일하게 처리하지 않는다는 점만 기억하세요.
Optional 필드
정상적인 경우에도 존재할 수도 있고 존재하지 않을 수도 있는 필드가 필요합니다. 예를 들어, 배송 메모(delivery_note)는 정상적인 주문이라도 고객이 입력할 수도 있고, 입력하지 않을 수도 있습니다.
Optional을 사용해야 하는 경우:
- 데이터 자체가 존재하지 않을 수 있음 (예: 익명 리뷰가 허용된 경우, 익명 리뷰에는 리뷰어 이름이 없음)
- LLM이 값을 추측하는 대신, 정보가 없음을 명시적으로 표시하기를 원함
필드를 optional(선택사항)으로 만들려면, typing 모듈의 Python Optional 타입을 사용하세요:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # 익명 리뷰에는 리뷰어 이름이 없음참고: Python 3.10+ 사용자는
Optional[str]대신str | None을 사용할 수 있습니다.
필드가 Optional일 때:
- LLM이 프롬프트에서 해당 정보를 찾지 못하면 응답에서 생략할 수 있습니다
- 생략된 필드는 기본값(
None)으로 설정됩니다
완전한 예제:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
제품 정보 추출: "UltraWidget Pro는 $299.99이며 재고가 있습니다."
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (언급되지 않음)
print(result.warranty_years) # None (언급되지 않음)스키마 설계 체크리스트
스키마를 최종 확정하기 전에 다음을 확인하세요:
필드 선택:
- 모든 required 필드가 진짜 필수인가요? (프롬프트에 이 정보가 없으면 어떻게 처리할 건가요?)
- Optional 필드는 정상적인 경우에도 값이 없을 수 있는 필드인가요?
필드 명세:
- 각 필드에 명확한 description이 있나요?
- 범주형 필드가 명시적으로 제약되어 있나요? (예: "정확히 'A', 'B', 또는 'C'여야 함")
7.4) 문제 발생 시 대처
구조화된 출력을 사용할 때 두 가지 문제가 발생할 수 있습니다:
- LLM이 필수 필드 값을 누락 → ValidationError 발생
- LLM이 없는 정보를 지어냄 → ValidationError는 발생하지 않지만 Python 애플리케이션이 잘못된 데이터를 처리
이 섹션에서는 각각을 어떻게 처리하는지 다룹니다.
ValidationError 이해하기
스키마에 정의된 필수 필드에 대한 정보가 사용자 프롬프트에 없으면, LLM은 해당 필드의 값을 제공할 수 없습니다. 그러면 Python 프로그램은 ValidationError를 발생시킵니다:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# 필수 정보가 누락된 입력
message = HumanMessage(content="""
제품 정보 추출: "이 위젯은 훌륭합니다! 강력 추천합니다."
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("ValidationError 발생")참고: 필수 필드에 대한 정보가 사용자 프롬프트에 없는 경우, 일부 LLM은 값을 지어내서 응답으로 제공할 수 있습니다. 이 경우
ValidationError는 발생하지 않지만 더 큰 문제가 발생할 수 있습니다. 이에 대한 내용은 다음 섹션에서 다루겠습니다.
사용자 프롬프트에 필수 필드에 대한 정보가 없어서 ValidationError가 발생하는 것은 실제로는 Python 애플리케이션에게 도움이 됩니다. 애플리케이션은 문제가 발생했음을 인지할 수 있고 준비된 방식으로 에러를 처리할 수 있습니다. 에러 복구 전략에 대해서는 14장 (에이전트 레벨 에러 복구)과 17장 (상태 관리를 통한 재시도 로직)에서 다룹니다.
더 큰 문제: 정보 부족 시 LLM이 값을 만들어내는 경우
7.3절에서 논의했듯이, 일부 LLM은 더 위험한 동작을 보이는 경우가 있습니다: 프롬프트에 정보가 누락되었을 때 값을 만들어내서 응답으로 제공합니다.
문제 발생 과정:
- 프롬프트에 필수 정보가 누락됨
- LLM이 그럴듯한 값을 생성함
ValidationError가 발생하지 않음- Python 애플리케이션이 만들어낸 데이터를 실제 데이터인 것처럼 처리함
예시:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
제품 정보 추출: "이 위젯은 훌륭합니다!"
""")
# 일부 LLM이 product_name, price, in_stock에 대한 정보를 만들어내서 응답으로 제공한 경우
result = structured_llm.invoke([message])
# 에러가 발생하지 않음!
print(result.product_name) # "widget" (텍스트에서 추출)
print(result.price) # 0.0 (만들어냄!)
print(result.in_stock) # False (만들어냄!)
# 문제: 어떤 값이 실제이고 만들어낸 것인지 알 수 없음이것은 ValidationError보다 더 나쁩니다 왜냐하면:
- Python 애플리케이션이 잘못된 데이터로 계속 실행됨
- 어떤 필드가 실제이고 만들어낸 것인지 알 수 없음
- 이후 로직이 가짜 데이터를 기반으로 잘못된 결정을 내릴 수 있음
해결책: Optional 필드와 검증 사용
해결책은 필수 필드들도 모두 Optional로 정의한 다음, validator를 사용하여 필수 필드들의 값이 모두 입력되었는지 확인하는 것입니다.
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# 실제로는 필수이지만 Optional로 선언
# 실제 검증은 아래 validator에서 처리
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""모든 필수 필드가 존재하는지 검증"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("모든 필드(product_name, price, in_stock)가 제공되어야 합니다")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# 불완전한 데이터로 테스트
message = HumanMessage(content="""
제품 정보 추출: "이 위젯은 훌륭합니다!"
""")
try:
result = structured_llm.invoke([message])
# 여기에 도달하면 모든 필드가 존재함이 보장됨
print(f"제품: {result.product_name}")
print(f"가격: ${result.price}")
except ValidationError as e:
# 필수 필드가 누락됨 - 추출 실패
print(f"불완전한 추출: {e}")여기서 무슨 일이 일어나는가:
@model_validator는 사용자 정의 검증 로직을 추가하는 Pydantic의 데코레이터입니다mode='after'는 모든 필드가 파싱된 후에 검증이 실행됨을 의미합니다- 필드가
None이면ValueError를 발생시켜 불완전한 데이터를 알립니다 - Pydantic이 자동으로 이
ValueError를ValidationError로 감쌉니다
이것이 작동하는 이유:
프롬프트에 필드들에 대한 정보가 누락될 때:
- LLM은 없는 정보를 만들어내지 않고 해당 필드 내용을 응답에 포함하지 않습니다
- 이 경우 해당 필드는
None이 됩니다 - 만일 해당 필드가 실제로는 필수 필드라면 Pydantic Validator에서
ValueError를 발생시킵니다 - Pydantic이 이를
ValidationError로 감쌉니다
이렇게 되면 Python 애플리케이션은 LLM이 지어낸 데이터가 아닌 명시적인 에러를 받아서 처리할 수 있습니다.
핵심 요약: 프롬프트에 필수 필드에 대한 정보가 없어 ValidationError가 발생하는 것은 매우 정상적인 흐름입니다. 진짜 위험은 LLM이 만들어낸 데이터입니다. Optional 필드와 validator를 사용하여 LLM이 없는 정보를 만들어내지 않도록 하면서, 필수 필드가 누락되었는지 명시적으로 감지할 수 있습니다.
챕터 요약:
이 챕터에서는 LLM 출력을 신뢰할 수 있는 Python 객체로 변환하는 방법을 배웠습니다:
- 왜 필요한가: 자유 텍스트 파싱은 취약함; 스키마 기반 출력은 타입 안전성 제공
- 어떻게 사용하나: Pydantic
BaseModel로 스키마 정의 →.with_structured_output()로 바인딩 - 설계 원칙: Required vs Optional 필드 선택, Field description으로 LLM 안내
- 문제 대처: ValidationError는 정상; 진짜 위험은 LLM이 만들어낸 데이터