SEXTANTEcursos técnicos de IA
métodoconstruir-y-endurecer
criterioframework-or-not
Entrar
N0 · Grafo y estado/L4

El loop modelo↔tools como grafo

Objetivo de maestría

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 ToolNode y tools_condition en 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_agent como atajo prebuilt, y create_react_agent como uso transitorio deprecado.

Necesitas saber antes:

  • De N0·L3: el grafo a mano —add_node, add_edge, add_conditional_edges, el ciclo que cierra con END—.
  • De N0·L2: el estado EstadoOps y el reducer add_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.

  1. En tu grafo a mano de L3, ¿qué función escribiste para decidir entre seguir al nodo de tools o terminar?
  2. ¿Qué devolvía tu nodo ejecutar_tools y cómo se fusionaba con el estado?
  3. ¿Qué reemplazó al contador MAX_PASOS como condición de parada?
Comprueba tu respuesta
  1. hay_tools(state): miraba el último mensaje; si tenía tool_calls devolvía "tools", si no devolvía END (§3.5).
  2. Devolvía {"messages": [...]} con los ToolMessage de cada resultado; add_messages los añadía al historial (§3.5).
  3. La arista condicional que devuelve END cuando 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:

python
1from langgraph.prebuilt import ToolNode, tools_condition

ToolNode 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:

python
1add_conditional_edges("agent", tools_condition)   # ¿tools? → "tools" ; ¿no? → __end__
2add_edge("tools", "agent")                          # el ciclo

Para 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:

python
1from langchain.agents import create_agent

Existe 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_agent cambia la firma: system_prompt reemplaza a prompt/messages_modifier, y añade un sistema de middleware.
  • Un s/create_react_agent/create_agent/ rompe el código —no es renombrar, es otra API—.
  • langchain.agents no exporta create_react_agent; ese nombre vive en langchain-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.

python
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

python
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 ciclo agent → tools → agent, sin contador.
  • El if not salida.tool_calls: returnadd_conditional_edges("agent", tools_condition).
  • El for tc in salida.tool_calls: TOOLS[...](...)ToolNode(TOOLS).
  • La variable local mensajes → el estado tipado EstadoOps, 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:

python
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:

python
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:

  1. Añade un nodo triaje antes de agent que clasifique la petición (usa Command(goto=...) de L3, o una arista condicional). Conéctalo: START → triaje → agent.
  2. Corre el stream con "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:

python
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()
  1. Identifica el fallo.
  2. Explica el síntoma.
  3. 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:

python
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 conecta tools_condition con ToolNode. 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 de tools_condition.
  • Gate: necesitas identificar el nombre del nodo y arreglarlo para superar este punto. Es exactamente el tipo de bug que stream con "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 stream que 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.

Cierra N0: el checkpoint C0


4.9Reflexiona

Tómate dos minutos. Responder esto por escrito consolida más que releer.

  • ¿Qué aprendiste? Resume en una frase qué te dan ToolNode y tools_condition que 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) (de langgraph.prebuilt): el nodo que ejecuta las tool calls del último mensaje. Acepta handle_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 (de langchain.agents): atajo prebuilt vigente que monta este grafo. Firma completa (system_prompt, middleware) → N2.
  • create_react_agent (de langgraph.prebuilt): deprecado, emite DeprecationWarning. No es drop-in de create_agent (cambia la firma). No vive en langchain.agents.
  • No usar nunca: un nodo de tools con nombre distinto de "tools" junto a tools_condition; s/create_react_agent/create_agent/ esperando que compile.