Aller au contenu principal

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.

Un JSON valide n'est pas une donnée correcte

{"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.

Écrire le schéma avant la consigne

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 null ou une sentinelle enumérée ; un champ marqué required sans 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.