Classificação de intenção com LLM: como o agente decide qual rota seguir
"Ele decide sozinho qual fluxo usar." Ótimo. Agora me mostra o log de por que ele escolheu o fluxo errado ontem às 14h.
Silêncio.
É sempre aqui que a conversa sobre agente autônomo trava. O roteamento é a decisão mais barata de implementar e a mais cara de depurar, porque ela acontece antes de qualquer coisa interessante aparecer no trace. O usuário pediu reembolso, o agente entrou no fluxo de suporte técnico, gastou seis tool calls e você só descobriu porque o cliente reclamou. O erro não foi do modelo grande. Foi de uma escolha de uma palavra feita três segundos antes.
Neste post a gente abre esse pedaço: como fazer classificação de intenção com LLM sem virar loteria, quando o roteador deve ser um match burro e quando deve ser um modelo, o que fazer quando nenhuma rota serve, e como deixar cada decisão de rota auditável para você conseguir responder aquela pergunta das 14h.
TL;DR
- O que é: a camada que classifica a intenção do usuário e decide para qual fluxo especializado mandar, antes do agente começar a trabalhar.
- Stack/Modelos: Claude (structured outputs), embeddings para roteamento semântico,
semantic-routerou LangGraph para orquestrar, OpenTelemetry para o trace. - Custo/Acesso: roteador determinístico custa zero, embedding custa centavos, roteador por LLM custa uma chamada extra por request. A escolha muda a conta no fim do mês.
- O que você leva: um roteador com schema fechado, política de fallback explícita, atributos de trace para auditar e um jeito de medir acerto de rota sem rotular dez mil conversas na mão.
Roteador determinístico vs. roteador por LLM
A Anthropic descreve roteamento como um dos padrões básicos de workflow: "routing classifies an input and directs it to a specialized followup task", e ele funciona bem "where there are distinct categories that are better handled separately, and where classification can be handled accurately, either by an LLM or a more traditional classification model/algorithm" (Building effective agents).
Repare no final da frase. Ou por LLM, ou por um algoritmo tradicional. A documentação oficial coloca as duas coisas no mesmo nível, e a maior parte dos times ignora a segunda metade.
O roteador determinístico é aquele que você escreve com regra explícita: o request veio do endpoint /billing, então rota billing. O usuário clicou no botão "cancelar assinatura", então rota cancelamento. O payload tem order_id, então rota pedido. Latência zero, custo zero, cem por cento auditável, e você consegue escrever um teste unitário para cada caminho.
Parece pouco sofisticado. É exatamente por isso que funciona.
A regra prática que eu uso: se a informação já está estruturada na borda, não pergunta para o modelo. Botão, rota HTTP, campo de formulário, tipo de anexo, canal de origem — isso é sinal determinístico. Jogar isso num prompt para o LLM "decidir" é pagar latência e incerteza para reconstruir uma informação que você já tinha.
O roteador por LLM entra quando a entrada é texto livre e a intenção só existe na semântica. "Comprei mês passado e até hoje não chegou" não tem order_id, não tem botão, não tem endpoint. Tem intenção. Aí sim você precisa de classificação.
E existe o meio-termo que quase ninguém usa: o roteador por embedding. Você embeda a query, compara com exemplos de cada rota e pega o mais próximo. É o que a lib semantic-router faz — em vez de esperar a geração lenta do LLM para decidir tool use, ela decide no espaço vetorial e devolve um RouteChoice com nome da rota e score de similaridade. Um levantamento de padrões de roteamento em produção coloca o roteador por embedding em 16–100ms contra 1–5 segundos do roteador por LLM.
A conta importa em escala. O RouteLLM, da LMSYS, treinou roteadores para escolher entre modelo forte e modelo fraco e reportou até 85% de redução de custo no MT Bench mantendo 95% da performance do GPT-4 — o melhor roteador atingiu esse patamar usando só 26% de chamadas ao modelo forte. Roteamento não é enfeite de arquitetura. É a alavanca de custo mais direta que existe depois de cache.
Roteamento é justamente um dos módulos que a gente destrincha ao vivo no AI Engineering Lab 3ª Edição, nos dias 19 e 20 de setembro: dois dias montando a arquitetura inteira do agente em produção, do tool calling ao tracing e ao custo. Se este post te fez olhar torto para o seu roteador, é lá que a gente coloca a mão nele.
O critério de decisão, sem "depende"
| Sinal de entrada | Roteador certo |
|---|---|
| Endpoint, botão, campo de formulário, canal | Determinístico |
| Texto livre, poucas rotas (2–8), bem separadas | Embedding |
| Texto livre, muitas rotas, ou rotas que se sobrepõem | LLM com structured output |
Texto livre + precisa extrair parâmetro junto (order_id, data) |
LLM com structured output |
Na prática você combina: uma cascata que tenta o barato primeiro e só escala para o caro quando o barato não tem confiança. Guarde essa ideia, ela volta na seção de fallback.
Classificação de intenção com LLM que aguenta ambiguidade
Classificação de intenção com LLM em produção (intent classification, no nome em inglês que você vai encontrar nas libs) não é "pergunta qual a categoria e faz if na string". Isso quebra na primeira vez que o modelo responde "Reembolso" com maiúscula, ou "reembolso/troca", ou uma frase educada explicando a escolha.
Fecha o schema. Com structured outputs o modelo é obrigado por decodificação restrita a devolver JSON que bate com o schema — não é "peça bonitinho e reze", é garantia de formato.
import json
import anthropic
client = anthropic.Anthropic()
ROTAS = ["reembolso", "status_pedido", "suporte_tecnico", "duvida_produto", "outro"]
SCHEMA = {
"type": "object",
"properties": {
"rota": {"type": "string", "enum": ROTAS},
"confianca": {"type": "number", "minimum": 0, "maximum": 1},
"segunda_opcao": {"type": "string", "enum": ROTAS},
"evidencia": {
"type": "string",
"description": "Trecho literal da mensagem que sustenta a rota escolhida.",
},
},
"required": ["rota", "confianca", "segunda_opcao", "evidencia"],
"additionalProperties": False,
}
def classificar(mensagem: str) -> dict:
resposta = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=300,
system=(
"Você classifica a intenção de mensagens de clientes de e-commerce.\n"
"Escolha UMA rota. Se a mensagem tiver duas intenções, escolha a que "
"bloqueia o cliente agora e registre a outra em segunda_opcao.\n"
"Se nenhuma rota descrever a mensagem, use 'outro' com confianca baixa. "
"Não force uma rota plausível."
),
messages=[{"role": "user", "content": mensagem}],
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
)
return json.loads(resposta.content[0].text)
Quatro decisões de design nesse schema, e nenhuma é enfeite:
enum fechado. O modelo não inventa rota nova. Se a lista de rotas mudar, o schema muda junto e o deploy quebra alto em vez de quebrar baixo.
confianca. Sem isso você não tem como implementar fallback nem amostragem para eval. É o número que separa "roteia direto" de "escala". Cuidado: confiança auto-reportada por LLM é sinal, não verdade — ela ordena bem, mas calibra mal. Serve para você escolher o que revisar, não para dizer "92% de chance de estar certo".
segunda_opcao. Mensagem real tem duas intenções o tempo todo: "não chegou e eu quero meu dinheiro de volta" é status_pedido e reembolso. Forçar escolha única sem registrar a alternativa joga metade do pedido do cliente no lixo. Com a segunda opção registrada, o fluxo de reembolso pode abrir o status do pedido junto, e o seu eval consegue medir quantas vezes a resposta certa estava em segundo lugar.
evidencia. Um trecho literal da mensagem. Esse campo é o que transforma "o agente decidiu" em "o agente decidiu por causa disto". É a diferença entre auditar e adivinhar.
Repare que eu usei Haiku, não o modelo grande. Roteador é classificação de texto curto com espaço de saída minúsculo — é o caso de uso mais barato que existe. Usar o modelo caro no roteador é queimar dinheiro na porta de entrada; se você quer ver os outros vazamentos do mesmo tipo, já mapeamos os mais comuns aqui.
O detalhe que faz mais diferença que o modelo
A qualidade do roteador depende menos do modelo e mais da definição das rotas. Rota com nome bonito e sem descrição é ambiguidade garantida.
Escreva cada rota como um contrato de três linhas: o que entra, o que explicitamente não entra, e um exemplo real de fronteira.
suporte_tecnico
ENTRA: produto recebido que não liga, não conecta, apresenta defeito de funcionamento.
NÃO ENTRA: produto que não chegou (-> status_pedido).
NÃO ENTRA: cliente quer devolver produto que funciona (-> reembolso).
FRONTEIRA: "chegou quebrado" -> suporte_tecnico, porque a ação é troca/garantia.
A linha NÃO ENTRA é a que mais move o ponteiro. Você está resolvendo colisão entre rotas, e colisão entre rotas é a causa raiz da maior parte dos erros de roteamento — não falta de inteligência do modelo.
E tem um limite que vale conhecer: conforme o número de opções cresce, a acurácia de seleção despenca. O levantamento citado acima mostra queda de ~94% de acerto com 50 tools para 20% com 417, por degradação de atenção em contexto longo. Se o seu agente tem trinta rotas planas, o problema não é o prompt. É a topologia. Agrupe em dois níveis: um roteador de domínio com 4–6 saídas, e roteadores de segundo nível dentro de cada domínio.
O fallback: o que fazer quando nenhuma rota serve
Aqui mora o bug que ninguém reproduz em staging.
Todo roteador é uma função total: ele sempre devolve alguma coisa. Se você não desenhou o caminho "nenhuma rota serve", o modelo vai preencher esse vazio com a rota mais parecida — e uma rota parecida é pior que rota nenhuma, porque ela executa. Ela abre chamado, dispara e-mail, consulta API, gasta token e entrega uma resposta confiante sobre a pergunta errada.
Fallback é decisão de produto, não de código. Três políticas, e você precisa escolher explicitamente:
1. Abstenção com pergunta de desambiguação. Confiança baixa, duas rotas empatadas: devolve uma pergunta curta ao usuário. É a melhor opção em chat e a pior em processamento assíncrono, onde não tem ninguém para responder.
2. Rota genérica com capacidade reduzida. Manda para um fluxo que só sabe responder com base em documentação e escalar. Não executa ação com efeito colateral. É o default seguro.
3. Escalada humana. Fila, ticket, atendente. Caro, e por isso mesmo precisa de threshold bem calibrado.
O padrão que junta tudo é a cascata: filtro determinístico primeiro, roteador por embedding depois, LLM como catch-all para o que sobrou de genuinamente ambíguo. A referência de produção sugere rotear automático acima de 0.8 de confiança, marcar 0.5–0.8 para revisão e escalar abaixo de 0.5. Trate esses números como ponto de partida, não como verdade: eles dependem da sua distribuição de rotas e do custo de errar cada uma.
def rotear(mensagem: str, contexto: dict) -> dict:
# 1. Determinístico: sinal estruturado ganha sempre.
if contexto.get("origem") == "botao_cancelar":
return {"rota": "cancelamento", "metodo": "deterministico", "confianca": 1.0}
# 2. Embedding: barato, resolve o caso comum.
rota, score = router_embedding(mensagem)
if score >= 0.85:
return {"rota": rota, "metodo": "embedding", "confianca": score}
# 3. LLM: só o que sobrou.
decisao = classificar(mensagem)
decisao["metodo"] = "llm"
# 4. Fallback explícito. Sem isso, o passo 3 sempre "resolve".
if decisao["confianca"] < 0.5 or decisao["rota"] == "outro":
decisao["rota"] = "desambiguacao"
decisao["metodo"] = "fallback"
return decisao
Um caso particular de roteamento que já cobrimos por aqui é a decisão de buscar ou não buscar: no agentic RAG, o agente decide se recupera contexto, o que recupera e quantas vezes. É o mesmo mecanismo desta seção com duas rotas em vez de seis — e é um bom lugar para praticar threshold antes de aplicar num roteador de dez saídas.
Vale registrar o que o fallback não é: ele não substitui validação de entrada e saída. Roteador escolhe caminho; quem barra conteúdo malicioso ou resposta fora de política é a camada de guardrails. São controles diferentes, em pontos diferentes do pipeline.
Tutorial te mostra o caminho — no Clã você constrói junto. Aula ao vivo toda semana, projetos reais de Engenharia de IA, ao lado de quem já está em produção.
Entrar no ClãDeixando a decisão auditável
Volta para a pergunta do começo: por que ele escolheu o fluxo errado ontem às 14h?
Você só responde isso se, no momento da decisão, tiver gravado o suficiente. E "o suficiente" não é o texto da resposta final — é o estado da decisão. Cinco campos, no span do roteador:
routing.method— determinístico, embedding ou llm.routing.route— a rota escolhida.routing.confidence— o score.routing.runner_up— a segunda opção e o score dela. É esse campo que revela empate.routing.version— versão do conjunto de rotas e do prompt do classificador.
Esse último é o mais esquecido e o mais valioso. Sem versão, quando alguém reclamar de um erro de três semanas atrás você não vai saber com qual definição de rota aquela decisão foi tomada. E como definição de rota muda toda semana, você fica auditando um sistema que não existe mais.
Para não inventar nomenclatura própria, ancore nas convenções semânticas de GenAI do OpenTelemetry, que padronizam atributos sob o namespace gen_ai.* e já preveem spans de invocação de agente e de execução de tool. Vale saber que essas convenções ainda são pré-estáveis: em junho de 2026 os atributos gen_ai.* foram movidos para um repositório dedicado e os nomes ainda podem mudar entre versões. Ou seja: ancore, mas isole atrás de uma função sua de instrumentação, para não sair caçando string pelo código na próxima release.
with tracer.start_as_current_span("route_intent") as span:
decisao = rotear(mensagem, contexto)
span.set_attribute("routing.method", decisao["metodo"])
span.set_attribute("routing.route", decisao["rota"])
span.set_attribute("routing.confidence", decisao["confianca"])
span.set_attribute("routing.runner_up", decisao.get("segunda_opcao", ""))
span.set_attribute("routing.version", ROUTES_VERSION)
span.set_attribute("routing.evidence", decisao.get("evidencia", "")[:200])
Duas ressalvas de quem já se queimou. Primeira: routing.evidence guarda trecho da mensagem do usuário, então isso é dado pessoal entrando no seu backend de observabilidade — trate com a mesma régua do resto (retenção, mascaramento de PII, controle de acesso). Segunda: se o roteador é síncrono no caminho do request, exporte o span de forma assíncrona. Auditoria não pode custar latência para o usuário.
O roteador é uma das seis camadas que separam um harness de POC de um que aguenta produção — a anatomia completa está aqui, e vale ler as outras cinco com a mesma lente de auditabilidade.
Medindo acerto de rota sem rotular tudo na mão
"Beleza, mas para saber se o roteador acerta eu preciso rotular dez mil conversas."
Não precisa. Você precisa rotular as certas.
Rotular aleatoriamente é o pior uso possível do seu tempo, porque a maior parte do tráfego é fácil e o roteador acerta com folga. Você gasta três dias confirmando o óbvio. A estratégia que funciona é amostragem estratificada por sinal de erro, em quatro baldes:
Balde 1 — confiança baixa. O percentil inferior de confianca. É onde os erros se concentram. A literatura de quantificação de incerteza usa exatamente isso: selecionar pelo menor percentil de confiança recupera desproporcionalmente os exemplos mal classificados, o que faz cada rótulo que você escreve valer muito mais.
Balde 2 — empate. Casos onde confianca e o score da segunda_opcao estão perto. Empate é ambiguidade real, e ambiguidade real normalmente significa que suas rotas colidem — o conserto costuma ser na definição, não no modelo.
Balde 3 — discordância entre métodos. Roda o roteador por embedding e o por LLM no mesmo tráfego, em shadow, e rotula só onde eles divergem. Discordância entre roteadores é o indicador mais barato de oportunidade de melhoria que existe, e não custa nada além de uma chamada em background.
Balde 4 — sinais implícitos de produção. Esse é de graça e quase ninguém coleta: transferência de fluxo no meio da conversa, usuário reformulando a mesma pergunta, abandono na primeira resposta, escalada para humano. Todo evento desses é um voto de "rota errada" sem nenhum anotador envolvido.
Com uns 200 casos rotulados nesses quatro baldes você já monta uma matriz de confusão útil. E olhe para ela por classe, não no agregado — roteador com 92% de acurácia média pode estar com 40% numa rota de baixo volume e alto impacto, tipo fraude. O agregado esconde exatamente o que dói.
Quando quiser automatizar parte do julgamento, o padrão é LLM-as-a-judge com rubrica e calibração contra os seus rótulos humanos — não confie no juiz sem medir o juiz. Montamos esse pipeline passo a passo aqui, e ele encaixa direto no roteador porque a saída é categórica e curta, que é o caso mais fácil de julgar.
FAQ rápido
Devo usar o mesmo modelo do agente no roteador? Não. Roteador é classificação de texto curto com saída minúscula — Haiku, um encoder fine-tunado ou embedding resolvem. Reserve o modelo caro para o fluxo especializado, que é onde a capacidade realmente aparece. Modelo caro no roteador é custo fixo por request sem ganho proporcional.
Roteamento por intenção não é a mesma coisa que tool calling? Não, e confundir os dois causa aquela degradação de acurácia com muitas opções. Tool calling escolhe uma ação dentro de um fluxo; roteamento escolhe qual fluxo (com qual prompt, quais tools e quais permissões) vai rodar. Roteamento reduz o número de tools que o agente enxerga por vez, e é justamente isso que segura a acurácia.
Preciso de framework para isso?
Não, mas ajuda a não reinventar o grafo. No LangGraph o padrão é schema com Literal via with_structured_output() e add_conditional_edges() ligando a decisão aos nós especializados (docs). Dá para fazer com um match e três funções também. Framework aqui é conveniência, não requisito.
Como versiono mudança de rota sem quebrar histórico?
Nunca renomeie nem recicle o identificador de uma rota. Rota antiga vira deprecada, rota nova ganha id novo, e routing.version sobe. Assim o trace de três semanas atrás continua interpretável e o seu eval não mistura duas definições diferentes no mesmo número.
Conclusão
Roteamento parece a parte trivial do agente e é a que mais devolve resultado por linha de código: corta custo escolhendo o modelo certo, corta erro reduzindo o espaço de decisão de cada fluxo, e corta tempo de debug quando você grava o porquê da escolha.
Três coisas para levar: sinal estruturado não vai para o LLM, fallback é decisão de produto e precisa existir antes do bug aparecer, e decisão sem routing.version no trace é decisão que você não consegue auditar depois.
O próximo passo natural é olhar o roteador dentro do desenho completo — ele é uma camada, não um sistema. O blueprint de arquitetura de agentes em seis camadas mostra onde ele encaixa entre contexto, tools, guardrails e observabilidade.
E aí, você consegue responder o que aconteceu ontem às 14h?
{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.
Conteúdo é o que não falta. Falta quem desembaralhe: o que importa agora é como implementar do jeito certo. No Clã você tem isso ao vivo, toda semana, com quem já filtrou o ruído.
Entrar no Clã