Agent Mentor Learn
Gestión de estado y persistencia: que las tareas largas sobrevivan a una interrupción · Lección 4 de 6

Lección 4: Efectos secundarios e idempotencia: qué herramientas es seguro volver a ejecutar al reanudar

Objetivos de aprendizaje:

  • Explicar por qué repetir al reanudar te entrega por defecto una semántica de ejecución at-least-once, nunca exactly-once
  • Juzgar si una operación de herramienta es idempotente, y detectar los efectos secundarios que causan daño real en cuanto se ejecutan dos veces
  • Diseñar e implementar un registro de efectos indexado por tool_use_id, para que una llamada colgante al reanudar consulte el registro antes de decidir si de verdad ejecuta

Requisitos: Leíste las Lecciones 2 y 3 y entiendes las reglas de conciliación de un pendingToolUse colgante en checkpoint.json (Lección 3); conoces el conjunto de herramientas HIGH_IMPACT y la compuerta de aprobación previa a la ejecución del Curso 7 de esta serie, «Fundamentos del arnés de agente: bucles y control» | Anterior: Lección 3 << | Siguiente: Lección 5 >>

Reanudar te da at-least-once: la Lección 3 dejó sin resolver las herramientas de alto impacto

La Lección 3 te enseñó a leer pendingToolUse desde checkpoint.json y a usarlo para traer de vuelta al bucle una llamada colgante —una donde la caída aterrizó entre la ejecución de la herramienta y la escritura del registro—. La regla de conciliación de entonces era: las herramientas de solo lectura simplemente se vuelven a ejecutar y, para las de alto impacto que no puedes determinar, se agrega un tool_result con is_error para que el bucle deje de estar trabado y la pregunta vuelva a una persona. Es un repliegue honesto, y también es un problema sin resolver. «No poder determinarlo» significa que la tarea no puede continuar por su cuenta, así que cada caída sobre una herramienta de alto impacto necesita a alguien vigilando.

La raíz es esta: reanudar, por su propia naturaleza, te da una semántica de ejecución at-least-once. El proceso puede morir después de que una herramienta tuvo éxito de verdad pero antes de que el resultado se escriba de vuelta en messages o quede confirmado en un punto de control; y en ese momento, «¿esta herramienta se ejecutó o no?» es una pregunta que checkpoint.json por sí solo no puede responder. Para una herramienta de solo lectura como read_file, no saberlo no te cuesta nada: la lees una vez de más y el resultado es el mismo. Para send_email, create_ticket o una transferencia de fondos, no saberlo es un incidente: volver a ejecutar significa que el destinatario puede recibir dos correos idénticos, y que un ticket duplicado puede aparecer en el sistema de la nada.

No es un problema nuevo. Antes en este curso dejamos establecido que los agentes tienen estado y los errores se componen1, y ejecutar dos veces un efecto secundario que debía ocurrir una sola vez es una de las formas concretas que toma esa composición. El error no se detiene en «lo ejecutamos una vez de más»: rueda aguas abajo montado sobre ese efecto secundario extra. Esta lección cierra el hueco que la Lección 3 dejó abierto: primero introduce la idempotencia como concepto y después le atornilla un registro de efectos al bucle de reanudación, para que «no se puede determinar» se convierta en «sí se puede determinar».

Qué significa idempotente: una ejecución o diez, el mismo efecto

Idempotente es una operación que produce el mismo efecto final la ejecutes una vez o muchas. Fíjate que esto va sobre el efecto —el estado final que la operación deja atrás en el mundo exterior (archivos, bases de datos, bandejas de entrada)—, no sobre el valor que literalmente devuelve cada llamada.

Para juzgar si una herramienta es idempotente alcanza con una pregunta: «Si esta operación se ejecutara calladamente una vez de más, ¿el mundo exterior terminaría con algo de más, o en un estado distinto?». Pasa estos pequeños ejemplos por esa pregunta y la diferencia aparece de inmediato.

