Inicio / Artículos

Agentes seguros para reproducción: mantener las llamadas al LLM fuera del flujo de trabajo mediante temporalidad.

Por qué un agente que se cae durante su ejecución no debe volver a utilizar su LLM: las actividades como límite del no determinismo, las mecánicas de reproducción, los indicadores de estado, las reintentos y los grafos sin estado.

Editor: REACTAPP.TOP16 min de lectura3685 palabras

Fuentes: Fixing Non-Deterministic Replay Bugs in AI Agent Loops

Imagínese a un agente en la etapa 7 de 10 cuando su proceso de trabajo muere: una eliminación por falta de memoria, un evento de mantenimiento o un pánico del kernel. Si la llamada al LLM se realiza dentro del flujo de trabajo, la recuperación se convierte en una reescritura silenciosa de la historia. El flujo de trabajo vuelve a ejecutar el modelo, recibe una respuesta diferente y el agente termina en un lugar distinto al donde murió. Los mismos tokens son pagados dos veces, y la ejecución recuperada es diferente a la original.

Esto no es un experimento mental. Una noche a las 02:14, un agente de soporte cerró una cuenta de cliente que debería haber recibido un período de gracia de 30 días. La llamada a la herramienta tuvo éxito. Cuando un ingeniero reprodujo el mismo mensaje del cliente, el agente se comportó correctamente. El análisis posterior presentaba un vacío donde debería estar la causa raíz, reemplazado por las palabras “no se pudo reproducir”. Una falla no reproducible no ha desaparecido; es una falla que ya no se puede estudiar.

La regla que lo impide es sencilla: el código que orquesta a un agente debe ser determinista, pero el LLM no lo es, por lo que debe encontrarse detrás de un límite que no es necesario. Temporal es el motor utilizado en los ejemplos, pero el patrón es independiente del framework. El mismo principio se aplica a AWS Step Functions, a Prefect y Dagster, a un programa que consulta la bandeja de salida de una base de datos, así como a una tabla de estado hecha en casa; las secciones siguientes relacionan esta idea con esos sistemas.

El error antes del vocabulario

Tomemos un agente de obtención de datos con cinco pasos: investigación, puntuación, decisión, aprobación y confirmación. El paso de decisión llama a un LLM, y el lugar natural y tentador para realizar esa llamada es directamente dentro de la clase del flujo de trabajo:

# ❌ What I almost wrote — LLM call INSIDE the workflow.
@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        suppliers = await workflow.execute_activity(research_activity, brief)
        scored = await workflow.execute_activity(score_activity, suppliers)

        # The LLM call — right here, in the workflow. THIS IS THE BUG.
        # If worker dies here, replay will re-call the LLM and mutate state/history.
        response = await llm_client.complete(
            messages=build_decide_prompt(scored),
            temperature=0.0,
        )
        selected = parse_llm_decision(response.content, scored)

        approval = await workflow.wait_condition(...)
        # ...create PO, initiate payment

Se lee bien. Un ingeniero senior que nunca hubiera visto Temporal lo calificaría como bueno. El problema surge si el trabajador experimenta una caída una vez que la llamada al LLM ha finalizado y mientras la aprobación humana aún está pendiente.

La solución consiste en trasladar esa llamada a una actividad, una función que el flujo de trabajo programa y cuyo resultado se registra:

# ✅ The fix — LLM call moved into an activity.
@activity.defn
async def decide_activity(scored: list[dict], brief: SourcingBriefInput) -> dict:
    """The LLM call lives here — in the activity, not the workflow."""
    from app.agentmesh.llm import get_llm_client

    client = get_llm_client()
    response = await client.complete(
        messages=build_decide_prompt(scored),
        temperature=0.0,
    )
    selected, rationale = parse_llm_decision(response.content, scored)
    return {"selected_supplier": selected, "decision_reason": rationale}


