Um agente de IA executa a mesma ação duas vezes por três motivos principais: o job que roda o loop do agente é reprocessado, o modelo pede a mesma tool duas vezes na mesma resposta, ou o usuário confirma em dobro. A defesa é tratar toda tool com efeito colateral como uma chamada a gateway de pagamento. Isso significa uma chave de idempotência derivada do contexto de negócio, um registro persistido de cada execução com índice único no PostgreSQL e um passo de confirmação atômico para ações sensíveis.

Aprendi isso do jeito chato. Quando você coloca um agente para agendar horários no WhatsApp ou gerar cobrança Pix, o problema deixa de ser "o modelo respondeu algo estranho" e vira "o cliente tem dois horários marcados" ou "o lojista recebeu duas cobranças". O LLM não sabe que já fez aquilo. Quem precisa saber é o seu back-end.

Por que um agente de IA executa a mesma ação duas vezes?

Antes de pensar em solução, vale mapear de onde vem a duplicação. Nos agentes que rodo em Laravel com filas no Horizon, as causas típicas são estas:

  1. Retry do job do agente. O job chama o Claude, recebe um tool_use, executa a tool (que cria o agendamento) e, antes de salvar o resultado ou responder ao usuário, estoura o timeout ou o worker morre. O Horizon reprocessa o job, o loop começa do zero, o modelo pede a mesma tool de novo e o agendamento é criado outra vez.
  2. Tool repetida na mesma resposta. O Claude suporta parallel tool use, ou seja, pode devolver vários blocos tool_use numa única resposta. De vez em quando ele devolve duas chamadas praticamente idênticas, por exemplo quando o usuário pediu "marca pra mim e pra minha esposa" e o modelo se confundiu nos parâmetros.
  3. Confirmação duplicada do usuário. O cliente manda "sim", acha que não foi e manda "sim" de novo. Ou o provedor de WhatsApp entrega o mesmo webhook duas vezes. Cada mensagem dispara um turno do agente, e cada turno pode executar a ação.
  4. O modelo esquece que já executou. Em conversas longas, com memória resumida, o modelo perde o registro de que a tool já rodou e chama de novo quando o usuário pergunta "e aí, marcou?".

O item 3 tem uma parte que se resolve antes do agente: deduplicar o webhook pelo ID da mensagem e agrupar mensagens picadas. Mas nenhum desses filtros protege contra os itens 1, 2 e 4. A proteção tem que estar na execução da tool.

Por que o tool_use_id não serve como chave de idempotência?

A primeira ideia é usar o id do bloco tool_use (algo como toolu_01...) como chave. Parece natural, porque é único por chamada. Só que ele é gerado pelo modelo a cada resposta. Quando o job é reprocessado e você chama a API de novo, o modelo devolve um tool_use com outro id, mesmo que os argumentos sejam idênticos. A chave muda exatamente no cenário em que você mais precisa que ela seja estável.

O tool_use_id continua útil para outra coisa: ligar o tool_result à chamada correspondente e rastrear a execução. Se você ainda não tem isso, o post sobre como rastrear cada tool call de um agente de IA no Laravel mostra como montar esse trace. Para idempotência, a chave precisa vir de algo que você controla e que não muda entre tentativas.

Como gerar uma chave de idempotência para tool calls?

Chave de idempotência é um identificador que representa "esta operação", de forma que repetir a operação com a mesma chave não produza um segundo efeito. Para tools de agente, eu uso duas fontes, dependendo da natureza da ação:

Tipo de ação Chave derivada de Exemplo Trade-off
Ação que só faz sentido uma vez Chave de negócio (quem + o quê) Agendar cliente X com profissional Y às 14h de terça Bloqueia repetição legítima, mas aqui repetição nunca é legítima
Ação que pode se repetir legitimamente Mensagem que disparou o turno + argumentos Gerar cobrança Pix de R$ 50 Protege retry e duplicata no mesmo turno, não protege pedido em turnos diferentes

No agendamento, a chave de negócio resolve quase tudo. Ninguém quer o mesmo horário marcado duas vezes para a mesma pessoa, então tanto faz se a duplicata veio de retry, de tool paralela ou de esquecimento.

