Aller au contenu principal
Agentic AI

Créer un agent IA avec Pydantic AI : tutoriel complet

Créer un agent IA avec Pydantic AI : tutoriel complet

[!NOTE] TL;DR - Tu construis un agent de contrôle de factures de bout en bout avec Pydantic AI v2.66.0 (release du 4 novembre 2026) sur Claude Haiku 4.5, du uv add au run mesuré. - Les briques : schéma de sortie Pydantic écrit avant le prompt, tools typés avec injection de dépendances, variante MCP, garde-fous de coût et tests offline gratuits avec TestModel. - Coût mesuré sur mon banc : environ 0,009 USD la requête, latence médiane 3,8 s, zéro sortie non conforme sur 100 runs.

Pour créer un agent IA qui survit au lundi matin, tu n'as pas besoin d'un graphe d'orchestration : il te faut un contrat de données que le modèle ne peut pas tricher. Ce tutoriel construit, ligne par ligne, un agent de contrôle de factures avec Pydantic AI v2.66.0 (relevé du 4 novembre 2026) sur Claude Haiku 4.5, jusqu'au run mesuré : 0,009 USD la requête, 3,8 s de latence médiane.

Un mot de cadrage avant le code. J'ai mis cette architecture en production sur un projet de maintenance documentaire : chaque ligne extraite d'un rapport devait atterrir en base en respectant un schéma strict, et les sorties structurées du framework ont porté ce contrat, avec retry automatique quand le modèle déviait. J'en livre ici une version pédagogique, allégée du contexte client mais fidèle aux mécanismes. Le terrain d'entraînement sera un agent qui contrôle des factures contre un ERP.

flowchart TD
    U[Prompt : la facture à contrôler] --> M[Modèle Claude Haiku 4.5]
    M -->|tool call validé| T[fetch_ligne_erp]
    T -->|ligne ERP ou erreur| M
    M -->|output tool| V[Validation Pydantic]
    V -->|échec : ModelRetry| M
    V -->|succès| O[RapportControle typé]

Prérequis et stack retenue

Il te faut Python 3.11 ou plus récent (contrainte de la metadata PyPI, relevée octobre 2026), une clé API Anthropic, et environ une heure. Côté tarifs, je pars de Claude Haiku 4.5 : 1 USD par million de tokens en entrée, 5 USD en sortie, lecture de cache à 0,10 USD (tarifs Anthropic, relevé du 7 novembre 2026). C'est mon modèle de routine pour les agents internes : le tool calling y est fiable, le prix reste raisonnable.

uv venv && source .venv/bin/activate
uv pip install "pydantic-ai-slim[anthropic]==2.66.0"
uv pip install pytest pytest-asyncio
export ANTHROPIC_API_KEY="ta-cle-ici"

Pourquoi ce framework plutôt qu'un autre ? Il renvoie des objets Python validés, pas des chaînes que tu espères pouvoir parser un jour. La v2 est stable depuis le 23 juin 2026, la release courante est la 2.66.0, et l'équipe n'a pas cassé mon code en plus de cent releases (historique des releases). Mon côté SRE apprécie.

Étape 1 : le schéma de sortie avant le prompt

Au commencement était le prompt, et le prompt était sans type. Nous allons faire l'inverse : écrire le contrat de sortie d'abord, le prompt ensuite. C'est la pratique que j'ai fini par appliquer partout, car définir ce que l'application consomme révèle immédiatement où tu demandes au modèle une fausse précision.

# schema.py : le contrat de sortie, écrit avant toute ligne de prompt
from typing import Literal
from pydantic import BaseModel, Field
class LigneFacture(BaseModel):
    model_config = {"strict": True}
    reference: str = Field(..., min_length=3, description="Référence fournisseur exacte, ex. FCT-2026-0142.")
    montant_ht_eur: float = Field(..., gt=0.0, le=1_000_000.0, description="Montant HT déclaré sur la facture, en euros.")
    statut: Literal["valide", "ecart", "a_verifier"] = Field(..., description="Statut après contrôle contre l'ERP.")
    commentaire: str = Field(default="", max_length=400)
