Agent Mentor Learn
De los bucles a los grafos: ingeniería de orquestación para sistemas de agentes · Lección 6 de 6

Lección 6: Manos a la obra: convertir tu arnés en un grafo pequeño

Objetivos de aprendizaje:

  • Soldar el enrutamiento, el fan-out, la fusión, el bucle de revisión y el informe de las primeras cinco lecciones en un solo orchestrate.mjs: el plan vive en el código, cada nodo sigue ejecutando el bucle de stop_reason del curso 7 (Fundamentos del arnés de agente: bucles y control), y los resultados intermedios se quedan en variables del script
  • Poner el bucle de revisión a girar de verdad, y ver las dos formas en que puede parar—un ticket corregido según el informe de la compuerta y listo, otro devolviendo informes idénticos dos rondas seguidas, juzgado sin más progreso, marcado needs_human
  • Persistir la traza de ejecución del grafo entero en run-state.json y run.jsonl, y después conciliarla contra la tabla resumen de la ejecución real: qué nodo gastó cuánto tiempo, cuántas llamadas al modelo, cuántos tokens, cuántas rondas de compuerta

Requisitos: Completaste las Lecciones 1–5, puedes ejecutar el bucle del arnés del curso 7 (Fundamentos del arnés de agente: bucles y control) | Anterior: << Lección 5

Primero, verlo ejecutarse

Las primeras cinco lecciones separaron las piezas: quién tiene el plan (Lección 1), encadenamiento y enrutamiento (Lección 2), seccionamiento y votación más un pool de concurrencia acotado (Lección 3), orquestador-trabajadores y los cuatro elementos de los prompts de delegación (Lección 4), el bucle de revisión y cómo componer estos patrones en lo que la Lección 5 llama un «grafo» (Lección 5). Esta lección los suelda en un solo archivo.

La tarea es deliberadamente mundana: inbox/ tiene seis tickets de atención al cliente, y el trabajo es escribir para cada uno una respuesta que se pueda enviar tal cual. Primero, cómo se ve cuando termina:

text
\$ node orchestrate.mjsinbox/ recibió 6 tickets: T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] techo de concurrencia 2, produjo 6 borradores[merge] escribió 6 archivos en out/, pasando aguas abajo solo referencias y resúmenes de una línea[review] reescrituras por compuerta: 2 rondas en total
=== Resumen de ejecución del grafo completo ===Nodo      Tiempo   Llamadas   Tokens   Rondas   Estadoroute     63ms     1          720      -        okfanout    246ms    8          8903     -        okmerge     2ms      0          0        -        okreview    129ms    2          3033     2        ok
=== Desglose por ticket ===Ticket   Categoría  Manejador         Rondas   Motivo de parada  EstadoT-1001   billing    worker:billing    0        gate_pass         passT-1002   bug        worker:bug        0        gate_pass         passT-1003   other      template          0        gate_pass         passT-1004   billing    worker:billing    1        no_progress       needs_humanT-1005   bug        worker:bug        1        gate_pass         passT-1006   other      template          0        gate_pass         pass
Directorio de salida out/: 6 respuestas; requieren traspaso a una persona: 1 ticket  - T-1004 (no_progress): Ticket T-1004: pasar la factura de nombre personal a nombre …Traza: run-state.json / run.jsonl (run_id=run-mtemep4r)\$ echo \$?1

Cada salida de terminal de esta lección viene de ejecuciones reales de este script, copiada línea por línea—ni una sola línea es un ejemplo escrito a mano. Dos cosas cambian en cada ejecución: los tiempos en milisegundos, y el run_id (es una marca de tiempo en base 36). Todo lo demás—resultados de clasificación, cantidad de llamadas, números de tokens, cantidad de rondas de compuerta, cuál ticket queda en needs_human—es una constante fijada. La razón se explica más adelante, en la sección «Preparación de la verificación».

Vale la pena quedarse mirando ese 1 final. No es un error—es un veredicto: seis tickets, uno no pudo terminarse solo, así que el código de salida no es 0. Cada ejecución de este grafo produce una conclusión que CI o un cron pueden parsear, no apenas un montón de logs.

Cómo se ve el grafo: el plan son esa docena de líneas de main()

Empieza por el esqueleto del script. Usar «grafo» y «nodo» es el vocabulario que introdujo la Lección 5—es un sistema visual nuestro, no un concepto oficial, y descansa en exactamente un ancla primaria: el script del flujo de trabajo mismo retiene el bucle, las bifurcaciones y los resultados intermedios1. El fragmento de abajo es la implementación literal de ese enunciado:

La Lección 5 dibujó primero un grafo compuesto; este grafo es una variante de aquel, con tres diferencias: la Lección 5 partía por dificultad en «simple / complejo», aquí partimos por tema en billing / bug / other; el fan-out de la Lección 5 era «un ticket complejo despachado a tres trabajadores y después fusionado», aquí es seccionamiento—«seis tickets, a cada uno se le asigna un manejador»; la arista de retorno de la Lección 5 volvía a un nodo [borrador] aparte, aquí vuelve al trabajador original. Por qué todos estos cambios está recogido en la sección «Tabla de conciliación» del final.

routed, drafts, items—estas tres declaraciones const son todo el estado del grafo. Son variables corrientes de JavaScript, no objetos de estado tipados, y no hay estrategia de fusión—los resultados intermedios se quedan en variables del script1, y los nodos se pasan datos por el valor de retorno de las funciones. Ningún modelo ve el panorama completo: el modelo de enrutamiento ve solo los textos de seis tickets, el trabajador de billing ve solo el ticket que le tocó, la compuerta de revisión ve solo un archivo de respuesta.

Así se ve en código la distinción arquitectónica entre flujo de trabajo y agente: los LLM y las herramientas se orquestan a través de caminos de código predefinidos2, y no es el modelo dirigiendo autónomamente sus propios procesos2.

Cinco nodos, cada uno se encarga de un tramo:

