Obtenir du JSON Fiable depuis un Agent (Schemas, Décodage Contraint, Relances)
Un pipeline que j'avais construit l'an dernier a planté à 2 h du matin parce qu'un modèle a décidé d'emballer son JSON dans un préambule bavard : « Bien sûr ! Voici les données que vous avez demandées : » suivi d'un bloc de code. Mon parser, qui s'attendait à ce que le corps de la réponse commence par {, a levé une exception. La relance a touché le même modèle aimable, a obtenu le même aimable préambule, et a encore levé une exception. Trois tentatives, trois échecs, un humain réveillé.
Ce qui est agaçant, c'est que le JSON à l'intérieur du bloc de code était parfait. Le modèle avait fait la partie difficile — l'extraction — correctement. Il ne pouvait tout simplement pas s'empêcher de dire bonjour d'abord. J'avais investi mon effort dans le prompt et rien sur le contrat.
C'est tout le problème en une anecdote. Le modèle est généralement capable de produire les données que vous voulez. L'échec se trouve dans les jointures : le préambule, la virgule de fin, le champ qu'il a renommé, la valeur d'enum qu'il a inventée, la réponse tronquée au milieu d'un objet parce que vous avez oublié d'augmenter la limite de tokens. Aucune de ces erreurs n'est un échec de raisonnement. Ce sont des échecs de contrat, et on s'en défend avec des mécanismes, pas avec un meilleur prompting.
La version courte : ne demandez pas du JSON, contraignez le modèle à en produire. Utilisez le mode de sortie structurée contraint par schema de votre fournisseur (désormais standard chez OpenAI, Anthropic et Google), validez le résultat contre un schema qui vous appartient, et maintenez une boucle de réparation-relance pour les cas qui passent malgré tout. Voici l'échelle du plus faible au plus fort, et jusqu'où je monte vraiment.
L'échelle, du plus faible au plus fort
Il y a environ cinq barreaux, et la plupart des gens se trouvent un barreau plus bas qu'ils ne le pensent.
Barreau un : le prompt-and-pray. Vous écrivez « réponds uniquement en JSON, sans autre texte » et vous espérez. Ça fonctionne assez souvent en démo pour être dangereusement trompeur. Ça échoue sur les préambules, les blocs de code, la prose de fin, et tout modèle qui a une journée expressive. Ne livrez pas ça en production.
Barreau deux : le mode JSON. Un flag fournisseur qui garantit que la sortie est du JSON syntaxiquement valide — il sera parsé. Il ne garantit pas que la sortie correspond à votre structure. Les docs d'OpenAI sont directs à ce sujet : c'est l'option la plus ancienne et la plus faible. « Structured Outputs est l'évolution du mode JSON. Bien que les deux assurent la production de JSON valide, seules les Structured Outputs assurent l'adhérence au schema » (guide des sorties structurées OpenAI). Le mode JSON arrête le problème de préambule. Il ne fait rien pour un champ manquant ou un enum hallucinated.
Barreau trois : les sorties structurées contraintes par schema. Vous fournissez un JSON Schema au fournisseur, qui contraint la génération pour que la sortie soit conforme — chaque champ obligatoire présent, chaque type correct, chaque valeur d'enum tirée de votre liste. C'est le barreau où vivre pour l'extraction de données.
Barreau quatre : l'appel d'outils / de fonctions. Le modèle émet un appel à une fonction dont les paramètres sont un JSON Schema. Mécaniquement, c'est la même contrainte que le barreau trois, appliquée aux arguments d'outils plutôt qu'au corps de la réponse. Vous en avez besoin quand le modèle choisit quelle action prendre, pas seulement quand il remplit un formulaire.
Barreau cinq : le décodage contraint / basé sur une grammaire que vous exécutez vous-même. Avec des modèles open-weight, vous contrôlez le décodeur, ce qui vous permet de masquer les logits de tokens à chaque étape pour interdire tout token qui briserait la grammaire. C'est la garantie la plus forte — le modèle ne peut littéralement pas émettre un token invalide — et le plus gros travail.
La plupart du code en production devrait se trouver au barreau trois ou quatre en utilisant un fournisseur hébergé. Laissez-moi vous montrer ce que ça donne.
Barreau trois : la sortie structurée contrainte par schema
Les trois grands fournisseurs le proposent maintenant, et la forme est suffisamment similaire pour être apprise en une fois.
OpenAI utilise response_format avec un json_schema et strict: true. Le flag strict transforme le « s'il vous plaît » en « garanti ». Ses exigences sont précises et faciles à faire trébucher : additionalProperties doit être false sur chaque objet, et chaque propriété doit figurer dans le tableau required. Les champs optionnels sont exprimés comme une union avec null, pas par omission (guide des sorties structurées OpenAI).
Anthropic a ajouté la même capacité à l'API Claude — maintenant généralement disponible, sans avoir besoin du header beta (structured-outputs-2025-11-13). Vous passez un output_config.format pour un corps de réponse JSON, et vous définissez strict: true sur les définitions d'outils pour des entrées d'outils garanties valides. Le sous-ensemble de schema est du même style qu'OpenAI : additionalProperties: false sur chaque objet, chaque propriété listée dans required (docs sorties structurées Anthropic).
Gemini de Google le fait via la configuration de génération : définissez responseMimeType sur application/json et passez votre schema en tant que responseSchema. Leurs SDKs vous permettent de définir le schema avec Pydantic en Python ou Zod en TypeScript au lieu d'écrire du JSON Schema à la main (docs sortie structurée Gemini).
Voici la forme TypeScript avec OpenAI. Je définis le schema en Zod et le convertis, parce que maintenir le JSON Schema à la main est la façon dont le schema et vos types divergent.
import OpenAI from "openai";
import * as z from "zod";
const Ticket = z.object({
priority: z.enum(["low", "medium", "high", "urgent"]),
category: z.enum(["billing", "bug", "feature", "other"]),
summary: z.string().max(280),
needs_human: z.boolean(),
});
const client = new OpenAI();
const res = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [
{ role: "system", content: "Classify the support ticket." },
{ role: "user", content: ticketText },
],
response_format: {
type: "json_schema",
json_schema: {
name: "ticket",
strict: true,
schema: z.toJSONSchema(Ticket),
},
},
});
const parsed = Ticket.parse(JSON.parse(res.choices[0].message.content));
z.toJSONSchema() est intégré dans Zod 4 et cible Draft 2020-12 par défaut (docs Zod JSON Schema). Notez que les champs priority et category sont des enums — avec le mode strict activé, le modèle ne peut pas retourner "critical" même s'il le voulait, parce que ce token n'est pas dans l'ensemble autorisé. Cette seule propriété élimine toute une classe de bugs.
Ce que la contrainte ne vous protège pas : elle garantit la forme, pas le sens. Un modèle contraint mettra joyeusement une vraie catégorie sur un ticket qu'il a mal lu. C'est pourquoi j'exécute quand même Ticket.parse() à la fin — en partie contre la dérive du schema entre mes types et le format sur le fil, en partie parce que j'ajoute parfois des vérifications sémantiques que JSON Schema ne peut pas exprimer.
Barreau quatre : l'appel d'outils, et quand le préférer
L'appel d'outils est la même contrainte pointée vers une cible différente. Au lieu de « remplis ce corps de réponse », c'est « si tu appelles un outil, tes arguments doivent correspondre à son schema ». L'utilisation stricte des outils d'Anthropic rend cette garantie explicite : avec strict: true, « les entrées d'outils de Claude correspondent exactement à votre schema » (docs sorties structurées Anthropic).
La raison de préférer les outils à une réponse structurée simple est le choix. Quand le modèle choisit parmi search_orders, issue_refund et escalate, chacun avec sa propre forme d'arguments, vous voulez l'appel de fonctions — l'union discriminée de quel outil, plus les args validés pour cet outil, est exactement le primitif de la boucle d'agent. Quand vous avez juste besoin d'une forme fixe en retour d'un appel, un corps de réponse structurée est plus simple et vous évitez la cérémonie d'appel d'outils.
Un croquis pratique du bouclage en TypeScript :
// model returns tool_calls; each has a name + JSON arguments
for (const call of message.tool_calls ?? []) {
const tool = tools[call.function.name];
if (!tool) throw new Error(`hallucinated tool: ${call.function.name}`);
// strict mode guarantees this parses to the tool's shape,
// but validate anyway — providers differ, and you own the contract
const args = tool.schema.parse(JSON.parse(call.function.arguments));
const result = await tool.run(args);
// feed result back into the conversation, continue the loop
}
Je garde le guard hallucinated tool même en mode strict, parce que l'ensemble de quels outils existent et l'ensemble de arguments valides par outil sont appliqués par différentes parties du stack, et je ne veux pas supposer que les deux sont hermétiques chez chaque fournisseur.
Barreau cinq : le décodage contraint que vous exécutez vous-même
Quand vous exécutez un modèle open-weight, vous possédez le décodeur, ce qui signifie que vous pouvez faire respecter la grammaire au niveau du token : à chaque étape, masquez tout token qui rendrait la sortie partielle non parseable contre votre schema. Le modèle ne peut pas produire de JSON invalide parce que les tokens invalides ne sont jamais au menu.
La bibliothèque avec laquelle la plupart des gens commencent est Outlines, qui supporte regex, JSON Schema et les grammaires complètes sans contexte en EBNF. C'est une vraie capacité avec un vrai coût : l'approche de machine à états finis initiale peut passer beaucoup de temps à compiler des schemas complexes, et un benchmark de JSON-schema a constaté que les délais de compilation faisaient baisser son taux de conformité sur les schemas lourds. Le backend vers lequel la plupart des stacks de service à haut débit ont migré est XGrammar — le backend de génération structurée par défaut dans des moteurs comme vLLM et SGLang, avec une très faible surcharge par token (XGrammar). Si vous vous auto-hébergez et que la sortie structurée est sur le chemin chaud, c'est là qu'est allé l'écosystème.
Une mise en garde à connaître avant d'y recourir : les contraintes de grammaire dures peuvent mal interagir avec le raisonnement. Des travaux publiés soutiennent que forcer la structure trop tôt peut nuire à la qualité réelle de la réponse du modèle par rapport à le laisser penser en prose et structurer ensuite — le soi-disant coût d'alignement du décodage contraint (arXiv). La lecture pratique : contraignez la sortie, mais donnez au modèle la place de raisonner d'abord si la tâche est difficile. N'obligez pas le modèle à penser en JSON.
Les modes de défaillance, et comment vraiment se défendre
La contrainte gère la forme. Voici ce qu'elle ne gère pas, et ce qui vous réveille en pleine nuit.
La troncation. La plus courante de toutes. Le modèle atteint la limite de tokens de sortie au milieu d'un objet et vous obtenez la moitié d'un document JSON qu'aucune contrainte ne peut sauver — il était valide jusqu'à ce qu'il s'arrête. Défenses : définissez max_tokens généreusement pour le pire cas du schema, et en cas d'échec de parsing vérifiez si la réponse s'est arrêtée à cause de la longueur avant de relancer aveuglément. Relancer une troncation avec la même limite brûle juste de l'argent.
La dérive du schema. La forme attendue de votre code et le schema que vous avez envoyé au modèle se désynchronisent — généralement parce que quelqu'un a édité le JSON Schema à la main. La solution est de ne jamais l'écrire à la main : dérivez le JSON Schema du même type Zod ou Pydantic avec lequel vous parsez, de sorte qu'il n'y ait qu'une seule source de vérité. Instructor s'appuie exactement sur cela, en enveloppant le client pour qu'un modèle Pydantic définisse le schema, valide la réponse et relance en cas d'échec, le tout en un seul objet.
Les valeurs d'enum hallucinées. Un modèle en mode prompt-and-pray invente "critical" quand votre enum est low|medium|high. Les sorties structurées strictes l'éliminent directement au barreau trois — le token invalide est inaccessible. Si vous n'êtes pas en mode contraint, un parser validateur le transforme en erreur capturable plutôt qu'en surprise en aval.
La sur-imbrication et les renommages silencieux. Le modèle retourne les bonnes données sous customer_name quand vous avez demandé name, ou enveloppe tout dans une couche { "result": ... } supplémentaire. additionalProperties: false plus une liste required exhaustive est ce qui interdit au modèle d'ajouter ou de renommer des champs ; c'est précisément pourquoi OpenAI et Anthropic rendent ces deux contraintes obligatoires pour le mode strict.
La boucle de réparation fait le lien. Quand la validation échoue, ne relancez pas juste le même appel — renvoyez l'erreur :
import instructor
from pydantic import BaseModel
client = instructor.from_provider("openai/gpt-4o")
class Ticket(BaseModel):
priority: str
summary: str
# on a validation error, Instructor sends the Pydantic error message
# back to the model and asks it to fix the specific field — then re-validates
ticket = client.chat.completions.create(
response_model=Ticket,
max_retries=2,
messages=[{"role": "user", "content": ticket_text}],
)
Montrer au modèle l'erreur de validation spécifique (« priority doit être l'un de low, medium, high ») est bien plus efficace qu'une relance à l'aveugle, parce que vous lui avez dit exactement quoi corriger. Mais limitez les relances — deux suffisent. Un modèle qui échoue la validation trois fois de suite échoue généralement pour une raison que plus de relances ne résoudront pas, et vous voulez dégrader gracieusement : retournez une erreur typée, revenez à une valeur par défaut, ou routez vers un humain. Boucler indéfiniment sur une entrée empoisonnée, c'est exactement ce qui m'a valu mon alerte à 2 h du matin.
Ce à quoi je recours vraiment, par cas d'usage
Pour l'extraction de données ou la classification contre un modèle hébergé — le cas courant — utilisez le mode strict contraint par schema de votre fournisseur, dérivez le schema de Zod ou Pydantic, et validez le résultat avec le même type. C'est le barreau trois plus un parser validateur, et ça couvre la majeure partie de ce que les gens construisent.
Pour les agents qui choisissent des actions, utilisez l'appel d'outils strict. La forme quel-outil-plus-args-validés est ce que vous voulez, et le flag strict comble le fossé de validation des arguments. Gardez un guard pour les noms d'outils que vous ne reconnaissez pas.
Pour les modèles open-weight auto-hébergés sur un chemin chaud, utilisez le décodage contraint par grammaire via votre moteur de service — les backends de type XGrammar sont maintenant assez rapides pour qu'il y ait peu de raisons de le construire à la main. Laissez le modèle raisonner en prose d'abord si la tâche est difficile, puis contraignez la partie structurée.
Et quel que soit le barreau : validez à la frontière avec un schema qui vous appartient, et maintenez une boucle de réparation bornée avec une dégradation gracieuse. La contrainte empêche le modèle d'émettre des déchets. Le validateur empêche vos hypothèses d'être les déchets. Mon pipeline et mon sommeil se sont tous les deux améliorés quand j'ai arrêté de faire confiance au prompt et commencé à faire respecter le contrat.
