DEV Community

Josimar Zimermann
Josimar Zimermann

Posted on Edited on

Encontrando o equilíbrio entre agentes de IA e código determinístico

Introdução

Provavelmente você já viu uma imagem similar a esta:

Advanced programming techniques

Obviamente isso é propositalmente exagerado. Mas nos ajuda a entender um padrão que tem se tornado cada vez mais comum nos dias de hoje: o uso indiscriminado de agentes de IA para solução de problemas triviais de computação.

Eu não estou aqui para falar que você não deve usar IA. Afinal, eu estou inserido em uma equipe guiada pelo conceito AI First, isto é, a IA é o motor central de tudo que criamos. Tampouco serei hipócrita, afinal eu já queimei algumas centenas de tokens para tarefas simples, ou escolhi modelos de última geração para escrever uma mensagem de commit.

O objetivo deste texto é mostrar que podemos encontrar o equilíbrio entre o uso de agentes de IA e código determinístico. E faremos isso com um caso real, uma PoC que construímos e validamos junto ao cliente.

Nota de confidencialidade: para proteger o negócio do cliente, o segmento de mercado, os produtos e todos os números de catálogo e de conversa deste artigo foram alterados para um domínio fictício. A arquitetura, os mecanismos e as lições, porém, são exatamente os do projeto real.

O cenário

O cliente em questão possui um e-commerce para venda de baterias automotivas. Mas um problema tem incomodado este cliente: a alta taxa de devolução de produtos. Bateria é um produto que o consumidor costuma comprar com urgência e devolver por incompatibilidade, como quando a caixa não cabe no suporte, o polo fica do lado errado ou a bateria não atende ao sistema start-stop do veículo. Isso gera uma experiência ruim para o consumidor final e custos operacionais para a loja. Ou seja, é uma dor latente que precisava de solução.

A ideia para resolver esse problema é muito simples e bastante difundida em e-commerces: criar um chatbot agêntico para guiar o usuário no processo de compra da bateria mais adequada.

Os critérios de seleção

Cada mercado possui suas próprias características. Vender bateria automotiva não é igual a vender uma camisa, por exemplo. Uma camisa já possui uma tabela de tamanhos pré-definidos e o cliente apenas escolhe o tamanho mais adequado. Para recomendar a bateria correta, o chatbot precisa conhecer uma série de variáveis:

  1. Montadora: qual a marca do veículo?
  2. Modelo: qual o modelo do veículo?
  3. Ano: qual o ano de fabricação?
  4. Motorização: 1.0, 1.3 turbo, 2.0…?
  5. Start-stop: o veículo tem sistema start-stop (o motor desliga sozinho nas paradas)?

Nós chamaremos essas variáveis de gates. Cada gate elimina um conjunto de baterias incompatíveis. Funciona como uma espécie de funil.

Funil
(Sim, essa imagem foi gerada por IA)

A primeira ideia: por que não jogar tudo num RAG?

O RAG é uma técnica bastante difundida para responder perguntas com base em um conjunto de documentos. Os documentos são indexados em um banco vetorial e o LLM responde usando os trechos recuperados. No nosso caso, isso significaria indexar os datasheets dos fabricantes e deixar o LLM responder sobre a compatibilidade das baterias. Essa foi a primeira alternativa que avaliamos, e acabou descartada por três motivos:

  1. Compatibilidade é uma decisão conjunta sobre atributos estruturados. A bateria recomendada precisa atender a todos os critérios ao mesmo tempo: montadora, modelo, ano, motorização e presença do sistema start-stop. A busca por similaridade devolve os trechos mais parecidos com a pergunta, mas não garante que eles cubram todas as restrições, nem que sejam do produto certo.
  2. As especificações estão descritas em texto livre. Informações como "indicada para motores 1.0 a 1.6, exceto versões turbo" aparecem no meio das características técnicas do produto. Além disso, o catálogo possui muitos produtos parecidos, da mesma família e com amperagens diferentes. Um modelo probabilístico pode facilmente misturar as especificações de dois produtos semelhantes. No nosso caso, esse tipo de erro significa uma devolução.
  3. O critério de aceite do projeto era rigoroso: nenhuma recomendação poderia contradizer a fonte oficial. Esse tipo de garantia não pode ser obtido com um processo probabilístico, por melhor que seja a taxa de acerto. Ela precisa vir de código determinístico, que pode ser auditado e testado. Quando cada erro gera o custo de uma devolução, acertar na maioria das vezes não é suficiente.