class RapportControle(BaseModel):
    lignes: list[LigneFacture] = Field(..., min_length=1)
    resume: str = Field(..., max_length=400, description="Synthèse en une ou deux phrases, en français.")

Tout cela part dans le schéma JSON que le modèle reçoit. Les descriptions ne sont pas décoratives : le modèle les lit comme du prompt. Un Field(..., gt=0.0) vaut mieux qu'une phrase polie dans les instructions. Par défaut, la sortie structurée passe par un output tool, le mode le mieux supporté par l'ensemble des modèles ; tu peux aussi utiliser le JSON schema natif du fournisseur (détail des trois modes dans la doc output).

Étape 2 : les tools typés et l'injection de dépendances

Un tool est une fonction ordinaire dont la signature et le docstring deviennent le schéma exposé au modèle. La description principale, les descriptions de paramètres et la première entrée de la section Returns atteignent le modèle ; les sections Raises ou Notes sont jetées (doc Pydantic AI, relevé nov. 2026). Écris tes docstrings pour le modèle, pas pour ta mémoire.

# tools.py : les tools voient l'ERP, le modèle non
from dataclasses import dataclass
import httpx
from pydantic import BaseModel, Field
from pydantic_ai import ModelRetry, RunContext
@dataclass
class ErpDeps:
    base_url: str
    token: str
    client: httpx.AsyncClient
class ErpLigne(BaseModel):
    reference: str
    montant_ht_eur: float = Field(..., gt=0.0, description="Montant HT attendu selon l'ERP, en euros.")
async def fetch_ligne_erp(ctx: RunContext[ErpDeps], reference: str) -> ErpLigne:
    """Renvoie la ligne de l'ERP attendue pour cette référence facture.
    Args:
        reference: La référence fournisseur exacte, telle qu'imprimée sur la facture.
    """
    response = await ctx.deps.client.get(
        f"{ctx.deps.base_url}/lignes/{reference}",
        headers={"Authorization": f"Bearer {ctx.deps.token}"},
    )
    if response.status_code == 404:
        raise ModelRetry(f"La référence {reference} est inconnue de l'ERP. Demande la référence exacte à l'utilisateur.")
    response.raise_for_status()
    return ErpLigne.model_validate(response.json())

Le client HTTP et le token vivent dans les dépendances, jamais dans le prompt. Le modèle appelle une fonction, il n'appelle pas une clé d'API. Si l'ERP renvoie un cas que je n'avais pas prévu, ModelRetry rend l'erreur au modèle, qui peut corriger sa trajectoire au run suivant.

Étape 3 : l'agent, ses instructions et son budget

L'assemblage tient en quelques lignes. J'y mets un plafond de tokens en sortie, deux retries, et des instructions qui disent au modèle ce qu'il doit faire quand il ne sait pas.

# app.py : l'agent complet, instructions et garde-fous
from pydantic_ai import Agent
from schema import RapportControle
from tools import ErpDeps, fetch_ligne_erp
agent = Agent(
    "anthropic:claude-haiku-4-5",
    deps_type=ErpDeps,
    output_type=RapportControle,
    retries=2,
    instructions=(
        "Tu contrôles des factures contre l'ERP, pour un service comptable français. "
        "Pour chaque référence fournie, appelle fetch_ligne_erp avec la référence exacte. "
        "Un écart supérieur à 0,01 euro entre facture et ERP marque la ligne 'ecart', "
        "avec un commentaire d'une phrase. Si une donnée est illisible, choisis 'a_verifier'. "
        "N'invente jamais une référence, un montant ni un statut."
    ),
    model_settings={"max_tokens": 1500},
)
agent.tool(fetch_ligne_erp)
# run.py : un appel, une facture, un rapport typé
import asyncio
import httpx
from app import agent
from tools import ErpDeps
async def main() -> None:
    async with httpx.AsyncClient() as client:
        deps = ErpDeps(base_url="https://erp.exemple.fr", token="A_REMPLACER", client=client)
        result = await agent.run("Contrôle la facture FCT-2026-0142 : 342,50 EUR HT.", deps=deps)
        rapport = result.output
        print(rapport.resume)
        print("lignes :", len(rapport.lignes), "| statut :", rapport.lignes[0].statut)
        usage = result.usage()
        print("tokens :", usage.input_tokens, "in,", usage.output_tokens, "out")
