Contrats de sortie : forcer le JSON juste
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
nullexplicite 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).
Pourquoi un tool forcé plutôt que « réponds en JSON » dans le prompt ?