Blog

Idempotência em tool use: evitar ação duplicada do agente

Um agente que só lê o mundo pode ser retentado à vontade: se a chamada falha, você tenta de novo e nada de mau acontece. O problema começa quando a ferramenta escreve, quando ela cobra um cartão, cria um pedido, envia uma mensagem ou dispara um estorno. Aí cada retry passa a ser perigoso, porque o mundo mudou entre a primeira tentativa e a segunda, e repetir a ação duplica o efeito. E retries são a regra, não a exceção, num sistema com LLM: o modelo é reexecutado quando a resposta vem malformada, o loop de tool use é retomado depois de uma queda, o timeout de rede dispara a tentativa antes de a primeira ter terminado. A idempotência é a propriedade que separa "executei a ação uma vez" de "executei a ação quantas vezes o loop precisou tentar". Este artigo mostra por que o agente duplica ação com tanta facilidade, como derivar uma chave de idempotência que sobrevive ao retry, o padrão de janela de deduplicação que grava o resultado da primeira execução, a diferença entre uma ferramenta naturalmente idempotente e uma que precisa ser blindada, e como testar que a duplicata realmente não passa.

2026-07-18 / IA Aplicada / 13 min

01

Por que o agente duplica ação com tanta facilidade

O ponto mais frágil de um loop de tool use é a fronteira entre "a ferramenta executou" e "o agente soube que executou". Entre esses dois momentos há uma rede, um processo que pode cair, um timeout que pode disparar. A ferramenta cobra o cartão com sucesso, mas a resposta se perde no caminho de volta; o agente, sem confirmação, conclui que a chamada falhou e tenta de novo. Do ponto de vista do modelo foi uma única intenção, "cobrar o cliente"; do ponto de vista do mundo foram duas cobranças. O agente não duplica por burrice, duplica porque a única informação que ele tem é a ausência de resposta, e ausência de resposta é indistinguível de falha real.

Some a isso a natureza probabilística do LLM. O modelo pode gerar a mesma tool call duas vezes na mesma conversa porque perdeu o rastro de que já a fez, pode reemitir uma ação depois que o contexto foi truncado, pode reagir a um erro de parsing repetindo o passo inteiro. E o loop de orquestração, tentando ser robusto, adiciona sua própria camada de retry por cima. O resultado é que a mesma ação de escrita tem várias fontes independentes de repetição, e nenhuma delas sabe da outra. Sem uma defesa explícita, a pergunta não é se a ação vai duplicar, é quando.

  • Retry do loop de orquestração: a chamada de rede deu timeout e o loop tenta de novo, sem saber se a primeira chegou a executar do outro lado.
  • Reexecução do modelo: a resposta veio malformada, o passo inteiro é refeito e a tool call de escrita é emitida uma segunda vez.
  • Retomada apos queda: o processo caiu no meio do loop, o estado durável é recarregado e a etapa em andamento roda de novo.
  • Repeticao pelo proprio LLM: o modelo perde o rastro do que ja fez e gera a mesma acao mais de uma vez na mesma conversa.

02

A chave de idempotência: derivar da intenção, não do acaso

A defesa começa com uma chave que identifica a intenção, não a tentativa. A ideia é que todas as repetições de uma mesma ação lógica carreguem a mesma chave, para que o lado que executa consiga reconhecer "isto eu já fiz". O erro clássico é gerar a chave dentro do retry: se cada tentativa cria uma chave nova aleatória, o deduplicador vê duas ações distintas e executa as duas. A chave tem que nascer antes do loop de retry e sobreviver a ele, presa à intenção, não ao instante.

Há duas formas de conseguir isso. A primeira é a chave explícita: o orquestrador gera um identificador único quando decide fazer a ação, uma vez, e o repassa em toda tentativa daquela ação. A segunda é a chave derivada: você calcula um hash determinístico dos parâmetros que definem a ação, de modo que a mesma intenção sempre produza a mesma chave sem precisar carregar um id. A derivada é atraente porque não exige estado extra, mas tem uma armadilha: se dois pedidos legítimos e distintos tiverem exatamente os mesmos parâmetros, eles colapsam na mesma chave e o segundo é engolido como se fosse duplicata. Por isso a chave derivada precisa incluir algo que distinga intenções genuinamente diferentes.