Percebemos que estávamos misturando duas tarefas diferentes, recuperar informação e decidir compatibilidade. RAG é ótimo na primeira e inadequado para a segunda. Então o RAG não foi descartado, foi realocado. Ele reaparece mais adiante, com outra função.

A arquitetura

Para construir a PoC, desenhamos uma arquitetura fundamentada na AWS. O diagrama abaixo representa essa arquitetura com seus principais componentes.

Diagrama da arquitetura AWS

  • CloudFront: hospeda o código necessário para renderizar o chatbot na página do e-commerce.
  • API Gateway: responsável pela intermediação entre frontend e backend.
  • Lambda: hospeda o código do backend, responsável por orquestrar as chamadas ao Bedrock, aplicar os gates e filtrar os produtos compatíveis.
  • DynamoDB: armazena o estado da conversa (controle de sessões, com expiração automática).
  • Bedrock: aciona o LLM (Claude Sonnet 4.6) que extrai os gates e conduz a conversa, invocado pelo backend; o system prompt do agente fica versionado no Bedrock Prompt Management, o que permite ajustar o comportamento do agente (e até rodar testes A/B de ordem de perguntas) sem deploy.
  • S3: armazena a tabela de compatibilidade, versionada, e as especificações dos produtos.
  • S3 Vectors: guarda o índice vetorial dos manuais e datasheets, usado pela camada de enriquecimento (RAG) que veremos adiante.

Nesta demonstração, tomei a decisão deliberada de não usar um banco de dados ou API externa para consultar a compatibilidade dos produtos. A tabela de compatibilidade é um simples documento JSON, curado a partir dos datasheets oficiais dos fabricantes e versionado no S3. Obviamente que a primeira reação é achar que essa escolha não seria apropriada em um ambiente de produção. Mas ela não é um atalho de PoC. A fonte da verdade precisa ser estável, auditável e verificável linha a linha, e uma API viva de catálogo não oferece nada disso. Em produção, o que mudaria seria o storage (um banco de dados no lugar do arquivo), não o padrão. O catálogo vivo do e-commerce entra apenas para enriquecer a recomendação com preço, foto e estoque.

O processo passo a passo

Para o nosso teste de mesa, vamos considerar que o usuário enviou a seguinte mensagem para o chatbot: "Preciso de uma bateria para o meu Ford Mondeo". A mensagem é direcionada para o Bedrock, que extrai os gates. Vamos usar uma tabela para rastrear os gates durante o processo:

Gate Valor Encontrados
Montadora Ford 38
Modelo Mondeo 12
Ano
Motorização
Start-stop

Repare em dois detalhes. Primeiro, uma única mensagem preencheu dois gates. A extração é exaustiva e aceita qualquer ordem; a sequência do funil é uma preferência de condução, não uma camisa de força. Isso evita que o usuário tenha que repetir informações que já forneceu. Segundo, a coluna Encontrados mostra o total de produtos que sobram na tabela de compatibilidade após aplicar os gates acumulados. A consulta é cumulativa. Só com a montadora seriam 38 baterias; aplicando também o modelo, sobram 12. A cada novo gate extraído, o backend refaz essa consulta.

Toda resposta do agente configurado no Bedrock é um documento JSON que respeita o seguinte esquema, definido no system prompt:

{
  "extracted": {
    "montadora": "<string | null>",
    "modelo": "<string | null>",
    "ano": "<number | null>",
    "motorizacao": "<string | null>",
    "start_stop": "sim | nao | desconhecido | null"
  },
  "next_action": "ask | enrich | recommend | handoff",
  "gate": "montadora | modelo | ano | motorizacao | start_stop | null",
  "message": "<string>"
}
Enter fullscreen mode Exit fullscreen mode

Alguns detalhes desse contrato merecem atenção:

  • extracted traz o que o agente identificou neste turno. O backend mescla esses valores no estado acumulado da conversa, e é o acumulado, não o turno, que alimenta a consulta de compatibilidade.
  • null e "desconhecido" são coisas diferentes. null indica que o gate ainda não apareceu na conversa; "desconhecido" indica que o cliente informou que não sabe a resposta, o que é bastante comum no caso do start-stop. O sistema trata cada um de forma diferente. null vira pergunta futura; "desconhecido" não é perguntado novamente, e limita o que pode ser prometido, como veremos.
  • gate é a próxima pergunta que o agente decidiu fazer. O backend usa esse campo para validar a pergunta e renderizar as opções de resposta rápida, que vêm de um catálogo definido em código, e não de texto gerado livremente pelo modelo.
  • next_action diz o que fazer com o turno: perguntar (ask), responder uma dúvida de apoio (enrich, assunto de uma seção adiante), recomendar (recommend) ou transbordar para atendimento humano (handoff).

