Files
confident-ai--deepeval/docs/content/integrations/frameworks/pydanticai.mdx
T
2026-07-13 13:32:05 +08:00

486 lines
19 KiB
Plaintext

---
id: pydanticai
title: Pydantic AI
sidebar_label: Pydantic AI
---
<IntegrationTagsDisplayer otel={true} cicdEvals={true} traceability={true} />
[Pydantic AI](https://ai.pydantic.dev/) is a Python framework for building production-grade applications with Generative AI, with type safety and validation for agent outputs and LLM interactions.
The `deepeval` integration auto-instruments to trace every call to your Pydantic AI `Agent`s. Every agent run, every tool call, and every LLM call becomes a span you can inspect — without wiring trace structure by hand.
<AgentTraceTerminal
title="pydantic_ai_agent · deepeval"
ariaLabel="Example Pydantic AI agent trace with per-step metric scores"
lines={[
{ kind: "cmd", name: "deepeval test run test_pydantic_ai_agent.py" },
{ kind: "blank" },
{ kind: "root", prefix: "●", name: "test_pydantic_ai_agent" },
{ kind: "blank", prefix: "│" },
{
kind: "agent",
prefix: "└─",
name: "assistant",
metric: "Answer Relevancy",
score: "0.93",
duration: "180ms",
pass: true,
},
{
kind: "llm",
prefix: " ├─",
name: "openai:gpt-5 · plan",
metric: "G-Eval",
score: "0.41",
duration: "62ms",
pass: false,
},
{
kind: "tool",
prefix: " ├─",
name: 'get_weather(city="Paris")',
duration: "44ms",
},
{
kind: "llm",
prefix: " └─",
name: "openai:gpt-5 · respond",
metric: "Faithfulness",
score: "0.94",
duration: "74ms",
pass: true,
},
{ kind: "blank" },
{
kind: "summary",
name: "Trace score 0.76 · 2/3 metrics passed",
pass: false,
},
]}
/>
`deepeval`'s Pydantic AI integration enables you to:
- **Auto-instrument every `Agent`** — each `agent.run(...)` produces a trace, and each LLM, tool, and sub-agent call inside it becomes a component span.
- **Evaluate the trace end-to-end or target model / agent components** with any `deepeval` metric.
- **Run evals from a script** (`evals_iterator`) **or from CI/CD** (`pytest` + `deepeval test run`) — same metrics, two surfaces.
- **Customize trace and span data at runtime** from anywhere in the call stack — your tool bodies, post-processors, or the call site.
## Getting Started
<Steps>
<Step>
### Installation
```bash
pip install -U deepeval pydantic-ai opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
```
Under the hood the integration plugs Pydantic AI's [OpenTelemetry instrumentation](https://ai.pydantic.dev/logfire/) into `deepeval`'s span processor.
:::info
You don't need to touch OTel directly — but it's worth knowing if you're already exporting traces somewhere else.
:::
</Step>
<Step>
### Instrument and evaluate
Pass `DeepEvalInstrumentationSettings` to the `Agent`'s `instrument` keyword. From that point on, any `agent.run(...)`, `agent.run_sync(...)`, or `agent.run_stream(...)` call produces a trace `deepeval` can read.
```python title="pydantic_ai_agent.py" showLineNumbers
from pydantic_ai import Agent
from deepeval.integrations.pydantic_ai import DeepEvalInstrumentationSettings
from deepeval.dataset import EvaluationDataset, Golden
from deepeval.metrics import TaskCompletionMetric
agent = Agent(
"openai:gpt-5",
system_prompt="Be concise, reply with one sentence.",
instrument=DeepEvalInstrumentationSettings(),
)
# Goldens are the inputs you want to evaluate.
dataset = EvaluationDataset(goldens=[Golden(input="What's the weather in Paris?")])
# `evals_iterator` loop through goldens and applies metrics
for golden in dataset.evals_iterator(metrics=[TaskCompletionMetric()]):
agent.run_sync(golden.input) # Produces trace for evaluation
```
Done ✅. You've run your first eval with full traceability into Pydantic AI via `deepeval`.
</Step>
</Steps>
## What gets traced
Each `agent.run(...)` call produces a **trace** — the end-to-end unit your user observes, from the prompt going in to the final output coming out. Inside that trace are **component spans** for every step the agent took to produce the answer:
- **LLM spans** — one per LLM call inside the run.
- **Tool spans** — one per tool call.
- **Agent spans** — nested for sub-agent calls (delegations, handoffs).
Sync, async, and streaming paths all flow through the same instrumentation — there's nothing to configure differently between them.
```text
Trace ← what the user observes (end-to-end)
└── Agent: assistant ← one agent.run(...) call
├── LLM: openai:gpt-5 ← component span: model decides which tool to call
├── Tool: get_weather ← component span: tool input + output
└── LLM: openai:gpt-5 ← component span: model produces the final answer
```
The trace and its component spans are independently evaluable. The next two sections describe how to run those evaluations.
## Running evals
There are two surfaces for running evals against a Pydantic AI agent. Pick by where you want results to surface — your terminal during a notebook session, or your CI pipeline as a pass/fail gate. Metric definitions are the same in both.
### In CI/CD (pytest)
Use the `deepeval` pytest integration. Each parametrized test invocation becomes one agent run; failing metrics fail the test, which fails the build. This is the right surface for regression gates and pre-merge checks.
Define an `EvaluationDataset` at module scope, parametrize the test over its goldens, call the agent inside the test, and let `assert_test` evaluate the trace it just produced.
```python title="test_pydantic_ai_agent.py" showLineNumbers
import pytest
from pydantic_ai import Agent
from deepeval import assert_test
from deepeval.dataset import EvaluationDataset, Golden
from deepeval.integrations.pydantic_ai import DeepEvalInstrumentationSettings
from deepeval.metrics import AnswerRelevancyMetric
agent = Agent(
"openai:gpt-5",
system_prompt="Be concise, reply with one sentence.",
instrument=DeepEvalInstrumentationSettings(name="my-agent"),
)
dataset = EvaluationDataset(
goldens=[
Golden(input="What's the weather in Paris?"),
Golden(input="What's the weather in London?"),
]
)
@pytest.mark.parametrize("golden", dataset.goldens)
def test_agent(golden: Golden):
agent.run_sync(golden.input)
assert_test(golden=golden, metrics=[AnswerRelevancyMetric()])
```
Run it with:
```bash
deepeval test run test_pydantic_ai_agent.py
```
The same metrics you used in `evals_iterator` work unchanged here. The only difference is what surfaces the failures: a CI badge instead of a notebook cell.
### In a script
Use `EvaluationDataset` + `evals_iterator(...)`. Each `Golden` becomes one agent run; metrics score the resulting trace. This is the right surface for ad-hoc runs, notebooks, and one-off comparisons.
```python title="pydantic_ai_agent.py" showLineNumbers
import asyncio
from pydantic_ai import Agent
from deepeval.dataset import EvaluationDataset, Golden
from deepeval.evaluate.configs import AsyncConfig
from deepeval.integrations.pydantic_ai import DeepEvalInstrumentationSettings
from deepeval.metrics import AnswerRelevancyMetric
agent = Agent(
"openai:gpt-5",
system_prompt="Be concise, reply with one sentence.",
instrument=DeepEvalInstrumentationSettings(name="my-agent"),
)
dataset = EvaluationDataset(
goldens=[
Golden(input="What's the weather in Paris?"),
Golden(input="What's the weather in London?"),
]
)
answer_relevancy = AnswerRelevancyMetric()
for golden in dataset.evals_iterator(
async_config=AsyncConfig(run_async=True),
metrics=[answer_relevancy],
):
task = asyncio.create_task(agent.run(golden.input))
dataset.evaluate(task)
```
`evals_iterator` is async-friendly; wrap each invocation in `asyncio.create_task` and pass it to `dataset.evaluate(...)` so multiple goldens run concurrently against the same dataset.
## Applying metrics to components
The `metrics=[...]` you passed to `evals_iterator` in the previous section evaluates the **trace** — the end-to-end behavior the user observes. To evaluate a **component** instead — a specific LLM call or the agent span itself — stage the metric with the appropriate `next_*_span(...)` wrapper before the run.
### LLM calls
Same shape with `next_llm_span(metrics=[...])`. Useful when you want to evaluate the LLM's reasoning step in isolation from the tool's effect.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import next_llm_span
async def run_agent(prompt: str):
with next_llm_span(metrics=[answer_relevancy]):
return await agent.run(prompt)
for golden in dataset.evals_iterator(async_config=AsyncConfig(run_async=True)):
task = asyncio.create_task(run_agent(golden.input))
dataset.evaluate(task)
```
### Agent spans
`next_agent_span(metrics=[...])` targets the agent component itself. The agent span shares its input and output with the trace, but it's a distinct unit — use this when you want a metric on the agent span specifically (rather than the trace).
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import next_agent_span
async def run_agent(prompt: str):
with next_agent_span(metrics=[answer_relevancy]):
return await agent.run(prompt)
for golden in dataset.evals_iterator(async_config=AsyncConfig(run_async=True)):
task = asyncio.create_task(run_agent(golden.input))
dataset.evaluate(task)
```
For deterministic tool calls, prefer `update_current_span(...)` to add metadata, inputs, and outputs instead of attaching metrics to the tool span.
## Customizing trace and span data at runtime
Trace-level fields you set on `DeepEvalInstrumentationSettings` are defaults; they apply to every trace produced by that agent. For anything dynamic, the right API depends on where your code runs.
Pydantic AI creates most of the trace structure for you, which means the agent, LLM, and tool spans are mostly hidden behind `agent.run(...)`. Calls like `update_current_trace(...)` and `update_current_span(...)` only work while there is an active `deepeval` trace/span in context. In practice, that means a Pydantic AI tool body is your clearest mutation point, because Pydantic has already opened the trace and the tool span before your function runs.
If you need to customize from outside a tool, use `DeepEvalInstrumentationSettings` for static defaults, `next_*_span(...)` to stage config for the next Pydantic-created span, or `@observe` / `with trace(...)` when you own the outer operation. The advanced section below shows those scenarios.
### Trace-level fields from inside a tool
`update_current_trace(...)` mutates the active trace. Use it when a tool discovers metadata you only know during the run, like a user id, request id, retrieved document id, or routing decision.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import update_current_trace
...
@agent.tool_plain
def fetch_user(user_id: str) -> dict:
user = users_db.get(user_id)
update_current_trace(
user_id=user_id,
metadata={"plan": user["plan"], "region": user["region"]},
)
return user
```
### Span-level fields from inside a tool
`update_current_span(...)` writes to whichever span Pydantic AI just opened — typically the tool span if you call it from inside a tool body. Useful for tagging tool-call metadata (cache hits, downstream IDs, retrieval context) without restructuring the tool.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import update_current_span
...
@agent.tool_plain
def get_weather(city: str) -> str:
cache_hit, value = weather_cache.lookup(city)
update_current_span(
metadata={"cache_hit": cache_hit, "city": city},
output=value,
)
return value
```
The general rule: settings hold defaults, `next_*_span(...)` stages changes before Pydantic opens the span, and `update_current_*(...)` mutates only after your code is already inside an active trace/span.
## Advanced patterns
The primitives above — `DeepEvalInstrumentationSettings`, `@observe`, `with trace(...)`, `next_*_span(...)`, `update_current_*(...)` — compose around one boundary: Pydantic AI owns the auto-instrumented spans, and your code customizes them from the places it can actually see. Use `@observe` or `with trace(...)` when you own an outer workflow, `next_*_span(...)` when you want to configure a Pydantic-created span before it exists, and `update_current_*(...)` when a tool or observed function is already running inside the trace.
### Evaluate subagents with `next_*_span`
`next_*_span(metrics=[...])` stages a metric for the next matching Pydantic AI component span. Use this when you want to evaluate a subagent or model step instead of the full trace. Pick the helper that matches the span you want to score: `next_agent_span(...)` or `next_llm_span(...)`.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import next_agent_span
...
async def run_agent(prompt: str):
with next_agent_span(metrics=[answer_relevancy]):
return await agent.run(prompt)
```
#### No trace-level metrics required
Trace-level metrics are end-to-end metrics: they score the whole trace. They are not strictly necessary here because the `AnswerRelevancyMetric` is attached to the next agent span, so CI/CD and scripts only need to run the subagent.
This is how you'd run it:
<Tabs items={["CI/CD", "Scripts"]}>
<Tab value="CI/CD">
```python title="test_pydantic_ai_agent.py" showLineNumbers
import asyncio
import pytest
from deepeval import assert_test
...
@pytest.mark.parametrize("golden", dataset.goldens)
def test_agent_span(golden: Golden):
asyncio.run(run_agent(golden.input))
assert_test(golden=golden)
```
```bash
deepeval test run test_pydantic_ai_agent.py
```
</Tab>
<Tab value="Scripts">
```python title="pydantic_ai_agent.py" showLineNumbers
...
for golden in dataset.evals_iterator(async_config=AsyncConfig(run_async=True)):
task = asyncio.create_task(run_agent(golden.input))
dataset.evaluate(task)
```
</Tab>
</Tabs>
### Wrap an agent run in `@observe`
When the agent run isn't your top-level unit of work — for example, a `respond_to_user(...)` function that calls the agent and post-processes the result — you can decorate that outer function with `@observe`. The Pydantic AI spans nest under your `@observe` span automatically; the result is a single trace rooted at your function with the agent run inside it.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import observe
...
@observe(name="respond_to_user")
async def respond_to_user(prompt: str) -> str:
result = await agent.run(prompt)
return result.output.strip().upper()
```
### Multiple agent runs under one trace
When a single logical unit of work makes several agent calls (e.g. a planner agent followed by a worker agent), bracket them with `with trace(...)` so they share a trace_id and show up as siblings under one root.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import trace
...
async def run_pipeline(prompt: str):
with trace(name="planner_then_worker"):
plan = await planner.run(prompt)
return await worker.run(plan.output)
```
### Mix native `@observe` spans with Pydantic AI spans
`@observe` works on any function, not just top-level ones. Decorating an internal helper inside a tool body adds a native `deepeval` span to the trace — useful for evaluating retrieval steps, ranker calls, or other sub-tool logic that Pydantic AI doesn't see.
```python title="pydantic_ai_agent.py" showLineNumbers
from deepeval.tracing import observe
...
@observe(name="rerank")
def rerank(docs: list[str], query: str) -> list[str]:
return sorted(docs, key=lambda d: -score(d, query))
@agent.tool_plain
def retrieve(query: str) -> list[str]:
raw = vector_store.search(query)
return rerank(raw, query)
```
## API reference
`DeepEvalInstrumentationSettings(...)` accepts the following trace-level kwargs. Each one is a default; runtime calls always win.
| Kwarg | Type | Description |
| ------------- | ----------- | -------------------------------------------------------------------------- |
| `name` | `str` | Default trace name. Override at runtime via `update_current_trace`. |
| `thread_id` | `str` | Default thread identifier. Useful for grouping conversational turns. |
| `user_id` | `str` | Default actor identifier. Override per-request via `update_current_trace`. |
| `metadata` | `dict` | Default trace metadata. Merged with runtime overrides; runtime wins. |
| `tags` | `list[str]` | Default tags applied to every trace produced by this agent. |
| `environment` | `str` | One of `"development"`, `"staging"`, `"production"`, `"testing"`. |
For runtime helpers (`update_current_trace`, `update_current_span`, `next_agent_span`, `next_llm_span`) and the test-decorator surface (`@observe`, `@assert_test`, `with trace(...)`), see the [tracing reference](/docs/evaluation-llm-tracing).
## FAQs
<FAQs
qas={[
{
question: "Can I evaluate a delegated sub-agent on its own?",
answer: (
<>
Yes. Sub-agent calls (delegations and handoffs) already nest as their
own agent spans. Stage a metric for one with{" "}
<code>with next_agent_span(metrics=[...])</code>, or target a model
step with <code>next_llm_span(...)</code>, to score it in isolation.
</>
),
},
{
question: "Can I run these Pydantic AI evals with Pytest?",
answer: (
<>
Yes. Build the <code>Agent</code> with{" "}
<code>instrument=DeepEvalInstrumentationSettings()</code>, run it in a
parametrized <code>pytest</code> test, and gate the build with{" "}
<code>assert_test(...)</code> and <code>deepeval test run</code>.
</>
),
},
{
question: "Can I monitor a Pydantic AI agent in production?",
answer: (
<>
Yes. Set <code>environment="production"</code> on{" "}
<code>DeepEvalInstrumentationSettings(...)</code>; once logged into
Confident AI the live traces support{" "}
<a href="https://www.confident-ai.com/docs/llm-tracing/online-evals">
online evals
</a>{" "}
on real traffic.
</>
),
},
{
question: "Can I see these traces and scores in a cloud dashboard?",
answer: (
<>
Yes, optionally. <code>deepeval login</code> connects{" "}
<a href="https://www.confident-ai.com">Confident AI</a>, which
visualizes every auto-instrumented agent, LLM, and tool span with its
score in a shared UI — no extra code.
</>
),
},
]}
/>