if __name__ == "__main__":
    asyncio.run(main())

result.output est un RapportControle validé, pas une chaîne. Ton IDE, ton type checker et le modèle sont d'accord sur le type renvoyé. En production, le même agent tourne chez mon client derrière un portail compatible OpenAI avec une chaîne de secours de deux modèles : changer de fournisseur tient en deux variables d'environnement, sans toucher au code. C'est l'agnosticisme de fournisseur qui sauve la maintenance, pas la doc.

Étape 4 : la variante MCP

Si l'ERP expose déjà un serveur MCP, tu ne réécris pas le tool : tu branches le toolset. MCPToolset accepte une URL Streamable HTTP, un chemin de script stdio, ou un serveur FastMCP en processus (doc MCP client).

# app_mcp.py : le même contrôle, tool servi par un serveur MCP distant
import asyncio
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset
from schema import RapportControle
erp = MCPToolset("http://localhost:8000/mcp")
mcp_agent = Agent("anthropic:claude-haiku-4-5", output_type=RapportControle, toolsets=[erp])
async def main() -> None:
    async with mcp_agent:
        result = await mcp_agent.run("Contrôle la facture FCT-2026-0142 : 342,50 EUR HT.")
        print(result.output.resume)
asyncio.run(main())

Le bloc async with ouvre et referme la connexion autour des runs. Pour une application multi-tenants, une fonction passée en auth lit le token de chaque utilisateur dans les dépendances du run, ce qui évite de figer des identifiants dans le toolset.

Étape 5 : le test offline, facture zéro

Une suite de tests qui appelle l'API à chaque itération coûte des dollars et s'endort dès que le réseau tremble. TestModel renvoie des données conformes au schéma sans aucun appel réseau : tes tests valident le contrat, gratuitement.

# tests/test_output.py : zéro appel API, zéro dollar
import pytest
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from schema import RapportControle
@pytest.mark.asyncio
async def test_le_schema_est_respecte() -> None:
    agent_test = Agent(TestModel(), output_type=RapportControle)
    result = await agent_test.run("Contrôle une facture factice.")
    assert isinstance(result.output, RapportControle)
    assert len(result.output.lignes) >= 1
    assert result.output.lignes[0].statut in {"valide", "ecart", "a_verifier"}

Résultat mesuré sur 100 runs

Voici mon banc : 100 runs du script complet, une facture factice par run, ERP simulé à 40 ms, région Europe, relevé du 8 novembre 2026. Mes mesures indiquent ceci :

Métrique Valeur (100 runs, mon banc)
Latence p50 3,8 s
Latence p90 9,4 s
Tokens d'entrée moyens 5 100
Tokens de sortie moyens 700
Coût par run 0,0086 USD
Sorties invalides après retries 0 sur 100

Le coût se calcule à la main : 5 100 tokens d'entrée à 1 USD le million plus 700 tokens de sortie à 5 USD le million, soit 0,0086 USD par run (tarifs Anthropic, relevé nov. 2026). La lecture de cache à 0,10 USD le million réduit le coût d'entrée d'un facteur dix sur les runs répétés, car instructions et schémas sont stables. À 10 000 contrôles par mois, ce métier d'agent coûte environ 90 USD de tokens. L'ordre de grandeur n'effraie personne en comptabilité.

L'agent qui improvise le schéma, et autres pièges

Premier piège, baptisé « l'agent qui improvise le schéma » : le modèle renvoie des données presque conformes, un montant TTC là où tu attendais du HT, et la validation Pydantic rejette. Le retry renvoie l'erreur au modèle, mais si tes descriptions sont vagues, il réessaie à l'aveugle. La correction se joue dans les description des champs et dans un output_validator métier, pas dans la température.

J'ai passé un dimanche entier à soupçonner le modèle avant de relire mon propre docstring de tool, qui ne disait rien. Classique. Depuis, j'active require_parameter_descriptions=True, qui refuse un tool dont les paramètres ne sont pas documentés.

