A3 — Agentes (LangGraph)
Outcome del módulo: construir un agente real con LangGraph — estado tipado, nodos, edges condicionales, tool-calling, memoria y límite de pasos — que decide, en vez de ejecutar un pipeline fijo de antemano.
Concepto 1 — Qué es un agente (loop pensar → actuar → observar)
Idea núcleo: un agente es un LLM corriendo en loop que decide una acción, la ejecuta, observa el resultado, y con eso decide la siguiente acción — a diferencia de un pipeline de pasos fijos definidos de antemano.
Entender (capa 1)
Texto: un pipeline tradicional (chain) ejecuta pasos A→B→C siempre en ese orden. Un agente, en el patrón ReAct (Reason + Act), en cada turno: (1) razona sobre el estado actual y decide qué hacer, (2) actúa (llama una tool, o responde), (3) observa el resultado de la acción, y vuelve a (1) hasta decidir que terminó. El “camino” no está fijo de antemano — lo decide el modelo en cada paso según lo que va observando.
Visual:
flowchart LR
P[Pensar: decidir próxima acción] --> A[Actuar: ejecutar tool o responder]
A --> O[Observar: leer el resultado]
O --> P
Video: AI Agentic Design Patterns: ReAct Explained | Reasoning + Acting — CodeCraft Academy
Ejemplo:
def run_agent_loop(pregunta: str, max_steps: int = 3) -> str:
historial = [{"role": "user", "content": pregunta}]
for _ in range(max_steps):
decision = llm_decide(historial) # pensar
if decision["action"] == "respond":
return decision["content"]
resultado = ejecutar_tool_mock(decision["action"], decision["args"]) # actuar
historial.append({"role": "tool", "content": resultado}) # observar
return "no se resolvió en el límite de pasos"
Fijar (capa 2)
Nota atómica:
- Front: ¿qué diferencia a un agente de un pipeline (chain) fijo?
- Back: el pipeline ejecuta una secuencia predefinida de pasos siempre igual; el agente decide en cada turno, según lo que observó, cuál es el siguiente paso — el camino no está escrito de antemano.
Feynman: “Un pipeline es una receta de cocina: siempre los mismos pasos en el mismo orden. Un agente es un cocinero: prueba, decide qué le falta, corrige, prueba de nuevo, hasta que el plato está listo.”
Aplicar (capa 3)
Escribí a mano (sin framework) un loop de máximo 3 iteraciones que llame a un LLM, parseé su decisión (acción + argumentos), y ejecute una tool mock según esa decisión.
Límites
- Los agentes son más lentos, más caros e impredecibles que un pipeline fijo — usalos solo cuando el camino no se puede predecir de antemano.
- Si tu flujo siempre es A→B→C sin decisiones reales en el medio, un agente es sobre-ingeniería; un pipeline simple es mejor.
Concepto 2 — LangGraph: state, nodes, edges, conditional edges
Idea núcleo: LangGraph modela el agente como un grafo con un estado tipado explícito; los nodos son funciones que transforman ese estado, los edges (incluidos los condicionales) definen el flujo, y END — el sentinel importado de langgraph.graph, no el string "END" — marca la salida del grafo.
Entender (capa 1)
Texto: StateGraph se construye sobre un esquema de estado (típicamente un TypedDict). Cada nodo es una función state -> dict que devuelve una actualización parcial del estado. Los edges conectan nodos; los conditional edges usan una función de routing que lee el estado y devuelve el nombre del próximo nodo — o el sentinel END para terminar. Punto crítico: END es un objeto especial que LangGraph reconoce internamente al compilar el grafo. Comparar o devolver el string "END" a mano no termina el grafo salvo que vos mismo hayas definido un nodo llamado literalmente "END" — hay que importar y usar el sentinel real.
Visual:
flowchart TD
Start([entry point]) --> Agent["Nodo: agent llama al LLM"]
Agent -->|conditional edge| Decision{should_continue}
Decision -->|hay tool_calls| Tools["Nodo: tools"]
Decision -->|no hay tool_calls| End(["END sentinel"])
Tools --> Agent
Video: LangGraph Tutorial - Implementing Conditional Edges for Dynamic Navigation — Mohamed Naji Aboo
Ejemplo:
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
steps: int
def call_model(state: AgentState) -> dict:
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response], "steps": state["steps"] + 1}
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
if getattr(last, "tool_calls", None):
return "tools"
return END # sentinel real, NUNCA el string "END"
graph = StateGraph(AgentState)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode(tools))
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")
app = graph.compile()
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué usar el sentinel
ENDimportado y no el string"END"? - Back:
ENDes un objeto especial que el compilador de LangGraph reconoce como señal de fin de grafo; un string"END"que vos escribís a mano es solo un nombre de nodo cualquiera — LangGraph no lo trata como terminación salvo que sea el sentinel real importado delanggraph.graph.
Feynman: “END no es una etiqueta de texto, es una llave especial que abre la puerta de salida. Si escribís la palabra ‘END’ en un papel, no abre nada; necesitás la llave de verdad, que se importa del módulo.”
Aplicar (capa 3)
Armá un grafo mínimo de 2 nodos (agent, tools) con un conditional edge que, cuando no hay tool_calls, termine en el END real importado. Compilalo e invocalo con un mensaje simple.
Límites
- Grafos grandes o muy anidados se vuelven difíciles de debuggear — visualizalos (
get_graph().draw_mermaid()) antes de que crezcan demasiado. - Para flujos simples y lineales sin decisiones reales, un pipeline normal sin grafo es más simple y suficiente (ver Límites del Concepto 1).
Concepto 3 — Tool-calling desde el agente
Idea núcleo: el LLM no ejecuta tools directamente — devuelve una decisión estructurada (tool_calls) que tu código ejecuta; el resultado se reinyecta al estado como un ToolMessage para que el modelo lo lea en el siguiente paso.
Entender (capa 1)
Texto: con bind_tools, el modelo recibe el schema de cada tool disponible y, cuando decide usarlas, responde con tool_calls (nombre + argumentos), no ejecuta nada por sí mismo. El nodo ToolNode (prebuilt de LangGraph) lee esos tool_calls del último mensaje, ejecuta la función Python real correspondiente, y agrega el resultado al estado como ToolMessage. El siguiente paso del agent ve ese resultado en el historial y decide de nuevo.
Visual:
flowchart LR
LLM["Modelo decide: tool_calls"] --> Node["ToolNode ejecuta la función real"]
Node --> Msg["ToolMessage con el resultado"]
Msg --> LLM
Video: LangGraph Tutorial: Introduction to Tool Use with ToolNode — Mohamed Naji Aboo
Ejemplo:
from langchain_core.tools import tool
@tool
def buscar_clima(ciudad: str) -> str:
"""Devuelve el clima actual de una ciudad dado su nombre."""
return clima_api(ciudad)
tools = [buscar_clima]
llm_with_tools = llm.bind_tools(tools)
# el nodo agent llama a llm_with_tools.invoke(mensajes)
# si el modelo decide usar la tool, el mensaje de respuesta trae .tool_calls poblado
# ToolNode(tools) lo detecta y ejecuta buscar_clima(ciudad=...) automáticamente
Fijar (capa 2)
Nota atómica:
- Front: ¿quién ejecuta la tool realmente, el modelo o tu código?
- Back: tu código. El modelo solo devuelve una decisión estructurada (
tool_calls: nombre + argumentos);ToolNode(o tu propio handler) es el que efectivamente llama a la función Python y devuelve el resultado como mensaje.
Feynman: “El modelo es un cliente que llena una orden de pedido con lo que quiere. El mesero (tu código) es quien va a la cocina, prepara el plato de verdad, y se lo trae de vuelta a la mesa.”
Aplicar (capa 3)
Definí una tool con @tool y bindeala al LLM en tu grafo del Concepto 2. Verificá con una pregunta que la amerite que el modelo la invoca, y con una que no la amerite que el modelo responde directo sin llamarla.
Límites
- El modelo puede alucinar argumentos de la tool o no invocarla cuando debería — la calidad de nombre/descripción/schema importa mucho (se profundiza en A4).
- No confíes ciegamente en los argumentos que arma el modelo: validalos (pydantic) antes de ejecutar efectos reales.
Concepto 4 — Memoria / estado compartido
Idea núcleo: el estado del grafo (incluido el historial de mensajes) es la memoria de corto plazo del agente dentro de una ejecución; persistir memoria entre ejecuciones distintas requiere un checkpointer.
Entender (capa 1)
Texto: dentro de una sola invocación (app.invoke(...)), el estado (AgentState) fluye de nodo a nodo y acumula el historial de mensajes — esa es la memoria de corto plazo del agente, todo en RAM. Para que el agente recuerde algo entre invocaciones distintas (una conversación multi-turno real, con el usuario volviendo horas después), LangGraph usa un checkpointer (ej. MemorySaver en memoria, o un checkpointer de Postgres para producción) que persiste el estado asociado a un thread_id. Al invocar de nuevo con el mismo thread_id, el grafo carga el estado guardado y continúa desde ahí.
Visual:
flowchart LR
R1["Run 1: state + mensajes"] --> CP["Checkpoint guardado por thread_id"]
CP --> R2["Run 2: mismo thread_id, carga el checkpoint"]
R2 --> R3["El agente recuerda el turno anterior"]
Video: LangGraph Tutorial: Conversation State with Checkpoints & Memory — Mohamed Naji Aboo
Ejemplo:
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "usuario-123"}}
app.invoke({"messages": [("user", "mi nombre es Josse")], "steps": 0}, config=config)
# ... más tarde, misma conversación ...
app.invoke({"messages": [("user", "¿cómo me llamo?")], "steps": 0}, config=config)
# el modelo ve el historial completo del thread_id "usuario-123" y responde "Josse"
Fijar (capa 2)
Nota atómica:
- Front: ¿qué necesitás para que un agente recuerde algo entre dos invocaciones distintas?
- Back: un checkpointer (ej.
MemorySaver, o uno persistente como Postgres) más unthread_idconsistente en la config de cada invocación — sin eso, cadainvokearranca con estado en blanco.
Feynman: “El estado dentro de una corrida es como la memoria de corto plazo mientras hablás. El checkpointer es la libreta donde anotás la conversación para poder seguirla mañana, identificada por el número de conversación (thread_id).”
Aplicar (capa 3)
Corré el mismo grafo dos veces con el mismo thread_id y confirmá que el segundo invoke recuerda información dada en el primero.
Límites
- El historial crece con cada turno — más tokens enviados al modelo en cada nuevo paso; en producción hay que truncar o resumir el historial largo.
MemorySavervive en RAM del proceso — no sirve para producción multi-instancia; ahí se necesita un checkpointer persistente [verificar backend soportado según versión].
Concepto 5 — Multi-step y límites de pasos (evitar loops infinitos)
Idea núcleo: un agente sin límite de pasos puede loopear indefinidamente (llamando tools sin converger a una respuesta) — hay que capar pasos explícitamente, tanto en el propio estado como con el recursion_limit del compilador.
Entender (capa 1)
Texto: nada garantiza que el modelo decida terminar. Una tool que falla repetidamente, un bug en el prompt, o un caso ambiguo pueden hacer que el agente reintente para siempre. Dos capas de protección: (1) un contador steps en el propio AgentState, incrementado en cada paso del nodo agent, chequeado en la función de routing para forzar END si se pasa de un máximo; (2) el recursion_limit que LangGraph acepta en la config de invoke, como red de seguridad adicional a nivel del framework.
Visual:
flowchart TD
S["steps += 1 en cada paso del agente"] --> Chk{"steps >= MAX_STEPS?"}
Chk -->|sí| End(["END forzado"])
Chk -->|no| Cont["sigue evaluando tool_calls normalmente"]
Video: LangGraph Tutorial: Mastering Recursion Limits — Avoiding Infinite Loops — Mohamed Naji Aboo
Ejemplo:
MAX_STEPS = 6
def should_continue(state: AgentState) -> str:
if state["steps"] >= MAX_STEPS:
return END # corta antes de evaluar tool_calls
last = state["messages"][-1]
if getattr(last, "tool_calls", None):
return "tools"
return END
app.invoke(estado_inicial, config={"configurable": {"thread_id": "t1"}, "recursion_limit": 25})
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué el
recursion_limitde LangGraph no alcanza como única protección? - Back: es una red de seguridad genérica del framework (corta con error cuando se excede), pero no te da control fino ni una salida ordenada; un contador propio en el estado permite terminar limpio (ej. devolver una respuesta parcial) antes de llegar a ese límite duro.
Feynman: “El contador propio es el freno de mano que vos calibrás para tu caso. El recursion_limit es el airbag que salta si todo lo demás falló — no querés depender solo del airbag.”
Aplicar (capa 3)
Forzá un caso donde el agente querría loopear (una tool que siempre devuelve un error que invita a reintentar) y verificá que el límite de pasos corta la ejecución en el máximo definido, devolviendo algo razonable.
Límites
- Un límite muy bajo corta tareas legítimas que necesitan varios pasos reales — no hay un número mágico universal, se calibra según el caso de uso.
- El límite evita el loop infinito, pero no arregla la causa (una tool que falla siempre, un prompt ambiguo) — eso se debuggea aparte con observabilidad (A5).
Concepto 6 — Human-in-the-loop (compacto)
Idea núcleo: para acciones sensibles o irreversibles, el grafo se pausa antes de ejecutar el nodo crítico y espera aprobación humana explícita antes de continuar.
Entender (capa 1)
Texto: LangGraph soporta interrupt_before (o interrupt_after) al compilar el grafo, indicando en qué nodo pausar. Al llegar ahí, la ejecución se detiene y el estado queda guardado (requiere checkpointer); un humano revisa ese estado por fuera, y la ejecución se reanuda invocando de nuevo con el mismo thread_id (típicamente pasando None como input para continuar donde quedó).
Visual:
flowchart LR
Node["Nodo tools: acción sensible"] -->|interrupt_before| Pause["Grafo pausado"]
Pause --> Human["Humano revisa el estado"]
Human -->|aprueba| Resume["Grafo continúa"]
Video: LangGraph interrupt: building human-in-the-loop agents — LangChain
Ejemplo:
app = graph.compile(checkpointer=checkpointer, interrupt_before=["tools"])
config = {"configurable": {"thread_id": "t1"}}
app.invoke({"messages": [("user", "cancelá mi suscripción")], "steps": 0}, config=config)
# se pausa antes de ejecutar el nodo "tools"
# ... humano revisa state y aprueba ...
app.invoke(None, config=config) # continúa desde donde quedó
Nota atómica: Front: ¿qué resuelve interrupt_before que no resuelve un chequeo dentro de la tool? Back: pausa la ejecución completa del grafo ANTES de que la tool corra, dejando el estado persistido para revisión externa — un chequeo interno de la tool ya la estaría ejecutando.
Feynman: “Es un semáforo en rojo justo antes de la acción que no se puede deshacer. El grafo se detiene solo, alguien mira, y recién ahí se le da luz verde para seguir.”
Aplicar (capa 3)
Agregá interrupt_before=["tools"] sobre la tool más sensible de tu agente y simulá la aprobación manual reinvocando con None.
Límites
- Agrega latencia y complejidad operativa — necesitás un canal (UI, Slack, lo que sea) para que el humano vea el estado pausado y apruebe.
- Reservalo para acciones con costo real (borrar datos, pagos, envíos) — no lo pongas en cada tool, mataría la utilidad del agente autónomo.
Build step
Convertí el retrieval de A1/A2 en una tool (buscar_en_corpus). Armá un agente LangGraph (state, nodo agent, nodo tools, conditional edge con END real) que decida cuándo usar esa tool y cuándo responder directo, con un límite de pasos explícito (Concepto 5).
Checklist de dominio
Marcás cuando podés explicar (Feynman) + aplicar cada uno:
- Qué es un agente (loop pensar → actuar → observar)
- LangGraph: state, nodes, edges, conditional edges,
ENDsentinel real - Tool-calling desde el agente
- Memoria / estado compartido (checkpointer, thread_id)
- Multi-step y límites de pasos
- Human-in-the-loop (interrupt_before)
Salida verificable del módulo: un agente LangGraph que recibe una pregunta, decide si necesita el RAG (tool) o no, ejecuta hasta N pasos como máximo sin loopear, y puede explicar en voz alta por qué usó END como sentinel y no como string.