@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        suppliers = await workflow.execute_activity(research_activity, brief)
        scored = await workflow.execute_activity(score_activity, suppliers)

        # The LLM call is NOWHERE in the workflow.
        # The activity result is recorded. On replay, it's injected.
        decision = await workflow.execute_activity(
            decide_activity,
            args=(scored, brief),
        )

Se movió un bloque de código. Al reproducirlo, la actividad no se ejecuta nuevamente, porque el flujo de trabajo recibe el resultado grabado y el modelo nunca se llama una segunda vez. La razón por la que esto funciona es el verdadero tema a analizar.

La regla: la reproducción debe reproducir la historia

Mientras se ejecuta un flujo de trabajo, Temporal escribe cada paso en un historial de eventos, un registro que solo crece, con entradas como “flujo de trabajo iniciado”, “actividad programada”, “actividad completada”, “señal recibida” y “timbre activado”. Los eventos son inmutables, y el historial es la fuente de la verdad.

Tras una caída, Temporal vuelve a ejecutar el flujo de trabajo. Un trabajador nuevo ejecuta la función del flujo de trabajo desde su primera línea. En cada llamada como workflow.execute_activity(...) o workflow.wait_condition(...), Temporal consulta el historial. Si existe un evento que coincida, inserta el resultado grabado en lugar de ejecutar cualquier acción. Si la función realiza una llamada que difiere de lo que indica el historial, Temporal genera un error de no determinismo.

Eso es el origen de eventos. El historial es el registro, la función de flujo de trabajo es la operación que procesa ese registro, y los mismos eventos en el mismo orden, a través de la misma función, siempre producen el mismo estado. La documentación de Temporal lo explica claramente: el código de flujo de trabajo debe ser determinista para permitir la reproducción, mientras que las operaciones no deterministas como las llamadas a API, las invocaciones a modelos de lenguaje grande y las consultas a bases de datos deben realizarse dentro de actividades. La actividad se ejecuta una vez, su resultado se guarda en el historial, y a partir de ahí las reproducciones lo leen de allí.

Cualquiera que haya trabajado con Postgres ha visto una estructura similar. El registro de escritura anticipada es el registro, los archivos de tabla son una vista materializada, y la recuperación reproduce ese registro. Un manual sobre el origen de eventos establece la misma regla: mismos eventos, mismo orden, mismo estado final, sin relojes, IDs aleatorios ni lecturas basadas en la hora dentro de la operación de procesamiento.

Así se ve la violación

Cuando el LLM se integra en el flujo de trabajo, dos cosas se ven afectadas al mismo tiempo.

La factura se duplica. Una llamada que costó tres centavos la primera vez cuesta otros tres centavos al reproducirla. Un equipo midió que las reintentos no controlados consumían unos dos dólares en tokens en treinta segundos para una sola conversación, y la interrupción automática redujo ese costo a un centavo, lo que representa una diferencia de 200 veces.

La ejecución varía. La ejecución original eligió al proveedor B. La reproducción eligió al proveedor A. La historia ya no coincide, Temporal genera un error de no determinismo, y el flujo de trabajo deja de funcionar.

Estos no son eventos raros. Aproximadamente el 0,8% de las llamadas a Anthropic y OpenAI fallan en un período de treinta días, por lo que un agente que realiza 12 llamadas al modelo por tarea enfrenta una tasa de fallo por tarea cercana al 9,2%. Si el LLM está en el flujo de trabajo, cada intento de reintentar dispara nuevamente la llamada. Si está en una actividad, el reintento recibe el resultado ya registrado. La propia explicación de Temporal deja claro este punto: el flujo de trabajo es la capa de orquestación y debe ser determinista para que los agentes puedan superar las caídas del proceso, mientras que las actividades son donde tiene lugar el trabajo real, incluidas las llamadas al LLM, las invocaciones a herramientas y las solicitudes a API, y pueden ser tan impredecibles como sea necesario.

Por qué la temperatura cero no es una protección

