Tutoriel Pydantic AI 2026 : Construire des agents LLM type-safe en Python

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Si vous avez déjà mis en production une fonctionnalité LLM en Python, vous connaissez le scénario d'échec : le modèle renvoie une chaîne au format JSON, vous la parsez, un champ manque, et vous écrivez encore un try/except défensif qui camoufle le problème. Pydantic AI renverse la logique. Il considère vos schémas Pydantic comme le contrat que le modèle doit honorer et vous offre par-dessus un runtime d'agents avec injection de dépendances à la FastAPI — streaming, outils, retries et observabilité déjà câblés.

Dans ce tutoriel, vous installerez Pydantic AI à partir de zéro, construirez un agent de support client avec sorties structurées et outils de base de données, basculerez les modèles entre OpenAI et Anthropic, streamerez les tokens vers un endpoint FastAPI, et instrumenterez le tout avec Logfire pour voir exactement ce que votre agent a fait et pourquoi.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Python 3.10 ou plus récent installé
  • Une clé API pour au moins un fournisseur (OpenAI, Anthropic, Google, Mistral, ou une instance Ollama locale)
  • Une connaissance de base des modèles Pydantic et de async/await
  • Un terminal et un éditeur de code (VS Code avec l'extension Python recommandé)
  • Optionnel : un compte Logfire pour l'observabilité (le palier gratuit est largement suffisant)

Ce que vous allez construire

À la fin de ce tutoriel, vous aurez :

  1. Un projet Pydantic AI avec des dépendances isolées dans un environnement
  2. Un agent de support typé qui renvoie des réponses structurées validées par Pydantic
  3. Des outils adossés à une base SQLite pour la consultation de tickets et le statut des commandes
  4. Un endpoint de chat en streaming exposé via FastAPI
  5. Une configuration agnostique du fournisseur qui bascule OpenAI, Anthropic et Gemini à l'exécution
  6. Une observabilité de bout en bout avec Logfire, incluant l'usage des tokens et les spans des outils
  7. Un harnais Pytest qui exécute l'agent contre un faux LLM pour des tests rapides et déterministes

Étape 1 : Installer Pydantic AI

Pydantic AI est livré sous forme d'un paquet unique avec des extras optionnels pour chaque fournisseur. Créez d'abord un environnement isolé — uv est l'option la plus rapide en 2026, mais venv fonctionne tout aussi bien.

mkdir support-agent && cd support-agent
uv venv
source .venv/bin/activate
uv pip install "pydantic-ai[openai,anthropic,logfire]" fastapi uvicorn aiosqlite

Si vous préférez pip classique :

python -m venv .venv
source .venv/bin/activate
pip install "pydantic-ai[openai,anthropic,logfire]" fastapi uvicorn aiosqlite

Vérifiez l'installation :

python -c "import pydantic_ai; print(pydantic_ai.__version__)"

Vous devriez voir une version commençant par 0.x. Pydantic AI est encore avant la 1.0 au moment de la rédaction, mais l'API publique est stable depuis plusieurs versions et l'équipe s'est engagée sur le semver pour la suite.

Étape 2 : Configurer vos fournisseurs

Exportez les clés API des fournisseurs que vous souhaitez utiliser. Pydantic AI les lit à l'exécution via les SDK sous-jacents, donc ne les commitez jamais.

export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."
export GEMINI_API_KEY="..."

Pour de vrais projets, mettez-les dans un fichier .env et chargez-les avec python-dotenv ou direnv. Ajoutez .env à .gitignore immédiatement.

Étape 3 : Votre premier agent typé

Créez agent.py avec un agent minimal qui renvoie une SupportResponse structurée :

# agent.py
from pydantic import BaseModel, Field
from pydantic_ai import Agent
 
 
class SupportResponse(BaseModel):
    """Structured response from the support agent."""
 
    answer: str = Field(description="Plain-language answer for the user")
    needs_human: bool = Field(
        description="True when the issue cannot be resolved by the bot"
    )
    confidence: float = Field(ge=0, le=1, description="Self-rated confidence")
 
 
support_agent = Agent(
    "openai:gpt-4o-mini",
    output_type=SupportResponse,
    system_prompt=(
        "You are a calm, concise customer-support agent for an e-commerce store. "
        "Always return a SupportResponse. Set needs_human=true if the user is angry, "
        "asks for a refund over 200 USD, or mentions legal action."
    ),
)
 
 
if __name__ == "__main__":
    result = support_agent.run_sync(
        "My order #4421 has been stuck on 'shipped' for 14 days. What now?"
    )
    print(result.output)
    print("Tokens used:", result.usage())

Exécutez-le :

python agent.py

Pydantic AI envoie votre system prompt et le message utilisateur à GPT-4o-mini, lui demande de renvoyer du JSON correspondant à SupportResponse, valide la réponse avec Pydantic, et vous donne un objet entièrement typé. Si le modèle renvoie un JSON invalide ou un champ manquant, l'agent réessaie automatiquement avec un message correctif — aucun parsing manuel requis.

Étape 4 : Ajouter l'injection de dépendances

Les vrais agents ont besoin d'accéder à des bases de données, des clients HTTP et de la configuration. Pydantic AI utilise un système de dépendances à la FastAPI : vous déclarez un deps_type et y accédez depuis les outils et les prompts via le RunContext.

Créez deps.py :

# deps.py
from dataclasses import dataclass
 
import aiosqlite
 
 
@dataclass
class SupportDeps:
    """Resources the agent needs at runtime."""
 
    db: aiosqlite.Connection
    customer_id: int

Mettez à jour agent.py pour l'utiliser :

from pydantic_ai import Agent, RunContext
 
from deps import SupportDeps
 
support_agent = Agent(
    "openai:gpt-4o-mini",
    deps_type=SupportDeps,
    output_type=SupportResponse,
    system_prompt=(
        "You are a customer-support agent. Use the provided tools to look up orders "
        "before answering. Never invent order details."
    ),
)
 
 
@support_agent.system_prompt
async def add_customer_context(ctx: RunContext[SupportDeps]) -> str:
    """Inject customer-specific context into every system prompt."""
    async with ctx.deps.db.execute(
        "SELECT name, tier FROM customers WHERE id = ?", (ctx.deps.customer_id,)
    ) as cursor:
        row = await cursor.fetchone()
    if not row:
        return "Customer record not found."
    name, tier = row
    return f"You are speaking with {name} (tier: {tier}). Address them by first name."

Remarquez comment le system prompt est une fonction async ordinaire avec un accès complet à deps. Vous obtenez la même ergonomie qu'avec une dépendance FastAPI, sauf que le consommateur est le LLM.

Étape 5 : Définir des outils que le modèle peut appeler

Les outils sont des fonctions async décorées avec @support_agent.tool. Pydantic AI inspecte leurs annotations de type et génère des schémas JSON que le modèle peut appeler. La valeur de retour est renvoyée dans la conversation comme un message d'outil.

from datetime import datetime
from typing import Literal
 
 
@support_agent.tool
async def get_order_status(
    ctx: RunContext[SupportDeps],
    order_id: int,
) -> dict:
    """Look up the latest status for a customer order.
 
    Args:
        order_id: The numeric order identifier shown on receipts.
    """
    async with ctx.deps.db.execute(
        "SELECT status, last_update FROM orders WHERE id = ? AND customer_id = ?",
        (order_id, ctx.deps.customer_id),
    ) as cursor:
        row = await cursor.fetchone()
    if not row:
        return {"error": "order_not_found", "order_id": order_id}
    status, last_update = row
    return {
        "order_id": order_id,
        "status": status,
        "last_update": last_update,
        "stale": (datetime.utcnow().isoformat() > last_update),
    }
 
 
@support_agent.tool
async def issue_refund(
    ctx: RunContext[SupportDeps],
    order_id: int,
    amount_cents: int,
    reason: Literal["damaged", "late", "wrong_item", "other"],
) -> dict:
    """Issue a refund for a specific order. Amounts over 20000 cents require human approval."""
    if amount_cents > 20_000:
        return {"approved": False, "reason": "amount_exceeds_bot_limit"}
    await ctx.deps.db.execute(
        "INSERT INTO refunds (order_id, amount_cents, reason) VALUES (?, ?, ?)",
        (order_id, amount_cents, reason),
    )
    await ctx.deps.db.commit()
    return {"approved": True, "order_id": order_id, "amount_cents": amount_cents}

Deux choses à remarquer :

  1. Les annotations de type deviennent le schéma. Le Literal pour reason devient un enum parmi lequel le modèle doit choisir. Pydantic valide chaque appel d'outil avant que votre fonction ne s'exécute.
  2. Les outils peuvent se protéger eux-mêmes. L'outil de remboursement refuse tout montant supérieur à 200 USD et laisse l'agent escalader. Vous n'avez pas à enseigner la limite au modèle en prose — l'outil l'impose.

Étape 6 : Exécuter l'agent contre une vraie base de données

Reliez le tout dans main.py :

# main.py
import asyncio
 
import aiosqlite
 
from agent import support_agent
from deps import SupportDeps
 
 
async def setup_db() -> aiosqlite.Connection:
    db = await aiosqlite.connect(":memory:")
    await db.executescript(
        """
        CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, tier TEXT);
        CREATE TABLE orders (
          id INTEGER PRIMARY KEY,
          customer_id INTEGER,
          status TEXT,
          last_update TEXT
        );
        CREATE TABLE refunds (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          order_id INTEGER,
          amount_cents INTEGER,
          reason TEXT
        );
        INSERT INTO customers VALUES (1, 'Aya', 'gold');
        INSERT INTO orders VALUES
          (4421, 1, 'shipped', '2026-04-13T08:00:00'),
          (4422, 1, 'delivered', '2026-04-22T14:30:00');
        """
    )
    await db.commit()
    return db
 
 
async def main() -> None:
    db = await setup_db()
    deps = SupportDeps(db=db, customer_id=1)
    result = await support_agent.run(
        "Order #4421 still says shipped after two weeks. Can you refund 35 USD as a goodwill credit?",
        deps=deps,
    )
    print("Answer:", result.output.answer)
    print("Needs human:", result.output.needs_human)
    print("Confidence:", result.output.confidence)
    print("Tool calls:", [m.kind for m in result.all_messages() if m.kind == "tool-call"])
 
 
if __name__ == "__main__":
    asyncio.run(main())

Exécutez :

python main.py

L'agent appellera get_order_status, verra que la commande est obsolète, appellera issue_refund pour 3500 cents, et renverra une SupportResponse que vous pouvez transmettre directement à votre couche UI.

Étape 7 : Streamer l'agent via FastAPI

Pydantic AI expose agent.run_stream qui produit une sortie incrémentale. Combinez-le avec StreamingResponse de FastAPI pour un endpoint de chat qui stream les tokens vers le navigateur.

# api.py
from contextlib import asynccontextmanager
 
import aiosqlite
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
 
from agent import support_agent
from deps import SupportDeps
 
 
class ChatBody(BaseModel):
    customer_id: int
    message: str
 
 
db_holder: dict = {}
 
 
@asynccontextmanager
async def lifespan(app: FastAPI):
    db_holder["db"] = await aiosqlite.connect("support.db")
    yield
    await db_holder["db"].close()
 
 
app = FastAPI(lifespan=lifespan)
 
 
@app.post("/chat")
async def chat(body: ChatBody) -> StreamingResponse:
    deps = SupportDeps(db=db_holder["db"], customer_id=body.customer_id)
 
    async def token_stream():
        async with support_agent.run_stream(body.message, deps=deps) as result:
            async for chunk in result.stream_text(delta=True):
                yield chunk
 
    return StreamingResponse(token_stream(), media_type="text/plain")

Démarrez le serveur :

uvicorn api:app --reload

Et depuis un autre terminal :

curl -N -X POST http://localhost:8000/chat \
  -H "Content-Type: application/json" \
  -d '{"customer_id": 1, "message": "Where is order 4422?"}'

Vous verrez les tokens arriver en temps réel. Comme output_type est défini, la valeur agrégée finale reste une SupportResponse validée — vous obtenez l'UX du streaming sans renoncer à la structure.

Étape 8 : Changer de fournisseur sans réécrire le code

Le premier argument d'Agent est un identifiant de modèle. Changez une seule chaîne et l'agent s'exécute contre un autre fournisseur. Pour les environnements où le choix est dynamique, utilisez directement pydantic_ai.models :

import os
 
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIModel
 
 
def pick_model():
    name = os.getenv("AGENT_MODEL", "openai:gpt-4o-mini")
    if name.startswith("openai:"):
        return OpenAIModel(name.split(":", 1)[1])
    if name.startswith("anthropic:"):
        return AnthropicModel(name.split(":", 1)[1])
    raise ValueError(f"Unknown model: {name}")
 
 
support_agent = Agent(
    pick_model(),
    deps_type=SupportDeps,
    output_type=SupportResponse,
    system_prompt="...",
)

Maintenant AGENT_MODEL=anthropic:claude-sonnet-4-6 python main.py exécute le même agent sur Claude Sonnet 4.6 sans aucun autre changement de code. Outils, sorties structurées et injection de dépendances fonctionnent tous à l'identique car Pydantic AI normalise pour vous les différences entre fournisseurs.

Étape 9 : Ajouter l'observabilité avec Logfire

Pydantic AI est conçu par l'équipe Pydantic, donc il s'intègre nativement avec Logfire — leur produit d'observabilité basé sur OpenTelemetry. Chaque appel de modèle, invocation d'outil, retry et erreur de validation devient un span que vous pouvez chercher.

Inscrivez-vous sur logfire.pydantic.dev et récupérez un write token. Ensuite :

# observability.py
import logfire
 
logfire.configure(token="your-write-token", service_name="support-agent")
logfire.instrument_pydantic_ai()

Importez ce module une seule fois en haut d'api.py. Redémarrez le serveur et envoyez quelques requêtes de chat. Dans l'interface Logfire vous verrez :

  • Un span racine par exécution d'agent
  • Des spans enfants pour chaque appel de modèle, avec prompt, réponse et compte de tokens
  • Des spans d'outils montrant arguments, valeurs de retour et durée
  • Des spans de validation quand Pydantic AI réessaie sur une mauvaise sortie

Pour de l'observabilité auto-hébergée, remplacez l'appel par logfire.configure(send_to_logfire=False) et pointez OTLP standard vers votre propre collecteur. L'instrumentation reste la même.

Étape 10 : Tester l'agent sans brûler de tokens

Le pydantic_ai.models.test.TestModel vous permet d'exécuter des tests d'agent de bout en bout avec zéro appel réseau. Il renvoie une réponse structurée déterministe qui correspond à votre output_type, et vous pouvez asserter sur les appels d'outils que l'agent a effectués.

# test_agent.py
import pytest
from pydantic_ai.models.test import TestModel
 
from agent import support_agent
from deps import SupportDeps
 
 
@pytest.mark.asyncio
async def test_refund_flow(tmp_db):
    deps = SupportDeps(db=tmp_db, customer_id=1)
    with support_agent.override(model=TestModel()):
        result = await support_agent.run(
            "Refund 50 USD for order 4421",
            deps=deps,
        )
    tool_calls = [m for m in result.all_messages() if m.kind == "tool-call"]
    assert any(t.tool_name == "issue_refund" for t in tool_calls)
    assert isinstance(result.output.answer, str)
    assert 0 <= result.output.confidence <= 1

Ajoutez pytest et pytest-asyncio à vos dépendances de développement et exécutez :

pytest -v

La suite complète se termine en quelques millisecondes car aucun vrai LLM n'est impliqué. Utilisez TestModel pour les tests unitaires, puis ajoutez un petit ensemble de tests d'intégration qui appellent un vrai fournisseur sur chaque release candidate.

Tester votre implémentation

Reparcourez le chemin nominal complet une fois de plus :

  1. python main.py renvoie une SupportResponse avec needs_human=False et un remboursement enregistré dans SQLite
  2. curl contre /chat stream les tokens et termine avec une sortie structurée
  3. AGENT_MODEL=anthropic:claude-sonnet-4-6 python main.py produit la même forme sur Claude
  4. Logfire affiche un arbre de spans avec appels d'outils et usage de tokens
  5. pytest passe en moins d'une seconde avec TestModel

Si l'un de ces points échoue, les coupables les plus fréquents sont des clés API manquantes, un extra fournisseur dépassé (lancez uv pip install --upgrade "pydantic-ai[openai,anthropic]"), ou une fonction outil qui ne déclare pas de types que Pydantic peut introspecter.

Dépannage

Le modèle continue à renvoyer du texte libre au lieu de sortie structurée. Assurez-vous que output_type est défini sur l'Agent et que vous ne demandez pas aussi du texte libre dans le system prompt. Pydantic AI utilise le tool calling sous le capot ; certains modèles plus anciens doivent être épinglés sur une variante capable de function calling.

Les erreurs de validation bouclent sans fin. Pydantic AI réessaie jusqu'à retries=1 par défaut. Augmentez avec Agent(..., retries=3) pour les modèles instables, mais si un champ est impossible à satisfaire, vous brûlerez des tokens. Lisez l'erreur de validation attentivement — elle pointe généralement vers une contrainte Field trop stricte.

Les outils ne sont jamais appelés. Vérifiez que vous les avez décorés avec @agent.tool (et non @agent.tool_plain sauf si vous voulez sauter RunContext) et que leurs docstrings décrivent quand les appeler. Les modèles s'appuient massivement sur les descriptions d'outils pour décider.

L'endpoint de streaming renvoie tout le message d'un coup. C'est du buffering FastAPI. Assurez-vous que vous renvoyez une StreamingResponse et que vous n'awaitez pas le générateur avant de yield.

Prochaines étapes

Conclusion

Pydantic AI prend les parties du développement web Python qui fonctionnent déjà — schémas typés, injection de dépendances et API async-first — et les applique aux agents LLM. Vous arrêtez de penser au parsing JSON et aux chaînes en forme de prompt et commencez à penser en contrats : que renvoie mon agent, quels outils peut-il appeler, et de quoi a-t-il besoin pour faire son travail. Le résultat est du code d'agent qui ressemble au reste de votre codebase Python, qui se teste comme le reste de votre codebase Python, et qui se livre avec la même confiance que le reste de votre codebase Python.

Construisez le bot de support ci-dessus, instrumentez-le avec Logfire, et la prochaine fois qu'on vous demandera comment vous gérez les sorties LLM structurées, vous pourrez pointer vers une suite de tests qui passe au lieu d'une regex pleine d'espoir.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Zustand + Next.js App Router : Gestion d'État React Moderne du Zéro à la Production.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire un Agent IA Autonome avec Agentic RAG et Next.js

Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

30 min read·