La semaine dernière, mon IA a écrit du code qui lisait un fichier JSON depuis le disque, le décryptait, faisait une recherche précise, puis répétait ça 900 fois dans une boucle for. À chaque itération : ouvrir le fichier, décoder le JSON, chercher une valeur, tout jeter. Et recommencer.
C’est le genre d’erreur que j’enseigne à mes étudiants à éviter dans le premier mois de cours.
Ce qui s’est passé (sans détour)
Je construis Tokamak, une application de barre de menus pour macOS qui surveille les quotas de Claude Max. Une partie de la fonctionnalité scanne environ 900 fichiers JSONL de sessions Claude Code. Pour chaque fichier, elle doit déterminer l’offset de byte où elle s’était arrêtée la fois précédente (lecture incrémentale — ne lire que les parties nouvelles).
Les offsets sont sauvegardés dans un fichier JSON :
| |
Un Dictionary<String, UInt64> contenant 900 entrées. ~55KB. Rien de très complexe.
Et voici ce qui rend la situation encore plus absurde : ce fichier est créé par l’application elle-même. Ce n’est pas un JSON provenant d’une API externe. Il ne vient pas de Claude Code. C’est un fichier d’état interne que Tokamak écrit et lit pour savoir où chaque session s’est arrêtée. L’IA lisait le fichier depuis le disque 900 fois, alors qu’elle l’avait généré elle-même à l’origine.
“Mais pourquoi n’utilises-tu pas Core Data ou SQLite, puisque tu les as déjà dans ton app ?” Bonne question. Parce que ce fichier est un cache jetable pour suivre les progrès. Si le fichier est corrompu, je peux le supprimer, et le prochain scan reconstruira tous les offsets en lisant entièrement les fichiers une seule fois. Aucune perte de données. Et en prime : je peux exécuter cat session-offsets.json | jq . pour déboguer (alors que, avec Core Data, il faudrait utiliser sqlite3 et chercher le chemin dans le sandbox). C’est également Sendable sans les complexités des tasks en background. Et si le fichier SQLite de Core Data se corrompt, cela n’aura pas d’incidence sur les offsets (et vice versa). Pour 55KB de dictionnaire simple, le cérémonial d’une entité avec des migrations de schéma ne se justifie pas.
Alors non, le format n’était pas le problème. C’était l’accès aux données.
Voici le code que l’IA a écrit pour parcourir et scanner les fichiers :
| |
Deux appels au disque par itération. 900 itérations. Ça fait 1.800 opérations d’I/O là où il devait y en avoir exactement 2 : une lecture au début, une écriture à la fin.
Les chiffres (xctrace ne ment pas)
J’ai découvert le problème grâce à Instruments (Time Profiler). Voici les résultats :
| Métrique | Avant | Après |
|---|---|---|
| Échantillons totaux | 7.260 | 489 |
Échantillons dans OffsetStore.load() | 1.704 (88%) | 10 (2%) |
| Temps de scan | >20s | <0.5s |
| Utilisation CPU | 81% | ~1.5% |
88% du temps de scan était consacré à lire et à décoder un JSON de 900 lignes. Encore et encore. Comme Sisyphe et son rocher, mais avec JSONDecoder.
La correction (qui devrait te donner un sérieux malaise)
| |
Le point clé : la structure de données n’a pas changé. Elle reste un Dictionary<String, UInt64>. La table de hash était déjà optimale. Le problème résidait dans la reconstruction depuis le disque à chaque itération.
Ce qui ne marche pas : écrire “ne fais pas ça” dans ton CLAUDE.md
Après avoir corrigé ce bug, j’ai ajouté cette règle dans le fichier CLAUDE.md du projet :
“NE FAITES JAMAIS d’I/O (disque, réseau, décodage JSON, fetch Core Data) dans une boucle si cela peut être fait auparavant. Chargez les données une fois avant la boucle, travaillez en mémoire et sauvegardez une fois après.”
Et tu sais quoi ? Ça n’a servi à rien.
Quelques semaines plus tard, en ajoutant un deuxième service (Codex), l’IA a généré exactement le même modèle d’erreur. Cette fois, l’instruction était pourtant bien là. C’est comme mettre une pancarte “ne pas marcher sur la pelouse” et espérer que les gens l’écoutent.
Pourquoi ? Parce que le LLM ne comprend pas la règle. Il l’a vue. Statistiquement, la majorité du code qu’il a ingéré pendant son entraînement effectue des I/O ponctuelles, pas dans des boucles de 900 itérations. Le schéma classique charger → utiliser → sauvegarder est le modèle attendu. Le fait que cette fonction soit appelée dans une boucle de 900 itérations est une nuance que le modèle n’est pas incité à remarquer.
Ce qui ne marche pas non plus : les linters
Aucun linter ne détectera ce type d’erreur. Ni SwiftLint, ni ESLint, ni Ruff, ni Clippy. Réfléchis bien : le code est synthétiquement correct et sémantiquement valide. Chaque appel à offsetStore.offset(for:) est parfaitement normal pris individuellement. En fait, le problème réside dans la composition.
En analysant les différents niveaux de sens dans le code (une idée que j’explore dans mon cours sur le développement adversarial) :
| Niveau | Question | Échec ? |
|---|---|---|
| 1. Signal | Est-ce que c’est du code ? | Non |
| 2. Langage | Est-ce du Swift valide ? | Non |
| 3. Syntaxe | Est-ce que ça compile ? | Non |
| 4. Sémantique locale | Est-ce que la fonction fait ce qu’elle promet ? | Non |
| 5. Sémantique système | Respecte-t-elle les contrats et la performance ? | Oui |
| 6. Architecture | Évolue-t-elle sans nuire au système ? | Oui |
Le problème se situe au niveau 5-6. Précisément là où les LLM échouent en ce début d’année 2026. La syntaxe et la logique locale sont parfaites. Le problème est émergent : il apparaît lorsqu’une fonction correcte est utilisée dans un contexte qui en fait un goulet d’étranglement.
Les linters fonctionnent à des niveaux 2-4. Ils n’ont pas de visibilité sur la composition ou la performance. Leur fonctionnement s’apparente à celui du correcteur orthographique dans Word : impossible pour eux de détecter un raisonnement erroné.
Ce qui fonctionne vraiment : les tests de performance a posteriori
Après avoir corrigé le premier bug, j’ai écrit ce test :
| |
C’est un test de régression extrêmement simple. 1000 fichiers, moins de 3 secondes, sinon le test échoue. Si quelqu’un (humain ou IA) remet des opérations I/O dans la boucle, le test passe de 0,2 seconde à plus de 30 secondes, et explose.
Et c’est ce qui s’est passé. Lorsque l’IA a généré le deuxième service avec le même bug, le test de performance pour le premier service fonctionnait toujours (puisque les deux services étaient différents). Mais en écrivant un test équivalent pour le nouveau service, il a immédiatement échoué. Ce test a rempli son rôle en détectant la régression que ni le CLAUDE.md ni aucun linter n’auraient pu voir.
Ce que cela confirme
Ce défaut est l’exemple parfait de la thèse centrale de ce que j’appelle le développement adversarial : ne jamais faire confiance, toujours vérifier.
Tu ne peux pas compter sur l’IA pour éviter les erreurs de niveau débutant. Elle en fera. À répétition. Et ce, même si tu lui dis explicitement de ne pas le faire.
Tu ne peux pas compter sur les linters pour détecter ces erreurs. Ils ne le peuvent pas. L’erreur dépasse leur niveau d’abstraction.
Ce que tu peux faire :
- Mettre en place des tests de performance comme filet de sécurité a posteriori
- Profiler réellement (xctrace, Instruments) pour mesurer le problème au lieu de le deviner
- Pratiquer la défense en profondeur : utiliser plusieurs couches, car aucune ne peut tout couvrir seule
En langage courant : la défense n’est pas un mur. C’est un oignon. Des couches et des couches. Et lorsque l’une échoue, la suivante prend le relais.
Aux sceptiques
“Mais, Fernando, un développeur humain ne ferait-il pas la même erreur ?”
Un débutant, certainement. Un expérimenté, probablement pas — parce qu’il a intégré le modèle en question. Mais même un senior ferait une code review et repérerait le problème. Le problème du code généré par une IA, c’est le volume : 50 fichiers en 10 minutes. Personne ne passe au crible 50 fichiers ligne par ligne. La fatigue due au volume est réelle.
C’est pourquoi la vérification doit être automatique et non humaine. Un test de performance ne se fatigue pas. Il ne se distrait pas. Il est implacable. Chaque fois que tu exécutes make test, il te dit si quelque chose cloche.
C’est exactement le principe que j’applique dans les 5 couches de défense contre les hallucinations : le système de vérification doit être externe au générateur. Si l’IA écrit le code, la vérification doit venir d’ailleurs. Dans ce cas, d’un chronomètre mesurant directement la durée d’exécution.