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:
- 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. - Tool repetida na mesma resposta. O Claude suporta parallel tool use, ou seja, pode devolver vários blocos
tool_usenuma ú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. - 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.
- 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:
- O update condicional em
takeOveré o lock. Dois workers podem tentar reassumir ao mesmo tempo, mas só um recebe1como linhas afetadas. Nada deSELECTseguido deUPDATEsem condição. locked_untilcobre o worker que morreu. Sem ele, uma execução que ficourunningpara sempre trava a ação daquele cliente. Dimensione o prazo pelo timeout real da tool, com folga.- 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 comalready_done, informe o que já foi feito e não repita. Funciona bem e evita a resposta "houve um problema, vou tentar de novo". - 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:
- Classificar as tools em leitura e escrita. Só as de escrita passam pelo executor idempotente.
- 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.
- Desligar parallel tool use no agente que escreve.
- Colocar ações de dinheiro atrás de proposta + confirmação atômica, de preferência por botão.
- 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.