Pydantic v2로 LLM 출력 스키마를 정의하고, Instructor 라이브러리로 자동 재시도와 스트리밍 구조화 출력을 구현합니다.
Pydantic은 Python의 데이터 검증 라이브러리입니다. v2에서는 Rust 기반 코어(pydantic-core)를 도입하여 성능이 크게 향상되었으며, JSON Schema 생성 기능이 내장되어 있어 LLM 구조화 출력과 자연스럽게 결합됩니다.
from pydantic import BaseModel, Field
from enum import Enum
class Sentiment(str, Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
NEUTRAL = "neutral"
class AspectSentiment(BaseModel):
"""개별 측면의 감성 분석 결과"""
aspect: str = Field(description="분석 대상 측면 (예: 배송, 포장, 가격)")
sentiment: Sentiment = Field(description="해당 측면의 감성")
evidence: str = Field(description="감성 판단의 근거가 되는 원문 발췌")
class ReviewAnalysis(BaseModel):
"""리뷰 감성 분석의 전체 결과"""
overall_sentiment: Sentiment = Field(description="전체적인 감성 판단")
confidence: float = Field(
ge=0.0, le=1.0,
description="감성 판단 신뢰도 (0.0 - 1.0)"
)
aspects: list[AspectSentiment] = Field(
description="측면별 세부 감성 분석",
min_length=1
)
summary: str = Field(
max_length=200,
description="분석 결과를 한두 문장으로 요약"
)Pydantic 모델에서 JSON Schema를 생성하는 것은 한 줄이면 충분합니다.
import json
schema = ReviewAnalysis.model_json_schema()
print(json.dumps(schema, indent=2, ensure_ascii=False)){
"title": "ReviewAnalysis",
"description": "리뷰 감성 분석의 전체 결과",
"type": "object",
"properties": {
"overall_sentiment": {
"description": "전체적인 감성 판단",
"enum": ["positive", "negative", "neutral"],
"title": "Overall Sentiment",
"type": "string"
},
"confidence": {
"description": "감성 판단 신뢰도 (0.0 - 1.0)",
"maximum": 1.0,
"minimum": 0.0,
"title": "Confidence",
"type": "number"
},
"aspects": {
"description": "측면별 세부 감성 분석",
"items": {"$ref": "#/$defs/AspectSentiment"},
"minItems": 1,
"title": "Aspects",
"type": "array"
},
"summary": {
"description": "분석 결과를 한두 문장으로 요약",
"maxLength": 200,
"title": "Summary",
"type": "string"
}
},
"required": ["overall_sentiment", "confidence", "aspects", "summary"]
}Pydantic의 Field에 설정한 description, ge, le, max_length, min_length 등의 제약 조건이 JSON Schema로 자동 변환됩니다. 이 메타데이터는 LLM이 올바른 값을 생성하는 데 중요한 가이드 역할을 합니다.
실무에서 자주 사용하는 복합 타입 패턴입니다.
from pydantic import BaseModel, Field
from typing import Literal
class TextEntity(BaseModel):
"""텍스트 엔티티"""
entity_type: Literal["person", "organization", "location", "date"]
value: str
start_offset: int = Field(ge=0, description="원문에서의 시작 위치")
end_offset: int = Field(ge=0, description="원문에서의 끝 위치")
class Relationship(BaseModel):
"""엔티티 간 관계"""
subject: str = Field(description="주체 엔티티")
predicate: str = Field(description="관계 유형 (예: works_at, located_in)")
obj: str = Field(description="대상 엔티티")
class DocumentAnalysis(BaseModel):
"""문서 분석 결과"""
title: str
language: Literal["ko", "en", "ja", "zh"]
entities: list[TextEntity]
relationships: list[Relationship]
key_facts: list[str] = Field(
max_length=10,
description="문서의 핵심 사실 (최대 10개)"
)
word_count: int = Field(ge=0)Instructor는 Pydantic 모델을 LLM 구조화 출력과 연결하는 오픈소스 라이브러리입니다. 자동 재시도, 스트리밍, 멀티 프로바이더 지원 등 프로덕션에 필요한 기능을 제공합니다.
pip install instructorimport instructor
from openai import OpenAI
from pydantic import BaseModel, Field
from enum import Enum
class Priority(str, Enum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class TicketClassification(BaseModel):
"""고객 문의 티켓 분류 결과"""
category: str = Field(description="문의 카테고리 (예: 결제, 배송, 반품)")
priority: Priority = Field(description="우선순위")
sentiment: str = Field(description="고객 감정 상태")
suggested_action: str = Field(description="권장 조치 사항")
requires_escalation: bool = Field(description="상위 담당자 에스컬레이션 필요 여부")
# Instructor로 OpenAI 클라이언트 패치
client = instructor.from_openai(OpenAI())
ticket = client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=TicketClassification,
messages=[
{
"role": "user",
"content": "결제가 이중으로 되었는데 3일째 환불이 안 됩니다. 정말 화가 납니다!"
}
]
)
print(ticket.model_dump_json(indent=2))Instructor의 핵심은 response_model 매개변수입니다. Pydantic 모델을 전달하면, 내부적으로 JSON Schema를 추출하여 LLM API에 전달하고, 응답을 Pydantic 인스턴스로 변환합니다.
LLM 출력이 Pydantic 검증을 통과하지 못하면 자동으로 재시도합니다. 재시도 시 검증 오류 메시지를 컨텍스트로 함께 전달하여 LLM이 오류를 수정할 수 있게 합니다.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, field_validator
class ExtractedAmount(BaseModel):
"""금액 추출 결과"""
currency: str = Field(description="통화 코드 (ISO 4217)")
amount: float = Field(gt=0, description="금액 (양수)")
original_text: str = Field(description="원문에서 금액이 표현된 부분")
@field_validator("currency")
@classmethod
def validate_currency(cls, v: str) -> str:
valid_currencies = {"KRW", "USD", "EUR", "JPY", "GBP", "CNY"}
if v not in valid_currencies:
raise ValueError(
f"지원하지 않는 통화입니다: {v}. "
f"지원 통화: {', '.join(sorted(valid_currencies))}"
)
return v
client = instructor.from_openai(OpenAI())
result = client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=ExtractedAmount,
max_retries=3, # 최대 3회 재시도
messages=[
{
"role": "user",
"content": "이번 달 전기요금이 45,000원 나왔습니다."
}
]
)
print(result)
# currency='KRW' amount=45000.0 original_text='45,000원'max_retries를 높게 설정하면 비용과 지연 시간이 증가합니다. 일반적으로 2-3회가 적절하며, 그 이상 재시도가 필요하다면 스키마나 프롬프트를 개선하는 것이 바람직합니다.
Instructor는 다양한 LLM 프로바이더를 동일한 인터페이스로 지원합니다.
import instructor
from openai import OpenAI
from anthropic import Anthropic
from pydantic import BaseModel, Field
class Summary(BaseModel):
"""텍스트 요약 결과"""
title: str = Field(description="요약 제목")
bullet_points: list[str] = Field(description="핵심 내용 (3-5개)")
word_count: int = Field(description="원문 단어 수 추정")
# OpenAI
openai_client = instructor.from_openai(OpenAI())
# Anthropic
anthropic_client = instructor.from_anthropic(Anthropic())
# 동일한 인터페이스로 호출
text = "분석할 긴 텍스트..."
# OpenAI로 요약
openai_result = openai_client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=Summary,
messages=[{"role": "user", "content": f"다음 텍스트를 요약하세요: {text}"}]
)
# Anthropic으로 요약
anthropic_result = anthropic_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
response_model=Summary,
messages=[{"role": "user", "content": f"다음 텍스트를 요약하세요: {text}"}]
)Instructor는 **Partial Streaming(부분 스트리밍)**을 지원합니다. LLM이 토큰을 생성할 때마다 부분적인 Pydantic 객체를 반환하여, 사용자에게 실시간 피드백을 제공할 수 있습니다.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
class ArticleOutline(BaseModel):
"""기사 개요"""
title: str = Field(description="기사 제목")
sections: list[str] = Field(description="섹션 목록")
estimated_length: int = Field(description="예상 분량 (단어)")
client = instructor.from_openai(OpenAI())
# 스트리밍 모드로 호출
stream = client.chat.completions.create_partial(
model="gpt-4o-2026-02",
response_model=ArticleOutline,
messages=[
{
"role": "user",
"content": "AI가 교육에 미치는 영향에 대한 기사 개요를 작성하세요."
}
]
)
for partial in stream:
# 부분적으로 채워진 Pydantic 객체
print(f"제목: {partial.title}")
print(f"섹션 수: {len(partial.sections)}")
print("---")Pydantic AI는 Pydantic 팀이 공식 개발한 AI 에이전트 프레임워크입니다. 구조화된 출력을 생성하는 세 가지 방식을 제공합니다.
도구 호출을 통해 구조화된 출력을 생성합니다. 기본 방식이며 대부분의 모델에서 안정적으로 동작합니다.
from pydantic_ai import Agent
from pydantic import BaseModel, Field
class CityInfo(BaseModel):
"""도시 정보"""
name: str
country: str
population: int = Field(description="인구 수 (추정)")
famous_for: list[str] = Field(description="유명한 것들")
agent = Agent(
"openai:gpt-4o",
output_type=CityInfo
)
result = agent.run_sync("서울에 대해 알려주세요.")
print(result.output)
# name='서울' country='대한민국' population=9700000 famous_for=['경복궁', 'N서울타워', ...]모델의 네이티브 Structured Output 기능을 사용합니다. OpenAI의 response_format 같은 기능을 직접 활용합니다.
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from pydantic import BaseModel
class MathSolution(BaseModel):
"""수학 문제 풀이"""
reasoning: str
answer: float
unit: str
agent = Agent(
"openai:gpt-4o",
output_type=NativeOutput(MathSolution)
)
result = agent.run_sync("원의 넓이를 구하세요. 반지름은 5cm입니다.")
print(result.output)모델에 JSON 형식을 프롬프트로 주입하는 방식입니다. 네이티브 구조화 출력이나 도구 호출을 지원하지 않는 모델에서 사용합니다.
from pydantic_ai import Agent
from pydantic_ai.output import PromptedOutput
from pydantic import BaseModel
class Translation(BaseModel):
"""번역 결과"""
original: str
translated: str
source_language: str
target_language: str
agent = Agent(
"openai:gpt-4o",
output_type=PromptedOutput(Translation)
)
result = agent.run_sync("Translate 'Hello World' to Korean.")
print(result.output)TypeScript 환경에서는 Zod 스키마 라이브러리가 Pydantic과 유사한 역할을 합니다. OpenAI의 Node.js SDK는 Zod를 공식 지원합니다.
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const ReviewAnalysis = z.object({
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("전체적인 감성"),
confidence: z.number().min(0).max(1)
.describe("감성 판단 신뢰도"),
aspects: z.array(
z.object({
aspect: z.string().describe("분석 대상 측면"),
sentiment: z.enum(["positive", "negative", "neutral"]),
})
).describe("측면별 감성 분석"),
summary: z.string().max(200).describe("분석 요약"),
});
type ReviewAnalysisType = z.infer<typeof ReviewAnalysis>;
const client = new OpenAI();
async function analyzeReview(text: string): Promise<ReviewAnalysisType> {
const completion = await client.beta.chat.completions.parse({
model: "gpt-4o-2026-02",
messages: [
{ role: "system", content: "리뷰를 분석하세요." },
{ role: "user", content: text },
],
response_format: zodResponseFormat(ReviewAnalysis, "review_analysis"),
});
const result = completion.choices[0].message.parsed;
if (!result) throw new Error("파싱 실패");
return result;
}Zod의 z.infer를 사용하면 스키마에서 TypeScript 타입을 자동으로 추론할 수 있습니다. 스키마와 타입이 항상 동기화되므로 타입 불일치 문제가 발생하지 않습니다.
이번 장에서는 Pydantic과 Zod를 활용한 타입 안전 구조화 출력을 학습했습니다.
핵심 내용을 정리하면 다음과 같습니다.
model_json_schema()로 JSON Schema를 자동 생성하며, Field의 메타데이터가 LLM의 출력 품질을 좌우합니다zodResponseFormat을 활용합니다5장에서는 비정형 데이터(PDF, 이미지, 웹페이지)에서 구조화된 정보를 추출하는 실전 기법을 다룹니다. OCR과 LLM을 결합한 파이프라인, 테이블 추출, 엔티티-관계 추출 등을 학습합니다.
이 글이 도움이 되셨나요?
PDF, 이미지, 웹페이지 등 비정형 데이터에서 LLM을 활용하여 구조화된 정보를 추출하는 실전 기법을 학습합니다.
Function Calling의 원리를 이해하고, OpenAI/Anthropic/Google의 도구 호출 인터페이스로 구조화된 출력을 생성하는 방법을 학습합니다.
대량 문서 처리 파이프라인을 구축하고, 배치 처리, 비동기 추출, 품질 검증 루프, 비용 최적화 전략을 학습합니다.