Agentes de IA con estado en LangGraph
Crea agentes de IA multi-paso con LangGraph usando máquinas de estado, aristas condicionales, tool calling y checkpoints human-in-the-loop para workflows de producción
Visión general
LangGraph extiende LangChain con grafos con estado y cíclicos para construir workflows agénticos. En lugar de cadenas lineales, defines una máquina de estados: los nodos son funciones que leen y actualizan un estado compartido, las aristas son transiciones, y las aristas condicionales deciden el siguiente paso en tiempo de ejecución. Eso permite que un agente itere (planifica, llama una herramienta, mira el resultado, planifica de nuevo) en lugar de ejecutarse una vez y esperar lo mejor.
Esta receta construye un agente de investigación con tool calling, enrutamiento condicional, checkpointing y una puerta de aprobación humana. El mismo esqueleto sirve para asistentes de código, agentes de calidad de datos y cualquier workflow donde el modelo necesite varios turnos para converger en una respuesta.
Las piezas que sostienen el diseño:
AgentStatees el contrato que todo nodo lee y escribe. El reduceradd_messagesagrega mensajes nuevos en lugar de reemplazar la lista, así que el historial de conversación sobrevive entre nodos.should_continuees una función normal que inspecciona el estado y devuelve a dónde ir después. La topología del grafo queda como datos; el enrutamiento queda como código.- El checkpointer (
MemorySaveren desarrollo, SQLite/Postgres en producción) guarda una foto del estado tras cada nodo bajo unthread_id. Es lo que hace posible reanudar conversaciones y el human-in-the-loop.
Cuándo usarla
- Agentes multi-paso que necesitan iterar: buscar, evaluar, refinar, repetir
- Workflows con branching condicional (si está seguro, responde; si no, busca más)
- Flujos human-in-the-loop donde una persona aprueba las llamadas a herramientas antes de ejecutarlas
- Conversaciones con estado que deben persistir entre sesiones o reinicios
- Configuraciones multi-agente donde varios nodos especializados comparten un mismo estado
Cuándo no usarla
Usa algo más simple cuando:
- Una sola llamada al LLM con
bind_tools()resuelve la petición, así que no hace falta un grafo para una function call de un solo paso. - El pipeline es lineal: para flujos prompt → modelo → parser, las cadenas de LangChain con LCEL son más cortas y fáciles de razonar.
- Ejecutas trabajos por lotes (clasificar 10.000 textos): un bucle simple sobre
model.invoke()gana a la orquestación de agentes, y las respuestas streaming cubren el lado UX si hace falta. - El agente nunca necesita estado: los checkpointers escriben una foto por nodo, así que no pagues por persistencia que no vas a leer.
Requisitos previos
- Python 3.10+
- Paquetes
langgraph,langchain-openaiylangchain-core - Una
OPENAI_API_KEYpara las llamadas al modelo en los ejemplos
Solución
1. Instalar dependencias
pip install langgraph langchain-openai langchain-core
2. Definir el estado del agente
import re
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
search_results: str
attempts: int
max_attempts: int
3. Definir herramientas
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
# Búsqueda simulada — reemplaza con Tavily, SerpAPI, etc.
results = {
"redis caching": "Redis cache-aside pattern: check cache, if miss, load from DB, set cache. TTL recommended.",
"docker compose": "Docker Compose defines multi-container apps in YAML. Use profiles for environment-specific services.",
}
return results.get(query.lower(), f"No results found for: {query}")
@tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression (digits and operators only)."""
if not re.fullmatch(r"[\d\s+\-*/().]+", expression):
return "Error: expression must contain only numbers and operators"
try:
return f"Result: {eval(expression)}"
except Exception as e:
return f"Error: {e}"
tools = [search_web, calculate]
La puerta del regex importa: eval() sobre la salida cruda del modelo es un agujero de inyección de código. Poner en lista blanca dígitos y operadores deja la herramienta lo bastante segura para un ejemplo. Un parser más estricto (ast.literal_eval o un pequeño evaluador de expresiones) es lo correcto en producción.
4. Definir nodos del grafo
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
model_with_tools = model.bind_tools(tools)
def agent_node(state: AgentState) -> dict:
"""Main agent node — decides what to do next."""
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
def tool_node(state: AgentState) -> dict:
"""Execute tool calls from the agent's last message."""
last_message = state["messages"][-1]
tool_messages = []
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
tool_id = tool_call["id"]
# Encontrar y ejecutar la herramienta
tool_map = {t.name: t for t in tools}
if tool_name in tool_map:
try:
result = tool_map[tool_name].invoke(tool_args)
except Exception as e:
result = f"Tool {tool_name} failed: {e}"
tool_messages.append(ToolMessage(
content=str(result),
tool_call_id=tool_id,
))
return {"messages": tool_messages, "attempts": state.get("attempts", 0) + 1}
def should_continue(state: AgentState) -> str:
"""Conditional edge — decide whether to use tools or end."""
last_message = state["messages"][-1]
attempts = state.get("attempts", 0)
max_attempts = state.get("max_attempts", 5)
if last_message.tool_calls and attempts < max_attempts:
return "tools"
return END
El try/except dentro de tool_node es deliberado: si una herramienta lanza y el error nunca llega al modelo como ToolMessage, el agente no puede recuperarse; solo ve silencio y o reintenta para siempre o abandona sin contexto.
5. Construir el grafo
def build_agent_graph(checkpointer=None):
"""Build and compile the LangGraph agent."""
workflow = StateGraph(AgentState)
# Agregar nodos
workflow.add_node("agent", agent_node)
workflow.add_node("tools", tool_node)
# Establecer punto de entrada
workflow.set_entry_point("agent")
# Agregar arista condicional desde el agente
workflow.add_conditional_edges(
"agent",
should_continue,
{
"tools": "tools",
END: END,
},
)
# Después de tools, volver al agente
workflow.add_edge("tools", "agent")
return workflow.compile(checkpointer=checkpointer)
app = build_agent_graph(checkpointer=MemorySaver())
should_continue devuelve un string y el dict de mapeo lo resuelve a un nodo. Devolver END (la constante __end__) termina la ejecución; puedes mapearlo a un nodo de limpieza o resumidor si el agente necesita un epílogo.
6. Ejecutar el agente
config = {"configurable": {"thread_id": "session-1"}}
result = app.invoke(
{
"messages": [HumanMessage(content="What is the Redis cache-aside pattern?")],
"max_attempts": 5,
},
config=config,
)
# Imprimir la respuesta final
for msg in result["messages"]:
if isinstance(msg, AIMessage):
print(f"AI: {msg.content}")
elif isinstance(msg, ToolMessage):
print(f"Tool: {msg.content}")
7. Stream de pasos del agente
for event in app.stream(
{
"messages": [HumanMessage(content="Calculate 15 * 23 and then search for Docker Compose")],
"max_attempts": 5,
},
config=config,
):
for node_name, node_output in event.items():
print(f"\n--- {node_name} ---")
if "messages" in node_output:
for msg in node_output["messages"]:
if isinstance(msg, AIMessage):
print(f"AI: {msg.content[:100]}")
elif isinstance(msg, ToolMessage):
print(f"Tool result: {msg.content[:100]}")
Cómo funciona
El diagrama muestra la topología compilada: el agente itera por tools hasta que should_continue devuelve END.
StateGraphcompila un grafo dirigido donde cada nodo es una función que recibe elAgentStatecompartido y devuelve una actualización parcial. Los nodos nunca mutan el estado en sitio; devuelven dicts que LangGraph fusiona usando el reducer de cada campo.- Los reducers controlan la fusión.
add_messagesagrega; un campo simple comoattemptssobrescribe. Elige los reducers a propósito. Un campo lista sinadd_messagesse reemplaza en cada actualización de nodo y pierde el historial en silencio. - Las aristas condicionales enrutan en tiempo de ejecución.
should_continueve el estado después de que corriera el nodo agente: si el último mensaje pide herramientas y el presupuesto de intentos no se agotó, enruta atools; si no, la ejecución termina. - El ciclo es lo que convierte esto en un agente y no en una cadena. Los resultados de herramientas vuelven como entradas
ToolMessage, el agente los lee y decide de nuevo. El bucle solo para cuando el modelo responde sin llamadas a herramientas o se agota el presupuesto. - El checkpointing guarda una foto del estado completo tras cada nodo bajo el
thread_iddelconfig. El mismothread_idhace que el siguienteinvokeretome con los mensajes previos, y es también por lo que cada sesión de usuario necesita su propio ID.
Variantes
Aprobación human-in-the-loop
from langgraph.types import interrupt, Command
def tool_node_with_approval(state: AgentState) -> dict:
"""Execute tools with human approval for sensitive operations."""
last_message = state["messages"][-1]
for tool_call in last_message.tool_calls:
if tool_call["name"] == "calculate":
# Interrumpir y esperar aprobación humana
approval = interrupt({
"question": f"Approve calculation: {tool_call['args']['expression']}?",
"tool_call": tool_call,
})
if not approval:
return {"messages": [ToolMessage(
content="Calculation rejected by user.",
tool_call_id=tool_call["id"],
)]}
# Proceder con la ejecución normal de herramientas
return tool_node(state)
# La primera llamada pausa dentro del interrupt:
result = app.invoke(
{"messages": [HumanMessage(content="Calculate 100 / 0")]},
config=config,
)
# Reanudar con la respuesta del humano:
result = app.invoke(Command(resume="approved"), config=config)
Dos detalles que conviene saber. interrupt y Command viven en langgraph.types, no en langgraph.graph; la ruta de import antigua no existe en las versiones actuales. Y reanudar vuelve a ejecutar todo el nodo desde el principio, así que mantén el código del nodo idempotente (o pon el interrupt al inicio del nodo).
Colaboración multi-agente
def researcher_node(state: AgentState) -> dict:
"""Researcher agent — searches for information."""
response = model_with_tools.invoke([
{"role": "system", "content": "You are a research assistant. Search for information."},
*state["messages"],
])
return {"messages": [response]}
def writer_node(state: AgentState) -> dict:
"""Writer agent — synthesizes findings into a report."""
response = model.invoke([
{"role": "system", "content": "You are a technical writer. Synthesize the research into a clear report."},
*state["messages"],
])
return {"messages": [response]}
def build_multi_agent_graph(checkpointer=None):
workflow = StateGraph(AgentState)
workflow.add_node("researcher", researcher_node)
workflow.add_node("tools", tool_node)
workflow.add_node("writer", writer_node)
workflow.set_entry_point("researcher")
workflow.add_conditional_edges("researcher", should_continue, {"tools": "tools", END: "writer"})
workflow.add_edge("tools", "researcher")
workflow.add_edge("writer", END)
return workflow.compile(checkpointer=checkpointer)
Aquí END de should_continue mapea a writer en lugar del final real: el valor devuelto por la arista condicional pasa por el mapeo, así que “la ejecución terminó” puede significar “entregar al siguiente especialista” en lugar de “parar”.
Estado persistente con SQLite
from langgraph.checkpoint.sqlite import SqliteSaver
# Persistir el estado a SQLite para continuidad entre sesiones
app = build_agent_graph(checkpointer=SqliteSaver.from_conn_string("agent_state.db"))
# El estado persiste entre sesiones con el mismo thread_id
MemorySaver muere con el proceso: vale para notebooks, inútil para un servicio. SqliteSaver sobrevive reinicios en una sola máquina; PostgresSaver es la opción de producción cuando varios workers necesitan los mismos checkpoints.
Solución de problemas
GraphInterruptpropaga como excepción: estás usandointerrupt()sin checkpointer. Los interrupts persisten el estado para reanudar después, así quecompile(checkpointer=...)es obligatorio: sin checkpointer no hay pausa.- El agente itera para siempre:
should_continuedevuelve"tools"cada ronda. Revisa el presupuestomax_attempts, y luego el freno duro:recursion_limiten elconfig(por defecto 25) mata grafos descontrolados. - El estado no persiste entre invokes:
thread_iddistinto en cada llamada, o no hay checkpointer. Tanto el thread ID como el checkpointer deben ser estables. - Errores de validación de
ToolMessage: unToolMessagenecesita untool_call_idque coincida con la llamada que responde. Si construyes los resultados a mano y los IDs no cuadran, la validación falla. O usa elToolNodepreconstruido y olvida el cableado manual. ImportErroreninterruptoCommand: viven enlanggraph.types, no enlanggraph.graph. Actualiza el import en lugar de bajar de versión.
Mejores prácticas
- Establece
max_attempts: previene bucles infinitos donde el agente sigue llamando herramientas sin converger. Trátalo como un presupuesto, no como un parche. - Usa
MemorySaveren desarrollo ySqliteSaveroPostgresSaveren producción. - Define prompts de sistema claros: el agente necesita instrucciones explícitas sobre cuándo usar herramientas frente a responder directamente.
- Stream para UX en tiempo real:
app.stream()muestra cada paso a medida que ocurre; las respuestas streaming del LLM cubren la variante a nivel de token. - Pon
interruptal inicio del nodo: reanudar re-ejecuta el nodo desde el principio, así que todo lo que esté antes del interrupt corre dos veces. - Nombra los nodos semánticamente:
agent,tools,writeraparecen en cada evento del stream y en cada checkpoint, así que depurar es más rápido cuando los nombres significan algo.
Errores comunes
- Olvidar la anotación
add_messages: sin ella, cada actualización de nodo reemplaza la lista de mensajes en lugar de agregar, y el historial de conversación desaparece a mitad de ejecución. - Dejar escapar las excepciones de herramientas: si una herramienta lanza, el agente se atasca o reintenta a ciegas. Captura los errores dentro del nodo de herramientas y devuélvelos como
ToolMessagepara que el modelo vea el fallo. - Sin límite de
max_attempts: el agente puede iterar indefinidamente llamando herramientas sin producir una respuesta final. - Reutilizar un
thread_identre usuarios: el estado se filtra entre sesiones. Un thread ID por conversación — siempre. - Importar
interruptdesdelanggraph.graph: vive enlanggraph.typesen las versiones actuales; la ruta antigua lanzaImportError.
Ver también
Preguntas frecuentes
LangGraph vs. cadenas de LangChain — ¿cuándo usar cuál?
Usa cadenas (LCEL) para pipelines lineales: prompt → modelo → parser. Recurre a LangGraph cuando el flujo necesita ciclos, estado compartido o varios agentes hablando entre sí. Si should_continue siempre devolviera el mismo nodo, no necesitas el grafo.
¿Puedo usar LangGraph con modelos que no son de OpenAI?
Sí. Cualquier BaseChatModel funciona: Anthropic, Google o modelos locales vía Ollama. Solo cambia la línea model = ChatOpenAI(...).
¿Cómo funciona el checkpointing?
El checkpointer guarda una foto del estado completo tras cada nodo bajo el thread_id. Invoca de nuevo con el mismo ID y el grafo retoma con los mensajes previos, que es lo que alimenta tanto la memoria de conversación como la reanudación tras un interrupt.
¿interrupt() o interrupt_before para aprobación humana?
interrupt() pausa dentro de un nodo y devuelve un valor de reanudación, así que encaja en "aprueba esta llamada a herramienta concreta". interrupt_before/interrupt_after pausan en los límites de los nodos, que sirve para "revisa el borrador antes de publicarlo". Usa la función para puertas granulares y las flags de compile para checkpoints de workflow.
¿Puedo visualizar el grafo?
Sí. app.get_graph().draw_png("agent.png") renderiza la topología a una imagen, y draw_mermaid() imprime una definición mermaid que puedes pegar en documentación.
¿Cómo testeo un grafo sin llamar al LLM?
Sustituye agent_node por un stub determinista que devuelva AIMessage scriptados. Los nodos son funciones normales, así que el grafo corre de punta a punta sin llamadas a la API. Es la forma más limpia de asertar la lógica de enrutamiento en CI.
¿Cómo manejo errores en un nodo?
Lanza dentro del nodo y LangGraph propaga la excepción al caller de invoke. Para fallos recuperables, captura el error tú mismo y devuelve una actualización de estado apuntando a un nodo de fallback, que mantiene la ejecución viva y deja rastro en el checkpoint.
Recursos Relacionados
LangChain LCEL: componer cadenas con pipes y fallbacks
Construye pipelines LLM componibles con LangChain Expression Language (LCEL) usando pipes, ejecución paralela y componentes runnable personalizados
RecipeSalida JSON estructurada con OpenAI Function Calling
Usa function calling y structured outputs de OpenAI para obtener JSON confiable de LLMs con validacion Pydantic y manejo de errores
RecipeStream de salida LLM con Server-Sent Events (SSE)
Stream respuestas LLM a clientes en tiempo real usando Server-Sent Events con FastAPI, OpenAI streaming y async generators para salida token por token
RecipeEjecuta LLMs localmente con Ollama para inferencia privada
Instala y usa Ollama para ejecutar LLMs open-source localmente con Python, incluyendo streaming, embeddings, function calling y gestion de modelos sin costos de API
PatternPatrón Agent Tool Selection
Selecciona dinamicamente que herramientas puede usar un agente LLM segun el contexto de la tarea. Reduce tokens y mejora la calidad de decisiones.
PatternPatrón Human-in-the-Loop
Pausa la ejecución del agente LLM para aprobación humana antes de acciones de alto impacto. Enruta decisiones a un revisor cuando la confianza es baja.