Featured Post

PydanticAI로 타입 안전한 AI 에이전트 만들기, 20년 개발자의 실전 활용법과 꿀팁

PydanticAI - 타입안전 에이전트 관련 이미지

요즘 제가 PydanticAI에 꽂힌 이유

요즘 개발하다 보면 AI 에이전트 이야기를 정말 자주 하게 되죠. 저도 처음엔 “또 새로운 프레임워크 하나 나왔구나” 정도로 봤어요. 그런데 팀에서 RAG 파이프라인을 만지다 보니, 어느 순간 이런 생각이 딱 들더라고요. LLM이 말해주는 결과를 그대로 믿고 서비스에 넣는 건 꽤 위험하겠다는 생각이요.

예를 들면 이런 거예요. 사용자가 “서울 내일 날씨 알려줘”라고 물었고, 모델이 그럴듯하게 “맑고 기온은 22도입니다”라고 답해요. 사람 입장에서는 괜찮아 보이죠. 그런데 백엔드 코드에서는 temperature, condition, humidity 같은 필드가 정확히 필요하거든요. 하나라도 빠지면 바로 파싱 에러가 나거나, 더 무섭게는 이상한 값이 조용히 들어갑니다. 이게 프로덕션에서 터지면 참 난감해요. 로그를 보면서 커피만 식어갑니다.

저는 오래전부터 Pydantic을 꽤 좋아했습니다. FastAPI랑 같이 쓰면 입력값 검증도 깔끔하고, 모델 구조도 눈에 잘 들어오니까요. 그래서 PydanticAI를 처음 만졌을 때도 느낌이 괜찮았어요. 뭐랄까, 기존에 쓰던 습관을 크게 버리지 않아도 AI 에이전트 쪽으로 자연스럽게 넘어갈 수 있는 느낌이었습니다.

이 글은 거창한 튜토리얼이라기보다는, 제가 실제로 AI 바이브 코딩하면서 PydanticAI를 어떻게 써봤는지, 어디서 삽질했는지, 어떤 부분이 진짜 쓸 만했는지 편하게 풀어보는 글입니다. 친구한테 “야, 이거 한번 써봐. 근데 이 부분은 조심하고”라고 말하는 마음으로 적어볼게요.

작은 예제로 보는 PydanticAI 에이전트 흐름

기본 뼈대는 SystemPrompt, UserMessage, Tool 조합이에요

PydanticAI를 처음 볼 때 너무 어렵게 생각할 필요는 없어요. 제가 느끼기엔 기본 구조는 꽤 단순합니다. 시스템 프롬프트로 역할을 정하고, 사용자 입력을 받고, 필요하면 Tool을 호출하고, 결과는 Pydantic 모델로 검증한다. 이 흐름이에요.

아래 코드는 제가 테스트용으로 자주 만들어보는 날씨 조회 에이전트의 축소 버전입니다. 실제 서비스 코드는 이것보다 조금 더 지저분합니다. API 키도 있고, 타임아웃도 있고, 캐시도 있고요. 그런데 핵심만 보면 이 정도예요.

from pydantic_ai import Agent, RunContext
from pydantic import BaseModel

class WeatherInput(BaseModel):
    city: str
    date: str

class WeatherOutput(BaseModel):
    temperature: float
    condition: str
    humidity: int

weather_agent = Agent[WeatherInput, WeatherOutput](
    model='openai/gpt-4o-mini',
    system_prompt="주어진 도시와 날짜의 날씨 정보를 JSON 형태로 반환하세요."
)

@weather_agent.tool
async def fetch_weather(ctx: RunContext, city: str) -> dict:
    # 여기서 실제 API 호출을 붙입니다. 예를 들면 OpenWeatherMap 같은 서비스요.
    return {
        "temperature": 22.5,
        "condition": "clear",
        "humidity": 60
    }

result = await weather_agent.run("서울", date="2025-04-15")
print(result.data)  # WeatherOutput 객체

여기서 제가 제일 마음에 들었던 건 WeatherInput과 WeatherOutput이 그냥 문서용 타입이 아니라 실제 검증 기준으로 동작한다는 점이었어요. 예전에는 “모델이 알아서 JSON 잘 주겠지” 하고 믿었다가 한두 번 크게 데인 적이 있거든요.

