Une IA qui génère des données parfaites n’est pas forcément fiable. Explications avec un cas concret de transactions financières.

LE PIÈGE DES SORTIES STRUCTURÉES PARFAITES

Imaginez une transaction financière extraite par une intelligence artificielle. Le JSON est impeccable : tous les champs sont présents, les types sont corrects, aucune erreur de syntaxe. Pourtant, une valeur est inventée de toutes pièces. Ce scénario n’est pas rare, et c’est le danger invisible des Structured Outputs.

UNE ERREUR QUI PASSE INAPERÇUE

Après trois semaines d’utilisation des Structured Outputs pour extraire des confirmations de paiement en transactions, une anomalie est apparue : 2 à 3 % des transactions d’une semaine présentaient un problème. Pas de crash, pas de ligne malformée, juste un décalage sur la date. Impossible de le remarquer immédiatement.

« Ce n’était pas un bug de fuseau horaire. Le modèle avait simplement inventé une date qui n’existait pas dans le message original. »

En analysant les messages sources, le pattern était clair : chaque transaction erronée provenait d’un message ne mentionnant aucune date. Pourtant, le schéma exigeait un champ transaction_date obligatoire. Le modèle, incapable de laisser un champ vide, avait rempli cette case avec la date du jour de l’extraction, décalée de moins d’une heure.

POURQUOI LE JSON PARFAIT NE GARANTIT RIEN

Les Structured Outputs résolvent un problème réel : avant leur arrivée, obtenir un JSON fiable d’un modèle de langage signifiait utiliser des expressions régulières, des boucles de réessai et des prompts désespérés pour supplier le modèle de ne produire que du JSON, sans markdown ni préambule.

Avec l’OpenAI Python SDK et un modèle Pydantic, cette souffrance a presque disparu. Voici un exemple simple :

import logging
from datetime import date
from pydantic import BaseModel
from openai import OpenAI

logger = logging.getLogger(__name__)
client = OpenAI()

class Transaction(BaseModel):
    sender: str
    amount: float
    transaction_id: str
    transaction_date: date

document = """Payment received from Chinedu.
Amount: ₦45,000
Reference: TXN-82K91
Date: 11 August 2026"""

completion = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "Extract the transaction details."},
        {"role": "user", "content": document},
    ],
    response_format=Transaction,
)

txn = completion.choices[0].message.parsed
logger.info("parsed txn %s", txn.transaction_id)

Sur un message propre, cela fonctionne exactement comme annoncé : chaque clé est présente, chaque type est correct. Mais que se passe-t-il si le message est incomplet ?

LE CAS OÙ L’IA INVENTE DES VALEURS

Prenons ce message :

document = """Payment received from Chinedu.
Amount: ₦45,000
Reference: TXN-82K91"""
# Pas de date dans ce message.

Le schéma exige toujours un champ transaction_date obligatoire. Le modèle, incapable de laisser un champ vide, invente une valeur : la date du jour, la date de fin d’entraînement du modèle, ou une estimation plausible. Le JSON qui en résulte est parfaitement valide. Pourtant, la date est totalement fictive.

« Le modèle remplit chaque champ pour respecter le schéma, même si cela signifie inventer des données. »

LA SOLUTION : RENDRE LES CHAMPS OPTIONNELS

La première étape pour éviter ces erreurs est de reconnaître qu’un champ vide n’est pas une erreur d’extraction, mais souvent la vérité. En rendant les champs nullable, on soulage le modèle de la pression d’inventer quelque chose :

class Transaction(BaseModel):
    sender: str | None
    amount: float | None
    transaction_id: str | None
    transaction_date: date | None

Si la date est manquante, le modèle peut simplement indiquer qu’elle est absente. Cette approche clarifie une distinction importante : l’extraction (ce qui est écrit dans le texte) versus l’inférence (ce que le texte implique).

Parfois, l’inférence est exactement ce que vous voulez. Mais cette décision doit être la vôtre, pas celle du modèle. Un champ nullable redonne ce contrôle à votre code :

if transaction.transaction_date is None:
    requestmissinginfo(transactionid=transaction.transactionid)

PREUVE À L’APPUI : LA TECHNIQUE DE L’EVIDENCE

Rendre les champs optionnels résout le problème de l’invention de valeurs, mais pas celui de la vérification de la source. Comment savoir si une valeur a été extraite du texte ou devinée ? La solution : demander au modèle de fournir la preuve.

On crée une classe générique Extracted qui encapsule à la fois la valeur et la preuve textuelle :

from pydantic import Field

class Extracted(BaseModel):
    """Wrapper générique pour ne pas écrire une classe par type de champ."""
    value: float | date | str | None
    evidence: str | None = Field(
        description="Citation exacte justifiant cette valeur, vide si non trouvée"
    )

class Transaction(BaseModel):
    sender: str | None
    amount: Extracted
    transaction_id: str | None
    transaction_date: Extracted

Cette approche a deux avantages :

  • Le modèle doit écrire la preuve avant la valeur, ce qui l’oblige à montrer son travail.
  • Un réviseur peut vérifier rapidement si la preuve correspond au texte source. Si la valeur est remplie mais la preuve est vide ou incorrecte, c’est une hallucination détectable.

Cette méthode a un coût : sur un lot de quelques centaines de messages, l’ajout des champs de preuve a augmenté les tokens de sortie d’environ un tiers, et la latence est devenue significative à l’échelle d’un pipeline.

« Pour un code postal à cinq chiffres, ce n’est pas justifié. Mais pour une figure financière sur laquelle on va agir, c’est indispensable. »

VALIDER LES VALEURS, PAS SEULEMENT LEUR FORME

