Validation et stratégies de retry
La validation, dernier rempart avant la production
Même avec les Custom Structured Outputs, la validation reste une étape indispensable. Le schéma garantit la structure, mais pas la cohérence sémantique des données. Un champ price peut contenir -999.0 — c’est un float valide, mais absurde pour un prix.
Validation structurelle vs sémantique
Validation structurelle (automatique)
Pydantic et Zod gèrent automatiquement :
- La présence des champs requis
- Le type de chaque champ (string, int, float, bool)
- Les contraintes d’enum (valeurs parmi un ensemble fini)
- Les structures imbriquées
Validation sémantique (à votre charge)
Vous devez vérifier :
- Les bornes des valeurs numériques (prix > 0, score entre 0 et 1)
- La cohérence entre champs (total = somme des lignes)
- Le format des chaînes (email valide, date au bon format)
- La logique métier (date d’échéance après date de facturation)
Validation sémantique avec Pydantic
Pydantic permet d’ajouter des validateurs directement dans le modèle :
from pydantic import BaseModel, Field, field_validator, model_validator
class InvoiceLine(BaseModel):
description: str
quantity: float
unit_price: float
total: float
@field_validator("quantity")
@classmethod
def quantity_positive(cls, v):
if v <= 0:
raise ValueError(f"La quantité doit être positive, reçu : {v}")
return v
@field_validator("unit_price")
@classmethod
def price_non_negative(cls, v):
if v < 0:
raise ValueError(f"Le prix unitaire ne peut pas être négatif : {v}")
return v
@model_validator(mode="after")
def check_total(self):
expected = round(self.quantity * self.unit_price, 2)
if abs(self.total - expected) > 0.01:
raise ValueError(
f"Total incohérent : {self.total} != {self.quantity} x {self.unit_price} = {expected}"
)
return self
En TypeScript avec Zod, utilisez .refine() :
import { z } from "zod";
const InvoiceLineSchema = z
.object({
description: z.string(),
quantity: z.number().positive("La quantité doit être positive"),
unitPrice: z.number().nonnegative("Le prix ne peut pas être négatif"),
total: z.number(),
})
.refine(
(data) => Math.abs(data.total - data.quantity * data.unitPrice) < 0.01,
{ message: "Le total ne correspond pas à quantité x prix unitaire" }
);
Stratégie de retry avec feedback
Quand la validation échoue, le pattern le plus efficace est de renvoyer l’erreur au modèle pour qu’il se corrige :
from mistralai import Mistral
from pydantic import BaseModel, ValidationError
import json
def extract_with_retry(
client: Mistral,
messages: list,
schema,
max_retries: int = 3,
model: str = "mistral-large-latest"
):
"""Extraction avec retry et feedback d'erreur structuré."""
conversation = list(messages) # Copie pour ne pas modifier l'original
for attempt in range(max_retries):
try:
response = client.chat.parse(
model=model,
messages=conversation,
response_format=schema,
temperature=0,
max_tokens=2048
)
# Vérifier que la réponse est complète
if response.choices[0].finish_reason != "stop":
raise ValueError(
f"Réponse incomplète (finish_reason={response.choices[0].finish_reason})"
)
parsed = response.choices[0].message.parsed
if parsed is None:
raise ValueError("Réponse parsed est None")
# Validation sémantique supplémentaire
validate_business_rules(parsed)
return parsed
except (ValidationError, ValueError) as e:
error_msg = str(e)
print(f"Tentative {attempt + 1}/{max_retries} échouée : {error_msg}")
if attempt < max_retries - 1:
# Ajouter le feedback au contexte
raw = response.choices[0].message.content if response else ""
conversation.append({"role": "assistant", "content": raw})
conversation.append({
"role": "user",
"content": f"Ta réponse contient une erreur : {error_msg}. Corrige-la."
})
else:
raise RuntimeError(
f"Échec après {max_retries} tentatives. Dernière erreur : {error_msg}"
)
Stratégie de fallback
Quand le retry échoue, vous avez besoin d’un plan B :
def extract_with_fallback(client, text, schema, system_prompt):
"""Extraction avec cascade de fallbacks."""
# Tentative 1 : Custom Structured Output (meilleur)
try:
return extract_with_retry(
client,
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": text}
],
schema,
max_retries=2,
model="mistral-large-latest"
)
except RuntimeError:
print("Custom parse échoué, fallback vers JSON Mode")
# Tentative 2 : JSON Mode (plus flexible)
try:
response = client.chat.complete(
model="mistral-large-latest",
messages=[
{"role": "system", "content": system_prompt + "\nRetourne un JSON valide."},
{"role": "user", "content": text}
],
response_format={"type": "json_object"},
temperature=0
)
raw = json.loads(response.choices[0].message.content)
return schema.model_validate(raw) # Tenter la validation Pydantic
except Exception:
print("JSON Mode échoué, fallback vers extraction manuelle")
# Tentative 3 : Retourner un objet partiel ou une erreur
return None
Pattern de circuit breaker
Pour les pipelines à haute fréquence, implémentez un circuit breaker qui arrête les appels quand le taux d’erreur est trop élevé :
from collections import deque
from time import time
class CircuitBreaker:
def __init__(self, failure_threshold=5, window_seconds=60):
self.failures = deque()
self.threshold = failure_threshold
self.window = window_seconds
self.is_open = False
def record_failure(self):
now = time()
self.failures.append(now)
# Nettoyer les erreurs hors fenêtre
while self.failures and self.failures[0] < now - self.window:
self.failures.popleft()
if len(self.failures) >= self.threshold:
self.is_open = True
def record_success(self):
self.is_open = False
self.failures.clear()
def can_proceed(self) -> bool:
if not self.is_open:
return True
# Vérifier si la fenêtre est passée
now = time()
while self.failures and self.failures[0] < now - self.window:
self.failures.popleft()
if len(self.failures) < self.threshold:
self.is_open = False
return True
return False
# Utilisation
breaker = CircuitBreaker(failure_threshold=5, window_seconds=60)
def safe_extract(client, text, schema):
if not breaker.can_proceed():
raise RuntimeError("Circuit ouvert — trop d'erreurs récentes")
try:
result = extract_with_retry(client, [...], schema)
breaker.record_success()
return result
except RuntimeError:
breaker.record_failure()
raise
Logging et monitoring
En production, loguez chaque appel pour diagnostiquer les problèmes :
import logging
import time
logger = logging.getLogger("structured_output")
def monitored_extract(client, text, schema, **kwargs):
start = time.time()
try:
result = extract_with_retry(client, [...], schema, **kwargs)
duration = time.time() - start
logger.info(f"Extraction réussie en {duration:.2f}s — schema={schema.__name__}")
return result
except Exception as e:
duration = time.time() - start
logger.error(f"Extraction échouée en {duration:.2f}s — schema={schema.__name__} — {e}")
raise
Points clés à retenir
- La validation structurelle (types) est automatique, la validation sémantique (logique) est à votre charge
- Le retry avec feedback est le pattern le plus efficace : renvoyez l’erreur au modèle
- Implémentez une cascade de fallbacks : Custom > JSON Mode > valeur par défaut
- En production haute fréquence, utilisez un circuit breaker pour éviter les cascades d’erreurs
- Loguez chaque appel pour le monitoring et le diagnostic