MÓDULO 5 · LANGGRAPH · LECCIÓN 04 · 40 MIN

Ciclos con freno: reintentos y recursion_limit

Un ciclo en LangGraph es una arista condicional que apunta hacia atrás. Lo que lo hace seguro no es el grafo: es el contador que vive en el estado. El recursion_limit es el airbag, no el cinturón.
01 — El ciclo mínimo
Una entrada del mapa que vuelve atrás
def router_validacion(state) -> str:
    if state["es_valido"]:
        return "exito"
    elif state["intentos"] >= 3:
        return "max_reintentos"
    return "reintentar"

builder.add_conditional_edges(
    "validar", router_validacion,
    {
        "exito": "fin_exito",
        "max_reintentos": "fin_error",
        "reintentar": "validar",      # <- el ciclo
    },
)
START→ validar→ router →fin_exito  / fin_error  / ↶ validar

No hay ninguna API especial de bucles. "reintentar": "validar" es una entrada del mapa como cualquier otra; que apunte al nodo del que sales es lo único que la convierte en un ciclo.

02 — El freno de verdad
El contador vive en el estado
def nodo_validar(state):
    intento_actual = state.get("intentos", 0) + 1
    ...
    return {"es_valido": ok, "intentos": intento_actual}

Dos reglas que no se negocian. Nada de variables globales: el estado es lo único que LangGraph serializa, restaura y enseña al depurar; un contador global no aparece en ningún checkpoint y miente en cuanto ejecutas dos hilos. Y intentos no lleva reducer: es un valor, no una traza.

// el router lee intentos después de que el nodo lo devuelva. Si el nodo olvida incluirlo en el return, el router ve siempre el mismo número y el ciclo no para nunca.
03 — Dos frenos, no uno
Condición de salida frente a límite de recursión
FrenoQuién lo poneQué hace
Condición de salidaTú, en el routerTermina en un nodo de tu grafo, con un mensaje que controlas
recursion_limitLangGraph, por defecto 25Lanza GraphRecursionError y aborta

El límite cuenta super-pasos del grafo, no visitas a un nodo concreto: un ciclo de tres nodos consume el presupuesto tres veces más rápido que uno de un solo nodo. Por eso 25 no se traduce a «25 reintentos».

ciclo de 1 nodo~25 vueltas
ciclo de 3 nodos~8 vueltas

Proporciones ilustrativas con el mismo presupuesto de 25 super-pasos. Cuenta las tuyas en el ejercicio del final en vez de fiarte del dibujo.

04 — La salida por abajo
Nodo de fallo, no excepción
def nodo_fallo_critico(state):
    return {"mensaje_final": "Se excedió el número máximo de reintentos."}

La rama max_reintentos no lanza: va a un nodo que escribe un mensaje en el estado y termina en END. Así el estado final tiene la misma forma tanto si el proceso salió bien como si no, y quien llama al grafo lee mensaje_final en lugar de envolver el invoke en un try. Llegar a GraphRecursionError en producción es un fallo de diseño, no una protección que funcionó.

Auditoría

El troubleshooting de la guía dice: «si necesitas ciclos muy largos, aumenta recursion_limit en compile()». Antes de repetirlo, abre la firma de compile() en tu versión instalada (help(builder.compile)) y busca ese parámetro.

¿Está ahí, o es un campo de la configuración que se pasa en invoke? Comprueba de paso el valor por defecto en tu versión, en vez de fiarte del «ej. 25» con el que la guía se cubre.

05 — Ahora tú
Baja el freno hasta que salte
app.invoke({"input_usuario": "Dato123", "intentos": 0, "es_valido": False,
            "mensaje_final": None},
           config={"recursion_limit": 2})

Copia el nombre exacto de la excepción. Luego sube el límite a 25, quita el elif state["intentos"] >= 3 del router y cuenta cuántas veces se ejecuta nodo_validar antes del error. ¿Coincide con 25? Si no, ya sabes qué está contando el límite.