La semaine dernière, j’ai raconté comment mon IA a inventé une structure JSON complète et l’a enveloppée dans des DTO, des fixtures et des tests qui passaient. 90 tests au vert. Tout était faux.
Ce post était le diagnostic. Voici le traitement.
Après avoir découvert le désastre, j’ai fait ce que fait tout ingénieur dont l’orgueil est blessé : enquêter de manière obsessionnelle pendant des jours pour éviter que cela ne se reproduise. J’ai lu des papers, testé des outils, analysé des données réelles de mes API, et construit un système de défenses pour mon app.
Ce que j’ai trouvé m’a surpris. Des 5 mesures réactives que j’ai identifiées, seulement 3 fonctionnent vraiment. Les deux autres sont, au minimum, du théâtre avec de bonnes intentions.
Le modèle mental : vous contre l’IA (littéralement)
Avant d’entrer dans les mesures, vous devez 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, peu importe)
- Le discriminateur essaie de détecter si le contenu est réel 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 vous programmez avec un LLM, vous êtes dans un GAN involontaire :
- Le LLM est le générateur. Il produit du code, des DTO, des tests, des fixtures.
- Vous êtes le discriminateur. Vous devez 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 vous vous fatiguez. Le LLM peut générer 50 fichiers sans sourciller. Vous en révisez 10, vous vous fatiguez, et le fichier 11 passe sans que vous le regardiez.
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 constamment en alerte est de la sécurité en carton.
Ce que le discriminateur doit surveiller
Vous ne pouvez pas (ni ne devez) réviser chaque ligne. Ce que vous devez surveiller, ce sont les frontières — là où votre code touche le monde extérieur :
| Frontière | Question clé |
|---|---|
| API externes | Les champs du DTO existent-ils dans la vraie API ? |
| Paquets | 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 “je 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 votre discipline et d’automatiser la vérification :
AVANT :
LLM génère → Vous révisez (parfois) → Merge
APRÈS :
LLM génère → CI vérifie contre données réelles → Vous révisez discordances → 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 que ça.
Les données dures (pour les sceptiques)
Avant que vous pensiez “ça ne m’arrive pas”, voici des chiffres d’études réelles :
- 21.7% des paquets recommandés par les LLM open-source sont inventés. Dans les modèles commerciaux, ça descend à 5.2%, ce qui reste un paquet sur 20.
- GPT-4o n’atteint qu’un 38.58% d’invocations valides pour les API peu fréquentes. Moins de 40%. Lancez une pièce en l’air.
- Les meilleures méthodes actuelles pour localiser les hallucinations dans le code atteignent une précision de 22-33%. En clair : nous détectons une sur quatre.
- Un chercheur a uploadé un paquet vide avec un nom que les LLM hallucinaient fréquemment. 30.000 téléchargements en 3 mois. Ils appellent ça le 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 API réelles
L’idée
Définir un “contrat” de ce que l’API retourne et vérifier automatiquement que votre code est compatible. Si votre DTO a des champs que le contrat ne définit pas : alarme.
Comment ça fonctionne
Imaginez que vous ayez un DTO comme ça :
struct OrganizationInfo: Decodable {
let uuid: String
let name: String
let activeFlags: [String] // ← Ça existe vraiment ?
}
Un contract test prend la réponse réelle de l’API, extrait les keys du JSON, et les compare avec les CodingKeys de votre DTO. Si votre DTO a un champ que l’API ne retourne pas, c’est un PHANTOM — un champ fantôme, possiblement inventé.
Keys dans l'API réelle : {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.
Avantages
- Déterministe. Ne dépend pas d’un autre LLM ni de votre flair. Si le champ n’est pas dans l’API, ça explose.
- Élimine les phantom fields par construction. Il est impossible qu’un champ inventé passe.
- Automatisable en CI. Vous l’exécutez à chaque push.
Inconvénients
- Vous avez besoin de la spec de l’API. Si l’API n’a pas de spec OpenAPI (comme celle de Claude), vous devez capturer les réponses manuellement.
- Ne détecte pas les noms incorrects. Si le champ existe mais s’appelle différemment (
active_flagsvscapabilities), ça ne le détecte pas automatiquement. - Nécessite des identifiants. Pour capturer la vraie réponse, vous avez 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 des 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. Donc j’ai construit une validation bidirectionnelle à la main :
make capturetélécharge les réponses réelles de toutes les API et les sauvegarde comme fixtures dansFixtures/real/SchemaValidationTestscompare lesCodingKeys.allCasesde chaque DTO contre les keys du fixture réel- S’il y a discordance → 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 le sais.
Verdict : la mesure la plus importante. Si vous n’en implémentez qu’une, que ce soit celle-ci.
Mesure 2 : Validation de fixtures (fixtures réels, pas inventés)
L’idée
Les fixtures de test doivent venir de données réelles capturées, pas écrites à la main par le LLM. Si le LLM génère le fixture, vous validez 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 répond aux besoins métier — ils confirment simplement que le code fait exactement ce qu’il a été écrit pour faire, y compris les bugs.”
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 les vraies réponses HTTP et les reproduisent dans les tests. Il n’y a aucune possibilité d’invention parce que le 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 |
Avantages
- Impossible d’inventer. Le fixture vient du réseau, pas du modèle.
- Inclut les métadonnées. URL, timestamp, status code. Vous pouvez tracer d’où ça vient.
- Se commit au repo. Les reviewers voient exactement ce que l’API a retourné.
Inconvénients
- Le fixture vieillit. Si l’API change, le fixture capturé n’est plus représentatif.
- Identifiants en CI. Vous devez pouvoir appeler l’API pour enregistrer.
- Ne passe pas à l’échelle pour toutes les variations. Vous capturez une réponse, mais l’API peut retourner plusieurs formes différentes.
Ce que j’ai implémenté
Deux couches de fixtures :
Tests/Fixtures/ ← Statiques, écrits par le LLM
Utilisés pour les tests unitaires de decode
PEUVENT contenir des erreurs (c'est acceptable)
Tests/Fixtures/real/ ← Capturés par make capture
Avec fichier .meta (timestamp de capture)
Source de vérité pour validation de schéma
Les fixtures statiques sont utiles pour tester les edge cases (JSON tronqué, champs vides, formats bizarres). Mais la validation de “ces champs existent-ils vraiment ?” se fait toujours avec le fixture réel.
Chaque fixture réel a un fichier .meta avec le timestamp de capture. Si un fixture a plus de 30 jours, vous savez qu’il faut le renouveler.
Verdict : essentiel comme complément du contract testing. Seul, ça ne suffit pas (vous avez besoin de la comparaison de la Mesure 1), mais sans fixtures réels, la Mesure 1 n’a rien contre quoi comparer.
Mesure 3 : Smoke tests avec données réelles (make doctor)
L’idée
Avant de valider un changement, faire un appel réel à l’API et vérifier que vos DTO parsent la réponse sans perte silencieuse.
Comment ça fonctionne
$ make doctor
Capturing /api/organizations... OK (2 orgs)
Capturing /api/organizations/{id}/usage... OK (9 windows)
Capturing ~/.claude/stats-cache.json... OK (115 sessions)
Capturing session JSONL... OK (847 entries)
Validating schemas...
✅ OrganizationInfo: OK
✅ UsageResponse: OK
✅ StatsCache: OK
✅ SessionEntry: OK
0 phantom fields, 0 new unconsumed fields
C’est make capture + make test en une seule étape. Capture des données fraîches de production et les croise avec les DTO.
Avantages
- La défense la plus honnête. Données réelles, comparaison directe, résultat sans équivoque.
- Rapide. 30 secondes en local.
- Détecte la dérive. Si l’API ajoute ou retire des champs, vous le savez immédiatement.
Inconvénients
- Nécessite une session active. Vous devez être connecté pour capturer.
- Ne va pas en CI (dans mon cas). L’API de Claude n’a pas d’identifiants de service, seulement des cookies de session.
- C’est manuel. Dépend du fait que vous vous rappeliez 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 DTO
- Une fois par semaine comme routine
- Quand quelque chose “sent bizarre” dans l’app
Pour les API que je ne peux pas appeler en CI, l’astuce est de sauvegarder le résultat du doctor comme fixture réel qui va au repo. Le CI valide contre ce 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 logue un .notice. Si le SessionTokenService lit des fichiers mais trouve 0 entries nouvelles, aussi. L’idée est que l’app prévienne si le format a changé, même si elle ne plante pas (parce que la graceful degradation peut cacher le problème).
Verdict : la mesure la plus pratique. Faible coût, haute valeur. Si vous avez 30 secondes, vous avez make doctor.
Mesure 4 : Détection d’anomalies dans le parsing (champs toujours null)
L’idée
Monitorer en runtime quels champs de vos modèles se peuplent avec des données réelles et lesquels sont toujours nil. Un champ qui fait 50 parsings consécutifs en étant 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 il a été demandé, combien de fois il a retourné des données, première et dernière fois qu’il a été utilisé. Les champs avec 0% d’usage sont marqués pour élimination.
Pour REST, il n’existe pas d’équivalent. Vous devez le construire vous-même.
Avantages
- Détecte en production. Vous n’avez pas besoin de capturer manuellement ; l’usage même 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.
Inconvénients
- Vous avez besoin de volume. Avec 5 appels, vous ne pouvez rien conclure. Il vous faut des centaines.
- Faux positifs. Un champ peut être légitimement null 95% du temps (ex.
seven_day_opus: nulldans mon API est normal si vous n’utilisez pas Opus cette semaine-là). - Implémentation manuelle. Il n’y a pas d’outil que vous branchez. Vous devez écrire le moniteur.
- Dans les apps client, sans APM. Dans un backend avec Datadog ou Sentry, vous émettez des métriques custom. Dans une app macOS de menu bar, vous êtes seul.
Ce que j’ai implémenté
Partiellement. Je n’ai pas de moniteur formel des champs-toujours-nil, mais j’ai les signaux précoces dans les logs :
// SessionTokenService.swift
if totalFilesRead > 0 && totalNewEntries == 0 {
logger.notice("read \(totalFilesRead) files but 0 new entries — possible format change")
}
C’est la version low-tech de la détection d’anomalies. Ne compte pas par champ, mais détecte le gros cas : “je lis des données mais rien d’utile ne sort”.
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 les hallucinations à ~0.9% en entreprise |
Et l’option maison : demander à GPT-4o de générer des DTO pour la même API sans voir votre code, et comparer :
Claude dit : activeFlags: [String]
GPT-4o dit : capabilities: [String]
→ DISCORDANCE : au moins un hallucine. Vérifier contre l'API réelle.
Avantages
- Passe à l’échelle sans effort manuel. Vous le mettez en CI et il s’exécute seul.
- Détecte des patterns subtils. Un second modèle peut remarquer des choses que vous ne voyez pas.
Inconvénients
Et c’est là que les choses se gâtent.
- Le juge peut halluciner aussi. Si le second LLM ne connaît pas l’API, il peut “confirmer” des champs inventés.
- Hallucinations systématiques. 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 consistance multi-échantillons ne détecte pas les hallucinations systématiques.
- Précision déplorable. Collu-Bench : les meilleures méthodes atteignent 22-33% de précision pour localiser les hallucinations de code. Vous détectez une sur quatre. Ce n’est pas une défense, c’est un tirage au sort.
- Coût. Chaque couche multiplie les appels LLM. Vous payez pour un détecteur qui a raison un tiers du temps.
- Biais de position. Les LLM 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émolisseuse :
“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 les phantom fields mécaniquement |
| 2. Validation de fixtures | Haute | Bas | Oui | Les fixtures réels éliminent fiction-valide-fiction |
3. Smoke tests (make doctor) | Haute | Bas | Oui | 30 secondes, valeur maximale |
| 4. Détection d’anomalies | Moyenne | Bas | Partiel | Signaux dans les logs, pas de moniteur 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 de fixtures répond : “ces données sont-elles réelles ?”
- Smoke tests répond : “ça marche maintenant ?”
Ensemble, elles font qu’un champ inventé doit survivre trois filtres indépendants. Ce n’est pas impossible, mais c’est beaucoup plus difficile que de tromper un test unitaire avec un fixture inventé.
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 travail
- Les tests de logique → OK, ils vérifient le comportement
- Les fixtures → NON, ils doivent venir de données réelles
- Les schémas → NON, ils doivent venir de la spec de l’API
- La validation que les données sont correctes → NON, c’est fait par 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.
Vous pouvez avoir 200 tests au vert et vivre dans Matrix. Ou vous pouvez avoir un make doctor qui en 30 secondes vous dit si vos données sont réelles ou fictives.
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). Ensuite le silent failure (l’IA qui invente et passe les tests). Et maintenant, les défenses. Chaque échec différent, un dénominateur commun : nous avons besoin de systèmes mécaniques, pas de promesses de bon comportement.
Cet article a été rédigé en espagnol et traduit avec l’aide de l’IA.