Siempre hay quien objeta que la temperatura se estableció en 0. Esto no ayuda, por varias razones:

  • La temperatura cero implica un decodificación arbitraria, no determinismo. En un análisis, mil solicitudes idénticas generaron 80 resultados diferentes, debido a diferencias en punto flotante dependientes del lote en las GPUs.
  • El mismo nombre de modelo no significa el mismo modelo. Los proveedores actualizan los modelos de forma silenciosa, por lo que el modelo utilizado el martes podría no coincidir con el del miércoles.
  • El proveedor podría estar fuera de servicio durante la reproducción. En ese caso, el flujo de trabajo queda atascado esperando.
  • Incluso una respuesta idéntica en cada byte cuesta dinero. Se siguió pagando por una segunda inferencia.

Un desarrollador que escribe bajo el nombre de Tisha describió bien el problema: los equipos pasan semanas intentando lograr un determinismo bit a bit en los modelos, fracasan y concluyen que el sistema es impredecible, cuando el determinismo real se encuentra en un nivel superior, en la orquestación. Estaban apuntando al nivel equivocado.

La actividad como membrana

El cambio es estructural. Dentro de una actividad se permite cualquier cosa. Afuera, todo debe ser determinista.

Un flujo de trabajo puede llamar a actividades, esperar señales, establecer temporizadores y crear ramificaciones, siempre y cuando las condiciones de las ramificaciones dependan únicamente de los resultados de las actividades y de las entradas del flujo de trabajo. Están prohibidas las llamadas al modelo, las solicitudes HTTP, las lecturas del reloj del sistema, la generación de números aleatorios y cualquier otra cosa que pueda producir un resultado diferente en otra máquina en otro momento. Todo eso se incluye dentro de las actividades.

Imagínelo como la anatomía. Lo que ejecuta el flujo de trabajo no es el agente en sí, sino la coordinación alrededor de él, mientras que el modelo es simplemente un servicio al que el agente consulta. El cerebro es impredecible, la columna vertebral debe ser estable, y el cerebro no entra dentro de la columna vertebral.

Así se ve en un código real

Los fragmentos simplificados anteriores ocultan un detalle. En un código real, la llamada al LLM se encuentra dentro de nodos de un grafo, y el grafo se ejecuta dentro de una actividad, por lo que hay dos límites, no uno:

Temporal Workflow (deterministic — replayed)
    └── Activity: run_graph_until_interrupt (non-deterministic — recorded once)
            └── LangGraph StateGraph
                    └── decide_node (async function)
                            └── get_llm_client().complete()  ← the LLM call

El flujo de trabajo en sí es pura orquestación, una secuencia de llamadas a execute_activity y wait_condition y nada más. La línea execute_activity marca el límite. Lo que ocurre dentro de la actividad se registra, y lo que ocurre fuera de ella se vuelve a reproducir:

@workflow.defn
class SourcingWorkflow:
    @workflow.run
    async def run(self, brief: SourcingBriefInput) -> SourcingResult:
        # ↓ This is the boundary. The activity runs once. Result is recorded.
        graph_result = await workflow.execute_activity(run_graph_until_interrupt, brief, ...)

        # Wait for human signal — a Temporal primitive, not an LLM call.
        # On replay, the signal is injected from history.
        await workflow.wait_condition(lambda: self._approval_received, timeout=timedelta(hours=24))

        # ↓ Another boundary. Resume activity runs once. Result is recorded.
        resume_result = await workflow.execute_activity(resume_graph, self._approval_data, ...)

        # ↓ Side effects — each is its own activity with its own retry policy.
        po_result = await workflow.execute_activity(create_po_activity, args=(...), ...)

Nada en él llama a un cliente LLM, realiza una solicitud HTTP, lee la hora actual ni genera un número aleatorio. Mismas entradas, mismo historial, mismo camino de ejecución, cada vez.