예를 들어 humidity가 숫자로 와야 하는데 모델이 "높음" 같은 문자열로 준다거나, temperature 필드가 아예 빠지면 Pydantic이 바로 잡아줍니다. 에러가 나긴 나요. 그런데 좋은 에러입니다. 애매하게 지나가는 것보다 훨씬 낫습니다.

예전 방식과 비교하면 차이가 꽤 크게 느껴집니다

예전에는 LLM 응답을 받아서 json.loads()로 파싱하고, 키가 있는지 하나씩 확인하고, 없으면 또 예외 처리하고 그랬잖아요. 저도 그렇게 많이 짰습니다. 사실 대부분 그렇게 시작하죠.

# 예전에 자주 쓰던 방식
response = llm("서울 날씨 알려줘")

try:
    data = json.loads(response)
    temp = data['temperature']
except (json.JSONDecodeError, KeyError) as e:
    log.error(f"파싱 실패: {e}")
    # 여기서부터 예외 처리 지옥이 시작됩니다.

이 코드가 나쁘다는 말은 아니에요. 빠르게 프로토타입 만들 때는 충분히 쓸 만합니다. 저도 바이브 코딩할 때는 일단 이렇게 시작할 때가 많아요. 문제는 이 코드가 서비스 코드로 오래 살아남을 때입니다. 필드 하나 늘어나고, 응답 포맷 바뀌고, 모델을 바꾸고, 프롬프트 수정하고. 그러다 보면 어느 순간 “이거 누가 책임지지?” 싶은 코드가 됩니다.

PydanticAI를 쓰면 적어도 출력 구조에 대한 책임을 코드 안에 명확하게 둘 수 있어요. LLM이 자유롭게 말하더라도, 최종적으로 우리 시스템이 받아들이는 데이터는 WeatherOutput이라는 문을 통과해야 합니다. 저는 이 느낌이 좋더라고요. AI가 아무리 똑똑해도, 서비스의 현관문은 개발자가 지켜야 한다고 생각합니다.

비교 항목 기존 방식 PydanticAI 방식
타입 검증 응답을 받은 뒤 직접 확인해야 함 Pydantic 모델 기준으로 입력과 출력을 검증
에러 확인 KeyError: 'temperature'처럼 맥락이 부족할 때가 많음 어떤 필드가 빠졌는지, 타입이 왜 틀렸는지 비교적 선명하게 보임
리팩토링 JSON 키를 바꾸면 관련 코드를 뒤져야 함 모델 클래스 중심으로 구조를 정리할 수 있음
IDE 도움 문자열 키 기반이라 자동완성이 약함 모델 객체라 자동완성과 타입 힌트가 꽤 든든함
운영 안정성 잘못된 응답이 뒤늦게 문제를 만들 수 있음 입구에서 걸러낼 수 있어 장애 원인 추적이 쉬워짐

실제로 써보면서 좋았던 부분들

Validation Error는 귀찮지만, 알고 보면 꽤 고마운 친구입니다

처음 PydanticAI를 붙였을 때는 솔직히 Validation Error가 자주 보여서 좀 귀찮았습니다. “아니, 그냥 적당히 받아주면 안 되나?” 싶은 순간도 있었어요. 그런데 며칠 써보니까 생각이 바뀌었습니다. 이 에러가 없었으면 이상한 데이터가 DB까지 들어갔을 수도 있겠더라고요.

특히 LLM 응답은 항상 일정하지 않잖아요. 같은 프롬프트를 줘도 어느 날은 필드를 잘 채우고, 어느 날은 설명 문장을 섞고, 어떤 모델은 숫자를 문자열로 줍니다. 사람처럼 말하는 모델을 시스템처럼 쓰려면 어딘가에서 선을 그어야 하는데, 그 역할을 Pydantic 모델이 해주는 셈입니다.

try:
    result = await weather_agent.run(...)
except PydanticValidationError as e:
    print(f"Validation 실패: {e.errors()}")
    # 예: [{'loc': ('temperature',), 'msg': 'Field required', ...}]

    # 여기서 다른 모델로 재시도하거나,
    # 프롬프트를 조금 더 엄격하게 바꿔 다시 호출할 수 있습니다.

