Nodos, edges y ciclos
construirás un StateGraph con nodos de responsabilidad única, aristas fijas y condicionales, y un ciclo que termina por una condición explícita —no por un contador—. Sabrás usar Command para enrutar y actualizar el estado en un mismo paso.
3.1El ciclo que no sabías parar
En L2 tipaste el estado de Elcano. En L1 viste que su bucle paraba por un contador mágico, no por una condición. Esta lección une las dos cosas: vas a montar el grafo, y el primer reto es el que mató al bucle —cuándo parar.
Repasa el síntoma. El agente-bucle de L1 termina así:
1 for _ in range(MAX_PASOS):
2 ...
3 return "(límite de pasos alcanzado)" # ← si llega aquí, ¿terminó o se rindió?Cuando Elcano entra en un ciclo —pide la misma consulta una y otra vez— el bucle no para por una regla de negocio. Para porque se acaban los pasos. Y el retorno final no te dice cuál de las dos cosas pasó: ¿resolvió la petición o se quedó sin gasolina?
En un grafo, el ciclo modelo → tools → modelo también existe. La diferencia es cómo se sale de él: cuando el modelo deja de pedir tools, una transición condicional enruta a END. La parada es una condición de la lógica, legible en el grafo. Esta lección construye ese grafo —nodos, aristas, la condicional y el ciclo— y al terminar la parada de Elcano será una regla, no un fusible.
3.2Qué vas a poder hacer
Al terminar serás capaz de:
- Construir un
StateGraph: declarar nodos conadd_node, aristas fijas conadd_edgey los puntosSTART/END. - Enrutar con
add_conditional_edges: una función que decide el destino según el estado. - Cerrar un ciclo que termina por condición explícita (devolver
END), no por contador. - Usar
Commandpara actualizar el estado y enrutar desde un nodo en un solo retorno.
Necesitas saber antes:
- De N0·L2: el estado
EstadoOpstipado y cómo cada nodo devuelve un dict parcial que se fusiona. - De N0·L1: el diagrama de la máquina de estados (modelo↔tools) y por qué la parada debe ser una condición.
Esta lección monta la estructura del grafo. El nodo de tools completo (ToolNode) y la inspección con stream llegan en L4; aquí escribimos las piezas a mano para verlas por dentro.
3.3Recupera
Antes de seguir, responde de memoria.
- ¿Qué dos nodos identificaste en L1 para Elcano, y qué hace cada uno?
- Un nodo recibe el estado y devuelve... ¿qué exactamente?
- ¿Qué hacía que el bucle de L1 parara, y por qué eso era frágil?
Comprueba tu respuesta
- Un nodo modelo (decide: responder o pedir tools) y un nodo tools (ejecuta las tools pedidas). Responsabilidad única cada uno (§1.6).
- Un dict parcial: solo los campos del estado que quiere actualizar. LangGraph lo fusiona con el estado usando los reducers de L2.
- Paraba por el contador
MAX_PASOS. Frágil porque el contador no es una regla de negocio: no distingue "terminó" de "se rindió" (§3.1).
3.4El concepto: el grafo como builder
Cuatro piezas, en orden: el builder y los nodos, las aristas fijas, la arista condicional que cierra el ciclo, y Command.
StateGraph: el builder
Un StateGraph es un builder: lo construyes paso a paso —añades nodos y aristas— y al final lo compile()as en un grafo ejecutable. Recibe el esquema de estado que tipaste en L2.
El import canónico reúne todo lo que necesitas:
1from langgraph.graph import StateGraph, START, END, MessagesState, add_messagesLa firma del constructor, verbatim de la referencia:
1StateGraph(
2 state_schema: type[StateT],
3 context_schema: type[ContextT] | None = None,
4 *,
5 input_schema: type[InputT] | None = None,
6 output_schema: type[OutputT] | None = None,
7) -> NonePara Elcano basta el primer argumento: StateGraph(EstadoOps). compile() devuelve un CompiledStateGraph —un Runnable con invoke, stream, ainvoke y astream—. Ese objeto compilado es el agente que ejecutas.
El ejemplo mínimo oficial muestra el ciclo de vida entero en seis líneas:
1from langgraph.graph import StateGraph, MessagesState, START, END
2
3def mock_llm(state: MessagesState):
4 return {"messages": [{"role": "ai", "content": "hello world"}]}
5
6graph = StateGraph(MessagesState)
7graph.add_node(mock_llm)
8graph.add_edge(START, "mock_llm")
9graph.add_edge("mock_llm", END)
10graph = graph.compile()
11graph.invoke({"messages": [{"role": "user", "content": "hi!"}]})Léelo de arriba abajo. Defines una función-nodo que recibe el estado y devuelve un parcial. La añades con add_node. La conectas con add_edge desde START y hacia END. Compilas. Invocas. Ese es el patrón que vas a ampliar.
Nodos y aristas fijas
Un nodo es una función que recibe el estado y devuelve el dict parcial de L2. add_node lo registra; si pasas la función, el nodo toma su nombre. La firma de add_node en 1.2.x tiene muchos parámetros opcionales —retry_policy, error_handler, timeout, entre otros—, pero son material de N2; en N0 usas la forma corta.
Una arista fija (add_edge) conecta dos nodos sin condición: "después de A, siempre B".
1add_edge(self, start_key: str | list[str], end_key: str) -> SelfUn matiz que importa: si start_key es una lista, es un fan-in —la arista espera a que terminen todos los nodos de la lista antes de pasar a end_key—. No lo usamos en N0, pero conviene saber que existe.
START y END son nodos virtuales. START marca por dónde entra el estado inicial; END marca dónde termina el grafo. No los defines tú: los importas.
La arista condicional que cierra el ciclo
Aquí está la pieza que reemplaza al if not salida.tool_calls del bucle. add_conditional_edges enruta según una función que tú escribes:
1add_conditional_edges(source, path, path_map=None)path es una función que recibe el estado y devuelve el nombre del nodo destino —o una clave que path_map traduce a un nodo—. Y aquí está la regla que cierra el ciclo: devolver END corta el ciclo.
Para Elcano, la función de ruteo es la versión explícita de la decisión que el bucle escondía:
1def hay_tools(state: EstadoOps):
2 ultimo = state["messages"][-1]
3 if ultimo.tool_calls: # el modelo pidió tools → al nodo de tools
4 return "tools"
5 return END # el modelo respondió → fin del cicloEsto es lo que convierte la parada en una condición legible. El ciclo modelo → tools → modelo se sostiene mientras el modelo pida tools; en cuanto responde sin pedir ninguna, hay_tools devuelve END y el grafo termina. Sin contador. Sin "límite de pasos alcanzado".
Command: enrutar y actualizar a la vez
A veces un nodo quiere dos cosas: actualizar el estado y decidir a dónde va el flujo. Podrías hacerlo con un parcial más una arista condicional. Command lo hace en un solo retorno.
Command (de langgraph.types) combina la actualización del estado y el enrutamiento desde un nodo, sin aristas separadas. Sus campos: graph, update, resume, goto. La forma verbatim:
1from langgraph.types import Command
2from typing import Literal
3
4def node_a(state: State) -> Command[Literal["node_b", "node_c"]]:
5 return Command(update={"foo": "bar"}, goto="node_b")updatees el dict parcial que se fusiona con el estado (con los reducers de L2).gotoes el nodo destino.- El tipo de retorno
Command[Literal["node_b", "node_c"]]declara los destinos posibles —ayuda a la herramienta y al lector a ver el grafo sin ejecutarlo—.
Commandtiene un campo más,graph, para saltar a un grafo padre desde un subgrafo. No lo necesitas en N0 (no hay subgrafos todavía); lo verás en el multi-agente de N3.
Para Elcano, un nodo de triaje que clasifica la petición entrante encaja con Command: lee la petición, anota la categoría en el estado y enruta en el mismo paso.
1def triaje(state: EstadoOps) -> Command[Literal["investigar", "responder"]]:
2 if _necesita_investigar(state):
3 return Command(update={"plan": []}, goto="investigar")
4 return Command(goto="responder")Cuándo usar cada uno: una arista condicional (add_conditional_edges) separa la decisión de ruteo en una función aparte; Command la mete dentro del nodo junto con la actualización. Las dos son válidas. Command brilla cuando enrutar y actualizar son la misma decisión.
3.5Míralo: el grafo de Elcano, montado a mano
Vamos a montar el grafo del ciclo modelo↔tools de Elcano, escribiendo el nodo de tools a mano para verlo por dentro. En L4 lo cambias por el ToolNode prebuilt; aquí el objetivo es entender la estructura, no ahorrar líneas.
Lee el bloque entero primero. La dificultad no está en ninguna línea: está en seguir el cableado —quién conecta con quién— y ver que el ciclo se cierra solo.
1# grafo.py — el grafo de Elcano (estructura a mano, baseline N0)
2from langgraph.graph import StateGraph, START, END
3from estado import EstadoOps # el estado tipado de L2
4
5# --- nodos: cada uno recibe el estado y devuelve un parcial ---
6def llamar_modelo(state: EstadoOps):
7 # 'modelo_con_tools' es tu modelo con las tools enlazadas: el mismo
8 # tool-calling de tu agente previo (prerequisito), no una API nueva.
9 salida = modelo_con_tools.invoke(state["messages"])
10 return {"messages": [salida]} # add_messages hará append (L2)
11
12def ejecutar_tools(state: EstadoOps):
13 ultimo = state["messages"][-1]
14 nuevos = []
15 for tc in ultimo.tool_calls:
16 resultado = TOOLS[tc["name"]](**tc["args"])
17 nuevos.append(_a_tool_msg(resultado, tc["id"]))
18 return {"messages": nuevos} # append de los resultados de tools
19
20# --- ruteo condicional: la parada como CONDICIÓN, no contador ---
21def hay_tools(state: EstadoOps):
22 if state["messages"][-1].tool_calls:
23 return "tools"
24 return END
25
26# --- construir el grafo ---
27grafo = StateGraph(EstadoOps)
28grafo.add_node("modelo", llamar_modelo)
29grafo.add_node("tools", ejecutar_tools)
30
31grafo.add_edge(START, "modelo") # entra siempre por el modelo
32grafo.add_conditional_edges("modelo", hay_tools) # ¿tools? → "tools" ; ¿no? → END
33grafo.add_edge("tools", "modelo") # el ciclo: tras ejecutar, vuelve a decidir
34
35elcano = grafo.compile()El cableado, en tres aristas:
START → modelo: el estado inicial entra por el nodo que decide.modelo → (hay_tools) → "tools" | END: la arista condicional. El corazón del control de flujo.tools → modelo: cierra el ciclo. Tras ejecutar tools, vuelve a decidir.
El ciclo modelo → tools → modelo gira mientras el modelo pida tools. Cuando responde sin pedir ninguna, hay_tools devuelve END. Compáralo con el for _ in range(MAX_PASOS): aquí no hay contador. La condición de parada es el grafo.
Self-explanation —respóndela antes de seguir: ¿qué línea de este grafo reemplaza al if not salida.tool_calls: return del bucle de L1, y qué gana al estar ahí?
Razónalo y comprueba
La reemplaza grafo.add_conditional_edges("modelo", hay_tools) junto con la función hay_tools. En el bucle, esa decisión estaba enterrada en un if dentro del cuerpo; aquí es una arista nombrada del grafo. Lo que gana: puedes ver la condición de parada leyendo el grafo, sin ejecutarlo (rúbrica C0, dim 3). La transición tiene nombre y posición; ya no está disuelta en el flujo de control. Y devolver END deja la parada como regla, no como contador.
3.6Hazlo tú
Ejercicio 1 — andamiaje parcial
Quieres que Elcano empiece por un nodo de triaje antes del modelo. El triaje clasifica la petición; si se puede responder sin investigar, va directo a un nodo responder y se salta la investigación. Te doy el grafo a medias. Completa las dos líneas marcadas:
1grafo = StateGraph(EstadoOps)
2grafo.add_node("triaje", triaje) # usa Command(goto=...) para enrutar
3grafo.add_node("modelo", llamar_modelo)
4grafo.add_node("tools", ejecutar_tools)
5grafo.add_node("responder", responder)
6
7grafo.add_edge(START, "triaje") # entra por triaje
8# (1) cuando triaje decide investigar, el flujo va a "modelo".
9# Si 'triaje' enruta con Command(goto="modelo" | "responder"), ¿necesitas
10# escribir una arista de salida de "triaje"? ________
11grafo.add_conditional_edges("modelo", hay_tools)
12grafo.add_edge("tools", "modelo")
13# (2) tras responder, el grafo termina. Escribe la arista: ________Comprueba
1# (1) NO necesitas arista de salida de "triaje":
2# Command(goto=...) ya enruta desde dentro del nodo. La arista la lleva el Command.
3# (2)
4grafo.add_edge("responder", END)- (1) Cuando un nodo enruta con
Command(goto=...), el enrutamiento vive en el retorno del nodo. No añadesadd_edgeniadd_conditional_edgespara su salida: elCommandya dice a dónde va. Esa es la diferencia con un nodo que devuelve solo un parcial. - (2)
responderno cicla: tras él, el grafo termina. Una arista fijaresponder → ENDlo cierra.
Si añadiste una arista de salida de triaje, revisa §3.4, "Command": el goto es la arista.
Ejercicio 2 — autónomo
Sin andamiaje. Dibuja (en texto o papel) el grafo del ejercicio 1 completo: nodos, aristas fijas, condicionales y los goto de triaje. Marca dónde está el ciclo y dónde termina por condición.
Elaborative interrogation —antes de leer: ¿por qué add_conditional_edges("modelo", hay_tools) puede devolver END directamente, mientras que el nodo responder necesita una add_edge("responder", END) explícita?
Ver razonamiento
Porque son dos mecanismos distintos de enrutar al final. hay_tools es una función de ruteo: devuelve el destino dinámicamente, y END es un destino válido que devuelve según el estado. El nodo responder no tiene función de ruteo asociada —solo devuelve un parcial—, así que su salida la fija una arista estática. La regla general: un nodo necesita alguna salida (arista fija, arista condicional o un Command con goto); si no la tiene, el grafo no sabe a dónde seguir tras él.
3.7Comprueba
Sin pistas. Aquí tienes un grafo con un fallo de estructura. Encuéntralo, explica el síntoma y arréglalo.
1grafo = StateGraph(EstadoOps)
2grafo.add_node("modelo", llamar_modelo)
3grafo.add_node("tools", ejecutar_tools)
4
5grafo.add_edge(START, "modelo")
6grafo.add_edge("modelo", "tools") # ← mira esta línea
7grafo.add_edge("tools", "modelo")
8elcano = grafo.compile()- Identifica el fallo.
- Describe qué le pasa a Elcano al ejecutarlo.
- Arréglalo.
Ver respuesta razonada
El fallo: la salida del modelo es una arista fija modelo → tools, no condicional. No hay ninguna transición a END.
El síntoma: el ciclo modelo → tools → modelo → tools… no tiene salida. Aunque el modelo deje de pedir tools, la arista fija lo manda igual a tools. Es el bucle infinito de L1, pero ahora sin contador que lo corte. Elcano no termina nunca.
El arreglo: la salida del modelo debe ser condicional, para poder devolver END:
1grafo.add_edge(START, "modelo")
2grafo.add_conditional_edges("modelo", hay_tools) # ¿tools? → "tools" ; ¿no? → END
3grafo.add_edge("tools", "modelo")Feedback formativo:
- Si encontraste que la salida del modelo debe ser condicional: dominas la pieza central de la dim 2 de C0 —el ciclo que termina por condición—. Es el fallo que separa un grafo que para de uno que no.
- Si dijiste "falta
ENDen el grafo" pero pusisteadd_edge("modelo", END): casi. Una arista fija aENDharía que el modelo siempre termine tras el primer turno, sin ejecutar tools nunca. La salida tiene que ser condicional: a vecestools, a vecesEND. Releer §3.4. - Si no viste el fallo: ejecuta el grafo mentalmente paso a paso.
START → modelo → tools → modelo → tools…. ¿Dónde sale? En ningún sitio. Esa es la pista.
3.8Conecta
Cierra el arco del §3.1. El bucle de L1 paraba por un contador y no sabías si había terminado o se había rendido. Ahora la parada de Elcano es una arista condicional que devuelve END cuando el modelo deja de pedir tools. La condición de parada es legible en el grafo —dim 2 y dim 3 de la rúbrica C0—.
Tienes la estructura: nodos de responsabilidad única, aristas fijas y condicionales, el ciclo que cierra solo, y Command para enrutar y actualizar a la vez. Lo que falta es endurecer y observar:
- En L4 cambias tu
ejecutar_toolsy tuhay_toolsa mano por los prebuiltToolNodeytools_condition, reescribes el agente-bucle de L1 entero como grafo, y lo haces inspeccionable constream. Ese es el entregable de C0.
Construiste el grafo viéndolo por dentro. Ahora vas a apoyarte en las piezas que LangGraph ya trae —y a comprobar que el comportamiento es el mismo que el bucle, pero auditable.
→ Reescribe el agente-bucle como grafo
3.9Reflexiona
Tómate dos minutos.
- Con tus palabras: ¿qué diferencia una arista fija de una condicional, y cuándo necesitas cada una?
- ¿Cuándo usarías
Commanden vez de un parcial más una arista condicional? - ¿Qué sigue sin estar claro? Si es "¿cómo veo el estado en cada paso del ciclo?", lo responde
streamen L4.
Referencia rápida
StateGraph(EstadoOps): el builder.compile()→CompiledStateGraph(Runnable:invoke/stream/ainvoke/astream).- Import:
from langgraph.graph import StateGraph, START, END, MessagesState, add_messages. add_node("nombre", fn): registra un nodo. La función recibe el estado, devuelve un dict parcial.add_edge(a, b): arista fija "tras A, siempre B".start_keycomo lista = fan-in (espera a todos).add_conditional_edges(source, path, path_map=None):path(state)devuelve el nombre del nodo destino; devolverENDcorta el ciclo.START/END: nodos virtuales de entrada y fin. Se importan.Command(update=, goto=)(delanggraph.types): actualiza el estado y enruta en un mismo retorno. (Tiene un campographpara saltar a un grafo padre desde un subgrafo; se ve en N3.)- Ciclo que termina por condición:
modelo → (condicional) → tools → modelo, y la condicional devuelveENDcuando el modelo deja de pedir tools. Sin contador. - No usar nunca: una arista fija
modelo → tools(ciclo sin salida); una arista fijamodelo → END(termina antes de ejecutar tools).