readFileContent es idempotente por naturaleza porque no tiene ningún efecto secundario: no hay nada «que quede atrás», propiamente hablando. setLine también es idempotente, y sí muta estado de verdad, pero la forma en que muta es sobrescribiendo: llámalo una vez y la línea 42 es X, llámalo diez veces y la línea 42 sigue siendo X. El estado final no varía con la cantidad de llamadas. appendRow y sendEmail no son idempotentes, y en ambos casos por la misma razón: su efecto es acumulativo, cada llamada agrega de verdad una cosa más al mundo exterior, así que la cantidad de llamadas aparece directamente en el estado final.

Sostén esa línea divisoria: las escrituras que sobrescriben suelen ser idempotentes, las que agregan al final normalmente no; las lecturas y las operaciones de «consultar primero y después decidir si actuar» suelen ser idempotentes, mientras que las inserciones lisas e incondicionales normalmente no. El registro de efectos de la sección siguiente existe justamente para atrapar las operaciones que no son idempotentes y que no se pueden rediseñar para que lo sean.

El registro de efectos: anotar qué efectos secundarios ya ocurrieron

La Lección 2 te enseñó a guardar en un punto de control la escena en curso del bucle —messages, los contadores, la llamada a herramienta que todavía no quedó anotada—, para que una caída se pueda retomar en el lugar. Pero un punto de control responde «a qué paso del bucle llegamos», no «el efecto secundario de ese paso ocurrió de verdad». Durante la operación normal los dos se mueven casi en sincronía, pero en cuanto una caída aterriza en el hueco entre ambos, dejan de coincidir, que es exactamente la razón por la que la Lección 3 tuvo que dejar sin resolver la conciliación de alto impacto.

Cerrar ese hueco requiere un registro de efectos: anotar en disco, aparte del punto de control, qué efectos secundarios ya ocurrieron. La estructura es simple, un mapa indexado por tool_use_id:

El momento en que se escribe el registro importa muchísimo: escríbelo en el instante en que la función de la herramienta tiene éxito de verdad y devuelve un resultado, y escríbelo un instante antes que el punto de control del «punto B» de la Lección 2 (la escritura de rutina que ocurre después de que el resultado de herramienta aterriza en messages). La razón es directa. Si la caída aterriza dentro de la ventana angosta entre «la herramienta tuvo éxito» y «el punto de control del punto B terminó de escribirse», ese punto de control nunca tuvo la oportunidad de dejar constancia de que esto pasó, y al reanudar lo único que puede decirte la verdad es el registro que terminó de escribirse antes. La escritura del registro también tiene que usar la escritura atómica de .tmp + rename de las Lecciones 2 y 3, por la misma razón: un archivo de registro escrito a medias es más peligroso que no tener registro, porque te hace creer en un efecto secundario que en realidad nunca se completó.

Con un registro en la mano, la regla de conciliación al reanudar sube de categoría: del «no se puede determinar» de la Lección 3 al «sí se puede determinar». Toma el pendingToolUse de checkpoint.json y busca su id en el registro. Acierto: el efecto secundario de verdad ocurrió, así que saca del registro el result guardado, úsalo para rellenar un tool_result y no vuelvas a ejecutar nunca. Fallo: esta llamada o bien nunca arrancó o bien murió a mitad de camino sin tener éxito, así que ejecutar es seguro. La regla vale para toda herramienta; lo que pasa es que, para las idempotentes, la consulta da lo mismo en cualquier caso. De lo que sí depende todo es de las operaciones que causan daño cuando se repiten.

Hay acá una idea que vale la pena enunciar por sí sola: tool_use_id ya es una clave de idempotencia. Cada vez que el modelo nombra una herramienta, lleva consigo "A unique identifier for this particular tool use block"2 (un identificador único para este bloque de uso de herramienta en particular), que es la definición textual del campo id en la especificación oficial. Si ese mismo nombramiento se ve una segunda vez por una repetición al reanudar, el id no cambia. Eso es precisamente lo que le permite al registro reconocer «esta llamada» y «aquella llamada anterior» como uno y el mismo evento, sin que tengas que inventar un esquema de deduplicación propio.

Dos compuertas en capas: la aprobación pregunta «¿deberíamos?», el registro pregunta «¿ya lo hicimos?»

