Módulo 7: Flujos Avanzados
Subgraphs: Composición Modular
Descripción de la cápsula
Tu patrón "buscar + parsear + resumir" se repite para cada fuente. Tienes los mismos 3 pasos (fetch → parse → summarize) duplicados 3 veces — una vez para web, otra para academic, otra para news. Si necesitas cambiar la lógica de parsing, tienes que modificar 3 lugares. Si agregas una cuarta fuente, copias y pegas los mismos 3 nodos otra vez.
¿Y si pudieras definir ese pipeline una sola vez y reutilizarlo? Así como una función puede llamar a otra función, un grafo puede contener otro grafo. Encapsulas lógica compleja en un grafo independiente y lo usas como nodo dentro de un grafo más grande. Eso es un subgraph.
Los subgraphs son el equivalente de funciones en programación modular: encapsulan, abstraen y permiten reutilización. La diferencia con una función regular es que un subgraph te da checkpointing por cada paso interno, visualización del flujo, y streaming — cosas que una función Python normal no ofrece.
El problema: lógica duplicada
Observa este grafo con 3 pipelines de búsqueda idénticos en estructura:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
query: str
results: Annotated[list[str], operator.add]
def fetch_web(state: State) -> dict:
return {"results": [f"[fetch_web] Raw data from web for '{state['query']}'"]}
def parse_web(state: State) -> dict:
raw = state["results"][-1]
return {"results": [f"[parse_web] Parsed: {raw}"]}
def summarize_web(state: State) -> dict:
parsed = state["results"][-1]
return {"results": [f"[summarize_web] Summary: {parsed}"]}
def fetch_academic(state: State) -> dict:
return {"results": [f"[fetch_academic] Raw data from academic for '{state['query']}'"]}
def parse_academic(state: State) -> dict:
raw = state["results"][-1]
return {"results": [f"[parse_academic] Parsed: {raw}"]}
def summarize_academic(state: State) -> dict:
parsed = state["results"][-1]
return {"results": [f"[summarize_academic] Summary: {parsed}"]}
def fetch_news(state: State) -> dict:
return {"results": [f"[fetch_news] Raw data from news for '{state['query']}'"]}
def parse_news(state: State) -> dict:
raw = state["results"][-1]
return {"results": [f"[parse_news] Parsed: {raw}"]}
def summarize_news(state: State) -> dict:
parsed = state["results"][-1]
return {"results": [f"[summarize_news] Summary: {parsed}"]}
graph = StateGraph(State)
graph.add_node("fetch_web", fetch_web)
graph.add_node("parse_web", parse_web)
graph.add_node("summarize_web", summarize_web)
graph.add_node("fetch_academic", fetch_academic)
graph.add_node("parse_academic", parse_academic)
graph.add_node("summarize_academic", summarize_academic)
graph.add_node("fetch_news", fetch_news)
graph.add_node("parse_news", parse_news)
graph.add_node("summarize_news", summarize_news)
graph.add_edge(START, "fetch_web")
graph.add_edge("fetch_web", "parse_web")
graph.add_edge("parse_web", "summarize_web")
graph.add_edge("summarize_web", "fetch_academic")
graph.add_edge("fetch_academic", "parse_academic")
graph.add_edge("parse_academic", "summarize_academic")
graph.add_edge("summarize_academic", "fetch_news")
graph.add_edge("fetch_news", "parse_news")
graph.add_edge("parse_news", "summarize_news")
graph.add_edge("summarize_news", END)
app = graph.compile()
result = app.invoke({"query": "Python 3.12", "results": []})
print(f"Nodos totales: 9")
print(f"Resultados: {len(result['results'])}")
# Output esperado:
# Nodos totales: 9
# Resultados: 9
9 nodos para 3 pipelines que hacen lo mismo con datos diferentes. Si necesitas agregar validación entre parse y summarize, tienes que tocar 3 lugares. Si necesitas una cuarta fuente, copias 3 nodos más. Esto no escala.
Subgraphs = funciones: la analogía fundamental
Piensa en funciones de Python:
def process_source(source_name: str, query: str) -> str:
raw = fetch(source_name, query)
parsed = parse(raw)
summary = summarize(parsed)
return summary
result_web = process_source("web", "Python 3.12")
result_academic = process_source("academic", "Python 3.12")
result_news = process_source("news", "Python 3.12")
Defines la lógica una vez y la llamas 3 veces con diferentes argumentos. Un subgraph hace exactamente lo mismo, pero con las ventajas de LangGraph:
| Función Python | Subgraph LangGraph |
|---|---|
| Define lógica reutilizable | Define lógica reutilizable |
| Se llama con argumentos | Se llama con estado de entrada |
| Retorna un valor | Retorna estado de salida |
| Sin checkpointing | Checkpoint en cada paso interno |
| Sin visualización | draw_mermaid_png() muestra el flujo interno |
| Sin streaming | Puedes hacer stream de pasos internos |
| Si falla a la mitad, re-ejecuta todo | Si falla a la mitad, resume desde el último checkpoint |
Crear un subgraph: define un StateGraph completo
Un subgraph es un StateGraph normal — lo defines, le agregas nodos y edges, y lo compilas. La única diferencia es que después lo usas como nodo dentro de otro grafo:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class SearchPipelineState(TypedDict):
source_name: str
query: str
raw_data: str
parsed_data: str
summary: str
def fetch(state: SearchPipelineState) -> dict:
return {"raw_data": f"Raw data from {state['source_name']} for '{state['query']}'"}
def parse(state: SearchPipelineState) -> dict:
return {"parsed_data": f"Parsed: {state['raw_data']}"}
def summarize(state: SearchPipelineState) -> dict:
return {"summary": f"Summary of {state['source_name']}: {state['parsed_data'][:50]}..."}
search_pipeline = StateGraph(SearchPipelineState)
search_pipeline.add_node("fetch", fetch)
search_pipeline.add_node("parse", parse)
search_pipeline.add_node("summarize", summarize)
search_pipeline.add_edge(START, "fetch")
search_pipeline.add_edge("fetch", "parse")
search_pipeline.add_edge("parse", "summarize")
search_pipeline.add_edge("summarize", END)
search_app = search_pipeline.compile()
result = search_app.invoke({
"source_name": "web",
"query": "Python 3.12",
"raw_data": "",
"parsed_data": "",
"summary": "",
})
print(result["summary"])
# Output esperado:
# Summary of web: Parsed: Raw data from web for 'Python 3.12'...
Este grafo funciona de forma independiente. Tiene su propio estado (SearchPipelineState), sus propios nodos, y se puede invocar directamente. Ahora lo vamos a usar como nodo de un grafo padre.
Usar un subgraph como nodo
Para usar un subgraph compilado como nodo, lo pasas directamente a add_node:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from IPython.display import Image, display
class SearchPipelineState(TypedDict):
source_name: str
query: str
raw_data: str
parsed_data: str
summary: str
def fetch(state: SearchPipelineState) -> dict:
return {"raw_data": f"Raw data from {state['source_name']} for '{state['query']}'"}
def parse(state: SearchPipelineState) -> dict:
return {"parsed_data": f"Parsed: {state['raw_data']}"}
def summarize(state: SearchPipelineState) -> dict:
return {"summary": f"Summary of {state['source_name']}: {state['parsed_data'][:50]}..."}
search_pipeline = StateGraph(SearchPipelineState)
search_pipeline.add_node("fetch", fetch)
search_pipeline.add_node("parse", parse)
search_pipeline.add_node("summarize", summarize)
search_pipeline.add_edge(START, "fetch")
search_pipeline.add_edge("fetch", "parse")
search_pipeline.add_edge("parse", "summarize")
search_pipeline.add_edge("summarize", END)
search_subgraph = search_pipeline.compile()
class ParentState(TypedDict):
query: str
source_name: str
raw_data: str
parsed_data: str
summary: str
all_summaries: Annotated[list[str], operator.add]
def collect_summary(state: ParentState) -> dict:
return {"all_summaries": [state["summary"]]}
parent = StateGraph(ParentState)
parent.add_node("search_pipeline", search_subgraph)
parent.add_node("collect", collect_summary)
parent.add_edge(START, "search_pipeline")
parent.add_edge("search_pipeline", "collect")
parent.add_edge("collect", END)
app = parent.compile()
display(Image(app.get_graph().draw_mermaid_png()))
result = app.invoke({
"query": "LangGraph subgraphs",
"source_name": "web",
"raw_data": "",
"parsed_data": "",
"summary": "",
"all_summaries": [],
})
print(f"Summary: {result['summary']}")
print(f"All summaries: {result['all_summaries']}")
# Output esperado:
# Summary: Summary of web: Parsed: Raw data from web for 'LangGraph subgrap...
# All summaries: ['Summary of web: Parsed: Raw data from web for \'LangGraph subgrap...']
parent.add_node("search_pipeline", search_subgraph) — el grafo compilado se usa directamente como nodo. LangGraph se encarga de ejecutar todos los nodos internos del subgraph cuando llega el turno de ese "nodo" en el grafo padre.
Interfaz de estado entre padre y subgraph
Cuando el padre y el subgraph comparten los mismos campos en su TypedDict, LangGraph mapea el estado automáticamente. Los campos que tienen el mismo nombre se pasan directamente:
class SearchPipelineState(TypedDict):
source_name: str # ← compartido con padre
query: str # ← compartido con padre
raw_data: str # interno del subgraph
parsed_data: str # interno del subgraph
summary: str # ← compartido con padre
class ParentState(TypedDict):
query: str # ← compartido con subgraph
source_name: str # ← compartido con subgraph
summary: str # ← compartido con subgraph
all_summaries: Annotated[list[str], operator.add] # solo del padre
Los campos que existen en ambos (query, source_name, summary) se comparten. Los campos que solo existen en el subgraph (raw_data, parsed_data) son internos — el padre no los ve. Los campos que solo existen en el padre (all_summaries) no afectan al subgraph.
Regla de mapeo
| Campo | En padre | En subgraph | Comportamiento |
|---|---|---|---|
query | ✅ | ✅ | Se pasa al subgraph, el subgraph lo recibe |
source_name | ✅ | ✅ | Se pasa al subgraph, el subgraph lo recibe |
summary | ✅ | ✅ | El subgraph lo escribe, el padre lo recibe de vuelta |
raw_data | ❌ | ✅ | Interno del subgraph, el padre no lo ve |
all_summaries | ✅ | ❌ | Solo del padre, el subgraph no lo afecta |
Estado diferente: subgraph con su propio TypedDict
A veces el subgraph necesita un estado completamente diferente al del padre. En ese caso, usas una función wrapper que traduce entre estados:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class AnalysisState(TypedDict):
text: str
word_count: int
sentiment: str
def count_words(state: AnalysisState) -> dict:
return {"word_count": len(state["text"].split())}
def detect_sentiment(state: AnalysisState) -> dict:
text = state["text"].lower()
if any(w in text for w in ["great", "excellent", "amazing", "good"]):
return {"sentiment": "positive"}
elif any(w in text for w in ["bad", "terrible", "awful", "poor"]):
return {"sentiment": "negative"}
return {"sentiment": "neutral"}
analysis_graph = StateGraph(AnalysisState)
analysis_graph.add_node("count", count_words)
analysis_graph.add_node("sentiment", detect_sentiment)
analysis_graph.add_edge(START, "count")
analysis_graph.add_edge("count", "sentiment")
analysis_graph.add_edge("sentiment", END)
analysis_subgraph = analysis_graph.compile()
class ParentState(TypedDict):
query: str
search_result: str
analysis: dict
summaries: Annotated[list[str], operator.add]
def search(state: ParentState) -> dict:
return {"search_result": f"Great results about '{state['query']}': Python is an amazing language."}
def analyze_wrapper(state: ParentState) -> dict:
analysis_input = {"text": state["search_result"], "word_count": 0, "sentiment": ""}
result = analysis_subgraph.invoke(analysis_input)
return {"analysis": {"word_count": result["word_count"], "sentiment": result["sentiment"]}}
def report(state: ParentState) -> dict:
a = state["analysis"]
return {"summaries": [f"Analysis: {a['word_count']} words, sentiment: {a['sentiment']}"]}
parent = StateGraph(ParentState)
parent.add_node("search", search)
parent.add_node("analyze", analyze_wrapper)
parent.add_node("report", report)
parent.add_edge(START, "search")
parent.add_edge("search", "analyze")
parent.add_edge("analyze", "report")
parent.add_edge("report", END)
app = parent.compile()
result = app.invoke({"query": "Python", "search_result": "", "analysis": {}, "summaries": []})
print(f"Result: {result['analysis']}")
print(f"Report: {result['summaries']}")
# Output esperado:
# Result: {'word_count': 9, 'sentiment': 'positive'}
# Report: ["Analysis: 9 words, sentiment: positive"]
La función analyze_wrapper traduce del estado del padre al estado del subgraph, invoca el subgraph, y traduce el resultado de vuelta. Es la misma idea que un adapter pattern — un puente entre dos interfaces diferentes.
Cuándo usar estados compartidos vs diferentes
| Criterio | Estados compartidos | Estados diferentes + wrapper |
|---|---|---|
| Campos en común | Muchos (>50%) | Pocos o ninguno |
| Acoplamiento | Alto (padre y subgraph están ligados) | Bajo (cada uno tiene su interfaz) |
| Simplicidad | Más simple, menos código | Más código, pero más flexible |
| Reutilización | El subgraph asume la estructura del padre | El subgraph es completamente independiente |
Reutilización: un subgraph usado múltiples veces
El verdadero poder de los subgraphs es que los defines una vez y los reutilizas. Aquí usamos el mismo pipeline de búsqueda para 3 fuentes diferentes:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from IPython.display import Image, display
class SearchState(TypedDict):
source_name: str
query: str
raw_data: str
parsed_data: str
summary: str
def fetch_data(state: SearchState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Simula datos crudos de la fuente '{state['source_name']}' sobre '{state['query']}'. "
f"Genera 2-3 oraciones de datos sin procesar."
)
return {"raw_data": response.content}
def parse_data(state: SearchState) -> dict:
return {"parsed_data": f"[{state['source_name']}] {state['raw_data'][:200]}"}
def summarize_data(state: SearchState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(f"Resume en 1 oración: {state['parsed_data']}")
return {"summary": response.content}
search_pipeline = StateGraph(SearchState)
search_pipeline.add_node("fetch", fetch_data)
search_pipeline.add_node("parse", parse_data)
search_pipeline.add_node("summarize", summarize_data)
search_pipeline.add_edge(START, "fetch")
search_pipeline.add_edge("fetch", "parse")
search_pipeline.add_edge("parse", "summarize")
search_pipeline.add_edge("summarize", END)
search_subgraph = search_pipeline.compile()
class OrchestratorState(TypedDict):
query: str
source_name: str
raw_data: str
parsed_data: str
summary: str
all_summaries: Annotated[list[dict], operator.add]
def set_source(name: str):
def node(state: OrchestratorState) -> dict:
return {"source_name": name}
return node
def collect_result(state: OrchestratorState) -> dict:
return {"all_summaries": [{"source": state["source_name"], "summary": state["summary"]}]}
parent = StateGraph(OrchestratorState)
parent.add_node("set_web", set_source("web"))
parent.add_node("pipeline_web", search_subgraph)
parent.add_node("collect_web", collect_result)
parent.add_node("set_academic", set_source("academic"))
parent.add_node("pipeline_academic", search_subgraph)
parent.add_node("collect_academic", collect_result)
parent.add_node("set_news", set_source("news"))
parent.add_node("pipeline_news", search_subgraph)
parent.add_node("collect_news", collect_result)
parent.add_edge(START, "set_web")
parent.add_edge("set_web", "pipeline_web")
parent.add_edge("pipeline_web", "collect_web")
parent.add_edge(START, "set_academic")
parent.add_edge("set_academic", "pipeline_academic")
parent.add_edge("pipeline_academic", "collect_academic")
parent.add_edge(START, "set_news")
parent.add_edge("set_news", "pipeline_news")
parent.add_edge("pipeline_news", "collect_news")
parent.add_edge("collect_web", END)
parent.add_edge("collect_academic", END)
parent.add_edge("collect_news", END)
app = parent.compile()
display(Image(app.get_graph().draw_mermaid_png()))
result = app.invoke({
"query": "RAG techniques",
"source_name": "", "raw_data": "", "parsed_data": "", "summary": "",
"all_summaries": [],
})
print(f"Fuentes procesadas: {len(result['all_summaries'])}")
for s in result["all_summaries"]:
print(f" [{s['source']}] {s['summary'][:80]}...")
# Output esperado:
# Fuentes procesadas: 3
# [web] RAG combina recuperación de documentos con generación de texto...
# [academic] Los estudios muestran que RAG mejora la precisión factual...
# [news] Empresas como Google y OpenAI están adoptando técnicas RAG...
Lo que ganamos
- ❌ Antes: 9 funciones diferentes (3 × fetch, parse, summarize) con lógica duplicada
- ✅ Después: 3 funciones + 1 subgraph reutilizado 3 veces
- Si cambias la lógica de parsing, la cambias en un solo lugar
El mismo search_subgraph se usa como pipeline_web, pipeline_academic y pipeline_news. Las 3 instancias comparten la misma lógica pero operan con diferentes datos gracias a set_source.
Nesting: subgraphs dentro de subgraphs
Los subgraphs pueden contener otros subgraphs. Pero mantén la profundidad máxima en 2 niveles (padre → hijo → nieto). Más niveles hacen el debugging difícil:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from IPython.display import Image, display
class InnerState(TypedDict):
text: str
processed: str
def clean_text(state: InnerState) -> dict:
return {"processed": state["text"].strip().lower()}
inner = StateGraph(InnerState)
inner.add_node("clean", clean_text)
inner.add_edge(START, "clean")
inner.add_edge("clean", END)
inner_compiled = inner.compile()
class MiddleState(TypedDict):
text: str
processed: str
word_count: int
def count_words(state: MiddleState) -> dict:
return {"word_count": len(state["processed"].split())}
middle = StateGraph(MiddleState)
middle.add_node("preprocess", inner_compiled)
middle.add_node("count", count_words)
middle.add_edge(START, "preprocess")
middle.add_edge("preprocess", "count")
middle.add_edge("count", END)
middle_compiled = middle.compile()
class OuterState(TypedDict):
text: str
processed: str
word_count: int
report: str
def make_report(state: OuterState) -> dict:
return {"report": f"Text '{state['processed']}' has {state['word_count']} words"}
outer = StateGraph(OuterState)
outer.add_node("analysis", middle_compiled)
outer.add_node("report", make_report)
outer.add_edge(START, "analysis")
outer.add_edge("analysis", "report")
outer.add_edge("report", END)
app = outer.compile()
display(Image(app.get_graph().draw_mermaid_png()))
result = app.invoke({"text": " Hello World ", "processed": "", "word_count": 0, "report": ""})
print(result["report"])
# Output esperado:
# Text 'hello world' has 2 words
Tres niveles: outer → middle → inner. El flujo es: outer.analysis → middle.preprocess → inner.clean → middle.count → outer.report. Cada nivel tiene su propio estado y sus propios checkpoints.
La regla de 2 niveles
| Niveles | Complejidad | Recomendación |
|---|---|---|
| 1 (padre + hijos) | Baja | ✅ Ideal para la mayoría de casos |
| 2 (padre + hijos + nietos) | Media | ✅ Aceptable si la estructura lo justifica |
| 3+ | Alta | ⚠️ Evitar — debugging se vuelve difícil, los stack traces se hacen largos |
Si necesitas más de 2 niveles, probablemente tu diseño necesita refactoring: combina niveles o extrae lógica a funciones regulares.
Subgraphs con Functional API: @entrypoint como módulo reutilizable
En la Functional API, un @entrypoint compilado puede actuar como un módulo reutilizable — similar a cómo un subgraph es un grafo dentro de otro grafo:
from dotenv import load_dotenv
load_dotenv()
from langgraph.func import entrypoint, task
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4.1-mini")
@task
def fetch_data(source: str, query: str) -> str:
response = model.invoke(f"Simula datos de {source} sobre '{query}'. 2-3 oraciones.")
return response.content
@task
def parse_data(source: str, raw: str) -> str:
return f"[{source}] {raw[:200]}"
@task
def summarize_data(parsed: str) -> str:
response = model.invoke(f"Resume en 1 oración: {parsed}")
return response.content
@entrypoint()
def search_pipeline(config: dict) -> dict:
source = config["source"]
query = config["query"]
raw = fetch_data(source, query).result()
parsed = parse_data(source, raw).result()
summary = summarize_data(parsed).result()
return {"source": source, "summary": summary}
@entrypoint()
def research_orchestrator(query: str) -> dict:
web_fut = search_pipeline.ainvoke({"source": "web", "query": query})
academic_fut = search_pipeline.ainvoke({"source": "academic", "query": query})
news_fut = search_pipeline.ainvoke({"source": "news", "query": query})
results = []
for label, fut in [("web", web_fut), ("academic", academic_fut), ("news", news_fut)]:
try:
r = fut.result()
results.append(r)
except Exception as e:
results.append({"source": label, "summary": f"Error: {e}"})
return {"results": results, "total": len(results)}
result = research_orchestrator.invoke("transformer architectures")
print(f"Total: {result['total']}")
for r in result["results"]:
print(f" [{r['source']}] {r['summary'][:80]}...")
# Output esperado:
# Total: 3
# [web] Transformer architectures have revolutionized natural language...
# [academic] Research shows transformers outperform RNNs in sequence...
# [news] Major tech companies are investing heavily in transformer-based...
El search_pipeline es un @entrypoint que encapsula la lógica de buscar-parsear-resumir. El research_orchestrator lo llama 3 veces con diferentes fuentes. Cada invocación tiene sus propios checkpoints internos.
Subgraph vs función regular: cuándo usar cada uno
La pregunta clave: ¿por qué no usar una función Python normal en vez de un subgraph?
def search_and_summarize(source: str, query: str) -> str:
raw = fetch(source, query)
parsed = parse(raw)
summary = summarize(parsed)
return summary
Esta función hace exactamente lo mismo que el subgraph. Es más simple, más corta y más fácil de entender. Entonces, ¿cuándo vale la pena usar un subgraph?
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4.1-mini")
def search_as_function(source: str, query: str) -> str:
raw = model.invoke(f"Simula datos de {source} sobre '{query}'").content
parsed = f"[{source}] {raw[:200]}"
summary = model.invoke(f"Resume en 1 oración: {parsed}").content
return summary
class SubgraphState(TypedDict):
source_name: str
query: str
raw_data: str
parsed_data: str
summary: str
def sg_fetch(state: SubgraphState) -> dict:
result = model.invoke(f"Simula datos de {state['source_name']} sobre '{state['query']}'")
return {"raw_data": result.content}
def sg_parse(state: SubgraphState) -> dict:
return {"parsed_data": f"[{state['source_name']}] {state['raw_data'][:200]}"}
def sg_summarize(state: SubgraphState) -> dict:
result = model.invoke(f"Resume en 1 oración: {state['parsed_data']}")
return {"summary": result.content}
search_graph = StateGraph(SubgraphState)
search_graph.add_node("fetch", sg_fetch)
search_graph.add_node("parse", sg_parse)
search_graph.add_node("summarize", sg_summarize)
search_graph.add_edge(START, "fetch")
search_graph.add_edge("fetch", "parse")
search_graph.add_edge("parse", "summarize")
search_graph.add_edge("summarize", END)
search_subgraph = search_graph.compile()
func_result = search_as_function("web", "Python 3.12")
print(f"Función: {func_result[:80]}...")
sg_result = search_subgraph.invoke({
"source_name": "web", "query": "Python 3.12",
"raw_data": "", "parsed_data": "", "summary": "",
})
print(f"Subgraph: {sg_result['summary'][:80]}...")
# Output esperado:
# Función: Python 3.12 incluye mejoras significativas en tipado...
# Subgraph: Python 3.12 incluye mejoras significativas en tipado...
Ambos producen el mismo resultado. La diferencia está en lo que pasa cuando algo sale mal o cuando necesitas observabilidad:
| Capacidad | Función regular | Subgraph |
|---|---|---|
| Código | 5 líneas | ~20 líneas |
| Checkpointing | ❌ Si falla en summarize, re-ejecuta todo | ✅ Resume desde el último paso completado |
| Visualización | ❌ No hay diagrama del flujo interno | ✅ draw_mermaid_png() muestra fetch→parse→summarize |
| Streaming | ❌ Solo ves el resultado final | ✅ Puedes hacer stream de cada paso |
| Debugging | Print statements | Estado visible en cada paso |
| Reutilización en grafos | Necesitas wrapper | Se usa directamente como nodo |
Regla de decisión
- ✅ Usa una función regular cuando la lógica es simple (1-3 pasos), no necesitas checkpointing, y no necesitas visualizar el flujo interno
- ✅ Usa un subgraph cuando la lógica es compleja (3+ pasos), necesitas recovery ante fallos, quieres visualización/streaming del flujo interno, o el pipeline se reutiliza en múltiples grafos por diferentes equipos
Ejemplo completo: Research Agent modular con subgraphs
Combinemos todo: subgraphs reutilizables, branching paralelo (de la cápsula anterior) y merge inteligente:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from IPython.display import Image, display
class PipelineState(TypedDict):
source_name: str
query: str
raw_data: str
summary: str
model = init_chat_model("openai:gpt-4.1-mini")
def pipeline_fetch(state: PipelineState) -> dict:
response = model.invoke(
f"Simula una búsqueda en {state['source_name']} sobre '{state['query']}'. "
f"Genera 2-3 oraciones informativas."
)
return {"raw_data": response.content}
def pipeline_summarize(state: PipelineState) -> dict:
response = model.invoke(
f"Resume en 1 oración lo siguiente de [{state['source_name']}]: {state['raw_data']}"
)
return {"summary": response.content}
pipeline = StateGraph(PipelineState)
pipeline.add_node("fetch", pipeline_fetch)
pipeline.add_node("summarize", pipeline_summarize)
pipeline.add_edge(START, "fetch")
pipeline.add_edge("fetch", "summarize")
pipeline.add_edge("summarize", END)
pipeline_compiled = pipeline.compile()
class ResearchState(TypedDict):
query: str
source_name: str
raw_data: str
summary: str
all_results: Annotated[list[dict], operator.add]
final_report: str
def set_source(name: str):
def node(state: ResearchState) -> dict:
return {"source_name": name, "raw_data": "", "summary": ""}
return node
def collect(state: ResearchState) -> dict:
return {"all_results": [{"source": state["source_name"], "summary": state["summary"]}]}
def final_merge(state: ResearchState) -> dict:
context = "\n".join(f"- [{r['source']}]: {r['summary']}" for r in state["all_results"])
response = model.invoke(
f"Genera un resumen ejecutivo de 3-4 oraciones basado en estas fuentes "
f"sobre '{state['query']}':\n\n{context}"
)
return {"final_report": response.content}
research = StateGraph(ResearchState)
for source in ["web", "academic", "news"]:
research.add_node(f"set_{source}", set_source(source))
research.add_node(f"pipeline_{source}", pipeline_compiled)
research.add_node(f"collect_{source}", collect)
research.add_edge(START, f"set_{source}")
research.add_edge(f"set_{source}", f"pipeline_{source}")
research.add_edge(f"pipeline_{source}", f"collect_{source}")
research.add_edge(f"collect_{source}", "merge")
research.add_node("merge", final_merge)
research.add_edge("merge", END)
app = research.compile()
display(Image(app.get_graph().draw_mermaid_png()))
result = app.invoke({
"query": "¿Cómo mejora RAG la precisión de los LLMs?",
"source_name": "", "raw_data": "", "summary": "",
"all_results": [], "final_report": "",
})
print(f"Fuentes: {len(result['all_results'])}")
for r in result["all_results"]:
print(f" [{r['source']}] {r['summary'][:80]}...")
print(f"\nReporte final: {result['final_report'][:200]}...")
# Output esperado:
# Fuentes: 3
# [web] RAG combina búsqueda de documentos con generación de texto para...
# [academic] Estudios recientes demuestran que RAG reduce las alucinaciones...
# [news] Empresas como Google y Microsoft están integrando RAG en sus...
# Reporte final: RAG (Retrieval-Augmented Generation) ha demostrado ser una técnica...
Arquitectura del grafo
START → set_web → pipeline_web (subgraph: fetch → summarize) → collect_web ─┐
START → set_academic → pipeline_academic (subgraph: fetch → summarize) → collect_academic ─┤→ merge → END
START → set_news → pipeline_news (subgraph: fetch → summarize) → collect_news ─┘
- Subgraph: definido una vez, usado 3 veces
- Branching: las 3 ramas se ejecutan en paralelo
- Merge: recibe los 3 resultados y sintetiza con LLM
Si mañana necesitas agregar una cuarta fuente ("social_media"), agregas 3 líneas al loop for source in [...] y listo.
Troubleshooting
Problema 1: "El subgraph no recibe los campos del estado padre"
Síntoma: El subgraph recibe campos vacíos o con valores por defecto.
Causa: Los nombres de los campos en el TypedDict del subgraph no coinciden con los del padre.
Solución: Verifica que los campos compartidos tengan exactamente el mismo nombre y tipo en ambos TypedDict:
# ❌ Nombres diferentes
class ParentState(TypedDict):
search_query: str # "search_query"
class SubgraphState(TypedDict):
query: str # "query" — no coincide
# ✅ Mismos nombres
class ParentState(TypedDict):
query: str # "query"
class SubgraphState(TypedDict):
query: str # "query" — coincide
Problema 2: "El subgraph sobreescribe campos del padre que no debería tocar"
Síntoma: Después de ejecutar el subgraph, campos del padre que no están en el subgraph cambian o se pierden.
Causa: El subgraph solo puede modificar campos que comparte con el padre. Si un campo del padre no existe en el subgraph, el subgraph no lo afecta. Pero si un campo compartido tiene un reducer en el padre (operator.add) y el subgraph retorna un valor para ese campo, el reducer lo procesa.
Solución: Ten cuidado con reducers en campos compartidos. Si no quieres que el subgraph afecte un campo con reducer, no incluyas ese campo en el TypedDict del subgraph.
Problema 3: "No puedo reutilizar el mismo subgraph con diferentes configuraciones"
Síntoma: Quieres usar el mismo subgraph para "web" y "academic", pero ambas instancias reciben los mismos datos.
Causa: El subgraph lee del estado del padre, que es compartido. Si no cambias source_name antes de cada instancia, ambas leen el mismo valor.
Solución: Usa un nodo set_source antes de cada instancia del subgraph que configure los campos necesarios:
def set_source(name: str):
def node(state):
return {"source_name": name}
return node
graph.add_node("set_web", set_source("web"))
graph.add_node("pipeline_web", search_subgraph)
graph.add_edge("set_web", "pipeline_web")
Problema 4: "El diagrama no muestra los nodos internos del subgraph"
Síntoma: draw_mermaid_png() muestra el subgraph como una caja opaca sin detalle interno.
Causa: Por defecto, la visualización del grafo padre muestra los subgraphs como nodos colapsados.
Solución: Usa el parámetro xray para expandir los subgraphs en la visualización:
display(Image(app.get_graph(xray=True).draw_mermaid_png()))
Con xray=True, verás los nodos internos de cada subgraph dentro de sus respectivas cajas.
Problema 5: "El subgraph falla y no se captura el error en el padre"
Síntoma: Un error dentro del subgraph se propaga sin manejo al nivel del padre y detiene todo el workflow.
Causa: Las excepciones dentro de nodos del subgraph se propagan hacia arriba como en cualquier call stack de Python.
Solución: Si usas el subgraph como nodo directo (add_node("pipeline", subgraph)), los errores se propagan. Para capturarlos, usa un wrapper:
def safe_pipeline(state):
try:
result = search_subgraph.invoke({"source_name": state["source_name"], "query": state["query"], ...})
return {"summary": result["summary"]}
except Exception as e:
return {"summary": f"Error en pipeline: {str(e)}"}
Ejercicios
Ejercicio 1: Subgraph básico (Fácil)
Crea un subgraph que procese texto en 2 pasos: (1) limpiar (strip + lowercase), (2) contar palabras. Usa este subgraph como nodo de un grafo padre que le agrega un paso final de reporte. Visualiza ambos grafos.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from IPython.display import Image, display
class TextState(TypedDict):
text: str
cleaned: str
word_count: int
def clean(state: TextState) -> dict:
return {"cleaned": state["text"].strip().lower()}
def count(state: TextState) -> dict:
return {"word_count": len(state["cleaned"].split())}
text_processor = StateGraph(TextState)
text_processor.add_node("clean", clean)
text_processor.add_node("count", count)
text_processor.add_edge(START, "clean")
text_processor.add_edge("clean", "count")
text_processor.add_edge("count", END)
text_subgraph = text_processor.compile()
print("=== Subgraph ===")
display(Image(text_subgraph.get_graph().draw_mermaid_png()))
class ParentState(TypedDict):
text: str
cleaned: str
word_count: int
report: str
def make_report(state: ParentState) -> dict:
return {"report": f"'{state['cleaned']}' tiene {state['word_count']} palabras"}
parent = StateGraph(ParentState)
parent.add_node("process", text_subgraph)
parent.add_node("report", make_report)
parent.add_edge(START, "process")
parent.add_edge("process", "report")
parent.add_edge("report", END)
app = parent.compile()
print("=== Grafo padre ===")
display(Image(app.get_graph().draw_mermaid_png()))
result = app.invoke({"text": " Hello World From LangGraph ", "cleaned": "", "word_count": 0, "report": ""})
print(result["report"])
# Output esperado:
# 'hello world from langgraph' tiene 4 palabras
El subgraph procesa el texto (clean + count) y el padre agrega el reporte. Cada uno tiene su diagrama visible.
Ejercicio 2: Subgraph con estado diferente (Fácil)
Crea un subgraph de análisis de sentimiento con su propio TypedDict (campos: text, sentiment, confidence). El grafo padre tiene un TypedDict diferente (campos: query, search_result, analysis). Usa una función wrapper para conectarlos.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class SentimentState(TypedDict):
text: str
sentiment: str
confidence: float
def analyze_sentiment(state: SentimentState) -> dict:
text = state["text"].lower()
positive_words = ["good", "great", "excellent", "amazing", "love", "best"]
negative_words = ["bad", "terrible", "awful", "hate", "worst", "poor"]
pos = sum(1 for w in positive_words if w in text)
neg = sum(1 for w in negative_words if w in text)
total = pos + neg
if total == 0:
return {"sentiment": "neutral", "confidence": 0.5}
if pos > neg:
return {"sentiment": "positive", "confidence": round(pos / total, 2)}
return {"sentiment": "negative", "confidence": round(neg / total, 2)}
sentiment_graph = StateGraph(SentimentState)
sentiment_graph.add_node("analyze", analyze_sentiment)
sentiment_graph.add_edge(START, "analyze")
sentiment_graph.add_edge("analyze", END)
sentiment_subgraph = sentiment_graph.compile()
class ParentState(TypedDict):
query: str
search_result: str
analysis: dict
def simulate_search(state: ParentState) -> dict:
return {"search_result": f"Great results! Python is an excellent and amazing language for AI."}
def analyze_wrapper(state: ParentState) -> dict:
result = sentiment_subgraph.invoke({
"text": state["search_result"],
"sentiment": "",
"confidence": 0.0,
})
return {"analysis": {"sentiment": result["sentiment"], "confidence": result["confidence"]}}
parent = StateGraph(ParentState)
parent.add_node("search", simulate_search)
parent.add_node("analyze", analyze_wrapper)
parent.add_edge(START, "search")
parent.add_edge("search", "analyze")
parent.add_edge("analyze", END)
app = parent.compile()
result = app.invoke({"query": "Python for AI", "search_result": "", "analysis": {}})
print(f"Search: {result['search_result'][:60]}...")
print(f"Analysis: {result['analysis']}")
# Output esperado:
# Search: Great results! Python is an excellent and amazing language...
# Analysis: {'sentiment': 'positive', 'confidence': 1.0}
El wrapper analyze_wrapper traduce del estado del padre al estado del subgraph y vice versa. Los TypedDict son completamente diferentes.
Ejercicio 3: Subgraph reutilizado en paralelo (Medio)
Usa un mismo subgraph de "fetch + summarize" en 3 ramas paralelas (web, academic, news). Cada rama configura la fuente antes de ejecutar el subgraph. Los resultados se acumulan con operator.add. Visualiza con xray=True.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from IPython.display import Image, display
class FetchState(TypedDict):
source: str
query: str
raw: str
summary: str
def fetch(state: FetchState) -> dict:
return {"raw": f"Data from {state['source']} about '{state['query']}'"}
def summarize(state: FetchState) -> dict:
return {"summary": f"[{state['source']}] Summary: {state['raw'][:50]}"}
fetch_graph = StateGraph(FetchState)
fetch_graph.add_node("fetch", fetch)
fetch_graph.add_node("summarize", summarize)
fetch_graph.add_edge(START, "fetch")
fetch_graph.add_edge("fetch", "summarize")
fetch_graph.add_edge("summarize", END)
fetch_subgraph = fetch_graph.compile()
class OrchestratorState(TypedDict):
query: str
source: str
raw: str
summary: str
all_summaries: Annotated[list[str], operator.add]
def set_src(name: str):
def node(state: OrchestratorState) -> dict:
return {"source": name, "raw": "", "summary": ""}
return node
def collect(state: OrchestratorState) -> dict:
return {"all_summaries": [state["summary"]]}
graph = StateGraph(OrchestratorState)
graph.add_node("merge", lambda state: {})
for src in ["web", "academic", "news"]:
graph.add_node(f"set_{src}", set_src(src))
graph.add_node(f"pipe_{src}", fetch_subgraph)
graph.add_node(f"collect_{src}", collect)
graph.add_edge(START, f"set_{src}")
graph.add_edge(f"set_{src}", f"pipe_{src}")
graph.add_edge(f"pipe_{src}", f"collect_{src}")
graph.add_edge(f"collect_{src}", "merge")
graph.add_edge("merge", END)
app = graph.compile()
display(Image(app.get_graph(xray=True).draw_mermaid_png()))
result = app.invoke({
"query": "LangGraph subgraphs", "source": "", "raw": "", "summary": "",
"all_summaries": [],
})
print(f"Summaries: {len(result['all_summaries'])}")
for s in result["all_summaries"]:
print(f" {s}")
# Output esperado:
# Summaries: 3
# [web] Summary: Data from web about 'LangGraph subgraphs'
# [academic] Summary: Data from academic about 'LangGraph subgrap
# [news] Summary: Data from news about 'LangGraph subgraphs'
Con xray=True, el diagrama muestra los nodos fetch y summarize dentro de cada subgraph.
Ejercicio 4: Functional API con módulo reutilizable (Medio)
Implementa el mismo patrón de la Functional API: un @entrypoint llamado process_source que hace fetch + summarize, y un orquestador que lo llama 3 veces en paralelo con diferentes fuentes. Maneja errores individuales.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from langgraph.func import entrypoint, task
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4.1-mini")
@task
def fetch_from_source(source: str, query: str) -> str:
response = model.invoke(f"Simula datos de {source} sobre '{query}'. 2 oraciones.")
return response.content
@task
def summarize_result(source: str, raw: str) -> str:
response = model.invoke(f"Resume en 1 oración: [{source}] {raw}")
return response.content
@entrypoint()
def process_source(config: dict) -> dict:
source = config["source"]
query = config["query"]
raw = fetch_from_source(source, query).result()
summary = summarize_result(source, raw).result()
return {"source": source, "summary": summary}
@task
def final_synthesis(results: list) -> str:
context = "\n".join(f"- [{r['source']}]: {r['summary']}" for r in results)
response = model.invoke(f"Sintetiza en 2 oraciones:\n{context}")
return response.content
@entrypoint()
def research_agent(query: str) -> dict:
sources = ["web", "academic", "news"]
futures = [process_source.ainvoke({"source": s, "query": query}) for s in sources]
results = []
errors = []
for source, fut in zip(sources, futures):
try:
results.append(fut.result())
except Exception as e:
errors.append({"source": source, "error": str(e)})
synthesis = final_synthesis(results).result()
return {
"results": results,
"errors": errors,
"synthesis": synthesis,
}
result = research_agent.invoke("fine-tuning techniques for LLMs")
print(f"Fuentes OK: {len(result['results'])}, Errores: {len(result['errors'])}")
for r in result["results"]:
print(f" [{r['source']}] {r['summary'][:80]}...")
print(f"Síntesis: {result['synthesis'][:150]}...")
# Output esperado:
# Fuentes OK: 3, Errores: 0
# [web] Fine-tuning techniques for LLMs include LoRA and full fine-tuning...
# [academic] Recent research compares parameter-efficient fine-tuning methods...
# [news] Companies are adopting fine-tuning to customize LLMs for specific...
# Síntesis: Fine-tuning LLMs has evolved with parameter-efficient techniques...
process_source encapsula el pipeline completo. El orquestador lo invoca 3 veces de forma asíncrona y maneja errores individuales.
Ejercicio 5: Subgraph vs función — comparación práctica (Avanzado)
Implementa el mismo pipeline (fetch → parse → summarize) tanto como función regular como subgraph. Invoca ambos con los mismos datos y compara: ¿producen el mismo resultado? ¿Qué obtienes extra con el subgraph? Muestra cómo visualizar el subgraph y cómo no puedes visualizar la función.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from IPython.display import Image, display
model = init_chat_model("openai:gpt-4.1-mini")
def pipeline_as_function(source: str, query: str) -> dict:
raw = model.invoke(f"Simula datos de {source} sobre '{query}'. 1 oración.").content
parsed = f"[{source}] {raw}"
summary = model.invoke(f"Resume: {parsed}").content
return {"source": source, "raw": raw, "parsed": parsed, "summary": summary}
class PipeState(TypedDict):
source_name: str
query: str
raw_data: str
parsed_data: str
summary: str
def sg_fetch(state: PipeState) -> dict:
result = model.invoke(f"Simula datos de {state['source_name']} sobre '{state['query']}'. 1 oración.")
return {"raw_data": result.content}
def sg_parse(state: PipeState) -> dict:
return {"parsed_data": f"[{state['source_name']}] {state['raw_data']}"}
def sg_summarize(state: PipeState) -> dict:
result = model.invoke(f"Resume: {state['parsed_data']}")
return {"summary": result.content}
pipe = StateGraph(PipeState)
pipe.add_node("fetch", sg_fetch)
pipe.add_node("parse", sg_parse)
pipe.add_node("summarize", sg_summarize)
pipe.add_edge(START, "fetch")
pipe.add_edge("fetch", "parse")
pipe.add_edge("parse", "summarize")
pipe.add_edge("summarize", END)
pipeline_subgraph = pipe.compile()
print("=== Subgraph (visualizable) ===")
display(Image(pipeline_subgraph.get_graph().draw_mermaid_png()))
print("\n=== Función (no visualizable — es una caja negra) ===")
print("No hay diagrama disponible para funciones Python regulares.\n")
func_result = pipeline_as_function("web", "Python async")
print(f"Función → {func_result['summary'][:80]}...")
sg_result = pipeline_subgraph.invoke({
"source_name": "web", "query": "Python async",
"raw_data": "", "parsed_data": "", "summary": "",
})
print(f"Subgraph → {sg_result['summary'][:80]}...")
print("\n=== Diferencias ===")
print("✅ Subgraph: checkpointing por paso, visualización, streaming, resume ante fallos")
print("✅ Función: más simple, menos código, suficiente para lógica trivial")
print("❌ Función: sin checkpointing, sin visualización, si falla re-ejecuta todo")
# Output esperado:
# === Subgraph (visualizable) ===
# [Diagrama: fetch → parse → summarize]
# === Función (no visualizable — es una caja negra) ===
# No hay diagrama disponible para funciones Python regulares.
# Función → Python's async capabilities enable concurrent execution...
# Subgraph → Python's async capabilities enable concurrent execution...
# === Diferencias ===
# ✅ Subgraph: checkpointing por paso, visualización, streaming, resume ante fallos
# ✅ Función: más simple, menos código, suficiente para lógica trivial
# ❌ Función: sin checkpointing, sin visualización, si falla re-ejecuta todo
Ambos producen resultados equivalentes. La diferencia es operacional: el subgraph te da observabilidad y resiliencia que la función no ofrece.
Ejercicio 6: Research Agent modular completo (Avanzado)
Construye un Research Agent que combine subgraphs + branching + merge con LLM. Requisitos: (1) un subgraph de búsqueda reutilizable (fetch → summarize), (2) un grafo padre que ejecute el subgraph en paralelo para 3 fuentes, (3) un nodo de merge que use un LLM para sintetizar, (4) manejo de errores en cada rama. Visualiza con xray=True.
Ver solución
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from IPython.display import Image, display
model = init_chat_model("openai:gpt-4.1-mini")
class SearchState(TypedDict):
source_name: str
query: str
raw_data: str
summary: str
def sg_fetch(state: SearchState) -> dict:
response = model.invoke(
f"Simula datos de '{state['source_name']}' sobre '{state['query']}'. 2-3 oraciones."
)
return {"raw_data": response.content}
def sg_summarize(state: SearchState) -> dict:
response = model.invoke(f"Resume en 1 oración: [{state['source_name']}] {state['raw_data']}")
return {"summary": response.content}
search_graph = StateGraph(SearchState)
search_graph.add_node("fetch", sg_fetch)
search_graph.add_node("summarize", sg_summarize)
search_graph.add_edge(START, "fetch")
search_graph.add_edge("fetch", "summarize")
search_graph.add_edge("summarize", END)
search_compiled = search_graph.compile()
class AgentState(TypedDict):
query: str
source_name: str
raw_data: str
summary: str
collected: Annotated[list[dict], operator.add]
report: str
def set_src(name: str):
def node(state: AgentState) -> dict:
return {"source_name": name, "raw_data": "", "summary": ""}
return node
def safe_collect(state: AgentState) -> dict:
if state.get("summary"):
return {"collected": [{"source": state["source_name"], "summary": state["summary"], "status": "ok"}]}
return {"collected": [{"source": state["source_name"], "summary": "No data", "status": "error"}]}
def merge_report(state: AgentState) -> dict:
ok = [r for r in state["collected"] if r["status"] == "ok"]
if not ok:
return {"report": "No se pudo obtener información de ninguna fuente."}
context = "\n".join(f"- [{r['source']}]: {r['summary']}" for r in ok)
response = model.invoke(
f"Genera un reporte de 3-4 oraciones sobre '{state['query']}' "
f"basado en estas fuentes:\n\n{context}"
)
return {"report": response.content}
agent = StateGraph(AgentState)
agent.add_node("merge", merge_report)
for src in ["web", "academic", "news"]:
agent.add_node(f"set_{src}", set_src(src))
agent.add_node(f"search_{src}", search_compiled)
agent.add_node(f"collect_{src}", safe_collect)
agent.add_edge(START, f"set_{src}")
agent.add_edge(f"set_{src}", f"search_{src}")
agent.add_edge(f"search_{src}", f"collect_{src}")
agent.add_edge(f"collect_{src}", "merge")
agent.add_edge("merge", END)
app = agent.compile()
display(Image(app.get_graph(xray=True).draw_mermaid_png()))
result = app.invoke({
"query": "¿Cómo funciona el fine-tuning de LLMs?",
"source_name": "", "raw_data": "", "summary": "",
"collected": [], "report": "",
})
print(f"Fuentes: {len(result['collected'])}")
for r in result["collected"]:
print(f" [{r['source']}] ({r['status']}) {r['summary'][:70]}...")
print(f"\nReporte: {result['report'][:200]}...")
# Output esperado:
# Fuentes: 3
# [web] (ok) Fine-tuning adapts pre-trained LLMs to specific tasks...
# [academic] (ok) Research shows parameter-efficient methods like LoRA...
# [news] (ok) Companies are increasingly using fine-tuning for custom...
# Reporte: El fine-tuning de LLMs es un proceso que adapta modelos pre-entrenados...
Este ejercicio combina todo lo aprendido: subgraph reutilizable, branching paralelo, error handling por rama, y merge con LLM. El diagrama con xray=True muestra los nodos internos de cada subgraph.
Resumen
En esta cápsula aprendiste:
- Subgraphs = funciones: así como una función puede llamar a otra función, un grafo puede contener otro grafo. Encapsulas lógica compleja y la reutilizas
- Crear un subgraph es crear un
StateGraphnormal, compilarlo, y usarlo como nodo en un grafo padre conadd_node("nombre", subgraph_compilado) - Estado compartido: los campos con el mismo nombre en padre y subgraph se mapean automáticamente. Los campos exclusivos de cada uno son invisibles para el otro
- Estado diferente: cuando padre y subgraph tienen TypedDict diferentes, usa una función wrapper que traduce entre ambas interfaces
- Reutilización: defines el subgraph una vez y lo usas N veces con diferentes configuraciones. Si cambias la lógica, la cambias en un solo lugar
- Nesting: subgraphs dentro de subgraphs es posible, pero mantén máximo 2 niveles de profundidad por sanidad
- Functional API: un
@entrypointcompilado puede actuar como módulo reutilizable, con la misma filosofía de encapsulación - Subgraph vs función regular: el subgraph te da checkpointing por paso, visualización y streaming. La función es más simple pero es una caja negra. Usa subgraph cuando la lógica es compleja y necesitas observabilidad
xray=Trueendraw_mermaid_png()expande los subgraphs para mostrar sus nodos internos
Próxima cápsula: Map-Reduce — cómo procesar colecciones de datos en paralelo, enviando cada elemento a su propia instancia de un subgraph.
Recursos adicionales
- LangGraph Subgraphs — How-to Guide — Tutorial oficial de subgraphs con ejemplos completos
- LangGraph Concepts: Subgraphs — Documentación conceptual de subgraphs
- State Interface Between Graphs — Cómo manejar estado entre padre y subgraph
- Visualizing Subgraphs — Usar xray para ver nodos internos
- LangGraph Branching — Combinar branching con subgraphs
- Functional API — @entrypoint como módulo reutilizable
- LangGraph Persistence — Checkpointing en subgraphs (cada paso interno tiene su propio checkpoint)
Módulo 7 — LangChain & LangGraph: From Chains to Agents