// idempotency/key.js
// Deriva uma chave de idempotencia a partir da intencao da acao.
// A mesma intencao logica -> a mesma chave, em toda tentativa.
import { createHash } from 'node:crypto';

export function idempotencyKey({ tool, actor, params, intentId }) {
  // intentId e gerado UMA vez, quando a acao e decidida, e repassado em
  // todo retry. E o que garante que duas intencoes legitimas com os mesmos
  // parametros (ex.: dois estornos iguais) NAO colapsem na mesma chave.
  const canonical = JSON.stringify({
    tool,                       // qual ferramenta
    actor,                      // em nome de quem (evita cruzar clientes)
    params: sortKeys(params),   // parametros normalizados e ordenados
    intentId,                   // desambigua intencoes distintas iguais
  });
  return createHash('sha256').update(canonical).digest('hex');
}

// Ordena as chaves para que { a, b } e { b, a } gerem o MESMO hash:
// a ordem das chaves do objeto nao pode mudar a identidade da acao.
function sortKeys(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (Array.isArray(obj)) return obj.map(sortKeys);
  return Object.keys(obj)
    .sort()
    .reduce((acc, k) => ((acc[k] = sortKeys(obj[k])), acc), {});
}

Repare no detalhe da normalização: `{ valor: 10, moeda: "BRL" }` e `{ moeda: "BRL", valor: 10 }` descrevem a mesma cobrança e precisam gerar a mesma chave, senão duas tentativas da mesma ação, serializadas em ordens diferentes, escapariam da deduplicação. Ordenar as chaves antes do hash é o que torna a chave estável. E incluir o `actor` na chave evita o pior tipo de bug: duas intenções de clientes diferentes com os mesmos parâmetros compartilharem uma chave e um cliente receber o resultado do outro.

03

A janela de deduplicação: gravar o resultado da primeira execução

Com a chave em mãos, o mecanismo é uma janela de deduplicação: um store que, para cada chave, guarda o estado da ação. Antes de executar, a ferramenta consulta o store. Se a chave é nova, ela reserva a chave, executa, grava o resultado e o devolve. Se a chave já existe e a ação terminou, ela devolve o resultado gravado sem executar nada. O ponto sutil, que separa uma defesa que funciona de uma que só parece funcionar, é o que acontece quando a chave existe mas a ação ainda está em andamento: duas tentativas quase simultâneas não podem executar as duas. É por isso que a reserva da chave e a checagem precisam ser atômicas, um "insere se não existe" que só um dos concorrentes vence.

Fluxo de uma chamada de ferramenta com idempotencia

  tool call + chave
        |
        v
  [ INSERE-SE-NAO-EXISTE a chave no store ]  <- atomico: so um vence
        |
     +--+---------------------------+
     | ganhou (chave nova)          | perdeu (chave ja existe)
     v                              v
  [ executa a acao de escrita ]   [ le o estado gravado ]
     |                              |
     v                         +----+-----------------+
  [ grava resultado ]         | done -> devolve o     | in-flight -> espera
     |                        | resultado gravado     | ou sinaliza retry
     v                        v                       v
  [ devolve resultado ]     (nao executa de novo)   (nao executa em paralelo)

  a acao de escrita roda UMA vez por chave; todo retry cai no ramo direito.
// idempotency/withIdempotency.js
// Envolve uma ferramenta de escrita com uma janela de deduplicacao.
// A acao roda no maximo uma vez por chave; retries devolvem o gravado.