NodoQué haceQuién lo hace
routeUna llamada barata parte seis tickets en tres categoríasUn bucle de modelo
fanoutDespacha por categoría a trabajadores especializados, con la concurrencia acotadaDos tipos de bucles de modelo + una plantilla de código puro
mergeLa salida aterriza en disco, aguas abajo solo van referencias y resúmenes de una líneaCódigo puro
reviewLa compuerta determinista filtra primero, lo que falla entra a revisar-corregir-revisarCódigo puro + bucles de modelo bajo demanda
reportImprime las tablas resumen, determina el código de salidaCódigo puro

Solo dos de los cinco nodos llaman de verdad a un modelo. No todo nodo tiene que ser un modelo—esta es la regla más barata de la lección y la que más fácil se pasa por alto: merge y report son funciones puras, la categoría other de fanout usa una plantilla de cadenas, y el primer filtro de review son unas pocas líneas de includes. Donde el código determinista pueda dar la misma respuesta, no hay razón para pagar el costo y la latencia de una llamada al modelo.

Dentro de los nodos: sigue siendo el bucle del curso 7

Fija primero la capa más interna y después el grafo cobra sentido. Cada nodo de modelo ejecuta internamente el bucle de stop_reason del curso 7 (Fundamentos del arnés de agente: bucles y control), sin cambios:

Los cuatro pasos del cuerpo del bucle—empujar assistant, ejecutar herramientas, empujar tool_result, reasignar response—son palabra por palabra idénticos a la Lección 6 del curso 7, hasta los comentarios están copiados. La válvula 1 (máximo de turnos) está en su posición original: al inicio del cuerpo del bucle, antes de turns++. Dejar un máximo de iteraciones como condición de parada de los bucles es práctica estándar para mantener el control2.

Frente al curso 7 hay dos cambios, los dos fuera del cuerpo del bucle: client y system pasaron de constantes a nivel de módulo a parámetros (tres roles necesitan stubs distintos y prompts de sistema distintos, hay que pasarlos); y el conteo de tokens y de llamadas se movió de dentro del cuerpo del bucle a una capa envolvente por fuera del cliente, con el interior del bucle sin cambios:

Este cambio tiene un costo y hay que declararlo: la válvula 2 del curso 7 (presupuesto de tokens) se apoyaba originalmente en el acumulador del cuerpo del bucle; ese acumulador ya no está en el bucle, así que la válvula 2 tampoco hizo la mudanza. En este grafo, la cola de respuestas del stub de cada nodo tiene largo fijo y agotarla lanza directamente, así que no se puede desbocar; pero cuando cambies los stubs por un cliente real, vuelve a poner la válvula 2—o haces que metered lance al pasarse del presupuesto, o mueves el conteo de vuelta al cuerpo del bucle y restauras la forma original del curso 7. La válvula 3 (detección de giro en falso) y la válvula 4 (aprobación humana) tampoco se mudaron; la razón está en la sección «Tabla de conciliación» más adelante.

La mitad de las herramientas también está copiada: la respuesta de un turno contiene varios bloques tool_use, se devuelven esa misma cantidad de bloques tool_result, y si una herramienta lanza se envuelve en is_error: true y se le pasa de vuelta al modelo, en vez de tumbar el proceso entero.

Nodo uno: enrutamiento—una llamada barata, y después ajustar la salida

El enrutamiento clasifica una entrada y la dirige hacia tareas de seguimiento especializadas2. Es la llamada al modelo más barata del grafo: una solicitud clasifica los seis, sin herramientas, sin escribir respuestas.

La clave son las diez líneas del medio, no la llamada al modelo. El modelo devuelve texto libre, cada rama de aguas abajo depende de ese valor, así que hay que ajustarlo a una de tres etiquetas legales antes de que entre aguas abajo: las líneas que no coinciden con el formato se descartan; las categorías fuera de la lista blanca caen a other; los tickets para los que no coincidió ni una línea los atrapa parsed.get(t.id) ?? "other".

Hice que el stub devolviera deliberadamente «queja» para el último ticket—no está en la lista blanca. El log de la ejecución real muestra ese ajuste:

text
{"ts":"2026-08-29T16:54:21.691Z","run_id":"run-mtemep4r","node":"route","event":"clamped","ticket":"T-1006","raw":"queja","category":"other"}

El modelo dio una etiqueta inventada, el código la acotó de vuelta a other, y dejó un registro de qué se acotó. Las ramas de aguas abajo solo reconocen valores que el código ya validó—esta es la diferencia práctica entre un nodo de enrutamiento y «dejar que el modelo decida directamente hacia dónde saltar», y es por lo que el enrutamiento se puede probar unitariamente.

Nodo dos: fan-out—tres trabajadores y un pool de concurrencia acotado

El fan-out sigue el seccionamiento: partir la tarea en subtareas mutuamente independientes y ejecutarlas en paralelo2. Aquí lo de «independientes» es natural—los seis tickets no tienen ninguna dependencia entre sí, el orden no importa.

Tres categorías, tres manejadores, y solo dos son modelos:

El pool de concurrencia es el pool de la Lección 3 (allá se llamaba pool, aquí runPool): las tareas esperan detrás de un cursor, se lanzan limit consumidores a tomarlas, y se termina cuando se agotan. El techo funciona de verdad, no es decorativo. Ajústalo a 1 y ejecuta de nuevo: el tiempo de la línea fanout se alarga notablemente (la cantidad de llamadas y los tokens son idénticos, los milisegundos fluctúan como siempre):

text
\$ POOL_SIZE=1 node orchestrate.mjs...=== Resumen de ejecución del grafo completo ===Nodo      Tiempo   Llamadas   Tokens   Rondas   Estadoroute     62ms     1          720      -        okfanout    491ms    8          8903     -        okmerge     2ms      0          0        -        okreview    128ms    2          3033     2        ok

491ms frente a 246ms, con cantidad de llamadas y tokens idénticos. La concurrencia compra tiempo de reloj, no menos trabajo—esto sigue siendo cierto tras cambiar a una API real, salvo que entonces además tienes que considerar los límites de tasa del proveedor, lo que vuelve el techo todavía más esencial.

Prompts de delegación: los cuatro elementos presentes

