# Mensagens picadas no WhatsApp: como agrupar antes de chamar o agente de IA no Laravel

> Debounce com jobs atrasados, teto de espera e lock por conversa para o agente de IA responder uma vez ao pensamento completo do cliente, e não a cada fragmento.

Por Lucas Roberto · IA · publicado em 2026-10-21 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/mensagens-picadas-no-whatsapp-como-agrupar-antes-de-chamar-o-agente-de-ia-no-laravel

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".

```php
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](/blog/webhooks-de-pagamento-duplicados-e-fora-de-ordem-como-processar-cada-evento-uma-unica-vez-no-laravel), aplicado a mensagens.

```php
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.

```php
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](/blog/como-transcrever-audios-do-whatsapp-com-openai-no-laravel-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](/blog/como-criar-um-agente-de-ia-com-tool-calling-do-claude-no-laravel-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.

```php
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.

```php
$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](/ferramentas/calculadora-custo-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:

```php
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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
