
Idempotencia en Tool Calls: el Bug Que Nadie Ve Hasta Que el Agente de IA Duplica un Cobro
Un agente de IA llama a la herramienta charge_customer. La llamada HTTP tarda más de lo esperado, salta el timeout, y el propio modelo (no un script, el modelo) decide intentarlo de nuevo. O el agente está en medio de una tarea larga, el historial de la conversación pasa por una compactación de contexto, y al retomar el razonamiento "olvida" que ya había completado ese paso y lo rehace. O el usuario simplemente pidió "inténtalo de nuevo, creo que se trabó" sin saber que la primera llamada ya había funcionado.
En cualquiera de estos tres casos, al cliente se le cobra dos veces. No es una hipótesis de paper académico. Es una clase de falla que aparece en cuanto un agente deja de responder preguntas y pasa a operar sistemas, y que suele descubrirse después del primer incidente.
Por qué esto es distinto del retry de API tradicional
La ingeniería de confiabilidad siempre supo manejar retries: exponential backoff, circuit breakers, entrega at-least-once con deduplicación en el consumidor. Lo que cambia con los agentes de IA es quién decide volver a llamar a la herramienta.
En un sistema tradicional, quien hace el retry es código determinista. Sabe exactamente por qué lo intenta de nuevo (timeout, 503, se cayó la conexión) y normalmente fue escrito con alguna noción de idempotencia en mente, o al menos con una política de retry predecible.
En un agente de IA, el "cliente" es un proceso de razonamiento probabilístico. Puede volver a llamar a la misma herramienta por motivos que no tienen nada que ver con una falla de red:
- Incertidumbre del propio modelo: la respuesta de la herramienta no quedó clara en el contexto, y el modelo "prefiere" intentarlo de nuevo antes que admitir que no sabe si funcionó.
- Compactación de contexto: las sesiones largas de agente suelen resumir el historial para que quepa en la ventana de contexto. Si el resumen no conserva con precisión "el paso X ya se ejecutó con éxito", el agente puede reconstruir el plan desde cero y repetir pasos que ya tenían un efecto secundario real.
- Un nuevo turno retomando el mismo objetivo: el usuario vuelve en otra sesión y pide "continuar" o "asegurarse de que pasó", y el agente, sin visibilidad perfecta de lo que ya corrió, rehace el trabajo por seguridad.
Ninguno de estos escenarios lo cubre axios-retry ni un circuit breaker. El problema no está en la capa de transporte; está en cómo el agente decide actuar.
El patrón: clave de idempotencia en la frontera de la tool
La técnica no es nueva. Es el mismo patrón que APIs de pago como la de Stripe usan desde hace años: toda operación con efecto secundario recibe una clave de idempotencia, y el backend garantiza que la misma clave nunca produce el efecto dos veces.
Lo que cambia con los agentes es de dónde sale la clave. Tiene que ser determinista respecto de la intención: la misma en la llamada original y en cualquier repetición. Una clave aleatoria por llamada anula la deduplicación, porque cada intento nuevo pasa por ella como si fuera otro cobro. En orden de preferencia:
- Una clave natural del dominio. Si la herramienta cobra una factura, la factura es la identidad del cobro:
charge:${invoiceId}. El modelo no tiene que recordar nada, porque la clave sale del propio pedido. - Una clave inyectada por el orquestador. Para acciones sin identidad natural, como enviar un e-mail, quien ejecuta el agente puede derivar la clave del id de la tarea y del paso del plan y pasársela a la herramienta, fuera del alcance del modelo.
- Una clave elegida por el modelo, con instrucciones en la descripción de la herramienta. Es el último recurso, y falla justo en el escenario que motivó el patrón: después de una compactación de contexto, el modelo puede ya no recordar qué clave usó.
La primera opción también cambia el diseño de la herramienta. En lugar de "cobra X al cliente Y", pasa a ser "cobra la factura Z", y el monto viene del backend, no del modelo:
// schema de la tool expuesta al agente (JSON Schema)
const chargeCustomerTool = {
name: 'charge_customer',
description:
'Cobra una factura del cliente. Cobrar la misma factura de nuevo devuelve el resultado ' +
'del primer cobro, sin cobrar otra vez.',
input_schema: {
type: 'object',
properties: {
invoiceId: {
type: 'string',
description: 'La factura a cobrar. Es la identidad del cobro.',
},
},
required: ['invoiceId'],
},
};
En el backend, el detalle que más tumba implementaciones es el orden. Consultar la clave, cobrar y solo después guardarla parece correcto, pero dos llamadas simultáneas pasan juntas por la consulta y cobran dos veces; la constraint UNIQUE solo se queja en el INSERT, cuando el dinero ya salió. La clave tiene que reservarse antes del efecto secundario:
// charges.service.ts (NestJS + Drizzle)
async chargeInvoice(invoiceId: string): Promise<ChargeResult> {
const key = `charge:${invoiceId}`;
// 1. Reserva. La UNIQUE en idempotency_keys.key garantiza un único ganador,
// incluso con dos llamadas llegando al mismo tiempo.
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),
});
// Ya terminada: devuelve el resultado guardado, sin cobrar de nuevo.
if (existing?.status === 'done') return existing.result;
// Otra llamada está en medio del cobro: el agente recibe "en curso".
throw new ConflictException('Este cobro ya está en curso.');
}
const invoice = await this.invoices.findById(invoiceId);
// 2. La misma clave va al proveedor (en Stripe, el header Idempotency-Key).
// Si el proceso cae entre el cobro y el paso 3, el siguiente intento no cobra de nuevo.
const result = await this.paymentProvider.charge(invoice.customerId, invoice.amountCents, {
idempotencyKey: key,
});
// 3. Completa la reserva con el resultado.
await this.db
.update(idempotencyKeys)
.set({ status: 'done', result })
.where(eq(idempotencyKeys.key, key));
return result;
}
Aquí trabajan juntas dos protecciones. La reserva en la base impide el segundo cobro mientras el primero está en curso y después de él. La clave que se pasa al proveedor cubre el hueco entre cobrar y guardar el resultado. El proveedor, sin embargo, solo guarda la clave por un tiempo (Stripe puede descartarla después de 24 horas), así que la reserva en la base sigue siendo la protección de largo plazo.
Queda una decisión que el código de arriba deja fuera a propósito: una reserva que quedó en in_progress porque el proceso murió a mitad de camino o porque la llamada al proveedor agotó el timeout. Tiene que expirar, o la factura no se podrá cobrar nunca más. Como el proveedor también recibió la clave, quien tome una reserva vencida puede repetir el cobro con seguridad, siempre que el proveedor todavía guarde la clave (en Stripe, hasta 24 horas después de la primera llamada); pasado ese plazo, la misma clave puede generar un cobro nuevo. Por eso la reserva tiene que expirar bastante antes de esa ventana. Una falla definitiva es otro caso: como no hay try/catch, una excepción en charge (una tarjeta rechazada, por ejemplo) también deja la reserva en in_progress, y el agente pasa a recibir "en curso" para un cobro que ya falló. Esa falla tiene que capturarse y guardarse, y el nuevo intento necesita otra clave (con el número de intento, por ejemplo), porque Stripe devuelve el mismo error para la misma clave.
No toda herramienta lo necesita, pero más de las que parece
La regla práctica: si llamar a la herramienta dos veces con la misma intención puede dejar el mundo en un estado visiblemente distinto que llamarla una vez, necesita idempotencia. Eso cubre mucho más que pagos:
send_email: duplicar un e-mail transaccional es, en el mejor de los casos, incómodo; en un e-mail de "tu contraseña fue cambiada" o "reembolso procesado", puede generar pánico o confusión real.create_support_ticket: dos tickets idénticos para el mismo problema ensucian la cola y hacen que el equipo humano pierda tiempo descubriendo que es un duplicado.post_comment,create_ordero cualquierINSERTdisparado por una tool: sin una clave natural para deduplicar, el agente puede crear dos filas para una sola intención del usuario.
Las herramientas de solo lectura (get_order_status, search_products) no necesitan nada de esto: llamarlas dos veces no cambia el estado del mundo. La pregunta solo hay que hacerla en las tools con efecto secundario, y hay que hacerla al diseñar la herramienta, no descubrirla después de un incidente.
Por qué esto importa más ahora que hace dos años
La compactación de contexto, la técnica que permite a un agente mantener una sesión larga sin reventar la ventana del modelo, es exactamente el tipo de mecanismo que puede borrar el registro preciso de "este paso ya corrió". Las sesiones de agente son cada vez más largas, más autónomas y menos supervisadas por un humano que confirme cada acción antes de que ocurra.
Eso cambia una premisa de la ingeniería de confiabilidad: ya no se puede asumir que quien llama a la API es código bien portado con una política de retry sensata. Puede ser un modelo que decide reintentar por razones que ninguna política de retry previó, y la única defensa que funciona sin importar el motivo es que la propia acción sea segura de ejecutar más de una vez.
Conclusión
La idempotencia siempre fue una buena práctica en APIs. Con agentes de IA ejecutando acciones reales (cobrar, enviar, crear, borrar), deja de ser una buena práctica y se vuelve un requisito de diseño de cualquier tool con efecto secundario, porque el patrón de retry de quien llama ya no es algo que controles o puedas prever.
Antes de exponer una herramienta a un agente, pregúntate: ¿qué pasa si se llama dos veces con la misma intención? Si la respuesta te incomoda, la clave de idempotencia no es opcional. Y, si puedes elegir, que sea una clave que el modelo no tenga que recordar.
Comentarios
Cargando comentarios...