El Curso 7 de esta serie, «Fundamentos del arnés de agente: bucles y control», le puso a runToolUses una compuerta de aprobación: antes de que una herramienta de alto impacto se ejecute de verdad, imprime lo que está por pasar, espera a que una persona confirme y recién ahí la deja pasar3. Esa compuerta frena la pregunta «¿esto debería hacerse?». El registro de efectos de esta lección frena una pregunta distinta: «¿esto ya se hizo?». Las dos compuertas preguntan cosas diferentes, pero se ubican en el mismo lugar: ambas encajadas en el momento posterior a que el modelo nombró una herramienta y anterior a que la herramienta se ejecutara de verdad. Ninguna de las dos deja que la función de la herramienta se ejecute hasta haber verificado.

Apila las dos y runToolUses queda así:

El orden no es negociable: la compuerta de idempotencia tiene que ir primero. La razón es llana: si esta llamada ya está en el registro, preguntar después «¿deberíamos hacerlo?» no significa nada, porque ya está hecho, y volver a preguntar solo confunde a quien responde —el sistema claramente terminó esto, ¿por qué me pide que lo confirme?—. Para una llamada colgante al reanudar, la primera pregunta es siempre «¿esto ocurrió?», y solo una vez zanjada eso le llega el turno a «¿esto debería ocurrir?».

Idempotencia en la capa de diseño de la herramienta: arregla la causa, no solo atrapes las consecuencias

El registro de efectos es una red de contención del lado del arnés: haya sido o no diseñada la herramienta para ser idempotente, el registro puede bloquear una ejecución duplicada usando tool_use_id. Pero una red de contención sigue siendo una red de contención, y la mejor inversión es arreglar la causa: donde puedas cambiar la herramienta, diséñala para que sea idempotente por naturaleza, para que el registro nunca tenga que intervenir.

La versión más común de ese cambio es convertir «crear» en «asegurar que existe»:

Llama a ensureTicket una vez o diez y el sistema termina con exactamente un ticket que coincide con ese título: el estado final no varía con la cantidad de llamadas, que es la definición de idempotente. El mismo razonamiento aplica a escribir archivos: un write_file de archivo completo es idempotente por naturaleza, y las llamadas repetidas dejan atrás el mismo contenido; un append_file que agrega al final no lo es, y el archivo crece una sección por llamada. Cuando puedas elegir sobrescribir, no elijas agregar al final.

Arreglar la causa y atrapar las consecuencias no son dos opciones entre las que elegir: son una división del trabajo. Las herramientas que puedes diseñar para que sean idempotentes habría que resolverlas en la capa de la herramienta, ahorrándole a cada llamada el rodeo por el registro. Y para las operaciones que genuinamente no se pueden «deduplicar y fusionar» como cuestión de lógica de negocio —dos transferencias que de verdad ocurrieron en momentos distintos, digamos, que deberían reconocerse como dos eventos diferenciados y no se pueden colapsar en uno por diseño ingenioso—, el registro es la única red de contención que hay.

Retomando el hilo: una barrera de protección más, y saber cuándo vale la pena

Este curso arrancó del punto de que la confiabilidad viene de emparejar la adaptabilidad del modelo con salvaguardas deterministas como la lógica de reintentos y los puntos de control regulares1. El registro de efectos es una de esas barreras. No le pide al modelo que juzgue «¿esto ya lo hice?»: eso siempre estuvo más allá de lo que el modelo puede percibir. Hace que el arnés emita ese juicio en nombre del modelo, usando evidencia definida escrita en disco.

Conserva también el sentido de la proporción. Si todas las herramientas que sostiene tu agente son de solo lectura, el registro de esta lección probablemente no se gane su lugar: un registro de efectos es en sí mismo una capa de complejidad, y lo que hace que valga la pena agregarlo es que bloquee genuinamente un riesgo real de efectos secundarios duplicados; habría que agregar complejidad solo cuando mejora los resultados de forma demostrable3. La prueba es la misma que la de la lección anterior: mira primero tu conjunto de herramientas en busca de operaciones no idempotentes y de alto impacto. Si están ahí, vale la pena instalar la compuerta. Si no están, no corras a escribirla.