Los prompts de los tres roles de modelo siguen los cuatro elementos de la Lección 4: objetivo, formato de salida, guía de herramientas, límites de la tarea. Los subagentes necesitan un objetivo, un formato de salida, guía sobre las herramientas y fuentes, y límites claros de la tarea; sin una descripción adecuada, los trabajadores duplican trabajo, dejan huecos, o no encuentran lo que deberían3. El trabajador de billing:

Cuatro líneas, cada una haciendo su trabajo: el objetivo determina qué escribe; el formato de salida le da a la compuerta de aguas abajo algo que revisar (el requisito de «empezar con el id del ticket» mapea directamente a la primera regla de la compuerta); la guía de herramientas clava «de dónde salen los montos» en lookup_order, bloqueando el camino de inventar cifras a partir de la descripción del ticket; los límites de la tarea bloquean acciones fuera de alcance y prohíben de antemano las muletillas.

La versión del trabajador de bug cambia el contenido a consultar la base de problemas conocidos, citar números de problema y prohibir números inventados; la «guía de herramientas» del enrutador dice «este paso no te da herramientas, juzga solamente por el texto del ticket», lo que concuerda con el arreglo vacío de tools que se pasa en el código. Las diferencias entre estos tres prompts son en sí mismas el rédito del enrutamiento: después de clasificar, cada uno escribe lo suyo, sin necesidad de embutir los requisitos de tres tipos de trabajo en un solo prompt—esto es precisamente la separación de responsabilidades y los prompts más especializados que habilita el enrutamiento2.

Nodo tres: fusión—pasar referencias, no cargas

merge es código puro, cero llamadas al modelo. Hace dos cosas: escribir cada borrador en out/, y después recolectar un manifiesto liviano para aguas abajo—{id, category, handler, file, oneLine}, una ruta de archivo más un resumen de una línea, no seis respuestas completas. (Al mismo tiempo también crea un registro para cada ticket en run-state.json, con los campos que se ven en la sección 9 del código completo.)

Esto trae a un script de un solo proceso el consejo de ingeniería de los sistemas multiagente: hacer que los agentes especializados guarden sus salidas en sistemas externos y le pasen de vuelta al coordinador solo referencias livianas3. En aquella retrospectiva, este consejo resolvía el inflado de contexto de «todo se retransmite vía el agente líder»; aquí resuelve la versión a pequeña escala de lo mismo—el nodo de revisión necesita «qué archivo hay que revisar», no los seis textos completos apilados en una variable que se pasa de mano en mano.

Por eso la primera acción del nodo de revisión es releer el contenido desde el archivo:

Este paso parece redundante—total, todo está en el mismo proceso, basta con pasar la cadena directamente. Pero compra dos cosas: el archivo en out/ se vuelve la única fuente de verdad de ese ticket, y quien sea que lo edite, eso es lo que la revisión revisa; y en cuanto esta arista necesite cruzar procesos o máquinas, solo cambia esta línea de readFileSync, y el contrato entre nodos no se mueve.

Una pregunta

A esta altura, tres de los cinco nodos del grafo están completos: el enrutamiento está ajustado por código, la fusión es código puro, y la compuerta que viene también será código puro. La pregunta que más se escucha en este punto se puede plantear directamente.

Nodo cuatro: bucle de revisión—la compuerta filtra primero, lo que falla vuelve al horno

El nodo de revisión hace revisar-corregir-revisar: ejecutar un verificador, corregir lo que falló, y repetir hasta que pase o deje de haber progreso1. Es el único lugar de este grafo donde «la salida de un modelo se manda de vuelta para reescribirla».

El primer filtro es determinista, unas pocas líneas de includes y listo:

Dos reglas, las dos del tipo que el curso 10 (Verificación y aseguramiento de calidad: que lo que «parece correcto» no se cuele) llamó «si se puede determinar de forma determinista, no le preguntes a un juez»: la respuesta debe contener el id del ticket (los sistemas de atención se indexan por él), y no debe contener muletillas sin contenido informativo como «espera un poco», «gracias por tu paciencia» o «lo resolveremos pronto». Ninguna de las dos requiere comprensión semántica, con inclusión de cadenas alcanza, el resultado es el mismo siempre, y de paso produce una cadena de informe que se le puede devolver directamente al trabajador.

El juez LLM aquí podría encargarse de «¿el tono de la respuesta es apropiado?», «¿los hechos exceden lo que devolvieron las herramientas?»—cosas verdaderamente no juzgables con includes. Pero tiene que ir después de la compuerta: la compuerta es gratis y determinista, deja que filtre primero los problemas claros, y lo que quede vale la pena gastar una llamada en consultarle al juez. Este grafo solo instaló la capa de compuerta, porque los criterios de aceptación de esta tanda de tickets resultan expresables como reglas; cuando los criterios de aceptación incluyan palabras como «apropiado el tono», agrega la capa de juez según la asignación por niveles de juicio del curso 10.

El bucle en sí se ve así:

Las tres sentencias break corresponden a tres formas de parar, que coinciden con lo que declaró la Lección 5: pasa (la condición del while se vuelve falsa naturalmente), no hay más progreso, se alcanzó el máximo de rondas. El tercer if es un parche—las respuestas de la categoría other las genera una plantilla de código puro, no hay trabajador al que devolvérselas, y si la plantilla misma está rota la única opción es el traspaso directo. Esta ejecución no lo alcanzó (la plantilla es constante y por fuerza pasa la compuerta); se conserva porque si alguien corrompe la cadena de la plantilla, prefiero ver un registro no_rewriter antes que un bucle girando en falso.

Lo que se le devuelve al trabajador al reciclar es sencillo: el texto completo de la versión anterior + el informe de la compuerta + una frase de «corrige solo los problemas nombrados en el informe y reescribe la respuesta completa» (se arma en callWorker).

Las dos formas de parar ocurrieron de verdad en esta ejecución

Planté dos guiones en los stubs para que cada salida del bucle se ejecute una vez.

