Análise Semântica com IA para Roteamento Inteligente de Agentes
O usuário digita "validar dados do usuário no cadastro" e o sistema precisa decidir qual agente vai responder. Parece trivial quando você tem 3 agentes. Fica impossível quando tem 15. O nome do domínio é "user/registration" mas o input não contém nem "user" nem "registration". Contém "usuário" e "cadastro". Substring matching não resolve. Você precisa de algo que entenda que "cadastro" e "registration" são a mesma coisa, que "validar dados" implica uma operação de "modify", e que o domínio mais provável é "user". A solução que implementei usa o próprio LLM para classificar a intenção e extrair entidades, com um fallback por keywords para quando a latência importa mais que a precisão.
Guia de tópicos:
- O Problema: Roteamento por Substring Não Escala
- Estrutura da Análise Semântica
- Usando LLM para Classificação de Intenção
- Extração de JSON da Resposta do LLM
- Fallback por Keywords: Quando Latência Importa
- Cálculo de Similaridade entre Input e Agente
- Cache de Análises: Evitando Chamadas Repetidas
- Considerações Finais
- Links Indicativos
O Problema: Roteamento por Substring Não Escala
Em artigos anteriores mostrei como um orquestrador seleciona agentes por correspondência de palavras. Funciona quando o input é "criar produto" e o domínio é "products". Não funciona quando o input é "implementar validação de CPF no formulário de registro" e o domínio é "user/registration". Não tem nenhuma palavra em comum.
O problema se agrava com inputs em português. Os domínios geralmente são nomeados em inglês (convenção de código), mas o usuário escreve em português. "Pagamento" precisa rotear para "payment". "Carrinho de compras" precisa rotear para "order/cart". "Autenticação por dois fatores" precisa rotear para "user/authentication". Nenhum desses casos é resolvido por substring matching.
A solução ingênua seria manter um dicionário de sinônimos. Mas isso não escala: cada projeto tem domínios diferentes, cada idioma tem variações, e manter esse dicionário atualizado é trabalho manual que ninguém vai fazer.
Estrutura da Análise Semântica
A análise retorna um struct com cinco campos que capturam tudo que o orquestrador precisa para tomar uma decisão:
type SemanticResult struct {
Intent string `json:"intent"`
Entities []string `json:"entities"`
Domains []string `json:"domains"`
Complexity string `json:"complexity"`
Keywords map[string]float64 `json:"keywords"`
}
Intent é a ação principal: create, modify, query, debug ou integrate. Entities são os substantivos importantes extraídos do input. Domains são os domínios técnicos prováveis. Complexity classifica se é simple (1 domínio), medium (2-3) ou complex (4+). Keywords são palavras-chave com peso de relevância de 0 a 1.
Com esses cinco campos, o orquestrador consegue decidir: qual agente usar (domains + entities), se precisa coordenar múltiplos agentes (complexity), e que tipo de operação o agente vai executar (intent).
Usando LLM para Classificação de Intenção
A ideia é usar o próprio serviço de LLM para analisar o input antes de processá-lo. Sim, é uma chamada extra. Sim, adiciona latência. Mas a alternativa é rotear errado e o usuário ter que repetir o comando, o que é pior.
func (s *SemanticAnalyzer) AnalyzeIntent(input string) (*SemanticResult, error) {
if cached, exists := s.cache[input]; exists {
return &cached, nil
}
prompt := fmt.Sprintf(`
Analise semanticamente esta requisição e retorne APENAS um JSON válido:
Requisição: "%s"
Formato esperado:
{
"intent": "create|modify|query|debug|integrate",
"entities": ["entidade1", "entidade2"],
"domains": ["dominio_provavel1", "dominio_provavel2"],
"complexity": "simple|medium|complex",
"keywords": {"palavra1": 0.9, "palavra2": 0.7}
}
`, input)
output, err := s.agentPool.Execute("semantic_analyzer", prompt)
if err != nil {
return nil, err
}
jsonStr := s.extractJSON(output)
if jsonStr == "" {
return s.fallbackAnalysis(input), nil
}
var result SemanticResult
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
return s.fallbackAnalysis(input), nil
}
s.cache[input] = result
return &result, nil
}
O prompt é deliberadamente restritivo: pede APENAS JSON, define o formato exato, e lista os valores possíveis para cada campo. Isso minimiza a chance do LLM inventar campos extras ou retornar texto livre em vez de JSON.
Extração de JSON da Resposta do LLM
LLMs nem sempre retornam JSON limpo. Às vezes vem com markdown (```json ... ```), às vezes com texto explicativo antes ou depois, às vezes com comentários. O extrator precisa ser robusto:
func (s *SemanticAnalyzer) extractJSON(text string) string {
start := strings.Index(text, "{")
if start == -1 {
return ""
}
end := strings.LastIndex(text, "}")
if end == -1 || end <= start {
return ""
}
return text[start : end+1]
}
A estratégia é simples: encontra o primeiro { e o último } e extrai tudo entre eles. Funciona para 95% dos casos. Falha quando o LLM retorna múltiplos objetos JSON ou quando tem {} dentro de texto explicativo. Para esses casos, o fallback por keywords entra em ação.
Uma abordagem mais robusta seria usar um parser JSON que tenta múltiplas posições, ou pedir ao LLM para retornar o JSON entre delimitadores específicos (como <json>...</json>). Mas na prática, o approach simples funciona bem o suficiente e não adiciona complexidade.
Fallback por Keywords: Quando Latência Importa
Nem toda requisição precisa de análise semântica com LLM. Se o input é "criar usuário" e existe um domínio "user", substring matching resolve em microsegundos. A análise com LLM levaria 3 segundos para chegar na mesma conclusão. O fallback serve tanto como backup (quando o LLM falha) quanto como fast path (quando a resposta é óbvia).
func (s *SemanticAnalyzer) fallbackAnalysis(input string) *SemanticResult {
input = strings.ToLower(input)
result := &SemanticResult{
Intent: "create",
Keywords: make(map[string]float64),
}
// Detecta intent por palavras-chave
if strings.Contains(input, "criar") || strings.Contains(input, "novo") {
result.Intent = "create"
} else if strings.Contains(input, "modificar") || strings.Contains(input, "alterar") {
result.Intent = "modify"
} else if strings.Contains(input, "erro") || strings.Contains(input, "bug") {
result.Intent = "debug"
} else if strings.Contains(input, "integrar") || strings.Contains(input, "conectar") {
result.Intent = "integrate"
}
// Detecta entidades comuns
entities := map[string]string{
"usuário": "user", "usuario": "user",
"produto": "product", "item": "product",
"pedido": "order", "compra": "order",
"pagamento": "payment", "checkout": "payment",
}
for word, entity := range entities {
if strings.Contains(input, word) {
result.Entities = append(result.Entities, entity)
result.Keywords[word] = 0.8
}
}
return result
}
O dicionário de entidades aqui é pequeno e genérico de propósito. Ele cobre os domínios mais comuns (user, product, order, payment) que aparecem em praticamente qualquer aplicação web. Para domínios específicos do projeto, a análise com LLM é necessária.
Cálculo de Similaridade entre Input e Agente
Com o resultado da análise semântica, o cálculo de similaridade entre o input e cada agente disponível combina três sinais:
func (s *SemanticAnalyzer) CalculateSimilarity(input string, agentDomain string) float64 {
analysis, err := s.AnalyzeIntent(input)
if err != nil {
return s.basicSimilarity(input, agentDomain)
}
score := 0.0
// Peso 0.4: domínios detectados
for _, domain := range analysis.Domains {
if strings.Contains(agentDomain, domain) {
score += 0.4
}
}
// Peso 0.3: entidades
for _, entity := range analysis.Entities {
if strings.Contains(agentDomain, entity) {
score += 0.3
}
}
// Peso 0.3: keywords com relevância
for keyword, weight := range analysis.Keywords {
if strings.Contains(agentDomain, keyword) {
score += weight * 0.3
}
}
return score
}
Os pesos foram calibrados empiricamente. Domínios têm peso maior (0.4) porque são o sinal mais forte: se o LLM disse que o domínio provável é "payment", e existe um agente "payment/gateway", a correspondência é quase certa. Entidades e keywords dividem o resto (0.3 cada) como sinais complementares.
O score final é usado pelo orquestrador para selecionar o agente com maior pontuação. Se nenhum agente passa de um threshold mínimo (por exemplo, 0.3), o sistema assume que precisa de coordenação multi-agente em vez de delegação para um único agente.
Cache de Análises: Evitando Chamadas Repetidas
O cache é um mapa simples em memória indexado pelo input exato:
type SemanticAnalyzer struct {
cache map[string]SemanticResult
agentPool *pool.AgentPool
}
Se o usuário manda o mesmo comando duas vezes — comum quando algo deu errado na primeira tentativa — a segunda execução pula a análise semântica completamente. O cache não tem TTL porque a análise de um input fixo não muda com o tempo: "criar usuário" sempre vai ter intent "create" e entity "user".
A limitação óbvia é que o cache não sobrevive ao restart do processo. Para um CLI interativo isso é aceitável: a sessão raramente dura mais que alguns minutos, e o cache acumula no máximo dezenas de entradas. Para um serviço long-running, seria necessário persistir em disco ou usar um TTL para evitar crescimento ilimitado de memória.
Considerações Finais
Usar LLM para rotear chamadas a LLM parece recursivo e ineficiente. E é, se você fizer para toda requisição. A chave é usar como último recurso: tenta substring matching primeiro (microsegundos), se não resolve tenta keywords (microsegundos), e só se nenhum dos dois funciona vai para análise semântica (segundos). Na prática, 80% das requisições são resolvidas sem a chamada extra ao LLM.
O que importa levar deste artigo: classificação de intenção não precisa de um modelo treinado especificamente para isso. O LLM generalista já sabe classificar texto em categorias se você der o prompt certo. O custo é latência, não complexidade de implementação. E com cache, esse custo é pago uma única vez por input único.
A evolução natural seria treinar um classificador local — um modelo pequeno tipo DistilBERT — com os dados do cache acumulado. Depois de 1000 análises cacheadas, você tem um dataset de treinamento gratuito. Mas isso é otimização prematura até que a latência da análise semântica se torne um gargalo real.
Links indicativos: