← Todos os textos

Um classificador é quase só encanamento

Nesta página

Na Flowstate a gente anda remoendo um problema que parece trivial até você tentar: rotear cada requisição que chega pro modelo mais barato que dá conta dela de verdade. Pra rotear um prompt, primeiro você precisa descobrir o que ele quer, e os prompts chegam como puro caos humano. Erros de digitação. Um despejo de 400 linhas de código com o pedido de verdade enterrado na última linha. “oi dá uma olhada nisso aqui”. Você precisa extrair uma intenção limpa disso, e precisa fazer isso em bem menos de um milissegundo, no mesmo processo, numa CPU, de graça. Um julgamento semântico numa pegada de memória menor que um JPEG, enquanto todo post sobre o assunto jura de pés juntos que você precisa de um rack de H100s.

Existe um manual padrão da indústria pra exatamente isso. Monte um dataset de referência com 200–500 prompts rotulados por humanos. Valide um LLM professor contra ele e não avance abaixo de um F1 de 0,90. Destile o professor num modelo aluno pequeno: SetFit, uma variante de BERT, algo com embeddings. Plote matrizes de confusão. Meça o p99 de latência em micro-benchmarks. Rode um deploy sombra de 48 horas antes de deixar o modelo encostar numa única requisição real. É um manual lindo, do tipo escrito por pesquisadores com computação infinita e sem pager de produção. Também é uma maneira fantástica de se superdimensionar até um beco sem saída.

O trabalho de verdade era estreito: marcar cada prompt que chega com um tipo de tarefa (parent → child, como engineering → fix_bug ou data → spreadsheet_edit) pra que o proxy roteie pra algum lugar sensato em vez de mandar tudo pro modelo mais caro do cardápio. Só CPU, nenhuma GPU no caminho crítico, um monte de requisições. O que faz treinar o próprio classificador ser sensato em vez de loucura é que a Flowstate já está sentada numa montanha de requisições reais. Só que sem rótulo. Esse é o único bom uso pra um LLM professor barato. Não como porteiro cobrando pedágio de cada requisição. Você aponta ele pra pilha uma vez, deixa rotular tudo e joga fora. Use a coisa cara exatamente uma vez.

O paradoxo do pedágio

A objeção óbvia, que me fez encarar o teto: pule o classificador local de vez. Chame um modelo barato e rápido (Flash, Haiku, o que estiver no fundo da tabela de preços), deixe ele marcar o prompt e termine antes do almoço. Ainda por cima seria mais preciso que qualquer coisa que eu conseguisse treinar.

Mas essa é exatamente a armadilha da qual toda a arquitetura existe pra escapar. O ponto de marcar um prompt antes de ele chegar a um modelo é gastar menos: mandar o que é fácil pra algum lugar barato, e só pagar preço de fronteira quando realmente precisar. Se o próprio roteador faz uma chamada de API paga em toda requisição, você construiu um pedágio na frente do seu pedágio. Você está pagando uma passagem de ônibus só pra perguntar pro motorista se é o ônibus certo. Queimou a economia antes de ganhar, grudou uma viagem de ida e volta pela rede em cada requisição e postou uma cópia de cada prompt nos logs de outra pessoa. Um classificador rodando no mesmo processo numa CPU que você já tem é de graça por requisição. Não barato, de graça, pra sempre depois de treinado. Então um modelo desajeitado de 92% que não custa nada pode valer mais que um de 99% que te cobra por token. Aqui você quer um segurança rápido e desajeitado, não um filósofo lento e caro.

O problema é a palavra “treinado”. Um modelo desses é faminto. Ele quer muito mais exemplos rotulados do que eu jamais rotularia à mão por diversão. Então o professor se paga exatamente uma vez: rotula a pilha offline, e o classificador roda de graça pra sempre.

Antes de discutir qualquer coisa disso, fiz o que costumo fazer: montei uma bancada pequena e medi.

Primeiro, uma confissão

Eu ainda não apontei o professor pra pilha real, então nessa primeira rodada gerei um substituto. Alguns milhares de prompts sintéticos em dezoito tipos de tarefa, com ambiguidade deliberada embutida pra o classificador ter algo em que errar de verdade. Prompts como “olha o endpoint quebrado”, que sinceramente é fix_bug ou debug e você não distingue pelo texto. Uns 2.800 pra treinar, 600 pra testar.

Isso importa, então vou dizer em voz alta: dado sintético rotulado por quem o gerou é jogo de cartas marcadas. Ele diz se o pipeline funciona e como as abordagens se classificam entre si. Ele não diz a acurácia no mundo real. E ainda bajula os modelos simples, porque texto de template deixa impressões digitais lexicais que um bag of words aspira. Guarde isso no bolso. Eu volto a isso.