T-1005: Corregido bien, listo. La primera versión del trabajador de bug se olvidó del id del ticket (falla la primera regla), la compuerta devuelve missing_ticket_id, el trabajador agrega la línea de apertura según el informe, y la segunda versión pasa:

text
{"ts":"2026-08-29T16:54:22.009Z","run_id":"run-mtemep4r","node":"review","event":"gate","ticket":"T-1005","round":0,"pass":false,"report":"missing_ticket_id"}{"ts":"2026-08-29T16:54:22.070Z","run_id":"run-mtemep4r","node":"review","event":"worker_done","ticket":"T-1005","round":2,"calls":1,"tokens":1638}{"ts":"2026-08-29T16:54:22.072Z","run_id":"run-mtemep4r","node":"review","event":"gate","ticket":"T-1005","round":1,"pass":true,"report":""}

T-1004: Corregido, pero no arreglado, y el bucle se detuvo solo. La primera versión del trabajador de billing escribió «espera un poco», la compuerta devuelve filler_word:espera un poco; el trabajador reescribió una versión, con la frase completamente distinta, más larga, con una explicación agregada, pero esa expresión sigue ahí. El informe de la segunda ronda es idéntico al de la primera:

text
{"ts":"2026-08-29T16:54:21.947Z","run_id":"run-mtemep4r","node":"review","event":"gate","ticket":"T-1004","round":0,"pass":false,"report":"filler_word:espera un poco"}{"ts":"2026-08-29T16:54:22.008Z","run_id":"run-mtemep4r","node":"review","event":"worker_done","ticket":"T-1004","round":2,"calls":1,"tokens":1395}{"ts":"2026-08-29T16:54:22.008Z","run_id":"run-mtemep4r","node":"review","event":"gate","ticket":"T-1004","round":1,"pass":false,"report":"filler_word:espera un poco"}

En este momento se cumple gate.report === lastReport, el bucle juzga que no hay más progreso, para, y marca este ticket como needs_human. Originalmente le quedaban dos rondas de presupuesto (MAX_REVIEW_ROUNDS es 3), pero gastarlas sería desperdicio—se le devuelve el mismo informe, y lo más probable es que vuelva la misma respuesta. El valor de la salida por «no hay más progreso» está aquí: corta pérdidas antes que el máximo de rondas, y da una conclusión informativa—no «se intentó tres veces y sigue fallando», sino «no entiende esta retroalimentación», que es justamente la señal para escalar a una persona.

La diferencia entre las dos salidas se ve de inmediato en los datos:

Los gate_rounds de los dos tickets son 1, así que la cantidad de rondas por sí sola no distingue el éxito del fracaso; la línea divisoria es el largo de gate_reports—registra cada informe fallido, incluido el último, el que causó la parada. T-1005 deja una sola entrada (la segunda versión pasó, no hubo segundo informe), T-1004 deja dos de contenido idéntico, y el campo stop escribe la conclusión directamente como no_progress.

Nodo cinco: informe y traza

El último nodo también es código puro: imprimir state.nodes y el desglose por ticket como dos tablas, contar los needs_human, determinar el código de salida. Todos pasaron es 0, uno requiere persona es 1.

La traza se parte en dos archivos, cada uno con su propósito. run.jsonl es el registro estructurado del curso 11 (Observabilidad y depuración: ver cada paso que da tu agente), un evento JSON por línea, cada uno llevando ts y run_id, grepeable después del hecho—esta ejecución totalizó 39 líneas, y los extractos de las secciones anteriores están todos grepeados de ahí tal cual.

run-state.json registra la traza de ejecución (distinta del «estado del grafo = esas pocas variables del script»), escrita al estilo del curso 9 (Gestión de estado y persistencia: hacer que las tareas largas sobrevivan a las interrupciones): primero se escribe .tmp y después un rename de intercambio atómico, así que si te matan en cualquier momento, en disco está o el estado completo anterior o el estado completo nuevo, nunca medio JSON:

El momento de escritura es «persistir después de cada paso pequeño»: tras completarse cada nodo se persiste una vez, y dentro del nodo de revisión se persiste otra vez tras el veredicto de cada ticket. La razón la citó la Lección 5—rastrear incrementalmente el resultado de cada agente es precisamente la premisa para recuperar una ejecución dentro de la misma sesión1; un flujo de trabajo que reparte el trabajo entre muchos agentes pequeños preserva más progreso que un solo agente largo1. Este grafo no es un runtime multiagente, pero el mismo enunciado se sostiene aquí: seis tickets son seis unidades de progreso independientes, y si muere a mitad de la revisión, lo ya persistido no debería desaparecer con él (la fase de fan-out todavía no logra esto—ver el punto 3 de la Tabla de conciliación).

Para ver el efecto real de este enunciado, usa STOP_AFTER=merge para detener el proceso después del fan-out y antes de la revisión:

text
\$ STOP_AFTER=merge node orchestrate.mjsinbox/ recibió 6 tickets: T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] techo de concurrencia 2, produjo 6 borradores[merge] escribió 6 archivos en out/, pasando aguas abajo solo referencias y resúmenes de una línea[stop] STOP_AFTER=merge: parando antes de la revisión, esta ejecución no da veredicto\$ echo \$?2

run-state.json en este momento (extracto):

Las cuentas de tres nodos están, la categoría, el manejador y las rutas de archivo de salida de los seis tickets están, y seis archivos de borrador ya están persistidos en out/. Solo se perdió el tramo de revisión: todos los tickets quedaron en status: "drafted", con stop: null. Este estado alcanza para sostener una reanudación—leer los borradores de vuelta desde out/ y arrancar directamente desde el nodo de revisión. Fíjate en que el one_line de T-1005 justamente deja al descubierto el defecto del borrador: a la apertura le falta el id del ticket. La revisión todavía no se ejecutó, así que ese defecto todavía no se atrapó.

(STOP_AFTER solo reconoce merge como valor único; es la versión simplificada del punto de caída controlada del curso 9: código de salida 0 todos pasaron, 1 hay tickets para una persona, 2 se detuvo antes sin veredicto, 3 el script mismo se cayó—cuatro códigos que no se solapan, y CI distingue de un vistazo «se ejecutó pero algunos necesitan traspaso» de «se cayó».)