Na cobrança, a chave de negócio seria agressiva demais: o lojista pode querer, sim, duas cobranças de R$ 50 para o mesmo cliente. Então a chave inclui o ID da mensagem de entrada que iniciou o turno. Esse ID é estável entre retries do job, porque o job carrega a mesma mensagem. Para o caso de turnos diferentes, entra a confirmação explícita, que mostro mais abaixo.

Cada tool com efeito colateral implementa a própria chave:

interface SideEffectTool
{
    public function name(): string;

    public function idempotencyKey(AgentTurn $turn, array $input): string;

    public function handle(array $input, string $idempotencyKey): array;
}

final class CreateBookingTool implements SideEffectTool
{
    public function name(): string
    {
        return 'create_booking';
    }

    public function idempotencyKey(AgentTurn $turn, array $input): string
    {
        return 'booking:' . hash('sha256', implode('|', [
            $turn->tenantId,
            $turn->customerId,
            (int) $input['professional_id'],
            Carbon::parse($input['starts_at'])->utc()->toIso8601String(),
        ]));
    }

    public function handle(array $input, string $idempotencyKey): array
    {
        // cria o agendamento e devolve dados para o modelo
    }
}

Repare na normalização. O modelo pode mandar 2025-03-11T14:00:00-03:00 numa tentativa e 2025-03-11 14:00 na outra. Se você fizer hash do input cru, a chave muda. Normalize datas para UTC, IDs para inteiro, valores monetários para centavos e strings com trim e mb_strtolower antes de compor a chave. Nunca confie que o LLM vai serializar os argumentos do mesmo jeito duas vezes.

E não dispense a constraint no domínio. Um índice único em appointments (professional_id, starts_at) continua sendo a última linha de defesa. A camada de idempotência da tool existe para devolver uma resposta coerente ao modelo em vez de uma exceção de violação de unicidade.

Como guardar o resultado da tool para o retry do job?

A chave sozinha não basta. Quando a segunda tentativa chega, você precisa devolver ao modelo o mesmo resultado da primeira, senão ele vai dizer ao usuário que deu erro. Para isso uso uma tabela de execuções:

Schema::create('agent_tool_executions', function (Blueprint $table) {
    $table->id();
    $table->string('idempotency_key')->unique();
    $table->foreignId('conversation_id')->constrained();
    $table->string('tool_name');
    $table->jsonb('input');
    $table->string('status'); // running, succeeded, failed
    $table->jsonb('result')->nullable();
    $table->timestamp('locked_until')->nullable();
    $table->timestamps();
});

O executor tenta reservar a chave com insertOrIgnore, que no PostgreSQL vira INSERT ... ON CONFLICT DO NOTHING e retorna quantas linhas foram inseridas. Se inseriu, quem executa é este processo. Se não inseriu, alguém já executou ou está executando:

