MÓDULO 5 · LANGGRAPH · LECCIÓN 05 · 45 MIN

El ciclo ReAct: agente, ToolNode y la arista de vuelta

ReAct en LangGraph son dos nodos y dos aristas: agente → (condicional) → tools → agente. La arista de vuelta es la pieza que convierte un cálculo en una respuesta: sin ella el grafo termina con el resultado bruto y sin nadie que lo interprete.
01 — El cerebro
bind_tools: enseñarle las firmas
from langchain_core.tools import tool

@tool
def multiplicar(a: int, b: int) -> int:
    """Multiplica dos números enteros."""
    return a * b

tools = [multiplicar]
llm_con_herramientas = llm.bind_tools(tools)

bind_tools no ejecuta nada. Serializa el nombre, la firma tipada y el docstring de cada función y los manda al modelo en cada petición. El modelo no ve tu código: ve esa descripción.

// el docstring de una @tool no es documentación. Es prompt, y viaja en cada llamada.

El modelo responde con un AIMessage que puede llevar tool_calls: una lista de peticiones con nombre y argumentos. Sigue siendo texto generado — nada se ha ejecutado todavía.

02 — Las manos
ToolNode: el nodo que ya sabe ejecutar
from langgraph.prebuilt import ToolNode

nodo_herramientas = ToolNode(tools)

Lee el último mensaje del estado, busca cada tool_call, llama a la función que corresponde y devuelve un ToolMessage por llamada, con el tool_call_id correcto. Ese emparejamiento por id es lo que permite al modelo saber qué resultado responde a qué petición cuando ha pedido varias a la vez.

La lista que pasas a ToolNode y la que pasas a bind_tools tienen que ser la misma. Si divergen, el modelo pide una herramienta que el nodo no conoce y revienta en ejecución, no al compilar.

03 — La decisión
El router de tool_calls
def router_herramientas(state: MessagesState) -> str:
    ultimo = state["messages"][-1]
    if getattr(ultimo, "tool_calls", None):
        return "ir_a_herramientas"
    return "terminar"

LangGraph trae este router hecho: from langgraph.prebuilt import tools_condition. Devuelve "tools" o END, así que si nombras "tools" a tu nodo puedes pasarlo directamente sin mapa. Escribirlo a mano solo compensa cuando necesitas una tercera rama — por ejemplo, un tope de iteraciones.

04 — La pieza que se olvida
La arista de vuelta
builder.add_conditional_edges("agente", router_herramientas,
                              {"ir_a_herramientas": "tools", "terminar": END})
builder.add_edge("tools", "agente")   # <- sin esto, el grafo acaba tras calcular
START→ agente→ ¿tool_calls? sí →tools ↶ agente   |  no →END

Es incondicional a propósito: después de ejecutar herramientas siempre se vuelve al modelo, y es él quien decide si ya puede responder o si necesita otra ronda. Ahí está el ciclo, y ahí está el riesgo: un modelo que insista en llamar herramientas gira hasta el recursion_limit. El contador de iteraciones de la lección anterior aplica igual aquí.

Auditoría

El troubleshooting de la guía recomienda «modelos capaces de Tool Calling (GPT-3.5-Turbo o superior)». Comprueba dos cosas antes de darlo por bueno: si gpt-3.5-turbo sigue disponible en la API que estás usando, y si «superior» significa algo técnico o solo más caro.

La guía accede además a ultimo_mensaje.tool_calls sin comprobar el tipo del mensaje, mientras el enunciado del reto del módulo usa hasattr(...) para exactamente lo mismo. ¿Por qué el propio material se protege en un sitio y no en el otro? ¿Qué mensaje puede quedar el último del historial y no tener ese atributo?

05 — Ahora tú
Quita las dos piezas y mira qué se rompe

Bórrale el docstring a @tool def multiplicar y vuelve a preguntar algo que requiera multiplicar. Anota si el modelo llama a la herramienta, si la llama con argumentos equivocados, o si responde a mano.

Después comenta la línea builder.add_edge("tools", "agente") y ejecuta el mismo caso. Mira qué hay en resultado["messages"][-1] y responde: ¿el grafo ha fallado, o ha terminado correctamente en un sitio que no te sirve?