Module 5 — Sorties structurées : JSON et schémas
Les modules précédents ont amené la sortie à ressembler à un JSON. Ce module s'occupe du dernier saut : garantir que la sortie est un JSON valide, conforme à un schéma précis, exploitable sans post-traitement fragile.
Le problème du « JSON en prose »
Une consigne bien écrite produit, la plupart du temps, un JSON exploitable. « La plupart du temps » ne suffit pas en production. Voici les échecs observés à taux résiduel sur notre fil rouge, même après le module 2 :
- Texte d'introduction avant le JSON (« Voici l'extraction : »)
- Bloc de code Markdown autour (
```json ... ```) - Clé manquante quand le courriel ne la mentionne pas
- Valeur hors énumération (« très_urgente ») quand le modèle « améliore »
- JSON coupé au milieu quand la limite de jetons est atteinte
- Guillemets typographiques
«»au lieu de guillemets droits"
Chaque type d'échec est rare, mais leur cumul rend l'application non-déterministe. Il faut deux choses : contraindre la sortie du modèle, et valider ce qu'il rend.
Le mode JSON natif
Les grandes API récentes proposent un mode dédié qui force la sortie à être un JSON syntaxiquement valide :
reponse = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEME},
{"role": "user", "content": courriel},
],
response_format={"type": "json_object"},
temperature=0,
)
Ce mode garantit la syntaxe : parenthèses appariées, chaînes bien terminées, virgules correctement placées. Il ne garantit pas le schéma : les clés peuvent manquer, être en trop, ou porter des valeurs hors énumération. C'est l'étape suivante.
Le mode « sortie structurée » avec schéma
Plusieurs fournisseurs offrent un mode plus fort où l'on transmet un JSON Schema et la sortie est garantie de s'y conformer :
schema = {
"type": "object",
"properties": {
"motif": {"type": "string", "enum": ["livraison", "produit_defectueux",
"facturation", "autre"]},
"produit": {"type": ["string", "null"]},
"urgence": {"type": "string", "enum": ["faible", "moyenne", "elevee"]},
"action_demandee": {"type": "string", "enum": ["remboursement",
"remplacement",
"information",
"aucune"]},
},
"required": ["motif", "produit", "urgence", "action_demandee"],
"additionalProperties": False,
}
reponse = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "system", "content": SYSTEME},
{"role": "user", "content": courriel}],
response_format={"type": "json_schema", "json_schema": {
"name": "reclamation", "schema": schema, "strict": True,
}},
)
Avec strict: True, le décodeur du fournisseur applique le schéma à la
génération, en interdisant tout jeton qui violerait la structure. Le
gain de fiabilité est spectaculaire ; le coût est une légère perte de
flexibilit é — le schéma doit être écrit à l'avance et ne peut pas évoluer
au fil de la conversation.
Écrire le schéma sans dupliquer les règles
Écrire un enum dans le schéma et répéter les valeurs autorisées dans
la consigne système invite la divergence. Une approche disciplinée :
générer la consigne système à partir du schéma.
def valeurs_autorisees(schema: dict, chemin: str) -> str:
return " | ".join(schema["properties"][chemin]["enum"])
SYSTEME = f"""
Extrais quatre champs d'un courriel de réclamation. Valeurs autorisées :
- motif : {valeurs_autorisees(schema, "motif")}
- urgence : {valeurs_autorisees(schema, "urgence")}
- action_demandee : {valeurs_autorisees(schema, "action_demandee")}
Rends un JSON conforme au schéma « reclamation ».
""".strip()
Le schéma devient l'unique source de vérité. Ajouter une valeur d'énumération se fait à un seul endroit, et la consigne système est mise à jour au prochain appel sans intervention manuelle.
Valider en Python, quand même
Même avec le mode strict côté fournisseur, valider côté client protège de trois choses : un changement d'API silencieux, une nouvelle version du modèle qui remet en cause une garantie, et surtout la relance en cas d'échec inattendu.
from pydantic import BaseModel, ValidationError
from typing import Literal, Optional
class Reclamation(BaseModel):
motif: Literal["livraison", "produit_defectueux", "facturation", "autre"]
produit: Optional[str]
urgence: Literal["faible", "moyenne", "elevee"]
action_demandee: Literal["remboursement", "remplacement",
"information", "aucune"]
def extraire_valide(courriel: str, max_essais: int = 2) -> Reclamation:
erreur = None
for _ in range(max_essais):
brut = appeler_modele(courriel, erreur)
try:
return Reclamation.model_validate_json(brut)
except ValidationError as e:
erreur = str(e)
raise RuntimeError(f"Extraction impossible : {erreur}")
En cas d'échec, on relance en injectant l'erreur dans la consigne utilisateur (« ta sortie précédente était invalide : ... corrige. »). Deux essais suffisent presque toujours ; au-delà, l'entrée est probablement pathologique et mérite un traitement humain.
Champs optionnels et valeurs inconnues
Le piège classique : marquer un champ required alors qu'il est absent
dans la moitié des courriels. Le modèle invente une valeur pour
respecter le schéma. C'est la première cause d'hallucination dans les
extractions structurées.
Deux solutions propres :
Autoriser null dans le type du champ pour dire « inconnu » :
"produit": {"type": ["string", "null"]}
Utiliser une valeur sentinelle enumérée pour distinguer « absent » de « ne s'applique pas » :
"action_demandee": {"enum": ["remboursement", "remplacement",
"information", "aucune", "non_precisee"]}
Dans la consigne système, dire explicitement que null ou la sentinelle
est un choix valide, sans quoi le modèle continuera à halluciner par
politesse.
{"motif": "livraison", "urgence": "elevee"} est syntaxiquement parfait et
factuellement faux si le courriel ne parle en réalité que de facturation.
Le mode JSON garantit le contenant, pas le contenu. L'évaluation
systématique du module 9 reste indispensable, même avec strict: True.
Le cas de la sortie tronquée
Une sortie coupée au milieu par la limite max_tokens casse la validation
sans que le modèle en soit responsable :
reponse = client.chat.completions.create(
...,
max_tokens=200,
)
if reponse.choices[0].finish_reason == "length":
logger.warning("sortie tronquee, augmenter max_tokens")
Toujours consulter finish_reason. Une valeur stop indique une sortie
complète ; length signale la troncature. Pour notre fil rouge, 200 jetons
suffisent largement, mais un résumé long ou une réponse rédigée demande
d'y penser.
Le réflexe utile : rédiger le JSON Schema en premier, sur papier ou dans
un fichier .json. Il capture la spécification métier — quelles valeurs
sont attendues, lesquelles sont optionnelles, comment noter l'inconnu.
La consigne système en découle mécaniquement et les deux ne divergent plus.
Cela impose aussi de discuter avec les consommateurs de la sortie avant
d'écrire une seule ligne de consigne.
En résumé
- Le mode JSON natif garantit la syntaxe ; le mode schema strict garantit aussi la structure et les énumérations.
- Écrire le schéma comme unique source de vérité et générer la consigne système à partir de lui évite la divergence entre les deux.
- Valider côté client avec Pydantic ou équivalent, et relancer en injectant l'erreur dans la consigne suivante, protège des cas résiduels.
- Un champ absent doit être
nullou une sentinelle enumérée ; un champ marquérequiredsans issue force le modèle à halluciner.
Module suivant : contrôler le style, la longueur et le ton — utile pour tout ce qui n'est pas une extraction, comme la réponse générée à envoyer au client.