Instructor : Fini le parsing foireux, tes LLMs vont cracher du JSON propre

Instructor : Fini le parsing foireux, tes LLMs vont cracher du JSON propre

De quoi avez-vous besoin

Version de Python

3.x

Packages

  • {"nom":"instructor","version":"1.x"}
  • {"nom":"openai","version":"1.x"}
  • {"nom":"pydantic","version":"2.x"}

Difficulté

Intermédiaire

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.

bash
pip install instructor pydantic

Il 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é.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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 :

python
# 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 valide

Le 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.

python
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.

Sources