export function withIdempotency(store, execute) {
  return async function idempotent(key, args) {
    // 1) Reserva atomica: so a PRIMEIRA tentativa consegue inserir a chave.
    const reserved = await store.putIfAbsent(key, { status: 'in-flight' });

    if (!reserved) {
      // 2) Chave ja existe: outra tentativa esta cuidando desta acao.
      const prior = await store.get(key);
      if (prior.status === 'done') return prior.result; // devolve o gravado
      // Ainda em andamento: NAO executa em paralelo. Espera e reconsulta,
      // ou sinaliza ao chamador para tentar de novo mais tarde.
      throw new InFlightError(key);
    }

    try {
      // 3) So o vencedor da reserva executa a acao de escrita de fato.
      const result = await execute(args);
      await store.put(key, { status: 'done', result });
      return result;
    } catch (err) {
      // Falhou de verdade: libera a chave para uma retentativa legitima.
      await store.delete(key);
      throw err;
    }
  };
}

Três decisões de projeto moram nesse wrapper. A primeira é o tratamento do estado in-flight: liberar a chave cedo demais reabre a janela para duplicar; segurá-la para sempre trava uma ação que falhou de verdade. A segunda é o TTL da chave: ela não pode viver eternamente, senão o store cresce sem limite, mas precisa viver mais que o maior intervalo de retry possível, senão a chave expira antes do retry chegar e a duplicata passa. A terceira é o que fazer quando a própria execução falha: liberar a chave para permitir uma retentativa legítima, mas com o cuidado de não liberar uma ação que talvez tenha executado do outro lado apesar do erro, que é exatamente o caso ambíguo do timeout.

04

Ferramentas naturalmente idempotentes versus as que precisam de blindagem

Nem toda ferramenta precisa do wrapper. A idempotência é primeiro uma propriedade da operação, e só depois um mecanismo que você adiciona. Uma leitura é idempotente por natureza: consultar o saldo dez vezes devolve o saldo dez vezes sem alterar nada. Uma escrita que define um valor absoluto também costuma ser: "marque este pedido como pago" produz o mesmo estado final quantas vezes rodar. O perigo mora nas operações relativas e nas que criam: "adicione dez ao saldo" soma dez a cada execução; "crie um pedido" cria um pedido novo por tentativa. Essas são as que a chave de idempotência precisa proteger.

Tipo de operaçãoIdempotente por natureza?O que fazer
Leitura (consultar saldo, status)Sim, não altera estadoPode retentar à vontade, sem chave
Escrita absoluta (definir status = pago)Sim, mesmo estado finalSegura para retry; chave opcional
Escrita relativa (somar ao saldo)Não, acumula a cada tentativaExige chave de idempotência
Criação (novo pedido, nova cobrança)Não, cria um por tentativaExige chave; muitas APIs têm campo próprio
Envio externo (mensagem, e-mail)Não, entrega duplicada visívelExige chave; deduplicar antes de enviar

A boa notícia é que muitas APIs externas já oferecem idempotência nativa: gateways de pagamento aceitam um cabeçalho de chave de idempotência, filas têm deduplicação por id de mensagem, bancos de dados dão upsert por chave única. Quando o provedor oferece, a melhor jogada é usar a defesa dele em vez de reinventá-la, passando a sua chave derivada para o campo que ele espera. O wrapper próprio fica para as ferramentas que não têm defesa nativa, um envio interno, uma ação composta, uma escrita num sistema legado. A regra prática é classificar cada ferramenta antes de blindar: se ela já é idempotente ou o provedor já protege, não adicione uma camada que só custa latência e um store para manter.

05

O caso ambíguo: quando você não sabe se a ação executou

O cenário que separa uma defesa madura de uma ingênua é o timeout na ação de escrita. Você mandou cobrar, o tempo estourou e a resposta nunca voltou. A ação executou do outro lado e a confirmação se perdeu, ou ela nem chegou a executar? As duas hipóteses produzem exatamente o mesmo sintoma local: silêncio. Retentar cegamente arrisca a dupla cobrança se a primeira tinha passado; desistir arrisca deixar o cliente sem o pedido se a primeira tinha falhado. Não há como decidir olhando só para o lado de cá.

