Demander à un modèle « réponds-moi en JSON » fonctionne à peu près… jusqu’au jour où il ajoute une phrase d’introduction, oublie une virgule, ou renomme un champ. Votre code de traitement plante alors sur un JSONDecodeError, en production, devant un client. Les sorties structurées (Structured Outputs) suppriment ce risque : vous fournissez un schéma, et le modèle est contraint, au niveau du décodage, de produire un JSON qui le respecte exactement — tous les champs requis présents, tous les types corrects, aucune valeur hors de l’énumération autorisée.
Dans notre assistant de support client, cette fiabilité devient cruciale dès qu’on veut traiter les messages automatiquement plutôt que seulement y répondre. Ici, nous transformons chaque message entrant en une fiche structurée — catégorie, priorité, numéro de commande, résumé — directement exploitable par le reste du système : tri, routage, tableau de bord.
🎯 Ce que vous allez apprendre
- Comprendre pourquoi le mode JSON « libre » échoue et ce que garantit le mode strict.
- Définir un schéma avec Pydantic et le passer au modèle via l’aide
parse. - Récupérer un objet Python déjà validé, sans parsing manuel.
- Écrire le même schéma à la main en JSON Schema quand vous n’utilisez pas Pydantic.
- Gérer les champs optionnels, les énumérations et les refus du modèle.
🛠️ Ce que vous allez construire
Une fonction analyser_message qui prend le texte brut d’un client (« Bonjour, ma commande 4827 n’est toujours pas arrivée, c’est urgent ! ») et renvoie un objet Python typé : catégorie = livraison, priorité = haute, numéro = 4827, résumé en une phrase. Le résultat est garanti conforme : votre code en aval peut s’y fier sans vérification défensive.
Prérequis
- Le premier appel à l’API OpenAI en place (clé, SDK).
- Installer Pydantic :
pip install pydantic. - Savoir lire une classe Python et des annotations de type (
nom: str). - ⏱️ Temps estimé : environ 30 minutes.
Étape 1 — Comprendre le problème que cela résout
Sans contrainte, un modèle génère du texte mot à mot ; rien ne l’empêche d’entourer son JSON de commentaires ou de s’écarter du format à la moindre ambiguïté. On parlait autrefois de « prompt-and-pray » : on demande gentiment un format et on prie pour qu’il soit respecté. Cela tient en démonstration, pas en production, où une requête sur cent qui dévie suffit à casser une chaîne de traitement.
Les sorties structurées changent la nature de la garantie. Au lieu de suggérer un format dans la consigne, vous l’imposez au niveau du moteur de génération : le modèle ne peut littéralement pas produire un jeton qui violerait le schéma. Le résultat est toujours un JSON valide et conforme. Cette capacité est disponible sur les modèles récents — à partir de la génération GPT-4o et sur la gamme GPT-5 actuelle — ce qui couvre les modèles que vous utiliserez de toute façon.
Étape 2 — Définir le schéma avec Pydantic
La voie la plus confortable en Python consiste à décrire la forme attendue avec une classe Pydantic. C’est lisible, réutilisable, et le SDK sait la convertir tout seul en schéma pour le modèle. On modélise notre fiche d’analyse de message.
from pydantic import BaseModel
from enum import Enum
class Categorie(str, Enum):
livraison = "livraison"
paiement = "paiement"
produit = "produit"
autre = "autre"
class Analyse(BaseModel):
categorie: Categorie
priorite: str # "haute", "moyenne" ou "basse"
numero_commande: str | None
resume: str
Chaque attribut devient un champ du JSON attendu. L’énumération Categorie restreint les valeurs possibles à une liste fermée : le modèle ne pourra pas inventer une catégorie « réclamation diverse » hors de la liste. Le type str | None sur numero_commande autorise l’absence de numéro quand le client n’en mentionne aucun. Cette classe est à la fois la spécification envoyée au modèle et le type que vous manipulerez ensuite : une seule source de vérité.
Étape 3 — Appeler le modèle avec l’aide parse
Le SDK propose une méthode parse qui prend directement la classe Pydantic, transmet le schéma au modèle, puis désérialise la réponse en une instance de cette classe. Vous n’écrivez aucun code de parsing : vous récupérez un objet Python prêt à l’emploi.
from openai import OpenAI
client = OpenAI()
def analyser_message(texte):
reponse = client.responses.parse(
model="gpt-5.4",
input=[
{"role": "system", "content":
"Tu analyses des messages de clients d'une boutique. "
"Extrais la categorie, la priorite, le numero de commande "
"s'il est cite, et un resume en une phrase."},
{"role": "user", "content": texte},
],
text_format=Analyse,
)
return reponse.output_parsed
fiche = analyser_message(
"Bonjour, ma commande 4827 n'est toujours pas arrivee, c'est urgent !"
)
print(fiche.categorie, "/", fiche.priorite, "/", fiche.numero_commande)
print(fiche.resume)
Le paramètre text_format reçoit la classe ; reponse.output_parsed renvoie une instance d’Analyse. On accède aux champs avec la notation pointée, comme sur n’importe quel objet — et l’éditeur de code propose même l’autocomplétion, puisque le type est connu. Lancez le script : vous obtenez categorie=livraison, priorite=haute, numero_commande=4827 et un résumé propre. Aucune désérialisation manuelle, aucune vérification de format : le contrat est tenu par construction.
✅ Point d’étape —
ficheest bien un objet typé dont les champs correspondent à votre classe. Testez un message sans numéro de commande :numero_commandedoit valoirNoneet non une chaîne vide ou un numéro inventé.
Étape 4 — Écrire le schéma à la main en JSON Schema
Si vous n’utilisez pas Pydantic — par exemple dans un autre langage, ou pour un schéma généré dynamiquement — vous fournissez le JSON Schema directement via le paramètre text de l’API Responses. C’est plus verbeux mais c’est exactement ce que Pydantic produit en coulisses.
schema = {
"type": "object",
"properties": {
"categorie": {"type": "string",
"enum": ["livraison", "paiement", "produit", "autre"]},
"priorite": {"type": "string", "enum": ["haute", "moyenne", "basse"]},
"numero_commande": {"type": ["string", "null"]},
"resume": {"type": "string"},
},
"required": ["categorie", "priorite", "numero_commande", "resume"],
"additionalProperties": False,
}
reponse = client.responses.create(
model="gpt-5.4",
input=[{"role": "user", "content": texte}],
text={"format": {"type": "json_schema", "name": "analyse",
"strict": True, "schema": schema}},
)
import json
fiche = json.loads(reponse.output_text)
On enveloppe le schéma dans text.format avec "type": "json_schema" et "strict": True. La réponse arrive cette fois comme texte JSON dans output_text, que l’on désérialise avec json.loads — mais en toute sécurité, puisqu’on sait qu’il est conforme. Notez deux exigences du mode strict, visibles ici : additionalProperties doit valoir False, et tous les champs doivent figurer dans required. C’est ce point qui surprend le plus souvent.
Étape 5 — Champs optionnels, énumérations et refus
Le mode strict impose que chaque champ déclaré soit requis. Comment, alors, rendre une donnée « optionnelle » ? On ne la retire pas de required : on autorise la valeur null via un type union, exactement comme numero_commande plus haut (["string", "null"], ou str | None en Pydantic). Le champ est donc toujours présent, mais peut valoir null quand l’information manque. C’est plus rigoureux qu’un champ absent, car votre code n’a jamais à tester l’existence d’une clé.
Les énumérations sont votre meilleur allié pour fiabiliser un classement : en listant les valeurs autorisées, vous éliminez les variantes orthographiques (« Haute », « urgent », « URGENT ») et obtenez des étiquettes propres, directement filtrables. Enfin, un modèle peut, dans de rares cas, refuser de répondre — par exemple face à une demande inappropriée. Le SDK expose alors un champ de refus plutôt qu’une fiche : prévoyez de le tester avant d’utiliser output_parsed, et traitez le refus comme un cas métier (message neutre, escalade humaine) plutôt que comme une panne.
✅ Point d’étape — Tous vos champs sont dans
requiredet les champs facultatifs acceptentnull. Si l’API renvoie une erreur de schéma, c’est presque toujours qu’un champ manque dansrequiredou queadditionalPropertiesn’est pas àFalse.
Quand préférer les sorties structurées
Toutes les réponses n’ont pas vocation à être structurées. Pour une réponse conversationnelle adressée à un humain, le texte libre reste le bon format. Les sorties structurées brillent dès que la réponse doit être consommée par du code : extraction d’entités, classification, remplissage de formulaire, étape intermédiaire d’un agent, alimentation d’une base. La règle pratique : si la sortie part vers un humain, laissez du texte ; si elle part vers une fonction, un if ou une colonne de table, imposez un schéma. Cette discipline supprime une catégorie entière de bugs et rend vos traitements déterministes là où ils doivent l’être.
Décortiquer une analyse, champ par champ
Reprenons le message « Bonjour, ma commande 4827 n’est toujours pas arrivée, c’est urgent ! » et suivons le raisonnement que le schéma impose au modèle. Pour le champ categorie, le modèle confronte le message aux quatre étiquettes autorisées ; « pas arrivée » renvoie sans ambiguïté à livraison, et l’énumération l’empêche de choisir une formulation maison. Pour priorite, le mot « urgent » et le point d’exclamation orientent vers haute — un jugement que la consigne système peut affiner en précisant ce qui constitue une urgence pour votre activité. Le champ numero_commande est rempli par extraction directe : « 4827 » est repéré dans le texte. Et resume condense le tout en une phrase neutre, utile pour un coup d’œil rapide dans un tableau de bord.
Ce qui change tout par rapport à une réponse libre, c’est la stabilité du résultat sur des milliers de messages. Quelle que soit la tournure du client — poli, agacé, télégraphique — la sortie a toujours la même forme. Vous pouvez donc empiler ces fiches dans une base, compter les tickets par catégorie, déclencher une alerte quand la priorité haute dépasse un seuil, sans jamais écrire de code défensif pour rattraper un format qui aurait dérapé. La structure n’est plus une espérance, c’est une propriété du système.
Concevoir un bon schéma
La fiabilité technique étant acquise, la vraie difficulté se déplace vers la conception du schéma. Trois principes guident un schéma robuste. D’abord, préférer les énumérations aux champs libres partout où l’ensemble des valeurs est connu : une catégorie, une priorité, un statut gagnent à être fermés, car ils alimentent ensuite des filtres et des comptages. Ensuite, nommer les champs sans ambiguïté et documenter leur intention dans la consigne : un champ resume peut signifier « résumé du problème » ou « résumé de la conversation » — le modèle remplit mieux ce qu’il comprend clairement. Enfin, garder le schéma aussi plat et minimal que possible : chaque champ superflu est une occasion d’erreur et un coût en jetons, tandis qu’une imbrication profonde complique autant la génération que l’exploitation.
Un dernier réflexe paie sur la durée : versionner le schéma. Quand votre fiche d’analyse évolue — un nouveau champ sentiment, une catégorie supplémentaire — traitez-la comme une migration de base de données, en pensant aux fiches déjà stockées dans l’ancien format. Cette hygiène vous évite de mélanger deux générations d’analyses incompatibles dans le même tableau de bord, et rend l’évolution de l’assistant prévisible plutôt que subie.
🐞 Pièges fréquents
| Symptôme / erreur | Cause probable | Correctif |
|---|---|---|
| Erreur « schema must have required … » | Un champ déclaré n’est pas listé dans required |
Mettre tous les champs dans required ; rendre optionnel via type null |
Erreur sur additionalProperties |
Clé absente ou à True |
Ajouter "additionalProperties": False sur chaque objet |
| Valeurs de catégorie incohérentes | Champ libre au lieu d’une énumération | Déclarer un enum avec la liste fermée des valeurs |
output_parsed vaut None |
Le modèle a refusé de répondre | Tester le refus avant d’utiliser le résultat |
| Modèle ancien sans support | Modèle antérieur à GPT-4o | Utiliser un modèle récent (gamme GPT-5) |
✅ Récapitulatif
Vous transformez désormais un message brut en données fiables : une classe Pydantic décrit la forme, responses.parse renvoie un objet validé, et le mode strict garantit la conformité au niveau du moteur. Vous savez aussi écrire le schéma à la main, rendre un champ optionnel sans le retirer de required, contraindre un classement par énumération et gérer un refus. Cette brique alimente naturellement le tableau de bord de tri et le routage automatique de votre assistant.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
class X(BaseModel) |
Décrire la forme attendue |
responses.parse(text_format=X) |
Appeler en exigeant ce format |
response.output_parsed |
Récupérer l’objet validé |
text={"format":{"type":"json_schema", ...}} |
Schéma manuel (sans Pydantic) |
strict: True + tout en required |
Garantie de conformité |
type ["string","null"] |
Champ optionnel correct |
💪 À vous de jouer
Ajoutez à la classe Analyse un champ sentiment contraint à l’énumération « positif / neutre / négatif », et un champ produits_cites qui est une liste de chaînes. Relancez l’analyse sur trois messages variés et vérifiez la cohérence.
Voir une piste
class Sentiment(str, Enum):
positif = "positif"
neutre = "neutre"
negatif = "negatif"
class Analyse(BaseModel):
categorie: Categorie
priorite: str
numero_commande: str | None
sentiment: Sentiment
produits_cites: list[str]
resume: str
Tutoriels associés
- Function calling avec GPT : laisser le modèle appeler vos fonctions
- Recherche sémantique avec les embeddings OpenAI
- 🔝 Retour au guide principal de la série
- Documentation officielle : OpenAI — Structured Outputs
FAQ
Quelle différence avec le simple « mode JSON » ?
Le mode JSON garantit seulement que la sortie est un JSON syntaxiquement valide, pas qu’elle respecte votre schéma. Les sorties structurées garantissent les deux : validité et conformité au schéma fourni.
Dois-je quand même décrire le format dans la consigne ?
Le schéma impose la structure, mais une consigne claire aide le modèle à bien remplir les champs (sens de chaque champ, règles de priorité). Les deux sont complémentaires.
Pydantic est-il obligatoire ?
Non. C’est le chemin le plus confortable en Python, mais vous pouvez fournir le JSON Schema à la main, ce qui fonctionne dans tous les langages.
Le mode strict ralentit-il les réponses ?
La première requête avec un nouveau schéma peut subir un léger surcoût de préparation, ensuite l’impact est négligeable au regard de la fiabilité gagnée.