Um classificador é sobretudo canalização
Nesta página
Na Flowstate, temos andado a ruminar um problema que parece trivial até o tentares resolver: encaminhar cada pedido para o modelo mais barato que o consiga realmente tratar. Para encaminhar um prompt, primeiro tens de perceber o que ele quer, e os prompts chegam como puro caos humano. Gralhas. Um despejo de 400 linhas de código com o pedido verdadeiro enterrado na última. “olá consegues ver isto”. Tens de tirar daí uma intenção limpa, e tens de o fazer em bem menos de um milissegundo, no próprio processo, num CPU, de borla. Um juízo semântico numa pegada de memória mais pequena do que uma JPEG, enquanto todos os artigos sobre o assunto juram a pés juntos que precisas de uma bateria de H100.
Existe um manual padrão da indústria para isto mesmo. Reúnes um conjunto de referência de 200 a 500 prompts etiquetados por humanos. Validas um LLM professor contra ele e não avanças abaixo de um F1 de 0,90. Destilas o professor num modelo aluno pequeno: SetFit, uma variante de BERT, qualquer coisa com embeddings. Desenhas matrizes de confusão. Fazes micro-benchmarks da latência p99. Corres 48 horas de implementação sombra antes de o deixares tocar num único pedido real. É um manual lindo, escrito por investigadores com computação infinita e sem um pager de produção. Também é uma maneira fantástica de te sobre-engenhares até a um beco sem saída.
O trabalho real era estreito: etiquetar cada prompt recebido com um tipo de tarefa (parent → child, como engineering → fix_bug ou data → spreadsheet_edit) para que o proxy o encaminhe para um sítio razoável, em vez de mandar tudo para o modelo mais caro do menu. Só CPU, sem GPU no caminho crítico, muitos pedidos. O que torna treinar o teu próprio classificador sensato em vez de loucura é que a Flowstate já tem uma montanha de pedidos reais. Só que sem etiquetas. É o único bom uso para um LLM professor barato. Não como porteiro a cobrar uma comissão sobre cada pedido. Apontas o professor ao monte uma só vez, deixas que etiquete tudo e deitas fora. Usas a coisa cara exatamente uma vez.
O paradoxo da portagem
A objeção óbvia, que me manteve a olhar para o teto: dispensa por completo o classificador local. Chama um modelo barato e rápido (Flash, Haiku, o que estiver no fundo da tabela de preços), deixa que ele etiquete o prompt e acaba antes do almoço. Seria até mais exato do que qualquer coisa que eu conseguisse treinar.
Mas essa é exatamente a armadilha de que toda a arquitetura existe para escapar. O objetivo de etiquetar um prompt antes de chegar a um modelo é gastar menos: mandar o que é fácil para um sítio barato e só pagar preços de topo quando for mesmo preciso. Se o próprio encaminhador faz uma chamada paga à API em cada pedido, construíste uma portagem à frente da tua portagem. Estás a pagar um bilhete para andar de autocarro só para perguntar ao motorista se é o autocarro certo. Gastaste a poupança antes de a ganhares, acrescentaste uma viagem de ida e volta pela rede a cada pedido e deixaste uma cópia de cada prompt nos registos de outra pessoa. Um classificador a correr no próprio processo, num CPU que já tens, é grátis por pedido. Não barato, grátis, para sempre depois de treinado. Por isso um modelo desajeitado de 92% que não custa nada pode valer mais do que um de 99% que te cobra por token. Aqui queres um segurança rápido e desajeitado, não um filósofo lento e caro.
O problema é a palavra “treinado”. Um modelo destes tem fome. Quer muitos mais exemplos etiquetados do que eu alguma vez etiquetaria à mão por gosto. Por isso o professor ganha o seu salário exatamente uma vez: etiqueta o monte offline, e o classificador corre de graça daí em diante.
Antes de discutir o que quer que fosse, fiz o que costumo fazer: montei uma pequena bancada e medi.
Primeiro, uma confissão
Ainda não apontei o professor ao monte real, por isso, nesta primeira ronda, gerei um substituto. Alguns milhares de prompts sintéticos em dezoito tipos de tarefa, com ambiguidade deliberada para o classificador ter algo em que realmente errar. Prompts como “vê o endpoint avariado”, que honestamente é fix_bug ou debug e não se percebe pelo texto. Cerca de 2 800 para treinar, 600 para testar.
Isto importa, por isso digo-o alto: dados sintéticos etiquetados por aquilo que os gerou são um jogo viciado. Dizem-te se o pipeline funciona e como as abordagens se ordenam entre si. Não te dizem a exatidão no mundo real. E lisonjeiam sem alarido os modelos simples, porque o texto feito a partir de modelos deixa impressões digitais lexicais que um saco de palavras aspira. Guarda isto no bolso de trás. Volto a isto.
Com isto pregado à porta, vamos aos números.
O modelo é a decisão aborrecida
Cinco abordagens, da mais barata à mais sofisticada. Um TF-IDF simples de saco de palavras para regressão logística. O mesmo com n-gramas de caracteres e um SVM. Um vetorizador por hashing para um classificador SGD. E aquele que o manual realmente quer que construas: embeddings de frases (um transformer BGE quantizado) para uma cabeça linear.
| Abordagem | Exat. folha | Exat. pai | p95 (1 pedido) | p95 (sob carga) | Tamanho |
|---|---|---|---|---|---|
| TF-IDF + logreg | 0,923 | 1,000 | 0,18 ms | 0,17 ms | 336 KB |
| TF-IDF + char + SVM | 0,926 | 1,000 | 1,4 ms | 29,6 ms | 3,2 MB |
| Hashing (2²⁰) + SGD | 0,926 | 1,000 | 20,7 ms | 42,4 ms | 147 MB |
| Hashing (2¹⁸) + SGD | 0,924 | 1,000 | 4,1 ms | 6,8 ms | 37 MB |
| Embeddings + logreg | 0,918 | 0,997 | 6,0 ms | 20,8 ms | 131 MB |
A coluna da exatidão é a maçadora. Todas as abordagens ficam a um ponto percentual das outras, todas agrupadas à volta de 92%. O transformer de 131 MB, aquele para cujo documento de design escreverias uma justificação, ficou em último, e foi o único que não acertou na perfeição na etiqueta pai, mais grosseira. O saco de palavras de 336 KB, uma ideia mais velha do que a maioria das frameworks no teu package.json, empatou com ele e saiu 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 estas abordagens realmente diferem é o operacional, e aí a opção mais burra ganha de lavada.
A coluna do pai esconde um resultado mais discreto, parada num 1,000 redondo para quase todos. Toda a confusão vive dentro de um domínio: viz confundido com data_analysis, market_research com web_research. Nunca ninguém confunde uma folha de cálculo com um relatório de bug. Se o proxy só precisa do domínio grosseiro, a coisa mais barata da lista já o tinha resolvido e eu podia ter ido para casa.
Então, se o modelo quase não importa, o que importa?
A canalização
Três coisas ocuparam-me muito mais atenção do que o modelo alguma vez ocupou, e nenhuma aparece em qualquer guia.
Armadilha um: o vetorizador por hashing come 147 megabytes sem avisar
O modelo de hashing com 2²⁰ características registou 20,7 ms e 147 MB de memória residente, para uma tarefa com dezoito classes. É um quarto de segundo de latência e um pequeno ficheiro de vídeo de RAM para decidir se alguém escreveu “arranja isto” ou “porque é que isto está avariado”.
A causa é banal e inteiramente autoinfligida: um espaço de 2²⁰ características a multiplicar por dezoito classes dá uma matriz densa de coeficientes do tamanho de um álbum de fotografias de férias, e cada previsão passa a mão por ela toda. Baixa o hash para 2¹⁸ e fica cinco vezes mais rápido e quatro vezes mais pequeno com a mesma exatidão. O valor por omissão é a armadilha, e ninguém avisa que está carregada.
Armadilha dois: a miragem do “na minha máquina funciona”
O SVM de n-gramas de caracteres parecia lindo isolado: 1,4 ms por pedido, a melhor exatidão da tabela por uma unha negra. Quase o fiz commit e fui buscar um café. Depois apontei-lhe oito workers em simultâneo e o p95 desabou para 29,6 milissegundos. Um colapso de vinte vezes no instante em que ganhou companhia.
Duas razões, ambas invisíveis a um benchmark de pedido único. Para tirares probabilidades de um SVM, calibras, o que treina e corre sem alarido três submodelos por previsão. E todo esse trabalho extra de CPU por pedido amontoa-se direto no bloqueio global do Python, de modo que os pedidos formam educadamente uma fila no edifício em chamas, em vez de passarem a voar. O saco de palavras, que faz quase nada por pedido, nem deu pela carga. 0,17 ms com oito workers, o mesmo que sozinho. Ganhar na mediana é bom. Manter a cabeça fria quando tudo está a arder é o que te interessa às 3 da manhã.
A cola: o truncamento é a diferença entre 17% e 83%
Esta fez-me sentir um idiota. Lancei um teste de esforço ao modelo vencedor: um bloco de Java de 400 linhas com uma instrução crucial pendurada na última linha, “agora corrige o bug que faz o total estar errado”. A forma real de um prompt numa ferramenta de programação.
Teve 16,7%. Perdeu a cabeça e etiquetou quase tudo como refactor, porque para um saco de palavras uma parede de código é um refactor, e a única instrução humana no fim afogou-se no ruído.
A solução não foi um modelo mais sofisticado nem uma matriz de embeddings. Foram quatro linhas que deitam fora tudo menos os últimos 200 caracteres e classificam esses.
De dezasseis por cento para oitenta e três, só por mudar o que o modelo podia ver e não qual era o modelo. (Só o fim ganha quando a instrução está no fim. Início mais fim é a aposta geral mais segura, caso o pedido esteja no topo.) O modelo esteve sempre bem. A canalização é que não.
A outra cola: ensiná-lo a dizer “não sei”
Um etiquetador que erra com confiança é pior do que um que se abstém. Por sorte, o modelo já sabia quando estava a adivinhar. As respostas erradas em prompts ambíguos de uma só palavra (“debug”, ”?”) vinham com confiança rasteira. Por isso varri um limiar de confiança: abaixo dele, encaminha para um saco de tudo em vez de adivinhar.
É uma troca limpa. Se deixares o limiar perto de 0,7, continuas a responder a 90% dos prompts, a exatidão nos respondidos sobe para 95,5% e apanhas 90% do lixo verdadeiramente fora do âmbito. Onde o pões é uma decisão de produto (quantas vezes aceitas encolher os ombros contra quantas vezes aceitas errar) e, outra vez, nada tem a ver com o modelo.
Onde isto é viciado e onde não é
De volta à confissão. Um leitor atento já está a escrever: os teus dados são sintéticos e lexicalmente arrumados, claro que o saco de palavras ganhou. Os embeddings justificam-se em prompts reais e desarrumados, que nunca testaste. Esse leitor tem razão, e eu aceitava a aposta. Em tráfego real, com gralhas, meias-ideias, três línguas na mesma frase, a mesma intenção dita de cem maneiras, espero plenamente que os embeddings passem à frente. A comparação de modelos, em concreto, é a parte disto em que deves confiar menos.
O teste honesto está mesmo ali: apontar o professor ao monte da Flowstate, etiquetá-lo uma vez e repetir a comparação com prompts reais. Aí saberias se os 92% eram os dados a serem simpáticos ou a abordagem a ser sólida. Se lá chegar, dá outro artigo.
A canalização, porém, não quer saber que dados lhe dás. O vetorizador por hashing come 147 MB seja como for. O SVM calibrado colapsa sob concorrência com qualquer entrada. O truncamento decide se o modelo chega sequer a ver a instrução. O limiar de confiança é uma propriedade da implementação, não do conjunto de dados. Estas conclusões sobrevivem intactas à ressalva, e foram a maior parte do trabalho.
O que realmente aprendi
- Mede antes de desenhares a arquitetura. Podia ter passado uma semana a discutir SetFit contra BERT. Uma tarde com um script disse-me que o modelo era a variável menos interessante do sistema.
- Os benchmarks de pedido único mentem. O SVM parecia o melhor sozinho e o pior sob carga. Se o teu proxy serve tráfego concorrente, mede com tráfego concorrente.
- Os valores por omissão são armadilhas. Um espaço de hash de 2²⁰ para dezoito classes são 147 MB de nada. Percebe o que o botão faz antes de o deixares onde o encontraste.
- O truncamento é um modelo. Escolher o que mostrar ao classificador mexeu mais na exatidão do que qualquer escolha de arquitetura: de 17% para 83% com quatro linhas.
- Deixa-o abster-se. Um limiar de confiança transforma “às vezes errado com confiança” em “normalmente certo, ocasionalmente honesto”. É um botão que vale a pena ter.
- A base de referência aborrecida é o que tens de bater, não o que deves saltar. Começa com 336 KB e cara séria. Estica a mão para o transformer de 131 MB quando tiveres dados reais a provar que precisas dele, e nem um commit antes.
O manual sofisticado não estava errado, bem vistas as coisas. Estava só a otimizar a única decisão que não importava, e mudo sobre as quatro que importavam. A indústria inteira vai vender-te um cluster de H100 para leres um prompt. O que realmente fez a diferença foram quatro linhas de fatiar strings. A maior parte de um classificador é canalização, e a canalização não sai bem nas fotografias, que é provavelmente porque ninguém escreve o guia para ela.
A bancada inteira tem umas 600 linhas de Python. É código de trabalho, por isso não o publico, mas tudo o que importa está neste artigo: as cinco abordagens, o teste de concorrência, a correção do truncamento, o varrimento do limiar. Dada a ressalva, buracos são exatamente o que procuro, por isso apontem ao método.