volver al blog

Idempotencia: la propiedad que a tus herramientas de agente les falta

agentesia-generativaarquitecturatool-calling

El correo que se envió tres veces

La traza es la misma siempre. El agente llama a enviar_email. La herramienta tarda 40 segundos, el cliente HTTP corta a 30 y devuelve un timeout. El modelo lee “error”, razona que la acción no se completó y vuelve a llamar. El correo ya había salido. Al tercer intento, el destinatario tiene tres copias y tú tienes un incidente.

Nadie escribió “reintenta hasta que funcione” en el prompt. No hacía falta. Un agente es un bucle que observa un resultado y decide el siguiente paso; ante un error transitorio, reintentar es exactamente lo que quieres que haga en el 95 % de los casos. El fallo no está en el bucle. Está en que la herramienta asumió que se la llamaría una vez.

Al menos una vez, nunca exactamente una

En sistemas distribuidos esto está resuelto desde hace décadas y la conclusión es incómoda: la entrega exactamente una vez no existe a nivel de transporte. Lo que puedes garantizar es al menos una vez, y luego hacer que las repeticiones no importen. Eso segundo es la idempotencia.

Una operación es idempotente si ejecutarla N veces con los mismos argumentos deja el sistema en el mismo estado que ejecutarla una. GET /usuarios/42 lo es. DELETE /usuarios/42 lo es (el segundo borrado no borra más). POST /pedidos no lo es en absoluto.

Los agentes reabren este problema por una razón concreta: entre el error y el reintento hay un modelo de lenguaje. No es un cliente HTTP con una política de backoff que tú configuraste. Es un componente no determinista que decide, cada vez, si el error merece otro intento. Y decide con la única información que le diste: la cadena de texto que devolvió la herramienta.

Las cuatro formas de duplicar una acción

Antes de arreglarlo, conviene saber qué se está arreglando. En agentes he visto estas cuatro, en este orden de frecuencia:

OrigenQué pasaSeñal en la traza
Timeout ambiguoLa acción se completó, la respuesta se perdióError de red seguido de la misma llamada
Reintento del modeloEl resultado fue confuso y el modelo insisteDos tool_call idénticos, sin error entre medias
Llamadas paralelasEl modelo emite el mismo tool_call dos veces en un turnoIDs distintos, argumentos idénticos, mismo timestamp
Reanudación de sesiónSe recupera un estado y se repite el último pasoSalto en el historial antes de la repetición

Las dos primeras se arreglan en la herramienta. La tercera se arregla también en la herramienta, porque deduplicar en el prompt no funciona. La cuarta se arregla en cómo persistes el estado del bucle, y es la única que requiere tocar el orquestador.

Fíjate en el patrón: tres de cuatro se resuelven en el mismo sitio. La herramienta es la frontera correcta para esta defensa, no el prompt.

Clave de idempotencia: el patrón que ya usa tu pasarela de pago

Stripe lleva años haciendo esto con la cabecera Idempotency-Key. La idea se traslada tal cual a las herramientas de un agente: quien llama aporta una clave que identifica la intención, no el intento. Si la clave ya se procesó, devuelves el resultado guardado en lugar de volver a ejecutar.

La parte delicada es de dónde sale la clave. Dejar que la genere el modelo es tentador y es un error: un modelo no determinista generará claves distintas para la misma intención, que es justo lo contrario de lo que necesitas. La clave hay que derivarla de los argumentos.

import hashlib, json
from datetime import timedelta