É aqui que a chave de idempotência muda o jogo: com ela, o retry deixa de ser uma aposta e vira uma pergunta segura. Reenviar a ação com a mesma chave é seguro por construção, porque o lado que executa reconhece a chave e, se a primeira tentativa tinha passado, devolve o resultado gravado em vez de executar de novo; se não tinha passado, executa agora. O retry idempotente resolve a ambiguidade do timeout sem você precisar saber a verdade: a mesma chamada é ao mesmo tempo a confirmação e a segunda tentativa. Onde a API externa oferece uma consulta de status por chave, melhor ainda: você pergunta "esta ação existe?" antes de reenviar. Mas mesmo sem essa consulta, a chave transforma o timeout de uma armadilha numa situação recuperável.

  1. Nunca retente uma ação de escrita sem chave apos um timeout: você não sabe se a primeira executou, e a duplicata é invisível até o cliente reclamar.
  2. Gere a chave antes do primeiro envio e reutilize-a em todo retry daquela ação; a chave é da intenção, e uma só por intenção.
  3. Prefira a idempotência nativa do provedor quando ela existir, passando sua chave ao campo esperado, em vez de recriar a janela por fora.
  4. Defina o TTL da chave maior que o maior intervalo de retry possível, para a chave nunca expirar antes de a última tentativa chegar.
  5. Trate o estado in-flight com cuidado: não execute em paralelo duas tentativas da mesma chave nem libere a chave antes de saber o desfecho.

A idempotência não elimina os retries, ela os torna seguros. Num sistema com LLM, onde o loop retenta, o modelo reexecuta e o processo retoma depois de cair, tentar de novo é inevitável e desejável, é o que dá robustez. O que não pode acontecer é que essa robustez se pague com efeitos duplicados no mundo real. A chave de idempotência é o contrato que permite retentar sem medo: você tenta quantas vezes precisar, e a ação acontece uma vez só.

06

Testar que a duplicata realmente não passa

Uma defesa de idempotência que nunca foi exercitada sob duplicação é uma suposição, não uma garantia. O teste que importa não é "a ferramenta funciona quando chamada uma vez", é "a ferramenta chamada duas vezes com a mesma chave produz um efeito só". Isso exige simular exatamente as condições que geram duplicata: a mesma chave chegando duas vezes, as duas tentativas quase simultâneas disputando a reserva, o timeout que deixa a ação em andamento e é retentado. Um teste que só verifica o caminho feliz sequencial passa mesmo quando a atomicidade da reserva está quebrada.

// idempotency/withIdempotency.test.js
// Testa a propriedade que importa: mesma chave -> efeito unico,
// inclusive sob concorrencia e sob retry apos timeout.
import { test, expect, vi } from 'vitest';
import { withIdempotency } from './withIdempotency.js';
import { memoryStore } from './memoryStore.js';

test('duas chamadas com a mesma chave executam a acao UMA vez', async () => {
  const charge = vi.fn(async () => ({ id: 'ch_1', ok: true }));
  const idempotent = withIdempotency(memoryStore(), charge);

  const r1 = await idempotent('key-abc', { amount: 10 });
  const r2 = await idempotent('key-abc', { amount: 10 }); // retry

  expect(charge).toHaveBeenCalledTimes(1);   // a escrita rodou 1x
  expect(r2).toEqual(r1);                     // o retry devolveu o gravado
});

test('duas tentativas concorrentes nao executam as duas', async () => {
  let running = 0;
  const charge = vi.fn(async () => {
    running += 1;
    expect(running).toBe(1);                  // nunca ha 2 execucoes juntas
    await new Promise((r) => setTimeout(r, 5));
    running -= 1;
    return { id: 'ch_1' };
  });
  const idempotent = withIdempotency(memoryStore(), charge);

  // Dispara as duas ao mesmo tempo, disputando a reserva atomica.
  const results = await Promise.allSettled([
    idempotent('key-xyz', { amount: 10 }),
    idempotent('key-xyz', { amount: 10 }),
  ]);

  // No maximo uma executou; a outra caiu no ramo de deduplicacao.
  expect(charge).toHaveBeenCalledTimes(1);
  expect(results.some((r) => r.status === 'fulfilled')).toBe(true);
});

