Pitch: Parei de tentar fazer o LLM escolher a ferramenta certa. Removo as erradas antes de chamar o modelo — e medi o que isso custa.
Mantenho sozinho uma plataforma de atendimento. Em produção são 38 ferramentas distribuídas em 7 subagentes, e há alguns meses bati num gargalo que me custou tempo e dinheiro — e que eu estava diagnosticando errado desde o começo.
O sintoma: ferramentas semanticamente próximas. Uma consulta o quadro de médicos de uma unidade, outra consulta os serviços da rede; uma rastreia talão, outra consulta protocolo. Mesma cara, intenção quase idêntica, descrições que diferem em poucas palavras. O modelo vivia chamando a vizinha errada — preenchia os argumentos de uma ferramenta que nem deveria estar disponível naquele ponto da conversa. E como eu rodo modelos pequenos e baratos (é onde a conta fecha num atendimento de volume), o problema era ainda mais agudo: quanto menor o modelo, mais ele se confunde entre duas opções parecidas.
Minha primeira reação foi a que imagino ser a de quase todo mundo: tratar como problema de autorização. "O modelo não podia ter chamado essa ferramenta aqui." Então eu validava depois. O LLM escolhia, eu checava se a escolha era permitida, e bloqueava. Funciona no sentido de que o estrago não acontece — mas o erro continua acontecendo, eu só o pego depois. E quanto mais ferramentas parecidas eu adicionava, mais frágil ficava.
Em algum momento caiu a ficha de que eu estava resolvendo o problema errado.
Não é bug de permissão. É bug de parsing.
A pergunta que eu fazia era "o modelo pode chamar essa ferramenta aqui?". Pergunta de autorização: booleana, pós-fato.
A pergunta certa é "essa ferramenta faz algum sentido neste contexto?". E se a resposta é não, ela não deveria nem existir aos olhos do modelo naquele momento.
A diferença é estrutural. Na primeira, o erro acontece e eu capturo. Na segunda, o erro não tem como acontecer, porque a opção incoerente nunca foi oferecida. O modelo não escolheu errado entre 38 — escolheu entre as 4 ou 5 que faziam sentido naquela intenção, e acertar entre opções distintas é muito mais fácil do que acertar entre opções quase iguais.
É a lógica de um parser. Um parser não recebe token inválido e depois "autoriza ou nega" — ele só produz o que a gramática permite naquele estado. O espaço inválido é inalcançável por construção, não filtrado por regra. Eu queria isso para seleção de ferramenta: reduzir o espaço antes da chamada, em vez de corrigir a escolha depois.
"Mas isso é só um if antes de montar a lista de ferramentas"
É a objeção óbvia, e com duas ferramentas ela está certa — é literalmente um if, e eu não escreveria biblioteca para isso.
O if colapsa na escala real. São 38 ferramentas em 7 subagentes, com condições compostas (esta exige conta ativa e não bloqueada; aquela exige cadastro mas não suspeita de fraude). O if-soup vira uma árvore que ninguém lê depois de três meses, e cada ferramenta nova é uma chance de esquecer um caso. Além disso, quando o atendimento erra em produção e o cliente reclama, eu quero abrir um registro e ver exatamente por que aquela ferramenta foi ou não oferecida naquele turno — e o if-soup não dá isso de graça.
Então a ideia não é "fazer o que o if faz". É tornar a adjacência declarativa, composável e auditável, e tirar a decisão de autorização das mãos do modelo. Virou uma microbiblioteca em Python, adjacency-agents. A frase que resume: o backend define o cenário, o motor monta a lista permitida para aquele contexto, o LLM escolhe só dentro desse espaço seguro.
A parte que importa: medi.
Argumento bonito não vale nada sem número. Montei um teste adversarial: 14 cenários onde 2+ ferramentas são propositalmente próximas e só uma é correta dado o contexto. Para cada cenário, 30 execuções por modelo, em duas condições idênticas exceto pelo que chega ao LLM — baseline (catálogo completo do subagente no prompt) e adjacency (lista filtrada pelo motor). Três modelos: GPT-5.4 nano (OpenAI), gpt-oss-120b e gpt-oss-20b (Groq). Cerca de 2.500 inferências. Script no repo, com seed por execução para reprodução.
Aviso de honestidade que vou repetir o post inteiro: o resultado não é "melhora em tudo, de graça". É mais interessante que isso.
Confusão genuína de ferramenta (escolheu a ferramenta errada)
| Modelo | Baseline | Adjacency | Δ |
|---|---|---|---|
| GPT-5.4 nano | 0,5% | 0,7% | praticamente igual |
| gpt-oss-120b | 9,7% | 0,7% | −9 pts |
| gpt-oss-20b | 10,7% | 5,0% | −5,7 pts |
O resultado central está na linha do 120b: a lista filtrada praticamente zerou a confusão genuína, de quase 1 em 10 para 7 em 1.000. É exatamente o ganho que eu queria, e está no modelo que dá para pagar em escala.
Repare no nano: ele já estava resolvido (0,5%) e a poda não ajudou — chegou a piorar de leve. Isso não é ruído de medição, é a tese. Modelo forte usa o catálogo inteiro como contexto: ver a ferramenta vizinha ajuda ele a entender a distinção. Quando você poda a lista, tira do modelo bom a informação que ele usava para desambiguar. A poda que salva o modelo fraco pode roubar contexto do modelo forte. Existe um cruzamento de capacidade — abaixo dele a adjacência ganha, acima ela pode atrapalhar. Na prática eu rodo modelo fraco porque é o que a conta permite, então estou sempre do lado onde ajuda. Mas a regra não é universal, e fingir que é seria mentira.
Primeira reviravolta: forçar a ferramenta certa não é de graça
Parte do meu motor não filtra — ele força. Em alguns cenários ele crava a ferramenta (tool_choice selado) sem deixar o modelo escolher. A intuição diz que isso só pode ajudar: você está dando a resposta de bandeja.
O dado diz outra coisa. Quando o motor força, os gpt-oss acertam qual ferramenta 100% das vezes — e emitem argumentos inválidos (erro 400 de schema, chamada inexecutável) em cerca de 1 a cada 5 chamadas:
| Modelo | Chamadas malformadas (auto) | Chamadas malformadas (forçado) |
|---|---|---|
| gpt-oss-120b | 8% | 20% |
| gpt-oss-20b | 3% | 22% |
| GPT-5.4 nano | 0% | 0% |
Forçar a escolha realoca o erro em vez de eliminá-lo. O modelo fraco para de errar qual ferramenta e passa a errar como chamá-la — você o empurrou para um caminho de geração que ele não consegue cumprir. O nano, capaz, não tem o problema em nenhum dos eixos. Lição prática: forçar tool_choice é ótimo em modelo capaz e ativamente nocivo em modelo fraco. Se você roda gpt-oss, ou trata os argumentos (repair/retry) ou desliga o forçamento para esses modelos.
Segunda reviravolta: em modelo fraco, mexer na lista desloca o erro, não o apaga
Rodando isso, o harness pegou um bug meu de produção: uma ferramenta de listar unidades estava sendo injetada em toda lista filtrada, e o modelo fraco a agarrava — 21 vezes ele pegou "listar unidades" no lugar de "consultar médicos da unidade". Corrigi (tirei a injeção indevida) e medi antes/depois.
A correção acertou o alvo: aquele erro foi de 21/30 para 0/30. Mas não foi limpo. Tirar aquela ferramenta mudou o conjunto de candidatas de outra intenção, e o oss20b — frágil — deslocou: passou a confundir "rastrear talão" com "consultar protocolo" 13 vezes, um erro que não existia antes. Troquei uma regressão de 21 por uma de 13.
O agregado do 20b melhorou (a correção removeu mais erro do que introduziu), então a conta fecha positiva — mas como ganho líquido, não ganho limpo. E isso ensina a coisa mais contraintuitiva de tudo: num modelo incapaz, a adjacência não conserta o modelo, ela reduz a superfície onde ele pode falhar — e cada redução corre o risco de concentrar a falha restante num par vizinho. É contenção com efeito-balão. Você empurra o erro para onde dói menos e vigia. O 120b, capaz, ficou estável a essa mesma perturbação — porque a decisão dele vem do conteúdo, não da forma da lista.
O que eu de fato aprendi
Não é "fiz uma lib que melhora seleção de ferramenta X%". É uma regularidade que apareceu em três eixos independentes, todos apontando para a mesma coisa: o valor e o comportamento da adjacência são função da capacidade do modelo, e os efeitos colaterais concentram-se exatamente onde a capacidade falta.
- Filtrar a lista zera a confusão no 120b (−9 pts), não faz nada no nano (que já acertava), e o ganho cresce conforme o modelo enfraquece — até o ponto em que poda demais rouba contexto.
- Forçar a ferramenta realoca o erro de seleção para serialização no modelo fraco (100% de acerto de qual, 20% de chamada inválida de como).
- Mexer na lista desloca erros entre pares vizinhos no modelo fraco; o modelo forte é estável à mesma mudança.
De bônus, o experimento me entregou dois bugs de produção que estavam me custando clientes em silêncio — a injeção indevida e o forçamento nos modelos abertos. Sozinho isso já pagou o exercício.
Onde isso não se aplica
Se suas ferramentas são poucas e distintas, isso é overkill — use o if. Se você roda só modelo de ponta, o ganho some (e a poda pode atrapalhar). Se o seu problema é o modelo raciocinar errado dentro da ferramenta certa, é outro problema. E nada disso substitui validação de argumentos ou as suas regras de negócio — é uma camada antes, não no lugar.
Mas se você tem catálogo grande de ferramentas parecidas, roda modelo pequeno por causa de custo, e está cansado de capturar a escolha errada depois que ela acontece — talvez valha parar de tratar como autorização e começar a tratar como parsing. Só não espere bala de prata: o ganho tem escopo e tem preço, e eu tentei medir os dois honestamente.
Código em github.com/SDWLincoln/adjacency-agents (pip install adjacency-agents), MIT. Crítica é bem-vinda — principalmente de quem discorda, e principalmente sobre o efeito-balão no modelo fraco, que é a parte que eu ainda não sei resolver direito.
Como vocês lidam com seleção de ferramenta em catálogos grandes com modelo pequeno? E alguém já viu esse efeito de "mexeu na lista, o erro migrou de lugar"? Genuinamente curioso.