def clave_de_intencion(nombre: str, args: dict, sesion: str) -> str:
    """Deriva la clave de los argumentos, no del intento.

    Incluir la sesión evita colisiones entre conversaciones distintas
    que hacen legítimamente la misma acción (dos usuarios pidiendo
    el mismo informe). Excluir el tool_call_id es lo importante:
    ese ID cambia en cada reintento, que es lo que queremos ignorar.
    """
    canonico = json.dumps(args, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(f"{sesion}:{nombre}:{canonico}".encode()).hexdigest()


async def ejecutar_herramienta(nombre, args, sesion, fn):
    clave = clave_de_intencion(nombre, args, sesion)

    # SET NX: solo un intento gana la carrera. Resuelve también las
    # llamadas paralelas dentro del mismo turno.
    if not await redis.set(f"tool:{clave}", "en_curso", nx=True, ex=900):
        estado = await redis.get(f"tool:{clave}")
        if estado == "en_curso":
            # Ya hay una ejecución viva. No es un error: es un duplicado.
            return {"status": "duplicado", "detalle": "Esta acción ya se está ejecutando."}
        return json.loads(estado)  # resultado cacheado del intento que ganó

    try:
        resultado = await fn(**args)
        await redis.set(f"tool:{clave}", json.dumps(resultado), ex=timedelta(hours=24))
        return resultado
    except Exception:
        await redis.delete(f"tool:{clave}")  # liberar: el reintento sí debe poder correr
        raise

Tres decisiones merecen justificación. El nx=True convierte la deduplicación en una operación atómica, así que también cubre el caso de las llamadas paralelas. La ventana de 24 horas es un compromiso: cubre de sobra la vida de una conversación sin convertir Redis en un almacén permanente. Y borrar la clave cuando la ejecución falla de verdad es deliberado: un fallo real sí debe poder reintentarse, o habrás construido un sistema que se atasca solo.

Lo que devuelves importa tanto como lo que ejecutas

Aquí está la diferencia entre esto y la idempotencia clásica de una API. En una API, el cliente es código y le da igual la redacción del error. Aquí el cliente es un modelo que va a interpretar la cadena. Un mensaje ambiguo provoca el reintento que acabas de gastarte una infraestructura en neutralizar.

Compara:

# Mal: el modelo no sabe si la acción ocurrió. Reintentará.
return {"error": "Request failed"}

# Mal: técnicamente cierto, semánticamente inútil.
return {"error": "409 Conflict"}

# Bien: cierra el bucle. No hay nada que reintentar.
return {
    "status": "ya_ejecutado",
    "detalle": "El correo a ana@ejemplo.es ya se envió en esta conversación (id: msg_8f2a). No hace falta repetirlo.",
}

La tercera versión funciona porque le dice al modelo el estado del mundo, no el código de error. La regla práctica: si la acción ocurrió, dilo en la primera frase de la respuesta. Un modelo que lee “ya se envió” no reintenta. Uno que lee “failed” sí, y hace bien.

Lo mismo aplica al timeout. Devolver un error genérico cuando la operación puede seguir viva es la peor opción posible. Si tu herramienta puede tardar más que el timeout del cliente, hazla asíncrona de forma explícita: devuelve un identificador de trabajo de inmediato y da al agente una herramienta consultar_estado. Convertir “no sé qué ha pasado” en “está en curso, pregunta luego” elimina la ambigüedad de raíz.

Qué proteger y qué no

No todo necesita esto, y ponerlo en todas partes añade latencia y una dependencia de Redis a herramientas que no la necesitan. El criterio que uso:

  • Siempre: cualquier cosa que envíe un mensaje, mueva dinero, cree un recurso externo o dispare un webhook. Efectos irreversibles y visibles para terceros.
  • Casi siempre: escrituras en tu propia base de datos, salvo que ya sean naturalmente idempotentes por clave primaria o UPSERT.
  • No hace falta: lecturas, búsquedas, cálculos puros. Un reintento aquí cuesta tokens y latencia, nada más.
  • Cuidado: las herramientas que parecen lecturas pero escriben algo por el camino, como un buscador que registra la consulta o incrementa un contador de uso. Son las que se cuelan.

Empieza por una

Si tu agente ya está en producción sin nada de esto, no reescribas las 15 herramientas. Ordénalas por daño en caso de duplicado, coge la primera y protégela hoy. Casi siempre es la que envía correos o la que crea registros en el CRM.

Y añade una métrica antes que el arreglo: cuenta cuántas veces una clave de intención se repite en una misma sesión. Ese número te dirá si tienes un problema latente y, después, si lo has resuelto. En los sistemas que he revisado nunca ha sido cero.