orchestrate.mjs completo

Abajo está el texto completo, un solo bloque continuo; cópialo y pégalo en orchestrate.mjs dentro de un directorio vacío y después node orchestrate.mjs. Cero dependencias, sin necesidad de npm i, sin necesidad de package.json (el sufijo .mjs ya declara que es un módulo ES), y sin necesidad de clave de API—el cliente del modelo es un stub. La primera ejecución creará inbox/, kb/, out/ y escribirá esos seis tickets.

Seiscientas setenta y nueve líneas en total, de las cuales alrededor de ciento noventa son datos alimentados a los stubs (la tabla SCRIPTS, los textos originales de los seis tickets, la base de problemas conocidos, el cliente stub); la lógica de orquestación propiamente dicha—cinco nodos, pool de concurrencia, compuerta y punto de entrada—ronda las doscientas cincuenta líneas, y otras cuarenta y pico son observabilidad y traza de estado. Esta escala es deliberada: un bucle más unos cuantos patrones es genuinamente algo implementable en unas pocas líneas de código2.

Preparación de la verificación

Cada salida de terminal de esta lección vino de ejecuciones reales de este script, no de «ejecutar varias veces y elegir la que se ve bien», sino de fijar de antemano dos fuentes de no determinismo.

Cambiar el modelo por un stub que reproduce una cola fija. SCRIPTS es una tabla cuya clave es «id de ticket + qué versión» y cuyo valor es una secuencia de respuestas escrita de antemano; cada llamada a messages.create escupe la siguiente en orden, y si la cola se agota y todavía se llama, lanza directamente. Así, «qué ticket llama a qué herramienta en qué ronda, cuándo termina el modelo» son todas constantes. El stub además dejó una aserción: create debe traer model y max_tokens, y si falta uno lanza—el cliente real exige esos dos parámetros, el stub no te va a tapar el hueco, así que no lo descubres el día que cambias al cliente real. Este método se usa desde la práctica del curso 8 hasta aquí, de modo que lo que se verifica es tu lógica de control, no el desempeño del modelo ese día (los modelos reales son no deterministas, y la misma entrada puede dar respuestas distintas4).

El stub además agrega un retardo fijo de 60 ms que reemplaza el viaje de red real. Sin él cada nodo daría 0 ms y el efecto del pool de concurrencia no se vería en la tabla resumen—la comparación de POOL_SIZE=1 de más arriba (491 ms frente a 246 ms) se apoya en eso.

Dos guiones de bucle plantados en los stubs. El bucle de revisión necesita girar de verdad, y para eso hace falta que algo falle de verdad en la compuerta. Entonces:

  • T-1005#1 (la primera versión del trabajador de bug) omite deliberadamente el id del ticket y dispara missing_ticket_id; T-1005#2 agrega la línea de apertura y la segunda versión pasa—esto demuestra la salida por finalización normal de «revisar-corregir-revisar».
  • T-1004#1 y T-1004#2 (las dos versiones del trabajador de billing) traen las dos «espera un poco». Las frases de las dos versiones son completamente distintas y de largo distinto, pero la compuerta busca si esa expresión está presente, así que las cadenas de informe de las dos rondas salen idénticas y se dispara el «no hay más progreso»—esto demuestra la salida que corta pérdidas.

La escritura de los dos guiones tiene su artesanía: no hacer que la segunda versión repita textualmente la primera (así hasta una persona vería que es un bucle muerto), sino hacerla «corregida, pero no arreglada». Este es el modo de fallo más común en los bucles reales, y es exactamente lo que atrapa el criterio de «dos rondas seguidas con informe idéntico».

Las constantes que dependen del idioma se localizaron junto con los textos. Tres constantes de este script están atadas al idioma de las respuestas, así que se movieron con él: FILLER_WORDS guarda las muletillas en español que efectivamente aparecen en las respuestas de los stubs (si se dejaran las de otro idioma, la compuerta nunca dispararía y el bucle de revisión no giraría ni una vez); oneLineOf corta por el punto de la oración en español; y pad cuenta puntos de código en vez de aplicar la regla de ancho doble, porque los acentos y la ñ ocupan una sola columna y con la regla de ancho doble las tablas quedarían desalineadas. La lógica de control no cambió en ninguno de los tres casos—cambió el dato dependiente del idioma que esa lógica consume.

Parada temprana controlada. STOP_AFTER=merge detiene el proceso después del fan-out y antes de la revisión, con código de salida 2. Es la versión simplificada del CRASH_AFTER del curso 9: hacer que «en qué paso se interrumpe» sea un parámetro especificable con precisión, en vez de depender de la suerte para dar con él. El run-state.json en estado drafted de más arriba salió de esa ejecución.

Tabla de conciliación: este grafo le debe cuentas a lecciones anteriores, salda línea por línea

Cuando un curso llega a su práctica final, el error más fácil es tumbar en silencio reglas establecidas antes. Así que conciliemos línea por línea aquí, con las discrepancias escritas explícitamente.

1. El cuerpo del bucle coincide con el curso 7. Los cuatro pasos del cuerpo del bucle—empujar assistant, ejecutar herramientas, empujar tool_result, reasignar response—son palabra por palabra idénticos a la Lección 6 del curso 7, hasta los comentarios sin cambios. La válvula 1 también en su posición original. Diferencias declaradas: la firma de runAgent agregó los dos parámetros client y system (tres roles necesitan stubs distintos y prompts de sistema distintos), y la llamada a create agregó un campo system; la medición de tokens se movió del cuerpo del bucle al envoltorio metered, así que la válvula 2 del curso 7 (presupuesto de tokens) no siguió, y la válvula 3 (detección de giro en falso) y la válvula 4 (aprobación humana) tampoco se movieron—las herramientas de este grafo son solo leer archivo y consultar pedido, las dos operaciones de solo lectura, sin acciones de alto impacto que requieran aprobación; y la cola del stub es finita, no puede girar en falso indefinidamente. Antes de conectar la API real, esas tres válvulas hay que instalarlas de vuelta.

