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 uniquementFallback historique, aucun schéma à respecter
Sortie structurée (schéma strict)Le JSON correspond exactement à votre schémaFaçonner la réponse pour l’utilisateur ou votre code
Function calling / tool useLe nom et les arguments du tool correspondent à son schémaLe 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é :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-6-astra",
    input="Extract the ticket details: 'Login fails with a 500 after the "
          "password reset flow, seen first on build 4.12, blocking release.'",
    text={
        "format": {
            "type": "json_schema",
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "summary": {"type": "string"},
                    "build": {"type": "string"},
                    "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]},
                },
                "required": ["summary", "build", "severity"],
                "additionalProperties": False,
            },
        }
    },
)
print(response.output_text)

Claude reçoit le schéma dans output_config.format, pas dans une définition de tool :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -d '{
    "model": "claude-sonnet-5-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
    "output_config": {
      "format": {
        "type": "json_schema",
        "schema": {
          "type": "object",
          "properties": {
            "summary": {"type": "string"},
            "build": {"type": "string"},
            "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
          },
          "required": ["summary", "build", "severity"],
          "additionalProperties": false
        }
      }
    }
  }'

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
  "format": {
    "type": "object",
    "properties": {
      "summary": {"type": "string"},
      "build": {"type": "string"},
      "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
    },
    "required": ["summary", "build", "severity"]
  },
  "stream": false
}'

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pydantic import BaseModel, field_validator

class Ticket(BaseModel):
    summary: str
    build: str
    severity: str

    @field_validator("build")
    @classmethod
    def must_look_like_a_build_number(cls, v: str) -> str:
        if not v.replace(".", "").isdigit():
            raise ValueError(f"'{v}' doesn't look like a build number")
        return v

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_tokens au 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 anyOf ajoute 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.

Articles associés