Comment nous avons reconstruit notre jeu de données d'injection de prompt après qu'il nous a discrètement menti
La veille de la publication de zn en open source, nous avons publié un résultat qui nous plaisait : notre porte d'injection fusionnée tenait face à un modèle de Meta bien plus grand, sur notre propre benchmark de 217 vecteurs. Puis nous sommes revenus entraîner la révision suivante de la tête du modèle, et nous avons découvert que nos données d'entraînement nous mentaient depuis des mois. Cet article est l'autopsie et la reconstruction.
Le diagnostic
Au checkpoint v13d, nous avons entraîné une tête de détection sur le corpus dont nous disposions alors. Les métriques d'entraînement étaient correctes. L'évaluation face à quoi que ce soit de réel ne l'était pas. Les entrées bénignes authentiques (prompts ordinaires d'utilisateurs, refus de LLM, traces de génération automatique d'attaques ne contenant jamais de charge utile) obtenaient un logit moyen de 4,26. Les attaques simples atteignaient en moyenne 2,83. Notre classe bénigne se situait au-dessus de notre classe d'attaque.
La tête n'avait pas appris « injection contre bénin ». Elle avait appris la frontière d'un corpus synthétique : de courtes chaînes issues de gabarits contre tout le reste. Chaque texte réel était hors distribution, et le hors distribution se projetait vers le haut. Les documents réels ressemblaient davantage à une « attaque » que les attaques elles-mêmes, parce que celles-ci sortaient de la même usine à gabarits que les textes bénins.
Pouvions-nous recalibrer les seuils et passer à autre chose ? Non. Avant l'entraînement, nous avions enregistré une règle d'arrêt : si un ensemble externe de prompts réels se séparait de notre réservoir avec une AUROC inférieure à 0,75, le jeu de données est cassé, et aucun réglage de seuil ne pouvait le sauver. La séparabilité mesurée était de 0,33, en dessous même de 0,5, ce qui signifie que le classement était effectivement inversé. La règle d'arrêt s'est déclenchée exactement comme prévu.
Pour être précis sur les responsabilités : ce n'était pas un problème d'architecture. Aucune fonction de perte, aucun échantillonneur ni aucune astuce de calibration ne peut corriger un espace d'étiquettes où la classe positive est définie comme « ressemble à notre corpus synthétique ».
Ce que nous avons construit
Nous avons reconstruit le corpus à partir d'un plan par familles plutôt qu'à partir d'un tas de gabarits :
- Familles d'attaques : simple, red-team, paraphrase et multilingue.
- Familles bénignes : prompts courts, prose réelle longue, documentation, transcriptions conversationnelles et méta-textes.
- Règle de conception : le texte bénin doit couvrir la distribution du texte réel, et non celle de notre générateur.
La famille des méta-textes mérite une mention à part, car elle est un vaccin direct contre le mode de défaillance décrit plus haut. Ce sont des documents qui parlent d'attaques par injection (considérations de sécurité dans les RFC, sections sécurité de la documentation) sans contenir la moindre charge utile. Ils sont bénins selon la vérité de référence, proviennent à 100 % de sources naturelles, et se situent précisément dans la région où l'ancienne tête a échoué : un texte qui discute d'attaques en prose ordinaire. Si un classifieur peut être convaincu de signaler un modèle de menace, il n'a pas appris les menaces ; il a appris des mots-clés.
Les paraphrases sont volontairement simples. Aucun LLM ne les réécrit. Une table fixe de 53 paires de synonymes fréquentes dans la formulation des injections, des réordonnancements sûrs de clauses appariés par expressions régulières, des préfixes et suffixes contextuels tirés de listes fixes, une casse et une ponctuation contrôlées : une seule passe, les remplacements ne sont jamais réanalysés, de sorte que les transformations ne s'enchaînent pas en quelque chose d'inattendu. Chaque élément enregistre sa provenance sous forme d'identifiant de graine et de liste de transformations, avec une date de provenance figée pour que les relances soient identiques octet pour octet. Maximum 3 variantes par graine. Nous le disons sans détour : la substitution mécanique est une variation de surface, et non une diversité sémantique. Elle ancre la surface, et nous ne revendiquons rien de plus.
La famille multilingue est nouvelle dans v15, et l'honnêteté impose de dire ce qu'elle est et ce qu'elle n'est pas. Il s'agit de 1 812 attaques dans 8 locales (français, espagnol, portugais, italien, néerlandais, polonais, allemand, russe), produites par traduction automatique de graines d'attaques anglaises avec Qwen3-32B d'Amazon Bedrock, pour un coût total de 0,07 $, derrière un cache de réponses figé et déterministe afin que les relances soient identiques octet pour octet. Chaque élément enregistre sa provenance sous forme d'identifiant de graine, de locale et d'identifiant de modèle. Une injection traduite reste une injection que la porte doit détecter : c'est donc un véritable point d'ancrage de capacité inter-linguistique, mais une couverture de surface : de l'anglais traduit automatiquement, et non des schémas d'attaque écrits nativement pour chaque locale. Les graines conservent des hachages stables, et le découpeur tenant compte des groupes place ensemble chaque graine et ses traductions, si bien qu'une traduction ne peut jamais être répartie entre entraînement et test.
Les découpages tiennent compte des groupes grâce à un regroupement de quasi-doublons par MinHash avec k=128, de sorte qu'un texte et ses quasi-jumeaux ne puissent jamais être répartis entre entraînement et test. Nous y avons consacré beaucoup d'attention, car nous avons audité l'ancien corpus et trouvé des fuites documentées par rapport à nos propres ensembles d'évaluation : 3 vecteurs sur 217 du benchmark et 1 élément sur 60 d'un autre jeu externe se recouvraient avec les données d'entraînement. L'audit post-découpage, exécuté sur l'ensemble des 23 699 textes, a donné 0 violation.
Les chiffres
Seulement ce qui est traçable vers des artefacts sous evidence/v15/ :
| Métrique | Valeur |
|---|---|
| Textes | 23 699 |
| Familles d'attaques | simple, red-team, paraphrase, multilingue |
| Paraphrases | 2 500, 3 variantes maximum par graine |
| Attaques multilingues | 1 812 dans 8 locales (traduction automatique, signalé) |
| Famille de méta-textes | 966 éléments, 100 % de sources naturelles |
| Audits de découpage | 7 contrôles réussis, 0 violation |
Pas encore de chiffres de précision, car l'objet de cet article est les données, et non un modèle entraîné dessus.
Le corpus est public : zn-prompt-injection-bench sur Hugging Face, publié sous CC-BY-4.0, avec les licences de chaque source documentées dans la fiche du jeu de données.
Ce qui manque encore
Trois lacunes, énoncées plutôt que masquées :
- La couverture multilingue est de l'anglais traduit automatiquement. Les huit locales contiennent des graines traduites, et non des attaques écrites nativement par des locuteurs de chaque langue. Les habitudes de formulation propres à chaque locale et les schémas d'obfuscation sont sous-représentés ; nous considérons donc cette famille comme un point d'ancrage inter-linguistique, et non comme une profondeur.
- La paraphrase mécanique est une variation de surface. Elle ne peut pas remplacer des formulations d'attaque sémantiquement distinctes, et nous l'interprétons en conséquence lorsque nous analysons les résultats.
- 1 416 lignes synthétiques restent privées. Elles dérivent de notre corpus de production interne (la tranche
sb4_mutants), que nous conservons donc sans les redistribuer. Leur existence est signalée dans la section limites de la fiche du jeu de données ; les données elles-mêmes ne sont pas publiques.
La suite
Figer le corpus, lancer le balayage GPU sous les portes préenregistrées G1 à G7, et publier ce qui en sort, y compris si c'est laid. C'est le contrat que nous avons passé avec nous-mêmes lorsque la règle d'arrêt de v13d s'est déclenchée.
Si vous avez construit des corpus d'injection et que vous en portez les cicatrices, nous voulons votre avis. Passez au crible la méthodologie, les quotas par famille et le pipeline de paraphrase. Le détecteur lui-même est livré sous forme d'un seul binaire Rust, la documentation d'intégration se trouve sur /docs, la spécification complète de l'API sur /openapi.json, et la méthodologie de benchmark de la comparaison précédente figure dans l'article précédent. Trouvez ce que nous avons mal fait avant que nous ne réentraînions là-dessus.