Idempotencia en agentes IA: evita duplicar acciones
Un timeout. El agente espera. No llega respuesta. El sistema de retry entra en acción.
Lo que el código no sabe: la primera llamada sí llegó al servidor de email. El email ya salió. El cliente lo recibió. Cuando el retry ejecuta la misma herramienta, el cliente recibe un segundo email idéntico. A veces, dos cobros.
No hay excepción en los logs. Todo aparece correcto.
Este es el problema de construir agentes IA en producción sin idempotencia en las tool calls.
Por qué los reintentos rompen agentes sin idempotencia
Cuando un agente llama a una herramienta y no recibe respuesta, no puede saber qué ocurrió:
- La llamada llegó, se procesó y la respuesta se perdió en la red
- La llamada no llegó porque el error ocurrió antes del envío
- La llamada llegó pero el servidor murió antes de responder
Sin idempotencia, el retry duplica cualquiera de estos casos. Los retries son necesarios — ya cubrimos retry con backoff exponencial y circuit breaker en profundidad. Pero si las herramientas no son idempotentes, el mecanismo que protege tu agente de las interrupciones puede causar un problema mayor.
Las operaciones de lectura son naturalmente idempotentes: puedes ejecutar getCustomer() diez veces y el resultado siempre es el mismo. El problema son las escrituras con efectos en el mundo real:
- Enviar un email transaccional
- Procesar un pago con Stripe
- Crear un registro en la base de datos
- Enviar una notificación push
- Publicar un mensaje en Slack o WhatsApp
Cada una puede ejecutarse dos veces con un solo timeout y su correspondiente retry.
El patrón de idempotency key
La solución es la misma que usan Stripe, Twilio y cualquier API de pagos seria: idempotency keys.
Antes de ejecutar la herramienta, se genera una clave única que identifica esa operación en ese run. Si la clave ya existe en el cache, se devuelve el resultado almacenado en lugar de volver a ejecutar:
import { createHash } from 'crypto';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
async function idempotentExecute<T>(
key: string,
operation: () => Promise<T>,
ttlSeconds = 3600
): Promise<T> {
const { data } = await supabase
.from('idempotency_cache')
.select('result')
.eq('key', key)
.gt('expires_at', new Date().toISOString())
.maybeSingle();
if (data?.result) {
return JSON.parse(data.result) as T;
}
const result = await operation();
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
await supabase.from('idempotency_cache').upsert({
key,
result: JSON.stringify(result),
expires_at: expiresAt,
});
return result;
}
Cómo generar la clave correcta
La clave debe identificar de forma única esta operación en esta ejecución. La fórmula correcta combina el ID del run con un hash del input:
import { createHash } from 'crypto';
function getIdempotencyKey(
runId: string,
toolName: string,
input: object
): string {
const inputHash = createHash('sha256')
.update(JSON.stringify(input))
.digest('hex')
.slice(0, 16);
return `${runId}:${toolName}:${inputHash}`;
}
Si el agente envía el mismo email en dos runs distintos (porque la lógica de negocio lo requiere), eso son dos operaciones con dos runId distintos — ambas se ejecutan correctamente. Pero si el mismo run llama a send_email dos veces con el mismo input porque hubo un retry, la segunda llamada encuentra la clave en cache y devuelve el resultado original sin re-ejecutar.
Implementación práctica: envío de emails
interface SendEmailInput {
to: string;
subject: string;
body: string;
}
const sendEmailTool = {
name: 'send_email',
description: 'Envía un email al cliente',
async execute(
input: SendEmailInput,
runId: string
): Promise<{ messageId: string }> {
const key = getIdempotencyKey(runId, 'send_email', input);
return idempotentExecute(key, async () => {
const messageId = await emailProvider.send(input);
return { messageId };
});
},
};
La primera ejecución envía el email y almacena el messageId. Si el timeout ocurre después de que el email salió pero antes de que el resultado llegue al agente, el retry consulta la tabla y devuelve el messageId original — sin enviar un segundo email.
La tabla en base de datos
create table idempotency_cache (
key text primary key,
result jsonb not null,
created_at timestamptz default now(),
expires_at timestamptz not null
);
create index idx_idempotency_expires
on idempotency_cache(expires_at);
Un job de limpieza o una función de Supabase elimina las claves expiradas:
delete from idempotency_cache
where expires_at < now();
Qué TTL usar por tipo de herramienta
El TTL debe ser mayor que el tiempo total máximo del run más sus reintentos:
| Tipo de operación | TTL recomendado |
|---|---|
| Pagos, emails transaccionales | 24 horas |
| Notificaciones, actualizaciones de registro | 1 hora |
| Generación de documentos, exportaciones | 7 días |
| Consultas de lectura | no aplica |
Cuándo NO es necesario
El patrón solo tiene sentido para operaciones con efectos secundarios en el mundo real:
- Lecturas (
getCustomer(),searchProducts()): naturalmente idempotentes, no necesitan cache - Logs y telemetría: un duplicado es inofensivo
- Operaciones analíticas: no modifican estado externo
Añadir idempotencia a las lecturas consume recursos sin ningún beneficio.
Conclusión
Un agente sin idempotencia en sus herramientas de escritura es un agente que, estadísticamente, va a duplicar una acción real. No porque el código falle, sino porque el mundo falla: los timeouts, las caídas de red y los reinicios de servidor son eventos que ocurren en producción. El retry que previene una interrupción puede causar un problema mayor si las herramientas no están preparadas para él.
Si ya tienes retries configurados en tus agentes, la idempotencia en tool calls es el siguiente paso. Sin ella, cada retry es un riesgo.
¿Construyes agentes IA para producción y necesitas una arquitectura que resista timeouts y retries sin duplicar acciones? Escríbeme por WhatsApp y lo resolvemos juntos.