Structured Outputs : obtenir du JSON fiable avec les LLM
Un LLM produit du texte, mais une application a besoin de données exploitables par le code : un objet JSON dont les champs, les types et les valeurs sont prévisibles. Comment garantir qu'une réponse respecte toujours le format attendu, sans parser du texte libre ni multiplier les relances en cas d'erreur ? Un simple oubli de guillemet, une virgule en trop ou une phrase d'introduction avant l'accolade ouvrante suffisent à faire planter un pipeline d'extraction.
Équipe Blent
Publié le · 12 min de lecture

Dans cet article
Un LLM produit du texte, mais une application a besoin de données exploitables par le code : un objet JSON dont les champs, les types et les valeurs sont prévisibles. Comment garantir qu'une réponse respecte toujours le format attendu, sans parser du texte libre ni multiplier les relances en cas d'erreur ? Un simple oubli de guillemet, une virgule en trop ou une phrase d'introduction avant l'accolade ouvrante suffisent à faire planter un pipeline d'extraction.
C'est le rôle des structured outputs (sorties structurées). Ce terme désigne l'ensemble des techniques qui forcent un modèle à produire une réponse conforme à un schéma, qu'il s'agisse d'un schéma JSON, d'une expression régulière ou d'une grammaire. Les principaux fournisseurs d'API (OpenAI, Anthropic, Google) et les serveurs d'inférence open source comme vLLM proposent aujourd'hui cette fonctionnalité, qui est devenue la brique de base de l'extraction d'informations, du tool calling et des agents.
Dans cet article, nous allons comparer les approches disponibles, de la simple consigne dans le prompt jusqu'à la génération contrainte, puis détailler la validation avec Pydantic et enfin les limites à connaître avant de passer en production.
Pourquoi la sortie d'un LLM est difficile à fiabiliser
Un LLM génère du texte token par token, en choisissant à chaque étape le prochain token le plus probable selon sa distribution de probabilités. Rien dans ce mécanisme ne garantit qu'une accolade ouverte sera un jour refermée ou qu'un champ obligatoire sera présent. Le modèle imite le format qu'il a vu pendant son entraînement, avec un taux de réussite élevé mais jamais de 100 %. Pour mieux comprendre ce découpage, vous pouvez consulter notre article sur la tokenization.
En pratique, les échecs prennent plusieurs formes :
- JSON syntaxiquement invalide : guillemets non échappés, virgule finale, réponse tronquée
- Texte parasite : phrase d'introduction, balises Markdown autour du JSON, commentaires
- Schéma non respecté : champ manquant, nom de clé différent, type incorrect (une chaîne à la place d'un entier)
- Valeurs hors domaine : une catégorie inventée alors que seules trois valeurs sont autorisées
Sur un chatbot, ces erreurs sont tolérables. Dans un pipeline qui traite des milliers de documents par heure, elles se traduisent par des exceptions, des relances coûteuses et des données corrompues. La fiabilité de la sortie devient alors un sujet d'ingénierie à part entière.
Les quatre niveaux de garantie
Les approches se distinguent par le niveau de garantie qu'elles offrent, du plus faible au plus fort.
Prompting et mode JSON
La première approche consiste à demander le format dans le prompt, avec une description du schéma et un ou deux exemples (le few-shot, présenté dans notre article sur les techniques de prompting). Elle fonctionne avec n'importe quel modèle et ne coûte rien, mais elle repose entièrement sur la bonne volonté du modèle.
Le mode JSON (par exemple response_format={"type": "json_object"} chez OpenAI) apporte une première garantie : la réponse est un JSON syntaxiquement valide. En revanche, il ne dit rien du contenu. Les clés, les types et les valeurs restent libres, et il faut toujours décrire le schéma dans le prompt.
Structured outputs par schéma JSON
Les structured outputs par schéma JSON vont plus loin : le développeur fournit un schéma, et l'API garantit que la réponse le respecte. Chez OpenAI, cette garantie s'active avec strict: true. Les autres fournisseurs proposent des mécanismes équivalents, avec des noms et des options qui diffèrent (response_schema chez Google, sorties structurées et tool use strict chez Anthropic). Dans tous les cas, le principe est le même : le schéma est compilé en grammaire côté serveur, et les tokens qui violeraient cette grammaire sont écartés pendant la génération.
L'exemple suivant utilise le SDK OpenAI avec un modèle Pydantic pour extraire les informations d'un e-mail :
from pydantic import BaseModel
from openai import OpenAI
client = OpenAI()
class Ticket(BaseModel):
"""Ticket de support extrait d'un e-mail."""
client: str
priorite: str
resume: str
response = client.responses.parse(
model="gpt-4o-mini",
input=[
{"role": "system", "content": "Extrais les informations du ticket."},
{"role": "user", "content": "Bonjour, ici Marie Dupont. Mon paiement échoue depuis ce matin, c'est urgent !"},
],
text_format=Ticket,
)
ticket = response.output_parsed
print(ticket.client, ticket.priorite)
Le SDK convertit la classe Pydantic en schéma JSON, envoie la requête, puis reconvertit la réponse en objet typé. Le champ output_parsed est directement manipulable par le reste du code. Les noms de modèles et les méthodes évoluant vite, il est prudent de vérifier la documentation du fournisseur pour la version du SDK utilisée.
Génération contrainte en local
Avec un modèle open weights hébergé par vos soins, la même logique est disponible via la génération contrainte (constrained decoding). À chaque étape, un moteur de grammaire calcule la liste des tokens autorisés compte tenu du schéma et de ce qui a déjà été généré, puis masque les autres en mettant leur probabilité à zéro avant l'échantillonnage. Le modèle ne peut tout simplement pas produire une sortie invalide.
Plusieurs bibliothèques implémentent ce mécanisme :
- Outlines : bibliothèque pionnière, qui compile schémas JSON, expressions régulières et grammaires en automates
- XGrammar : moteur de grammaires optimisé pour la performance, adopté comme backend par défaut dans plusieurs serveurs d'inférence
- llguidance : moteur écrit en Rust, utilisé notamment par le backend
guidancede vLLM - Ollama et llama.cpp : prise en charge des schémas JSON et des grammaires pour l'exécution locale (voir notre article sur Ollama)
Côté serveur, vLLM intègre ces moteurs. Il accepte plusieurs types de contraintes : un schéma JSON, une expression régulière, un choix parmi une liste de valeurs ou une grammaire EBNF. Selon la documentation actuelle, les options passent par le champ structured_outputs du paramètre extra_body, ou par response_format pour un schéma JSON. Les anciens paramètres guided_* ont été supprimés dans les versions récentes :
from openai import OpenAI
# Connexion au serveur vLLM local
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
# Classification contrainte à une liste fermée de valeurs
response = client.chat.completions.create(
model="Qwen/Qwen3-8B",
messages=[{"role": "user", "content": "Classe ce retour client : « Livraison très rapide, merci ! »"}],
extra_body={"structured_outputs": {"choice": ["positif", "negatif", "neutre"]}},
)
print(response.choices[0].message.content)
Ici, le modèle ne peut répondre que par l'une des trois valeurs. Pour un schéma complet, la clé json de structured_outputs accepte un schéma JSON, généré par exemple avec Ticket.model_json_schema(). Le backend se choisit au lancement du serveur, avec une valeur auto par défaut qui sélectionne le moteur adapté à chaque requête.
Comparatif des approches
Le tableau suivant résume les compromis de chaque approche.
| Approche | Garantie | Contrôle du schéma | Modèles concernés |
|---|---|---|---|
| Prompting seul | Aucune | Aucun | Tous |
| Mode JSON | JSON valide | Aucun | API des fournisseurs |
| Structured outputs (schéma) | JSON conforme au schéma | Sous-ensemble de JSON Schema | API des fournisseurs |
| Génération contrainte | Conforme à la grammaire | JSON Schema, regex, grammaire | Modèles open weights auto-hébergés |
La garantie porte sur la forme de la réponse, jamais sur son exactitude. Un objet parfaitement conforme peut contenir une valeur fausse : le schéma empêche un format incorrect, pas une erreur de raisonnement ou une hallucination.
Valider avec Pydantic
Même avec une garantie de forme, la validation côté application reste indispensable. Pydantic est le standard de fait en Python : il sert à la fois à définir le schéma, à le transmettre au modèle et à valider la réponse. Les SDK des fournisseurs et des frameworks comme LangChain l'utilisent nativement.
Le schéma peut embarquer des règles métier que le JSON Schema simple n'exprime pas toujours, comme des bornes, des énumérations ou des validateurs personnalisés :
from enum import Enum
from pydantic import BaseModel, Field, ValidationError, field_validator
class Priorite(str, Enum):
basse = "basse"
normale = "normale"
haute = "haute"
class Ticket(BaseModel):
client: str = Field(description="Nom complet du client")
priorite: Priorite
montant_eur: float | None = Field(default=None, ge=0)
@field_validator("client")
@classmethod
def client_non_vide(cls, valeur: str) -> str:
# Refuse les chaînes vides ou composées d'espaces
if not valeur.strip():
raise ValueError("Le nom du client est vide")
return valeur
# Validation d'une réponse brute issue du modèle
try:
ticket = Ticket.model_validate_json(raw_response)
except ValidationError as erreur:
print(erreur.errors())
Les descriptions des champs (Field(description=...)) sont transmises au modèle dans le schéma et améliorent sensiblement la qualité des extractions : elles jouent le rôle d'instructions locales. Quant à la validation, elle sert de filet de sécurité. En cas d'échec, on peut relancer la requête en ajoutant le message d'erreur au contexte, ce qui permet souvent au modèle de se corriger. Des bibliothèques comme Instructor automatisent cette boucle de relance.
À découvrir : notre formation LLM Engineering
Les limites à connaître
Les sorties structurées ne règlent pas tout, et plusieurs pièges attendent les équipes qui les adoptent en production.
Impact sur la qualité
Contraindre le format peut dégrader le raisonnement. Plusieurs travaux de recherche ont observé qu'imposer un JSON strict dès le début de la réponse réduit la performance sur les tâches qui demandent de réfléchir avant de conclure. Le modèle n'a plus la place d'expliciter sa démarche. Deux parades courantes existent :
- Ajouter un champ de raisonnement placé avant la réponse finale dans le schéma (par exemple
raisonnementpuisreponse), pour que le modèle réfléchisse avant de conclure - Procéder en deux temps : une première génération libre pour raisonner, puis une seconde, contrainte, pour mettre en forme le résultat
Les reasoning models réfléchissent avant de répondre, ce qui limite ce problème, mais il reste pertinent de mesurer l'écart entre sortie libre et sortie contrainte sur votre jeu de données, avec une démarche d'évaluation comme le LLM-as-a-judge.
Schémas complexes
Les fournisseurs ne supportent qu'un sous-ensemble de JSON Schema. Selon les API, certaines contraintes (bornes numériques, motifs, références récursives) sont ignorées ou refusées, et des règles s'appliquent : champs tous obligatoires, additionalProperties à false, limites sur la profondeur d'imbrication et sur le nombre de propriétés. Ces limites changent d'une version à l'autre, d'où l'intérêt de consulter la documentation du fournisseur.
La complexité a aussi un coût :
- Latence à la première requête : un nouveau schéma doit être compilé en grammaire, ce qui ralentit son premier appel, tandis que les suivants sont plus rapides
- Schémas profonds : plus la grammaire est complexe, plus le masquage des tokens coûte cher et plus le modèle a de mal à remplir correctement tous les champs
- Valeurs optionnelles : elles s'expriment souvent par une union avec
nullplutôt que par un champ facultatif
La règle pratique consiste à garder des schémas plats et courts, avec des noms de champs explicites et des énumérations plutôt que du texte libre.
Gestion des refus et des réponses incomplètes
Une réponse structurée n'est pas toujours un objet conforme. Deux cas doivent être traités explicitement dans le code :
- Le refus : lorsqu'une requête déclenche les règles de sécurité du modèle, l'API renvoie un message de refus, signalé par un champ dédié, qui ne respecte pas le schéma. Il faut vérifier le type de chaque élément de la réponse avant de lire l'objet analysé
- La troncature : si la limite de tokens (
max_tokens) est atteinte avant la fermeture du JSON, la réponse est coupée et donc invalide. Le statut de la réponse (raison de fin de génération) permet de la détecter
Dans les deux cas, la bonne pratique est de journaliser l'incident, de relancer éventuellement avec un budget de tokens plus large, puis de basculer sur un traitement de secours. Les guardrails complètent ce dispositif en contrôlant le contenu des réponses, là où le schéma ne contrôle que leur forme.
Quelle approche choisir ?
Le choix dépend du contexte de déploiement et du niveau d'exigence :
- API d'un fournisseur : utiliser les structured outputs par schéma, avec Pydantic pour définir et valider le modèle de données
- Modèle auto-hébergé : activer la génération contrainte de vLLM (XGrammar ou llguidance) et conserver la validation Pydantic en sortie
- Modèle ne supportant aucune contrainte : combiner un prompt précis avec des exemples, une validation stricte et une boucle de relance
- Classification simple : préférer une contrainte
choiceou une énumération, plus fiable et moins coûteuse qu'un objet JSON complet
Dans tous les cas, la validation applicative reste en place, car elle protège contre les refus, les troncatures et les changements de comportement entre versions de modèles.
Conclusion
Les structured outputs répondent à un problème central de l'intégration des LLM : transformer un générateur de texte probabiliste en composant logiciel fiable. Mode JSON, schéma JSON côté fournisseur ou génération contrainte en local, chaque niveau apporte une garantie plus forte sur la forme des réponses, au prix d'une dépendance à un sous-ensemble de JSON Schema et d'une attention particulière à la qualité du raisonnement.
La combinaison gagnante associe un schéma court et bien décrit, une contrainte appliquée au moment de la génération et une validation Pydantic systématique. Elle doit s'accompagner d'une gestion explicite des refus et des réponses tronquées, ainsi que d'une évaluation régulière de la qualité des extractions sur des cas réels.
À mesure que les agents et les systèmes multi-modèles se généralisent, la capacité à produire des sorties exploitables par le code devient une compétence différenciante pour les développeurs. Maîtriser ces techniques, c'est pouvoir bâtir des applications LLM prévisibles et testables, là où un simple prompt laisse la porte ouverte aux surprises.