2. Los cuatro elementos de los prompts de delegación están completos (Lección 4). Los tres prompts—enrutador, trabajador de billing, trabajador de bug—escribieron cada uno las cuatro secciones completas de objetivo, formato de salida, guía de herramientas y límites de la tarea, una línea cada una, comparables línea por línea3.

3. El pool de concurrencia tiene techo, y la fusión pasa referencias y no cargas (Lección 3). El limit de runPool es un techo duro, y la diferencia de tiempo entre POOL_SIZE=1 y POOL_SIZE=2 ya quedó verificada. De merge en adelante se pasa aguas abajo {id, category, handler, file, oneLine}, el texto completo se queda en out/, y el nodo de revisión lo lee de vuelta desde el archivo por su cuenta3. Diferencia declarada: el pool de la Lección 3 era «la misma tanda de subtareas ejecutadas en paralelo», aquí el pool abarca tres tipos de manejador—dos trabajadores de modelo más una plantilla de código puro, y la entrada de la plantilla al pool casi no cuesta tiempo. La semántica del pool no cambió (la cantidad de tareas en vuelo no supera el techo), apenas las tareas mismas son heterogéneas. También hay algo que la Lección 3 estableció y aquí se omitió por brevedad del script: la Lección 3 exigía un try/catch separado por carril, para que el fallo de un carril no arrastrara la tanda entera, y a runPool le falta ese envoltorio—el costo es que si en la fase de fan-out cualquier carril lanza, la tanda entera de borradores no se persiste. Antes de conectar la API real hay que agregarlo; con red real, el tiempo agotado de un carril suelto es normal.

4. La compuerta antes del juez, y las condiciones de parada del bucle coinciden con la Lección 5. El primer filtro es código determinista, no modelo; esta lección no instaló la capa de juez LLM porque los criterios de aceptación de esta tanda de tickets resultan expresables como reglas, e instalarla sería dinero desperdiciado—el juicio por niveles del curso 10 tiene este orden: primero lo que se puede juzgar de forma determinista, y lo que quede se le consulta al juez. Las condiciones de parada del bucle son de tres tipos: pasa, no hay más progreso, se alcanzó el máximo de rondas1 2, y los conceptos se corresponden uno a uno con la Lección 5. Pero los nombres de campo y de valores cambiaron: la Lección 5 aterrizaba en el campo reason, con valores passed/no-progress/max-rounds, y aquí aterriza en el campo stop, con valores gate_pass/no_progress/max_rounds (el criterio cambió de juez a compuerta, y el guion también cambió a guion bajo según la convención snake_case de esta lección); además, el rounds de la Lección 5 cuenta las veces de generación, y el borrador cuenta como ronda 1, mientras que el gate_rounds de esta lección cuenta las veces de reescritura, y el borrador es la ronda 0, así que para un mismo ticket el punto de partida del conteo de las dos lecciones difiere en uno. Diferencia declarada: el código tiene una cuarta salida, no_rewriter (la plantilla de código puro no tiene trabajador al que devolverle). No es un patrón que la Lección 5 haya omitido, es la situación específica de este grafo—el bucle de la Lección 5 presuponía «quien produce es un modelo», y aquí una categoría de productores es una plantilla. Esta ejecución no alcanzó esa rama.

5. La forma de hablar de «grafo» coincide con la declaración de la Lección 5. El «grafo» y el «nodo» de todo el texto son la metáfora de ingeniería propia de esta lección, la Lección 5 ya lo declaró explícitamente al introducir este sistema visual, y no es un concepto oficial de ningún material primario; el ancla primaria sobre la que se puede parar es solo esa: el script del flujo de trabajo mismo retiene el bucle, las bifurcaciones y los resultados intermedios1. Esta lección no agregó terminología nueva—«máquina de estados» y «objetos de estado que se pasan entre nodos» no se usan; la «arista» que definió la Lección 5 (quién alimenta a quién con su salida) apareció solo una vez, al explicar el flujo de datos de merge → review, y no es vocabulario nuevo. routed / drafts / items son apenas tres variables locales corrientes.

6. La escritura atómica de run-state.json coincide con el curso 9. Primero se escribe .tmp, después el intercambio con renameSync, sin faltar un paso. El momento de escritura también sigue el calibre de ese curso: persistir una vez tras completarse cada paso pequeño, no una vez tras completarse la ejecución entera.

7. El calibre de observabilidad tiene la misma forma que el curso 11, pero con grano más grueso. Un evento JSON por línea, cada uno llevando ts y run_id, grepeable después del hecho. Cuatro diferencias: (a) el logger del curso 11 registra un resumen del contenido (forma, largo, primeros caracteres), y esta lección registra solo id, categoría, nombre de archivo, cadena de informe y contadores, sin registrar el texto completo de la respuesta—el texto completo ya está en out/; (b) el campo de asociación que el curso 11 llama trace_id aquí se llama run_id; (c) el núcleo de ese curso es usar span_id/parent_id para encadenar un árbol de traza, y este grafo, aunque tiene el anidamiento de tres capas nodo→trabajador→herramienta, no implementó el enlace padre-hijo, así que no hay árbol de traza; (d) initLog() limpia run.jsonl en cada ejecución y conserva solo la más reciente, así que para hacer la comparación entre ejecuciones del curso 11 (v-good frente a v-bug) habría que cambiarlo a agregar en archivos separados por run_id. Para conectar este grafo a un sistema de trazas real, hay que agregar los campos de span del curso 11 siguiendo ese patrón.

8. El patrón orquestador-trabajadores esta lección no lo implementó a propósito (Lección 4). En orquestador-trabajadores de la Lección 4, la clave es que «cuántos despachar y qué hace cada uno» lo decide el modelo mirando la entrada sobre la marcha; este grafo no es así—cómo se clasifican los seis tickets y a qué trabajador va cada categoría quedó cerrado en CATEGORIES y en tres prompts constantes antes de escribir la primera línea de código. Esto es precisamente la aplicación directa del «si lo puedes predefinir, no lo vuelvas dinámico» de la Lección 4: la forma de esta tanda de trabajo es conocida, así que no habría que devolverle la autoridad de decisión al modelo. Así que, en rigor, lo soldado en este archivo son cuatro patrones (encadenamiento, enrutamiento, paralelización-seccionamiento, bucle de revisión); la votación completa el quinto en el ejercicio de Nivel 2, y orquestador-trabajadores es el que queda vedado por la naturaleza de esta tanda de tareas.