저는 운영 코드에서는 이런 식으로 처리했습니다.

    • Validation Error 내용은 반드시 구조화해서 로그로 남깁니다. 나중에 어떤 필드에서 자주 실패하는지 보려면 이게 정말 중요해요.
    • 한 번 정도는 같은 모델로 재시도합니다. LLM은 가끔 두 번째에 멀쩡한 답을 주기도 합니다. 사람도 그렇잖아요.
    • 반복 실패하면 더 강한 모델이나 보수적인 프롬프트로 fallback합니다. 비용은 조금 들지만 장애보다는 쌉니다.
    • 실패 응답 원문도 따로 저장합니다. 이건 프롬프트 개선할 때 아주 좋은 재료가 됩니다.

여기서 중요한 건 에러를 없애는 게 아니라, 에러가 났을 때 시스템이 당황하지 않게 만드는 것입니다. 20년 개발하면서 느낀 건데, 좋은 시스템은 에러가 안 나는 시스템이 아니라 에러가 나도 침착한 시스템이더라고요.

Nested Model을 쓰면 업무 데이터가 훨씬 예쁘게 정리됩니다

간단한 날씨 예제는 사실 좀 심심합니다. 실제 업무에서는 데이터가 대부분 중첩되어 있죠. 주문 안에 상품이 있고, 상품 안에 옵션이 있고, 고객 정보가 붙고, 배송지가 붙고, 쿠폰이 붙고. 어느 순간 JSON이 미로처럼 됩니다.

이럴 때 Nested Model이 빛을 봅니다. Pydantic의 장점이 그대로 살아나요. BaseModel 안에 또 다른 BaseModel을 넣고, 리스트로 감싸고, 필요한 값은 타입으로 강제할 수 있습니다.

class Item(BaseModel):
    name: str
    price: float
    quantity: int

class Order(BaseModel):
    order_id: str
    items: list[Item]
    total: float

order_agent = Agent[str, Order](
    model='openai/gpt-4o',
    system_prompt="사용자의 주문 내역을 구조화된 JSON으로 변환하세요."
)

result = await order_agent.run(
    "사과 3개에 2000원, 바나나 1개에 1000원 주문할게요"
)

print(result.data.items[0].name)  # "사과"

이런 구조는 단순히 코드가 예뻐지는 정도가 아닙니다. 실제로 유지보수가 편해져요. 예를 들어 나중에 상품에 discount_rate가 추가된다면 Item 모델에 필드 하나 넣으면 됩니다. 그 필드를 어디서 쓰는지 IDE가 따라와 줍니다. 이게 은근히 큽니다.

제가 한 번은 상품 옵션이 꽤 복잡한 커머스성 데이터를 만진 적이 있었는데요. 옵션 그룹, 옵션 값, 추가 금액, 재고 여부, 노출 여부까지 들어가니까 그냥 dict로는 감당이 안 되더라고요. 처음엔 “대충 JSON으로 처리하지 뭐” 했는데, 두 시간 뒤에 제 자신을 탓했습니다. 결국 Nested Model로 다시 잡았고, 그 뒤로 테스트 코드 짜는 속도가 확 빨라졌어요.

상황 dict 중심 코드 Nested Model 코드
필드 추가 어디서 쓰는지 검색부터 해야 함 모델 수정 후 타입 힌트로 추적 가능
테스트 작성 샘플 dict를 계속 직접 만들어야 함 모델 기준으로 테스트 데이터 관리 가능
버그 추적 어느 단계에서 값이 깨졌는지 찾기 어려움 검증 실패 위치가 비교적 분명함

Streaming Response는 Optional 설계가 은근히 중요합니다

AI 에이전트를 만들다 보면 스트리밍 욕심이 생깁니다. 특히 사용자가 기다리는 화면에서는 답변이 한 번에 툭 나오는 것보다 조금씩 흘러나오는 게 훨씬 자연스럽죠. 저도 챗봇 UI 붙일 때는 거의 스트리밍을 먼저 생각합니다.

PydanticAI에서도 StreamedAgentResponse를 활용할 수 있습니다. 다만 여기서 한 가지 감각이 필요해요. 스트리밍 중간에는 아직 모든 필드가 다 채워지지 않았을 수 있습니다. 완성된 객체를 기대하면 중간중간 삐걱거릴 수 있어요.

