Sorties structurées avec JSON Schema : un guide pratique
Arrêtez de parser de la prose pour du JSON. Comment le json_schema strict d'OpenAI, le responseSchema de Gemini et les outils d'Anthropic forcent des réponses conformes à un schéma défini.
Dès l'instant où vous essayez d'alimenter du code avec la réponse d'un modèle, « renvoie du JSON » cesse d'être suffisant. Demandez poliment et vous obtenez du JSON enveloppé dans une excuse, du JSON à l'intérieur d'un bloc Markdown, ou du JSON de syntaxe valide mais dont il manque le champ dont votre parseur a besoin. La solution fiable est d'arrêter de demander et de commencer à contraindre : indiquez au fournisseur le schéma exact que vous attendez et laissez-le garantir — ou du moins imposer fortement — que la réponse correspond. Cet article couvre les mécanismes disponibles aujourd'hui — le json_schema strict d'OpenAI, le responseSchema de Gemini et le output_config.format natif d'Anthropic, avec l'usage strict des outils comme pendant du function calling — les règles de schéma que chacun impose, et pourquoi vous validez malgré tout ensuite.
Deux modes d'échec, une seule cause racine
Il y a deux façons distinctes dont le JSON en texte libre casse, et il est utile de les nommer. L'échec syntaxique est une sortie qui ne parsera pas du tout : une virgule finale, une phrase parasite, un bloc de code. L'échec sémantique est une sortie qui parse mais qui est erronée pour votre programme : une valeur d'énumération que vous n'avez jamais définie, un champ requis manquant, une chaîne là où vous aviez besoin d'un nombre. L'ingénierie de prompt (« réponds uniquement avec du JSON ») réduit le premier type et ne fait presque rien pour le second. La génération contrainte par schéma attaque les deux, car le schéma est imposé pendant le décodage plutôt que vérifié après coup.
OpenAI : strict: true est toute la fonctionnalité
Les sorties structurées d'OpenAI sont l'évolution de l'ancien mode JSON, et la différence tient en un seul drapeau. Le mode JSON simple (response_format: { "type": "json_object" }) garantit que la sortie est du JSON valide mais rien sur sa forme. Les sorties structurées ajoutent un schéma et un interrupteur strict qui lie le modèle à celui-ci.
Sur Chat Completions, vous passez un response_format de type json_schema :
from openai import OpenAI
client = OpenAI(base_url="https://your-gateway.example.com/v1", api_key="AVE_GATEWAY_API_KEY")
resp = client.chat.completions.create(
model="gpt-5.6",
messages=[{"role": "user", "content": "Extract the invoice fields."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string", "enum": ["USD", "EUR", "JPY"]},
},
"required": ["vendor", "total", "currency"],
"additionalProperties": False,
},
},
},
)
Sur l'API Responses, le même schéma se déplace vers un champ différent — text.format au lieu de response_format. Un appel simple à responses.create ne possède pas de output_parsed au niveau supérieur ; pour obtenir un objet parsé, utilisez l'assistant parse du SDK avec un format typé, ou lisez response.output_text et validez-le vous-même :
from pydantic import BaseModel
class Invoice(BaseModel):
vendor: str
total: float
currency: str
resp = client.responses.parse(
model="gpt-5.6",
input="Extract the invoice fields.",
text_format=Invoice, # SDK builds the strict json_schema and parses the result
)
invoice = resp.output_parsed # a validated Invoice instance
Si vous préférez la voie brute, envoyez text={"format": {"type": "json_schema", "name": "invoice", "strict": True, "schema": SCHEMA}} avec responses.create, puis parsez et validez explicitement resp.output_text : n'attendez pas qu'un champ parsé apparaisse de lui-même.
Envoyer response_format à Responses, ou text.format à Chat Completions, est une erreur fréquente et déroutante — c'est le nom du champ qui a changé, pas le concept. La couverture d'une passerelle dépend de l'adaptateur et n'est pas uniforme : cette passerelle mappe un response_format de forme OpenAI vers le responseSchema de Gemini, mais ne le traduit pas actuellement vers le champ natif de sortie structurée de Claude. Pour Claude, envoyez donc la forme native Anthropic ci-dessous au lieu de supposer que votre response_format lui parvient inchangé.
Le sous-ensemble de schéma strict a ses règles
strict: true prend en charge une grande partie de JSON Schema mais pas tout, et les contraintes sont assez strictes pour qu'un schéma qui valide parfaitement dans un validateur normal puisse être rejeté ici :
- Chaque propriété doit être listée dans
required. Il n'y a pas de champs vraiment optionnels. Pour modéliser « peut être absent », faites du champ une union nullable —"type": ["string", "null"]— plutôt que de l'omettre derequired. additionalProperties: falseest obligatoire sur chaque objet. Le modèle ne peut pas inventer de clés supplémentaires.- La racine doit être un objet. Un tableau de premier niveau n'est pas autorisé ; enveloppez-le dans un objet avec une propriété de type tableau.
- La récursion utilise
{"$ref": "#"}pour référencer la racine, ce qui est la façon d'exprimer des structures arborescentes imbriquées.
Lorsque le modèle refuse pour une raison de sécurité, cela est exposé programmatiquement comme un champ refusal sur le message plutôt que comme un contenu conforme au schéma, si bien que vous vérifiez la présence d'un refus avant de faire confiance au parse. Et une réponse peut être tronquée par la longueur : inspectez finish_reason (une valeur "length" signifie du JSON tronqué), car une réponse stricte tronquée reste du JSON invalide même si le schéma a été imposé.
Gemini : responseSchema plus un type MIME JSON
Gemini contraint la sortie via generationConfig. Vous définissez responseMimeType à "application/json" et fournissez un responseSchema (un objet de schéma de style OpenAPI) — ou, pour du JSON Schema standard, responseJsonSchema :
from google import genai
from google.genai import types
client = genai.Client()
resp = client.models.generate_content(
model="gemini-3.5-flash",
contents="Classify the ticket.",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature"]},
"priority": {"type": "integer"},
},
"required": ["category", "priority"],
},
),
)
Gemini contraint la génération au schéma, prend en charge enum pour la classification, et vous laisse fixer l'ordre des champs avec propertyOrdering — utile car l'ordre peut affecter la qualité et la cohérence. Notez deux réserves sur lesquelles la documentation est explicite : toutes les fonctionnalités de JSON Schema ne sont pas prises en charge, et des schémas très complexes peuvent être rejetés. Gemini souligne aussi que la sortie est du JSON valide et conforme au schéma mais que vous devriez quand même valider les valeurs — il peut produire quelque chose de conforme au schéma mais sémantiquement erroné (une catégorie plausible-mais-incorrecte). Le mécanisme garantit la forme, pas la vérité.
Anthropic : output_config.format natif, avec usage strict des outils
Anthropic propose désormais des sorties structurées natives ; l'usage forcé des outils n'est donc plus l'unique voie, mais le repli de compatibilité. Deux fonctionnalités complémentaires couvrent le besoin. Les sorties JSON placent sur la requête un objet output_config dont le format vaut {"type": "json_schema", "schema": ...} ; la réponse de Claude revient sous forme de JSON conforme au schéma dans le contenu textuel. L'usage strict des outils ajoute strict: true au input_schema d'un outil pour garantir des arguments conformes au schéma. Vous pouvez employer l'un ou l'autre séparément, ou les combiner.
import anthropic
client = anthropic.Anthropic(base_url="https://your-gateway.example.com", api_key="AVE_GATEWAY_API_KEY")
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {"vendor": {"type": "string"}, "total": {"type": "number"}},
"required": ["vendor", "total"],
"additionalProperties": False,
},
}
},
messages=[{"role": "user", "content": "Extract the invoice fields."}],
)
data = msg.content[0].text # valid JSON matching your schema
Quelques limites actuelles méritent d'être connues : additionalProperties doit valoir false, tandis que les contraintes numériques ou textuelles telles que minimum et maxLength, ainsi que les schémas récursifs, ne sont pas encore pris en charge — les envoyer produit un 400. Le SDK Python expose aussi client.messages.parse(..., output_format=PydanticModel) pour construire le schéma et parser la réponse en une étape. Lorsque vous avez précisément besoin que le modèle invoque quelque chose, l'usage strict des outils est la voie idiomatique : définissez un outil dont l'input_schema est votre schéma cible, ajoutez strict: true et forcez-le avec tool_choice.
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=[{
"name": "record_invoice",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"vendor": {"type": "string"}, "total": {"type": "number"}},
"required": ["vendor", "total"],
"additionalProperties": False,
},
}],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=[{"role": "user", "content": "Extract the invoice fields."}],
)
data = next(b.input for b in msg.content if b.type == "tool_use")
Forcer l'outil rend le comportement déterministe : le modèle doit appeler record_invoice, et son input est votre objet. Utilisez les sorties JSON lorsque vous voulez une réponse typée, et l'usage strict des outils lorsque le modèle doit appeler quelque chose avec des arguments garantis conformes. L'usage forcé des outils sans strict reste un repli historique pour les anciens modèles ou certaines formes de boucles d'outils ; sur les modèles actuels, préférez les fonctionnalités natives. Si votre produit route déjà des outils entre fournisseurs, les définitions se reportent — voir l'appel d'outils portable entre fournisseurs.
Sortie structurée vs function calling : choisir selon l'intention
Ces deux usages de JSON Schema se ressemblent et sont faciles à confondre. La distinction tient à l'intention. Utilisez le function calling quand le modèle doit décider s'il faut invoquer un outil qui fait quelque chose, et comment — interroger une base de données, appeler une API, exécuter du code. Utilisez un format de réponse structuré (json_schema / text.format / responseSchema / output_config.format) quand vous voulez simplement que la réponse du modèle soit mise en forme comme des données que votre programme consommera. Les trois fournisseurs exposent désormais les deux possibilités : OpenAI prend en charge les schémas stricts dans les formats de réponse comme dans les outils, Anthropic associe les sorties JSON natives à l'usage strict des outils, et Gemini sépare schémas de réponse et déclarations de fonctions. Recourir à un appel d'outil alors que vous ne vouliez qu'une réponse typée ajoute un aller-retour dont vous n'avez pas besoin.
Vous validez malgré tout — toujours
L'imposition d'un schéma n'est pas un permis pour sauter la validation, pour trois raisons concrètes. Premièrement, l'imposition garantit la forme, jamais l'exactitude : un modèle peut renvoyer une valeur bien typée mais factuellement erronée, et seules vos règles métier attrapent cela. Deuxièmement, la troncature arrive — une réponse limitée en longueur ou expirée produit du JSON incomplet qu'aucun mode de schéma ne peut sauver, si bien que vous devez gérer le finish_reason/status et l'erreur de parse. Troisièmement, la prise en charge des schémas est un sous-ensemble qui diffère selon le fournisseur, si bien qu'un schéma qui impose strictement sur un amont peut être traité plus souplement sur un autre. Faites passer l'objet parsé par un validateur (Pydantic, Zod, JSON Schema) à la frontière, et traitez un échec de validation comme une branche normale et attendue plutôt que comme une exception. C'est le même principe de défense en profondeur qui sous-tend le bornage de toute entrée externe.
Une passerelle aide ici en vous donnant un seul endroit où envoyer response_format et un seul endroit où voir quelles requêtes ont utilisé une sortie structurée et combien de fois elles ont été réessayées, sans apprendre à chaque client les noms de champs de trois fournisseurs. Derrière une passerelle d'API compatible OpenAI, vous écrivez une seule fois le response_format de forme OpenAI, puis la passerelle le route vers le canal qui sert le modèle. Sachez toutefois que la traduction dépend de l'adaptateur : le champ atteint OpenAI sans modification et est mappé vers le responseSchema de Gemini, mais le output_config.format natif de Claude n'est pas actuellement produit à partir de celui-ci. Ciblez donc Claude avec la forme native Anthropic.
FAQ
Quelle est la différence entre le mode JSON et les sorties structurées ?
Le mode JSON (response_format: {"type": "json_object"}) garantit que la sortie parse comme du JSON mais ne dit rien sur sa forme — les champs, les types et les énumérations sont non contraints. Les sorties structurées ajoutent un json_schema avec strict: true, qui lie le modèle à votre schéma. Préférez les sorties structurées chaque fois que le fournisseur les prend en charge ; réservez le mode JSON nu aux cas où vous n'avez véritablement pas de forme fixe.
Pourquoi mon schéma strict est-il rejeté alors qu'il valide ailleurs ?
Le sous-ensemble strict est plus étroit que le JSON Schema complet. Les coupables habituels sont un additionalProperties: false manquant, une propriété laissée hors de required, ou une racine non-objet. Rendez chaque champ requis (en utilisant des unions nullables pour ceux optionnels), définissez additionalProperties: false sur chaque objet, et enveloppez les tableaux de premier niveau dans un objet. Sur Claude en particulier, les contraintes numériques ou textuelles et les schémas récursifs sont rejetés sans détour ; retirez-les également.
La sortie structurée garantit-elle que les valeurs sont correctes ?
Non. Elle garantit que la réponse correspond à la forme du schéma — types, champs requis, valeurs d'énumération autorisées — pas que le contenu est exact. Un modèle peut renvoyer une réponse parfaitement typée mais erronée. Validez toujours les valeurs par rapport à vos règles métier après le parsing, et traitez la conformité au schéma comme une garantie de syntaxe, pas d'exactitude.
Comment obtenir une sortie contrainte par schéma depuis Claude ?
Envoyez un output_config dont le format vaut {"type": "json_schema", "schema": ...} pour obtenir une réponse typée, ou ajoutez strict: true à un outil forcé lorsque le modèle doit invoquer quelque chose. Ces deux mécanismes sont natifs sur les modèles actuels ; l'ancienne astuce consistant à forcer un outil sans strict fonctionne encore, mais constitue désormais un repli plutôt que la voie prévue.
Supprimez une regex
Prenez un endroit dans votre code où vous parsez actuellement la réponse en texte libre d'un modèle et extrayez le JSON par regex. Remplacez le prompt par un json_schema — ou le output_config.format de Claude —, définissez strict: true, et enveloppez le résultat parsé dans un validateur de schéma. Envoyez ensuite une entrée délibérément difficile et confirmez deux choses : la requête réussit sans étape de nettoyage de JSON encadré, et un refus ou une troncature apparaît comme une branche gérée plutôt que comme un plantage. Supprimer cette regex est le signal concret que la contrainte fait le travail.