Dix-sept échecs et une sigmoïde plate : comment nous avons construit la porte supA
Dans l'article précédent, nous vous avons expliqué comment notre corpus d'entraînement nous avait discrètement menti et ce que nous avons fait pour y remédier. Voici la seconde moitié de l'histoire, et elle est moins flatteuse : même après la correction du jeu de données, notre modèle ne parvenait toujours à rien détecter. Des mois d'entraînements ont échoué à notre propre porte de publication. Lorsque nous avons enfin correctement instrumenté le système, les scores restaient plats, à pile ou face. Cet article retrace ce qui était cassé, ce que s'est révélé être la solution, et les chiffres que la porte réparée affiche désormais, y compris les aspects dont nous ne sommes pas fiers.
En bref
- Sur les révisions v9 à v11, nous avons lancé 17 configurations d'entraînement dans un seul balayage. Aucune n'a franchi notre porte de publication. Les meilleurs taux de faux négatifs se situaient entre 63 % et 98 %.
- Nous avons tout de même publié le jeu de données reconstruit, pièces à l'appui : notre meilleur modèle de l'époque y obtenait 86 % de FNR.
- La nuit de débogage a révélé des scores bloqués entre 0,498 et 0,509 sur des centaines d'entrées. Cause profonde : notre objectif d'entraînement était une perte de type recherche, appliquée à des paires aléatoires de même étiquette. L'étiquette n'entrait jamais dans le gradient. L'encodeur apprenait un regroupement thématique, jamais une marge entre attaque et bénin, et la tête du classifieur reposait sur des caractéristiques façonnées pour la mauvaise tâche.
- La solution a été un entraînement supervisé de bout en bout, en une seule phase : BCE sur l'encodeur et la tête, AdamW à 2e-5 et 1e-3, échauffement sur 10 %, 6 époques, et des faux-amis bénins de l'attaque suréchantillonnés x4 uniquement à partir de la partition d'entraînement.
- Résultat, sur la partition de test figée : FPR 0,82 %, FNR 8,20 %, AUROC 0,9958, latence p50 de 17,9 ms avec quantification int8 sur CPU. Le même encodeur MiniLM-L6 que nous avions failli abandonner.
- Elle est livrée aujourd'hui sous le nom de porte supA sur
POST https://api.usezn.com/v30/analyze: un second point d'accès pour un second cas d'usage, et non un remplacement./prod/analyzereste notre porte à base de règles et la valeur par défaut livrée : le chemin à latence ultra-faible pour le filtrage en ligne. v30 échange des millisecondes contre une détection nettement meilleure. La comparaison honnête côte à côte figure plus bas.
Dix-sept essais, aucune réussite
Une fois la règle d'arrêt de v13d déclenchée et le corpus reconstruit, nous avons appliqué les nouvelles données à notre pipeline d'entraînement existant. Cela ne s'est pas bien passé. Les révisions v9 à v11 ont accumulé des configurations échouées, jusqu'à un balayage de 17 configurations dans lequel chacune a manqué notre porte de publication préenregistrée. Selon la configuration, le meilleur taux de faux négatifs se situait quelque part entre 63 % et 98 %. Une porte de sécurité qui laisse passer la plupart des attaques n'est qu'une décoration : aucune n'a été livrée.
On pouvait lire cela de deux façons. Première lecture : le nouveau jeu de données est trop difficile, revenons à quelque chose de plus accommodant. Seconde lecture : le jeu de données dit enfin la vérité sur un modèle qui n'avait jamais fonctionné. Nous nous étions engagés à publier ce qui sortait, y compris les parties laides ; ce fut donc la seconde option.
Publier le jeu de données malgré tout
Nous avons publié le corpus reconstruit — 23 699 textes, accessibles sur Hugging Face sous licence CC-BY-4.0 — alors que notre meilleur modèle de l'époque y échouait franchement : 86 % des attaques passaient entre les mailles. Ce chiffre figure délibérément dans l'article sur le jeu de données. Un benchmark que votre meilleur modèle ne peut pas réussir est soit un benchmark cassé, soit un benchmark honnête, et la différence se tranche en corrigeant le modèle, non le benchmark.
La nuit où les scores refusaient de bouger
Vint ensuite la nuit qui a transformé le soupçon en diagnostic. Nous avons branché le meilleur checkpoint de l'époque sur un banc de sondage et fait passer des centaines d'entrées : attaques, prompts bénins, paraphrases, attaques multilingues. La sortie de probabilité bougeait à peine. Chaque score se situait entre 0,498 et 0,509 : un pile ou face orné de décimales. Rien dans l'espace d'entrée ne semblait avoir d'importance.
Cette platitude est en fait instructive. Elle signifie que les logits oscillent autour de zéro pour tout : le modèle n'offre aucun classement exploitable, et régler les seuils revient à réarranger les transats du pont. Nous avons donc cessé d'ajuster et nous sommes lancés à la recherche de la cause.
Deux constats, l'un embarrassant, l'autre fatal :
- Nos comparaisons de référence comparaient des pommes et des oranges. La comparaison à base de règles seules précédente s'appuyait sur un jeu de 217 prompts soigneusement sélectionnés. Les échecs se situaient sur le jeu de test complet, une distribution totalement différente. Les deux mesures étaient vraies individuellement ; les placer côte à côte créait un sentiment de sécurité que ni l'une ni l'autre ne justifiait.
- L'objectif d'entraînement n'aurait pas pu apprendre la détection, même avec des données parfaites. C'était le véritable bogue.
Une fonction de perte qui ignorait les étiquettes
Notre objectif d'entraînement relevait de la recherche : on tirait des paires dans le corpus et on rapprochait leurs embeddings. Voici sa forme, simplifiée :
# What we trained for months (simplified)
for a, b in sampled_pairs(batch): # pairs drawn from the pool
za, zb = encode(a), encode(b)
loss = pull_together(za, zb) # geometry-only objective
Relisez cette boucle et remarquez ce qui manque : l'étiquette n'apparaît nulle part. Qu'un texte soit une attaque ou un prompt bénin n'a aucun effet sur le gradient. Un objectif de ce type apprend à l'encodeur un regroupement thématique : les textes qui ressemblent à des appels d'outils dérivent vers d'autres textes qui ressemblent à des appels d'outils. C'est une bonne propriété pour la recherche, et presque inutile pour la détection. Rien n'exerce de pression pour éloigner les attaques du texte bénin, si bien qu'aucune marge entre attaque et bénin ne peut se former dans la géométrie.
La tête du classifieur devait ensuite tracer une frontière de décision à travers cet espace d'embeddings figé. Demander à une couche linéaire de trouver une marge que l'encodeur n'a jamais été optimisé à produire, c'est obtenir une sigmoïde qui renvoie 0,5 pour l'ensemble de l'univers.
Ce que la tâche exige réellement, c'est une supervision qui atteint l'encodeur :
# What detection needs (simplified)
logits = head(encode(text)) # gradients flow into the encoder
loss = binary_cross_entropy(logits, y) # the label IS the signal
La même architecture d'encodeur. Les mêmes données. Un objectif différent, et cette fois la perte peut savoir quels exemples sont des attaques.
La solution
La recette qui a fonctionné, de bout en bout :
- Entraînement supervisé en une seule phase. Entropie croisée binaire sur la tête, rétropropagée à la fois dans la tête et dans l'encodeur. Pas d'étapes figées, pas de passage de témoin en deux phases.
- AdamW avec deux groupes de paramètres : taux d'apprentissage 2e-5 pour l'encodeur, 1e-3 pour la tête.
- Échauffement sur les 10 % premiers pas, 6 époques au total.
- Suréchantillonnage x4 des négatifs difficiles : des imitations bénignes d'appels d'outils (les entrées qui méritent le plus d'être confondues avec des attaques), extraites uniquement de la partition d'entraînement et pondérées. Le mineur n'a touché que les identifiants de lignes d'entraînement ; les partitions tenant compte des groupes (16 888 / 3 301 / 3 510) garantissent l'absence de fuite vers la validation ou le test.
- Encodeur inchangé. Le même MiniLM-L6 centré sur l'anglais, pour lequel nous avions déjà préparé une oraison funèbre.
Les chiffres
Tout ce qui suit est mesuré sur la partition de test figée du corpus public :
Deux lignes méritent un commentaire. La ligne multilingue : avant la correction, la précision s'effondrait hors anglais. Après, l'AUROC par langue se situe entre 0,90 et 1,00 sur les huit locales, ce qui indique que l'effondrement multilingue venait de la fonction de perte, et non de l'encodeur d'embeddings. Et la ligne de latence : 17,9 ms en p50 correspondent à de l'ONNX quantifié en int8 et exécuté sur CPU, mesuré en flux unique ; les chiffres de concurrence en production seront différents, et nous les publierons lorsque nous aurons confiance dans la méthodologie.
La comparaison honnête : règles contre v30
Les chiffres étant sur la table, il est utile d'être explicite sur la façon dont tout cela est livré, car zn dispose désormais de deux points d'accès d'analyse, conçus pour deux usages différents. Aucun ne remplace l'autre.
POST /prod/analyze— la porte à règles. Correspondance de motifs déterministe, sans modèle dans le chemin de la requête : l'option la plus faible en latence que nous livrons, et la valeur par défaut adaptée pour filtrer chaque appel en ligne lorsque le budget de latence est serré. Elle détecte de façon fiable les motifs connus, à un coût négligeable.POST /v30/analyze— la porte neuronale (supA). Le modèle dont traite cet article : une détection nettement meilleure, à une latence mesurablement plus élevée.
Les deux ont été mesurées sur la même partition de test figée (3 510 lignes), sans sélection cerise sur gâteau cette fois :
* Les règles seules mesurent un FPR de 8,39 % et un FNR de 94,74 % sur la partition de test, et un FPR de 9,60 % et un FNR de 86,43 % sur les 23 699 lignes du corpus. Les deux ensembles étant publics, nous citons la fourchette plutôt que l'extrémité qui nous avantage le plus.
Dit simplement : vous échangez des millisecondes contre la détection d'attaques que les règles ratent entièrement, soit, sur cet ensemble de test, environ neuf sur dix. Ce n'est pas une critique du moteur de règles. Il reste notre valeur par défaut livrée, il fait son travail à un coût quasi nul, et une couche déterministe qui signale les motifs connus est exactement ce que vous voulez voir consommer zéro temps d'inférence sur chaque requête du chemin critique.
Conseil pratique : gardez la porte à règles en ligne sur chaque appel lorsque votre budget de latence est serré, et dirigez vers v30 les entrées qui méritent un examen plus approfondi (prompts à risque plus élevé, trafic échantillonné, ou scoring asynchrone et forensique). Vous pouvez aussi accepter la latence supplémentaire partout si votre modèle de menace exige le rappel maximal.
Ce que cela ne corrige pas
Trois réserves, énoncées clairement :
- La partition de test partage une ascendance de distribution avec le corpus d'entraînement. Les deux descendent des mêmes plans de familles. C'est une pratique courante et les partitions sont sans fuite, mais cela signifie que ces chiffres mesurent les performances sur des formes connues. Les attaques de type jour zéro, des familles réellement nouvelles, seront plus difficiles, et nous préférons le dire plutôt que de laisser l'AUROC parler à leur place.
- Un FNR de 8,20 % n'est pas nul. Environ une attaque sur douze passe encore à notre point de fonctionnement. Quiconque déploie cette porte doit la superposer à d'autres couches, et non s'y fier seule.
- L'encodeur est petit et centré sur l'anglais. Les chiffres multilingues sont bons, pas miraculeux ; les locales à faibles ressources se situent au bas de cette bande 0,90-1,00. Une phase de distillation par enseignant vers un encodeur multilingue plus puissant est la prochaine phase prévue.
Essayez-la
La porte à règles continue d'être servie à son adresse habituelle ; supA la rejoint dès aujourd'hui :
- API :
POST https://api.usezn.com/prod/analyze(règles — en ligne, valeur par défaut à faible latence) etPOST https://api.usezn.com/v30/analyze(supA neuronal — inspection plus profonde). Même forme de requête et de réponse, même clé d'API ; routez selon les recommandations ci-dessus. Documentation d'intégration sur /docs, spécification complète sur /openapi.json. - Code : l'entraîneur, le banc d'évaluation et la porte sont disponibles sur github.com/usezn/zn, sous licence MIT.
- Données : zn-prompt-injection-bench sur Hugging Face, 23 699 lignes, CC-BY-4.0, partitions et audits documentés dans la fiche du jeu de données.
Si vous faites du red-team à plein temps, l'engagement de l'article précédent tient toujours : cassez-la et dites-nous comment. Le moyen le plus rapide d'améliorer la prochaine version est de trouver ce que celle-ci a mal fait.