async for chunk in weather_agent.run_stream("서울", date="2025-04-15"):
    print(chunk.temperature)

이 코드만 보면 간단해 보이는데, 실제로는 temperature가 아직 없을 수도 있습니다. 그래서 저는 스트리밍용 출력 모델은 조금 더 느슨하게 잡는 편입니다. 예를 들어 일부 필드는 Optional로 두고, 기본값을 None으로 둡니다.

from typing import Optional
from pydantic import BaseModel

class StreamingWeatherOutput(BaseModel):
    temperature: Optional[float] = None
    condition: Optional[str] = None
    humidity: Optional[int] = None
    message: Optional[str] = None

처음부터 너무 엄격하게 잡으면 스트리밍의 장점이 줄어들고, 너무 느슨하게 잡으면 타입 안전성이 흐려집니다. 저는 그래서 화면에 바로 보여줄 중간 응답 모델과 최종 저장용 모델을 따로 두는 방식을 좋아합니다. 약간 번거롭긴 한데, 나중에 마음이 편해요.

    • 화면 표시용 모델은 Optional을 넉넉하게 둡니다.
    • 최종 저장용 모델은 엄격하게 둡니다.
    • 스트리밍 중간 상태와 완료 상태를 같은 기준으로 보지 않습니다.
    • 사용자에게 보여주는 메시지와 시스템이 저장하는 데이터는 분리합니다.

이건 AI 개발뿐 아니라 일반 백엔드에서도 비슷합니다. 화면은 조금 부드럽게, 저장은 단단하게. 저는 이 원칙을 꽤 오래 가져가고 있습니다.

제가 실제로 잡아본 프로젝트 구조

폴더 구조는 사람마다 취향이 정말 많이 갈리죠. 저도 예전에는 한 파일에 다 넣고 시작했다가, 기능이 커지면 그때 나누는 편이었습니다. 그런데 AI 에이전트는 프롬프트, Tool, 모델, 테스트가 금방 섞여요. 그래서 처음부터 어느 정도는 나눠두는 게 낫더라고요.

제가 실험용으로 자주 쓰는 구조는 대략 이런 느낌입니다.

my_agent/
├── agent/
│   ├── __init__.py
│   ├── weather.py          # WeatherAgent, WeatherInput, WeatherOutput
│   └── order.py            # OrderAgent, Item, Order 모델
├── tools/
│   ├── openweather.py      # 실제 외부 API 호출
│   └── database.py         # DB 조회 관련 Tool
├── prompts/
│   ├── weather_system.txt  # 날씨 에이전트 시스템 프롬프트
│   └── order_system.txt    # 주문 분석용 시스템 프롬프트
├── main.py                 # FastAPI 앱, agent.run() 호출
├── tests/
│   ├── test_weather.py     # 날씨 에이전트 테스트
│   └── test_order.py       # 주문 에이전트 테스트
└── pydantic_agent.toml     # 설정 파일이 필요할 때 사용

제가 여기서 중요하게 보는 건 프롬프트를 코드에서 분리하는 것입니다. 처음에는 문자열로 바로 넣어도 괜찮아요. 그런데 프롬프트가 길어지고, 예시 JSON이 들어가고, 조건이 붙기 시작하면 코드가 지저분해집니다. 프롬프트만 따로 파일로 빼두면 리뷰하기도 편하고, 운영 중에 어떤 프롬프트가 문제였는지 추적하기도 좋습니다.

그리고 Tool도 꼭 따로 빼는 편이 좋아요. 에이전트 파일 안에 API 호출 코드까지 다 넣어버리면 테스트할 때 피곤합니다. 외부 API는 느리고, 가끔 죽고, 비용도 들고, 테스트를 불안정하게 만들거든요.

테스트 코드는 생각보다 빨리 투자 효과가 납니다

AI 에이전트 테스트라고 하면 조금 막막하게 들릴 수 있어요. “LLM 응답이 매번 다른데 이걸 어떻게 테스트하지?” 싶은 거죠. 저도 처음엔 그랬습니다. 그런데 모든 걸 LLM까지 붙여서 테스트할 필요는 없습니다.

