Módulo 10: Multi-Agent Systems
Pattern Subagents
Descripción de la cápsula
En las cápsulas anteriores aprendiste dos formas de coordinar agentes: el supervisor (un agente central que decide) y los handoffs (transferencia directa entre agentes). Ambos comparten una característica: el estado fluye completo entre los agentes. El researcher ve su investigación, el analyst ve la investigación + su análisis, el writer ve todo lo anterior. Con cada paso, el contexto crece.
Los subagents resuelven este problema con aislamiento de contexto. Un agente padre (supervisor) crea una subtarea, la envía a un subagent, y el subagent trabaja en su propio espacio — sin ver la conversación completa del padre. Cuando termina, devuelve solo el resultado. El padre integra ese resultado y continúa. El contexto del padre no crece con cada subtarea.
La diferencia fundamental: en handoffs, el agente destino recibe el contexto acumulado. En subagents, el agente hijo recibe solo su instrucción y trabaja desde cero. Es la diferencia entre "pásame todo el expediente" y "dime qué necesitas que investigue."
El problema del context bloat
Para entender por qué los subagents existen, hay que ver el problema que resuelven:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
class SharedState(TypedDict):
task: str
agent_a_work: str
agent_b_work: str
agent_c_work: str
context_size: int
log: Annotated[list[str], operator.add]
def agent_a(state: SharedState) -> Command:
work = "A" * 200
size = len(state["task"]) + len(work)
return Command(
goto="agent_b",
update={
"agent_a_work": work,
"context_size": size,
"log": [f"agent_a: generó {len(work)} chars, contexto total: {size}"],
},
)
def agent_b(state: SharedState) -> Command:
work = "B" * 300
size = state["context_size"] + len(work)
return Command(
goto="agent_c",
update={
"agent_b_work": work,
"context_size": size,
"log": [f"agent_b: generó {len(work)} chars, contexto total: {size}"],
},
)
def agent_c(state: SharedState) -> dict:
work = "C" * 400
size = state["context_size"] + len(work)
return {
"agent_c_work": work,
"context_size": size,
"log": [f"agent_c: generó {len(work)} chars, contexto total: {size}"],
}
builder = StateGraph(SharedState)
builder.add_node("agent_a", agent_a)
builder.add_node("agent_b", agent_b)
builder.add_node("agent_c", agent_c)
builder.add_edge(START, "agent_a")
builder.add_edge("agent_c", END)
graph = builder.compile()
result = graph.invoke({
"task": "Analizar mercado AI",
"agent_a_work": "", "agent_b_work": "",
"agent_c_work": "", "context_size": 0, "log": [],
})
print("=== Context Bloat con estado compartido ===\n")
for entry in result["log"]:
print(f" {entry}")
print(f"\nContexto final: {result['context_size']} chars")
print(f"Agent C necesitaba ver todo? Probablemente no.")
# Output esperado:
# === Context Bloat con estado compartido ===
#
# agent_a: generó 200 chars, contexto total: 218
# agent_b: generó 300 chars, contexto total: 518
# agent_c: generó 400 chars, contexto total: 918
#
# Contexto final: 918 chars
# Agent C necesitaba ver todo? Probablemente no.
Con cada handoff, el contexto crece. En este ejemplo simplificado son chars, pero en un sistema real son mensajes de LLM, resultados de tools, razonamientos intermedios. Con 5 agentes y herramientas que retornan documentos largos, fácilmente llegas a miles de tokens que el último agente no necesita.
Con subagents, cada agente trabaja en un espacio limpio. El contexto del padre permanece enfocado.
Cómo funcionan los subagents
El flujo de un subagent tiene 5 pasos:
- El padre crea una descripción de la subtarea — "Investiga tendencias AI 2025"
- El subagent recibe SOLO esa descripción — no ve la conversación del padre ni el trabajo de otros subagents
- El subagent ejecuta con sus propias tools y prompt — trabaja de forma autónoma en su contexto aislado
- El subagent retorna un resultado conciso — solo el output final, no todo su razonamiento interno
- El padre integra el resultado — lo incorpora a su propio contexto y decide el siguiente paso
La clave: el padre no envía toda su conversación al subagent. Y el subagent no devuelve todo su proceso interno al padre. Ambos mantienen sus contextos separados.
Implementación básica: subagent como tool
El patrón más directo: crear un agente con create_agent y envolverlo como una @tool que el agente padre puede llamar:
from dotenv import load_dotenv
load_dotenv()
from langchain.tools import tool
from langchain.agents import create_agent
research_subagent = create_agent(
"openai:gpt-4.1-mini",
tools=[],
prompt="Eres un investigador especializado. Responde de forma concisa con hallazgos clave.",
)
analysis_subagent = create_agent(
"openai:gpt-4.1-mini",
tools=[],
prompt="Eres un analista de datos. Identifica tendencias y riesgos. Sé conciso.",
)
@tool("research", description="Investiga un tema y retorna hallazgos clave")
def call_researcher(query: str) -> str:
result = research_subagent.invoke({
"messages": [{"role": "user", "content": query}]
})
return result["messages"][-1].content
@tool("analyze", description="Analiza información y retorna tendencias y riesgos")
def call_analyst(data: str) -> str:
result = analysis_subagent.invoke({
"messages": [{"role": "user", "content": data}]
})
return result["messages"][-1].content
supervisor = create_agent(
"openai:gpt-4.1-mini",
tools=[call_researcher, call_analyst],
prompt=(
"Eres un supervisor de investigación. "
"Usa la herramienta 'research' para investigar temas y "
"'analyze' para analizar los hallazgos. "
"Combina los resultados en un reporte final conciso."
),
)
result = supervisor.invoke({
"messages": [{"role": "user", "content": "Investiga y analiza el estado de LLMs en producción"}]
})
for msg in result["messages"]:
msg.pretty_print()
# Output esperado (varía según el modelo):
# ================================ Human Message =================================
# Investiga y analiza el estado de LLMs en producción
# ================================== Ai Message ==================================
# Voy a investigar este tema y luego analizarlo.
# [tool call: research("Estado actual de LLMs en producción empresarial")]
# ================================== Tool Message ================================
# Los LLMs en producción han visto avances significativos: ...
# ================================== Ai Message ==================================
# [tool call: analyze("Los LLMs en producción han visto...")]
# ================================== Tool Message ================================
# Tendencias: adopción creciente. Riesgos: costos y alucinaciones...
# ================================== Ai Message ==================================
# REPORTE: Los LLMs en producción muestran adopción creciente...
El aislamiento de contexto ocurre naturalmente:
- ✅ El
research_subagentsolo ve:"Investiga el estado de LLMs en producción"— no ve la conversación completa del supervisor - ✅ El
analysis_subagentsolo ve los hallazgos que el supervisor le pasa — no ve la pregunta original del usuario ni el razonamiento del supervisor - ✅ El supervisor solo ve los resultados finales de cada subagent — no ve los razonamientos internos de cada uno
Subagent como subgrafo compilado
Cuando necesitas más control sobre el subagent (estado custom, nodos múltiples, flujos internos), puedes crear un StateGraph compilado e invocarlo desde un nodo del grafo padre:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubagentState(TypedDict):
task: str
result: str
class ParentState(TypedDict):
objective: str
research_result: str
analysis_result: str
final_report: str
log: Annotated[list[str], operator.add]
def do_research(state: SubagentState) -> dict:
return {"result": f"Investigación sobre '{state['task']}': 5 fuentes, 3 hallazgos clave"}
def do_analysis(state: SubagentState) -> dict:
return {"result": f"Análisis de '{state['task']}': tendencia alcista, 2 riesgos"}
sub_research_builder = StateGraph(SubagentState)
sub_research_builder.add_node("work", do_research)
sub_research_builder.add_edge(START, "work")
sub_research_builder.add_edge("work", END)
research_subgraph = sub_research_builder.compile()
sub_analysis_builder = StateGraph(SubagentState)
sub_analysis_builder.add_node("work", do_analysis)
sub_analysis_builder.add_edge(START, "work")
sub_analysis_builder.add_edge("work", END)
analysis_subgraph = sub_analysis_builder.compile()
def research_node(state: ParentState) -> dict:
sub_result = research_subgraph.invoke({"task": state["objective"], "result": ""})
return {
"research_result": sub_result["result"],
"log": [f"research subagent retornó: {len(sub_result['result'])} chars"],
}
def analysis_node(state: ParentState) -> dict:
sub_result = analysis_subgraph.invoke({"task": state["research_result"], "result": ""})
return {
"analysis_result": sub_result["result"],
"log": [f"analysis subagent retornó: {len(sub_result['result'])} chars"],
}
def report_node(state: ParentState) -> dict:
report = (
f"REPORTE\n"
f"Objetivo: {state['objective']}\n"
f"Research: {state['research_result']}\n"
f"Analysis: {state['analysis_result']}"
)
return {"final_report": report, "log": ["report generado"]}
builder = StateGraph(ParentState)
builder.add_node("research", research_node)
builder.add_node("analysis", analysis_node)
builder.add_node("report", report_node)
builder.add_edge(START, "research")
builder.add_edge("research", "analysis")
builder.add_edge("analysis", "report")
builder.add_edge("report", END)
graph = builder.compile()
result = graph.invoke({
"objective": "Estado de AI agents en 2025",
"research_result": "", "analysis_result": "",
"final_report": "", "log": [],
})
print(result["final_report"])
print(f"\nFlujo:")
for entry in result["log"]:
print(f" → {entry}")
# Output esperado:
# REPORTE
# Objetivo: Estado de AI agents en 2025
# Research: Investigación sobre 'Estado de AI agents en 2025': 5 fuentes, 3 hallazgos clave
# Analysis: Análisis de 'Investigación sobre 'Estado de AI agents en 2025': 5 fuentes, 3 hallazgos clave': tendencia alcista, 2 riesgos
#
# Flujo:
# → research subagent retornó: 73 chars
# → analysis subagent retornó: 113 chars
# → report generado
El aislamiento es explícito: cada subgrafo tiene su propio SubagentState separado del ParentState. El nodo padre construye el input ({"task": state["objective"]}), invoca el subgrafo, y extrae solo sub_result["result"]. El subgrafo nunca ve ParentState y el padre nunca ve el estado interno del subgrafo.
Aislamiento de contexto: qué ve cada quién
Para que el aislamiento de contexto quede claro, veamos explícitamente qué información tiene disponible cada agente:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
instruction: str
result: str
class ParentState(TypedDict):
user_query: str
conversation_history: list[str]
internal_reasoning: str
sub_result_1: str
sub_result_2: str
final_answer: str
log: Annotated[list[str], operator.add]
def sub_worker(state: SubState) -> dict:
visible_keys = list(state.keys())
result = (
f"Subagent trabajando. "
f"Ve: {visible_keys}. "
f"Instrucción: '{state['instruction']}'. "
f"NO ve: user_query, conversation_history, internal_reasoning"
)
return {"result": result}
sub_builder = StateGraph(SubState)
sub_builder.add_node("work", sub_worker)
sub_builder.add_edge(START, "work")
sub_builder.add_edge("work", END)
subagent = sub_builder.compile()
def step_1(state: ParentState) -> dict:
sub_result = subagent.invoke({
"instruction": "Busca información sobre Python async",
"result": "",
})
return {
"sub_result_1": sub_result["result"],
"conversation_history": state["conversation_history"] + ["step_1 completado"],
"internal_reasoning": "Necesito datos sobre async para responder al usuario",
"log": ["step_1: subagent invocado con contexto aislado"],
}
def step_2(state: ParentState) -> dict:
sub_result = subagent.invoke({
"instruction": "Busca benchmarks de async vs sync en Python",
"result": "",
})
return {
"sub_result_2": sub_result["result"],
"conversation_history": state["conversation_history"] + ["step_2 completado"],
"log": ["step_2: segundo subagent invocado, también aislado"],
}
def synthesize(state: ParentState) -> dict:
parent_sees = {
"user_query": state["user_query"][:30],
"history_length": len(state["conversation_history"]),
"reasoning": state["internal_reasoning"][:30],
"sub_result_1": state["sub_result_1"][:40],
"sub_result_2": state["sub_result_2"][:40],
}
return {
"final_answer": f"Padre ve todo su contexto: {parent_sees}",
"log": ["synthesize: padre integra resultados"],
}
builder = StateGraph(ParentState)
builder.add_node("step_1", step_1)
builder.add_node("step_2", step_2)
builder.add_node("synthesize", synthesize)
builder.add_edge(START, "step_1")
builder.add_edge("step_1", "step_2")
builder.add_edge("step_2", "synthesize")
builder.add_edge("synthesize", END)
graph = builder.compile()
result = graph.invoke({
"user_query": "Explícame async/await en Python",
"conversation_history": [],
"internal_reasoning": "",
"sub_result_1": "", "sub_result_2": "",
"final_answer": "", "log": [],
})
print("=== Qué ve el subagent ===")
print(f" {result['sub_result_1']}\n")
print("=== Qué ve el padre ===")
print(f" {result['final_answer']}\n")
print("=== Flujo ===")
for entry in result["log"]:
print(f" → {entry}")
# Output esperado:
# === Qué ve el subagent ===
# Subagent trabajando. Ve: ['instruction', 'result']. Instrucción: 'Busca información sobre Python async'. NO ve: user_query, conversation_history, internal_reasoning
#
# === Qué ve el padre ===
# Padre ve todo su contexto: {'user_query': 'Explícame async/await en Python', 'history_length': 2, 'reasoning': 'Necesito datos sobre async para', 'sub_result_1': 'Subagent trabajando. Ve: ['instruction', ', 'sub_result_2': 'Subagent trabajando. Ve: ['instruction', '}
#
# === Flujo ===
# → step_1: subagent invocado con contexto aislado
# → step_2: segundo subagent invocado, también aislado
# → synthesize: padre integra resultados
El subagent ve: ['instruction', 'result']. No ve user_query, conversation_history, ni internal_reasoning. El padre ve todo su propio estado y los resultados de los subagents. Cada quién trabaja en su propio espacio.
Subagents en paralelo
Una de las ventajas más prácticas de los subagents: como trabajan en contexto aislado, puedes ejecutar varios al mismo tiempo sin conflictos de estado:
from dotenv import load_dotenv
load_dotenv()
import concurrent.futures
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
source: str
query: str
result: str
class ParentState(TypedDict):
query: str
web_results: str
papers_results: str
docs_results: str
combined_report: str
log: Annotated[list[str], operator.add]
def search_source(state: SubState) -> dict:
return {
"result": f"[{state['source']}] 3 resultados para '{state['query']}'"
}
sub_builder = StateGraph(SubState)
sub_builder.add_node("search", search_source)
sub_builder.add_edge(START, "search")
sub_builder.add_edge("search", END)
search_subagent = sub_builder.compile()
def parallel_research(state: ParentState) -> dict:
sources = [
{"source": "web", "query": state["query"], "result": ""},
{"source": "papers", "query": state["query"], "result": ""},
{"source": "docs", "query": state["query"], "result": ""},
]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(search_subagent.invoke, src): src["source"]
for src in sources
}
results = {}
for future in concurrent.futures.as_completed(futures):
source_name = futures[future]
results[source_name] = future.result()["result"]
return {
"web_results": results["web"],
"papers_results": results["papers"],
"docs_results": results["docs"],
"log": [f"parallel_research: 3 subagents completados ({', '.join(results.keys())})"],
}
def combine_results(state: ParentState) -> dict:
report = (
f"REPORTE COMBINADO para '{state['query']}':\n"
f" Web: {state['web_results']}\n"
f" Papers: {state['papers_results']}\n"
f" Docs: {state['docs_results']}"
)
return {"combined_report": report, "log": ["combine: reporte generado"]}
builder = StateGraph(ParentState)
builder.add_node("parallel_research", parallel_research)
builder.add_node("combine_results", combine_results)
builder.add_edge(START, "parallel_research")
builder.add_edge("parallel_research", "combine_results")
builder.add_edge("combine_results", END)
graph = builder.compile()
result = graph.invoke({
"query": "RAG patterns en producción",
"web_results": "", "papers_results": "", "docs_results": "",
"combined_report": "", "log": [],
})
print(result["combined_report"])
print(f"\nLog:")
for entry in result["log"]:
print(f" → {entry}")
# Output esperado:
# REPORTE COMBINADO para 'RAG patterns en producción':
# Web: [web] 3 resultados para 'RAG patterns en producción'
# Papers: [papers] 3 resultados para 'RAG patterns en producción'
# Docs: [docs] 3 resultados para 'RAG patterns en producción'
#
# Log:
# → parallel_research: 3 subagents completados (web, papers, docs)
# → combine: reporte generado
Los 3 subagents se ejecutan en paralelo con ThreadPoolExecutor. Cada uno trabaja en su propio estado (SubState), busca en su fuente asignada, y retorna su resultado. El nodo padre recopila todos los resultados y los integra. Sin conflictos de estado porque cada subagent tiene su propio espacio.
Single dispatch tool: un tool para N subagents
Cuando tienes muchos subagents, crear un @tool por cada uno escala mal. El patrón single dispatch usa un solo tool parametrizado que invoca al subagent correcto por nombre:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
task: str
result: str
def researcher_work(state: SubState) -> dict:
return {"result": f"[RESEARCH] Investigación sobre: {state['task'][:40]}"}
def writer_work(state: SubState) -> dict:
return {"result": f"[WRITER] Contenido generado para: {state['task'][:40]}"}
def reviewer_work(state: SubState) -> dict:
return {"result": f"[REVIEWER] Review completado de: {state['task'][:40]}"}
def build_subagent(work_fn) -> object:
b = StateGraph(SubState)
b.add_node("work", work_fn)
b.add_edge(START, "work")
b.add_edge("work", END)
return b.compile()
REGISTRY = {
"researcher": build_subagent(researcher_work),
"writer": build_subagent(writer_work),
"reviewer": build_subagent(reviewer_work),
}
def dispatch(agent_name: str, task_description: str) -> str:
"""Invoca un subagent por nombre con aislamiento de contexto."""
if agent_name not in REGISTRY:
return f"Error: agente '{agent_name}' no encontrado. Disponibles: {list(REGISTRY.keys())}"
subagent = REGISTRY[agent_name]
result = subagent.invoke({"task": task_description, "result": ""})
return result["result"]
class OrchestratorState(TypedDict):
objective: str
steps_completed: list[str]
final_output: str
log: Annotated[list[str], operator.add]
def orchestrator(state: OrchestratorState) -> dict:
research = dispatch("researcher", f"Investiga: {state['objective']}")
writing = dispatch("writer", f"Escribe basándote en: {research}")
review = dispatch("reviewer", f"Revisa: {writing}")
return {
"steps_completed": [research, writing, review],
"final_output": review,
"log": [
f"dispatch → researcher: {research[:40]}...",
f"dispatch → writer: {writing[:40]}...",
f"dispatch → reviewer: {review[:40]}...",
],
}
builder = StateGraph(OrchestratorState)
builder.add_node("orchestrator", orchestrator)
builder.add_edge(START, "orchestrator")
builder.add_edge("orchestrator", END)
graph = builder.compile()
result = graph.invoke({
"objective": "Mejores prácticas para deploying AI agents",
"steps_completed": [], "final_output": "", "log": [],
})
print(f"Output final: {result['final_output']}")
print(f"\nPasos completados:")
for step in result["steps_completed"]:
print(f" {step}")
print(f"\nLog:")
for entry in result["log"]:
print(f" → {entry}")
# Output esperado:
# Output final: [REVIEWER] Review completado de: [WRITER] Contenido generado para: [RES
#
# Pasos completados:
# [RESEARCH] Investigación sobre: Investiga: Mejores prácticas para deployi
# [WRITER] Contenido generado para: Escribe basándote en: [RESEARCH] Invest
# [REVIEWER] Review completado de: Revisa: [WRITER] Contenido generado para
#
# Log:
# → dispatch → researcher: [RESEARCH] Investigación sobre: Inves...
# → dispatch → writer: [WRITER] Contenido generado para: Escrib...
# → dispatch → reviewer: [REVIEWER] Review completado de: Revis...
Un solo dispatch(agent_name, task) invoca cualquier subagent registrado. Para agregar un nuevo subagent, solo lo registras en REGISTRY. El orquestador no necesita cambiar.
Con create_agent, el pattern es idéntico pero los subagents son agentes con LLM:
from langchain.tools import tool
from langchain.agents import create_agent
SUBAGENTS = {
"researcher": create_agent("openai:gpt-4.1-mini", prompt="Eres un investigador..."),
"writer": create_agent("openai:gpt-4.1-mini", prompt="Eres un escritor..."),
}
@tool
def task(agent_name: str, description: str) -> str:
"""Lanza un subagent para una tarea específica."""
agent = SUBAGENTS[agent_name]
result = agent.invoke({"messages": [{"role": "user", "content": description}]})
return result["messages"][-1].content
Cuándo usar subagents vs handoffs
| Criterio | Subagents | Handoffs |
|---|---|---|
| Contexto | Aislado — cada subagent ve solo su tarea | Compartido — el contexto fluye entre agentes |
| Context bloat | No — el padre solo ve resultados finales | Sí — crece con cada handoff |
| Ejecución paralela | Natural — subagents independientes | Difícil — el flujo es secuencial |
| Interacción con usuario | No directa — pasan por el padre | Directa — cada agente puede interactuar |
| Coordinación | Centralizada en el padre | Distribuida entre agentes |
| Caso ideal | Muchas subtareas independientes | Flujo secuencial con contexto compartido |
| Ejemplo | "Busca en 3 fuentes en paralelo" | "Investiga → analiza → escribe" |
Regla práctica:
- ✅ Usa subagents cuando las subtareas son independientes y no necesitan verse entre sí
- ✅ Usa handoffs cuando el flujo es secuencial y cada agente necesita el contexto del anterior
- ✅ Combina ambos: handoffs para el flujo principal, subagents para subtareas internas de cada agente
Manejo de errores y timeouts en subagents
Los subagents pueden fallar: el LLM no responde, una tool da error, o el subagent se queda en un loop infinito. El padre necesita manejar estos casos:
from dotenv import load_dotenv
load_dotenv()
import concurrent.futures
from typing import TypedDict, Annotated
import operator
import time
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
task: str
result: str
class ParentState(TypedDict):
query: str
results: dict
errors: dict
final_report: str
log: Annotated[list[str], operator.add]
def fast_agent(state: SubState) -> dict:
return {"result": f"Resultado rápido para: {state['task'][:30]}"}
def slow_agent(state: SubState) -> dict:
time.sleep(0.5)
return {"result": f"Resultado lento para: {state['task'][:30]}"}
def failing_agent(state: SubState) -> dict:
raise ValueError(f"Error procesando: {state['task'][:20]}")
fast_sub = StateGraph(SubState)
fast_sub.add_node("work", fast_agent)
fast_sub.add_edge(START, "work")
fast_sub.add_edge("work", END)
fast_subagent = fast_sub.compile()
slow_sub = StateGraph(SubState)
slow_sub.add_node("work", slow_agent)
slow_sub.add_edge(START, "work")
slow_sub.add_edge("work", END)
slow_subagent = slow_sub.compile()
fail_sub = StateGraph(SubState)
fail_sub.add_node("work", failing_agent)
fail_sub.add_edge(START, "work")
fail_sub.add_edge("work", END)
failing_subagent = fail_sub.compile()
AGENTS = {
"fast": fast_subagent,
"slow": slow_subagent,
"failing": failing_subagent,
}
def safe_invoke(agent_name: str, task: str, timeout: float = 2.0) -> dict:
"""Invoca un subagent con timeout y manejo de errores."""
agent = AGENTS[agent_name]
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(agent.invoke, {"task": task, "result": ""})
result = future.result(timeout=timeout)
return {"success": True, "result": result["result"]}
except concurrent.futures.TimeoutError:
return {"success": False, "result": f"TIMEOUT: {agent_name} excedió {timeout}s"}
except Exception as e:
return {"success": False, "result": f"ERROR en {agent_name}: {str(e)[:50]}"}
def orchestrate(state: ParentState) -> dict:
agents_to_run = ["fast", "slow", "failing"]
results = {}
errors = {}
for name in agents_to_run:
outcome = safe_invoke(name, state["query"])
if outcome["success"]:
results[name] = outcome["result"]
else:
errors[name] = outcome["result"]
report_parts = [f"Query: {state['query']}"]
if results:
report_parts.append(f"Exitosos ({len(results)}): {list(results.keys())}")
if errors:
report_parts.append(f"Fallidos ({len(errors)}): {list(errors.keys())}")
return {
"results": results,
"errors": errors,
"final_report": " | ".join(report_parts),
"log": [
f"exitosos: {list(results.keys())}",
f"errores: {list(errors.keys())}",
],
}
builder = StateGraph(ParentState)
builder.add_node("orchestrate", orchestrate)
builder.add_edge(START, "orchestrate")
builder.add_edge("orchestrate", END)
graph = builder.compile()
result = graph.invoke({
"query": "Estado de AI agents",
"results": {}, "errors": {},
"final_report": "", "log": [],
})
print(f"Reporte: {result['final_report']}")
print(f"\nResultados exitosos:")
for name, res in result["results"].items():
print(f" ✅ {name}: {res}")
print(f"\nErrores:")
for name, err in result["errors"].items():
print(f" ❌ {name}: {err}")
# Output esperado:
# Reporte: Query: Estado de AI agents | Exitosos (2): ['fast', 'slow'] | Fallidos (1): ['failing']
#
# Resultados exitosos:
# ✅ fast: Resultado rápido para: Estado de AI agents
# ✅ slow: Resultado lento para: Estado de AI agents
#
# Errores:
# ❌ failing: ERROR en failing: Error procesando: Estado de AI agents
La función safe_invoke envuelve cada subagent con:
- ✅ Timeout: Si el subagent no responde en N segundos, retorna error
- ✅ Try/except: Captura cualquier excepción del subagent
- ✅ Resultado estructurado:
{"success": bool, "result": str}— el padre sabe qué funcionó y qué no
El padre decide qué hacer con los errores: reintentar, usar resultados parciales, o abortar.
Troubleshooting
Problema 1: "El subagent retorna demasiada información"
Síntoma: El resultado del subagent incluye todo su razonamiento interno, tool calls, y mensajes intermedios. El contexto del padre crece como si fuera un handoff.
Causa: Estás retornando todo el estado del subagent en lugar de solo el resultado final.
Solución: Extrae solo el contenido del último mensaje:
result = subagent.invoke({"messages": [{"role": "user", "content": task}]})
return result["messages"][-1].content
Problema 2: "Subagents en paralelo causan errores de estado"
Síntoma: Al ejecutar subagents en paralelo, algunos fallan o retornan resultados corruptos.
Causa: Los subagents comparten algún recurso mutable (variable global, archivo, conexión).
Solución: Asegúrate de que cada subagent sea verdaderamente independiente. Cada invocación debe crear su propio estado:
result = subagent.invoke({"task": task, "result": ""})
Problema 3: "El subagent no tiene las tools que necesita"
Síntoma: El subagent no puede completar su tarea porque le faltan herramientas.
Causa: Cada subagent tiene su propio conjunto de tools. No hereda las tools del padre.
Solución: Define las tools del subagent al crearlo:
research_subagent = create_agent(
"openai:gpt-4.1-mini",
tools=[web_search, paper_search],
prompt="Eres un investigador con acceso a búsqueda web y papers.",
)
Problema 4: "No puedo debuggear qué hizo el subagent"
Síntoma: El subagent retorna un resultado incorrecto pero no sabes qué pasó internamente.
Causa: Por diseño, el aislamiento de contexto oculta los detalles internos del subagent.
Solución: Agrega logging dentro del subagent, o usa LangSmith para trazar la ejecución completa. También puedes retornar metadata adicional junto con el resultado:
return {
"result": final_answer,
"metadata": {"steps_taken": 3, "tools_used": ["web_search"]}
}
Ejercicios
Ejercicio 1: Subagent básico con contexto aislado (Fácil)
Crea un sistema padre-hijo simple. El padre tiene un query y un secret_context (información interna). El subagent solo recibe el query y retorna un resultado. Verifica que el subagent NO puede ver secret_context.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
query: str
result: str
class ParentState(TypedDict):
user_query: str
secret_context: str
sub_result: str
log: Annotated[list[str], operator.add]
def subagent_work(state: SubState) -> dict:
visible = list(state.keys())
return {"result": f"Subagent ve claves: {visible}. Procesó: '{state['query'][:30]}'"}
sub_builder = StateGraph(SubState)
sub_builder.add_node("work", subagent_work)
sub_builder.add_edge(START, "work")
sub_builder.add_edge("work", END)
subagent = sub_builder.compile()
def parent_node(state: ParentState) -> dict:
result = subagent.invoke({"query": state["user_query"], "result": ""})
return {
"sub_result": result["result"],
"log": [f"subagent retornó: {result['result'][:50]}..."],
}
builder = StateGraph(ParentState)
builder.add_node("parent", parent_node)
builder.add_edge(START, "parent")
builder.add_edge("parent", END)
graph = builder.compile()
result = graph.invoke({
"user_query": "¿Qué es machine learning?",
"secret_context": "CONFIDENCIAL: presupuesto interno $500k",
"sub_result": "", "log": [],
})
print(f"Resultado: {result['sub_result']}")
print(f"Secret context intacto: {result['secret_context'][:20]}...")
# Output esperado:
# Resultado: Subagent ve claves: ['query', 'result']. Procesó: '¿Qué es machine learning?'
# Secret context intacto: CONFIDENCIAL: presup...
Ejercicio 2: Tres subagents en paralelo (Medio)
Crea un sistema donde el padre lanza 3 subagents en paralelo: news_agent (busca noticias), social_agent (busca redes sociales), y academic_agent (busca papers). Usa ThreadPoolExecutor para ejecutarlos simultáneamente. Combina los resultados en un resumen.
Ver solución
from dotenv import load_dotenv
load_dotenv()
import concurrent.futures
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
query: str
source_type: str
result: str
class ParentState(TypedDict):
topic: str
news: str
social: str
academic: str
summary: str
log: Annotated[list[str], operator.add]
def search_source(state: SubState) -> dict:
return {"result": f"[{state['source_type'].upper()}] 5 resultados para '{state['query'][:25]}...'"}
sub_builder = StateGraph(SubState)
sub_builder.add_node("search", search_source)
sub_builder.add_edge(START, "search")
sub_builder.add_edge("search", END)
search_sub = sub_builder.compile()
def parallel_search(state: ParentState) -> dict:
configs = [
{"query": state["topic"], "source_type": "news", "result": ""},
{"query": state["topic"], "source_type": "social", "result": ""},
{"query": state["topic"], "source_type": "academic", "result": ""},
]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(search_sub.invoke, c): c["source_type"] for c in configs}
results = {}
for f in concurrent.futures.as_completed(futures):
name = futures[f]
results[name] = f.result()["result"]
return {
"news": results["news"],
"social": results["social"],
"academic": results["academic"],
"log": [f"parallel: {len(results)} fuentes completadas"],
}
def summarize(state: ParentState) -> dict:
summary = (
f"RESUMEN sobre '{state['topic']}':\n"
f" {state['news']}\n"
f" {state['social']}\n"
f" {state['academic']}"
)
return {"summary": summary, "log": ["resumen generado"]}
builder = StateGraph(ParentState)
builder.add_node("search", parallel_search)
builder.add_node("summarize", summarize)
builder.add_edge(START, "search")
builder.add_edge("search", "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()
result = graph.invoke({
"topic": "LangGraph multi-agent patterns",
"news": "", "social": "", "academic": "",
"summary": "", "log": [],
})
print(result["summary"])
print(f"\nLog: {result['log']}")
# Output esperado:
# RESUMEN sobre 'LangGraph multi-agent patterns':
# [NEWS] 5 resultados para 'LangGraph multi-agent pat...'
# [SOCIAL] 5 resultados para 'LangGraph multi-agent pat...'
# [ACADEMIC] 5 resultados para 'LangGraph multi-agent pat...'
#
# Log: ['parallel: 3 fuentes completadas', 'resumen generado']
Ejercicio 3: Registry con dispatch y manejo de errores (Medio)
Crea un registry de 3 subagents (translator, summarizer, formatter). Implementa una función safe_dispatch que invoque el subagent por nombre con try/except. El orquestador debe llamar a los 3 en secuencia, manejando errores individuales sin abortar todo el flujo.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
input_text: str
result: str
def translate(state: SubState) -> dict:
return {"result": f"[EN] Translated: {state['input_text'][:30]}..."}
def summarize(state: SubState) -> dict:
return {"result": f"[SUMMARY] Key points from: {state['input_text'][:30]}..."}
def format_output(state: SubState) -> dict:
raise ValueError("Formatter temporalmente no disponible")
def build_sub(fn):
b = StateGraph(SubState)
b.add_node("work", fn)
b.add_edge(START, "work")
b.add_edge("work", END)
return b.compile()
REGISTRY = {
"translator": build_sub(translate),
"summarizer": build_sub(summarize),
"formatter": build_sub(format_output),
}
def safe_dispatch(name: str, text: str) -> dict:
try:
result = REGISTRY[name].invoke({"input_text": text, "result": ""})
return {"success": True, "agent": name, "result": result["result"]}
except Exception as e:
return {"success": False, "agent": name, "result": f"ERROR: {str(e)[:40]}"}
class OrcState(TypedDict):
text: str
results: list[dict]
log: Annotated[list[str], operator.add]
def orchestrate(state: OrcState) -> dict:
pipeline = ["translator", "summarizer", "formatter"]
results = []
current = state["text"]
for agent_name in pipeline:
outcome = safe_dispatch(agent_name, current)
results.append(outcome)
if outcome["success"]:
current = outcome["result"]
successes = [r["agent"] for r in results if r["success"]]
failures = [r["agent"] for r in results if not r["success"]]
return {
"results": results,
"log": [
f"completados: {successes}",
f"fallidos: {failures}",
],
}
builder = StateGraph(OrcState)
builder.add_node("orchestrate", orchestrate)
builder.add_edge(START, "orchestrate")
builder.add_edge("orchestrate", END)
graph = builder.compile()
result = graph.invoke({"text": "Los agentes de IA están transformando la industria", "results": [], "log": []})
for r in result["results"]:
status = "✅" if r["success"] else "❌"
print(f" {status} {r['agent']}: {r['result'][:50]}...")
print(f"\nLog: {result['log']}")
# Output esperado:
# ✅ translator: [EN] Translated: Los agentes de IA están trans...
# ✅ summarizer: [SUMMARY] Key points from: [EN] Translated: Los agent...
# ❌ formatter: ERROR: Formatter temporalmente no disponib...
#
# Log: ['completados: ['translator', 'summarizer']', "fallidos: ['formatter']"]
Ejercicio 4: Subagents con agregación de resultados (Avanzado)
Crea un sistema de "voting" donde 3 subagents analizan el mismo texto de forma independiente y retornan una clasificación (positivo, negativo, neutro). El padre agrega los votos y decide la clasificación final por mayoría. Implementa los subagents en paralelo.
Ver solución
from dotenv import load_dotenv
load_dotenv()
import concurrent.futures
from collections import Counter
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class SubState(TypedDict):
text: str
analyst_id: int
classification: str
class ParentState(TypedDict):
text: str
votes: list[str]
final_classification: str
confidence: float
log: Annotated[list[str], operator.add]
ANALYST_BIASES = {
1: ["innovación", "crecimiento", "oportunidad"],
2: ["riesgo", "costo", "problema"],
3: [],
}
def classify(state: SubState) -> dict:
text_lower = state["text"].lower()
analyst_id = state["analyst_id"]
positive_kw = ["bueno", "éxito", "crecimiento", "innovación", "mejora"]
negative_kw = ["malo", "fracaso", "riesgo", "problema", "costo"]
bias = ANALYST_BIASES.get(analyst_id, [])
pos_score = sum(1 for w in positive_kw + bias if w in text_lower)
neg_score = sum(1 for w in negative_kw + bias if w in text_lower)
if pos_score > neg_score:
return {"classification": "positivo"}
elif neg_score > pos_score:
return {"classification": "negativo"}
return {"classification": "neutro"}
sub_builder = StateGraph(SubState)
sub_builder.add_node("classify", classify)
sub_builder.add_edge(START, "classify")
sub_builder.add_edge("classify", END)
classifier = sub_builder.compile()
def parallel_classify(state: ParentState) -> dict:
configs = [
{"text": state["text"], "analyst_id": i, "classification": ""}
for i in range(1, 4)
]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(classifier.invoke, c) for c in configs]
votes = [f.result()["classification"] for f in futures]
counts = Counter(votes)
winner = counts.most_common(1)[0]
confidence = winner[1] / len(votes)
return {
"votes": votes,
"final_classification": winner[0],
"confidence": confidence,
"log": [
f"votos: {votes}",
f"resultado: {winner[0]} ({confidence:.0%} confianza)",
],
}
builder = StateGraph(ParentState)
builder.add_node("classify", parallel_classify)
builder.add_edge(START, "classify")
builder.add_edge("classify", END)
graph = builder.compile()
texts = [
"Gran innovación en AI, crecimiento del 40% y mejora continua",
"Alto riesgo y costo, el problema persiste sin solución clara",
"El mercado se mantiene estable sin cambios significativos",
]
for text in texts:
result = graph.invoke({
"text": text, "votes": [],
"final_classification": "", "confidence": 0.0, "log": [],
})
print(f"Texto: {text[:50]}...")
print(f" Votos: {result['votes']}")
print(f" Clasificación: {result['final_classification']} ({result['confidence']:.0%})\n")
# Output esperado:
# Texto: Gran innovación en AI, crecimiento del 40% y mejor...
# Votos: ['positivo', 'positivo', 'positivo']
# Clasificación: positivo (100%)
#
# Texto: Alto riesgo y costo, el problema persiste sin soluc...
# Votos: ['negativo', 'negativo', 'negativo']
# Clasificación: negativo (100%)
#
# Texto: El mercado se mantiene estable sin cambios signific...
# Votos: ['neutro', 'neutro', 'neutro']
# Clasificación: neutro (100%)
Resumen
En esta cápsula aprendiste:
- Los subagents trabajan en contexto aislado — reciben solo la descripción de su tarea, no la conversación completa del padre. Esto previene el context bloat que ocurre con handoffs y estado compartido
- El patrón básico es envolver un agente como
@tool—create_agentcrea el subagent,@toollo expone al padre. El padre llama la tool, el subagent ejecuta en su contexto, y retorna solo el resultado - Subgrafos compilados dan más control — para subagents con flujos internos complejos, crea un
StateGraphindependiente con su propioSubStatee invócalo desde un nodo del padre - La ejecución paralela es natural — como los subagents tienen contexto aislado, puedes ejecutar varios simultáneamente con
ThreadPoolExecutorsin conflictos de estado - El single dispatch pattern escala mejor — un solo
dispatch(agent_name, task)con un registry reemplaza N tools individuales cuando tienes muchos subagents - El manejo de errores es responsabilidad del padre — usa
safe_invokecon try/except y timeouts para que un subagent fallido no tumbe todo el sistema - Subagents vs handoffs: usa subagents cuando necesitas aislamiento de contexto y ejecución paralela; usa handoffs cuando necesitas contexto compartido en flujo secuencial
Próxima cápsula: Diseño de estado en multi-agent — cómo diseñar el estado compartido entre agentes, manejar conflictos, y decidir qué información comparten vs qué mantienen aislado.
Recursos adicionales
- LangChain — Subagents — Documentación oficial del patrón subagents
- LangGraph — Use Subgraphs — Cómo usar subgrafos compilados como nodos
- LangGraph — Functional API —
@tasky@entrypointpara subagents con aislamiento automático - Context Engineering for Agents — Diseño del flujo de contexto entre agentes
- LangGraph — Multi-Agent Systems — Visión general de patrones multi-agente
Módulo 10 — LangChain & LangGraph: From Chains to Agents