Loops, harness y grafos
Matheus Cardoso
Ya usaste un agente: Claude Code, Cursor, Copilot en modo agent. Lee tu pedido, hace una cosa, mira el resultado y decide la siguiente. A veces parece magia. A veces intenta la misma corrección equivocada cuatro veces seguidas. Los dos comportamientos salen de la misma estructura, y esa estructura se entiende completa sin una línea de matemáticas.
El loop: una cosa por vez
Todo agente es un while. Arma un contexto (tu pedido, lo que ya pasó, la lista de herramientas disponibles), lo manda al modelo, recibe de vuelta una acción, ejecuta esa acción, guarda el resultado en el historial y vuelve al principio. Cuando el modelo responde “terminé” en lugar de pedir otra acción, el loop se detiene.
const history = [userRequest]
while (true) {
const answer = await model.generate({ history, tools })
if (answer.done) return answer.text
const result = await runTool(answer.tool, answer.input)
history.push(answer, result)
}Eso es todo. Todo agente de código que hayas usado es una variación de veinte líneas de esto, y es justamente por ser tan poco que funciona tan bien en tantas cosas.
Ahora fijate en dos propiedades de ese código, porque todo lo demás en este texto sale de ellas. La primera: en cada vuelta existe exactamente una cosa por hacer. No dos. El modelo devuelve una acción, la ejecutás, volvés al principio. La segunda: quien elige esa acción es el modelo. No hay ninguna función en tu código que diga “ahora va el paso 3”. La decisión viene de dentro de una inferencia que no podés inspeccionar ni reproducir igual.
De esas dos propiedades salen los tres problemas que ya viste en la práctica, incluso sin tener nombres para ellos:
- El orden solo existe en la conversación
- Pediste “editá el archivo, después corré los tests”. Nada en el sistema impide correr los tests primero. La dependencia es una frase en el contexto, no una regla en el ejecutor. Con contexto corto acierta; con contexto largo, se olvida.
- Nadie definió cuántos intentos
- El test falló. ¿Reintenta? ¿Prueba otro enfoque? ¿Replanifica? El modelo decide en el momento, y no hay un máximo escrito en ningún lado. De ahí viene el agente que insiste en la misma corrección equivocada hasta que matás el proceso.
- El plan se sobrescribe
- Planificó A, a mitad de camino cambió a B, y ahora el plan A es un mensaje viejo enterrado en el historial. Semanas después, cuando querés saber qué plan generó ese commit raro, no hay forma de responder.
Harness: todo lo que no es el modelo
Harness, en inglés, es el cinturón de seguridad de quien hace algo riesgoso. El nombre cae bien. El modelo es el motor. El harness es el resto del auto: el loop, qué herramientas le entregás, qué entra en el contexto, cuántos intentos tiene, cuándo desistir.
La distinción importa por un motivo práctico. No controlás el modelo: es un servicio de terceros que llamás por HTTP. El harness es 100% tu código. Cuando un agente tuyo se porta mal, la probabilidad de que el problema esté en el harness es mucho mayor que la de que esté en el modelo. Y cuatro líneas de harness resuelven la mayor parte de la sección anterior:
const MAX_STEPS = 40
const MAX_ATTEMPTS = 3
const HISTORY_TURNS = 30
const history = [userRequest]
for (let step = 0; step < MAX_STEPS; step++) {
const answer = await model.generate({
history: history.slice(-HISTORY_TURNS),
tools,
maxTokens: 4096,
})
if (answer.done) return answer.text
const result = await withAttempts(
() => runTool(answer.tool, answer.input),
MAX_ATTEMPTS,
)
history.push(answer, result)
}
throw new StepLimitReached(MAX_STEPS)Nada de eso es sofisticado, y ese es el punto. MAX_STEPS convierte “loop infinito” en “loop que termina”. Un techo de intentos por paso convierte “insiste para siempre” en “insiste tres veces”. El slice(-HISTORY_TURNS) convierte “la conversación crece hasta reventar la ventana de contexto” en “la conversación tiene tamaño máximo”. Y maxTokens convierte “la factura es una sorpresa a fin de mes” en “la factura tiene techo”.
Si te llevás una sola cosa de este texto, llevate esta: a casi todo agente amateur le faltan esas cuatro líneas, y casi todo agente de producción las tiene. Antes de cambiar de arquitectura, ajustá el harness.
El grafo: escribir el plan antes de empezar
El loop decide el próximo paso en el camino. La alternativa es decidir todos los pasos antes de empezar, escribir esa decisión en un formato que el código pueda leer, y después solo ejecutar lo que está escrito.
Ese formato es un grafo. Si la palabra intimida, cambiala por una que ya usás toda la semana: es un pipeline de CI. En GitHub Actions escribís jobs y le ponés needs: build a uno. Listo, es un grafo: cajas, y flechas que dicen “esta solo empieza después de aquella”. Un Makefile es la misma idea. Las dependencias de tu package.json también.
El nombre técnico es DAG, y vale traducir las tres partes porque cada una trae una garantía. Grafo: cajas unidas por flechas. Dirigido: las flechas tienen punta, así que A → B no es B → A. Acíclico: ninguna flecha vuelve hacia atrás. Esa última no es trivia de vocabulario: es la garantía estructural de que la ejecución termina. Sin ciclo, no hay camino que pueda repetirse para siempre.
En la práctica, el plan es un array:
const plan = [
{ id: "search_auth", needs: [] },
{ id: "search_utils", needs: [] },
{ id: "read_auth", needs: ["search_auth"] },
{ id: "read_utils", needs: ["search_utils"] },
{ id: "analyze", needs: ["read_auth", "read_utils"] },
{ id: "fix_a", needs: ["analyze"] },
{ id: "fix_b", needs: ["analyze"] },
{ id: "update_docs", needs: ["analyze"] },
{ id: "run_tests", needs: ["fix_a", "fix_b"], waitFor: "any" },
{ id: "report", needs: ["run_tests", "update_docs"] },
]Fijate en lo que cambió. needs no es una frase pidiendo buen comportamiento: es dato. El ejecutor lee needs y simplemente no despacha analyze antes de que read_auth y read_utils hayan terminado. No hay nada que “olvidar”: la dependencia dejó de ser memoria del modelo y pasó a ser una condición en un if.
Y aparece una ventaja que el loop no puede tener de ninguna manera. En cada ronda el ejecutor toma todos los pasos cuyas dependencias ya terminaron, no solo uno. Si dos pasos no tienen flecha entre ellos, corren juntos:
function readySteps(plan, settled) {
return plan.filter((step) => {
if (settled.has(step.id)) return false
return step.waitFor === "any"
? step.needs.some((id) => settled.has(id))
: step.needs.every((id) => settled.has(id))
})
}
while (settled.size < plan.length) {
const batch = readySteps(plan, settled)
if (batch.length === 0) throw new NothingReady()
await Promise.all(batch.map(run))
}Son cuatro líneas de filter y un Promise.all. La misma tarea que el loop hace en once vueltas en serie sale en seis rondas:
- search_authsearch_utils
- read_authread_utils
- analyze
- fix_afix_bupdate_docs
- run_tests
- report
Esperar a todos, o esperar a uno
Cuando un paso depende de otros dos, “depende” puede significar dos cosas bastante distintas. Confundirlas es un bug caro, y el loop no tiene forma de expresar la segunda.
Esperar a todos. report necesita los tests y la documentación. Solo empieza cuando los dos terminaron. Es el caso común, y es el default.
Esperar a uno. fix_a y fix_b son dos correcciones alternativas para el mismo bug. run_tests necesita una de ellas. Si fix_b funciona, fix_a dejó de importar, y lo correcto es marcarlo como descartado, no seguir reintentándolo hasta gastar el presupuesto de reintentos en un camino que nadie va a usar.
En el loop, esa segunda situación no se puede escribir. El modelo intenta A, falla, decide intentar B, y desistir de A es una frase en el historial. En el grafo es un campo: waitFor: "any". Es la diferencia entre acordar algo y esperar que alguien se acuerde.
Cuando falla: una escalera de tres peldaños
El síntoma más molesto de un agente es el que gira en falso: falló, replanificó, falló, replanificó, y cuarenta mil tokens después está exactamente donde empezó. Pasa porque “qué hacer cuando falla” se delegó al modelo, que tiene un sesgo fuerte a intentar otra cosa en lugar de intentar de nuevo.
La corrección es sacarle esa decisión y convertirla en tres peldaños fijos:
- Intentá de nuevoMismo paso, misma configuración. Sirve para lo transitorio: se cayó la red, rate limit, timeout. Barato.
- Ajustá el pasoMismo paso, configuración distinta: otro prompt, otro modelo, otra herramienta. La estructura del plan queda intacta.
- Rehacé el planGenera un plan nuevo desde cero. Caro, lento, y es el único peldaño que puede arreglar un plan que estaba mal desde el principio.
Y la regla que hace funcionar la escalera: no se puede saltar un peldaño. El peldaño 3 solo después de agotar 1 y 2. Son media docena de líneas: un contador por paso, que solo sube de uno en uno:
const LADDER = ["retry", "patch", "replan"]
function nextAction(stepId) {
const rung = attempts.get(stepId) ?? 0
if (rung >= LADDER.length) throw new GaveUp(stepId)
attempts.set(stepId, rung + 1)
return LADDER[rung]
}No es elegante y no necesita serlo. El punto es que ahora hay un lugar en el código donde está escrito cuántas veces puede intentar el agente antes de escalar, y ese lugar no es un prompt.
El plan no cambia en el medio
Un plan que se puede editar durante la ejecución parece flexibilidad y es, en la práctica, un problema de depuración. Si el agente cambió el plan a mitad de camino y algo salió mal después, no sabés si la culpa es del plan original, del cambio, o de la combinación de los dos, porque ninguno de los dos existe más de una pieza.
La convención que resuelve esto ya la usás todos los días: un commit. El plan tiene una versión. Durante la ejecución, nadie la edita. Si tiene que cambiar, generás la versión 2 y registrás que la 1 fue abandonada y por qué. Cada línea del log de ejecución dice qué versión gobernaba en ese momento.
El costo es real: perdés la capacidad de ajustar el plan con lo que acabás de descubrir sin pagar el precio de una replanificación entera. La ganancia es poder responder “¿qué plan produjo esto?” semanas después. En tarea exploratoria el costo es mayor que la ganancia. En algo que toca producción, es al revés.
Dónde el grafo es peor
El grafo no es el upgrade del loop. Es otra elección, con otras cuentas. Cuatro situaciones donde pierde, y vale conocer las cuatro antes de reescribir nada:
El plan vale lo que vale quien lo escribe
El paralelismo solo existe si alguien dibujó las flechas correctas. Si el planificador escribe una línea recta (1 → 2 → 3 → 4), el grafo ejecuta una cosa por vez, igual que el loop, con mucho más código en el camino. Y quien normalmente escribe el plan es un LLM, que se equivoca. Toda la ganancia de velocidad estaba en la estructura, y la estructura no está garantizada.
El error en paralelo cuesta más
En el loop, si el modelo se equivoca en la vuelta 4, muchas veces lo nota en la vuelta 5 y corrige: desperdició un paso. En el grafo, si al plan le falta una flecha, tres ramas corren al mismo tiempo sobre la premisa equivocada. Paralelizaste el desperdicio.
La tarea exploratoria no entra en un diagrama
“Investigá el outage y arreglá lo que esté roto.” No podés listar los pasos antes, porque el paso 3 depende de lo que encuentre el paso 2. Un grafo estático no expresa eso. Un loop lo expresa naturalmente: es literalmente lo que hace.
El código es un orden de magnitud más grande
Un loop honesto con harness ajustado entra en unos cientos de líneas. Un ejecutor de grafo de verdad tiene que validar el plan (¿hay ciclos? ¿hay pasos que nunca corren?), agendar en paralelo respetando rate limits, persistir estado para auditoría, implementar la escalera de recuperación y validar la salida de cada paso. Son unos miles de líneas, y cada una es tuya para mantener.
Cómo elegir
Orden práctico, de lo más barato a lo más caro:
- Loop con harness ajustadoLa respuesta correcta la mayoría de las veces. Techo de pasos, techo de intentos por paso, ventana en el historial, techo de tokens. Una tarde de trabajo, y resuelve la mayor parte de los síntomas que hacen que la gente quiera cambiar de arquitectura.
- Loop con plan en el promptEl modelo escribe los pasos antes y vos los mantenés visibles en el contexto. Ayuda a que no se pierda en tareas largas, pero sigue siendo una cosa por vez: un plan en un prompt es una sugerencia, no una regla, y no paraleliza nada.
- Un grafo de verdadCuando las tres cosas sean verdad al mismo tiempo: conocés las dependencias antes de empezar, hay paralelismo real para ganar, y alguien va a necesitar auditar lo que pasó después. Si solo dos son verdad, probablemente no vale el costo.
La opción del medio es la trampa más común. “Mi agente planifica antes de actuar” suena a grafo y no lo es: si la ejecución sigue pidiéndole una acción por vez al modelo, mejoraste la calidad de las decisiones y no cambiaste nada estructural. El paralelismo y el orden garantizado solo aparecen cuando el plan sale del prompt y se vuelve dato que el ejecutor lee.
El resumen
El loop es una cosa por vez, elegida por el modelo. El grafo es varias cosas por vez, elegidas por la estructura. El harness es tu código alrededor de los dos, y es donde vive la mayor parte de la calidad de un agente, cualquiera de los dos que elijas.
Si tu agente está gastando de más o girando en falso, es un problema de harness y lo arreglás hoy. Si está lento porque hace en serie cosas que no dependen una de la otra, ahí sí vale mirar grafos.
Como siempre, escribime si tenés cualquier duda en X.