Pydantic과 llm.with_structured_output — 함정과 안전장치
Pydantic 모델과 LangChain의 with_structured_output을 깔끔하게 조합하기 — 프롬프트 충돌, 검증 에러, 무한 평가 루프 피하기.
개요
with_structured_output은 Pydantic BaseModel을 LLM에 묶어서, 모델이 자유 텍스트 대신 검증된 객체를 반환하게 만든다. 잘 동작하지만, 내가 쓴 시스템 프롬프트가 이 메서드가 뒤에서 몰래 주입하는 프롬프트와 싸우기 시작하면 얘기가 달라진다. 실제로 무엇이 어긋나는지, 그리고 어떻게 지루하고 안정적인 상태로 유지할지 정리한다.
시스템 프롬프트에서 raw JSON 출력을 강제하라
응답이 반드시 단일 JSON 객체여야 한다고 명시하자. 이게 없으면 모델은 페이로드를 산문이나 마크다운 코드 펜스로 감싸는 경향이 있고, 그러면 파싱이 깨진다.
스키마를 프롬프트에 다시 적지 마라
with_structured_output은 넘겨받은 BaseModel을 따르라고 모델에게 지시하는 자체 히든 시스템 프롬프트를 주입한다. 여기에 더해서 필드 제약을 하나하나 다시 적으면 두 규칙 집합이 충돌할 수 있고, 검증 에러나 환각 키로 이어진다.
프롬프트는 형식만 다루게 하고, 스키마는 메서드에 맡겨라.
binary_score를 기다리는 평가 루프를 주의하라
흔한 채점기 패턴은 점수가 뒤집힐 때까지 폴링한다:
result = grader.invoke({...})
while result.binary_score.lower() != "yes":
result = grader.invoke({...}) # 계속 폴링
모델이 한 번이라도 잘못된 JSON을 반환하거나 "yes" / "no" 외의 값을 내놓으면 Pydantic이 검증 에러를 던진다. 바깥의 while은 조용히 재시도하고, 워크플로는 무한 루프가 된다.
실전 안전장치
| 위험 | 대응 |
|---|---|
| JSON 파싱 실패 | invoke 호출을 try/except로 감싸고 N 회 재시도 후 빠져나온다. |
| 예상 밖 필드 | 모델에 extra = "forbid"를 설정해 문제가 즉시 드러나게 한다. |
| 끝나지 않는 루프 | LangGraph 노드에 max_attempts나 타임아웃을 두고, 초과 시 폴백을 반환한다. |
모델 표기 흔들림 ("Yes" vs "yes") | 비교 전에 .strip().lower()로 정규화한다. |
위 루프의 한계가 있는 버전:
from pydantic import BaseModel, ConfigDict
class Grade(BaseModel):
model_config = ConfigDict(extra="forbid") # Pydantic v2; v1에서는 `class Config`
binary_score: str
max_attempts = 5
for attempt in range(max_attempts):
try:
result = grader.invoke({...})
except Exception: # JSON / 검증 실패
continue
if result.binary_score.strip().lower() == "yes":
break
else:
result = fallback_response() # 루프 소진, 멈춰 있지 않도록
권장하는 최소 시스템 프롬프트
You are a JSON-only assistant. Respond with a JSON object that matches the schema
exactly—no commentary, no extra keys, no markdown fencing.
프롬프트가 할 일은 이게 전부다. 형식을 강제하는 것. 스키마의 세부 사항은 with_structured_output이 처리한다.
정리
시스템 프롬프트는 짧게 유지하고 스키마 강제는 with_structured_output에 위임하자. 프롬프트 충돌을 피하고, 검증 문제를 삼키는 대신 일찍 드러내고, 영원히 도는 루프를 배포하지 않게 된다.