La actividad construye el grafo y lo invoca, y esa única línea graph.ainvoke() es donde reside el no determinismo: llamadas a LLM, llamadas a herramientas y consultas a la memoria. La actividad devuelve un diccionario, que se registra, y la reproducción salta todo el proceso:

@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
    graph = build_sourcing_graph(checkpointer=await get_checkpointer())
    config = {"configurable": {"thread_id": activity.info().workflow_id}}

    # ↓ Everything inside this call is non-deterministic. It runs ONCE.
    #   The result dict is recorded in the event history. On replay, it's injected.
    final_state = await graph.ainvoke({"brief": brief, "suppliers": [], "attempts": 0, ...}, config)

    return {"paused": True, "selected_supplier": selected, "suppliers": final_state.get("suppliers", [])}

En la parte inferior de la pila, el nodo del grafo es donde el modelo realiza realmente la acción. Construye la solicitud a partir de los proveedores calificados y las decisiones anteriores y luego realiza la llamada:

async def decide_node(state: AgentState) -> dict:
    scored = state.get("scored_suppliers", [])
    messages = build_decide_prompt(brief.item, brief.quantity, brief.budget, scored, past_decisions)

    # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
    response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)

    selected, rationale = parse_llm_decision(response.content, scored)
    return {"selected_supplier": selected, "decision_reason": rationale, "cost_incurred": response.cost_usd}    # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
    response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)

Eso corresponde a tres capas por debajo del flujo de trabajo: actividad, grafo, nodo, y luego el LLM. Al reproducirlo, ninguna de ellas se ejecuta, ya que se inyecta el resultado de la actividad. El flujo de trabajo es determinista. La actividad, el grafo y el modelo no lo son, pero el no determinismo queda contenido dentro de los límites de la actividad. El flujo de trabajo solo ve un diccionario grabado, el mismo en cada reproducción.

Donde se vuelve borroso el límite

El patrón parece claro en teoría, pero se vuelve confuso en algunos aspectos en la práctica. El caso de varios días merece atención porque su fallo es sutil y dos capas de persistencia pueden separarse sin previo aviso.

Cuando el grafo se detiene en un nodo de aprobación mediante interrupt() de LangGraph, la actividad se completa y devuelve un diccionario al flujo de trabajo. El flujo de trabajo llama entonces a workflow.wait_condition() para esperar una señal humana. En ese momento, existen dos copias del estado en dos sistemas:

Temporal event history             LangGraph checkpoint (Postgres)
  └── ActivityCompleted(result)      └── graph state at interrupt()
        paused: True                       node: "approve"
        selected: SupplierB                 selected: SupplierB
        suppliers: [A, B, C]                suppliers: [A, B, C]

Ambos se encuentran en Postgres, pero son tablas separadas a las que se les proporciona datos a través de rutas de código distintas en momentos diferentes. El punto de control de LangGraph es escrito por la actividad cuando se dispara la interrupción; una vez que la actividad vuelve, Temporal agrega su evento de finalización de actividad. Eso genera dos escrituras, dos sistemas y un estado lógico.

Los problemas surgen cuando solo sobrevive uno de los escritos y el otro no. Una solución es de tipo operativo: el thread_id debe ser el workflow_id de Temporal, que se mantiene estable en las reproducciones a diferencia de run_id; la tabla de puntos de control debe conservar los datos tanto tiempo como la historia de Temporal; y la actividad de reanudación debe llamar a graph.ainvoke(None, config), ya que pasar None indica a LangGraph que cargue desde el punto de control y no que ejecute todo desde cero. Si se comete un error en alguno de estos aspectos, las capas del sistema se desvían. El flujo de trabajo no se cae, pero reanuda desde un estado incorrecto.

La mejor solución: hacer que el grafo sea sin estado