Com isso pregado na porta, vamos aos números.

O modelo é a decisão chata

Cinco abordagens, da mais barata à mais sofisticada. Um TF-IDF simples com bag of words numa regressão logística. O mesmo com n-gramas de caracteres e um SVM. Um vetorizador por hashing num classificador SGD. E a que o manual realmente quer que você construa: embeddings de sentenças (um transformer BGE quantizado) com uma cabeça linear.

AbordagemAcur. folhaAcur. paip95 (1 req)p95 (sob carga)Tamanho
TF-IDF + logreg0,9231,0000,18 ms0,17 ms336 KB
TF-IDF + char + SVM0,9261,0001,4 ms29,6 ms3,2 MB
Hashing (2²⁰) + SGD0,9261,00020,7 ms42,4 ms147 MB
Hashing (2¹⁸) + SGD0,9241,0004,1 ms6,8 ms37 MB
Embeddings + logreg0,9180,9976,0 ms20,8 ms131 MB

A coluna de acurácia é a sem graça. Todas as abordagens ficam a um ponto percentual das outras, todas agrupadas em torno de 92%. O transformer de 131MB, aquele pra o qual você escreveria um design doc de justificativa, ficou em último e foi o único que não acertou o rótulo pai, o mais grosso, perfeitamente. O bag of words de 336KB, uma ideia mais velha que a maioria dos frameworks no seu package.json, empatou com ele e entregou em um terço de milissegundo.

A latência é a coluna que não é plana. Duas ordens de grandeza entre o topo e o fundo. O único eixo em que essas abordagens realmente diferem é o operacional, e nele a opção mais burra vence de lavada.

A coluna do pai esconde um resultado mais discreto, parada num 1,000 redondo pra quase todo mundo. Toda a confusão mora dentro de um domínio: viz confundido com data_analysis, market_research com web_research. Nada nunca confunde uma planilha com um relatório de bug. Se o proxy só precisa do domínio grosso, a coisa mais barata da lista já tinha resolvido e eu podia ter ido pra casa.

Então, se o modelo mal importa, o que importa?

O encanamento

Três coisas consumiram muito mais da minha atenção que o modelo, e nenhuma aparece em guia nenhum.

Armadilha um: o vetorizador por hashing come 147 megabytes sem avisar

O modelo de hashing com 2²⁰ features marcou 20,7ms e 147MB de memória residente, pra uma tarefa com dezoito classes. É um quarto de segundo de latência e um arquivo de vídeo pequeno de RAM pra decidir se alguém digitou “arruma isso” ou “por que isso tá quebrado”.

A causa é sem graça e inteiramente autoinfligida: um espaço de 2²⁰ features vezes dezoito classes é uma matriz densa de coeficientes do tamanho de um álbum de fotos de férias, e cada predição passa a mão por ela inteira. Baixe o hash pra 2¹⁸ e fica cinco vezes mais rápido e quatro vezes menor com a mesma acurácia. O valor padrão é a armadilha, e ninguém avisa que ela está armada.

Armadilha dois: a miragem do “na minha máquina funciona”

O SVM com n-gramas de caracteres ficou lindo isolado: 1,4ms por requisição, a melhor acurácia do placar por um fio. Quase fiz o commit e fui tomar um café. Aí apontei oito workers concorrentes pra ele e o p95 despencou pra 29,6 milissegundos. Um colapso de vinte vezes no instante em que ele ganhou companhia.

Dois motivos, ambos invisíveis num benchmark de requisição única. Pra tirar probabilidades de um SVM você o calibra, o que treina e roda três submodelos por predição sem fazer alarde. E todo esse trabalho extra de CPU por requisição se empilha direto no lock global do Python, então as requisições formam uma fila educada dentro do prédio em chamas em vez de sair voando. O bag of words, fazendo quase nada por requisição, nem percebeu a carga. 0,17ms com oito workers, igualzinho a rodar sozinho. Ganhar na mediana é bom. Manter a cabeça fria quando tudo está pegando fogo é o que importa às 3 da manhã.

A cola: truncar é a diferença entre 17% e 83%

Essa me fez sentir um idiota. Joguei um teste de estresse no modelo vencedor: um blob de 400 linhas de Java com uma instrução crucial pendurada na última linha, “agora arruma o bug que faz o total ficar errado”. O formato real de um prompt numa ferramenta de código.