Tendo em mente o formato da resposta, voltemos ao nosso exemplo (com números ilustrativos, como manda a nota de confidencialidade). Para a primeira mensagem do usuário, a resposta do agente seria algo como:

{
  "extracted": {
    "montadora": "Ford",
    "modelo": "Mondeo",
    "ano": null,
    "motorizacao": null,
    "start_stop": null
  },
  "next_action": "ask",
  "gate": "ano",
  "message": "Ótimo! Para encontrar a bateria certa para o seu Mondeo, me diga: qual é o ano do veículo?"
}
Enter fullscreen mode Exit fullscreen mode

O código-fonte do backend efetua então a leitura da resposta do agente, mescla os gates capturados no estado da conversa e consulta a tabela de compatibilidade para filtrar os produtos. A quantidade de produtos compatíveis fica salva no estado da conversa, no DynamoDB. No próximo turno, o LLM lê essa informação para decidir a condução.

O agente somente efetua a recomendação quando todos os gates obrigatórios foram preenchidos. Mas atenção: preencher todos os gates é condição necessária, não suficiente. A garantia de que o usuário só recebe recomendações 100% compatíveis vem de três mecanismos empilhados:

  1. Quem decide compatibilidade é o filtro determinístico sobre a tabela curada, nunca o LLM.
  2. Só entram na recomendação produtos confirmados na fonte oficial para todos os gates levantados na conversa.
  3. Se nenhum produto estiver confirmado, o agente transborda para o atendimento humano, em vez de forçar uma venda.

Isso é extremamente importante, considerando o objetivo que desejamos alcançar: reduzir o número de devoluções.

O diagrama de sequência abaixo apresenta a interação entre os atores sempre que o usuário envia uma nova mensagem para o chatbot.

Diagrama de sequência

Todo esse processo acontece de forma transparente para o usuário, que recebe como resposta a pergunta sobre o próximo gate. A tabela abaixo mostra a sequência de mensagens trocadas até o agente ter tudo que precisa:

Usuário Agente Gates Encontrados
Preciso de uma bateria para o meu Ford Mondeo Ótimo! Para encontrar a bateria certa para o seu Mondeo, me diga: qual é o ano do veículo? Montadora: Ford, Modelo: Mondeo 12
É 2004 Perfeito, Mondeo 2004! E qual é a motorização? 2.0 ou V6? + Ano: 2004 8
2.0 Entendido! Última pergunta: o seu Mondeo tem sistema start-stop, aquele em que o motor desliga sozinho nas paradas? + Motorização: 2.0 4
Não tem Ótimo! Tenho todas as informações necessárias. Vou buscar as baterias compatíveis com o seu carro agora! + Start-stop: não 4

Note que o agente pergunta sobre start-stop mesmo para um carro de 2004, pois não presume nada sobre o veículo do cliente. Note também que a resposta negativa não cortou o funil; nem todo gate elimina produtos.

Regras e restrições

O cenário desenhado neste artigo foi simplificado para facilitar a explicação (no projeto real havia mais gates, alguns condicionais, e mais regras de negócio). Na prática, aplicamos também:

  • Guardrails: o Bedrock aplica guardrails contra prompt injection e temas fora do escopo do agente. Nesses casos, a mensagem não é simplesmente ignorada. O turno é bloqueado com uma mensagem padrão; em segurança, a regra é fail-closed.
  • Handoff: quando o chatbot não pode recomendar, o usuário é direcionado ao atendimento humano e o atendente recebe um resumo do contexto da conversa, para o cliente não recomeçar do zero.
  • Verificação de estoque: antes de recomendar, o sistema consulta a API do e-commerce para exibir apenas produtos com estoque, além de obter foto e preço. Sem estoque, isso é comunicado ao usuário.
  • A guarda determinística: o LLM conduz a conversa, mas uma guarda em código valida cada decisão dele e impede, por exemplo, uma recomendação prematura, devolvendo a pergunta que falta. Aprendemos o valor disso na prática. Em certo momento, uma otimização implementada via prompt permitia encerrar o funil mais cedo quando restavam poucos produtos, e o teste de validação do cliente identificou que algumas perguntas estavam sendo puladas. A regra saiu do prompt e virou invariante de código. O que for garantia não reside no prompt.