제가 주로 보는 테스트 포인트는 이런 쪽입니다.

    • Pydantic 모델이 정상 데이터와 비정상 데이터를 잘 구분하는지
    • Tool 함수가 외부 API 응답을 우리 모델 형태로 잘 바꾸는지
    • 에이전트가 실패했을 때 fallback 로직이 도는지
    • 프롬프트 예시와 출력 모델 필드명이 서로 맞는지
    • 운영에서 자주 나온 실패 응답을 회귀 테스트로 막을 수 있는지

이렇게 나눠서 보면 테스트가 그렇게 어렵지 않습니다. 진짜 LLM 호출은 통합 테스트에서 제한적으로만 돌리고, 대부분은 mock으로 막아도 충분합니다. 저는 이런 식으로 해두니까 에이전트 코드를 고칠 때 훨씬 덜 불안하더라고요.

제가 겪은 흔한 실패와 작은 해결책들

필드명이 프롬프트와 모델에서 살짝 다르면 계속 실패합니다

은근히 자주 나오는 실수입니다. Pydantic 모델에는 temperature라고 되어 있는데, 프롬프트 예시에는 temp라고 적어둔다든지요. 사람은 같은 뜻이라고 생각하지만, 시스템은 그렇게 너그럽지 않습니다.

이럴 때는 프롬프트 안에 출력 예시를 아주 명확하게 넣어주는 게 좋습니다. 저는 보통 아래처럼 필드명을 그대로 박아둡니다.

{
  "temperature": 22.5,
  "condition": "clear",
  "humidity": 60
}

그리고 가능하면 필드 설명도 Pydantic 모델 쪽에 넣습니다. 모델과 프롬프트가 따로 놀지 않게 만드는 거죠. 이건 작은 습관인데 효과가 꽤 큽니다.

너무 엄격한 모델은 개발 초반에 발목을 잡기도 합니다

타입 안전성이 중요하다고 했지만, 처음부터 모든 걸 너무 빡빡하게 잡으면 개발 속도가 확 떨어질 때가 있습니다. 특히 AI 바이브 코딩할 때는 일단 흐름을 보는 게 중요하잖아요. 이 에이전트가 일을 할 수 있는지, Tool 호출이 자연스러운지, 출력 구조가 말이 되는지 빨리 확인해야 하니까요.

그래서 저는 초반에는 조금 유연하게 갑니다. 예를 들어 예상 못 한 필드가 들어올 수 있는 상황이면 model_config = {"extra": "allow"} 같은 설정을 잠깐 쓰기도 합니다. 대신 이걸 오래 방치하면 안 됩니다. 프로토타입이 끝나고 서비스 코드로 넘어갈 때는 다시 단단하게 조이는 편이 좋아요.

단계 제가 선호하는 방식 이유
프로토타입 모델을 조금 유연하게 둠 흐름과 가능성을 빨리 확인하기 위해
내부 테스트 필수 필드와 타입을 점점 엄격하게 조정 실패 패턴을 보면서 모델을 다듬기 위해
프로덕션 출력 모델을 명확하고 보수적으로 유지 잘못된 데이터 유입을 막기 위해

LLM을 너무 믿으면 로그가 복수합니다

이건 농담처럼 말하지만 진심입니다. LLM은 똑똑하지만, 항상 우리 서비스의 사정을 아는 건 아닙니다. 프롬프트에 “반드시 JSON만 반환하세요”라고 써도 가끔 친절한 설명을 붙입니다. “물론입니다. 아래는 JSON입니다.” 같은 문장이 섞이면 파서는 울죠.

그래서 저는 출력 형식이 중요한 작업에서는 아래 기준을 거의 습관처럼 둡니다.

    • 출력 예시를 프롬프트에 넣습니다.
    • 필드명을 Pydantic 모델과 100% 맞춥니다.
    • 설명 문장을 붙이지 말라고 명확히 적습니다.
    • 실패한 원문 응답은 꼭 저장합니다.
    • 자주 실패하는 케이스는 테스트 데이터로 승격합니다.

뻔해 보이지만, 이런 기본기가 운영에서 차이를 만듭니다. AI 개발은 신기술 같지만, 막상 현장에서 버티게 해주는 건 결국 오래된 개발 습관이더라고요. 로그, 테스트, 타입, fallback. 참 재미없어 보이는데 제일 오래 갑니다.

