A complete introduction to PydanticAI
PydanticAI is a library for building AI agents that leverages Pydantic’s powerful type validation capabilities. This post is a complete introduction to PydanticAI, exploring how to implement an agent using PydanticAI, with detailed code examples.
Recently, the AI field has been shifting its focus from merely relying on pre-trained knowledge to developing AI agents that can independently utilize external tools, retrieve necessary information, and even collaborate with other AI models to achieve specific goals. But how can we implement an AI agent?
Libraries like LangGraph specialize in managing complex workflows, enabling the development of more flexible AI agent applications. Meanwhile, frameworks such as CrewAI provide a more streamlined approach to executing desired tasks.
In this post, we will explore PydanticAI, an AI agent development framework based on Pydantic, a powerful library for data type validation. Effective communication between AI agents requires well-structured input and output formats, and Pydantic’s concise and efficient type validation system makes development much more efficient.
Now, let's dive into how to use PydanticAI!
Installation
You can easily install PydanticAI using pip
:
pip install pydantic-ai
If you're using the uv package manager, you can install it with:
uv add pydantic-ai
Key Concepts of PydanticAI - Agent
The Agent
class is the core component of PydanticAI. Conceptually, an agent acts as a container that includes the following components:
- System Prompt: Defines the agent’s initial instructions and behavior.
- Dependencies: Specifies the required external tools or other agents that the agent relies on.
- Structured Result Type: Ensures that the agent's output follows a predefined schema using Pydantic models.
- Function Tool: Allows the agent to use additional functions for processing or retrieving information.
- LLM Model & Settings: Configures the large language model (LLM) that powers the agent, including parameters like temperature and response limits.
Now, let's explore each component in detail and learn how to define and use an agent in PydanticAI!
Static System Prompt Example
A static system prompt can be defined using the system_prompt
parameter when initializing an Agent
, as shown in the example below.
from pydantic_ai import Agent
agent = Agent(
"openai:gpt-4o-mini",
system_prompt="You are a helpful assistant",
)
result = agent.run_sync("Where does 'hello world' come from?")
print(result.data)
# 'Hello, World!' originated from the 1972 programming language tutorial in
# the book "The C Programming Language" by Brian Kernighan and Dennis Ritchie.
Dynamic System Prompt Example
If you want to insert runtime-determined elements into the system prompt, you can:
- Define a separate function that returns the desired system prompt.
- Wrap this function with
@agent.system_prompt
.
For example, to include the current time in the system prompt, you can do the following:
from pydantic_ai import Agent
from datetime import datetime
agent = Agent(
"openai:gpt-4o-mini",
)
@agent.system_prompt
def system_prompt():
return f"You are a helpful assistant. The current date and time is {datetime.now()}"
result = agent.run_sync("What is the current date and time?")
print(result.data)
# The current date and time is January 29, 2025, 20:19:10.162546.
Dependencies
In the PydanticAI framework, dependencies refer to resources or contexts that can be shared throughout the execution of an Agent
. These dependencies can include strings, numbers, or arbitrary Python objects.
However, to adhere to PydanticAI's type-safety philosophy, the data structure of dependencies must be predefined. This can be done using a Python dataclass
.
For example, if you want to include the current user's information in the system prompt:
- Define the dependency data structure as a
dataclass
(e.g.,MyDependency
). - Pass this structure to the
deps_type
parameter when initializing theAgent
. - Define a system prompt that accepts a
RunContext
containing the dependency. - Inject the dependency object when executing the agent using
run_sync()
.
This ensures seamless dependency injection via RunContext
throughout the agent's execution cycle. Let's take a look at an example!
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
@dataclass
class MyDependency:
name: str
age: int
agent = Agent(
"openai:gpt-4o-mini",
deps_type=MyDependency, # define dependency type
)
@agent.system_prompt
def system_prompt(ctx: RunContext[MyDependency]):
return f"""
You are a helpful assistant for user {ctx.deps.name} who is {ctx.deps.age} years old.
"""
# pass dependency
result = agent.run_sync("Who am I?", deps=MyDependency(name="Bongnam Kim", age=30))
print(result.data)
# You are Bongnam Kim, a 30-year-old individual.
# If you’d like to share more information about yourself, feel free to let me know!
Structured Result Type
You can enforce the desired data format for the agent's execution results by utilizing Pydantic's data type validation. Simply specify the data format as the result_type
parameter when initializing the Agent
. You can easily check this with the example below.
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
# define result type
class Area(BaseModel):
area: int
unit: str
# bind two tools
def get_circle_area(radius: float) -> float:
return radius * radius * 3.14
def get_square_area(side: float) -> float:
return side * side
agent = Agent(
"openai:gpt-4o-mini",
result_type=Area, # force result type to be `Area`
system_prompt=("You are a helpful assistant. Always give your answer with the name of the user."),
tools=[get_circle_area, get_square_area],
)
result = agent.run_sync("What is the area of a circle with a radius of 10cm?")
print(result.data.area) # 314
print(result.data.unit) # cm²
result = agent.run_sync("What is the area of a square with a side of 10cm?")
print(result.data.area) # 100
print(result.data.unit) # cm²
By providing tools to calculate the area of a circle and a square, and enforcing the result type as an Area
type with properties like area
and unit
, we can ensure that the agent returns the correct Area
object after executing the necessary tools.
Function Tools
The agents we've seen so far were simple, single LLM requests. However, the true power of an agentic workflow lies in the agent's ability to make decisions and use the required tools to gather base information and draw conclusions.
To enable this, we need to give the agent the tools it needs (tool binding). In PydanticAI, tool binding can be done in three ways. Let’s explore examples of each:
- Using the
@agent.tool
decorator: This method is used when the tool execution needs access to context information (likeRunContext
), similar to dependencies. - Using the
@agent.tool_plain
decorator: This method is used when the tool execution does not need to depend onRunContext
. - Passing a list of Tool objects or functions to the
tools
parameter: This method doesn't rely on individual agent instance decorators, increasing the reusability of the tool functions.
Tool Binding Using @agent.tool
Decorator
Here’s an example where the tool get_user_name
retrieves the user’s name stored in the current dependency and returns it along with the answer. Pay attention to the code below:
- A
str
type dependency is defined, and inrun_sync()
, the dependency value "Bongnam Kim" is passed. - This value is accessed within the
get_user_name
function usingctx.deps
. - The static system prompt always includes the user's name in the response.
- The
get_user_name
function is wrapped with the@agent.tool
decorator for tool binding.
from pydantic_ai import Agent, RunContext
agent = Agent(
"openai:gpt-4o-mini",
deps_type=str,
system_prompt=("You are a helpful assistant. Answer with the name of the user."),
)
@agent.tool # binds this tool to the object `agent`
def get_user_name(ctx: RunContext[str]) -> str:
"""Get the user's name."""
return ctx.deps
result = agent.run_sync("Hi!", deps="Bongnam Kim")
print(result.data)
# Hello, Bongnam Kim! How can I assist you today?
As a result, we can confirm that the user's name, Bongnam Kim, is successfully added to the response.
A natural question that arises is whether the agent actually executed the get_user_name
tool to retrieve the user's name, Bongnam Kim. Is it possible to trace the tool execution flow of the model?
Indeed! The result.all_messages()
function returns a list of ModelRequest
and ModelResponse
objects. This list contains logs showing the agent's decision-making process, including which tools were executed and what values were returned by each tool.
[ModelRequest(parts=[SystemPromptPart(content='You are a helpful assistant. '
'Answer with the name of the '
'user.',
dynamic_ref=None,
part_kind='system-prompt'),
UserPromptPart(content='Hi!',
timestamp=datetime.datetime(2025, 1, 29, 12, 19, 34, 232313, tzinfo=datetime.timezone.utc),
part_kind='user-prompt')],
kind='request'),
ModelResponse(parts=[ToolCallPart(tool_name='get_user_name',
args=ArgsJson(args_json='{}'),
tool_call_id='call_WUJCzFb9xoExxDmP0RTYCvmk',
part_kind='tool-call')],
model_name='gpt-4o-mini',
timestamp=datetime.datetime(2025, 1, 29, 12, 19, 34, tzinfo=datetime.timezone.utc),
kind='response'),
ModelRequest(parts=[ToolReturnPart(tool_name='get_user_name',
content='Bongnam Kim',
tool_call_id='call_WUJCzFb9xoExxDmP0RTYCvmk',
timestamp=datetime.datetime(2025, 1, 29, 12, 19, 35, 41514, tzinfo=datetime.timezone.utc),
part_kind='tool-return')],
kind='request'),
ModelResponse(parts=[TextPart(content='Hello, Bongnam Kim! How can I assist '
'you today?',
part_kind='text')],
model_name='gpt-4o-mini',
timestamp=datetime.datetime(2025, 1, 29, 12, 19, 35, tzinfo=datetime.timezone.utc),
kind='response')]
By checking the logs above, you can see the second element ModelResponse(parts=[ToolCallPart(tool_name='get_user_name', args=ArgsJson(Args_json='{}'), ...
confirming that the get_user_name
tool was actually executed!
Tool Binding Using @agent.tool_plain
Decorator
In many cases, tool execution does not require RunContext
. For example, consider a simple multiplication tool that helps the agent with calculations. In this case, there’s no need for the tool to access dependencies.
For such cases, you can use the @agent.tool_plain
decorator to bind the tool, as shown below:
from pydantic_ai import Agent
agent = Agent(
"openai:gpt-4o-mini",
system_prompt=("You are a helpful assistant."),
)
@agent.tool_plain
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
result = agent.run_sync("What is 1234 + 5678?")
print(result.data)
# 1234 + 5678 equals 6912.
When looking at the logs related to tool execution, it appears as follows. The add
tool is used with the arguments a=1234
and b=5678
.
ModelResponse(parts=[ToolCallPart(tool_name='add',
args=ArgsJson(args_json='{"a":1234,"b":5678}'),
tool_call_id='call_FyI5w9Y33lY4l9kVrTZC0fsM',
part_kind='tool-call')],
model_name='gpt-4o-mini',
timestamp=datetime.datetime(2025, 1, 29, 12, 31, 48, tzinfo=datetime.timezone.utc),
kind='response'),
Tool Binding by Passing Tool Objects or Function List to tools
Parameter
The decorator-based methods mentioned earlier are effective when a specific tool is implemented and used for a specific agent. However, in many cases, from a reusability perspective, you might want to keep the implementation of the tool (or function) single and have multiple agents use the same tool.
For this, PydanticAI allows tool binding without relying on decorators. Instead, you can simply pass a list of tools (e.g., tools=[tool1, tool2]
) during the Agent
initialization.
Note: If you are implementing tools that depend on RunContext
, remember that the first argument of the tool function must be of type RunContext[XXX]
.
from pydantic_ai import Agent, RunContext
# tool without RunContext
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
# tool with RunContext
def get_user_name(ctx: RunContext[str]) -> str:
"""Get the user's name."""
return ctx.deps
agent = Agent(
"openai:gpt-4o-mini",
deps_type=str,
system_prompt=("You are a helpful assistant. Always give your answer with the name of the user."),
tools=[add, get_user_name],
)
result = agent.run_sync("What is 1234 + 5678?", deps="Bongnam Kim")
print(result.data)
# The result of 1234 + 5678 is 6912, Bongnam Kim.
LLM Model and Model Settings (LLM model / settings)
PydanticAI is a model-agnostic framework, meaning it does not depend on a specific LLM model type. In other words, you can configure the agent to work with different LLM models interchangeably. The following model providers are supported by PydanticAI:
- OpenAI
- Anthropic
- Gemini (Generative Language API / Vertex API)
- Ollama
- Groq
- Mistral
Settings
If you installed PydanticAI using pip install pydantic-ai
or uv add pydantic-ai
, you are ready to use all the model providers by default.
However, if you used pip install pydantic-ai-slim
or uv add pydantic-ai-slim
to install only the necessary packages, you will need to install individual model providers, such as:
pip install pydantic-ai-slim[openai]
pip install pydantic-ai-slim[anthropic]
For more detailed installation instructions, please refer to the PydanticAI official documentation.
Using OpenAI Model in PydanticAI
- Environment Variable Setup: To use the OpenAI model in PydanticAI, you need to set the OpenAI API key as an environment variable:
export OPENAI_API_KEY='your-api-key'
- Usage Example
from pydantic_ai import Agent
# Initialize the agent with OpenAI as the LLM provider
agent = Agent(model="openai")
# Run the agent
response = agent.run("What is the capital of France?")
print(response)
Using DeepSeek V3 Model in PydanticAI
- Environment Variable Setup:
export DEEPSEEK_API_KEY='your-api-key'
- Usage example
import os
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
model = OpenAIModel(
'deepseek-chat',
base_url='https://api.deepseek.com',
api_key=os.getenv('DEEPSEEK_API_KEY'),
)
agent = Agent(model)
Using Ollama Model in PydanticAI
- Using Ollama on the Same Machine
from pydantic_ai import Agent
agent = Agent('ollama:llama3.2')
- Using a remote Ollama server
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.ollama import OllamaModel
ollama_model = OllamaModel(
model_name='llama3.2',
base_url='http://192.168.1.74:11434/v1', # remote url
)
agent = Agent(model=ollama_model)
Other Options
Usage Limits with UsageLimits
for API Request Limitation
One important thing to keep in mind when building an application with an Agent
is that you cannot predict how many API requests the agent will make in advance. If the number of API requests increases, it can result in excessive costs, so it's safer to set usage limits beforehand.
In PydanticAI, you can limit usage by passing a UsageLimits
object as the usage_limits
parameter when running the Agent
.
- When creating the
UsageLimits
object, you can define maximum limits for various usage items, such asrequest_limit
,request_tokens_limit
,response_tokens_limit
, andtotal_tokens_limit
. - For example, if you want to limit the number of response tokens to fewer than 10, you can do so as shown below. An error will occur if the number of response tokens exceeds the limit, and the execution will stop immediately.
from pydantic_ai import Agent, RunContext
from pydantic_ai.usage import UsageLimits
agent = Agent(
"openai:gpt-4o-mini",
system_prompt=("You are a helpful assistant."),
)
# tool without RunContext
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
# tool with RunContext
def get_user_name(ctx: RunContext[str]) -> str:
"""Get the user's name."""
return ctx.deps
agent = Agent(
"openai:gpt-4o-mini",
deps_type=str,
system_prompt=("You are a helpful assistant. Always give your answer with the name of the user."),
tools=[add, get_user_name],
)
result = agent.run_sync(
"What is 1234 + 5678?",
deps="Bongnam Kim",
usage_limits=UsageLimits(response_tokens_limit=10)
)
print(result.data)
Adjusting LLM Model Execution Parameters Using model_settings
You may want to adjust the execution parameters of the LLM that underpins the Agent
, such as temperature
, timeout
, and other settings. In this case, you can use the model_settings
parameter during execution to customize these parameters.
from pydantic_ai import Agent
agent = Agent(
"openai:gpt-4o-mini",
system_prompt=("You are a helpful assistant"),
)
result = agent.run_sync(
"What is the capital of France?",
model_settings={'temperature': 0.0}
)
print(result.data) # The capital of France is Paris.
Example: Responding with Web Search
LLM models can only provide information up to the point of their training, meaning they cannot answer questions about newer information created after the model’s training. However, what if we give the agent a web search tool? By searching for the latest information and then using the search results to compose a response, the agent could provide better, up-to-date answers! In this example, we will give the agent a simple web search tool based on DuckDuckGo, and ask it about the Korean national public holidays in 2025.
from pydantic_ai import Agent
from duckduckgo_search import DDGS
# simple web search tool using duckduckgo_search
# this may be replaced with Tavily, Perplexity or else
def duckduckgo_search(query: str) -> str:
"""Search the web for the given query."""
with DDGS() as ddgs:
return ddgs.text(query)
agent = Agent(
"openai:gpt-4o-mini",
system_prompt=("You are a helpful assistant."),
tools=[duckduckgo_search],
)
result = agent.run_sync(
"List all the Korean national holidays in 2025."
)
print(result.data)
# Here are the Korean national holidays for 2025:
# 1. **New Year's Day** - January 1, 2025 (Wednesday)
# 2. **Seollal (Lunar New Year)** - January 28, 2025 (Tuesday) to January 30, 2025 (Thursday)
# 3. **Independence Movement Day** - March 1, 2025 (Saturday)
# 4. **Buddha's Birthday** - May 15, 2025 (Thursday)
# 5. **Memorial Day** - June 6, 2025 (Friday)
# 6. **National Liberation Day** - August 15, 2025 (Friday)
# 7. **Chuseok (Korean Harvest Festival)** - September 29, 2025 (Monday) to October 1, 2025 (Wednesday)
# 8. **National Foundation Day** - October 3, 2025 (Friday)
# 9. **Hangeul Day (Korean Alphabet Day)** - October 9, 2025 (Thursday)
# 10. **Christmas Day** - December 25, 2025 (Thursday)
# Please note that some holidays may be adjusted based on the lunar calendar, and there may be substitute holidays if the date falls on a weekend.
# For more detailed information, you can check resources like [Public Holidays Korea](https://publicholidays.co.kr/2025-dates/)
# or the [Korean Calendar site](https://timeanddate.com/holidays/south-korea/2025).
If we take a quick look at how the model used the duckduckgo_search
tool, it would look something like this:
ModelResponse(parts=[ToolCallPart(tool_name='duckduckgo_search',
args=ArgsJson(args_json='{"query":"Korean national holidays 2025'"}'),
tool_call_id='call_VjYGMZzYh5JGSQ2BMR6W7a6f',
part_kind='tool-call')],
model_name='gpt-4o-mini',
timestamp=datetime.datetime(2025, 1, 29, 14, 1, 5, tzinfo=datetime.timezone.utc),
kind='response'),
You can see that the model is using duckduckgo_search('Korean national holidays 2025')
to attempt the search. This search query will retrieve the relevant web results, which the model will then use to generate an up-to-date response regarding the national holidays in Korea for 2025.
Conclusion
In this post, we explored the core philosophy and concepts of the PydanticAI library. We discussed how to provide static or dynamic system prompts during agent initialization, how to pass the agent's execution context and dependencies, and how to bind tools to the agent. Hopefully, this gives you a sense of how to implement an agentic framework.
Once you become familiar with the relationship between Agent, RunContext, and Tool in PydanticAI, you'll be able to freely implement various agentic workflows. I, too, am excited to start building with these concepts! 😊