La demo funcionó. Escribiste una petición, el agente recorrió tres herramientas, devolvió una respuesta limpia y la enviaste. Después se topó con tráfico real y se cayó. Esta es la brecha de la que nadie te avisa: el salto de demo a producción no es escribir más código alrededor del modelo. Es reconocer que lo que construiste es un workflow distribuido, de larga duración y no determinista, donde cada paso tiene efectos que cuestan dinero (tokens) y efectos que no puedes deshacer (escrituras de negocio).

Quédate con esa idea. En el módulo del loop el control de flujo del agente vivía en el código: llamar al modelo, ejecutar la herramienta que pidió, devolverle el resultado, repetir. Ese loop es correcto. El problema es que corre sobre una máquina que se cae, detrás de una cola que reintenta, contra un modelo que devuelve algo distinto cada vez. La ingeniería de producción consiste en hacer que ese loop sobreviva a las tres cosas.

La fiabilidad se compone, y la cuenta es brutal

Empieza por el número que reencuadra todo. Encadena pasos y sus fiabilidades se multiplican. Un agente de 20 pasos donde cada paso funciona el 98% de las veces es 0.98^20 ≈ 0.67. Dos tercios. Tu componente "98% fiable" envió un volado-y-pico.

Por eso la demo miente. En una demo corres el camino feliz una vez y sale verde. La métrica que captura eso es pass@k: al menos un éxito en k intentos, que tiende al 100% y no te dice nada. Lo que el usuario vive de verdad es pass^k: que los k intentos tengan éxito. Al 75% por intento, pass^3 es 0.75^3 ≈ 0.42. Los agentes de cara al cliente se diseñan para pass^k, no para la corrida elegida a dedo de la demo.

La consecuencia: cada paso tiene que ser barato de re-ejecutar y seguro de re-ejecutar. Barato, para que un reintento no vuelva a gastar tokens. Seguro, para que un reintento no reserve dos veces la misma sala. Esos dos requisitos son durabilidad e idempotencia, y son la columna vertebral del resto del módulo.

Separa el plano de control del plano de ejecución

La primera decisión estructural, y la que los principiantes hacen mal, es dónde ocurre la llamada al LLM. El instinto es llamarlo dentro del handler HTTP: llega la petición, esperas al modelo, devuelves la respuesta. Eso falla de cuatro maneras a la vez. El modelo es más lento que cualquier timeout HTTP razonable. El cliente reintenta al timeout y obtienes turnos duplicados. No puedes drenar el trabajo en vuelo para hacer un deploy. Y la latencia del usuario queda soldada a la del modelo.

El arreglo es una separación limpia. El edge (stateless, rápido) solo valida, persiste y encola. El worker (durable, lento) hace la llamada al modelo y las herramientas.

El edge nunca llama al modelo

Ahora la durabilidad, el backpressure y el escalado viven en la capa de workers, desacoplados de la petición. El usuario recibe un acuse instantáneo; la parte lenta ocurre detrás de la cola, donde se puede reintentar, drenar y observar.

Durable execution: persistir el estado, reanudar desde el checkpoint

Aquí está el patrón que vuelve manejables los agentes de larga duración. Es un patrón de industria, encarnado por motores de workflow durable (Temporal, Inngest, Restate, DBOS), no el truco propietario de nadie. La idea: el motor persiste el resultado de cada paso completado. Cuando el worker se cae a mitad del workflow, al reiniciar no re-ejecuta desde cero. Reproduce los pasos ya completados desde sus resultados guardados y reanuda desde el último checkpoint.