Ele fez 16,7%. Perdeu a cabeça e rotulou quase tudo como refactor, porque pra um bag of words uma parede de código é um refactor, e a única instrução humana no fim se afogou no ruído.

A correção não foi um modelo mais sofisticado nem uma matriz de embeddings. Foram quatro linhas que jogam fora tudo menos os últimos 200 caracteres e classificam só esses.

De dezesseis por cento pra oitenta e três, só mudando o que o modelo tinha permissão de ver em vez de qual modelo ele era. (Só o fim ganha quando a instrução está no final. Início mais fim é a aposta geral mais segura, caso o pedido esteja no topo.) O modelo estava bem o tempo todo. O encanamento é que não.

A outra cola: ensinar o modelo a dizer “não sei”

Um classificador que erra o rótulo com toda a confiança é pior que um que se abstém. Por sorte, o modelo já sabia quando estava chutando. As respostas erradas dele em prompts ambíguos de uma palavra só (“debug”, ”?”) vinham com confiança lá embaixo. Então varri um limiar de confiança: abaixo dele, roteia pra um balaio geral em vez de chutar.

É uma troca limpa. Segure o limiar perto de 0,7 e você ainda responde 90% dos prompts, a acurácia nos respondidos sobe pra 95,5%, e você pega 90% do lixo genuinamente fora de escopo. Onde você coloca o limiar é decisão de produto (quantas vezes você aceita dar de ombros contra quantas vezes aceita errar) e, de novo, nada a ver com o modelo.

Onde isso é jogo de cartas marcadas, e onde não é

De volta à confissão. Um leitor atento já está digitando: seus dados são sintéticos e lexicalmente arrumadinhos, claro que o bag of words ganhou. Embeddings se pagam em prompts reais e bagunçados, que você nunca testou. Esse leitor tem razão, e eu aceitaria a aposta dele. Em tráfego real, com erros de digitação, pensamentos pela metade, três idiomas na mesma frase, a mesma intenção escrita de cem jeitos, eu espero plenamente que os embeddings passem na frente. A comparação entre modelos, especificamente, é a parte disso em que você deve confiar menos.

O teste honesto está bem ali: aponte o professor pra pilha da Flowstate, rotule uma vez e rode a comparação de novo com prompts reais. Aí você descobre se os 92% foram os dados sendo gentis ou a abordagem sendo sólida. Se eu chegar a fazer isso, vira um post à parte.

O encanamento, porém, não liga pros dados que você joga nele. O vetorizador por hashing come 147MB de qualquer jeito. O SVM calibrado colapsa sob concorrência com qualquer entrada. A truncagem decide se o modelo chega a ver a instrução. O limiar de confiança é propriedade do deployment, não do dataset. Essas conclusões sobrevivem à ressalva intactas, e foram a maior parte do trabalho.

O que eu aprendi de verdade

  • Meça antes de projetar a arquitetura. Eu poderia ter passado uma semana discutindo SetFit versus BERT. Uma tarde com um script me mostrou que o modelo era a variável menos interessante do sistema.
  • Benchmark de requisição única mente. O SVM parecia o melhor sozinho e o pior sob carga. Se o seu proxy atende tráfego concorrente, faça benchmark de tráfego concorrente.
  • Valores padrão são armadilhas. Um espaço de hash 2²⁰ pra dezoito classes é 147MB de nada. Entenda o que o botão faz antes de deixar onde o encontrou.
  • Truncar é um modelo. Escolher o que mostrar ao classificador moveu mais a acurácia que qualquer escolha de arquitetura: de 17% a 83% com quatro linhas.
  • Deixe ele se abster. Um limiar de confiança transforma “às vezes errado com toda a confiança” em “quase sempre certo, de vez em quando honesto”. É um botão que vale ter.
  • A base simples é a coisa a ser batida, não a coisa a ser pulada. Comece com 336KB e cara séria. Parta pro transformer de 131MB quando tiver dados reais provando que precisa, e nem um commit antes.

O manual sofisticado não estava errado, exatamente. Só estava otimizando a única decisão que não importava, e mudo sobre as quatro que importavam. A indústria inteira vai te vender um cluster de H100 pra ler um prompt. O que realmente fez diferença foram quatro linhas de fatiar string. A maior parte de um classificador é encanamento, e encanamento não sai bem em foto, o que provavelmente explica por que ninguém escreve o guia dele.

A bancada inteira tem umas 600 linhas de Python. É código do trabalho, então não vou publicar, mas tudo o que importa está neste post: as cinco abordagens, o teste de concorrência, a correção da truncagem, a varredura do limiar. Dada a ressalva, furos são exatamente o que eu quero, então mirem no método.