Límites

Este grafo maneja algo pequeño: un proceso, una tanda de tickets, se ejecuta y sale. Vale la pena construirlo porque los cinco pasos «llegan los tickets → clasificar → manejar por categoría → revisar → informar» quedaron cerrados antes de escribir la primera línea de código. Si la tarea se vuelve «averigua con qué se topó de verdad este cliente en los últimos seis meses, y cuántos pasos hacen falta júzgalo tú», entonces este grafo es la arquitectura equivocada—ese tipo de problema abierto, donde no puedes predecir los pasos de antemano ni fijar un camino en el código, pertenece por naturaleza a un bucle autónomo2.

Varios límites, declarados explícitamente:

El fan-out es síncrono y va a doler a escala. El pool de fanoutNode debe esperar a que la tanda entera se complete antes de entrar a merge. Este es precisamente el cuello de botella que aquel sistema real en producción reconoció: la ejecución síncrona simplifica la coordinación, pero crea cuellos de botella en el flujo de información—un subagente que se demora una eternidad y el sistema entero atascado esperando3. Seis tickets, a lo sumo dos llamadas cada uno, y este cuello de botella no duele nada; seiscientos tickets, diez llamadas cada uno, y se vuelve «el más lento determina el tiempo de reloj de la tanda entera». Si conviene cambiar a asíncrono hay que calcular el costo: lo asíncrono deja a los agentes trabajar de forma concurrente y lanzar nuevos bajo demanda, pero agrega dificultad en la coordinación de resultados, la consistencia de estado y la propagación de errores entre los subagentes3—esos tres no existen en la versión síncrona, porque el orden lo determina el código.

Las dos reglas del bucle de revisión son superficiales y frágiles. includes("espera un poco") va a marcar mal frases como «no hace falta que esperes ni un poco, ya está resuelto». Este es el viejo problema del que advertía el curso 10: los validadores deterministas demasiado estrictos juzgan lo correcto como incorrecto. Para producción real, estas dos reglas necesitan calibrarse contra una tanda pequeña de respuestas reales, o degradarse a «marcar para que el juez lo revise de nuevo» en vez de mandarlo de vuelta a reescribir directamente.

Cambiar a la API real es cambiar solo el stub, la estructura no se mueve. Cambia makeStubClient(queue) por new Anthropic(), borra la tabla SCRIPTS entera, y el resto no cambia ni una línea—runAgent siempre estuvo escrito con la forma stop_reason / tool_use / tool_result de la API real, y model y max_tokens siempre se llevaron. Tras el cambio van a cambiar tres cosas: el resultado de la clasificación va a fluctuar (los mismos tickets, dos ejecuciones podrían caer en categorías distintas), las rondas de compuerta van a fluctuar, y el conteo de tokens va a fluctuar; una ejecución va a costar dinero y tiempo; y las tres válvulas del curso 7 que no se movieron hay que instalarlas de vuelta.

Cada capa de complejidad agregada debe pasar la compuerta de la «mejora medible». Cada patrón de este grafo se puede quitar por separado: sin enrutamiento, un prompt genérico también puede responder tickets; sin fan-out, ejecutarlos en serie también termina; sin bucle de revisión, la inspección manual por muestreo también es un método. Si al quitarlo bajan las métricas, y cuánto bajan, hay que probarlo para saberlo. Solo cuando la complejidad mejora genuinamente los resultados vale la pena agregarla2.

💻 Ejercicios

Resumen

  • Cuatro patrones soldados en un solo archivo (la votación completa el quinto en el ejercicio, y orquestador-trabajadores está ausente a propósito porque el despacho se puede predefinir); el enunciado del «plan en el código» tiene forma concreta: la docena de líneas de main() son todas flujo de control, y las tres variables corrientes routed / drafts / items son todo el estado. Los LLM y las herramientas se orquestan a través de caminos de código predefinidos2, el script mismo retiene el bucle, las bifurcaciones y los resultados intermedios, y el contexto del modelo retiene solo lo que necesita para este paso1
  • No todo nodo tiene que ser un modelo: de cinco nodos, dos llaman al modelo; merge, report y el primer filtro de la compuerta son todos código puro, y la categoría other pasa por una plantilla de cadenas. Donde el código determinista pueda dar la misma respuesta, no hay razón para pagar el dinero y la latencia de una llamada
  • El valor del enrutamiento no está en esa llamada, sino en esas diez líneas de código de ajuste posteriores a la llamada: el texto libre del modelo se comprime a una de tres etiquetas legales, y las ramas de aguas abajo solo reconocen valores que el código ya validó; los prompts especializados son el dividendo que compró la clasificación2
  • La concurrencia del fan-out debe tener techo, y la fusión debe pasar referencias y no cargas—la salida aterriza en disco y aguas abajo solo van referencias livianas3, y el nodo de revisión las lee de vuelta desde el archivo por su cuenta. El fan-out síncrono no duele a esta escala, pero a escala grande se vuelve cuello de botella3, y cambiar a asíncrono obliga a pagar tres costos: coordinación de resultados, consistencia de estado, propagación de errores entre subagentes3
  • El bucle de revisión es revisar-corregir-revisar, hasta que pase o deje de haber progreso1, más una red de seguridad de máximo de rondas2. La compuerta determinista va antes del juez; el criterio de «dos rondas seguidas con informe idéntico» corta pérdidas antes que el máximo de rondas, y la conclusión que da es más informativa: no «se intentó tres veces y falla», sino «no entiende esta retroalimentación»
  • La traza incremental trae recuperabilidad: persistir una vez tras completarse cada nodo es precisamente la premisa para que una ejecución se pueda continuar dentro de la misma sesión1 (la continuación entre procesos y entre máquinas es una promoción de una capa propia de esta lección tras persistir el estado a disco); emparejado con el intercambio atómico de escribir .tmp y después rename, si te matan en cualquier momento el disco tiene un estado completo que se puede leer de vuelta
  • Este grafo maneja un proceso, una tanda de tickets, un trabajo de pasos cerrados. Los problemas abiertos de pasos impredecibles deberían volver a bucles autónomos2; y cada capa de complejidad agregada debe pasar la compuerta de la «mejora medible»2

