Dès que la sortie du modèle alimente une base, un formulaire ou un Agent en aval, on ne peut plus miser sur un « veuillez renvoyer du JSON » dans le prompt. OpenAI Structured Outputs s’appuie sur un décodage contraint pour coller la réponse au JSON Schema fourni : plus de champ manquant, plus d’énumération inventée, plus de type qui bascule entre chaîne et nombre. Ce guide s’adresse aux ingénieurs qui extraient, classent des tickets, notent des évaluations ou enchaînent des workflows : le chemin complet, de JSON Mode jusqu’à strict: true.
Pourquoi JSON Mode ne suffit pas
JSON Mode (type: "json_object") garantit seulement que le texte « passe dans un parseur JSON ». Il ne garantit ni les noms de clés, ni les champs obligatoires, ni l’ensemble d’énumération, ni le type numérique. En production, les trois échecs les plus fréquents : severity oublié, 42 renvoyé comme "42", et une valeur hors enum du type urgent-plus. Dès que l’aval parse en typage fort, le pipeline casse sur un échantillon au hasard.
| Capacité | JSON Mode | Structured Outputs |
|---|---|---|
| JSON syntaxiquement valide | Oui | Oui |
| Respect du JSON Schema fourni | Non (via le prompt) | Oui (décodage contraint) |
| Activation | json_object |
json_schema + strict: true |
| Modèles typiques | GPT-4o / GPT-3.5 plus anciens, etc. | gpt-4o-2024-08-06, gpt-4o-mini et snapshots plus récents |
| Gestion du refus | Peut encore produire un refus « qui ressemble à du JSON » | Champ refusal distinct |
La documentation officielle présente Structured Outputs comme l’évolution de JSON Mode : un nouveau projet doit partir du schéma, plutôt que de relancer trois fois après un échec de parse. Si vous comparez aussi la facture tokens de plusieurs modèles, voyez le coût API de Kimi K3 face à GPT-5.5 — la sortie structurée n’enfle presque pas le volume ; ce qui fait vraiment monter la note, ce sont les retries et un raisonnement trop long.
response_format ; l’API Responses le met dans text.format. Les chemins de champs diffèrent, mais strict, additionalProperties: false et « tout est required » restent identiques.
Requête minimale qui tourne
Le schéma d’extraction de ticket ci-dessous couvre environ 80 % des besoins en production : chaîne, énumération, entier, et un type union avec null pour simuler un champ optionnel. Le modèle doit renvoyer toutes les clés ; s’il n’y a pas d’identifiant de compte, il met null explicitement au lieu d’omettre la clé.
{
"model": "gpt-4o-2024-08-06",
"messages": [
{"role": "system", "content": "Extrayez la description utilisateur en objet ticket."},
{"role": "user", "content": "La page de paiement renvoie une 500 avec une carte enregistrée. Depuis aujourd'hui. Compte acct_8842."}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "support_ticket",
"strict": true,
"schema": {
"type": "object",
"properties": {
"summary": { "type": "string" },
"category": {
"type": "string",
"enum": ["billing", "bug", "account", "other"]
},
"severity": { "type": "integer" },
"account_id": { "type": ["string", "null"] }
},
"required": ["summary", "category", "severity", "account_id"],
"additionalProperties": false
}
}
}
}
L’équivalent Responses API consiste à coller le même schéma dans text.format, avec type, name, schema et strict en champs frères. La première fois qu’un schéma est utilisé, le serveur compile les contraintes : la latence peut être un peu plus haute. Ensuite le schéma est mis en cache et les requêtes suivantes reviennent à un délai normal.
Règles d’un schéma strict
Une fois strict: true activé, vous ne soumettez plus un « JSON Schema souple », mais le sous-ensemble officiellement pris en charge. Un schéma non conforme déclenche un 400, pas un « on fera de notre mieux ».
- La racine doit être un object : pas de
anyOfni de tableau à la racine. Si lediscriminatedUnionde Zod produit unanyOfde premier niveau, enveloppez-le, par exemple{ "result": ... }. - Chaque object exige
additionalProperties: false: y compris les objets imbriqués. Un oubli suffit à faire refuser le schéma. - Toutes les clés de
propertiesdoivent figurer dansrequired: pour l’optionnel, utilisez"type": ["string", "null"]ouanyOfavecnull. - Types pris en charge : string, number, integer, boolean, object, array, enum,
anyOf. - Contraintes de chaîne :
pattern,format(email,date-time,uuid,ipv4, etc.). - Nombres et tableaux :
minimum/maximum/multipleOf;minItems/maxItems. - Explicitement non pris en charge :
allOf,not,if/then/else,dependentRequiredet autres mots-clés de composition. - Plafonds d’échelle : environ 5000 propriétés d’objet au total, 10 niveaux d’imbrication ; la somme des noms de propriétés, noms de définitions et valeurs d’énumération ne dépasse pas 120 000 caractères ; au plus 1000 valeurs d’énumération.
pattern, format, minLength, minimum, minItems et d’autres contraintes peuvent encore ne pas être supportées. Validez d’abord le schéma sur un snapshot de base, puis décidez du fine-tuning.
Appel d’outil vs corps de réponse
Le même strict s’applique aux arguments de fonction / d’outil. L’intention change : le schéma d’outil décrit « ce que le modèle doit appeler » ; response_format / text.format décrit « la forme de la réponse à l’utilisateur ». Extraction, notation, état d’UI : le second. Météo, écriture de fichier, commande : le premier. Pour comparer agent terminal et contrôle d’interface, voir le lien entre Claude Code et OpenAI Computer Use — c’est une autre couche, « comment agir » ; ici on fixe le contrat de données avant et après l’action.
Parser un objet via le SDK
Écrire le JSON Schema à la main, c’est souvent oublier un required. Les SDK officiels fournissent des aides Pydantic / Zod : le schéma est généré depuis les types, et la réponse arrive déjà parsée en objet. Voici l’usage typique en Python.
from typing import Literal, Optional
from pydantic import BaseModel
from openai import OpenAI
class SupportTicket(BaseModel):
summary: str
category: Literal["billing", "bug", "account", "other"]
severity: int
account_id: Optional[str]
client = OpenAI()
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extrayez le ticket."},
{"role": "user", "content": "Page de paiement en 500, compte acct_8842."},
],
response_format=SupportTicket,
)
ticket = completion.choices[0].message.parsed
if completion.choices[0].message.refusal:
raise RuntimeError(completion.choices[0].message.refusal)
print(ticket.category, ticket.severity)
Erreurs, refus et checklist
Passez cette liste avant la mise en prod : elle élimine la plupart des « ça marche en curl local, le pipeline renvoie 400 ».
- 400 Unsupported schema :
additionalProperties: falsemanquant, champ non required, racine enanyOf, ou usage deallOf. - Refus du modèle : si la politique de sécurité se déclenche, le contenu n’est pas fourré dans le schéma ; lisez
refusal, affichez-le dans l’UI, ne relancez pas comme un échec de parse JSON. - Champ optionnel devenu chaîne vide : si le contrat prévoit
null, accepteznull; mappez-le enNULLSQL à l’insertion, sans deuxième couche de devinettes. - Énumération trop large : ramenez les statuts métier à 5–8 valeurs ; des centaines d’enums brûlent le contexte et se rapprochent du plafond de 1000.
- Ordre des clés : l’ordre de sortie suit celui déclaré dans le schéma, ce qui permet un parse incrémental en streaming.
- Le prompt reste utile : le schéma gère la forme, le prompt la sémantique. « severity de 1 à 5, 5 = panne globale » va dans le system prompt ; on peut encore verrouiller la plage d’entiers avec
minimum/maximum.
En pratique, figez trois choses : le schéma dans Git ; des métriques distinctes pour refus, 400 et timeout ; un jeu de tickets réels en régression, pas un « Hello, renvoie du JSON ». Une fois la couche de parse stable, l’énergie va à la précision de classification et à la latence, plus à un regex à réparer chaque semaine.
Extraire sur Mac mini : un environnement plus simple
La boucle de debug Structured Outputs est courte : on change le schéma, on lance un petit lot, on inspecte l’objet parsé. Sur macOS, Python, Node.js, Docker et Homebrew sont prêts, sans WSL à installer d’abord. La mémoire unifiée Apple Silicon permet d’ouvrir en parallèle l’éditeur, les scripts de validation locaux et une évaluation à long contexte, sans basculer en swap dès le type-check.
Un Mac mini M4 consomme environ 4 W en veille : idéal pour laisser tourner le jeu de régression toute la nuit. macOS plante peu ; Gatekeeper et SIP réduisent aussi le risque qu’un script de dépendances soit altéré. Face à un PC Windows au même prix, pour des évaluations d’Agent sans surveillance, stabilité et facture électrique sont plus favorables.
Si vous voulez un nœud toujours en ligne, dédié aux régressions JSON et aux comparaisons de modèles, découvrez les offres Kvmzen et déplacez le contrat de schéma du portable vers un Mac cloud à spécification fixe.