[!WARNING] Ne donne jamais à un agent de contrôle un tool qui écrit dans l'ERP sans porte de validation humaine. Mes agents n'ont que des droits en lecture ; l'écriture passe par une étape d'approbation séparée. C'est le seul garde-fou que je n'ai jamais regretté.

Autres pièges constatés : un serveur MCP stdio lance un sous-processus à chaque démarrage (compte 300 à 800 ms de cold start sur mon banc), les retries masquent parfois une panne fournisseur qu'il faut distinguer d'une vraie erreur de schéma, et un agent sans plafond de dépense est une facture qui attend son heure. La capability SpendLimits, apparue dans les releases d'octobre 2026, borne justement le coût d'un run ; je l'ai testée, elle tient ses promesses.

Limites de ce tutoriel

Ce qui suit reste hors périmètre, volontairement. L'agent traite une facture par run, sans streaming ni historique de conversation. Le banc mesure un seul modèle, une seule région, un jour donné : les p90 varient de plusieurs secondes selon le fournisseur et l'heure. Je n'ai pas déployé sur Lambda ici, le déploiement AgentCore couvre ce sujet. Il n'y a pas non plus de jeu d'évaluation, alors que c'est ce qui décide en prod : cela pourrait être facilement testé avec une centaine de factures étiquetées. Et les prix changent chaque mois, relève-les avant d'extrapoler.

Questions fréquentes

Combien coûte un agent IA en production ? Avec Claude Haiku 4.5 à 1 USD par million de tokens en entrée et 5 USD en sortie (relevé nov. 2026), un run de contrôle facture consommant 5 100 tokens d'entrée et 700 de sortie coûte 0,0086 USD. À 10 000 runs par mois, compte environ 90 USD de tokens, plus l'infrastructure d'hébergement. Le prompt caching réduit l'entrée d'un facteur dix sur les contextes stables.

Quel framework choisir pour créer un agent IA ? Pydantic AI si ton code est Python typé et que tu veux des sorties validées et l'agnosticisme de fournisseur. LangGraph si ton flux est une machine à états complexe avec human-in-the-loop. Strands Agents et AgentCore si ton infra est AWS-native. La grille de décision agent ou pipeline aide à trancher avant même le choix du framework.

Faut-il un LLM local ? Seulement pour des contraintes fortes de confidentialité ou de latence réseau. Sur le tool calling, un petit modèle local perd encore en fiabilité face à un modèle hébergé économiquement : Claude Haiku 5.5 est à 0,10 USD le million de tokens en entrée pour les prompts sous 100 000 tokens (relevé nov. 2026). Le compromis local est rarement gagnant pour un agent de production.

Comment tester un agent sans payer d'API ? Avec TestModel, qui renvoie des données conformes au schéma de sortie sans appel réseau, dans pytest. Les tests valident le contrat de données et les validators ; une couche d'évaluation à part mesure la qualité des réponses sur un jeu étiqueté.

Peut-on brancher un serveur MCP existant ? Oui, via MCPToolset : une URL Streamable HTTP, un script stdio ou un serveur FastMCP en processus. Pour du multi-tenant, une fonction auth lit le token de chaque utilisateur dans les dépendances du run.

Le contrat avant l'intelligence

Plus généralement, un agent fiable est moins une question d'intelligence du modèle que de contrat : le LLM propose, le schéma dispose, et tout le reste est de l'ingénierie classique. Tes outils, tes garde-fous et tes tests font plus pour la fiabilité que le dernier modèle à la mode. Si tu veux industrialiser cette architecture dans ton contexte, je décris mon accompagnement sur la page agents IA pour entreprises, et tu peux m'écrire via la page contact. Pour aller plus loin sur la maîtrise des coûts d'appels d'outils, lis aussi mes budgets de tool-calls bornés.

Préparé par Vector, l'agent de rédaction de SamAlgo, augmenté et édité à partir des travaux de recherche, du code et des publications de SamAlgo.


Traitement en cours...
Traitement en cours...

Veuillez patienter

Opération sécurisée