Além do teste automatizado, vale monitorar a duplicação em produção com uma métrica simples: a taxa de acertos da janela de deduplicação, ou seja, quantas tentativas caíram no ramo "chave já existe". Um valor perto de zero pode significar que a defesa nunca é exercida, mas também pode significar que a chave está sendo gerada errada e cada tentativa cria uma chave nova, caso em que a proteção não existe de fato. Um pico súbito nessa métrica costuma denunciar um loop de retry mal calibrado disparando muitas repetições. A idempotência é uma daquelas propriedades que só se sabe que está funcionando quando se mede o quanto ela está barrando, porque o efeito dela é justamente uma ausência: a duplicata que não aconteceu.

FAQ

Perguntas frequentes

O que é uma chave de idempotência e por que ela é da intenção, não da tentativa?

Uma chave de idempotência é um identificador que marca uma ação lógica única, de modo que todas as tentativas de executar essa mesma ação carreguem a mesma chave. É por isso que ela precisa ser da intenção e não da tentativa: se cada retry gerasse uma chave nova, o mecanismo de deduplicação veria ações distintas e executaria todas, exatamente o que você queria evitar. A chave tem que nascer antes do loop de retry, presa à intenção de fazer aquela ação, e ser reutilizada em cada tentativa. Ela pode ser explícita, um id gerado uma vez quando a ação é decidida, ou derivada, um hash determinístico dos parâmetros normalizados da ação. A derivada dispensa carregar estado, mas precisa incluir algo que distinga intenções genuinamente diferentes com os mesmos parâmetros, para não colapsar dois pedidos legítimos numa chave só.

Toda ferramenta de um agente precisa de idempotência?

Não. A idempotência só importa para ações que mudam o estado do mundo e cujo efeito se acumula ou se duplica a cada execução. Leituras, como consultar um saldo ou um status, são idempotentes por natureza e podem ser retentadas à vontade sem defesa nenhuma. Escritas absolutas, do tipo "defina este pedido como pago", também tendem a ser seguras porque produzem o mesmo estado final quantas vezes rodem. Quem precisa da chave são as operações relativas, como "some dez ao saldo", e as de criação e envio, como "crie um pedido" ou "envie esta mensagem", porque cada tentativa gera um efeito novo e visível. A regra é classificar a operação antes de blindar: adicionar uma janela de deduplicação a uma ferramenta que já é idempotente só custa latência e um store para manter, sem ganho.

Como a idempotência resolve o retry após um timeout?

O timeout numa ação de escrita é ambíguo: a ação pode ter executado do outro lado e a confirmação ter se perdido, ou pode nem ter chegado a executar, e o sintoma local é o mesmo silêncio nos dois casos. Sem idempotência, retentar arrisca duplicar a ação e desistir arrisca deixá-la sem efeito, e não há como decidir olhando só para o seu lado. Com uma chave de idempotência, o retry deixa de ser uma aposta: reenviar a mesma ação com a mesma chave é seguro por construção, porque o lado que executa reconhece a chave e, se a primeira tentativa tinha passado, devolve o resultado gravado em vez de executar de novo; se não tinha passado, executa agora. A mesma chamada funciona ao mesmo tempo como confirmação e como segunda tentativa, então você recupera o timeout sem precisar saber qual das duas hipóteses era a verdadeira.

Idempotência é o que torna o retry do agente seguro

Num sistema com LLM os retries são inevitáveis: o loop retenta, o modelo reexecuta, o processo retoma depois de cair. Sem uma chave de idempotência derivada da intenção e uma janela de deduplicação que grava o resultado da primeira execução, cada uma dessas repetições vira uma cobrança dupla, um pedido duplicado ou uma mensagem enviada duas vezes. Posso desenhar essa camada de idempotência nas ferramentas de escrita do seu agente, classificando cada operação, escolhendo entre a defesa nativa do provedor e o wrapper próprio, e testando que a duplicata realmente não passa, para que o agente possa tentar de novo quantas vezes precisar sem mexer duas vezes no mundo real.