Limites du JSON Mode et gestion d'erreurs
Mis à jour le 29 juillet 2026
Valide ne veut pas dire correct
Le JSON Mode garantit un JSON syntaxiquement valide, mais pas un JSON sémantiquement correct. Cette distinction paraît subtile en développement ; elle devient centrale en production. Un JSON qui se parse sans erreur mais dont les clés ne sont pas celles attendues produit exactement le type de bug que l’on met des jours à localiser, parce qu’aucune exception ne se déclenche au moment où le problème apparaît.
Aucune garantie de schéma
Le mécanisme ne sait rien de votre schéma : il n’a jamais vu la structure que votre code attend. Le modèle peut donc omettre un champ dont vous avez besoin, en ajouter un que vous n’avez pas demandé, renommer une clé — firstName au lieu de first_name — ou changer un type en retournant "42" sous forme de chaîne là où vous attendiez le nombre 42. Rien de tout cela n’est illégal en JSON.
# Vous attendez ceci :
# {"name": "Alice", "age": 30, "city": "Paris"}
# Mais vous pourriez recevoir :
# {"nom": "Alice", "âge": "trente ans", "ville": "Paris", "pays": "France"}
Le JSON est valide, mais la structure ne correspond pas à ce que votre code attend. C’est la différence fondamentale avec les Custom Structured Outputs.
Validation côté client obligatoire
Puisque le schéma n’est pas garanti, la validation vous revient. Le pattern minimal fait trois choses dans cet ordre : il tente le parsing, il vérifie que la racine est bien un objet et non une liste ou un scalaire, puis il contrôle la présence des clés indispensables. Chaque échec lève une ValueError porteuse d’un message exploitable dans vos logs.
import json
from typing import Optional
def parse_and_validate(raw_content: str, required_keys: list[str]) -> dict:
"""Parse le JSON et vérifie les clés requises."""
try:
data = json.loads(raw_content)
except json.JSONDecodeError as e:
raise ValueError(f"JSON invalide : {e}")
if not isinstance(data, dict):
raise ValueError(f"Attendu un objet JSON, reçu {type(data).__name__}")
missing = [k for k in required_keys if k not in data]
if missing:
raise ValueError(f"Clés manquantes : {missing}")
return data
# Utilisation
try:
result = parse_and_validate(
response.choices[0].message.content,
required_keys=["name", "category", "priority"]
)
process_ticket(result)
except ValueError as e:
log_error(f"Sortie invalide : {e}")
fallback_processing(raw_content)
La version TypeScript va un cran plus loin en vérifiant, au-delà de la présence des clés, que la catégorie retournée appartient bien à la liste autorisée. C’est le contrôle qui manque le plus souvent, et celui qui empêche un ticket classé "urgent_billing" de traverser silencieusement votre routage.
interface TicketClassification {
category: string;
priority: string;
sentiment: string;
summary: string;
}
function validateTicket(raw: string): TicketClassification {
const data = JSON.parse(raw);
const requiredKeys = ["category", "priority", "sentiment", "summary"];
const missing = requiredKeys.filter((k) => !(k in data));
if (missing.length > 0) {
throw new Error(`Clés manquantes : ${missing.join(", ")}`);
}
const validCategories = ["billing", "account", "technical", "feature_request", "other"];
if (!validCategories.includes(data.category)) {
throw new Error(`Catégorie invalide : ${data.category}`);
}
return data as TicketClassification;
}
Trois pannes que vous rencontrerez
La première est le JSON tronqué. Si la réponse atteint la limite max_tokens, elle est coupée en plein milieu et devient invalide — l’accolade fermante n’arrive jamais. Le symptôme est repérable avant même le parsing : finish_reason vaut "length" au lieu de "stop".
response = client.chat.complete(
model="mistral-small-latest",
messages=messages,
response_format={"type": "json_object"},
max_tokens=50 # Trop petit pour un JSON complet
)
# response.choices[0].finish_reason sera "length" au lieu de "stop"
if response.choices[0].finish_reason == "length":
print("Attention : réponse tronquée, augmentez max_tokens")
Vérifiez donc toujours finish_reason : s’il vaut "length", la réponse est incomplète, quoi que dise le reste du contenu.
La deuxième panne concerne les types. Un âge peut arriver en chaîne, un prix en entier là où vous attendez un flottant. Plutôt que de faire confiance, encapsulez la lecture dans un accesseur qui tente la conversion et retombe sur une valeur par défaut si elle échoue.
def safe_get_int(data: dict, key: str, default: int = 0) -> int:
"""Extrait un entier, avec conversion si nécessaire."""
value = data.get(key)
if value is None:
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(value)
except ValueError:
return default
return default
# Utilisation
age = safe_get_int(data, "age", default=0)
La troisième est le renommage de clés. Sur un prompt en français, le modèle bascule volontiers phone en telephone, ou name en nom. Un mapping de synonymes absorbe ces variations sans que vous ayez à relancer l’appel.
def normalize_keys(data: dict, key_mapping: dict) -> dict:
"""Normalise les clés en utilisant un mapping de synonymes."""
normalized = {}
for standard_key, aliases in key_mapping.items():
for alias in aliases:
if alias in data:
normalized[standard_key] = data[alias]
break
return normalized
# Mapping de synonymes
mapping = {
"name": ["name", "nom", "full_name", "fullName"],
"email": ["email", "mail", "e-mail", "emailAddress"],
"phone": ["phone", "telephone", "tel", "phoneNumber"],
}
normalized = normalize_keys(raw_data, mapping)
Le retry avec feedback
Quand la validation échoue malgré tout, la meilleure réaction n’est pas de relancer le même appel à l’identique : c’est de dire au modèle ce qui n’allait pas. En réinjectant sa propre réponse puis le message d’erreur dans la conversation, vous lui donnez le contexte nécessaire pour se corriger, généralement dès la deuxième tentative.
import json
def call_with_retry(client, messages, max_retries=3):
"""Appel avec retry et feedback d'erreur."""
for attempt in range(max_retries):
response = client.chat.complete(
model="mistral-large-latest",
messages=messages,
response_format={"type": "json_object"},
temperature=0
)
raw = response.choices[0].message.content
try:
data = json.loads(raw)
validate_schema(data) # Votre fonction de validation
return data
except (json.JSONDecodeError, ValueError) as e:
if attempt < max_retries - 1:
# Ajouter le feedback d'erreur au contexte
messages.append({"role": "assistant", "content": raw})
messages.append({
"role": "user",
"content": f"Ta réponse est invalide : {e}. Corrige et retourne un JSON valide."
})
else:
raise RuntimeError(f"Échec après {max_retries} tentatives : {e}")
Le signal du changement d’approche
Si vous vous retrouvez à écrire beaucoup de code de validation, c’est un signal fort qu’il est temps de migrer vers les Custom Structured Outputs. Trois seuils typiques le confirment : au-delà de cinq champs à valider, la fonction de contrôle devient plus longue que l’appel lui-même ; dès que les types se compliquent avec des listes imbriquées ou des enums, la validation manuelle devient inséparable des bugs qu’elle introduit ; et pour tout pipeline critique en production, déléguer la contrainte au schéma est simplement plus sûr que de la reconstruire à la main.
Points clés à retenir
- Le JSON Mode garantit un JSON valide, pas un schéma valide
- Validez toujours la sortie côté client : clés présentes, types corrects, valeurs dans les bornes
- Vérifiez
finish_reasonpour détecter les réponses tronquées - Implémentez un mécanisme de retry avec feedback d’erreur
- Si la validation devient complexe, migrez vers les Custom Structured Outputs