Las doce lecciones terminan aquí.

Mirando hacia atrás, lo que tienes ahora llegó pieza por pieza: en el curso 1 (Skills de Claude Code: construye tus propios flujos de trabajo de IA) escribiste tu primer prompt y aprendiste a enunciar requisitos con claridad; después vinieron las llamadas a herramientas, los flujos de trabajo, las Skills, la colaboración multiagente, hasta el curso 7—ese curso te hizo escribir un bucle con tus propias manos, while (response.stop_reason === "tool_use"), y desde ese día los agentes dejaron de ser una caja negra para ti y pasaron a ser un pedazo de código que puedes leer. El curso 8 (Ingeniería de contexto: gastar la atención finita donde cuenta) te enseñó a gestionar su contexto, a no dejar que el bucle girara hasta reventar la ventana. El curso 9 te enseñó a hacerlo sobrevivir a las interrupciones, a que si lo matan pueda continuar desde donde se quedó. El curso 10 te enseñó a verificar su salida, a separar «parece terminado» de «terminado». El curso 11 te enseñó a ver su proceso, a que cuando algo se rompa haya logs y trazas que consultar. Este curso te enseñó a componer varios bucles en un grafo que tiene el plan él mismo.

Estas seis cosas son seis caras de una sola: dentro de un código que escribiste tú, estás controlando algo no determinista. El bucle lo escribes tú, el contexto lo gestionas tú, los puntos de control los guardas tú, los criterios de aceptación los defines tú, los logs los imprimes tú, el plan lo dispones tú. El modelo es muy potente, pero trabaja dentro de este código de control que construiste.

El paso final aterriza en una acción concreta: cambia el makeStubClient(queue) de orchestrate.mjs por new Anthropic(), borra la tabla SCRIPTS, instala de vuelta las tres válvulas del curso 7 que no se movieron, y después vuelca en inbox/ esa tanda real de tareas apiladas de tu trabajo—tickets reales, logs reales, pendientes reales—y ejecútalo por primera vez. Lo más probable es que unos cuantos aterricen en needs_human, y así es exactamente como debería verse este grafo.

Footnotes

  1. Orchestrate subagents at scale with dynamic workflows — Claude Code official documentation — https://code.claude.com/docs/en/workflows 2 3 4 5 6 7 8 9 10

  2. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3 4 5 6 7 8 9 10 11 12 13 14 15

  3. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5 6 7 8 9

  4. Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents

Ejercicios

01

Abajo está la tabla resumen de una ejecución completa de este grafo, más los registros de dos tickets tomados de run-state.json (resultados de ejecución real; los milisegundos y el run_id cambian cada vez):

Nivel 1: Leer el diagrama—qué pasó de verdad en el bucle
text
=== Resumen de ejecución del grafo completo ===Nodo      Tiempo   Llamadas   Tokens   Rondas   Estadoroute     63ms     1          720      -        okfanout    246ms    8          8903     -        okmerge     2ms      0          0        -        okreview    129ms    2          3033     2        ok
=== Desglose por ticket ===Ticket   Categoría  Manejador         Rondas   Motivo de parada  EstadoT-1001   billing    worker:billing    0        gate_pass         passT-1002   bug        worker:bug        0        gate_pass         passT-1003   other      template          0        gate_pass         passT-1004   billing    worker:billing    1        no_progress       needs_humanT-1005   bug        worker:bug        1        gate_pass         passT-1006   other      template          0        gate_pass         pass

Sin escribir código, responde tres preguntas: (1) ¿Cuáles de los seis tickets entraron al bucle de revisión, cuántas rondas giró cada uno, y de qué campo lo leíste? (2) Los gate_rounds de T-1004 y T-1005 son ambos 1, ¿por qué uno queda en pass y el otro en needs_human? ¿La evidencia está en qué campo y cómo se lee? (3) Supón que el proceso se mata justo después de que termina el fan-out, antes de que arranque la revisión: ¿qué puede conservar run-state.json y qué se pierde? Al reiniciar, ¿desde qué paso se puede reanudar?

Criterios de finalización · marcado local
02

La categoría other tiene un ticket con el tono difícil de calibrar—T-1006: «Llevo tres meses usándolo, reporté problemas varias veces y nunca hubo respuesta. ¿Todavía hay alguien manteniendo este producto?». Responderle con una plantilla fija lo más probable es que quede inapropiado: demasiado frío parece desdén, demasiado cálido arriesga prometer de más.

Nivel 2: Agregar un nodo de votación al grafo

Agrega un nodo de votación a este grafo: el mismo ticket, la misma tarea, ejecutada una vez desde dos ángulos[^S1], y después usa código puro para comparar las dos versiones y meter la superior en la fusión. Las reglas de comparación son solo dos y ninguna puede preguntarle al modelo: primero usa las reglas deterministas de la compuerta para eliminar (si tiene palabras prohibidas o le falta el id del ticket, queda fuera directamente), y entre los sobrevivientes elige el más corto (las respuestas de atención no divagan).

Requisitos: los prompts de los dos ángulos deben tener todos los cuatro elementos; las dos llamadas deben pasar honestamente por runAgent (es decir, pasar por el bucle completo), y los stubs le dan a cada una una cola de respuestas; el proceso de selección debe dejar traza en la terminal y en run.jsonl, para que se sepa por qué se eligió esa versión. Después de escribirlo ejecútalo de verdad una vez y pega la salida. Responde además una pregunta: ¿por qué usar comparación en código puro aquí, y no llamar a un modelo para que juzgue qué versión es mejor?

Criterios de finalización · marcado local