Même avec des champs optionnels et des preuves, un problème persiste : la valeur extraite a-t-elle du sens dans le monde réel ? Le schéma garantit que amount est un nombre à virgule, mais pas qu’il est positif. De même, transaction_date est une date, mais pas forcément future.

Les premiers essais consistaient à ajouter des instructions dans le prompt comme « le montant doit être supérieur à zéro ». Mais un modèle de langage n’est pas une calculatrice. La solution ? Un validateur automatique, gratuit et infaillible :

from pydantic import model_validator, ValidationError

class Transaction(BaseModel):
    sender: str | None
    amount: float | None
    transaction_id: str | None
    transaction_date: date | None

    @model_validator(mode="after")
    def checksanevalues(self) -> "Transaction":
        # Les montants négatifs sont apparus exactement deux fois,
        # toujours pour des remboursements, pas des paiements
        if self.amount is not None and self.amount <= 0:
            raise ValueError(f"amount must be positive, got {self.amount}")
        
        if self.transaction_date is not None and self.transaction_date > date.today():
            raise ValueError(f"transactiondate {self.transactiondate} is in the future")
        
        return self

Désormais, l’API garantit la structure dès la Génération de la réponse, et Pydantic garantit que les données ont du sens dès leur analyse. Si le validateur échoue, vous avez deux options : envoyer l’enregistrement à un humain ou renvoyer l’erreur exacte au modèle pour un nouvel essai.

LA BOUCLE DE RÉESSAI INTELLIGENTE

Voici une fonction qui gère les réessais avec une limite stricte :

MAX_RETRIES = 2

def extractwithretry(document: str) -> Transaction:
    history = [
        {"role": "system", "content": "Extract the transaction details."},
        {"role": "user", "content": document},
    ]
    
    for attempt in range(MAX_RETRIES + 1):
        completion = client.beta.chat.completions.parse(
            model="gpt-4o",
            messages=history,
            response_format=Transaction
        )
        raw = completion.choices[0].message.content
        
        try:
            return Transaction.modelvalidatejson(raw)
        except ValidationError as e:
            if attempt == MAX_RETRIES:
                raise  # abandon, laisse le code appelant gérer l'erreur
            
            logger.warning("validation failed on attempt %d: %s", attempt, e)
            history += [
                {"role": "assistant", "content": raw},
                {"role": "user", "content": f"That failed validation: {e}. Fix only the bad field."},
            ]

La limite de deux réessais est cruciale. Une première tentative était de laisser le modèle continuer indéfiniment, mais deux échecs consécutifs signifient presque toujours que le document source est le problème, pas le prompt. Un troisième essai automatisé ne ferait que gaspiller des appels API sur quelque chose qu’un humain résoudrait en dix secondes.

UNE SOLUTION QUI NE DÉPEND PAS D’OPENAI

Toutes les solutions présentées ici fonctionnent avec d’autres modèles. Remplacez l’appel à l’API OpenAI par Anthropic Tool Use ou un setup auto-hébergé avec vLLM et Outlines, le modèle Pydantic reste inchangé. Seule la couche d’appel API change.

LE VRAI DANGER : CONFIANCE MAL PLACÉE

Au début, le critère de succès était embarrassamment bas : le modèle remplissait-il l’objet sans casser le parseur ? Aujourd’hui, ce critère récompense exactement le mauvais comportement. Un modèle qui remplit chaque champ avec assurance, même sans raison valable, n’est pas fiable. Il est simplement confiant, et c’est bien plus dangereux.

Les Structured Outputs sont excellentes pour ce qu’elles font : garantir la forme. Mais elles ne garantissent pas la vérité. Une fois le stress des crochets et des échappements de guillemets disparu, la vraie question reste entière : chaque valeur de cet objet a-t-elle une raison d’exister ?

« Les Structured Outputs vous évitent de vous soucier des parenthèses. Mais le vrai travail commence quand il faut vérifier que chaque donnée est réelle. »

CONCLUSION : NE VOUS FIEZ PAS À LA PERFECTION DU JSON

Les Structured Outputs sont une avancée majeure, mais elles ne résolvent pas tous les problèmes. Elles garantissent la structure, pas la vérité. Une fois ce piège compris, la vraie question devient : comment vérifier que chaque donnée extraite est bien réelle ?

La réponse n’est pas dans le prompt, ni dans le schéma. Elle est dans votre code : champs optionnels, preuves textuelles, et validateurs automatiques. Sans cela, une IA qui génère des données parfaites peut très bien vous mentir sans que vous vous en rendiez compte.

Le JSON parfait n’est pas une preuve de fiabilité. C’est juste le début du travail.

CE QU’IL FAUT RETENIR

• Les Structured Outputs garantissent la forme du JSON, pas la vérité des données.

• Un champ obligatoire absent pousse le modèle à inventer des valeurs fictives pour respecter le schéma.

• Rendre les champs optionnels (nullable) évite les inventions, mais ne suffit pas à vérifier la source.

• Ajouter des champs de preuve (evidence) permet de détecter les hallucinations en comparant valeur et source.

• Les validateurs automatiques (comme model_validator) garantissent que les valeurs ont du sens dans le monde réel.

• Une boucle de réessai limitée à deux tentatives évite de gaspiller des appels API sur des documents problématiques.

• Ces techniques fonctionnent avec n’importe quel modèle, pas seulement OpenAI.

Sources :
  • Towards Data Science

L'indépendance de CLODCO est votre garantie.

Pour que l'actualité de l'IA reste sans filtre et sans concession, votre soutien est indispensable. Votre contribution est le seul moteur de notre liberté éditoriale.

Soutenir CLODCO