
Idempotência em Tool Calls: o Bug Que Ninguém Vê Até o Agente de IA Duplicar uma Cobrança
Um agente de IA chama a ferramenta charge_customer. A chamada HTTP demora mais que o esperado, o timeout dispara, e o próprio modelo (não um script, o modelo) decide tentar de novo. Ou o agente está no meio de uma tarefa longa, o histórico da conversa passa por uma compactação de contexto, e ao retomar o raciocínio ele "esquece" que já tinha completado aquele passo e o refaz. Ou o usuário simplesmente pediu "tenta de novo, acho que travou" sem saber que a primeira chamada já tinha dado certo.
Em qualquer um desses três casos, o cliente é cobrado duas vezes. Não é hipótese de paper acadêmico. É uma classe de falha que aparece assim que um agente deixa de responder perguntas e passa a operar sistemas, e que costuma ser descoberta depois do primeiro incidente.
Por que isso é diferente do retry de API tradicional
Engenharia de confiabilidade sempre soube lidar com retries: exponential backoff, circuit breakers, entrega at-least-once com deduplicação no consumidor. O que muda com agentes de IA é quem decide chamar a ferramenta de novo.
Num sistema tradicional, quem faz o retry é código determinístico. Ele sabe exatamente por que está tentando de novo (timeout, 503, conexão caiu) e normalmente foi escrito com alguma noção de idempotência em mente, ou pelo menos com uma política de retry previsível.
Num agente de IA, o "cliente" é um processo de raciocínio probabilístico. Ele pode chamar a mesma ferramenta de novo por motivos que não têm nada a ver com falha de rede:
- Incerteza do próprio modelo: a resposta da ferramenta não ficou clara no contexto, e o modelo "prefere" tentar de novo a admitir que não sabe se funcionou.
- Compactação de contexto: sessões longas de agente costumam resumir o histórico para caber na janela de contexto. Se o resumo não preservar com precisão "o passo X já foi executado com sucesso", o agente pode reconstruir o plano do zero e repetir passos que já tinham efeito colateral real.
- Um novo turno retomando o mesmo objetivo: o usuário volta numa sessão diferente e pede para "continuar" ou "garantir que aconteceu", e o agente, sem visibilidade perfeita do que já rodou, refaz o trabalho por segurança.
Nenhum desses cenários é coberto por axios-retry ou por um circuit breaker. O problema não está na camada de transporte; está em como o agente decide agir.
O padrão: chave de idempotência na fronteira da tool
A técnica não é nova. É o mesmo padrão que APIs de pagamento como a do Stripe usam há anos: toda operação com efeito colateral recebe uma chave de idempotência, e o backend garante que a mesma chave nunca produz o efeito duas vezes.
O que muda com agentes é de onde a chave vem. Ela precisa ser determinística em relação à intenção: igual na chamada original e em qualquer repetição dela. Uma chave aleatória por chamada anula a deduplicação, porque cada tentativa nova passa por ela como se fosse outra cobrança. Em ordem de preferência:
- Uma chave natural do domínio. Se a ferramenta cobra uma fatura, a fatura é a identidade da cobrança:
charge:${invoiceId}. O modelo não precisa lembrar de nada, porque a chave sai do próprio pedido. - Uma chave injetada pelo orquestrador. Para ações sem identidade natural, como enviar um e-mail, quem executa o agente pode derivar a chave do id da tarefa e do passo do plano e passá-la para a ferramenta, fora do alcance do modelo.
- Uma chave escolhida pelo modelo, com instruções na descrição da ferramenta. É o último recurso, e falha justamente no cenário que motivou o padrão: depois de uma compactação de contexto, o modelo pode não lembrar mais qual chave usou.
A primeira opção também muda o desenho da ferramenta. Em vez de "cobre R$ X do cliente Y", ela passa a ser "cobre a fatura Z", e o valor vem do backend, não do modelo:
// schema da tool exposta ao agente (JSON Schema)
const chargeCustomerTool = {
name: 'charge_customer',
description:
'Cobra uma fatura do cliente. Cobrar a mesma fatura de novo devolve o resultado ' +
'da primeira cobrança, sem cobrar outra vez.',
input_schema: {
type: 'object',
properties: {
invoiceId: {
type: 'string',
description: 'A fatura a cobrar. Ela é a identidade da cobrança.',
},
},
required: ['invoiceId'],
},
};
No backend, o detalhe que mais derruba implementações é a ordem. Consultar a chave, cobrar e só depois gravar parece certo, mas duas chamadas simultâneas passam juntas pela consulta e cobram duas vezes; a constraint UNIQUE só reclama no INSERT, depois que o dinheiro já saiu. A chave tem de ser reservada antes do efeito colateral:
// charges.service.ts (NestJS + Drizzle)
async chargeInvoice(invoiceId: string): Promise<ChargeResult> {
const key = `charge:${invoiceId}`;
// 1. Reserva. A UNIQUE em idempotency_keys.key garante um único vencedor,
// mesmo com duas chamadas chegando ao mesmo tempo.
const [reserved] = await this.db
.insert(idempotencyKeys)
.values({ key, status: 'in_progress' })
.onConflictDoNothing({ target: idempotencyKeys.key })
.returning();
if (!reserved) {
const existing = await this.db.query.idempotencyKeys.findFirst({
where: eq(idempotencyKeys.key, key),
});
// Já concluída: devolve o resultado gravado, sem cobrar de novo.
if (existing?.status === 'done') return existing.result;
// Outra chamada está no meio da cobrança: o agente recebe "em andamento".
throw new ConflictException('Esta cobrança já está em andamento.');
}
const invoice = await this.invoices.findById(invoiceId);
// 2. A mesma chave segue para o provedor (no Stripe, o header Idempotency-Key).
// Se o processo cair entre a cobrança e o passo 3, a próxima tentativa não cobra de novo.
const result = await this.paymentProvider.charge(invoice.customerId, invoice.amountCents, {
idempotencyKey: key,
});
// 3. Conclui a reserva com o resultado.
await this.db
.update(idempotencyKeys)
.set({ status: 'done', result })
.where(eq(idempotencyKeys.key, key));
return result;
}
Duas proteções trabalham juntas aqui. A reserva no banco impede a segunda cobrança enquanto a primeira está em andamento e depois dela. A chave repassada ao provedor cobre o buraco entre cobrar e gravar o resultado. O provedor, porém, só guarda a chave por um tempo (o Stripe pode descartá-la depois de 24 horas), por isso a reserva no banco continua sendo a proteção de longo prazo.
Falta uma decisão que o código acima deixa de fora de propósito: uma reserva que ficou em in_progress porque o processo morreu no meio ou porque a chamada ao provedor estourou o timeout. Ela precisa expirar, ou a fatura nunca mais é cobrada. Como o provedor também recebe a chave, quem assumir uma reserva vencida pode repetir a cobrança com segurança, desde que o provedor ainda guarde a chave (no Stripe, até 24 horas depois da primeira chamada); passado esse prazo, a mesma chave pode gerar uma cobrança nova. Por isso a reserva tem de expirar bem antes dessa janela. Falha definitiva é outro caso: como não há try/catch, uma exceção em charge (um cartão recusado, por exemplo) também deixa a reserva em in_progress, e o agente passa a receber "em andamento" para uma cobrança que já falhou. Essa falha precisa ser capturada e gravada, e a nova tentativa precisa de outra chave (com o número da tentativa, por exemplo), porque o Stripe devolve o mesmo erro para a mesma chave.
Nem toda ferramenta precisa disso, mas mais precisam do que parece
A regra prática: se chamar a ferramenta duas vezes com a mesma intenção pode deixar o mundo num estado visivelmente diferente de chamar uma vez, ela precisa de idempotência. Isso cobre muito mais do que pagamento:
send_email: duplicar um e-mail transacional é, na melhor das hipóteses, constrangedor; num e-mail de "sua senha foi alterada" ou "reembolso processado", pode gerar pânico ou confusão de verdade.create_support_ticket: dois tickets idênticos para o mesmo problema poluem a fila e fazem o time humano perder tempo descobrindo que é duplicata.post_comment,create_orderou qualquerINSERTdisparado por uma tool: sem uma chave natural para deduplicar, o agente pode criar duas linhas para uma única intenção do usuário.
Ferramentas somente leitura (get_order_status, search_products) não precisam de nada disso: chamar duas vezes não muda o estado do mundo. A pergunta só precisa ser feita nas tools com efeito colateral, e precisa ser feita no desenho da ferramenta, não descoberta depois de um incidente.
Por que isso importa mais agora do que há dois anos
A compactação de contexto, a técnica que permite a um agente manter uma sessão longa sem estourar a janela do modelo, é exatamente o tipo de mecanismo que pode apagar o registro preciso de "esse passo já rodou". As sessões de agente estão ficando mais longas, mais autônomas e cada vez menos acompanhadas por um humano confirmando cada ação antes de ela acontecer.
Isso muda uma premissa da engenharia de confiabilidade: não dá mais para assumir que quem chama a API é código bem-comportado com uma política de retry sensata. Pode ser um modelo que decide tentar de novo por razões que nenhuma política de retry previu, e a única defesa que funciona independente do motivo é tornar a própria ação segura de executar mais de uma vez.
Conclusão
Idempotência sempre foi boa prática em APIs. Com agentes de IA executando ações reais (cobrar, enviar, criar, apagar), ela deixa de ser boa prática e vira requisito de desenho de qualquer tool com efeito colateral, porque o padrão de retry de quem chama não é mais algo que você controla ou consegue prever.
Antes de expor uma ferramenta a um agente, pergunte: o que acontece se ela for chamada duas vezes com a mesma intenção? Se a resposta incomoda, a chave de idempotência não é opcional. E, se der para escolher, que seja uma chave que o modelo não precisa lembrar.
Comentários
Carregando comentários...