Dezassete execuções falhadas e uma sigmoide plana: como construímos a porta supA
No artigo anterior contámos como o nosso corpus de treino nos mentiu em silêncio e o que fizemos a esse respeito. Esta é a segunda metade da história, e é menos lisonjeira: mesmo depois de o dataset ser corrigido, o nosso modelo continuava sem conseguir detetar nada. Meses de execuções de treino falharam a nossa própria porta de lançamento. Quando finalmente instrumentámos a coisa como deve ser, as pontuações estavam chatas como uma moeda ao ar. Este post percorre o que estava partido, qual se revelou ser a correção e os números que a porta reparada apresenta agora, incluindo as partes de que não nos orgulhamos.
Resumo
- Ao longo das revisões v9 a v11 correm 17 configurações de treino num só varrimento. Nenhuma passou a nossa porta de lançamento. As melhores taxas de falsos negativos ficaram entre 63% e 98%.
- Publicámos o dataset reconstruído na mesma, com provas: o nosso melhor modelo da altura obteve 86% de FNR nele.
- A noite de depuração encontrou pontuações fixas entre 0,498 e 0,509 em centenas de entradas. Causa raiz: o nosso objetivo de treino era uma perda do tipo recuperação sobre pares aleatórios com o mesmo rótulo. O rótulo nunca entrava no gradiente. O codificador aprendia agrupamento temático, nunca uma margem entre ataque e benigno, e a cabeça do classificador assentava em características moldadas para a tarefa errada.
- A correção foi treino supervisionado de fase única e de ponta a ponta: BCE sobre o codificador e a cabeça, AdamW com 2e-5 e 1e-3, aquecimento de 10%, 6 épocas, e sobreamostragem x4 de imitações benignas de ataques, retiradas apenas da partição de treino.
- Resultado, na partição de teste congelada: FPR 0,82%, FNR 8,20%, AUROC 0,9958, latência p50 de 17,9 ms com quantização int8 em CPU. O mesmo codificador MiniLM-L6 que quase deitámos fora.
- Entra hoje em produção como a porta supA em
POST https://api.usezn.com/v30/analyze: um segundo endpoint para um segundo caso de uso, não uma substituição. O/prod/analyzecontinua a ser a nossa porta de regras e o valor por defeito distribuído: o caminho de latência ultrabaixa para filtragem inline. A v30 troca milissegundos por uma deteção muito melhor. A comparação honesta, lado a lado, está mais abaixo.
Dezassete execuções, zero aprovações
Depois de a regra de paragem da v13d disparar e de reconstruirmos o corpus, apontámos os dados novos ao nosso pipeline de treino existente. Não correu bem. As revisões v9 a v11 acumularam configurações falhadas, até um varrimento final de 17 configurações em que cada uma falhou a nossa porta de lançamento pré-registada. Consoante a configuração, a melhor taxa de falsos negativos ficava entre 63% e 98%. Uma porta de segurança que deixa passar a maioria dos ataques é decoração, por isso nenhuma foi lançada.
Havia duas formas de ler isto. Uma: o dataset novo é difícil demais, voltemos a algo mais simpático. Duas: o dataset finalmente diz a verdade sobre um modelo que nunca funcionou. Tínhamos-nos comprometido antecipadamente a publicar o que saísse, incluindo as partes feias, por isso foi a segunda opção.
Publicar o dataset na mesma
Lançámos o corpus reconstruído — 23.699 textos, públicos no Hugging Face sob CC-BY-4.0 — enquanto o nosso melhor modelo da altura falhava nele rotundamente: 86% dos ataques escapavam. Esse número está no post sobre o dataset de propósito. Um benchmark que o teu melhor modelo não consegue passar é um benchmark partido ou um benchmark honesto, e a diferença resolve-se corrigindo o modelo, não o benchmark.
A noite em que as pontuações não mexiam
Depois veio a noite que transformou a suspeita em diagnóstico. Ligámos o melhor checkpoint da altura a um harness de sondagem e passámos-lhe centenas de entradas: ataques, prompts benignos, paráfrases, ataques multilingues. A saída de probabilidade quase não mexia. Todas as pontuações ficavam entre 0,498 e 0,509: uma moeda ao ar com decimais decorativos. Nada no espaço de entrada parecia importar.
Essa planura é, na verdade, informativa. Significa que os logits oscilam em torno de zero para tudo: o modelo não tem um ranking utilizável, e ajustar limiares é mudar as cadeiras do convés. Por isso parámos de ajustar e fomos à procura da causa.
Duas descobertas, uma embaraçosa, outra fatal:
- As nossas comparações de base eram maçãs com laranjas. A comparação só com regras anterior correu sobre um conjunto curado de 217 prompts. As falhas viviam no conjunto de teste completo, uma distribuição totalmente diferente. Ambas as medições eram verdadeiras individualmente; pô-las lado a lado criou uma sensação de segurança que nenhuma das duas sustentava.
- O objetivo de treino nunca poderia ter aprendido deteção, mesmo com dados perfeitos. Este era o bug verdadeiro.
Uma função de perda que ignorava os rótulos
O nosso objetivo de treino tinha sabor a recuperação: tirávamos pares do corpus e puxávamos os seus embeddings uns para os outros. Eis a forma, simplificada:
# 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
Leia outra vez esse ciclo e repare no que falta: o rótulo não aparece em lado nenhum. Que um texto seja um ataque ou um prompt benigno não tem efeito nenhum no gradiente. Um objetivo assim ensina o codificador a fazer agrupamento temático: textos que parecem chamadas de ferramentas derivam para outros textos que parecem chamadas de ferramentas. É uma boa propriedade para recuperação e quase inútil para deteção. Não há pressão em lado nenhum que afaste os ataques do texto benigno, por isso não se pode formar nenhuma margem ataque/benigno na geometria.
A cabeça do classificador tinha depois de traçar uma fronteira de decisão nesse espaço de embeddings congelado. Pedir a uma camada linear que encontre uma margem que o codificador nunca foi otimizado para produzir é a forma de obter uma sigmoide que dá 0,5 para o universo inteiro.
O que a tarefa exige de facto é supervisão que chegue ao 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
A mesma arquitetura de codificador. Os mesmos dados. Um objetivo diferente — e desta vez a perda pode saber quais os exemplos que são ataques.
A correção
A receita que funcionou, de ponta a ponta:
- Treino supervisionado de fase única. Entropia cruzada binária sobre a cabeça, retropropagada para a cabeça e para o codificador em conjunto. Sem fases congeladas, sem passagens de testemunho em duas fases.
- AdamW com dois grupos de parâmetros: taxa de aprendizagem 2e-5 para o codificador e 1e-3 para a cabeça.
- Aquecimento nos primeiros 10% dos passos, 6 épocas no total.
- Sobreamostragem x4 de negativos difíceis: imitações benignas de chamadas de ferramentas (as entradas que mais merecem ser confundidas com ataques), extraídas apenas da partição de treino e com peso aumentado. O minerador tocou apenas nos ids das linhas de treino; as divisões sensíveis a grupos (16.888 / 3.301 / 3.510) garantem que não há fugas para validação ou teste.
- Codificador inalterado. O mesmo MiniLM-L6 centrado no inglês para o qual já tínhamos rascunhado um epitáfio.
Os números
Tudo o que se segue foi medido na partição de teste congelada do corpus público:
Duas linhas merecem comentário. A linha multilingue: antes da correção, a exatidão colapsava fora do inglês. Depois, o AUROC por idioma fica entre 0,90 e 1,00 nos oito locales, o que indica que o colapso multilingue foi causado pela perda, e não pelo codificador de embeddings. E a linha de latência: 17,9 ms em p50 correspondem a ONNX quantizado em int8 a correr em inferência de CPU, medido em fluxo único; os números de concorrência em produção serão diferentes e publicá-los-emos quando confiarmos na metodologia.
A comparação honesta: regras vs. v30
Com os números em cima da mesa, vale a pena ser explícito sobre como isto é entregue, porque o zn tem agora dois endpoints de análise e foram construídos para dois trabalhos diferentes. Nenhum substitui o outro.
POST /prod/analyze— a porta de regras. Correspondência determinista de padrões, sem nenhum modelo no caminho do pedido: a opção de menor latência que entregamos, e o valor por defeito certo para filtrar cada chamada inline quando o orçamento de latência é apertado. Apanha padrões conhecidos de forma fiável, a custo negligenciável.POST /v30/analyze— a porta neuronal (supA). O modelo de que trata este post: deteção muito melhor, com uma latência mensuravelmente mais alta.
Ambos foram medidos na mesma partição de teste congelada (3.510 linhas) — sem seleção de casos a dedo desta vez:
* Só as regras medem FPR 8,39% / FNR 94,74% na partição de teste e FPR 9,60% / FNR 86,43% em todas as 23.699 linhas do corpus. Ambos os conjuntos são públicos, por isso citamos a faixa e não o extremo que mais nos favorece.
Dito sem rodeios: trocam-se milissegundos por apanhar ataques que as regras deixam passar por completo — neste conjunto de teste, qualquer coisa como nove em cada dez. Isto não é uma crítica ao motor de regras. Continua a ser o nosso valor por defeito distribuído, faz o seu trabalho a custo praticamente zero, e uma camada determinista que assinala padrões conhecidos é exatamente o que se quer que gaste zero tempo de inferência em cada pedido do caminho quente.
Orientação prática: mantenha a porta de regras inline em cada chamada quando o orçamento de latência é apertado, e encaminhe para a v30 as entradas que merecem uma análise mais exigente — prompts de maior risco, tráfego por amostragem, ou pontuação assíncrona e forense — ou aceite a latência extra em todo o lado se o seu modelo de ameaça exigir o máximo de recall.
O que isto não resolve
Três ressalvas, ditas com clareza:
- A partição de teste partilha ascendência de distribuição com o corpus de treino. Ambos descendem dos mesmos blueprints de famílias. É prática corrente e as partições não têm fugas, mas significa que estes números medem desempenho em formas conhecidas. Ataques do tipo dia zero, famílias verdadeiramente novas, serão mais difíceis, e preferimos dizê-lo a deixar o AUROC falar por eles.
- Um FNR de 8,20% não é zero. Cerca de um ataque em cada doze ainda passa no nosso ponto de operação. Quem implementar esta porta deve colocá-la em camadas, não depender só dela.
- O codificador é pequeno e centrado no inglês. Os números multilingues são bons, não milagrosos; os locales com poucos recursos ficam no fundo dessa faixa 0,90-1,00. Uma fase de destilação por professor para um codificador multilingue mais forte é a próxima fase planeada.
Experimente
A porta de regras continua a servir no endereço habitual; a supA junta-se-lhe hoje:
- API:
POST https://api.usezn.com/prod/analyze(regras — inline, valor por defeito de baixa latência) ePOST https://api.usezn.com/v30/analyze(supA neuronal — inspeção mais profunda). Mesma forma de pedido e resposta, mesma chave de API; encaminhe segundo a orientação acima. Documentação de integração em /docs, especificação completa em /openapi.json. - Código: o treinador, o harness de avaliação e a porta estão em github.com/usezn/zn, com licença MIT.
- Dados: zn-prompt-injection-bench no Hugging Face, 23.699 linhas, CC-BY-4.0, com as divisões e auditorias documentadas na ficha do dataset.
Se faz red-team profissionalmente, o acordo do artigo anterior mantém-se: parta-a e diga-nos como. A forma mais rápida de melhorar a próxima versão é encontrar o que esta fez mal.