Inteligência Artificial Aplicada e Engenharia de Dados

Debounce de Mensagens com Trava Distribuída no Redis para Resposta Única e Reavaliação de Intenção

Em aplicações de mensageria de alto volume (como WhatsApp, Telegram ou webchats), é comum o usuário enviar múltiplas mensagens em sequência (mensagem A seguida da mensagem B em um curto intervalo de tempo) antes que a primeira resposta seja gerada e entregue.

agentes-iaarquiteturainteligencia-artificial

Introdução

Este artigo apresenta um estudo de caso completo sobre o gerenciamento de estado concorrente em Agentes de IA conversacionais. Em aplicações de mensageria de alto volume (como WhatsApp, Telegram ou webchats), é comum o usuário enviar múltiplas mensagens em sequência (mensagem A seguida da mensagem B em um curto intervalo de tempo) antes que a primeira resposta seja gerada e entregue. Sem um controle arquitetural de concorrência, o sistema sofre com consumo duplicado de tokens em LLMs, desincronização de contexto e entrega de respostas contraditórias ou fora de ordem.

Código Fonte Repositório completo no GitHub contendo as branches evolutivas, os scripts de simulação e as instruções do Docker Compose: 🔗 https://github.com/marcelo3macedo/gerenciamento-estado-concorrente-no-adk


O Problema

Em sistemas conversacionais baseados em agentes de Inteligência Artificial, o comportamento real dos usuários em canais de mensageria (como WhatsApp, Telegram ou Webchat) difere drasticamente do modelo tradicional de requisição-resposta síncrona do protocolo HTTP. Os usuários interagem de forma fluida e frequentemente enviam mensagens em rajadas curtas e sequenciais (rapid-fire messaging). Por exemplo:

Quando a arquitetura do agente opera de forma assíncrona desacoplada, onde um webhook recebe cada mensagem de forma isolada, dispara a inferência na LLM e enfileira a resposta em um corretor de mensagens com delay operacional, surge uma condição de corrida (race condition) e falha no gerenciamento de estado.

Este é um diagrama interativo, disponível apenas na versão completa do artigo.

O problema manifesta-se em três dimensões críticas:

Inconsistência Semântica e Respostas Contraditórias Como a mensagem A já iniciou seu ciclo de vida no agente, o sistema gera a confirmação de cancelamento e a envia para a fila de saída. Quando a mensagem B chega 1,5 segundo depois, o agente gera a resposta de alteração de endereço e a enfileira em seguida. No canal do usuário, contudo, a resposta de cancelamento referente a A é entregue depois que o cliente já havia desistido do cancelamento, gerando ruído e desconfiança.

Desperdício Computacional e Financeiro O agente consome recursos computacionais relevantes e tokens de entrada/saída na API do modelo (LLM) para processar e gerar a resposta da mensagem A, ignorando que o contexto do diálogo foi completamente invalidado segundos depois pela mensagem B.

Quebra na Experiência do Usuário (UX) A sobreposição de bolhas de conversa fora de ordem viola a expectativa conversacional esperada em assistentes virtuais de nível corporativo.


A Arquitetura da Solução

Para sanar a condição de corrida, a arquitetura utiliza o Redis como gerenciador de travas distribuídas atrelado ao identificador da sessão e o RabbitMQ como fila de despacho assíncrono. Sempre que uma nova mensagem entra no sistema enquanto uma resposta anterior ainda está sendo processada ou aguardando envio na fila de saída, o Redis sinaliza uma trava ativa. Esse bloqueio intercepta o consumidor do RabbitMQ, impedindo que a mensagem em trânsito seja despachada ao usuário até que um agente avaliador determine se a resposta anterior deve ser liberada, cancelada ou fundida com a nova entrada.

Este é um diagrama interativo, disponível apenas na versão completa do artigo.

Ao longo de três branches, o order-service foi ganhando as camadas necessárias para tratar essa concorrência como um problema de primeira classe, não como um bug a esconder:

BranchO que foi adicionado
feature/01-naive-fastapi-rabbitmq-delayCenário base: webhook + agente ADK + fila de saída com delay operacional, sem nenhuma proteção contra concorrência
feature/02-redis-lock-rabbitmq-exponential-backoffTrava de sessão no Redis + escada de retry com backoff exponencial no consumer via DLX do RabbitMQ
feature/03-google-adk-intent-evaluator-triageIntention Evaluator decide RELEASE_FIRST / CANCEL_FIRST / MERGE
feature/04-e2e-simulative-tests-pytest-testcontainersSuíte E2E com testcontainers

Situação das mensagens durante o processamento

Este é o núcleo do case: uma mensagem publicada em outbound_messages não é só "entregue ou não entregue", ela transita por um pequeno conjunto de estados, guardados em duas estruturas no Redis e consultados pelo consumer a cada tentativa de despacho.

