Demander à un LLM de renvoyer du JSON, c'est le grand classique du développement d'applications IA. Et c'est aussi l'une des sources de bugs les plus frustrantes : le modèle ajoute un commentaire avant le JSON, oublie une accolade, renvoie parfois un champ en trop ou un type inattendu. Résultat, ton parsing plante, et tu empiles des regex et des try/except pour tenter de sauver les meubles. Instructor met fin à ce bricolage en inversant le problème : au lieu de parser du JSON fragile, tu décris une structure Pydantic, et le modèle s'engage à la remplir correctement.
Instructor fonctionne comme une fine couche au-dessus des clients OpenAI, Anthropic, Mistral et bien d'autres. Tu lui donnes une classe Pydantic, il pilote le modèle pour produire une sortie qui valide exactement cette classe, avec re-demande automatique en cas d'erreur. Tu récupères en retour un objet Python proprement typé, pas une chaîne JSON à décoder.
Ce tutoriel te prend par la main : de ta première extraction à la gestion des schémas complexes, des retries et du multi-fournisseur, jusqu'aux pipelines d'extraction sur des textes longs. Un seul objectif en ligne de mire : ne plus jamais écrire un json.loads défensif pour compenser les caprices d'un modèle.
Le problème du JSON instable
Sans outil dédié, obtenir du JSON fiable depuis un LLM demande de jongler avec :
- Le prompt engineering : répéter « réponds uniquement en JSON valide, sans texte autour ».
- Le nettoyage : retirer les blocs de code Markdown, les commentaires, les virgules de trop.
- La validation : vérifier que chaque champ existe, avec le bon type, dans les bonnes limites.
- Les retries : redemander au modèle quand la sortie est invalide, en espérant qu'il corrige.
Chaque étape est une source d'erreur. Instructor encapsule tout cela : la description du schéma sert à la fois de contrat pour le modèle et de validation stricte côté Python. Un seul objet Pydantic remplace des dizaines de lignes de code défensif.
Installation
Instructor s'appuie sur Pydantic, la bibliothèque de validation de données la plus populaire de l'écosystème Python. Si tu connais déjà Pydantic, tu connais déjà 80% d'Instructor.
pip install instructor pydanticIl te faut aussi une clé API pour au moins un fournisseur (OpenAI dans les exemples). La variable d'environnement OPENAI_API_KEY doit être définie avant de créer le client.
Ta première extraction structurée
Le flux de base tient en quatre étapes : envelopper le client, définir une classe Pydantic, appeler le modèle avec response_model, et récupérer un objet typé.
import instructor
from openai import OpenAI
from pydantic import BaseModel
# 1. On enveloppe le client OpenAI avec Instructor
client = instructor.from_openai(OpenAI())
# 2. On décrit la structure attendue avec Pydantic
class Utilisateur(BaseModel):
nom: str
age: int
# 3. On appelle le modèle en lui demandant de remplir cette structure
utilisateur = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Utilisateur,
messages=[{"role": "user", "content": "Jean a 34 ans et habite à Lyon."}],
)
# 4. On récupère un objet Python typé, pas du JSON à parser
print(utilisateur.nom) # Jean
print(utilisateur.age) # 34
print(type(utilisateur)) # <class '__main__.Utilisateur'>Remarque la dernière ligne : type(utilisateur) renvoie bien la classe Utilisateur. Ce n'est pas un dict, pas une chaîne : c'est un objet Python avec des attributs typés, l'auto-complétion de ton éditeur et la garantie de validation. Si le modèle avait renvoyé un âge sous forme de texte, Instructor aurait déclenché une re-demande pour corriger.
Modèles imbriqués et listes
La vraie puissance apparaît quand la structure se complexifie. Pydantic permet d'imbriquer des modèles, de déclarer des listes, des énumérations, des unions. Le modèle remplit l'ensemble de la structure d'un coup.
from pydantic import BaseModel, Field
class Adresse(BaseModel):
rue: str
ville: str
code_postal: str
class Personne(BaseModel):
nom: str
age: int = Field(ge=0, le=120, description="Âge en années")
adresse: Adresse
loisirs: list[str]
client = instructor.from_openai(OpenAI())
personne = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Personne,
messages=[{
"role": "user",
"content": (
"Marie Dupont, 42 ans, vit au 12 rue des Lilas, 75011 Paris. "
"Elle aime la lecture, la randonnée et la cuisine."
),
}],
)
print(personne.nom) # Marie Dupont
print(personne.age) # 42
print(personne.adresse.ville) # Paris
print(personne.loisirs) # ['lecture', 'randonnée', 'cuisine']Ici, Personne contient une Adresse (elle-même un modèle) et une liste de chaînes. Le LLM comprend la structure demandée et produit une sortie qui la respecte, y compris l'imbrication. C'est ce qui permet d'extraire des données réellement riches à partir d'un texte libre.
Valider avec les contraintes Pydantic
Les contraintes Field ne servent pas qu'à documenter : elles sont réellement vérifiées. Si la sortie du modèle les viole, Instructor la rejette et redemande une correction, en transmettant le message d'erreur au modèle pour qu'il comprenne ce qui ne va pas.
from pydantic import BaseModel, Field
class Produit(BaseModel):
nom: str = Field(min_length=2, description="Nom du produit")
prix: float = Field(gt=0, description="Prix en euros, strictement positif")
categorie: str = Field(pattern="^(livre|film|jeu)$", description="Catégorie autorisée")
note: int = Field(ge=1, le=5, description="Note sur 5")
client = instructor.from_openai(OpenAI())
# Le texte contient une incohérence : une note de 9 (hors limites 1-5).
# Instructor détecte l'échec de validation et redemande au modèle.
produit = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Produit,
messages=[{
"role": "user",
"content": "Le produit s'appelle 'X', il coûte 12 euros, catégorie livre, note 9/5.",
}],
)
print(produit)Dans cet exemple, la note 9 dépasse la borne le=5. Instructor détecte l'échec de validation Pydantic, reformule la demande en expliquant l'erreur au modèle, et obtient une valeur conforme. Ce cycle validation-correction est automatique et invisible.
Le système de retries
Le nombre de tentatives de correction est configurable globalement ou au cas par cas. Par défaut, Instructor fait une tentative, puis une re-demande ; en augmentant max_retries, tu tolères des modèles plus capricieux ou des schémas plus exigeants.
import instructor
from openai import OpenAI
# max_retries : nombre maximal de tentatives de correction
# quand la sortie du modèle ne valide pas le schéma Pydantic.
client = instructor.from_openai(OpenAI(), max_retries=3)
# On peut aussi le fixer au niveau de l'appel individuel
utilisateur = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Utilisateur,
max_retries=5,
messages=[{"role": "user", "content": "Jean a 34 ans."}],
)Si le modèle échoue à produire une sortie valide après toutes les tentatives, Instructor lève une exception explicite plutôt que de renvoyer silencieusement un objet invalide. C'est un comportement sain : une erreur bruyante vaut mieux qu'une donnée corrompue qui passe inaperçue.
Choisir le mode : tools, json ou MD_JSON
Tous les modèles n'offrent pas les mêmes capacités de structuration. Instructor propose plusieurs modes pour s'adapter : le function calling natif, le mode JSON strict, ou le JSON dans un bloc Markdown.
import instructor
from openai import OpenAI
# Trois stratégies pour obtenir du JSON structuré :
# 1. TOOLS (défaut) : utilise le "function calling" natif du modèle.
# Le plus fiable, recommandé dans la majorité des cas.
client = instructor.from_openai(OpenAI(), mode=instructor.Mode.TOOLS)
# 2. JSON : force le mode JSON natif (json_object). Rapide et économique,
# mais nécessite que le modèle supporte response_format json_object.
client = instructor.from_openai(OpenAI(), mode=instructor.Mode.JSON)
# 3. MD_JSON : demande du JSON dans un bloc Markdown, puis le parse.
# Utile avec des modèles qui ne supportent ni tools ni mode JSON.
client = instructor.from_openai(OpenAI(), mode=instructor.Mode.MD_JSON)Le mode TOOLS (défaut) est le plus robuste car il s'appuie sur le function calling natif du modèle. Le mode JSON est plus léger mais exige un modèle qui le supporte. Le mode MD_JSON est le filet de sécurité universel, utile avec des modèles plus anciens ou des fournisseurs exotiques.
Multi-fournisseurs
Instructor ne se limite pas à OpenAI. La même logique s'applique à Anthropic, Mistral, Google Gemini, Cohere, et même aux serveurs locaux compatibles OpenAI (Ollama, vLLM, LM Studio). Une fois le client enveloppé, le code d'extraction est identique.
import instructor
from pydantic import BaseModel
class Resume(BaseModel):
titre: str
points: list[str]
# Anthropic (Claude)
import anthropic
client_anthropic = instructor.from_anthropic(anthropic.Anthropic())
# Mistral
from mistralai import Mistral
client_mistral = instructor.from_mistral(Mistral())
# Google Gemini
import google.generativeai as genai
client_gemini = instructor.from_gemini(
client=genai.GenerativeModel(
model_name="models/gemini-1.5-flash-latest",
),
mode=instructor.Mode.GEMINI_JSON,
)
# La suite du code est identique, quel que soit le fournisseur
resume = client_anthropic.chat.completions.create(
model="claude-3-5-sonnet-20241022",
response_model=Resume,
messages=[{"role": "user", "content": "Résume : l'IA transforme la santé."}],
)
print(resume.titre)Cette portabilité est précieuse : tu peux écrire ton pipeline d'extraction une seule fois et le faire tourner sur le modèle le moins cher, le plus rapide ou le plus privé selon les besoins, sans réécrire la logique métier.
L'asynchrone
Pour les pipelines qui traitent de gros volumes, Instructor fournit des clients asynchrones. Combiné à asyncio.gather, tu parallélises les extractions et divises le temps de traitement par le nombre de requêtes simultanées.
import asyncio
import instructor
from openai import AsyncOpenAI
from pydantic import BaseModel
class Utilisateur(BaseModel):
nom: str
age: int
client = instructor.from_openai(AsyncOpenAI())
async def extraire(texte):
return await client.chat.completions.create(
model="gpt-4o-mini",
response_model=Utilisateur,
messages=[{"role": "user", "content": texte}],
)
async def main():
# Deux extractions en parallèle
u1, u2 = await asyncio.gather(
extraire("Jean a 34 ans."),
extraire("Sophie a 27 ans."),
)
print(u1.nom, u1.age)
print(u2.nom, u2.age)
asyncio.run(main())Streaming et résultats partiels
Pour les réponses longues ou les interfaces temps réel, create_partial renvoie un itérable qui produit l'objet au fur et à mesure de sa construction. Tu peux afficher la progression champ par champ.
import instructor
from openai import OpenAI
from pydantic import BaseModel
class Utilisateur(BaseModel):
nom: str
age: int
ville: str
client = instructor.from_openai(OpenAI())
# create_partial renvoie un itérable : on reçoit l'objet
# au fur et à mesure qu'il se remplit, champ par champ.
for utilisateur in client.chat.completions.create_partial(
model="gpt-4o-mini",
response_model=Utilisateur,
messages=[{"role": "user", "content": "Jean a 34 ans et vit à Lyon."}],
):
print(utilisateur) # affiche l'objet partiel à chaque étapeÀ chaque itération, l'objet partiel contient les champs déjà complétés (les autres valent None). C'est idéal pour du streaming d'interface ou pour interrompre une génération dès que tu as l'information qu'il te faut.
Les pièges courants
- Schéma trop vague : un champ nommé data sans description donne des résultats imprévisibles. Documente chaque champ avec description.
- Contraintes irréalistes : un Field trop strict (ex. pattern impossible) provoque des échecs en cascade. Garde des bornes larges.
- Oubli de la clé API : la variable d'environnement du fournisseur doit être définie avant la création du client.
- Mauvais mode pour le modèle : un modèle sans function calling échouera en mode TOOLS. Bascule sur JSON ou MD_JSON.
- max_retries trop bas : pour des schémas complexes, autorise quelques tentatives de correction avant d'abandonner.
Itérer sur une liste de résultats
Quand le modèle doit produire plusieurs éléments, create_iterable les diffuse au fur et à mesure, sous forme d'objets Pydantic déjà validés. Tu n'as pas à découper un tableau JSON à la main, et tu peux commencer à traiter les premiers résultats avant même que les derniers soient générés.
import instructor
from openai import OpenAI
from pydantic import BaseModel
class Article(BaseModel):
titre: str
resume: str
client = instructor.from_openai(OpenAI())
for article in client.chat.completions.create_iterable(
model="gpt-4o-mini",
response_model=Article,
messages=[{
"role": "user",
"content": "Liste trois actualités IA du jour, avec un résumé.",
}],
):
print("-", article.titre)
print(" ", article.resume)Chaque itération renvoie un objet Article complet et validé. Cette API est idéale pour l'extraction de listes d'entités, la génération de jeux de données ou la structuration de documents longs en séries d'éléments homogènes.
Unions et champs optionnels
La réalité n'est pas toujours uniforme : un message peut être tantôt une simple phrase, tantôt une commande structurée. Pydantic gère cela avec les unions discriminées, et Instructor s'en accommode parfaitement. Le modèle choisit lui-même la classe qui correspond à l'entrée.
from typing import Annotated, Union, Literal
from pydantic import BaseModel, Field
import instructor
from openai import OpenAI
class Message(BaseModel):
type: Literal["message"]
texte: str
class Commande(BaseModel):
type: Literal["commande"]
action: str
parametres: dict
# Union discriminée : le champ "type" détermine la classe retenue
Action = Annotated[Union[Message, Commande], Field(discriminator="type")]
client = instructor.from_openai(OpenAI())
action = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Action,
messages=[{"role": "user", "content": "Programme une alarme à 7h demain."}],
)
print(type(action).__name__) # Commande
print(action.action)Les champs optionnels (Optional[...]) fonctionnent sur le même principe : le modèle renseigne le champ s'il trouve l'information, le laisse à None sinon. Cette souplesse est indispensable pour l'extraction depuis des textes hétérogènes où toutes les informations ne sont pas toujours présentes.
Valider avec tes propres règles
Au-delà des contraintes prédéfinies de Field, tu peux écrire des validateurs Pydantic sur mesure. L'intérêt est double : la règle est appliquée strictement, et si elle échoue, Instructor transmet l'erreur au modèle pour qu'il se corrige. C'est un pont direct entre tes règles métier et la génération.
from pydantic import BaseModel, field_validator
import instructor
from openai import OpenAI
class Utilisateur(BaseModel):
nom: str
email: str
@field_validator("email")
@classmethod
def email_valide(cls, v):
if "@" not in v or "." not in v:
raise ValueError("adresse email invalide")
return v
client = instructor.from_openai(OpenAI())
# Le modèle fournit un email mal formé : le validateur le refuse,
# et Instructor redemande une valeur correcte.
utilisateur = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Utilisateur,
messages=[{"role": "user", "content": "Jean, email: jean.sans-arobase"}],
)
print(utilisateur.email)Cette mécanique transforme Pydantic en véritable langage de contraintes métier : format d'email, plages de dates, cohérence entre champs, tout ce que tu sais exprimer en Python devient une règle que le modèle est contraint de respecter.
Instructor face aux alternatives
Instructor n'est pas la seule façon d'obtenir du JSON structuré. Pour situer l'outil, voici comment il se compare aux approches voisines :
- Function calling brut : tu écris toi-même le schéma, tu valides la sortie et tu gères les retries. Instructor automatise exactement ce travail, avec moins de code.
- LangChain et ses output parsers : un écosystème riche mais lourd, avec de nombreuses couches d'abstraction. Instructor se concentre sur une seule chose et la fait proprement.
- Guidance ou Outlines : une génération contrainte au niveau des tokens, très puissante pour des formats exotiques, mais plus complexe à mettre en œuvre. Instructor couvre l'immense majorité des besoins avec Pydantic.
Le point commun de ces alternatives : elles exigent de comprendre le mécanisme de structuration du modèle. Instructor, lui, te laisse raisonner uniquement en termes de classes Pydantic, ce qui réduit considérablement la charge cognitive.
Cas d'usage concrets
Partout où un LLM doit produire une sortie exploitable par du code, Instructor a sa place. Quelques scénarios typiques :
- Extraction d'entités depuis des documents : factures, CV, contrats transformés en objets exploitables.
- Classification de textes avec une justification structurée plutôt qu'une simple étiquette.
- Génération de jeux de données synthétiques typés pour tester une application.
- Structuration des réponses d'un chatbot : intention, entités détectées, prochaine action.
- Conversion de texte libre en JSON pour alimenter une API ou une base de données.
Dans chacun de ces cas, la sortie structurée est le produit, pas un détail. La fiabilité qu'apporte Instructor, avec ses retries et sa validation, fait la différence entre un prototype qui marche sur un exemple et un composant digne de la production.
Observabilité et journalisation
Quand une extraction échoue ou se répète, tu veux comprendre pourquoi. Instructor journalise ses appels et ses tentatives de correction via le logger Python standard, ce qui te permet de suivre le dialogue avec le modèle.
import logging
# Affiche les appels et les cycles de correction d'Instructor
logging.basicConfig(level=logging.INFO)
# En production, branche un système d'observabilité (Langfuse, LangSmith)
# via les callbacks du client pour tracer chaque extraction et son coût.Une bonne visibilité sur les corrections est précieuse : si un modèle retente sans cesse sur un champ, c'est souvent le signe d'une contrainte mal formulée ou d'un schéma ambigu. Les journaux d'Instructor te montrent exactement ce que le modèle a produit et pourquoi cela a été refusé.
Migrer depuis le parsing manuel
Si tu as déjà du code qui demande du JSON puis le parse à la main, la comparaison avant/après parle d'elle-même. Voici ce que le même besoin donne avec et sans Instructor :
# AVANT : parsing manuel, fragile
import json
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Jean a 34 ans."}],
response_format={"type": "json_object"},
)
donnees = json.loads(resp.choices[0].message.content) # peut lever une exception
nom = donnees.get("nom") # aucune garantie de type ni de présence
age = donnees.get("age") # idem
# APRÈS : Instructor
utilisateur = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Utilisateur,
messages=[{"role": "user", "content": "Jean a 34 ans."}],
)
nom = utilisateur.nom # str, garanti présent
age = utilisateur.age # int, garanti valideLe code « après » est plus court, plus lisible, et surtout plus sûr : plus de json.loads qui peut planter, plus de .get() qui renvoie None silencieusement, plus de vérification manuelle des types. La classe Pydantic joue le rôle de contrat, et Instructor s'assure qu'il est respecté.
Choisir le bon modèle pour la structuration
Tous les modèles ne se valent pas pour produire du JSON structuré. Quelques repères pour orienter ton choix :
- Modèles avec function calling robuste (GPT, Claude) : utilise le mode TOOLS, c'est le plus fiable.
- Modèles avec mode JSON natif : le mode JSON est plus économique et suffisant pour des schémas simples.
- Modèles locaux via Ollama : le mode MD_JSON est souvent le plus robuste, ou TOOLS si le modèle le supporte.
- Schémas complexes ou imbriqués : préfère un modèle plus grand, la structuration exige de la rigueur et de la constance.
Une bonne règle de départ : commence avec un modèle raisonnablement grand pour valider ton schéma, puis redescends vers un modèle plus petit ou local en vérifiant que la qualité d'extraction se maintient. La portabilité d'Instructor rend cette itération quasi gratuite.
Extraire depuis un texte long
Les modèles ont une fenêtre de contexte limitée : impossible d'envoyer un document de cent pages d'un bloc. La technique classique consiste à découper le texte en morceaux, puis à extraire la structure de chaque morceau. Instructor s'intègre naturellement dans ce genre de pipeline.
import instructor
from openai import OpenAI
from pydantic import BaseModel
class Evenement(BaseModel):
date: str
lieu: str
description: str
client = instructor.from_openai(OpenAI())
def decouper(texte, taille=2000):
# Découpe un texte long en morceaux de taille fixe
return [texte[i:i + taille] for i in range(0, len(texte), taille)]
texte = "..." # remplace par ton document complet
evenements = []
for extrait in decouper(texte):
evt = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Evenement,
messages=[{"role": "user", "content": f"Extrais l'événement décrit : {extrait}"}],
)
evenements.append(evt)
print(len(evenements), "événements extraits")
for evt in evenements:
print(evt.date, "-", evt.lieu)Chaque morceau produit un objet Evenement validé, et la liste finale est proprement typée. Ce patron « découper puis extraire » est au cœur de la plupart des pipelines de traitement documentaire, et la fiabilité d'Instructor évite qu'une extraction ratée sur un seul morceau ne corrompe tout le lot.
Pour des documents réellement structurés (PDF, tableaux), combine Instructor avec un parseur de document en amont, puis applique l'extraction sur le texte nettoyé. La qualité de l'entrée conditionne directement celle de la sortie, comme toujours en traitement du langage.
Aller plus loin : agents et pipelines
Instructor n'est qu'une brique. Dans une architecture réelle, il s'assemble naturellement avec d'autres outils : un agent qui décide de déclencher une extraction, un pipeline qui enchaîne plusieurs étapes de structuration, ou une API qui expose l'extraction comme un service. Sa simplicité en fait un composant facile à composer.
Tu peux par exemple construire un agent qui, face à un texte, choisit le bon schéma d'extraction via une union discriminée, puis alimente une base de données avec le résultat validé. La garantie de type offerte par Pydantic rend chaque étape fiable et testable, ce qui est le fondement d'un pipeline robuste.
De la même façon, rien n'empêche d'utiliser Instructor à l'intérieur d'un agent LangChain ou d'un script autonome : c'est une bibliothèque de structuration, pas un framework qui impose sa façon de faire. Elle s'insère là où tu as besoin d'une sortie fiable, et disparaît ailleurs.
L'essentiel à retenir
- Décris la sortie attendue avec Pydantic, pas avec des instructions floues dans le prompt.
- Laisse Instructor gérer la validation et les retries automatiques.
- Choisis le mode selon ton modèle : TOOLS, JSON ou MD_JSON.
- Le même code fonctionne sur OpenAI, Anthropic, Mistral et les modèles locaux.
Conclusion
Instructor remplace le parsing défensif par un contrat clair : tu décris ce que tu veux avec Pydantic, et tu reçois un objet validé. Les retries automatiques, la portabilité multi-fournisseurs et le support asynchrone en font un outil incontournable dès que ton application consomme des sorties structurées de LLM : extraction d'entités, classification, génération de formulaires, structuration de documents.
Le réflexe à adopter : chaque fois que tu t'apprêtes à écrire json.loads sur la sortie d'un LLM, demande-toi si une classe Pydantic et Instructor ne feraient pas le travail plus proprement. Dans l'immense majorité des cas, la réponse est oui.






