Pydantic AI 기본 정리


공식 문서: https://pydantic.dev/docs/ai/

Pydantic AI이란?

Pydantic AI는 Pydantic 팀에서 만든 Python용 LLM Agent 프레임워크다.

쉽게 말하면 LLM을 그냥 한 번 호출하는 수준에서 끝나는 게 아니라, 아래 같은 흐름을 하나의 Agent로 묶어서 다룰 수 있게 해준다.

사용자 입력
   ↓
Agent
   ↓
LLM 응답 또는 tool 호출
   ↓
최종 결과 검증
   ↓
result.output

특히 Pydantic답게 타입힌트와 검증을 적극적으로 쓴다. 그래서 문자열로 받은 응답을 직접 파싱하는 대신, 원하는 출력 형태를 모델로 정의해두고 검증된 결과를 받을 수 있다.

정리하면 이런 느낌이다.

특징 설명
Agent 중심 모델, 지시문, tool, 출력 타입을 하나로 묶는다.
타입 기반 deps_type, output_type 같은 타입 정보를 적극적으로 쓴다.
구조화된 응답 Pydantic 모델로 최종 응답 형태를 고정할 수 있다.
모델 교체 가능 OpenAI, Anthropic, Google, Ollama 등 여러 provider를 쓸 수 있다.

설치

Python 3.10 이상이 필요하다.

pip install pydantic-ai
# uv add pydantic-ai

특정 provider만 가볍게 쓰고 싶다면 pydantic-ai-slim도 있다. 예를 들어 OpenAI만 쓸 거라면 이런 식이다.

pip install "pydantic-ai-slim[openai]"

API 키 세팅

.env같은 환경변수를 설정하는 곳에 API 키를 넣어둔다.

export OPENAI_API_KEY="..."
export ANTHROPIC_API_KEY="..."

모델 이름은 provider:model 형태로 적는다.

'openai:gpt-5.2'
'anthropic:claude-sonnet-4-6'

실제로 어떤 모델을 쓸지는 본인 계정에서 사용 가능한 모델에 맞춰 바꾸면 된다.

예제

일단 실행만 해보면 구조는 꽤 단순하다.

from pydantic_ai import Agent

agent = Agent(
    'openai:gpt-5.2',
    instructions='짧게 한 문장으로 답해줘.',
)

result = agent.run_sync('Pydantic AI에 대해 설명해봐')
print(result.output)

여기서 중요한 건 세 가지다.

  1. Agent를 만든다.
  2. run_sync()로 실행한다.
  3. 결과는 result.output에서 꺼낸다.

비동기로 돌릴 때는 run_sync() 대신 await agent.run(...)을 쓰면 된다.

result = await agent.run('Pydantic AI에 대해 설명해봐')
print(result.output)

Agent에서 자주 보는 것들

이름 의미
instructions LLM에게 주는 기본 지시문
tool LLM이 필요할 때 호출할 수 있는 함수
deps tool이나 동적 지시문에 넘기는 의존성
output_type 최종 응답의 타입

instructions

instructions는 Agent의 기본 역할이나 답변 방식을 정하는 지시문이다.

from pydantic_ai import Agent

agent = Agent(
    'openai:gpt-5.2',
    instructions='너는 친절한 Python 튜터야. 초보자도 이해할 수 있게 설명해줘.',
)

고정된 지시문은 이렇게 생성자에 넣으면 된다.

상황에 따라 달라지는 지시문이 필요하면 @agent.instructions를 쓸 수 있는데, 이건 나중에 deps랑 같이 다시 보는 게 좋을 것 같다.

tool

LLM이 직접 알 수 없는 정보는 tool로 연결한다.

예를 들어 상품 가격, 주문 상태, DB에 있는 사용자 정보 같은 건 모델이 원래 알 수 없다. 이런 값을 그냥 물어보면 모델이 그럴듯하게 지어낼 수도 있다.

그래서 실제 조회 로직은 Python 함수로 만들고, Agent에게 “필요하면 이 함수를 호출해도 된다”고 알려준다. 이 함수가 tool이다.

아래 예제는 상품 가격을 조회하고, 회원 등급에 따라 할인까지 계산하는 Agent다.

from dataclasses import dataclass
from pydantic_ai import Agent, RunContext


@dataclass
class Deps:
    membership: str

agent = Agent(
    'openai:gpt-5.2',
    deps_type=Deps,
    instructions='너는 상품 가격과 회원 할인을 안내하는 상담원이야.',
)

prices = {
    '키보드': 120000,
    '마우스': 45000,
    '모니터': 320000,
}

def fetch_price_from_db(product_name: str) -> int:
    """예제용 함수. 실제로는 DB나 외부 API를 조회하는 코드가 들어간다."""
    return prices.get(product_name, 0)

