learn.chetana.fr

Contrats de sortie : forcer le JSON juste

14 min readCore

Le problème : un LLM produit du texte, ton app veut de la donnée

« Réponds en JSON » dans le prompt donne du JSON… 95 % du temps. Les 5 % (une virgule en trop, un champ manquant, du markdown autour) cassent en prod. La solution de production n'est pas de mieux prier — c'est de contraindre la génération.

La chaîne à trois maillons

1. TOOL FORCÉ       tool_choice = {type:"tool", name:"emit_order"}
                    input_schema = le JSON Schema de la sortie voulue
   → le modèle NE PEUT PAS produire autre chose que des arguments conformes au schéma

2. VALIDATION       StructuredOrder.model_validate(tool_input)   # Pydantic
   → deuxième rideau : types, bornes (quantity > 0), enums, champs requis
   → une sortie invalide EXPLOSE ici, à la frontière (fail loud)

3. FILTRE MÉTIER    validated_lines = [l for l in lines if l.quantity is not None]
   → la décision métier, en code déterministe (pas dans le prompt)

Le premier maillon élimine les erreurs de forme (le modèle est bridé au schéma), le deuxième les erreurs de type/valeur, le troisième applique la logique métier. On ne demande jamais au prompt ce qu'un schéma peut garantir.

Les décisions de conception d'un bon schéma

  • null explicite plutôt qu'absence : « je n'ai pas trouvé » (null) ≠ « champ oublié » — le schéma doit distinguer, la doctrine quantités en dépend ;
  • enums fermés : un statut est matched|needs_review|not_found, jamais une string libre — le schéma ferme l'espace ;
  • le schéma partagé entre routes : dans le projet, la sortie texte (Anthropic) et la sortie vision (Mistral OCR) valident le même StructuredOrder — un contrat, deux producteurs (cours Stack IA) ;
  • descriptions dans le schéma : les champs JSON Schema portent des description — le modèle les lit, elles font partie du prompt effectif.

Fermer le schéma : bannir les sacs libres

Un contrat n'est fermé que si le modèle ne peut rien y glisser hors périmètre. Le contre-exemple classique : un champ fourre-tout — metadata, additional_info, extra — « au cas où ». C'est une porte dérobée : le modèle y déverse ce qu'il ne sait pas caser, et ton contrat, si strict soit-il sur le reste, laisse passer un sac de données non structurées que personne ne valide. Un null explicite dit « pas trouvé » ; un sac libre dit « débrouille-toi » — l'exact inverse d'un contrat.

La parade est déclarative : additionalProperties: false en JSON Schema (ou model_config = ConfigDict(extra="forbid") côté Pydantic). Tout champ non prévu fait alors échouer la validation au lieu d'être silencieusement accepté. Le schéma passe de « au moins ces champs » à « exactement ces champs ».

Mais il y a un piège de couverture, et c'est le vrai enseignement : l'invariant doit couvrir AUSSI les schémas dérivés. Si un composant génère des variantes du schéma à la volée — un splitter qui découpe l'extraction en sous-schémas, un builder qui compose des blocs — poser additionalProperties: false sur le seul schéma principal ne suffit pas : la porte se rouvre par le chemin annexe. Le test d'invariant doit s'appliquer récursivement à chaque schéma réellement envoyé au modèle, pas seulement au schéma canonique :

def test_aucun_sac_libre(schema: dict):
    # sur le schéma principal ET tout schéma dérivé/généré
    assert schema.get("additionalProperties") is False
    for sous_schema in objets_imbriques(schema):   # récursif : nested + variantes générées
        test_aucun_sac_libre(sous_schema)

Principe transférable : fermer une porte ne suffit pas — il faut fermer toutes celles qui donnent sur la même pièce. Un invariant qui ne couvre que le chemin principal est un faux sentiment de sécurité : la régression passe toujours par la variante qu'on a oublié de tester.

Le coût, honnêtement

Le tool forcé consomme des tokens (le schéma est envoyé) et contraint un peu le modèle (parfois il « raisonnerait mieux » en texte libre puis structurerait). Le compromis quasi toujours gagnant en prod : la fiabilité de forme vaut plus que les quelques points de qualité d'un texte libre non parsable. Pour les cas où le raisonnement compte, un pattern existe : penser en texte d'abord (un champ reasoning), émettre la structure ensuite — dans le même schéma.

Le piège : deux sources de vérité qui divergent

Souvent le JSON Schema envoyé au tool est écrit à la main, séparément du modèle Pydantic qui valide. Deux définitions de la même structure = double maintenance. Le jour où tu ajoutes un champ au modèle mais oublies le schéma (ou l'inverse), le tool ne le produit jamais, ou Pydantic le rejette — un bug silencieux et pénible à diagnostiquer.

La parade n'est pas « faire attention », c'est un test qui verrouille la synchronie :

def test_schema_couvre_le_modele():
    assert set(TOOL_SCHEMA["properties"]) == set(StructuredOrder.model_fields)
    # + récursif sur les blocs imbriqués (lignes, adresses…)

Le test échoue à la seconde où les deux divergent. Principe général : quand une même vérité est dupliquée (par nécessité), un test doit rendre la divergence impossible à merger. Mieux encore quand c'est faisable : générer le schéma depuis le modèle (model_json_schema()) — une seule source. Le test reste utile pour ce qui doit rester manuel (l'ordre des champs, les description).

🧩 Quiz1/5

Pourquoi un tool forcé plutôt que « réponds en JSON » dans le prompt ?

🃏 Flashcards1/6