Resumen

  • Reanudar te entrega una semántica de ejecución at-least-once: el proceso puede morir después de que una herramienta tuvo éxito de verdad pero antes de que el resultado quede escrito en el registro, y el punto de control por sí solo no puede decirte si esa llamada colgante se ejecutó. Esa es la raíz de por qué la Lección 3 tuvo que dejar sin resolver la conciliación de alto impacto.
  • La definición de idempotente: una operación que produce el mismo efecto final se ejecute una vez o muchas. Las escrituras que sobrescriben (set_config, write_file) suelen ser idempotentes; las que agregan al final (append_log, send_email) normalmente no.
  • El registro de efectos anota en disco qué efectos secundarios ya ocurrieron, indexados por tool_use_id, el identificador único que el modelo lleva consigo cuando nombra una herramienta2, que no cambia cuando ese mismo nombramiento se repite al reanudar, y que por eso es una clave de idempotencia por naturaleza. Escríbelo con la escritura atómica .tmp + rename, en el momento en que la herramienta tiene éxito.
  • La regla de conciliación al reanudar sube de categoría a: si pendingToolUse.id acierta en el registro, reutiliza el resultado guardado y no vuelvas a ejecutar; si falla, ejecuta con seguridad.
  • La compuerta de aprobación pregunta «¿esto debería hacerse?», el registro de efectos pregunta «¿esto ya se hizo?». Se complementan, ambas se ubican delante de la ejecución real, y la compuerta de idempotencia va primero.
  • Arreglar la causa le gana a atrapar las consecuencias: diseña herramientas idempotentes por naturaleza («asegurar que existe» antes que «crear», sobrescribir antes que agregar al final) y no vas a necesitar el registro para todo. La confiabilidad viene de la adaptabilidad del modelo emparejada con salvaguardas deterministas1, pero una salvaguarda también es complejidad, y habría que agregarla solo cuando mejora los resultados de forma demostrable3.

>> Lección 5: Rebobinar y bifurcar: el segundo valor de los puntos de control

Footnotes

  1. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3

  2. Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls 2

  3. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3

Ejercicios

01

Para cada una de las seis herramientas de abajo, decide: (1) si es idempotente; (2) si una llamada colgante a ella se vuelve a ejecutar al reanudar, si el riesgo es alto, medio o bajo, y por qué.

Nivel 1: Calificar seis herramientas por idempotencia y riesgo al volver a ejecutarlas
  • read_file(path) — lee el contenido de un archivo
  • send_email(to, subject, body) — envía un correo
  • ensure_ticket(title, body) — busca por título, devuelve el ticket existente si lo hay, crea uno nuevo solo si no lo hay
  • append_log(line) — agrega una línea al final de un archivo de log
  • set_config(key, value) — fija una clave de configuración a un valor dado (sobrescribiendo)
  • delete_file(path) — borra un archivo
Criterios de finalización · marcado local
02

La semana pasada tu arnés estaba resolviendo una tarea de «un cliente reporta una caída del servicio, abrir un ticket». Ejecutó create_ticket, obtuvo un resultado exitoso, y justo ahí dio la casualidad de que reiniciaron el contenedor y lo mataron, antes de que el resultado llegara de vuelta a un tool_result. Cuando el proceso volvió, el arnés leyó pendingToolUse desde checkpoint.json y encontró exactamente esa llamada a create_ticket. Con el runToolUses sin registro de abajo, su única opción era volver a ejecutar, así que el reporte de caída de un solo cliente se convirtió en un ticket duplicado en el sistema, salido de la nada.

Nivel 2: Agregar un registro de efectos a un runToolUses que no lo tiene

Tu trabajo: (1) escribir las funciones de lectura y escritura loadEffects/saveEffects para effects.json, usando una escritura atómica (primero .tmp, después rename); (2) reescribir runToolUses para agregar la lógica de «consultar el registro antes de ejecutar, ejecutar solo si hay fallo, anotar en el registro en el momento en que la ejecución tiene éxito»; (3) escribir un script de Node que lo demuestre: llamar dos veces a tu runToolUses reescrito con el mismo tool_use_id (simulando una repetición al reanudar) y mostrar que la segunda llamada no vuelve a ejecutar la implementación real de create_ticket.

Criterios de finalización · marcado local