LangGraph — Módulo 9: sistemas multi-agente
Noveno módulo del curso de LangGraph: cuándo dividir un agente en varios. Subgrafos como nodos, el patrón supervisor con subagentes como tools, y handoffs con Command(goto, graph=Command.PARENT).
☰ Índice del contenido
Arranca el bloque avanzado del Curso completo de LangGraph. Hasta aquí construimos un agente cada vez mejor equipado. Hoy vemos qué hacer cuando ese agente se vuelve demasiado grande — y, tanto o más importante, cómo darte cuenta de que todavía no lo es.
La tesis del módulo
Multi-agente no es una mejora: es una decisión de arquitectura con un costo. Divides cuando un solo agente ya no rinde —demasiadas tools, demasiadas instrucciones contradictorias— no cuando suena sofisticado. Y cuando divides, en LangGraph los agentes no son un concepto nuevo: son grafos dentro de grafos.
Primero: no dividas todavía
El síntoma real que justifica dividir es concreto: el agente elige mal la tool. Con 25 herramientas en el prompt, el modelo empieza a confundir buscar_cliente con buscar_pedido. El prompt de sistema se llena de excepciones (“si es de facturación, entonces… salvo que…”). La precisión cae y cada cambio rompe otra cosa.
Antes de llegar a multi-agente, prueba lo barato: mejores docstrings, menos tools, un prompt más corto. Un solo agente bien afinado le gana a tres agentes mal coordinados casi siempre.
Lo que un sistema multi-agente te cuesta: más llamadas al modelo (más latencia y más plata), un flujo más difícil de depurar, y un problema nuevo — decidir qué información se pasa entre agentes.
La pieza base: el subgrafo
Un subgrafo es un grafo compilado que usas como nodo dentro de otro grafo. Eso es todo el concepto. Y como un agente ya es un grafo compilado, un agente es un subgrafo.
Si el padre y el subgrafo comparten claves de estado, lo pasas directo a add_node:
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
class State(TypedDict):
tema: str
# El subgrafo
sub = StateGraph(State)
sub.add_node("investigar", investigar)
sub.add_edge(START, "investigar")
subgrafo = sub.compile()
# El padre lo usa como un nodo más
padre = StateGraph(State)
padre.add_node("investigacion", subgrafo) # ← un grafo compilado como nodo
padre.add_edge(START, "investigacion")
graph = padre.compile()
Si los esquemas de estado no comparten claves, no puedes pasarlo directo: necesitas una función que traduzca en ambas direcciones.
class EstadoPadre(TypedDict):
tema: str
class EstadoSub(TypedDict):
consulta: str
def llamar_subgrafo(state: EstadoPadre) -> dict:
salida = subgrafo.invoke({"consulta": state["tema"]}) # traducir hacia adentro
return {"tema": salida["consulta"]} # y de vuelta hacia afuera
padre.add_node("investigacion", llamar_subgrafo)
Esa función traductora no es burocracia: es el contrato entre los dos agentes. Escribirla te obliga a decidir explícitamente qué información cruza la frontera — que es justamente la decisión difícil de un sistema multi-agente.
Para depurar:
graph.get_state(config, subgraphs=True)te muestra también el estado interno de los subgrafos, y en el streaming puedes filtrar por namespace para saber de qué subgrafo viene cada evento. Sin esto, un sistema multi-agente es una caja negra dentro de otra caja negra.
Patrón 1: supervisor con subagentes como tools
El patrón más usado y el más fácil de razonar. Un agente central —el supervisor— tiene como herramientas a los demás agentes. Decide a quién llamar, recibe el resultado y sigue.
from langchain.tools import tool
from langchain.agents import create_agent
agente_investigador = create_agent(model=MODELO, tools=[buscar_web, leer_pagina])
agente_redactor = create_agent(model=MODELO, tools=[revisar_estilo])
@tool("investigar", description="Investiga un tema y devuelve los hallazgos con fuentes.")
def llamar_investigador(consulta: str) -> str:
r = agente_investigador.invoke({"messages": [{"role": "user", "content": consulta}]})
return r["messages"][-1].content
@tool("redactar", description="Redacta un texto final a partir de hallazgos ya investigados.")
def llamar_redactor(instruccion: str) -> str:
r = agente_redactor.invoke({"messages": [{"role": "user", "content": instruccion}]})
return r["messages"][-1].content
supervisor = create_agent(
model=MODELO,
tools=[llamar_investigador, llamar_redactor],
)
┌──────────────┐
│ supervisor │
└──┬────────┬──┘
investigar│ │redactar
▼ ▼
investigador redactor
Fíjate en lo que realmente pasa: es el ciclo ReAct del Módulo 5. No hay maquinaria nueva; lo que cambió es que detrás de la tool hay un agente completo en vez de una función.
Dos propiedades importantes de este patrón:
- Los subagentes son efímeros. Cada llamada empieza limpia; no recuerdan turnos anteriores. La memoria de la conversación vive en el supervisor.
- Solo cruza texto. El subagente devuelve su última respuesta como string. Eso es una ventaja (aísla el ruido: el supervisor no ve los 30 mensajes internos del investigador) y una limitación (si necesitas estructura, devuélvela como JSON y parséala).
Si tienes muchos agentes, en vez de una tool por cada uno usa una sola tool parametrizada — así el prompt no crece:
SUBAGENTES = {"investigador": agente_investigador, "redactor": agente_redactor}
@tool
def delegar(agente: str, tarea: str) -> str:
"""Delega una tarea a un agente especialista: 'investigador' o 'redactor'."""
r = SUBAGENTES[agente].invoke({"messages": [{"role": "user", "content": tarea}]})
return r["messages"][-1].content
Patrón 2: handoffs (transferencia entre pares)
En el supervisor, el control siempre vuelve al centro. En un handoff el control se transfiere: el agente de ventas le pasa la conversación al de soporte y se sale del camino. Es el modelo de un call center.
La pieza clave es que una tool devuelva un Command que redirige en el grafo padre:
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import tool, ToolRuntime
from langgraph.types import Command
@tool
def transferir_a_soporte(runtime: ToolRuntime) -> Command:
"""Transfiere la conversación al agente de soporte técnico."""
ultimo_ai = next(
m for m in reversed(runtime.state["messages"]) if isinstance(m, AIMessage)
)
aviso = ToolMessage(
content="Transferido al agente de soporte.",
tool_call_id=runtime.tool_call_id,
)
return Command(
goto="agente_soporte", # a qué nodo ir
update={"messages": [ultimo_ai, aviso]}, # qué contexto llevar
graph=Command.PARENT, # el destino está en el grafo padre
)
Tres cosas que importan aquí:
Commandes un objeto que un nodo (o tool) devuelve para hacer dos cosas a la vez: actualizar el estado (update) y decidir a dónde ir (goto). Es la alternativa a las aristas condicionales cuando la decisión nace dentro del nodo.graph=Command.PARENTes lo que hace posible el handoff: sin eso,gotobuscaría el nodo dentro del propio agente, no en el grafo que lo contiene.- El par
AIMessage+ToolMessage. Toda tool call necesita su respuesta (lo vimos en el Módulo 7). Si transfieres sin cerrar la tool call, el historial queda inválido y el proveedor devuelve error.
El grafo padre coordina a los agentes como nodos hermanos, y una clave del estado —agente_activo— recuerda quién tiene el control:
builder = StateGraph(EstadoMulti)
builder.add_node("agente_ventas", llamar_ventas)
builder.add_node("agente_soporte", llamar_soporte)
builder.add_conditional_edges(START, ruta_inicial, ["agente_ventas", "agente_soporte"])
builder.add_conditional_edges("agente_ventas", tras_agente, ["agente_ventas", "agente_soporte", END])
builder.add_conditional_edges("agente_soporte", tras_agente, ["agente_ventas", "agente_soporte", END])
Cuál usar
| Supervisor | Handoff | |
|---|---|---|
| Control | Vuelve siempre al centro | Se transfiere |
| Contexto | Aislado (solo pasa texto) | Compartido (el historial viaja) |
| Depurar | Fácil: un solo coordinador | Más difícil: control distribuido |
| Cuándo | Tareas que se descomponen (investigar → redactar) | Conversaciones que cambian de dominio |
Empieza siempre por supervisor. Es más fácil de razonar, más fácil de depurar y cubre la mayoría de los casos. Pasa a handoffs cuando de verdad necesites que el contexto conversacional completo siga con el usuario a través de especialistas.
Errores comunes
- Dividir antes de tiempo. Tres agentes mediocres son peores que uno bueno. Divide por síntoma medido, no por estética.
- Pasar demasiado contexto entre agentes. Si le mandas el historial completo a cada subagente, pagas el aislamiento con tokens y confusión.
- Pasar muy poco. El subagente responde algo inútil porque no sabía para qué se lo preguntaban. Ese equilibrio es el trabajo de diseño.
- Handoff sin cerrar la tool call. Falta el
ToolMessagey la siguiente llamada al modelo falla. - Olvidar
graph=Command.PARENT. Elgotono encuentra el nodo y el handoff no ocurre. - Ciclos entre agentes. Ventas transfiere a soporte, soporte devuelve a ventas, infinito. Aplica el
recursion_limitdel Módulo 4 — pero la solución real es una condición de salida.
La frase para llevarse
“Un agente no se divide porque sea complejo, sino porque ya se equivoca de forma medible. Y cuando lo divides, el trabajo difícil no es separarlo: es decidir qué se dicen entre ellos.”
Qué viene
En el Módulo 10, la memoria que falta. El checkpointer del Módulo 6 recuerda dentro de un thread; el Store recuerda entre threads: lo que sabes de un usuario y que sigue siendo cierto la próxima semana.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.