La semaine passée, j’ai raconté comment mon IA a inventé une structure JSON complète et l’a enveloppée dans des DTOs, fixtures et tests qui passaient. 90 tests au vert. Tout faux.
Ce post était le diagnostic. Celui-ci est le traitement.
Après avoir découvert le désastre, j’ai fait ce que fait tout ingénieur avec l’orgueil blessé : rechercher obsessionnellement pendant des jours pour que ça ne se reproduise plus. J’ai lu des papers, testé des outils, analysé les vraies données de mes APIs, et construit un système de défenses pour mon app.
Ce que j’ai trouvé m’a surpris. Parmi les 5 mesures réactives que j’ai identifiées, seules 3 fonctionnent vraiment. Les deux autres sont, au minimum, du théâtre avec de bonnes intentions.
Le modèle mental : toi contre l’IA (littéralement)
Avant d’entrer dans les mesures, tu dois comprendre le cadre. Et la meilleure analogie que j’ai trouvée vient du deep learning.
Dans un GAN (Generative Adversarial Network) il y a deux réseaux de neurones qui se font concurrence :
- Le générateur produit du contenu (images, texte, ce que tu veux)
- Le discriminateur essaie de détecter si le contenu est vrai ou faux
Le système s’améliore parce que les deux se poussent mutuellement. Le générateur apprend à mieux tromper. Le discriminateur apprend à mieux détecter.
Quand tu programmes avec un LLM, tu es dans un GAN involontaire :
- Le LLM est le générateur. Il produit du code, des DTOs, tests, fixtures.
- Tu es le discriminateur. Tu dois détecter ce qui est réel et ce qui est inventé.
Mais il y a une asymétrie brutale : le générateur est infatigable et toi tu te fatigues. Le LLM peut générer 50 fichiers sans se décoiffer. Toi tu en révises 10, tu te fatigues, et le fichier 11 passe sans que tu le regardes.
C’est la même fatigue d’autorisation que j’ai racontée avec 1Password demandant Touch ID 47 fois par jour. La sécurité qui dépend d’un humain en permanence alerte est de la sécurité en carton.
Ce que le discriminateur doit surveiller
Tu ne peux pas (ni ne dois) réviser chaque ligne. Ce que tu dois surveiller ce sont les frontières — où ton code touche le monde extérieur :
| Frontière | Question clé |
|---|---|
| APIs externes | Les champs du DTO existent-ils dans la vraie API ? |
| Packages | La dépendance existe-t-elle et s’appelle-t-elle ainsi ? |
| Schémas de BD | La table a-t-elle vraiment ces colonnes ? |
| URLs/endpoints | L’endpoint existe-t-il et répond-il ce qu’on attend ? |
Règle : tout ce que le LLM déclare sur le monde extérieur est suspect jusqu’à vérification. Qu’il le dise avec confiance n’est pas une preuve. Anthropic le reconnaît dans sa propre documentation :
“Claude peut parfois générer des réponses qui contiennent des informations fabriquées… présentées de manière confiante et autoritaire.”
Un LLM qui dit “j’en suis sûr” et un qui dit “je pense que” ont exactement la même probabilité de se tromper.
Automatiser le discriminateur
L’objectif final est d’arrêter de dépendre de ta discipline et d’automatiser la vérification :
AVANT :
LLM génère → Tu révises (parfois) → Merge
APRÈS :
LLM génère → CI vérifie contre vraies données → Tu révises les divergences → Merge
Les 5 mesures qui suivent sont des façons d’automatiser des parties de ce rôle de discriminateur. Certaines fonctionnent. D’autres pas tant.
Les données dures (pour les sceptiques)
Avant que tu penses “ça ne m’arrive pas”, voici des chiffres d’études réelles :
- 21.7% des packages recommandés par les LLMs open-source sont inventés. Dans les modèles commerciaux ça descend à 5.2%, ce qui reste un package sur 20.
- GPT-4o n’atteint que 38.58% d’invocations valides pour les APIs peu fréquentes. Moins de 40%. Lance une pièce en l’air.
- Les meilleures méthodes actuelles pour localiser les hallucinations dans le code atteignent 22-33% de précision. Dit autrement : on en détecte une sur quatre.
- Un chercheur a uploadé un package vide avec un nom que les LLMs hallucinaient fréquemment. 30.000 téléchargements en 3 mois. Ils appellent ça du slopsquatting.
Et il y a une taxonomie formelle. Le paper CodeHalu (AAAI 2025) définit 4 catégories d’hallucinations de code :
| Catégorie | Ce que c’est | Exemple réel |
|---|---|---|
| Mapping | Champs mappés incorrectement | Confondre user_id avec account_id |
| Naming | Noms inventés | response.quota.percentage quand c’est response.utilization |
| Resource | Ressources qui n’existent pas | Champ active_flags dans une API qui ne l’a pas |
| Logic | Logique plausible mais incorrecte | isPaid = !activeFlags.isEmpty avec un champ toujours vide |
Mon cas était un Resource qui a dérivé en Logic. Le champ n’existait pas, et la logique qui en dépendait semblait parfaite. Fiction cohérente de livre.
Mesure 1 : Contract testing contre vraies APIs
L’idée
Définir un “contrat” de ce que l’API retourne et vérifier automatiquement que ton code est compatible. Si ton DTO a des champs que le contrat ne définit pas : alarme.
Comment ça fonctionne
Imagine que tu aies un DTO comme ça :
| |
Un contract test prend la réponse réelle de l’API, extrait les keys du JSON, et les compare avec les CodingKeys de ton DTO. Si ton DTO a un champ que l’API ne retourne pas, c’est un PHANTOM — un champ fantôme, possiblement inventé.
Keys dans la vraie API : {uuid, name, capabilities, billing_type}
Keys dans le DTO : {uuid, name, activeFlags}
PHANTOM: activeFlags ← Dans le DTO mais PAS dans l'API. Halluciné ?
UNCONSUMED: capabilities, billing_type ← Dans l'API mais pas dans le DTO.
Pour
- Déterministe. Ne dépend pas d’un autre LLM ni de ton flair. Si le champ n’est pas dans l’API, ça saute.
- Élimine les phantom fields par construction. Il est impossible qu’un champ inventé passe.
- Automatisable en CI. Tu l’exécutes à chaque push.
Contre
- Tu as besoin de la spec de l’API. Si l’API n’a pas de spec OpenAPI (comme celle de Claude), tu dois capturer les réponses manuellement.
- Ne détecte pas le naming incorrect. Si le champ existe mais s’appelle différemment (
active_flagsvscapabilities), ça ne le choppe pas automatiquement. - Nécessite des credentials. Pour capturer la vraie réponse tu as besoin d’une session valide.
Outils par stack
| Stack | Outil | Approche |
|---|---|---|
| Python | Pydantic extra='forbid' | Rejette les champs JSON non déclarés dans le modèle |
| TypeScript | Zod .strict() | Même concept, rejette les extras |
| Swift | Decoder custom ou comparaison manuelle de keys | Codable ignore les clés inconnues par défaut |
| Dart | json_serializable + disallowUnrecognizedKeys | Rejette les champs non déclarés |
| Agnostique | oasdiff, Specmatic | Comparent les specs OpenAPI |
Ce que j’ai implémenté
Dans mon app (Swift/SPM) il n’y a pas de spec OpenAPI de l’API de Claude. Alors j’ai construit une validation bidirectionnelle à la main :
make capturetélécharge les vraies réponses de toutes les APIs et les sauve comme fixtures dansFixtures/real/SchemaValidationTestscompare lesCodingKeys.allCasesde chaque DTO contre les keys du vrai fixture- S’il y a une divergence → PHANTOM (champ dans le DTO mais pas dans l’API) ou UNCONSUMED (champ dans l’API qu’on ne consomme pas)
$ make doctor
✅ OrganizationInfo: 4 common, 0 phantom, 8 unconsumed
✅ UsageResponse: 9 common, 0 phantom, 1 unconsumed
⚠️ StatsCache: PHANTOM field 'totalSpeculationTimeSaved' — not in real data
Les champs intentionnellement non consommés vont dans une allowlist documentée avec la raison. Si demain un nouveau champ apparaît dans l’API, le test échoue avec UNCONSUMED et je suis au courant.
Verdict : la mesure la plus importante. Si tu n’en implémentes qu’une, que ce soit celle-ci.
Mesure 2 : Validation de fixture (vraies fixtures, pas inventées)
L’idée
Les fixtures de test doivent venir de vraies données capturées, pas écrites à la main par le LLM. Si le LLM génère la fixture, tu valides fiction contre fiction.
Le problème que ça résout
George Tsiokos l’a cloué dans un post de février 2025 :
“Les tests ne valident pas que le logiciel remplit les besoins business — ils confirment simplement que le code fait exactement ce pour quoi il a été écrit, bugs inclus.”
Quand le LLM génère le code ET les tests ET les fixtures :
LLM invente champ → LLM écrit fixture avec ce champ → LLM écrit test
→ Test passe ✅ → Personne n'a vérifié contre la réalité ❌
La solution : record-replay
Les frameworks record-replay enregistrent de vraies réponses HTTP et les reproduisent dans les tests. Il n’y a pas de possibilité d’invention parce que la fixture vient de l’API, pas du modèle.
| Stack | Outil |
|---|---|
| Python | VCR.py, pytest-recording |
| TypeScript | Polly.js (Netflix), MSW |
| Swift | Replay (mattt) |
| Agnostique | Hoverfly |
Pour
- Impossible d’inventer. La fixture vient du réseau, pas du modèle.
- Inclut les métadonnées. URL, timestamp, status code. Tu peux tracer d’où ça vient.
- Se committa au repo. Les reviewers voient exactement ce que l’API a retourné.
Contre
- La fixture vieillit. Si l’API change, la fixture capturée n’est plus représentative.
- Credentials en CI. Tu as besoin de pouvoir appeler l’API pour enregistrer.
- N’évolue pas à toutes les variations. Tu captures une réponse, mais l’API peut retourner beaucoup de formes différentes.
Ce que j’ai implémenté
Deux couches de fixtures :
Tests/Fixtures/ ← Statiques, écrites par le LLM
Utilisées pour tests unitaires de decode
PEUVENT contenir des erreurs (c'est acceptable)
Tests/Fixtures/real/ ← Capturées par make capture
Avec fichier .meta (timestamp de capture)
Source de vérité pour validation de schema
Les fixtures statiques sont utiles pour tester les edge cases (JSON tronqué, champs vides, formats bizarres). Mais la validation de “est-ce que ces champs existent vraiment ?” la fait toujours la vraie fixture.
Chaque vraie fixture a un fichier .meta avec le timestamp de capture. Si une fixture a plus de 30 jours, tu sais qu’il faut la renouveler.
Verdict : essentiel comme complément du contract testing. Seule elle ne suffit pas (tu as besoin de la comparaison de la Mesure 1), mais sans vraies fixtures la Mesure 1 n’a rien contre quoi comparer.
Mesure 3 : Smoke tests avec vraies données (make doctor)
L’idée
Avant de valider un changement, faire un vrai appel à l’API et vérifier que tes DTOs parsent la réponse sans perte silencieuse.
Comment ça fonctionne
| |
C’est make capture + make test en une seule étape. Capture de vraies données de production et les croise contre les DTOs.
Pour
- La défense la plus honnête. Vraies données, comparaison directe, résultat sans équivoque.
- Rapide. 30 secondes en local.
- Détecte le drift. Si l’API ajoute ou enlève des champs, tu le sais immédiatement.
Contre
- Nécessite une session active. Tu dois être connecté pour capturer.
- Ne va pas en CI (dans mon cas). L’API de Claude n’a pas de credentials de service, seulement des cookies de session.
- C’est manuel. Ça dépend de toi pour te souvenir de l’exécuter.
Ce que j’ai implémenté
make doctor est la commande la plus importante de mon projet. Je l’exécute :
- Après chaque changement dans les DTOs
- Une fois par semaine en routine
- Quand quelque chose “sent bizarre” dans l’app
Pour les APIs que je ne peux pas appeler en CI, le truc c’est de sauver le résultat du doctor comme vraie fixture qui va au repo. Le CI valide contre cette fixture. Ce n’est pas du temps réel, mais c’est mieux que rien.
En plus, le système émet des signaux précoces en runtime : si le SessionFileReader lit des lignes de type assistant sans champ usage, il log un .notice. Si le SessionTokenService lit des fichiers mais trouve 0 nouvelles entries, aussi. L’idée c’est que l’app prévienne si le format a changé, même si elle ne crashe pas (parce que la graceful degradation peut cacher le problème).
Verdict : la mesure la plus pratique. Faible coût, haute valeur. Si tu as 30 secondes, tu as make doctor.
Mesure 4 : Anomaly detection en parsing (champs toujours null)
L’idée
Monitorer en runtime quels champs de tes modèles se remplissent avec de vraies données et lesquels sont toujours nil. Un champ qui reste 50 parsings de suite nil est suspect d’être inventé.
Le modèle mental
GraphQL a résolu ça. Des outils comme Apollo GraphOS reportent l’usage par champ : combien de fois demandé, combien ont retourné des données, première et dernière fois utilisé. Les champs avec 0% d’usage sont marqués pour élimination.
Pour REST, il n’y a pas d’équivalent. Tu dois le construire toi.
Pour
- Détecte en production. Tu n’as pas besoin de capturer manuellement ; la propre utilisation de l’app génère les données.
- Complète les autres mesures. Un champ qui passe le contract test (existe dans l’API) mais est toujours null en pratique reste suspect.
Contre
- Tu as besoin de volume. Avec 5 appels tu ne peux rien conclure. Tu as besoin de centaines.
- Faux positifs. Un champ peut être légitimement null 95% du temps (ex.
seven_day_opus: nulldans mon API est normal si tu n’uses pas Opus cette semaine). - Implémentation manuelle. Il n’y a pas d’outil que tu branches. Tu dois écrire le monitor.
- Dans les apps client, sans APM. Dans un backend avec Datadog ou Sentry, tu émets des métriques custom. Dans une app macOS de menu bar, tu es seul.
Ce que j’ai implémenté
Partiellement. Je n’ai pas de monitor formel de champs-toujours-nil, mais j’ai les signaux précoces dans les logs :
| |
C’est la version low-tech d’anomaly detection. Ça ne compte pas par champ, mais détecte le gros cas : “je lis des données mais il n’en sort rien d’utile”.
Verdict : utile comme signal d’alerte, mais pas comme défense primaire. C’est un canari dans la mine, pas un mur.
Mesure 5 : Diff sémantique post-génération (LLM-as-Judge)
L’idée
Utiliser un second LLM (ou le même avec un prompt différent) pour auditer le code généré, cherchant des champs ou structures qu’il ne peut pas vérifier contre la documentation connue.
L’état de l’art
Il y a des outils sérieux qui travaillent là-dessus :
| Outil | Ce qu’il fait |
|---|---|
| VERDICT (Haize Labs) | Pipeline modulaire : vérification + débat + agrégation |
| DeepEval | Framework type pytest avec HallucinationMetric |
| Patronus Lynx | Modèle SOTA détection hallucinations, open-source |
| Vectara HHEM | Modèle + API, réduit hallucinations à ~0.9% en enterprise |
Et l’option maison : demander à GPT-4o de générer des DTOs pour la même API sans voir ton code, et comparer :
Claude dit : activeFlags: [String]
GPT-4o dit : capabilities: [String]
→ DIVERGENCE : au moins l'un hallucine. Vérifier contre vraie API.
Pour
- Évolue sans effort manuel. Tu le mets en CI et ça s’exécute tout seul.
- Détecte des patterns subtils. Un second modèle peut remarquer des choses que tu ne vois pas.
Contre
Et c’est là que ça devient moche.
- Le juge peut halluciner aussi. Si le second LLM ne connaît pas l’API, il peut “confirmer” des champs inventés.
- Hallucinations systémiques. Si les deux modèles ont été entraînés avec des données similaires, ils peuvent partager la même invention. SelfCheckGPT (Cambridge, EMNLP 2023) a démontré que la cohérence multi-échantillon ne détecte pas les hallucinations systémiques.
- Précision déplorable. Collu-Bench : les meilleures méthodes atteignent 22-33% de précision localisant les hallucinations de code. Tu en détectes une sur quatre. Ça c’est pas une défense, c’est un tirage au sort.
- Coût. Chaque couche multiplie les appels à LLM. Tu paies pour un détecteur qui vise juste un tiers du temps.
- Biais de position. Les LLMs juges préfèrent les réponses plus longues et celles qui apparaissent en premier. Ils ne jugent pas ; ils ont des préférences esthétiques.
Evidently AI l’a résumé avec une question démolissante :
“Comment monitorer un système qui hallucine occasionnellement avec un autre système qui hallucine occasionnellement ?”
Ce que j’ai implémenté
Rien. Zéro.
Et c’est une décision consciente. Les mesures déterministes (1, 2 et 3) me donnent une détection fiable, reproductible, sans faux positifs ni coûts par appel. Mettre un LLM à surveiller un autre LLM c’est comme mettre un stagiaire à superviser un autre stagiaire. Mieux vaut mettre une caméra.
Verdict : recherche intéressante, production prématurée. Quand la précision passera de 33% à 90%, on en reparle. Aujourd’hui, c’est du théâtre avec un budget R&D.
Le score final
| Mesure | Fiabilité | Coût | Implémentée ? | Pourquoi ? |
|---|---|---|---|---|
| 1. Contract testing | Haute | Moyen | Oui | Détecte phantom fields mécaniquement |
| 2. Validation fixture | Haute | Bas | Oui | Vraies fixtures éliminent fiction-valide-fiction |
3. Smoke tests (make doctor) | Haute | Bas | Oui | 30 secondes, valeur max |
| 4. Anomaly detection | Moyenne | Bas | Partiel | Signaux dans logs, pas de monitor formel |
| 5. LLM-as-Judge | Basse | Haut | Non | 22-33% précision = tirage au sort |
Les mesures 1, 2 et 3 forment un trépied. Chacune couvre un angle différent :
- Contract testing répond : “ces champs existent-ils ?”
- Validation fixture répond : “ces données sont-elles réelles ?”
- Smoke tests répond : “est-ce que ça fonctionne maintenant ?”
Ensemble, elles font qu’un champ inventé doit survivre trois filtres indépendants. Ce n’est pas impossible, mais c’est beaucoup plus dur que de tromper un test unitaire avec une fixture inventée.
La règle d’or
Je veux terminer avec la règle la plus importante que j’ai tirée de tout ça :
Le système de vérification doit être externe au générateur.
Si le LLM génère :
- Le code → OK, c’est son boulot
- Les tests de logique → OK, vérifient le comportement
- Les fixtures → NON, doivent venir de vraies données
- Les schémas → NON, doivent venir de la spec de l’API
- La validation que les données sont correctes → NON, la fait un système déterministe
C’est la séparation des pouvoirs appliquée au développement. Celui qui écrit la loi ne peut pas être celui qui la juge. Celui qui génère le code ne peut pas être celui qui vérifie qu’il est correct.
Tu peux avoir 200 tests au vert et vivre dans Matrix. Ou tu peux avoir un make doctor qui en 30 secondes te dit si tes données sont réelles ou fiction.
Moi je préfère la pilule rouge.
Série complète : Ce post est le quatrième chapitre d’une série involontaire sur les échecs d’IA en production. D’abord il y a eu les 44 emails inventés (l’IA qui agit sans permission). Puis MEMORY.md (l’IA qui oublie). Après le silent failure (l’IA qui invente et passe les tests). Et maintenant, les défenses. Chaque échec différent, un dénominateur commun : on a besoin de systèmes mécaniques, pas de promesses de bon comportement.