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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. 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:

  1. 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.
  2. WithoutOverlapping por conversa com releaseAfter e expireAfter. Mesmo sem debounce, isso já elimina a corrida que cria agendamentos duplicados. É a parte que protege dados, não só a experiência.
  3. 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.
  4. 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.