Este é um diagrama interativo, disponível apenas na versão completa do artigo.

A trava e o estado por mensagem resolvem perguntas diferentes:


A escada de retry

Enquanto a trava está ativa, o consumer não faz polling nem mantém a mensagem em memória, ele faz NACK e republica numa fila de atraso com TTL, crescente a cada tentativa:

Este é um diagrama interativo, disponível apenas na versão completa do artigo.

O timeline abaixo é dado real, extraído dos logs de uma execução da suíte: uma trava que nunca é liberada faz a mensagem esgotar as 3 tentativas e ir parar em outbound_messages.parked, sem nunca chegar ao usuário e sem retentar para sempre:

“Timeline real: mensagem parqueada (trava nunca liberada)” é um gráfico interativo, disponível apenas na versão completa do artigo.

Isso impede que mensagens presas fiquem sendo reprocessadas indefinitivamente, o que consumiria CPU e memória do servidor sem necessidade.


Análise de Decisão

Informações detalhada sobre as principais decisões deste case:

DecisãoAlternativa ConsideradaPor que foi Escolhida?Trade-off Aceito
Liberação explícita da trava ao fim da triagem, TTL só como rede de segurançaConfiar só no TTL da trava (5s) para liberar a sessãoReter a trava até o TTL expirar faria toda mensagem em voo esperar até 5s mesmo quando a triagem termina em milissegundos — desnecessário na maioria dos casos.Se o processo do webhook morrer entre ativar a trava e liberá-la, a mensagem fica retentando (backoff) até a trava expirar por TTL
Matriz de decisão via 2º agente ADK (output_schema=IntentionDecision, sem tools, sessão efêmera)Heurística determinística (palavras-chave, similaridade de texto)Distinguir "cancela" de "não cancela, esquece" exige entender a intenção da frase, não só presença de palavras.Cada colisão (B chegando com A ainda em voo) soma uma chamada de LLM extra à latência de B, e o custo de uma segunda sessão ADK por avaliação.
Retry com backoff limitado a 3 tentativas + fila de parkingRetry infinito até a trava liberarUma trava que nunca libera (crash do webhook) não pode virar uma mensagem retentando para sempre e consumindo o consumer.Mensagens parqueadas não são reentregues automaticamente; exigem intervenção manual ou um processo separado de reprocessamento.
Entrega assíncrona via RabbitMQ (webhook publica, consumer despacha) em vez de resposta síncrona no POST /webhook/messageDevolver a resposta do agente diretamente no corpo da resposta HTTPNecessário para simular e testar o delay operacional de um canal real, e para a trava fazer sentido — sem uma fila, não existe "mensagem em voo" para reavaliar.Latência adicional (o delay operacional) entre a geração da resposta e a entrega, mesmo no caminho feliz sem colisão nenhuma.

Casos exemplificados

scripts/simulate_intention_triage.py reproduz as 3 rotas de decisão via HTTP, cada uma numa sessão própria. Os tempos abaixo são reais, extraídos dos logs da suíte E2E, com A e B separados por ~0,3s, dentro da janela de delay operacional de 0,6s configurada para os testes:

Decisão 1 — RELEASE_FIRST (B não conflita com A) A = "Qual o horário de funcionamento?", B = "E vocês aceitam PIX?". Nada é marcado no Redis; as duas respostas chegam ao usuário, A primeiro (606ms), B logo depois (891ms).

Decisão 2 — CANCEL_FIRST (B anula A) A = "Quero cancelar meu pedido", B = "Na verdade não cancela, acabei de receber". A é marcada CANCELLED; o consumer descarta A (reason=cancelled, 607ms depois do webhook) sem disparar ao usuário. Só a resposta de B chega (892ms).

Decisão 3 — MERGE (B complementa A) A = "Adiciona uma pizza de calabresa", B = "E uma Coca 2L também". A é marcada SUPERSEDED; o consumer descarta A (reason=superseded, 608ms). Como a sessão do agente já tem A na memória, o segundo turno já produz uma única resposta cobrindo os dois itens — o usuário recebe uma resposta combinada (893ms), nunca duas.

Caso extra — Parqueamento Mensagem publicada, trava ativada e nunca liberada. A escada de retry esgota as 3 tentativas (0,6s + 0,3s + 0,6s + 1,2s) e a mensagem é movida para outbound_messages.parked em 2,71s, sem chegar ao usuário e sem retentar para sempre.

“Latência até o desfecho, por mensagem (ms) — dados reais da suíte E2E” é um gráfico interativo, disponível apenas na versão completa do artigo.


Pontos observados

O código completo, com as quatro branches e a suíte de testes, está em: