Para agrupar mensagens picadas no WhatsApp antes de chamar o agente de IA, grave cada mensagem no banco assim que o webhook chega e despache um job com alguns segundos de atraso. Quando o job roda, ele só chama o LLM se nenhuma mensagem mais nova chegou nesse intervalo. Com um teto de espera máximo e um lock por conversa, o agente responde uma vez ao pensamento completo do cliente, e não quatro vezes a quatro fragmentos.
Esse problema aparece cedo em qualquer agente de atendimento no WhatsApp, e no Horalis não foi diferente. O cliente não escreve como quem preenche um formulário. Ele manda "oi", depois "queria marcar", depois "pra sexta" e por fim "de manhã se tiver". Se cada uma dessas mensagens dispara uma chamada ao Claude, o resultado é ruim em todos os sentidos.
Por que responder cada mensagem do WhatsApp separadamente quebra o agente de IA?
São três problemas que se somam:
- Respostas atropeladas. O agente responde "Olá! Como posso ajudar?" ao "oi" enquanto o cliente já está digitando o pedido. Logo depois chega uma resposta perguntando o dia, quando o dia já foi informado. A conversa parece um robô surdo.
- Execuções concorrentes na mesma conversa. Quatro webhooks em poucos segundos viram quatro jobs. Se dois workers pegam jobs da mesma conversa ao mesmo tempo, os dois leem o mesmo histórico, chamam tools e podem até criar dois agendamentos.
- Custo multiplicado. Cada chamada manda o system prompt, as definições de tools e o histórico inteiro. Quatro chamadas para um único pedido custam perto de quatro vezes mais, e a única que importava é a última.
A solução é parar de reagir a mensagens e passar a reagir a lotes de mensagens.
O que é debounce e qual variante usar para mensagens?
Debounce é a técnica de adiar uma ação até que os eventos parem de chegar por um intervalo. É o mesmo princípio do campo de busca que só consulta a API quando você para de digitar. Existem algumas variantes, e a escolha muda bastante o comportamento do agente:
| Variante | Como funciona | Problema para atendimento |
|---|---|---|
| Leading edge | Processa a primeira mensagem e ignora as seguintes por um tempo | Responde ao "oi" e perde o pedido |
| Janela fixa | Agrupa tudo que chega em blocos de N segundos a partir da primeira | Corta no meio se o cliente ainda está escrevendo |
| Trailing edge | Espera N segundos de silêncio depois da última mensagem | Se o cliente nunca para, o agente nunca responde |
| Trailing com teto | Trailing edge, mas processa de qualquer jeito após um tempo máximo | Nenhum relevante, é a que uso |
O teto é o que torna o trailing edge seguro em produção. Tem cliente que manda dez mensagens seguidas, cada uma a dois segundos da outra. Sem teto, ele fica um tempão sem resposta e acha que o sistema travou.
Um detalhe que pesa nessa decisão: em geral o seu back-end não recebe um evento confiável de "digitando" do cliente. O único sinal que você tem de verdade é o tempo entre as mensagens.
Como salvar as mensagens no webhook sem perder nem duplicar?
A fonte da verdade é uma tabela no PostgreSQL, e não a memória do job. Cada mensagem é gravada com a coluna processed_at nula. O lote é simplesmente "tudo que ainda não foi processado nesta conversa".
Schema::create('inbound_messages', function (Blueprint $table) { $table->id(); $table->foreignId('conversation_id')->constrained(); $table->string('provider_message_id')->unique(); $table->string('type'); // text, audio, image $table->text('body')->nullable(); $table->text('transcription')->nullable(); $table->timestamp('received_at'); $table->timestamp('processed_at')->nullable(); $table->index(['conversation_id', 'processed_at']); });
O provider_message_id único resolve o reenvio do webhook. Provedores de WhatsApp reentregam eventos quando não recebem 2xx a tempo, e uma mensagem duplicada no lote faz o agente achar que o cliente repetiu o pedido. É o mesmo raciocínio de processar webhooks de pagamento duplicados uma única vez, aplicado a mensagens.
public function __invoke(Request $request, Conversations $conversations) { // validação da assinatura do provedor omitida $data = $this->extractMessage($request); $conversation = $conversations->forPhone($data['from']); $message = InboundMessage::createOrFirst( ['provider_message_id' => $data['id']], [ 'conversation_id' => $conversation->id, 'type' => $data['type'], 'body' => $data['text'] ?? null, 'received_at' => now(), ], ); if ($message->wasRecentlyCreated) { ProcessConversationBatch::dispatch($conversation->id, $message->id) ->delay(now()->addSeconds(config('agent.debounce_seconds', 5))); } return response()->noContent(); }
Uso createOrFirst em vez de firstOrCreate porque ele tenta o insert primeiro e, se a constraint única estourar, busca o registro existente. Com dois webhooks idênticos chegando ao mesmo tempo, firstOrCreate pode lançar exceção de violação de unicidade. createOrFirst foi feito justamente para essa corrida.
Como implementar o debounce com jobs atrasados no Laravel?
A ideia central: cada mensagem agenda um job, mas só o job da mensagem mais recente chama o agente. Os jobs das mensagens anteriores acordam, percebem que existe algo mais novo e saem sem fazer nada. Não há nada para cancelar. Os jobs obsoletos se descartam sozinhos ao rodar.
Eu poderia guardar o "último timestamp" no Redis, mas como as mensagens já estão no banco com ID crescente, comparar IDs basta e sobrevive a um flush do cache.
namespace App\Jobs; use App\Agent\AgentRunner; use App\Models\Conversation; use App\Models\InboundMessage; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Collection; class ProcessConversationBatch implements ShouldQueue { use Queueable; public function __construct( public int $conversationId, public int $triggerMessageId, ) {} public function middleware(): array { return [ (new WithoutOverlapping($this->conversationId)) ->releaseAfter(3) ->expireAfter(180), ]; } public function retryUntil(): \DateTime { return now()->addMinutes(5); } public function handle(AgentRunner $agent): void { $pending = InboundMessage::where('conversation_id', $this->conversationId) ->whereNull('processed_at') ->orderBy('id') ->get(); if ($pending->isEmpty()) { return; // outro job já respondeu este lote } if ($this->shouldWaitForNewer($pending)) { return; // o job da mensagem mais nova assume } if ($pending->contains(fn ($m) => $m->type === 'audio' && $m->transcription === null)) { $this->release(2); return; } $conversation = Conversation::findOrFail($this->conversationId); $this->finish($conversation, $pending, $agent->reply($conversation, $pending)); } private function shouldWaitForNewer(Collection $pending): bool { $isLatest = $pending->last()->id === $this->triggerMessageId; return ! $isLatest && $this->waitedSeconds($pending) < config('agent.max_wait_seconds', 20); } private function waitedSeconds(Collection $pending): float { return $pending->first()->received_at->diffInSeconds(now()); } }
Repare como o teto funciona sem nenhum mecanismo extra. Se o cliente manda uma mensagem a cada dois segundos com debounce de cinco, todo job acorda e encontra uma mensagem mais nova. Isso vale até que a mensagem mais antiga do lote passe de max_wait_seconds. A partir daí, o próximo job que acordar processa o lote inteiro, mesmo não sendo o da última mensagem. Os jobs que vierem depois encontram o lote já processado, ou só as mensagens que chegaram depois, e seguem o fluxo normal.
O caso do áudio merece atenção. Se o lote tem um áudio ainda sem transcrição, mandar só o texto faz o agente responder sem a parte mais importante do pedido. O job se libera de volta para a fila e tenta de novo em dois segundos. Detalhei a transcrição em como transcrever áudios do WhatsApp com OpenAI e mandar para o agente de IA. O ponto aqui é só que o debounce precisa saber esperar por ela.
O retryUntil substitui o $tries. Tanto o release() manual quanto o middleware de overlap contam como tentativa. Com $tries = 3, um lote com áudio demorado poderia esgotar as tentativas e cair em failed_jobs. Quando os dois estão definidos, o Laravel dá precedência ao retryUntil, então eu limito por tempo e não por contagem.
Como impedir que duas execuções do agente rodem na mesma conversa?
O middleware WithoutOverlapping adquire um lock atômico (no Redis, se esse for o driver de cache) com a chave que você passar. Se o lock já estiver ocupado, ele devolve o job para a fila. Usando o ID da conversa como chave, no máximo uma execução do agente acontece por conversa, mesmo com vários workers no Horizon.
Dois parâmetros importam:
releaseAfter(3): quanto tempo o job bloqueado espera antes de tentar de novo. Sem isso, o comportamento padrão é descartar o job bloqueado, e você perde a resposta.expireAfter(180): TTL do lock. Se o worker morrer no meio da chamada ao LLM, o lock não fica preso para sempre. O valor precisa ser maior que o pior caso de uma execução completa do agente, somando todas as rodadas de tool calling e o timeout HTTP.
O lock por conversa é o que torna seguro deixar o agente executar tools de escrita, como criar agendamento ou gerar link de pagamento. A estrutura do agente em si está em como criar um agente de IA com tool calling do Claude para agendamentos no WhatsApp. Sem serializar por conversa, duas execuções paralelas leem o mesmo estado e as duas decidem agendar.
O que fazer quando o cliente manda mensagem enquanto o agente ainda está respondendo?
Esse é o caso que o debounce sozinho não resolve. O lote foi fechado, o agente está no meio da chamada ao Claude, e chega "ah, na verdade pode ser sábado". A nova mensagem agenda o próprio job, que vai ficar esperando o lock. Mas a resposta em andamento já está desatualizada.
As opções são:
| Estratégia | Prós | Contras |
|---|---|---|
| Enviar e processar a nova depois | Simples, nada é jogado fora | O cliente recebe uma resposta que ignora a correção |
| Descartar e reprocessar junto | A resposta final considera tudo | Paga a chamada descartada |
| Descartar só se nada foi escrito | Equilibra coerência e segurança | Exige saber se alguma tool alterou estado |
Fico com a terceira. Antes de enviar, verifico se chegou mensagem nova durante a execução. Se chegou e o agente só fez leituras, como consultar horários livres, descarto a resposta sem marcar o lote como processado. O job da mensagem nova pega tudo junto. Se o agente já executou uma tool de escrita, a resposta precisa ir, porque ela comunica algo que aconteceu de fato. Descartar "Agendei para sexta às 9h" deixaria o cliente sem saber que existe um agendamento.
private function finish(Conversation $conversation, Collection $pending, AgentResult $result): void { $arrivedMeanwhile = InboundMessage::where('conversation_id', $conversation->id) ->whereNull('processed_at') ->where('id', '>', $pending->last()->id) ->exists(); $canDiscard = ! $result->executedWriteTools && $this->waitedSeconds($pending) < config('agent.max_wait_seconds', 20); if ($arrivedMeanwhile && $canDiscard) { return; // o próximo job responde o lote completo } DB::transaction(function () use ($conversation, $pending, $result) { InboundMessage::whereKey($pending->pluck('id'))->update(['processed_at' => now()]); $reply = $conversation->assistantMessages()->create(['body' => $result->text]); SendWhatsAppReply::dispatch($reply->id)->afterCommit(); }); }
AgentResult é uma classe minha. O runner marca executedWriteTools sempre que executa uma tool registrada como escrita. Essa classificação de tools em leitura e escrita vale a pena por si só, porque ajuda em auditoria e em confirmação humana. Aqui ela também decide se a resposta pode ser descartada.
O teto aparece de novo em $canDiscard. Sem ele, um cliente que corrige a mensagem sem parar provocaria descartes em sequência, e cada descarte é uma chamada paga. Passado o tempo máximo, a resposta vai mesmo que esteja um pouco atrás.
O envio sai da transação com afterCommit(). Se a transação falhar, o cliente não recebe uma resposta que o banco não registrou.
Como montar o lote de mensagens para o Claude?
No histórico da conversa, o lote vira um único turno user com vários blocos de texto, um por mensagem. Isso é explícito e não depende de como a API trata turnos consecutivos do mesmo papel. Também preserva a fronteira entre as mensagens, que às vezes carrega significado, como uma correção logo depois do pedido.
$content = $pending->map(fn (InboundMessage $m) => [ 'type' => 'text', 'text' => match ($m->type) { 'audio' => '[áudio transcrito] '.$m->transcription, default => (string) $m->body, }, ])->values()->all(); $messages = [...$history, ['role' => 'user', 'content' => $content]]; $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ])->timeout(60)->post('https://api.anthropic.com/v1/messages', [ 'model' => config('agent.model'), 'max_tokens' => 1024, 'system' => $systemPrompt, 'messages' => $messages, ]);
No system prompt, incluo uma linha avisando que o cliente costuma mandar várias mensagens curtas em sequência e que elas chegam agrupadas. O agente deve tratá-las como um único pedido e considerar a última versão quando houver contradição. Sem essa instrução, o modelo às vezes responde a cada bloco separadamente dentro da mesma resposta, o que anula parte do ganho.
No histórico persistido, as mensagens continuam separadas na tabela. O agrupamento acontece só na hora de montar a requisição. Assim o painel de atendimento mostra exatamente o que o cliente mandou.
Quanto tempo esperar na janela de debounce?
Não existe número mágico. É uma troca direta entre parecer responsivo e pegar o pensamento inteiro:
| Janela | Comportamento | Risco |
|---|---|---|
| 2 a 3 s | Resposta rápida | Ainda responde a fragmentos de quem digita devagar |
| 4 a 6 s | Bom ponto de partida para atendimento | Pouco perceptível para quem mandou uma frase completa |
| 8 s ou mais | Agrupa quase tudo | O cliente que fez uma pergunta simples acha que está lento |
Começo com 5 segundos de debounce e 20 de teto, e ajusto olhando os dados da própria tabela. A coluna received_at permite medir o intervalo entre mensagens consecutivas da mesma conversa e ver em que faixa cai a maioria. Se a janela está curta demais, aparecem muitos lotes de uma mensagem só seguidos de outro lote poucos segundos depois.
Dá para refinar com heurísticas baratas, como encurtar a janela quando a mensagem termina com ponto de interrogação ou é longa, e alongar quando é uma saudação curta. Eu só faria isso depois de medir, porque cada regra é mais um comportamento a explicar quando o atendimento parecer estranho.
Vale também olhar o efeito no custo. Agrupar reduz o número de chamadas, e cada chamada evitada economiza o system prompt e o histórico inteiro. Para estimar quanto isso representa no seu volume, a calculadora de custo de IA ajuda a comparar cenários por modelo.
Como testar o debounce sem esperar segundos de verdade?
O debounce é lógica pura sobre IDs e timestamps, então dá para testar sem fila real e sem LLM:
it('ignora o job de uma mensagem que já tem outra mais nova', function () { $conversation = Conversation::factory()->create(); $first = InboundMessage::factory()->for($conversation)->create(['received_at' => now()]); InboundMessage::factory()->for($conversation)->create(['received_at' => now()]); $agent = Mockery::mock(AgentRunner::class); $agent->shouldNotReceive('reply'); (new ProcessConversationBatch($conversation->id, $first->id))->handle($agent); }); it('processa mesmo assim quando passa do teto', function () { // mesmo setup, com $agent->shouldReceive('reply')->once() $this->travel(25)->seconds(); // ... });
O travel() avança o relógio do teste, então o cenário do teto roda instantaneamente. No webhook, Queue::fake() com Queue::assertPushed confirma que o job saiu com atraso e que um webhook repetido não despacha um segundo job.
Conclusão: o que eu configuraria primeiro
Se o seu agente de WhatsApp responde cada mensagem na hora, eu faria nesta ordem:
- Tabela de mensagens com ID do provedor único e
processed_at. É o que permite tratar o lote como estado no banco, e não como algo frágil na memória do job. WithoutOverlappingpor conversa comreleaseAftereexpireAfter. Mesmo sem debounce, isso já elimina a corrida que cria agendamentos duplicados. É a parte que protege dados, não só a experiência.- Job atrasado que só processa se for o da última mensagem, com teto de espera. Comece com 5 e 20 segundos e ajuste com o histórico real.
- Checagem antes de enviar, descartando só respostas de execuções sem escrita. É a última camada, e só faz diferença depois que as três primeiras estão no lugar.
O resultado não é um agente mais inteligente. É um agente que espera o cliente terminar de falar antes de responder, e no WhatsApp isso conta tanto quanto a qualidade da resposta.