El diseño intuitivo permite que el checkpointer de Postgres de LangGraph haga un seguimiento del estado en los nodos de agente junto con el historial de Temporal. El rastreo de los modos de fallo durante pausas de varios días revela la trampa oculta, que es un problema clásico de escritura dual entre dos bases de datos. Si un proceso falla justo después de que LangGraph escriba su punto de control pero antes de que Temporal registre la finalización, los motores de estado no están de acuerdo, y el flujo de trabajo reanuda silenciosamente a partir de un estado inconsistente.

En lugar de seguir añadiendo reglas de sincronización relacionadas con el mapeo de hilos, la retención y las recargas, la decisión más adecuada es eliminar el segundo motor de estado. Sin su puntero de verificación, LangGraph se convierte en una unidad de razonamiento sin estado que opera en memoria dentro de la actividad. Temporal se encarga de todo el estado a largo plazo, el historial de ejecución y las pausas causadas por intervención humana a través de workflow.wait_condition(). LangGraph simplemente ejecuta su bucle de razonamiento, devuelve un diccionario con los resultados a la actividad y termina. El resultado: sin escrituras duales, sin riesgo de desincronización y una interfaz de operación más sencilla.

Ninguna de las herramientas se vuelve redundante. LangGraph sigue siendo eficaz en el enrutamiento rápido de grafos en memoria, en la llamada a herramientas y en los ciclos de razonamiento a nivel de nodos, mientras que Temporal destaca por su durabilidad a lo largo de los días, su capacidad de recuperación ante fallos y su orquestación distribuida. Un LangGraph sin estado dentro de una actividad Temporal duradera evita que compitan por la persistencia y mantiene las ventajas de ambos.

Una restricción en muchos sistemas

Los marcos de orquestación son solo un lugar donde aparece el contrato de reproducción. Cualquier sistema que se recupere mediante la reproducción cuenta con las mismas tres partes: un registro de eventos, una función pura que los transforma en estado, y todo lo impredecible que se almacena al otro lado de un borde registrado.

Los agentes lo complican aún más. En un registro basado en eventos clásico, la función apply permanece pura, ya que acreditar diez dólares en una cuenta siempre produce el mismo resultado. La lógica de negocio de un agente consiste en preguntarle al LLM qué hacer, y la respuesta varía cada vez. Si el cerebro se encuentra en la ruta de reproducción, esta resulta imposible, por lo que debe estar fuera de ella.

Temporal fue elegido para la plataforma de ejemplo porque hace cumplir el contrato: su SDK genera un error de no determinismo en caso de violación en lugar de producir silenciosamente un resultado divergente. Step Functions lo hace cumplir de forma gratuita, ya que su lenguaje declarativo de estados impide que se realice accidentalmente una llamada a un LLM dentro de la máquina de estados. Con un sistema de mensajes pendientes o una consulta personalizada, el cumplimiento es manual. Nada impide que se realice una llamada a un LLM dentro del bucle de consulta y nada advierte al respecto. La restricción es idéntica en todos ellos. La única pregunta es quién detecta la violación: el framework, o quien esté de guardia a las 3 de la mañana.

La misma división se aplica en otros stacks de agentes. Algunos la imponen, como Temporal y el Microsoft Agent Framework. Otros proporcionan las primitivas y dejan la implementación en manos del desarrollador, como lo hacen LangGraph y AutoGen. Hay también aquellos que ignoran por completo este aspecto, como Celery y CrewAI. La pregunta práctica es cuánto del contrato de reproducción debe construirse a mano.

Las cifras obtenidas de comparaciones externas indican lo mismo. Un informe sobre la migración de Celery a Temporal señaló un alto riesgo de ejecución duplicada en los intentos de reintentar tareas, especialmente aquellas que consumen una cantidad significativa de tiempo de la GPU. Un proyecto que utiliza Temporal junto con CrewAI llegó a la arquitectura descrita anteriormente, empleando el motor de orquestación como fuente de verdad para el estado y el framework de agentes como una unidad de razonamiento sin estado que se invoca desde el interior de una actividad. Además, en una prueba de rendimiento entre AutoGen y CrewAI, AutoGen completó las tareas con una latencia p95 de 2.1 segundos frente a 4.7 segundos, y tuvo un éxito del 94 % frente al 83 %, lo que recuerda que la forma en que un framework maneja los fallos se refleja en las cifras. El Microsoft Agent Framework incluye almacenamiento de puntos de control basado en archivos y superpasos tolerantes a fallos, lo cual constituye su forma de ofrecer la misma garantía.

