El loop modelo↔tools como grafo
reescribirás el agente-bucle de Elcano como un StateGraph usando los prebuilt ToolNode y tools_condition, y lo harás inspeccionable con stream. Sabrás que existe el atajo create_agent —y por qué create_react_agent quedó deprecado.
4.1El lab: convertir la avería en un artefacto
Esta lección es un laboratorio. Coges el agente-bucle de Elcano —el del reembolso doble de L1— y lo reescribes como grafo. No para que funcione distinto: para que falle a la vista.
Recupera el dolor del §1.1. Cuando el reembolso se duplicó, abriste el código y no pudiste ver el estado a mitad de run. El bucle era opaco. El entregable de este lab es lo contrario: el mismo comportamiento, pero como grafo tipado donde, en cualquier paso, lees qué hizo el agente y por qué.
Vas a hacer tres cosas. Reescribir el ciclo modelo↔tools con ToolNode y tools_condition —las piezas prebuilt que sustituyen tu cableado a mano de L3—. Inspeccionar el estado en cada superstep con stream. Y conocer create_agent, el atajo que monta este grafo por ti, para saber cuándo escribirlo a mano y cuándo no. Al terminar tienes el grafo de C0.
4.2Qué vas a poder hacer
Al terminar serás capaz de:
- Reescribir el agente-bucle de L1 como
StateGraph, con paridad de comportamiento. - Usar
ToolNodeytools_conditionen lugar del nodo de tools y la condición escritos a mano. - Inspeccionar el estado en cada superstep con
stream(stream_mode="updates"y"values"). - Situar
create_agentcomo atajo prebuilt, ycreate_react_agentcomo uso transitorio deprecado.
Necesitas saber antes:
- De N0·L3: el grafo a mano —
add_node,add_edge,add_conditional_edges, el ciclo que cierra conEND—. - De N0·L2: el estado
EstadoOpsy el reduceradd_messages. - El agente-bucle de L1 (
ops_agent.py), que es lo que vas a reescribir.
Un aviso de alcance: aquí defines tools con @tool solo lo justo para que ToolNode funcione. Los contratos de tool en serio —validación, errores, retries idempotentes— son N2. No los re-enseñamos aquí.
4.3Recupera
Antes de seguir, responde de memoria.
- En tu grafo a mano de L3, ¿qué función escribiste para decidir entre seguir al nodo de tools o terminar?
- ¿Qué devolvía tu nodo
ejecutar_toolsy cómo se fusionaba con el estado? - ¿Qué reemplazó al contador
MAX_PASOScomo condición de parada?
Comprueba tu respuesta
hay_tools(state): miraba el último mensaje; si teníatool_callsdevolvía"tools", si no devolvíaEND(§3.5).- Devolvía
{"messages": [...]}con losToolMessagede cada resultado;add_messageslos añadía al historial (§3.5). - La arista condicional que devuelve
ENDcuando el modelo deja de pedir tools. La parada es una condición, no un fusible (§3.8).
Vas a sustituir las dos primeras piezas por prebuilt. La tercera —el porqué de la parada— no cambia.
4.4El concepto: las piezas prebuilt del loop
Tres piezas: ToolNode (ejecuta tools), tools_condition (decide seguir o parar) y stream (mira el estado). Y un atajo que las empaqueta: create_agent.
ToolNode: el nodo de tools, hecho
En L3 escribiste ejecutar_tools a mano: iterar las tool_calls, ejecutar cada una, envolver el resultado en un ToolMessage. LangGraph trae ese nodo hecho.
ToolNode (de langgraph.prebuilt) ejecuta las tool calls del último mensaje y devuelve los resultados como mensajes. Le pasas la lista de tools y él se encarga del bucle interno que tú escribías:
1from langgraph.prebuilt import ToolNode, tools_conditionToolNode acepta un parámetro que vas a usar de verdad en N2, handle_tool_errors, que controla qué pasa cuando una tool lanza. En N0 lo dejas por defecto. Solo recuerda que ese comportamiento por defecto cambió tras la 1.0.1: cuando llegues a N2, confírmalo contra la versión instalada antes de apoyarte en él.
tools_condition: la decisión de parar, hecha
tools_condition (mismo import) es tu hay_tools prebuilt. Enruta: devuelve "tools" si el último mensaje tiene tool calls, y "__end__" si no. Ese "__end__" es el valor de END —por eso corta el ciclo igual que tu función a mano—.
El cableado estándar es exactamente el de L3, con las piezas prebuilt:
1add_conditional_edges("agent", tools_condition) # ¿tools? → "tools" ; ¿no? → __end__
2add_edge("tools", "agent") # el cicloPara que esto funcione hay una convención de nombres: el nodo de tools debe llamarse "tools", porque es a donde tools_condition enruta por defecto. Si lo llamas de otro modo, la decisión no encuentra el destino.
stream: ver el estado en cada superstep
Aquí está lo que el bucle nunca te dio. stream (y astream) ejecutan el grafo emitiendo lo que pasa en cada superstep. El parámetro stream_mode elige qué ves:
stream_mode admite, entre otros: "values", "updates", "messages", "custom". Los dos que usas para auditar:
"updates"→ los cambios por nodo: qué devolvió cada nodo en cada superstep. Ideal para ver quién tocó el estado."values"→ el estado completo tras cada superstep. Ideal para ver cómo queda el estado paso a paso.
("messages" emite tokens del LLM; "checkpoints" y "tasks" requieren checkpointer —eso es N1—. En N0 te bastan "updates" y "values".)
Un superstep es una unidad de avance del grafo: el conjunto de nodos que se ejecutan antes de la siguiente actualización del estado. Varios nodos pueden correr en paralelo en un mismo superstep; en el grafo secuencial de N0, cada nodo es su propio superstep. La rúbrica C0 pide que, en cualquier superstep, el estado sea legible. stream es cómo lo demuestras.
create_agent: el atajo (y la deprecación que debes conocer)
Todo este grafo —modelo, ToolNode, tools_condition, el ciclo— es un patrón tan común que el ecosistema LangChain lo empaqueta en una función prebuilt (ojo: vive en el paquete langchain, no en langgraph). Conviene conocerlo, y conviene conocer la trampa de versión que lo rodea.
El prebuilt vigente es create_agent, de langchain.agents:
1from langchain.agents import create_agentExiste en jun 2026 y es el camino recomendado. Monta por ti el grafo del loop modelo↔tools que acabas de escribir a mano.
Y aquí la corrección de versión que te ahorra horas. El prebuilt antiguo, create_react_agent (de langgraph.prebuilt), está deprecado: sigue funcionando, pero emite DeprecationWarning. No es un reemplazo drop-in:
create_agentcambia la firma:system_promptreemplaza aprompt/messages_modifier, y añade un sistema demiddleware.- Un
s/create_react_agent/create_agent/rompe el código —no es renombrar, es otra API—. langchain.agentsno exportacreate_react_agent; ese nombre vive enlangchain-classic, marcado como no apto para producción.
Por eso en este lab escribes el grafo a mano: para ver las piezas. La firma exacta de create_agent —sus parámetros completos, el middleware— la fijamos en N2, donde las tools son el tema y el atajo gana su sitio. En N0, saber que existe y que create_react_agent quedó atrás es suficiente.
4.5Míralo funcionar: el agente-bucle, reescrito
Vamos a montar grafo.py: el agente-bucle de L1, reescrito como grafo con ToolNode y tools_condition. Es código con varias piezas encadenadas.
Antes de leerlo línea a línea, lee el bloque entero de corrido. Lo difícil no es ninguna línea: es ver que este grafo hace lo mismo que el bucle de L1, con tres aristas en vez de un for.
Las tools, mínimas
ToolNode necesita tools. Las defines con @tool (de langchain_core.tools), con type hints —son obligatorios: de ahí se infiere el esquema que el modelo ve—. En N0 las dejamos mínimas; los contratos serios son N2.
1# tools.py — las tools de Elcano, mínimas (los contratos serios son N2)
2from langchain_core.tools import tool
3
4@tool
5def consultar_cuenta(cuenta_id: str) -> dict:
6 """Devuelve el estado de la cuenta: plan, estado de pago."""
7 return _billing_mock.cuenta(cuenta_id)
8
9@tool
10def consultar_cargos(cuenta_id: str) -> list[dict]:
11 """Lista los últimos cargos de la cuenta."""
12 return _billing_mock.cargos(cuenta_id)
13
14@tool
15def emitir_reembolso(cargo_id: str, importe: float) -> dict:
16 """Emite un reembolso por un cargo. ACCIÓN CONSECUENTE e irreversible."""
17 return _billing_mock.reembolsar(cargo_id, importe)
18
19TOOLS = [consultar_cuenta, consultar_cargos, emitir_reembolso]Fíjate en emitir_reembolso: es la acción consecuente, la que se duplicó. En N0 vive en el grafo sin protección —igual que en el bucle—. La diferencia es que ahora está en un nodo separado, con una frontera donde N1 colgará el gate humano.
El grafo
1# grafo.py — Elcano como grafo (paridad con el bucle de L1, pero auditable)
2from langgraph.graph import StateGraph, START, END
3from langgraph.prebuilt import ToolNode, tools_condition
4from estado import EstadoOps # el estado tipado de L2
5from tools import TOOLS
6
7def llamar_modelo(state: EstadoOps):
8 # 'modelo_con_tools' es tu modelo con TOOLS enlazadas: el mismo tool-calling
9 # de tu agente previo (prerequisito), no una API nueva del curso.
10 return {"messages": [modelo_con_tools.invoke(state["messages"])]}
11
12grafo = StateGraph(EstadoOps)
13grafo.add_node("agent", llamar_modelo)
14grafo.add_node("tools", ToolNode(TOOLS)) # el nodo de tools, prebuilt
15
16grafo.add_edge(START, "agent")
17grafo.add_conditional_edges("agent", tools_condition) # ¿tools? → "tools" ; ¿no? → __end__
18grafo.add_edge("tools", "agent") # el ciclo
19
20elcano = grafo.compile()Compáralo con el bucle de L1, lado a lado en tu cabeza:
- El
for _ in range(MAX_PASOS)→ el cicloagent → tools → agent, sin contador. - El
if not salida.tool_calls: return→add_conditional_edges("agent", tools_condition). - El
for tc in salida.tool_calls: TOOLS[...](...)→ToolNode(TOOLS). - La variable local
mensajes→ el estado tipadoEstadoOps, legible en cada paso.
Mismo comportamiento. Otra forma. El nodo de tools se llama "tools" porque tools_condition enruta ahí por defecto.
Inspeccionar el estado con stream
Aquí demuestras la dim 3 de C0: el estado es legible en cada superstep. En vez de invoke (que solo te da el resultado final), usas stream:
1entrada = {"messages": [{"role": "user",
2 "content": "Cancela mi suscripción y reembolsa el último cargo"}]}
3
4# stream_mode="updates": qué devolvió CADA nodo en cada superstep
5for paso in elcano.stream(entrada, stream_mode="updates"):
6 print(paso) # {'agent': {'messages': [...]}} o {'tools': {'messages': [...]}}Cada elemento que imprime stream es un superstep: un dict {nombre_del_nodo: parcial_que_devolvió}. Lees, en orden, cómo Elcano decidió, qué tool ejecutó y qué devolvió. La traza del reembolso doble que en L1 era invisible, aquí es una secuencia de pasos que puedes leer.
Para ver el estado completo tras cada paso, cambias el modo:
1# stream_mode="values": el estado ENTERO tras cada superstep
2for snapshot in elcano.stream(entrada, stream_mode="values"):
3 print(len(snapshot["messages"]), snapshot.get("plan"))"updates" te dice quién tocó qué; "values" te dice cómo queda el estado. Con los dos, auditas el run entero sin meter un solo print dentro de la lógica del agente. Eso es lo que el bucle no te daba.
Self-explanation —respóndela antes de seguir: ¿por qué stream con "updates" hace visible el reembolso doble que el bucle de L1 escondía?
Razónalo y comprueba
Porque cada superstep del nodo "tools" aparece como un elemento separado en el stream, con el resultado de cada tool. Si emitir_reembolso se ejecuta dos veces, ves dos supersteps de "tools" con dos reembolsos, en orden, sin tocar la lógica. En el bucle, esas dos ejecuciones ocurrían dentro de un for en una variable local: para verlas tenías que parar el proceso e instrumentar a mano. El grafo expone la frontera entre pasos; stream la lee. Auditar dejó de exigir un debugger.
4.6Hazlo tú
Ejercicio — añade un nodo y obsérvalo en el stream
Sobre el grafo de §4.5, haz dos cambios:
- Añade un nodo
triajeantes deagentque clasifique la petición (usaCommand(goto=...)de L3, o una arista condicional). Conéctalo:START → triaje → agent. - Corre el
streamcon"updates"sobre la misma petición y comprueba que el primer superstep ahora es{'triaje': ...}.
Pista: si triaje enruta con Command(goto="agent"), no necesitas arista de salida para él (lo viste en §3.6). Reutiliza el patrón de L3.
Elaborative interrogation —antes de correrlo, predice: ¿qué verás en el primer elemento del stream con "updates", y en qué se diferencia de lo que verías con "values"?
Comprueba tu predicción
Con "updates", el primer elemento es {'triaje': <parcial que devolvió triaje>}: solo el cambio que produjo ese nodo. Con "values", el primer elemento es el estado completo tras el superstep de triaje —todos los campos de EstadoOps, no solo lo que cambió—. La diferencia: "updates" aísla la contribución de cada nodo (útil para auditar quién hizo qué); "values" te da la foto completa del estado en ese punto (útil para ver cómo queda). Si tu objetivo es seguir el flujo nodo a nodo, "updates" es más legible.
4.7Comprueba
Sin pistas. Gate de maestría: detectar por qué un grafo reescrito no tiene paridad con el bucle.
Un compañero reescribió el agente-bucle como grafo, pero Elcano termina tras el primer turno sin ejecutar nunca una tool. Aquí está su grafo:
1grafo = StateGraph(EstadoOps)
2grafo.add_node("agent", llamar_modelo)
3grafo.add_node("herramientas", ToolNode(TOOLS)) # ← nodo de tools
4
5grafo.add_edge(START, "agent")
6grafo.add_conditional_edges("agent", tools_condition)
7grafo.add_edge("herramientas", "agent")
8elcano = grafo.compile()- Identifica el fallo.
- Explica el síntoma.
- Arréglalo.
Criterio de corrección + feedback
El fallo: el nodo de tools se llama "herramientas", pero tools_condition enruta por defecto al nodo llamado "tools".
El síntoma: cuando el modelo pide una tool, tools_condition devuelve "tools" —un nodo que no existe con ese nombre—. El destino no se encuentra, así que el flujo nunca alcanza el nodo de tools. En la práctica, el agente no ejecuta herramientas: no hay paridad con el bucle, que sí las ejecutaba.
El arreglo: renombrar el nodo a "tools", que es la convención que tools_condition espera:
1grafo.add_node("tools", ToolNode(TOOLS))
2...
3grafo.add_edge("tools", "agent")Feedback formativo:
- Si viste que el nombre del nodo debe ser
"tools": dominas la convención que conectatools_conditionconToolNode. Es el fallo silencioso más común al usar los prebuilt —no lanza error, solo no ejecuta tools—. - Si dijiste "falta el ciclo" o "falta
END": mira de nuevo: el ciclo y la salida condicional están bien. El fallo es el nombre. Una pista para la próxima: si las tools nunca se ejecutan, sospecha del destino detools_condition. - Gate: necesitas identificar el nombre del nodo y arreglarlo para superar este punto. Es exactamente el tipo de bug que
streamcon"updates"te ayuda a cazar —verías que nunca aparece un superstep de tools—.
4.8Conecta
Acabas de construir el artefacto de C0, no de hacer un ejercicio. Este grafo ES el entregable del checkpoint.
Cierra el arco que abrió L1. Tenías un bucle opaco donde el reembolso se duplicó sin que pudieras verlo. Ahora tienes el mismo comportamiento como grafo tipado: ToolNode ejecuta las tools, tools_condition cierra el ciclo por condición, y stream te deja leer el estado en cada superstep. El fallo que era invisible ahora es una secuencia de pasos auditable.
Lo que llevas a L5, el mastery gate:
- El grafo con estado tipado (L2), nodos y ciclo condicional (L3) y las piezas prebuilt (L4).
- El diagrama del grafo y una traza por
streamque muestra el estado en cada paso. - La justificación: qué bug del bucle implícito tu grafo hace visible o imposible.
Y mira hacia delante, sin entrar todavía. emitir_reembolso sigue sin protección en este grafo. En N1 colgarás un gate de aprobación humana en la frontera entre decidir y ejecutar, y harás el agente durable —que sobreviva a una caída y reanude sin re-ejecutar el reembolso—. Cuando llegues ahí, configurarás durability="async" de forma explícita en la invocación (no en compile). La estructura que montaste hoy es lo que hace posible colgar esa compuerta.
4.9Reflexiona
Tómate dos minutos. Responder esto por escrito consolida más que releer.
- ¿Qué aprendiste? Resume en una frase qué te dan
ToolNodeytools_conditionque tu cableado a mano de L3 hacía igual. - ¿Qué sigue sin estar claro? ¿Tienes clara la diferencia entre
stream_mode="updates"y"values"? Si no, vuelve a §4.5. - ¿Qué harías distinto? ¿Cuándo escribirías el grafo a mano y cuándo usarías el atajo
create_agent? (Es la pregunta que N2 y N4 responden con detalle.)
Esto requiere práctica. La soltura con los prebuilt llega montando grafos, no leyendo sobre ellos.
Referencia rápida
ToolNode(TOOLS)(delanggraph.prebuilt): el nodo que ejecuta las tool calls del último mensaje. Aceptahandle_tool_errors(lo usas en N2; su default cambió tras 1.0.1 — verifícalo entonces).tools_condition(mismo import): enruta"tools"si hay tool calls,"__end__"si no. El nodo de tools debe llamarse"tools".- Cableado estándar:
add_conditional_edges("agent", tools_condition)+add_edge("tools", "agent"). stream(entrada, stream_mode=...):"updates"= cambios por nodo;"values"= estado completo por superstep. ("messages"= tokens;"checkpoints"/"tasks"necesitan checkpointer → N1.)create_agent(delangchain.agents): atajo prebuilt vigente que monta este grafo. Firma completa (system_prompt,middleware) → N2.create_react_agent(delanggraph.prebuilt): deprecado, emiteDeprecationWarning. No es drop-in decreate_agent(cambia la firma). No vive enlangchain.agents.- No usar nunca: un nodo de tools con nombre distinto de
"tools"junto atools_condition;s/create_react_agent/create_agent/esperando que compile.