Lección 6 — Fiabiliser en production

Garde-fous d'entrée et de sortie, sorties structurées validées

5 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

Le modèle est un composant non fiable

En production, on traite la sortie d'un LLM comme une entrée utilisateur : elle peut être fausse, mal formée ou manipulée. L'OWASP Top 10 pour les applications LLM classe en tête l'injection de prompt (LLM01) et liste la gestion non sûre des sorties parmi les risques majeurs.

flowchart LR
  U["Utilisateur"] --> GE["Garde-fous d'entrée"]
  GE -- "refus" --> R1["Message de refus"]
  GE --> RAG["Recherche + LLM"]
  RAG --> V["Validation du schéma"]
  V -- "invalide" --> RT["Nouvel essai ou repli"]
  V --> GS["Garde-fous de sortie"]
  GS -- "bloqué" --> R2["Réponse de repli"]
  GS --> OK["Réponse affichée"]

Garde-fous d'entrée

ContrôleButMise en œuvre simple
Longueur maximaleLimiter coût et surface d'attaquelen(question) <= 2000
Données personnellesNe pas envoyer d'IBAN, numéro de sécurité sociale… au modèleRegex + masquage ; Cloud DLP (Sensitive Data Protection) sur GCP
PérimètreRefuser tôt les demandes hors sujetPetit classifieur (LLM rapide ou mots-clés)
Injection évidenteRepérer « ignore tes instructions »Heuristiques en complément, jamais seules
garde_fous.py
import re

MOTIFS_PII = {
    "iban": re.compile(r"\bFR\d{2}(?:\s?\d{4}){5}\s?\d{3}\b"),
    "email": re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b"),
}

def masquer_pii(texte):
    for nom, motif in MOTIFS_PII.items():
        texte = motif.sub(f"[{nom.upper()}_MASQUÉ]", texte)
    return texte

def controler_entree(question):
    if len(question) > 2000:
        return None, "Votre question est trop longue (2000 caractères maximum)."
    return masquer_pii(question), None

assert masquer_pii("mon mail : a.b@exemple.fr") == "mon mail : [EMAIL_MASQUÉ]"

Sorties structurées validées

Demander « réponds en JSON » ne suffit pas : il faut valider la sortie contre un schéma et traiter l'échec. Les deux plateformes aident :

PlateformeMécanisme
Vertex AI (Gemini)response_mime_type="application/json" + response_schema dans la configuration de génération
ClaudeDéfinir un outil avec un input_schema JSON et forcer son usage (tool_choice), ou utiliser les sorties structurées lorsqu'elles sont disponibles pour votre modèle

Dans tous les cas, revalidez côté application : c'est votre contrat, pas celui du fournisseur.

schema.py
from pydantic import BaseModel, Field, ValidationError, field_validator

class Reponse(BaseModel):
    reponse: str = Field(min_length=1, max_length=2000)
    sources: list[str]
    confiance: str  # "haute" | "basse"

    @field_validator("confiance")
    @classmethod
    def valeurs(cls, v):
        if v not in {"haute", "basse"}:
            raise ValueError("confiance doit valoir haute ou basse")
        return v

def parser(texte_llm, sources_autorisees):
    try:
        r = Reponse.model_validate_json(texte_llm)
    except ValidationError as e:
        return None, f"schéma invalide : {e.error_count()} erreur(s)"
    inconnues = set(r.sources) - set(sources_autorisees)
    if inconnues:  # le modèle cite un document qui n'était pas dans le contexte
        return None, f"sources inventées : {sorted(inconnues)}"
    return r, None

Gemini est configuré avec response_schema. Faut-il encore valider la sortie côté application ?

Garde-fous de sortie

  • Fidélité minimale : si la réponse ne cite aucune source alors que la question est factuelle, renvoyer « je ne trouve pas cette information ».
  • Fuite du prompt système : bloquer une sortie qui contient un extrait reconnaissable du prompt (une phrase témoin).
  • Contenu interdit : liste de termes (concurrents, insultes) ou classifieur de sécurité.
  • Rendu sûr : échapper le HTML, ne jamais exécuter du code ou une requête SQL produits par le modèle sans validation.

Lesquels sont de bons garde-fous de sortie pour un assistant RAG interne ?

Exercice : écrire le pipeline de réponse protégé

Écrivez repondre_protege(question, appeler_rag) qui : contrôle l'entrée, appelle le RAG (qui renvoie (texte_llm, ids_documents_fournis)), valide la sortie avec parser, et renvoie toujours un dictionnaire {"reponse", "sources", "statut"}.

Voir la solution
pipeline.py
REPLI = "Je ne trouve pas cette information dans la documentation. Contactez le support interne."

def repondre_protege(question, appeler_rag):
    q, refus = controler_entree(question)
    if refus:
        return {"reponse": refus, "sources": [], "statut": "refus_entree"}
    texte, docs_fournis = appeler_rag(q)
    r, erreur = parser(texte, docs_fournis)
    if erreur:
        # journaliser l'erreur (leçon observabilité), puis repli
        return {"reponse": REPLI, "sources": [], "statut": f"repli:{erreur}"}
    if not r.sources and r.confiance == "basse":
        return {"reponse": REPLI, "sources": [], "statut": "repli:sans_source"}
    return {"reponse": r.reponse, "sources": r.sources, "statut": "ok"}

Le champ statut est essentiel : il permet de compter les replis en production et d'ajouter un cas au jeu doré à chaque nouveau type d'échec.

Je sais faire

  • Placer des garde-fous avant et après l'appel au modèle.
  • Masquer des données personnelles avant l'envoi au LLM.
  • Imposer et revalider une sortie structurée (Gemini response_schema, outil Claude, Pydantic).
  • Rejeter les citations de documents absents du contexte.
  • Expliquer pourquoi la protection contre l'injection est d'abord architecturale.
Siguiente