El costo del determinismo

El límite de las actividades no es libre. La simplicidad en formato en línea desaparece, ya que la versión incorrecta tiene menos líneas y es más fácil de leer. El acceso en línea al estado también desaparece, porque una actividad solo ve lo que se le pasa, no los detalles internos del flujo de trabajo. Además, surgen tres casos límite que no existen con un LLM en formato en línea. Estas son decisiones reales de ingeniería, no trade-offs filosóficos.

Caso límite uno: actividades largas y mensajes de estado

Una llamada a un LLM puede tardar 30 segundos. Un grafo de agente con múltiples interacciones y llamadas a herramientas puede tardar cinco minutos. El valor predeterminado de start_to_close_timeout en Temporal es de 10 segundos. Si una actividad lo supera, Temporal la marca como fallida y la vuelve a intentar, lo que repite la llamada al modelo, reembolsa los tokens y realiza nuevamente el trabajo no determinista que había sido cuidadosamente aislado.

La solución es un tiempo de espera por latido cardíaco. Cualquier actividad que dure más de unos segundos debe llamar a activity.heartbeat() a intervalos regulares, lo cual indica a Temporal que sigue activa. Si el proceso falla, Temporal utiliza el último latido para determinar hasta dónde llegó la actividad, de modo que una actividad que permita puntos de control pueda reanudarse y cualquier otra pueda reiniciarse desde cero:

@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
    # Long-running: graph may run 5+ minutes with multiple LLM calls
    for node in graph.stream(initial_state, config):
        activity.heartbeat()  # ← "I'm alive, don't timeout me"
        # ... process node output

Hay un problema. Un latido cardíaco no salva el progreso interno de la actividad; lo único que hace es retrasar el tiempo de espera. Si el proceso falla a mitad del proceso, el intento de reintentar comienza la actividad de nuevo desde el principio y el LLM vuelve a procesarla, ya que el resultado solo se registra cuando la actividad finaliza. En un proceso de cinco minutos, un proceso que falla en el minuto cuatro se lleva consigo cuatro minutos de trabajo sin protección.

Por eso el ejemplo divide el proceso en dos actividades: una que se ejecuta hasta la interrupción y otra que continúa después de ella. Cada una es lo suficientemente corta como para completarse dentro de su tiempo de espera, y cada resultado se registra de forma independiente. Esta división existe por razones de granularidad en la durabilidad, no por organización del código.

Caso límite dos: tokens en streaming

Cuando el LLM está integrado directamente en un flujo de trabajo, los tokens pueden enviarse directamente a la respuesta HTTP del usuario. Cuando se encuentra dentro de una actividad, los tokens van a la salida de dicha actividad, un único valor de retorno registrado en el historial. Nada llega al usuario hasta que la actividad finaliza.

La transmisión en tiempo real plantea la objeción más frecuente contra este patrón, y tiene fundamento. Existen tres enfoques, con un nivel de complejidad creciente. El primero acepta que el resultado es una decisión, no un flujo de chat, lo cual se adapta al agente de origen. El segundo añade un canal secundario para interacciones de varias rondas, como una entrevista en la que el usuario debe ver cada respuesta. El canal secundario puede ser un punto de extremo de eventos enviados por el servidor respaldado por Redis: la actividad publica tokens en Redis, el punto de extremo los transmite al usuario, mientras que Temporal solo conserva el resultado final. Un usuario que abandone la interacción puede regresar más tarde y solicitar el resultado del flujo de trabajo. Los tokens en la transmisión son desechables; lo que permanece es el resultado almacenado.