worker.ts
// Cada step.run se memoiza. Ante un crash + retry, el motor lee
// los pasos completados del almacenamiento en vez de re-ejecutarlos.
async function handleTurn({ event, step }) {
  // Se reproduce desde cache en el retry — el modelo NO se vuelve a llamar.
  const plan = await step.run("plan", () =>
    callModel(event.data.history)
  );

  // Cada tool call es su propio checkpoint durable.
  for (const call of plan.toolCalls) {
    await step.run(\`tool:\${call.id}\`, () => runTool(call));
  }

  // Suspende el worker durante horas sin retener recursos.
  const approval = await step.waitForEvent("approval", {
    timeout: "24h",
  });

  return step.run("finalize", () => callModel(/* ... */));
}

Por qué esto importa más para agentes que para jobs corrientes: re-ejecutar un paso completado no solo desperdicia unos ciclos de CPU. Re-ejecutar el paso del modelo cuesta tokens y devuelve una respuesta distinta, porque el modelo es probabilístico. Memoizar el resultado del paso es la única forma de que un reintento se mantenga barato y consistente.

Idempotencia: porque reintentos más no-determinismo significan que los pasos corren más de una vez

La durable execution memoiza los pasos del modelo. Pero en el momento en que una herramienta toca el exterior de tu sistema, cobra una tarjeta, reserva, envía un correo, la memoización no basta, porque el efecto ya ocurrió afuera. Necesitas que la propia operación externa sea idempotente: ejecutarla dos veces tiene el mismo efecto que ejecutarla una.

La razón de que no puedas evitarlo: en un sistema distribuido la entrega es at-least-once. Un worker puede terminar una escritura y morir antes de registrar que terminó; la cola la reentrega; el paso corre de nuevo. Súmale el no-determinismo del modelo y el mismo paso lógico produce dos intentos distintos. Así que derivas una clave de idempotencia determinista de (workflow_id, step_id) y se la pasas a toda llamada con efecto. El procesador de pagos, la API de reservas, tus propias escrituras, todos deduplican con esa clave.

La base de datos como fuente de verdad, y el dual-write que muerde

El edge hace dos cosas en cada mensaje: persistir el turno y encolar el job. La trampa es hacerlas como dos operaciones separadas. Commit en Postgres, luego push a la cola. Si el proceso muere entremedio, te queda un turno fantasma (persistido, nunca ejecutado) o uno perdido (ejecutado contra un estado que no commiteó). Este es el problema del dual-write, y es silencioso hasta que deja de serlo.

El arreglo es el transactional outbox: escribir el evento "encola este job" en una tabla outbox dentro de la misma transacción de base de datos que el cambio de estado. Un relay aparte lee el outbox y publica a la cola. Ahora el persistir-y-encolar es atómico, porque es un solo commit.

¿Por qué no encolar primero y persistir después?

Invertir el orden no lo arregla, mueve la ventana de fallo. Encola y luego persiste, y un crash entremedio te da un job que corre contra un estado que la base de datos nunca registró. No hay orden de dos escrituras independientes que sea atómico. El outbox funciona porque colapsa ambas en una sola transacción, y el trabajo del relay es entrega at-least-once, que la idempotencia ya maneja aguas abajo. Si usas una cola fuertemente acoplada a Postgres, algunos motores te dan esto transaccional de fábrica.

El human-in-the-loop es una pausa durable, no un if en memoria

Una aprobación del tipo "confirma esta acción" puede tardar horas. Un humano está dormido, en una reunión, fuera el fin de semana. Modelar eso como una espera bloqueante en un worker vivo es como agotas tu pool de workers. El modelo correcto es un suspend durable: persiste la acción preparada, libera el worker y espera una señal externa. Cuando llega la aprobación, el motor reanuda el workflow exactamente donde pausó. Sin worker retenido, sin tokens gastados mientras espera.

Un requisito sutil que muchos pasan por alto: persiste la entrada normalizada en el momento en que pides la aprobación. El usuario aprueba lo que se le mostró. Si, entre la aprobación y la ejecución, el agente regenera la acción, aparece el drift, el usuario confirmó reservar la sala A y el agente reserva la sala B. Congela la acción en el momento de aprobar para que lo ejecutado sea igual a lo mostrado.

Los presupuestos de coste y latencia van en la infraestructura

Los runaway loops son incidentes reales. Un postmortem público (un pipeline multi-agente, reportado en blog y anecdótico, no un informe corporativo auditado) describe ~$47k quemados en 11 días porque dos agentes hicieron ping-pong sin tope de gasto ni kill switch. Toma la cifra exacta como ilustrativa, pero el modo de fallo es real y común.

La lección no es "añade un límite al system prompt". Un límite a nivel de prompt es una sugerencia que el agente puede ignorar o razonar para sortear. Los límites duros, máximo de iteraciones por turno, presupuesto de tokens por tenant y por día, un circuit breaker que se dispara cuando la velocidad de gasto supera la media móvil, viven en infraestructura, fuera del control del agente, chequeados antes de la llamada al modelo.

System prompt:
"No uses más de 10 tool calls.
Detente si la tarea parece demasiado cara."

El agente puede racionalizar para saltárselo. "Esta tarea es importante, así que 12 llamadas se justifican." No hay enforcement. Cuando entra en loop, nada lo detiene salvo tu alerta de facturación, que llega cuando el dinero ya se fue.

// Chequeado antes de cada llamada al modelo, el agente no lo evade.
if (turn.iterations >= MAX_ITERS) throw new IterationCapExceeded();
if (await tenantSpendToday(tenant) >= DAILY_BUDGET) throw new BudgetExceeded();
if (runCost > MOVING_AVG * SPIKE_FACTOR) breaker.trip();

El agente no tiene camino para esquivarlo. El tope lo impone el harness, no se le pide al modelo.

Las alertas de facturación no son un control de coste. Te avisan de dinero ya gastado. El control es un breaker activo que rechaza la siguiente llamada.

Lo que el consenso se equivoca

Hay patrones sobrevalorados en este espacio. WebSocket como default para responder por streaming, la mayoría del streaming de tokens es unidireccional y SSE con reconexión por Last-Event-ID es más simple, más barato y reanuda tras una desconexión. WebSocket se gana su complejidad solo cuando necesitas interacción bidireccional real, como interrumpir un turno en vuelo. Event-sourcing pesado o Kafka en un SaaS de agentes en etapa temprana, Postgres más un outbox más una cola sólida lo cubren hasta que de verdad tengas fan-out a escala.

Y una tensión que está infravalorada: prompt caching contra context compaction. Un prefijo estable se cachea y se sirve mucho más barato. Pero compactar la conversación para gestionar memoria edita turnos anteriores, lo cual invalida el cache y dispara el coste. Tiran en direcciones opuestas. No puedes optimizar ambos a ciegas; los balanceas, y mides el hit-rate del cache como métrica de coste de primera clase.

Hay mucho aquí que simplifiqué. Cuándo el exactly-once es "efectivo" y no literal, cómo las sagas y la compensación manejan el rollback multi-paso, cómo se ve un drenado limpio de colas con workers stateful. Cada uno es su propia madriguera. La idea con la que quedarte: el agente es la parte fácil. El workflow a su alrededor es el sistema.