Diecisiete ejecuciones fallidas y una sigmoide plana: cómo construimos la puerta supA
En el artículo anterior contamos cómo nuestro corpus de entrenamiento nos engañó en silencio y qué hicimos al respecto. Esta es la segunda mitad de la historia, y es menos halagüeña: incluso después de arreglar el dataset, nuestro modelo seguía sin detectar nada. Meses de ejecuciones de entrenamiento no superaron nuestra propia puerta de publicación. Cuando por fin instrumentamos el sistema como es debido, las puntuaciones se quedaron planas, como una moneda al aire. Este artículo repasa qué estaba roto, cuál resultó ser la solución y las cifras que la puerta reparada publica ahora, incluidas las partes de las que no nos enorgullecemos.
Resumen
- En las revisiones v9 a v11 ejecutamos 17 configuraciones de entrenamiento en un solo barrido. Ninguna superó nuestra puerta de publicación. Las mejores tasas de falsos negativos quedaron entre 63 % y 98 %.
- Publicamos igualmente el dataset reconstruido, con las pruebas a la vista: nuestro mejor modelo de entonces obtuvo 86 % de FNR en él.
- La noche de depuración encontró puntuaciones fijadas entre 0,498 y 0,509 en cientos de entradas. Causa raíz: nuestro objetivo de entrenamiento era una pérdida de tipo recuperación sobre pares aleatorios de la misma etiqueta. La etiqueta nunca entraba en el gradiente. El codificador aprendió agrupación temática, nunca un margen entre ataque y benigno, y la cabeza del clasificador descansaba sobre rasgos moldeados para la tarea equivocada.
- La solución fue entrenamiento supervisado de una sola fase de extremo a extremo: BCE sobre el codificador y la cabeza, AdamW con 2e-5 y 1e-3, calentamiento del 10 %, 6 épocas, y ejemplos benignos parecidos a ataques sobremuestreados x4 solo desde la partición de entrenamiento.
- Resultado, sobre la partición de prueba congelada: FPR 0,82 %, FNR 8,20 %, AUROC 0,9958, latencia p50 de 17,9 ms con cuantización int8 en CPU. El mismo codificador MiniLM-L6 que casi descartamos.
- Hoy se publica como la puerta supA en
POST https://api.usezn.com/v30/analyze: un segundo endpoint para un segundo caso de uso, no un reemplazo./prod/analyzesigue siendo nuestra puerta de reglas y el valor por defecto distribuido: la ruta de latencia ultrabaja para el filtrado en línea. v30 cambia milisegundos por una detección muy superior. La comparación honesta, cara a cara, está más abajo.
Diecisiete ejecuciones, cero aprobadas
Después de que se disparara la regla de parada de v13d y de reconstruir el corpus, apuntamos los datos nuevos a nuestro pipeline de entrenamiento existente. No fue bien. Las revisiones v9 a v11 acumularon configuraciones fallidas, hasta llegar a un barrido de 17 configuraciones en el que cada una falló nuestra puerta de publicación preregistrada. Según la configuración, la mejor tasa de falsos negativos se situó entre 63 % y 98 %. Una puerta de seguridad que deja escapar la mayoría de los ataques es decorativa, así que ninguna se publicó.
Había dos maneras de leer esto. Una: el dataset nuevo es demasiado difícil, volvamos a algo más amable. Dos: el dataset por fin dice la verdad sobre un modelo que nunca había funcionado. Nos habíamos comprometido de antemano a publicar lo que saliera, incluidas las partes feas, así que elegimos la segunda opción.
Publicar el dataset de todos modos
Publicamos el corpus reconstruido —23.699 textos, públicos en Hugging Face bajo CC-BY-4.0— mientras nuestro mejor modelo de entonces lo suspendía de plano: el 86 % de los ataques se colaba. Esa cifra aparece a propósito en el artículo sobre el dataset. Un benchmark que tu mejor modelo no puede superar es, o bien un benchmark roto, o bien uno honesto, y la diferencia se resuelve arreglando el modelo, no el benchmark.
La noche en que las puntuaciones no se movían
Entonces llegó la noche que convirtió la sospecha en diagnóstico. Conectamos el mejor checkpoint de entonces a un arnés de sondeo y le pasamos cientos de entradas: ataques, prompts benignos, parafraseos, ataques multilingües. La salida de probabilidad apenas se movía. Todas las puntuaciones estaban entre 0,498 y 0,509: una moneda al aire con decimales decorativos. Nada del espacio de entrada parecía importar.
Esa planitud, en realidad, es informativa. Significa que los logits rondan cero para todo: el modelo no tiene un ranking utilizable, y ajustar umbrales es mover las sillas de cubierta. Así que dejamos de ajustar y nos pusimos a buscar la causa.
Dos hallazgos, uno embarazoso y otro fatal:
- Nuestras comparaciones de referencia eran manzanas con naranjas. La comparación solo con reglas anterior se ejecutó sobre un conjunto curado de 217 prompts. Los fallos vivían en el conjunto de prueba completo, una distribución totalmente distinta. Ambas medidas eran verdaderas por separado; ponerlas una al lado de la otra creaba una sensación de seguridad que ninguna de las dos respaldaba.
- El objetivo de entrenamiento no podía haber aprendido la detección ni con datos perfectos. Este era el error real.
Una función de pérdida que ignoraba las etiquetas
Nuestro objetivo de entrenamiento tenía sabor a recuperación: tomábamos pares del corpus y acercábamos sus embeddings. Su forma, simplificada, es esta:
# 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
Lee ese bucle otra vez y fíjate en lo que falta: la etiqueta no aparece en ninguna parte. Que un texto sea un ataque o un prompt benigno no tiene ningún efecto en el gradiente. Un objetivo así enseña al codificador agrupación temática: los textos que parecen llamadas a herramientas se acercan a otros textos que parecen llamadas a herramientas. Es una buena propiedad para recuperación y casi inútil para detección. No hay presión en ningún punto que empuje a los ataques lejos del texto benigno, así que en la geometría no puede formarse ningún margen entre ataque y benigno.
Se esperaba entonces que la cabeza del clasificador trazara una frontera de decisión sobre ese espacio de embeddings congelado. Pedirle a una capa lineal que encuentre un margen que el codificador nunca fue optimizado para producir es la forma de obtener una sigmoide que devuelve 0,5 para todo el universo.
Lo que la tarea exige en realidad es una supervisión que llegue al codificador:
# 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 misma arquitectura de codificador. Los mismos datos. Un objetivo distinto, y esta vez la pérdida puede saber qué ejemplos son ataques.
La solución
La receta que funcionó, de principio a fin:
- Entrenamiento supervisado de una sola fase. Entropía cruzada binaria sobre la cabeza, retropropagada a la cabeza y al codificador a la vez. Sin etapas congeladas, sin traspasos en dos fases.
- AdamW con dos grupos de parámetros: tasa de aprendizaje 2e-5 para el codificador y 1e-3 para la cabeza.
- Calentamiento durante el primer 10 % de los pasos, 6 épocas en total.
- Sobremuestreo x4 de negativos difíciles: imitaciones benignas de llamadas a herramientas (las entradas que más merecen confundirse con ataques), extraídas solo de la partición de entrenamiento y con mayor peso. El minero tocó únicamente los ids de filas de entrenamiento; las particiones conscientes de grupos (16.888 / 3.301 / 3.510) garantizan que no haya fugas hacia validación ni prueba.
- Codificador sin cambios. El mismo MiniLM-L6 centrado en inglés para el que ya habíamos redactado un epitafio.
Las cifras
Todo lo que sigue se mide sobre la partición de prueba congelada del corpus público:
Dos filas merecen comentario. La fila multilingüe: antes de la solución, la precisión se desplomaba fuera del inglés. Después, el AUROC por idioma se sitúa entre 0,90 y 1,00 en los ocho locales, lo que indica que el colapso multilingüe lo causaba la función de pérdida, no el codificador de embeddings. Y la fila de latencia: 17,9 ms en p50 corresponden a ONNX cuantizado a int8 ejecutándose en CPU, medido en flujo único; las cifras de concurrencia en producción serán distintas, y las publicaremos cuando confiemos en la metodología.
La comparación honesta: reglas frente a v30
Con las cifras sobre la mesa, conviene ser explícitos sobre cómo se distribuye esto, porque zn tiene ahora dos endpoints de análisis creados para dos trabajos distintos. Ninguno sustituye al otro.
POST /prod/analyze: la puerta de reglas. Coincidencia determinista de patrones sin ningún modelo en la ruta de la petición: la opción de menor latencia que distribuimos, y el valor por defecto correcto para filtrar cada llamada en línea cuando el presupuesto de latencia es ajustado. Detecta patrones conocidos de forma fiable y a coste despreciable.POST /v30/analyze: la puerta neuronal (supA). El modelo del que trata este artículo: detección muy superior, con una latencia medible más alta.
Ambas se midieron sobre la misma partición de prueba congelada (3.510 filas), sin selección de casos curados en esta ocasión:
* Solo con reglas se mide FPR 8,39 % / FNR 94,74 % en la partición de prueba y FPR 9,60 % / FNR 86,43 % en las 23.699 filas del corpus. Ambos conjuntos son públicos, así que citamos la banda en lugar del extremo que más nos favorezca.
Dicho claramente: se cambian milisegundos por detectar ataques que las reglas dejan pasar por completo, en este conjunto de prueba, alrededor de nueve de cada diez. Esto no es una crítica al motor de reglas. Sigue siendo nuestro valor por defecto distribuido, cumple su función con un coste prácticamente nulo, y una capa determinista que marca patrones conocidos es justo lo que quieres que gaste cero tiempo de inferencia en cada petición del camino crítico.
Orientación práctica: mantén la puerta de reglas en línea en cada llamada cuando tu presupuesto de latencia es ajustado, y envía a v30 las entradas que merecen un análisis más exigente (prompts de mayor riesgo, tráfico muestreado, o puntuación asíncrona y forense), o acepta la latencia extra en todas partes si tu modelo de amenazas exige el máximo recall.
Lo que esto no arregla
Tres advertencias, dichas claramente:
- La partición de prueba comparte ascendencia de distribución con el corpus de entrenamiento. Ambas descienden de los mismos planos de familias. Es práctica estándar y las particiones no tienen fugas, pero significa que estas cifras miden el rendimiento sobre formas conocidas. Los ataques de tipo día cero, familias genuinamente nuevas, serán más difíciles, y preferimos decirlo antes que dejar que el AUROC hable por ellos.
- Un FNR de 8,20 % no es cero. Aproximadamente uno de cada doce ataques todavía se cuela con nuestro punto de operación. Quien despliegue esta puerta debería combinarla con otras capas, no confiar solo en ella.
- El codificador es pequeño y centrado en inglés. Las cifras multilingües son buenas, no milagrosas; los locales con pocos recursos quedan en la parte baja de esa banda de 0,90-1,00. Una fase de destilación desde un profesor hacia un codificador multilingüe más potente es la siguiente fase planificada.
Pruébalo
La puerta de reglas sigue sirviendo en su dirección habitual; supA se une hoy a ella:
- API:
POST https://api.usezn.com/prod/analyze(reglas: en línea, valor por defecto de baja latencia) yPOST https://api.usezn.com/v30/analyze(supA neuronal: inspección más profunda). Misma forma de petición y respuesta, misma clave de API; enruta según la orientación de arriba. Documentación de integración en /docs, especificación completa en /openapi.json. - Código: el entrenador, el arnés de evaluación y la puerta están en github.com/usezn/zn, con licencia MIT.
- Datos: zn-prompt-injection-bench en Hugging Face, 23.699 filas, CC-BY-4.0, con las particiones y auditorías documentadas en la ficha del dataset.
Si te dedicas a hacer red-team, el acuerdo del artículo anterior sigue en pie: rómpelo y cuéntanos cómo. La forma más rápida de mejorar la próxima versión es encontrar lo que esta hizo mal.