PydanticAI로 타입 안전 AI 에이전트 만들기 — 프로덕션에서 버그를 23건 줄인 방법
AI 에이전트를 처음 프로덕션에 올려봤을 때를 떠올리면 지금도 아찔합니다. LLM이 돌려준 JSON을 파싱하는 코드 주변에 try/except를 덕지덕지 바르고, 응답 형식이 조금만 달라져도 파이프라인 전체가 뻗어버리는 상황을 몇 번이나 겪었는지 모릅니다. 한 번은 confidence 필드가 0.87 대신 "high"로 내려와서 다운스트림 수치 계산 전체가 터진 적도 있었습니다. 그때 "애초에 LLM 출력을 타입으로 강제할 수 있다면?" 이라는 생각이 들었는데, PydanticAI가 정확히 그 지점을 파고들었습니다.
PydanticAI는 FastAPI를 만든 Pydantic 팀이 "FastAPI가 웹 개발에 한 일을 AI 에이전트 개발에도 해보자"는 철학으로 만든 프레임워크입니다. 2025년 9월에 V1 안정 버전이 공개되었고, 그 사이 누적 다운로드 1,500만 건, GitHub 스타 16,000개를 넘겼습니다. 실사용 데이터로 보면 동일 기능(구조화 출력·의존성 주입·도구 등록이 포함된 분석 에이전트) 기준으로 LangGraph 대비 코드량이 43% 짧고, 개발 과정에서 타입 오류를 23건 더 잡아냈다는 비교 측정값도 있습니다. 지금 이 글을 읽지 않고 LLM 응답을 여전히 텍스트로 파싱하고 있다면, 같은 종류의 버그를 반복하고 있을 가능성이 높습니다.
이 글에서는 PydanticAI의 세 가지 핵심 메커니즘(타입 안전 출력, 의존성 주입, 도구 등록)을 실제 코드와 함께 살펴보고, 언제 PydanticAI를 선택하고 언제 다른 프레임워크를 선택해야 하는지 솔직하게 정리합니다.
핵심 개념
타입 안전 출력: LLM 응답을 Pydantic 모델로 강제하기
PydanticAI의 가장 큰 차별점은 LLM의 응답을 Pydantic BaseModel에 자동으로 맞춰준다는 것입니다. 단순히 JSON을 파싱하는 게 아니라, 형식이 틀리면 재시도하거나 타입 예외를 발생시키는 방식으로 데이터 무결성을 보장합니다.
from pydantic import BaseModel
from pydantic_ai import Agent
class ResponseModel(BaseModel):
answer: str
confidence: float
agent = Agent(
model="openai:gpt-4o",
result_type=ResponseModel,
system_prompt="You are a helpful assistant."
)
result = agent.run_sync("What is the capital of France?")
print(result.data.answer) # "Paris"
print(result.data.confidence) # 0.99비동기 환경에서는
agent.run_sync()대신await agent.run()을 사용할 수 있습니다. 이 글에서는 동기 컨텍스트에서는run_sync(), 비동기 함수 안에서는await agent.run()을 씁니다. Python 비동기 패턴이 낯설다면run_sync()만으로도 충분히 시작할 수 있습니다.
result_type에 ResponseModel을 넘기는 것만으로 끝입니다. LLM이 confidence를 문자열로 돌려보내면 PydanticAI가 알아서 변환을 시도하고, 불가능하면 ValidationError를 발생시키고 기본 3회까지 재시도합니다. 저도 처음엔 "그냥 프롬프트에 JSON 형식으로 답하라고 쓰면 되지 않나?" 싶었는데, 프롬프트 지시는 LLM이 무시할 수 있지만 타입 검증은 코드 레벨에서 강제된다는 점이 다릅니다.
내부적으로 구조화된 출력은 세 가지 경로로 처리됩니다.
| 경로 | 동작 방식 | 적합한 상황 |
|---|---|---|
| 도구 호출 기반 추출 | LLM이 함수 호출 형태로 데이터 반환 | OpenAI, Anthropic 등 Function Calling 지원 모델 |
| 제공자 관리 JSON 스키마 | 모델 API의 response_format 활용 |
JSON Mode를 지원하는 최신 모델 |
| 프롬프트 주입 포매팅 | 스키마를 시스템 프롬프트에 삽입 | Function Calling 미지원 모델 폴백 |
Pydantic BaseModel이란 Python 데이터 검증 라이브러리인 Pydantic의 기본 클래스입니다. 클래스에 타입 힌트로 필드를 선언하면 인스턴스 생성 시 자동으로 타입 검증과 변환이 이루어집니다.
의존성 주입: 테스트와 프로덕션 코드를 깔끔하게 분리하기
AI 에이전트를 테스트하는 게 어려운 이유 중 하나는 에이전트 로직 안에 DB 연결, API 클라이언트 같은 외부 의존성이 뒤섞여 있기 때문입니다. 의존성 주입(Dependency Injection)이란 이런 외부 객체를 코드 안에 고정하지 않고 실행 시점에 외부에서 주입받는 패턴입니다. PydanticAI는 FastAPI와 똑같은 방식으로 이 문제를 풀었습니다.
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
db_conn: DatabaseConnection # 실제 DB 클라이언트로 교체 필요
api_key: str
agent = Agent(
model="anthropic:claude-3-5-sonnet-latest",
deps_type=Deps
)
@agent.tool
async def get_user_data(ctx: RunContext[Deps], user_id: str) -> dict:
return await ctx.deps.db_conn.fetch(
"SELECT * FROM users WHERE id = $1", user_id
)
# 프로덕션 실행
result = await agent.run(
"Get data for user 123",
deps=Deps(db_conn=real_db, api_key="prod-key")
)
# 테스트 실행 — mock 객체를 그냥 끼워 넣을 수 있습니다
result = await agent.run(
"Get data for user 123",
deps=Deps(db_conn=mock_db, api_key="test-key")
)실제 DB 연결 없이 단위 테스트를 돌릴 수 있다는 게 실무에서 얼마나 편한지는 써본 사람만 압니다. deps_type으로 의존성 타입을 선언하고, agent.run(deps=...) 시점에 실제 객체를 주입하는 구조라 테스트 환경에서는 mock 객체를 그냥 끼워 넣을 수 있습니다.
도구 등록: Python 함수를 LLM의 손발로 만들기
@agent.tool 데코레이터 하나로 일반 Python 함수를 LLM이 호출할 수 있는 도구로 등록할 수 있습니다. LLM은 함수의 docstring과 타입 힌트를 보고 어떤 상황에서 해당 도구를 써야 할지 판단합니다.
@agent.tool
async def query_sales(ctx: RunContext[Deps], region: str) -> list[dict]:
"""지정된 지역의 판매 데이터를 조회합니다.
Args:
region: 조회할 지역명 (예: 'seoul', 'busan')
"""
return await ctx.deps.db_conn.fetch(
"SELECT * FROM sales WHERE region = $1", region
)
@agent.tool
async def calculate_growth_rate(
ctx: RunContext[Deps],
current: float,
previous: float
) -> float:
"""전기 대비 성장률을 계산합니다."""
return (current - previous) / previous * 100Function Calling(도구 호출): LLM이 텍스트 생성 대신 특정 함수를 호출하도록 지시하는 기능입니다. LLM이 "이 질문에 답하려면 DB 조회가 필요하다"고 판단하면
query_sales함수를 호출하고, 결과를 받아 최종 응답을 생성합니다.
이 세 가지 메커니즘이 실제로 어떻게 조합되는지 금융 도메인 코드로 직접 확인해 보겠습니다.
실전 적용
예시 1: 금융 데이터 분석 에이전트
금융 도메인에서 타입 안전성은 선택이 아니라 필수입니다. revenue가 문자열로 들어오거나 growth_rate가 없는 채로 다운스트림 시스템에 흘러들어가면 큰일이 나니까요. PydanticAI의 Pydantic 검증이 이 방화벽 역할을 해줍니다.
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
from typing import Optional
class SalesReport(BaseModel):
region: str
total_revenue: float = Field(ge=0, description="총 매출 (원)")
growth_rate: float = Field(description="전기 대비 성장률 (%)")
top_product: str
risk_level: str = Field(pattern="^(low|medium|high)$")
summary: str
action_items: list[str]
@dataclass
class AnalyticsDeps:
db_conn: DatabaseConnection # 실제 DB 클라이언트로 교체 필요
report_date: str
agent = Agent(
model="openai:gpt-4o",
result_type=SalesReport,
deps_type=AnalyticsDeps,
system_prompt="""
You are a financial analyst. Analyze sales data and provide
structured reports with actionable insights.
"""
)
@agent.tool
async def get_regional_sales(
ctx: RunContext[AnalyticsDeps],
region: str
) -> dict:
"""특정 지역의 판매 데이터를 조회합니다."""
return await ctx.deps.db_conn.fetch(
"SELECT * FROM sales WHERE region = $1 AND date = $2",
region, ctx.deps.report_date
)
@agent.tool
async def get_previous_period(
ctx: RunContext[AnalyticsDeps],
region: str
) -> Optional[dict]:
"""전기 비교 데이터를 조회합니다."""
return await ctx.deps.db_conn.fetch_one(
"SELECT total_revenue FROM sales WHERE region = $1 AND date < $2 ORDER BY date DESC LIMIT 1",
region, ctx.deps.report_date
)
# 실행
deps = AnalyticsDeps(db_conn=db, report_date="2026-05-01")
result = await agent.run("서울 지역 5월 판매 성과를 분석해줘", deps=deps)
print(result.data.risk_level) # "low", "medium", "high" 중 하나 보장
print(result.data.growth_rate) # float 타입 보장
print(result.data.action_items) # list[str] 타입 보장각 필드에 붙은 검증 옵션이 핵심입니다.
| 코드 요소 | 역할 |
|---|---|
Field(ge=0) |
음수 매출 원천 차단 |
Field(pattern="^(low|medium|high)$") |
허용된 값(low, medium, high)만 통과 |
result_type=SalesReport |
LLM 출력 전체를 Pydantic으로 검증 |
deps_type=AnalyticsDeps |
DB 연결을 에이전트 로직과 분리 |
예시 2: Human-in-the-Loop 승인 워크플로우
민감한 작업(이체, 삭제, 배포 등)에서 "에이전트가 자율 실행할 상황"과 "사람이 개입해야 할 상황"을 명확히 구분하고 싶을 때, 구조화 출력 패턴이 깔끔한 해법이 됩니다. requires_approval 필드를 모델이 비즈니스 로직에 따라 직접 채우게 해서 코드 레벨에서 분기를 처리하는 방식입니다.
from pydantic import BaseModel
from pydantic_ai import Agent
class TransferRequest(BaseModel):
from_account: str
to_account: str
amount: float
reason: str
requires_approval: bool
agent = Agent(
model="anthropic:claude-3-5-sonnet-latest",
result_type=TransferRequest,
system_prompt="""
Process transfer requests. For amounts over 1,000,000 KRW,
set requires_approval to True.
"""
)
result = await agent.run(
"계좌 A에서 계좌 B로 150만원 이체 처리해줘"
)
transfer = result.data
if transfer.requires_approval:
# 사람이 개입해야 하는 시점을 타입으로 명확히 표현
await request_human_approval(transfer)
else:
await execute_transfer(transfer)requires_approval 필드 하나로 "자동 처리"와 "사람이 확인해야 하는 케이스"를 코드 레벨에서 명확히 구분할 수 있습니다. LLM 응답 텍스트에서 "승인 필요"라는 단어를 파싱해서 분기하던 시절에는 "승인이 필요할 것 같습니다"처럼 애매한 표현이 내려오면 어떻게 처리해야 할지 매번 고민이었는데, 이 구조에서는 bool 타입이 그 모호함을 없애줍니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 타입 안전성 | 개발 중 타입 오류 23건 추가 감지 (LangGraph, CrewAI 동일 기능 구현 비교 기준) |
| 코드 간결성 | 동일 기능 기준 PydanticAI 160줄 vs LangGraph 280줄 vs CrewAI 420줄 |
| 테스트 용이성 | 의존성 주입 구조로 실제 API 없이 단위 테스트 가능 |
| 모델 무관 | OpenAI, Anthropic, Gemini, DeepSeek 등 주요 모델 전부 지원 |
| 내구성 | API 장애·재시작에도 진행 상태를 보존하는 Durable Agent 지원 |
| 무료 오픈소스 | MIT 라이선스, LLM API 비용 외 추가 비용 없음 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 멀티에이전트 제한 | 역할 기반 멀티에이전트 시스템 미지원 | CrewAI 또는 LangGraph와 혼합 사용 |
| 생태계 규모 | LangChain 300+ 통합 대비 상대적으로 작은 서드파티 에코시스템 | MCP 통합으로 외부 툴 연동 확장 |
| 복잡한 상태 관리 | LangGraph 수준의 체크포인팅·워크플로우 상태 관리는 수동 구현 필요 | 복잡한 워크플로우는 LangGraph와 조합 |
| 커뮤니티 자료 | LangChain 대비 레퍼런스·예제 상대적으로 적음 | 공식 문서 품질이 높아 대체 가능 |
멀티에이전트 제한이 가장 아팠던 순간은 역할이 다른 에이전트 세 개를 연결해서 오케스트레이션하려 할 때였습니다. 결국 그 부분만 LangGraph로 처리하고, 개별 에이전트 로직은 PydanticAI로 유지하는 혼합 구조로 해결했습니다. 두 프레임워크가 서로 잘 어울리는 편이라 조합 자체는 어렵지 않습니다.
Durable Agent: 에이전트 실행 중 API 장애나 서버 재시작이 발생해도 진행 중이던 상태를 체크포인트에 저장해두고 이어서 실행할 수 있는 기능입니다. 장시간 실행되는 배치 작업이나 복잡한 멀티스텝 에이전트에 특히 유용합니다.
실무에서 가장 흔한 실수
-
result_type없이 에이전트를 만들어두는 것 — 타입 안전성이 PydanticAI의 핵심인데,result_type을 생략하면 그냥 문자열을 돌려받는 일반 LLM 호출과 다를 바 없습니다. BaseModel부터 설계하는 것이 좋습니다. -
도구 docstring을 대충 작성하는 것 — LLM이 어떤 상황에서 어떤 도구를 써야 할지 판단하는 근거가 docstring입니다. "데이터 조회" 같은 모호한 설명보다 "서울 지역의 2024년 4분기 판매량을 날짜 범위로 조회합니다"처럼 구체적으로 작성할수록 도구 선택 정확도가 올라갑니다.
-
의존성 주입을 쓰지 않고 전역 변수로 DB 연결을 넘기는 것 — 처음엔 편해 보이지만, 테스트 코드에서 실제 DB를 mock으로 교체하는 게 불가능해집니다.
deps_type과RunContext를 처음부터 쓰는 게 나중에 훨씬 편합니다.
마치며
LLM 응답을 '믿고 파싱'하는 대신 '증명하고 사용'하는 패턴 — 이것이 PydanticAI가 제안하는 변화입니다. 금융, 의료, 보안처럼 데이터 무결성이 중요한 도메인이거나, 빠르게 에이전트를 구축하고 테스트 가능한 구조를 원하는 팀이라면 시작해볼 만합니다.
지금 바로 시작할 수 있는 3단계:
pip install pydantic-ai— 설치는 한 줄입니다.BaseModel하나 정의하고Agent(result_type=YourModel)로 실행해보면 LLM 응답이 타입으로 묶이는 감각을 바로 얻을 수 있습니다.- 기존 LangChain 코드가 있다면 가장 단순한 체인 하나를 재구현해서 코드량과 타입 오류 감지 시점의 차이를 직접 비교해볼 수 있습니다.
참고 자료
- Pydantic AI 공식 문서 | ai.pydantic.dev
- Pydantic AI V1 릴리즈 노트 | pydantic.dev
- GitHub - pydantic/pydantic-ai
- Build Type-Safe LLM Agents | Real Python
- Pydantic AI Beginner's Guide | DataCamp
- PydanticAI v1 프레임워크 비교 | AgentMarketCap
- Pydantic AI vs LangGraph | ZenML
- 프로덕션에서 PydanticAI 활용 | DEV Community
- Pydantic MCP 통합 발표 | pydantic.dev
- 에이전트 프레임워크 선택 가이드 | Speakeasy