Onde o RAG entra de verdade

É aqui que entra o RAG que realocamos no início do artigo. No meio do funil, o cliente pergunta:

"Qual a diferença entre uma bateria EFB e uma AGM?"

Isso não é resposta de gate, mas sim uma dúvida de apoio. O mesmo LLM que já lê a mensagem do turno detecta a dúvida, sem nenhuma chamada extra de classificação, e devolve next_action: "enrich". O backend então consulta o índice vetorial dos manuais e datasheets (S3 Vectors), responde a dúvida citando a fonte oficial e retoma o funil no mesmo turno, com a resposta seguida da próxima pergunta pendente. A conversa não perde o ritmo, e a dúvida não fica sem resposta.

As regras de ouro dessa camada:

  • O RAG nunca altera a seleção de produtos. Ele responde dúvidas; quem decide compatibilidade continua sendo o filtro determinístico.
  • O condutor nunca responde com base no conhecimento interno do modelo. Dúvida sobre produto vai para o RAG, que só afirma o que encontra nas fontes indexadas, ou seja, sem fonte, sem afirmação.
  • Em caso de falha, o sistema degrada com elegância. Se o RAG estiver fora do ar, o agente avisa que não conseguiu consultar e a conversa continua (fail-soft). Se o próprio LLM falhar, um fallback determinístico pergunta o próximo gate do funil. Se o guardrail falhar, o turno não prossegue (fail-closed).

Em resumo, o LLM decide e o código executa. E quando o LLM falha, o código mantém a conversa em andamento.

A garantia: proveniência por célula

Resta explicar o que sustenta a garantia de zero recomendações contradizendo a fonte oficial. Cada célula da tabela de compatibilidade (produto × atributo) carrega a sua proveniência: de qual documento e de qual página do datasheet oficial aquele valor foi extraído. Se um datasheet não declara um atributo, a célula fica marcada como desconhecido e o sistema nunca infere o valor. Essa é a regra "sem fonte, sem afirmação", e ela tem uma consequência sutil. Um produto com atributo desconhecido não é descartado do funil (ele pode até ser compatível), mas também não pode ser recomendado com aquele atributo como promessa.

Voltemos ao nosso exemplo. O funil terminou com 4 baterias compatíveis, mas o agente recomenda apenas 2. Por quê?

Gate aplicado Compatíveis 100% confirmados
… + start-stop = não 4 2

Dois dos quatro fabricantes simplesmente não declaram a aplicação para o Mondeo 2004 nas suas tabelas oficiais. Carros antigos são os mais afetados, pois os catálogos priorizam a frota recente. Esses dois produtos não foram eliminados, mas o agente não tem fonte para prometer o encaixe. Então a recomendação exibe só os 2 confirmados.

E quando a conta chega a zero confirmados? O agente não flexibiliza o critério para mostrar proximidades. Transborda para o atendimento humano. No projeto real, houve um gate para o qual a fonte oficial não confirmava o atributo para nenhum produto do catálogo, e aceitamos, por decisão registrada em ADR, que 100% desses pedidos transbordassem para humanos. Pode parecer uma limitação do produto, mas é o comportamento certo quando a meta é reduzir devoluções.

Esse desenho também muda a forma de medir a confiança no agente. O filtro é código determinístico, então cada regra de compatibilidade pode ser coberta por testes. A proveniência, por sua vez, permite auditar qualquer recomendação até a página do datasheet de origem. Por fim, o roteiro de validação executado junto ao cliente exercita os casos-limite, inclusive os transbordos. Com isso, o critério de zero recomendações contradizendo a fonte oficial deixa de ser uma promessa e passa a ser verificado de forma objetiva. Esse foi o gate de qualidade que a PoC precisou passar para ser aprovada.

Custos

Uma das vantagens normalmente atribuídas ao uso de código determinístico está na redução de custos. O backend da aplicação roda em um Lambda com arquitetura arm64 e 512 MB de memória RAM, escrito em Python. Cogitamos Go ou Rust pela performance, mas o custo de computação do Lambda é desprezível perto do custo de tokens (menos de 1% do custo de um turno). A economia real vem da arquitetura, não da linguagem. É uma única chamada curta de LLM por turno, com o catálogo fora do contexto, já que a consulta de compatibilidade é feita pelo backend, sem gastar token. O turno de abertura do widget sequer aciona o LLM. Um fallback determinístico faz a primeira pergunta.