final class ToolExecutor
{
    public function run(SideEffectTool $tool, AgentTurn $turn, array $input): array
    {
        $key = $tool->idempotencyKey($turn, $input);

        $reserved = DB::table('agent_tool_executions')->insertOrIgnore([
            'idempotency_key' => $key,
            'conversation_id' => $turn->conversationId,
            'tool_name' => $tool->name(),
            'input' => json_encode($input),
            'status' => 'running',
            'locked_until' => now()->addMinutes(2),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        if ($reserved === 0 && ! $this->takeOver($key)) {
            return $this->previousOutcome($key);
        }

        try {
            $result = $tool->handle($input, $key);
        } catch (Throwable $e) {
            DB::table('agent_tool_executions')->where('idempotency_key', $key)
                ->update(['status' => 'failed', 'updated_at' => now()]);

            throw $e;
        }

        DB::table('agent_tool_executions')->where('idempotency_key', $key)->update([
            'status' => 'succeeded',
            'result' => json_encode($result),
            'updated_at' => now(),
        ]);

        return $result;
    }

    private function takeOver(string $key): bool
    {
        // reassume execuções que falharam ou cujo worker morreu no meio
        return DB::table('agent_tool_executions')
            ->where('idempotency_key', $key)
            ->where(fn ($q) => $q->where('status', 'failed')
                ->orWhere(fn ($q) => $q->where('status', 'running')
                    ->where('locked_until', '<', now())))
            ->update([
                'status' => 'running',
                'locked_until' => now()->addMinutes(2),
                'updated_at' => now(),
            ]) === 1;
    }

    private function previousOutcome(string $key): array
    {
        $execution = DB::table('agent_tool_executions')
            ->where('idempotency_key', $key)->first();

        return match ($execution->status) {
            'succeeded' => json_decode($execution->result, true)
                + ['already_done' => true],
            default => [
                'already_in_progress' => true,
                'instruction' => 'Esta ação já está sendo processada. Não chame a tool de novo.',
            ],
        };
    }
}

Alguns detalhes que importam:

  1. O update condicional em takeOver é o lock. Dois workers podem tentar reassumir ao mesmo tempo, mas só um recebe 1 como linhas afetadas. Nada de SELECT seguido de UPDATE sem condição.
  2. locked_until cobre o worker que morreu. Sem ele, uma execução que ficou running para sempre trava a ação daquele cliente. Dimensione o prazo pelo timeout real da tool, com folga.
  3. O retorno com already_done é para o modelo. Em vez de erro, ele recebe os dados do agendamento existente e uma flag. No system prompt, eu instruo: se a tool voltar com already_done, informe o que já foi feito e não repita. Funciona bem e evita a resposta "houve um problema, vou tentar de novo".
  4. A tabela só cobre tools com efeito colateral. Consultar agenda, buscar pedido ou ver saldo não passa pelo executor. Registrar leitura é ruído e custo de escrita à toa.

Esse desenho conversa diretamente com a forma como o job do agente é configurado. Se você ainda está decidindo tries, timeout e backoff, o post sobre chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo complementa este: lá o foco é não pagar duas vezes pelos tokens, aqui é não executar duas vezes a ação.

O que fazer quando o Claude chama a mesma tool duas vezes na mesma resposta?

A tabela de execuções já resolve o caso de argumentos idênticos: a segunda chamada cai no previousOutcome. O problema é quando os argumentos diferem pouco e geram chaves diferentes, como dois horários vizinhos. Aí são duas ações legítimas para o sistema, e possivelmente um erro do modelo.

Para agentes que mexem com agenda ou dinheiro, eu desligo o parallel tool use. Na API de Messages da Anthropic isso é um campo dentro de tool_choice:

{
  "model": "<modelo configurado>",
  "max_tokens": 1024,
  "system": "Você é o assistente de agendamento...",
  "tools": [ { "name": "create_booking", "description": "...", "input_schema": { "type": "object" } } ],
  "tool_choice": { "type": "auto", "disable_parallel_tool_use": true },
  "messages": [ ]
}

Com isso, o modelo chama no máximo uma tool por resposta. O trade-off é latência: um fluxo que precisaria de três consultas paralelas passa a fazer três idas e voltas. Por isso eu não desligo para tudo. Agentes de consulta, que só leem, ficam com paralelismo; agentes que escrevem ficam sem. Se o mesmo agente faz as duas coisas, prefiro desligar e aceitar a latência a lidar com duas escritas concorrentes no mesmo turno. Se você está começando a montar esse loop, o post sobre como criar um agente de IA com tool calling do Claude no Laravel mostra a estrutura de tool_use e tool_result que estou assumindo aqui.

Como impedir ação duplicada quando o usuário confirma duas vezes?

Para ações sensíveis (cobrança, cancelamento, estorno), eu separo a tool em duas etapas. A primeira, propose_charge, não cobra nada. Ela cria uma ação pendente e devolve um resumo para o modelo apresentar ao usuário. A segunda, confirm_action, recebe o action_id e executa.

Schema::create('pending_actions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignId('conversation_id')->constrained();
    $table->string('type');
    $table->jsonb('payload');
    $table->string('status'); // pending, confirmed, expired
    $table->timestamp('expires_at');
    $table->timestamps();
});

A confirmação é uma transição de estado atômica:

$confirmed = DB::table('pending_actions')
    ->where('id', $actionId)
    ->where('conversation_id', $turn->conversationId)
    ->where('status', 'pending')
    ->where('expires_at', '>', now())
    ->update(['status' => 'confirmed', 'updated_at' => now()]);

if ($confirmed === 0) {
    return ['already_confirmed_or_expired' => true];
}

// executa a cobrança usando o action_id como chave de idempotência

O segundo "sim" chega, o modelo chama confirm_action com o mesmo action_id e recebe already_confirmed_or_expired. Não há segunda cobrança. O filtro por conversation_id impede que um action_id vazado ou inventado pelo modelo confirme a ação de outra conversa, e o expires_at evita que um "sim" de amanhã confirme uma proposta de hoje.

Existe um passo além: tirar a confirmação das mãos do modelo. No WhatsApp, dá para mandar a proposta como mensagem interativa com botão, cujo payload carrega o action_id. Quando o webhook do clique chega, o seu código confirma direto, sem passar pelo LLM. É mais seguro, porque nenhum texto manipulado convence o modelo a confirmar sozinho, mas custa flexibilidade: o usuário que digita "pode mandar" em vez de clicar precisa de um caminho alternativo. Eu uso botão para dinheiro e confirmação via modelo para agenda.

Como propagar a idempotência até o gateway de pagamento?

A tabela de execuções protege o seu lado. Mas se a tool chama um provedor como Asaas, Pagar.me ou Stripe e a requisição dá timeout, você não sabe se a cobrança foi criada. O takeOver vai reexecutar a tool, e sem cuidado o provedor cria a segunda cobrança.

Por isso o handle recebe a chave. Ela é repassada ao provedor como Idempotency-Key quando a API suporta, ou usada como external_reference para consultar antes de criar quando não suporta. Detalhei os dois caminhos no post sobre timeout no gateway de pagamento e Idempotency-Key no Laravel. A ideia é uma chave só atravessando todas as camadas: turno do agente, execução da tool, chamada ao provedor.

Qual defesa resolve cada causa de duplicação?

Causa Defesa principal Defesa complementar
Retry do job do agente Tabela de execuções com chave estável Idempotency-Key no provedor
Tool repetida na mesma resposta disable_parallel_tool_use em agentes que escrevem Chave de negócio
"Sim" duplicado / webhook duplicado Ação pendente com transição atômica Dedupe do webhook pelo ID da mensagem
Modelo esquece que executou Chave de negócio + retorno already_done Registrar ações executadas na memória do agente
Worker morre no meio locked_until + takeOver condicional Constraint única no domínio

Para testar, simulo cada linha dessa tabela. Com Http::fake devolvo duas respostas seguidas do Claude com o mesmo tool_use e ids diferentes, rodo o job duas vezes e verifico que existe um agendamento só e que a segunda resposta ao usuário menciona o agendamento existente. Também testo o caso do running expirado, que é o mais fácil de esquecer.

Conclusão: trate tool com efeito colateral como endpoint de pagamento

Se eu tivesse que reduzir tudo a uma regra, seria esta: o LLM é um cliente não confiável que vai repetir requisições, e você já sabe lidar com clientes assim. O caminho que eu sigo em todo agente novo:

  1. Classificar as tools em leitura e escrita. Só as de escrita passam pelo executor idempotente.
  2. Definir a chave de cada tool de escrita: chave de negócio quando a repetição nunca é legítima, mensagem do turno + argumentos normalizados quando pode ser.
  3. Desligar parallel tool use no agente que escreve.
  4. Colocar ações de dinheiro atrás de proposta + confirmação atômica, de preferência por botão.
  5. Repassar a mesma chave ao provedor externo e manter a constraint única no domínio.

Nada disso depende do modelo acertar. E esse é o ponto. O prompt ajuda o agente a se comportar bem, mas quem garante que o cliente não vai receber duas cobranças é o PostgreSQL.