LangChain이나 LlamaIndex와 비교했을 때 제 느낌

이 부분은 제 개인적인 취향이 좀 들어갑니다. 저는 PydanticAI가 LangChain이나 LlamaIndex보다 훨씬 직관적으로 느껴졌습니다. 물론 LangChain이나 LlamaIndex가 가진 장점도 분명합니다. 생태계도 크고, 다양한 기능이 있고, 예제도 많죠.

그런데 제가 백엔드 개발자 입장에서 에이전트를 만들 때는, 너무 많은 추상화가 오히려 부담스러울 때가 있었습니다. 내부에서 무슨 일이 일어나는지 한 번 더 따라가야 하고, 버전이 바뀌면 예제가 안 맞는 경우도 있었고요. 반면 PydanticAI는 “입력 모델, 출력 모델, Tool, 실행”의 느낌이 비교적 단순했습니다.

특히 이미 FastAPI와 Pydantic을 쓰고 있는 팀이라면 진입 장벽이 낮습니다. 팀원들에게 설명하기도 편해요. “LLM 응답도 API Request Body 검증하듯이 모델로 받자”라고 말하면 대체로 바로 이해합니다.

도구 제가 느낀 장점 조심할 점
PydanticAI 타입 모델 중심이라 백엔드 개발자에게 익숙함 아직 변화가 빠르고 버전 확인이 필요함
LangChain 생태계와 예제가 많고 다양한 패턴을 지원함 추상화가 많아 단순한 작업에는 무겁게 느껴질 수 있음
LlamaIndex 문서 검색, 인덱싱, RAG 쪽에 강점이 있음 에이전트 출력 타입 관리만 놓고 보면 별도 설계가 필요할 수 있음

그래서 PydanticAI, 누구에게 잘 맞을까요?

솔직히 말하면 PydanticAI가 모든 상황의 정답은 아닙니다. 아직 버전이 빠르게 변하는 편이고, 레퍼런스를 보면서 맞춰가야 할 때도 있습니다. 그리고 에이전트가 복잡해질수록 결국 프롬프트 설계가 중요해집니다. Pydantic 모델만 잘 만든다고 모든 문제가 사라지진 않아요.

그래도 저는 이런 분들에게는 꽤 추천하고 싶습니다.

    • Python 백엔드 개발을 오래 해왔고 Pydantic이 익숙한 분
    • FastAPI 기반 프로젝트에 AI 기능을 붙이려는 분
    • LLM 응답을 그냥 문자열로 다루는 게 불안한 분
    • AI 에이전트 결과를 DB나 외부 시스템에 안전하게 저장해야 하는 분
    • 바이브 코딩은 하되, 운영 코드에서는 최소한의 질서를 지키고 싶은 분

제가 요즘 느끼는 건 이렇습니다. AI 바이브 코딩은 확실히 개발을 즐겁게 만들어줍니다. 예전 같으면 반나절 걸렸을 실험을 한두 시간 만에 해보기도 하고, 낯선 라이브러리도 훨씬 빨리 만져볼 수 있어요. 그런데 프로덕션으로 넘어가는 순간에는 다시 개발자의 오래된 감각이 필요합니다. 타입을 잡고, 실패를 기록하고, 테스트를 만들고, 데이터 경계를 세우는 감각이요.

PydanticAI는 그 경계를 세우는 데 꽤 괜찮은 도구였습니다. LLM의 자유로움과 백엔드 시스템의 단단함 사이에서 적당한 손잡이를 하나 만들어주는 느낌이랄까요.

AI 에이전트를 처음 만들고 있다면 너무 큰 구조부터 잡으려고 하지 않아도 됩니다. 작은 출력 모델 하나 만들고, 간단한 Tool 하나 붙여보고, Validation Error를 일부러 한번 내보세요. 그때 감이 옵니다. “아, 이래서 타입 안전성이 필요하구나” 하고요.

저도 아직 계속 삽질 중입니다. 그래도 이런 삽질은 꽤 재미있어요. 예전에는 에러 로그 보면 한숨부터 나왔는데, 요즘은 “이걸 또 어떻게 다듬어볼까” 하는 생각이 먼저 듭니다. 다음에 더 괜찮은 패턴을 찾으면 또 편하게 공유해볼게요.

댓글