Caso límite tres: las políticas de reintentos difieren

Las reintentos temporales fallan automáticamente las actividades, pero el fracaso significa cosas diferentes para cada actividad. Una búsqueda de solo lectura y un efecto secundario no deben reintentarse de la misma manera. En el caso de un efecto secundario como la creación de una orden de compra, la política es estricta y no permite reintentos, contando con una clave de idempotencia que garantiza la seguridad:

# Side effect — strict, non-retryable. Idempotency key handles safety.
po_result = await workflow.execute_activity(
    create_po_activity,
    args=(supplier, item, quantity, price),
    retry_policy=workflow.RetryPolicy(
        initial_interval=timedelta(seconds=1),
        maximum_attempts=1,  # ← don't retry. Idempotency key prevents duplicates.
        non_retryable_error_types=["DuplicatePOError"],
    ),
)

# Verification — aggressive retry, but with backoff for eventual consistency
po_verification = await workflow.execute_activity(
    verify_po_exists,
    args=(po_result["po_id"],),
    retry_policy=workflow.RetryPolicy(
        initial_interval=timedelta(seconds=2),  # ← give the DB time to sync
        maximum_attempts=5,
    ),
)

La política de reintentos forma parte del contrato de reproducción. Cuando una actividad falla y se vuelve a intentar, los reintentos se registran en el historial, y al reproducirla Temporal inyecta el resultado final sin volver a ejecutar los intentos fallidos. Sin embargo, una actividad que crea un pedido de compra con maximum_attempts=3 tiene tres intentos para crearlo, y al no haber una clave de idempotencia, eso significa tres pedidos. Los dos mecanismos cooperan: la política decide cuándo reintentar, y la clave decide qué ocurre al hacerlo.

Estos son los costos de la durabilidad. La cuestión no es determinista frente a no determinista, sino que se trata de algo duradero con cierta indirección frente a algo simple e irreparable. Un agente de producción debería elegir la durabilidad porque las interrupciones en la producción son inevitables. La discusión sobre la fiabilidad de los agentes suele quedar en el nivel incorrecto, concentrándose en el modelo: mejores indicaciones, mayor cobertura de evaluación, un punto de control más inteligente el próximo trimestre. Todo eso choca contra un techo infranqueable.

La única regla a seguir

De todo lo que hay en el sistema, solo el LLM resiste la reproducción, y esa es la razón por la cual debe permanecer en un lugar al que la reproducción nunca llegue. La sección de Preguntas Frecuentes de Temporal lo explica aún más: siempre debe encerrarse las llamadas al LLM dentro de una actividad. Siempre, no generalmente ni cuando el tiempo lo permita, porque la única vez que se omite es durante la caída a las 3 de la mañana, cuando la reproducción genera un resultado diferente y alguien de turno debe hacerse cargo de un agente que permanece en estado fallido durante toda la noche.

Al no contar con el contrato de reproducción, las tres capas de arnés, bucle y grafo son únicamente organizativas. Ayudan a pensar en un sistema, pero no contribuyen a que sobreviva. Con el contrato, pasan a ser estructurales: el bucle reanuda desde el último paso completado, con actividades inyectadas desde el historial; el estado del grafo se almacena en un único lugar y es cargado por la actividad de reanudación; las herramientas no se ejecutan nuevamente, ya que los resultados se registran y la clave de idempotencia constituye una segunda línea de defensa. Las capas permanecen separadas en caso de fallo, y no solo por cuestión de principio.

Queda un límite. Existen garantías temporales de que un flujo de trabajo se reproduzca correctamente tras una caída. No se garantiza que la lógica empresarial sea segura para reproducirse, y esa parte corresponde al desarrollador. La idempotencia es el siguiente problema a resolver.

Leer más