Demandez à un modèle de répondre en JSON, et la plupart du temps
json.loads() fonctionne sans problème. Les échecs sont ceux qui vous
coûtent une session de débogage : un commentaire sur une ligne à l’intérieur
de l’objet, un code fence markdown qui l’entoure, une clé écrite userEmail
quand votre code attend user_email. Un prompt plus strict (“répondez
UNIQUEMENT avec l’objet JSON, aucun autre texte”) rend ces cas plus rares. Il
ne les rend pas impossibles, car le modèle peut quand même ignorer la
consigne. La sortie structurée n’a plus besoin de cette consigne : vous
donnez à l’API un JSON Schema, qui contraint l’échantillonnage des tokens du
modèle pour que la réponse doive s’y conformer.
JSON mode contre sortie structurée : ce que chacun garantit
Le “JSON mode” (response_format: {"type": "json_object"} dans l’ancienne
API OpenAI, et l’équivalent chez la plupart des autres fournisseurs) ne
garantit qu’une chose : la sortie est un JSON valide. Il ne dit rien sur
les clés présentes, leur type, ou si un champ dont vous dépendez est bien là.
Un modèle en JSON mode peut renvoyer {"result": "yes"} quand votre code
attend {"status": "approved", "confidence": 0.91}, sans qu’aucun signal
n’indique que quelque chose a mal tourné.
La sortie structurée (Structured Outputs chez OpenAI, JSON outputs chez Anthropic) est une garantie différente : la réponse correspond au JSON Schema que vous fournissez, avec les clés requises présentes et le bon type pour chacune. Le function calling résout un problème voisin : faire en sorte que le modèle demande à votre code d’exécuter quelque chose. Les deux se confondent facilement parce que les deux s’appuient sur un schéma. Ce qui diffère, c’est ce que chaque schéma façonne :
| Fonctionnalité | Ce qu’elle garantit | À quoi elle sert |
|---|---|---|
JSON mode (json_object) | Syntaxe JSON valide uniquement | Fallback historique, aucun schéma à respecter |
| Sortie structurée (schéma strict) | Le JSON correspond exactement à votre schéma | Façonner la réponse pour l’utilisateur ou votre code |
| Function calling / tool use | Le nom et les arguments du tool correspondent à son schéma | Le modèle décide d’invoquer votre code |
Un pipeline en production combine souvent les deux derniers. Le tool use strict évite que les appels de fonction d’un agent arrivent malformés, et un schéma strict sur la réponse finale type le résumé renvoyé à l’utilisateur.
Comment le constrained decoding force la correspondance
Le modèle ne compare jamais sa réponse à votre schéma après l’avoir générée. Le schéma change ce qu’il a le droit de générer, un token à la fois.
La couche de serving compile votre JSON Schema en une grammaire : une machine à états qui suit, à chaque position de la sortie, quels tokens mèneraient encore à quelque chose de valide. Avant l’échantillonnage de chaque token, tout token du vocabulaire que la grammaire exclut à cette position voit son logit poussé à moins l’infini, donc sa probabilité devient nulle après le softmax. Le modèle échantillonne parmi ce qui reste, et les probabilités relatives entre les tokens survivants sont préservées. Une clé mal orthographiée, une accolade fermante au mauvais endroit, une chaîne là où le schéma exige un nombre : une fois que la grammaire les exclut, ces tokens ne sont plus des options.
Cette étape de compilation a un coût. Construire une grammaire pour un nouveau schéma demande un vrai travail, donc la première requête contre ce schéma est plus lente que les suivantes. L’API d’Anthropic met la grammaire compilée en cache pendant 24 heures après sa dernière utilisation, et les appels suivants avec le même schéma évitent la compilation.
Le mécanisme explique aussi la limite. Le constrained decoding fixe
uniquement la forme, rien d’autre. La grammaire sait quels tokens sont
syntaxiquement légaux, pas lequel est vrai, donc le modèle peut encore
remplir un champ "email" valide avec une adresse inventée.
Comment obtenir du JSON conforme au schéma avec OpenAI, Claude et Ollama
La Responses API d’OpenAI reçoit le schéma dans text.format, avec strict
activé :
| |
Claude reçoit le schéma dans output_config.format, pas dans une définition
de tool :
| |
Le JSON revient dans le contenu texte de la réponse, pas dans un bloc de tool call. L’ancien contournement (définir un faux tool, forcer le modèle à l’appeler, lire les arguments) fonctionne encore sur les modèles qui ne supportent que le tool use. Là où la sortie structurée native existe, vous n’en avez plus besoin : le décodeur impose le schéma directement, au lieu d’espérer que les arguments du tool correspondent par hasard.
Un modèle exécuté en local avec Ollama
reçoit le schéma via le champ format de /api/chat :
| |
Le mécanisme est le même. Le runtime llama.cpp d’Ollama construit une
grammaire à partir du schéma et masque les tokens comme le font les API
hébergées.
Quelles fonctionnalités de JSON Schema chaque fournisseur supporte
Chaque fournisseur impose un sous-ensemble de JSON Schema, et ces sous-ensembles diffèrent assez pour casser un schéma copié d’une API à l’autre.
OpenAI exige que tout objet porte "additionalProperties": false, avec
chacune de ses propriétés listée dans required — il n’existe aucun moyen
de marquer un champ comme réellement optionnel, donc modélisez-le comme
required et nullable. Anthropic est plus permissif : une propriété absente
de required est véritablement optionnelle, et les propriétés required
apparaissent en premier dans la sortie, suivies des optionnelles présentes.
Au-delà, les deux divergent encore. Anthropic supporte les $ref/$defs
locaux, anyOf et const, mais rejette les schémas récursifs, les bornes
numériques comme minimum et maximum, et les contraintes de longueur de
chaîne comme maxLength. Les exemples d’OpenAI incluent un $ref: "#"
récursif pour une sortie en arbre, que la documentation d’Anthropic écarte
explicitement.
Quand votre schéma a besoin d’une contrainte que le fournisseur ne supporte
pas, déplacez-la dans la description du champ (“doit être un entier positif
inférieur à 100”) et vérifiez-la vous-même après le parsing. Les helpers des
SDK des deux plateformes le font automatiquement quand vous construisez le
schéma à partir d’un modèle Pydantic ou Zod plutôt que d’écrire du JSON
Schema à la main.
Comment valider la sortie structurée après le parsing
Une forme valide n’est pas une réponse correcte. Le schéma garantit que
severity est l’une de vos quatre valeurs d’enum. Que “blocking” soit le bon
choix pour ce ticket est une question différente. Traitez l’objet parsé
comme vous traiteriez la soumission d’un formulaire : un input à valider,
pas une vérité acquise.
| |
Instructor encapsule OpenAI, Anthropic
et la plupart des autres fournisseurs dans une boucle de validation et de
retry. Vous lui donnez un modèle Pydantic. En cas d’échec de validation, il
renvoie l’erreur au modèle et redemande, jusqu’à une limite de retry que vous
fixez, au lieu de lever une ValidationError au premier échec. C’est aussi
la voie pratique pour obtenir une garantie de schéma chez un fournisseur ou
un modèle plus ancien sans strict mode natif : la boucle de retry remplace
la contrainte imposée au niveau du décodeur, et chaque échec coûte un
aller-retour supplémentaire.
Les validateurs de champ détectent les valeurs malformées sur une seule réponse. Pour vérifier si les réponses sont correctes sur des centaines d’entrées, et si un changement de prompt les a dégradées, il vous faut une eval suite.
Quand la sortie structurée échoue malgré tout
Le strict mode ne couvre pas ces cas. Vérifiez-les avant de partir à la chasse à un bug dans votre propre code :
- Refus. Un modèle peut refuser de répondre, et le texte du refus ne
correspond pas à votre schéma. Vérifiez
stop_reason(ou l’équivalent) avant le parsing, pas après que le parsing a levé une erreur. - Troncature. Atteindre
max_tokensau milieu d’un objet produit du JSON incomplet. La grammaire contrôle quel token vient après, pas l’espace restant pour terminer. Augmentez la limite ou réduisez le schéma. - Chemins non contraints. Le JSON mode, une bibliothèque de retry sur un
modèle sans strict mode, ou un fournisseur qui ignore silencieusement un
mot-clé de schéma qu’il ne supporte pas peuvent tous renvoyer
"Blocking"quand votre enum dit"blocking". Si une requête de votre système passe par l’un de ces chemins, normalisez les valeurs à l’entrée. - Taille du schéma. Un gros schéma avec de nombreuses branches
anyOfajoute des tokens à chaque requête, en plus du délai de compilation pour un nouveau schéma. Gardez les schémas aussi plats et petits que la tâche le permet.
La garantie du fournisseur est réelle, mais votre code de parsing ne devrait pas supposer qu’elle a tenu.
JSON mode, schéma strict ou function calling : lequel choisir
N’utilisez le JSON mode simple que contre un modèle ou un chemin de SDK sans support de schéma, comme fallback plutôt que comme choix par défaut. Utilisez un JSON Schema strict quand vous façonnez une réponse pour votre propre code ou un utilisateur : extraction, classification, un résumé typé. Utilisez le function calling quand c’est le modèle qui décide d’exécuter quelque chose. Activez le strict mode aussi sur la définition du tool, pour qu’un appel malformé échoue au niveau de l’API plutôt que dans votre code.
Si tout ce qu’il vous faut est une étiquette issue d’un enum court, comparez aussi avec un modèle System One. Quand la sortie est restreinte à un ensemble connu de réponses, un modèle peut en choisir une plutôt que la générer, et les compromis de latence et de coût diffèrent assez pour que vous les mesuriez sur votre propre trafic.
Pour tout le reste, le choix par défaut est un schéma strict plus un validateur sur le résultat parsé. Le schéma garde la forme correcte. Vérifier que le contenu est correct reste votre travail.