A tabela abaixo apresenta uma estimativa de custos em diferentes cenários, considerando três abordagens:

  1. Motor híbrido: a abordagem deste artigo.
  2. 100% IA (sem cache): o mesmo produto, mas com a tabela de compatibilidade e as especificações dentro do contexto a cada turno, e o LLM decidindo a compatibilidade.
  3. 100% IA (com prompt caching): idem, com o catálogo em cache de prompt.
Cenário Motor híbrido 100% IA (sem cache) 100% IA (com prompt caching)
Por turno $0,008 $0,074 $0,017*
Por conversa $0,04 $0,37 $0,16
1.000 conversas/mês $66 $396 $186
10.000 conversas/mês $438 $3.738 $1.638
100.000 conversas/mês $4.150 $37.150 $16.150

* No primeiro turno da conversa há o custo de escrita do cache (~$0,092); os turnos seguintes leem o cache.

Nota de metodologia: valores estimados com Claude Sonnet 4.6 (temperature 0, maxTokens 500); conversa típica de 6 turnos, dos quais 5 acionam o LLM (a abertura é determinística). No motor híbrido, cada turno usa ~1,5 mil tokens de entrada e ~230 de saída; nos cenários "100% IA", o catálogo adiciona ~22 mil tokens de entrada por turno. As linhas mensais incluem a infraestrutura fixa (API Gateway, Lambda, DynamoDB, CloudFront, S3), idêntica nas três colunas. Números arredondados para ilustrar ordem de grandeza.

Em termos relativos, quantas vezes mais caro fica o "100% IA" em relação ao motor híbrido:

Cenário 100% IA (sem cache) 100% IA (com prompt caching)
Por conversa 9,3× 4,0×
1.000 conversas/mês 6,0× 2,8×
10.000 conversas/mês 8,5× 3,7×
100.000 conversas/mês 9,0× 3,9×

Conclusão

A escolha por código determinístico em pontos estratégicos da arquitetura está ancorada em três pilares:

  • Acurácia
  • Custos
  • Latência

No caso apresentado, a acurácia foi o principal motivador. A recomendação precisa ser exata. E ser exata, neste contexto, significa nunca contradizer a fonte oficial. Essa propriedade só é garantida porque a decisão de compatibilidade é código determinístico sobre dado curado com proveniência. O LLM faz o que só ele faz bem: conversar, extrair informação em qualquer ordem e responder dúvidas com fonte citada.

A redução de custos foi consequência. Confesso que não pensamos nisso no início do projeto. Tampouco o cliente abordou a questão. Mas quando fizemos as contas, ficamos surpresos com a economia. Sem o motor híbrido, o catálogo inteiro seria enviado no contexto do modelo a cada turno, consumindo tokens para uma decisão que uma consulta de milissegundos resolve.

A latência seguiu o mesmo caminho. Como a consulta de compatibilidade é feita diretamente pelo backend, nem a requisição nem a resposta precisam ser interpretadas pelo modelo.

Apesar do avanço dos agentes de IA, o código determinístico continua sendo a melhor opção para os pontos do sistema onde entrada e saída são conhecidas. Um bom exemplo são os cálculos matemáticos. Uma simples calculadora, movida por duas pilhas AA, é muito mais rápida e eficiente para fazer contas do que um LLM rodando em um supercomputador equipado com várias placas de vídeo de última geração. Mas os casos que aparecem em produção raramente estão nesse extremo. O mais comum é o meio-termo, como no nosso exemplo. A conversa é aberta e, portanto, fica com o LLM. A decisão é fechada, uma regra de compatibilidade sobre uma tabela curada, e por isso fica com o código.

E esse meio-termo generaliza. Se no seu domínio uma recomendação errada vira devolução, troca ou retrabalho (autopeças, informática, móveis sob medida), a decisão deve estar ancorada em dados estruturados, verificáveis e com proveniência, filtrados por código. O LLM entra para conversar, extrair informações e enriquecer respostas, não para inventar a resposta.

Um bom arquiteto de software não é aquele que sempre usa os recursos mais atuais e avançados de computação, mas sim aquele que conhece a melhor aplicabilidade para cada tecnologia.

Top comments (0)