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ôle | But | Mise en œuvre simple |
|---|---|---|
| Longueur maximale | Limiter coût et surface d'attaque | len(question) <= 2000 |
| Données personnelles | Ne pas envoyer d'IBAN, numéro de sécurité sociale… au modèle | Regex + masquage ; Cloud DLP (Sensitive Data Protection) sur GCP |
| Périmètre | Refuser tôt les demandes hors sujet | Petit classifieur (LLM rapide ou mots-clés) |
| Injection évidente | Repérer « ignore tes instructions » | Heuristiques en complément, jamais seules |
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 :
| Plateforme | Mécanisme |
|---|---|
| Vertex AI (Gemini) | response_mime_type="application/json" + response_schema dans la configuration de génération |
| Claude | Dé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.
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, NoneGemini est configuré avec response_schema. Faut-il encore valider la sortie côté application ?
Le schéma côté fournisseur réduit fortement les erreurs de format, mais ne vérifie pas vos règles métier (sources réellement fournies, valeurs cohérentes). La validation applicative reste la dernière ligne de défense.
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 ?
A, C et D sont des contrôles simples et efficaces. Afficher du HTML généré sans l'échapper ouvre la porte à l'injection de script (gestion non sûre des sorties).
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
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.