def fetch_discount_rate(membership: str) -> float:
    """예제용 함수. 실제로는 회원 DB나 정책 서버에서 할인율을 가져온다."""
    discount_rates = {
        'basic': 0,
        'gold': 0.1,
        'vip': 0.2,
    }
    return discount_rates[membership]

@agent.tool_plain
def get_product_price(product_name: str) -> int:
    """상품 이름으로 현재 가격을 조회한다."""
    return fetch_price_from_db(product_name)

@agent.tool
def get_discount_rate(ctx: RunContext[Deps]) -> float:
    """현재 사용자의 회원 등급에 맞는 할인율을 조회한다."""
    return fetch_discount_rate(ctx.deps.membership)

result = agent.run_sync(
    '키보드랑 마우스 같이 사면 내 회원 할인 받아서 20만원 안 넘을까?',
    deps=Deps(membership='gold'),
)

print(result.output)

여기서 get_product_price()get_discount_rate() 함수 자체는 AI가 아니다. 그냥 실행 가능한 Python 코드다. 지금은 예제를 짧게 보려고 dict를 썼지만, 실제 서비스라면 DB 조회나 외부 API 호출이 들어갈 자리다.

AI가 하는 일은 이 함수를 직접 구현하는 게 아니라, 사용자의 자연어 질문을 보고 tool 호출이 필요한지 판단하는 것이다. 위 코드는 대략 이런 흐름으로 돌아간다.

  1. 사용자가 키보드랑 마우스 같이 사면 내 회원 할인 받아서 20만원 안 넘을까?라고 묻는다.
  2. LLM은 키보드, 마우스 가격과 현재 회원 할인율이 필요하다고 판단한다.
  3. get_product_price(product_name='키보드')를 호출한다.
  4. get_product_price(product_name='마우스')를 호출한다.
  5. get_discount_rate()를 호출해서 gold 등급의 할인율을 가져온다.
  6. tool 결과를 바탕으로 총액, 할인 금액, 최종 금액을 계산하고 20만원 이하인지 답한다.

만약 코드에서 바로 get_product_price('키보드')처럼 호출할 거라면 AI가 필요 없다. 하지만 사용자는 보통 키보드랑 마우스 같이 사면 20만원 넘나?처럼 자연어로 묻기 때문에, 그 문장을 해석해서 필요한 tool 호출로 바꾸는 데 LLM을 쓰는 것이다.

Pydantic AI에서는 tool을 붙이는 방식이 크게 두 가지다.

방식 언제 쓰는지
@agent.tool_plain 사용자 질문에서 뽑은 인자만 있으면 될 때
@agent.tool deps처럼 실행할 때 따로 넘긴 값이 필요할 때

여기서 말하는 context는 그냥 일반 변수 하나를 뜻하는 게 아니다. ctx: RunContext[Deps]처럼 이번 Agent 실행에 딸려오는 정보 묶음이라고 보면 된다. 그 안에 ctx.deps가 있고, run_sync(..., deps=Deps(...))로 넘긴 값을 tool 안에서 꺼내 쓸 수 있다.

위 예제에서 get_product_price(product_name)은 상품 이름만 있으면 되기 때문에 @agent.tool_plain을 썼다. 반대로 get_discount_rate(ctx)는 사용자의 회원 등급인 ctx.deps.membership이 필요하기 때문에 @agent.tool을 썼다.

쉽게 말하면 tool_plain은 혼자 실행해도 되는 함수에 가깝고, tool은 이번 실행의 사용자 정보나 설정값을 같이 봐야 하는 함수에 가깝다.

# 질문에서 뽑은 값만 사용
get_product_price(product_name='키보드')

# 실행할 때 넘긴 deps 값 사용
ctx.deps.membership  # 'gold'

tool이 명확하게 등록되어 있으면 instructions에 매번 “tool로 확인해라”까지 적지 않아도 된다. 다만 실제 서비스에서 가격, 재고, 주문 상태처럼 틀리면 안 되는 값이라면 가격은 반드시 tool로 확인해줘 같은 규칙을 추가해두는 편이 안전하다.

여기서 docstring도 중요하다. Pydantic AI가 이 설명을 tool 설명으로 넘기기 때문에, 모델이 언제 이 함수를 써야 하는지 이해할 수 있게 적어주는 게 좋다.

그리고 product_name 같은 함수 인자는 사용자 질문에서 모델이 알아서 뽑아 넣는다. 그래서 tool 함수의 이름, 인자 이름, docstring을 너무 대충 적으면 모델이 엉뚱하게 호출할 수 있다.

deps

deps는 Agent를 실행할 때 같이 넘기는 외부 값이다. 쉽게 말하면 “이번 실행은 어떤 사용자/환경에서 돌아가는지”에 대한 정보다.

예를 들어 로그인한 사용자 ID, DB 연결 객체, API client, 설정값처럼 tool 안에서 필요하지만 전역 변수로 두기 애매한 값들이 여기에 들어간다.

회원 등급처럼 사용자마다 달라지는 값도 deps로 넘기기 좋다. 같은 질문이라도 basic 유저인지, gold 유저인지에 따라 tool 결과나 지시문이 달라져야 하기 때문이다.

아래 예시는 depstoolinstructions 둘 다에 어떻게 들어가는지 한 번에 보여준다.

from dataclasses import dataclass
from pydantic_ai import Agent, RunContext

@dataclass
class Deps:
    membership: str

agent = Agent(
    'openai:gpt-5.2',
    deps_type=Deps,
    instructions='너는 상품 가격과 회원 할인을 안내하는 상담원이야.',
)

prices = {
    '키보드': 120000,
    '마우스': 45000,
    '모니터': 320000,
}

discount_rates = {
    'basic': 0,
    'gold': 0.1,
    'vip': 0.2,
}

@agent.instructions
def add_membership_instruction(ctx: RunContext[Deps]) -> str:
    if ctx.deps.membership == 'vip':
        return 'VIP 회원에게는 전용 혜택과 우선 상담 가능 여부도 같이 안내해줘.'

    return '일반 회원에게는 기본 할인 정보만 간단히 안내해줘.'

@agent.tool
def get_discounted_price(ctx: RunContext[Deps], product_name: str) -> int:
    """현재 사용자의 회원 등급을 기준으로 상품 할인 가격을 조회한다."""
    price = prices.get(product_name, 0)
    discount_rate = discount_rates[ctx.deps.membership]
    return int(price * (1 - discount_rate))

result = agent.run_sync(
    '키보드랑 마우스 할인 받으면 얼마야?',
    deps=Deps(membership='gold'),
)

print(result.output)

이 흐름은 이렇게 보면 된다.

  1. run_sync()를 실행할 때 deps=Deps(...)를 같이 넘긴다.
  2. @agent.instructionsctx.deps.membership을 보고 VIP용 또는 일반용 지시문을 추가한다.
  3. @agent.tool이 같은 ctx.deps.membership을 보고 할인율을 계산한다.
  4. LLM은 지시문과 tool 결과를 같이 받아서 최종 답변을 만든다.

즉, deps는 AI에게 직접 보여주고 싶은 값이라기보다, toolinstructions가 같이 참고하는 실행 context에 가깝다.

output_type

LLM 응답을 문자열로만 받으면 후처리가 귀찮아질 때가 많다.

예를 들어 가격 안내 결과에서 최종 금액, 할인 금액, 안내 문구를 따로 쓰고 싶다고 해보자. 문자열 하나로 받으면 다시 파싱해야 한다.

그럴 때 output_type을 지정하면 최종 응답을 Pydantic 모델 형태로 받을 수 있다.

from pydantic import BaseModel, Field
from pydantic_ai import Agent

class PriceAnswer(BaseModel):
    products: list[str]
    total_price: int
    discount_price: int
    final_price: int
    message: str = Field(description='사용자에게 보여줄 짧은 안내 문구')

agent = Agent(
    'openai:gpt-5.2',
    output_type=PriceAnswer,
)

result = agent.run_sync(
    '키보드 12만원, 마우스 4만5천원이고 10% 할인 받으면 최종 금액 알려줘.'
)

print(result.output.products)
print(result.output.total_price)
print(result.output.discount_price)
print(result.output.final_price)
print(result.output.message)

이렇게 해두면 result.output이 그냥 문자열이 아니라 PriceAnswer 객체가 된다.

예를 들어 아래처럼 바로 필드에 접근할 수 있다.

answer = result.output

print(answer.final_price)
print(answer.message)

output_type의 장점은 답변 형식을 코드에서 믿고 다루기 쉬워진다는 점이다. final_priceint여야 하고, productslist[str]여야 한다. 모델이 이상한 형태로 답하면 Pydantic AI가 검증하고, 필요한 경우 다시 요청해서 형식을 맞추려고 한다.

output_type은 “예쁘게 답변해줘”가 아니라, “최종 답변은 이 구조로 줘”에 가깝다.

정리

처음 볼 때는 Pydantic AI의 기능이 많아 보여서 복잡하게 느껴질 수 있는데, 기본 흐름은 생각보다 단순하다.

  1. Agent를 만든다.
  2. instructions로 역할을 정한다.
  3. 필요한 기능은 tool로 연결한다.
  4. 외부 값은 deps로 넘긴다.
  5. 결과 형태가 중요하면 output_type을 쓴다.

Author: Ruby Kim
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Ruby Kim !
Comments
  TOC