Agentes de IA con LangGraph para Análisis de Datos
Construye un agente autónomo de análisis de datos con LangGraph: implementa el ciclo ReAct con herramientas y aprende cuándo usar agentes vs RAG.
En un artículo anterior mostré cómo construir un sistema RAG con LangChain: indexas documentos, recuperas los fragmentos más relevantes y se los pasas a un LLM. Eso funciona bien cuando la respuesta ya existe en algún lugar de tus documentos y solo necesitas encontrarla.
Pero ¿qué pasa si la pregunta requiere múltiples pasos? "¿Qué clientes se fueron el mes pasado y qué tenían en común?" — esto requiere cargar datos, filtrar, agregar e interpretar resultados. Un sistema RAG no puede hacer eso. Un agente sí — y en este artículo construirás uno con LangGraph: un agente de análisis de datos que carga CSVs, ejecuta consultas de pandas y reporta sus hallazgos con números.
Agentes vs. RAG: La Diferencia Clave
Un sistema RAG es un pipeline de un solo paso: recuperar → generar.
Un agente es un ciclo:
- Razonar: dado el objetivo y el estado actual, ¿qué debo hacer a continuación?
- Actuar: llamar a una herramienta (ejecutar SQL, cargar un CSV, graficar)
- Observar: leer el resultado de la herramienta
- Volver al paso 1 hasta lograr el objetivo
Este es el patrón ReAct (Reason + Act). LangGraph facilita implementarlo como una máquina de estados explícita.
Instalación
pip install langgraph langchain langchain-openai pandas numpy
Necesitarás una clave de API de OpenAI (o puedes cambiar el modelo por cualquier LLM compatible con LangChain):
export OPENAI_API_KEY="sk-..."
Definiendo el Estado del Agente
LangGraph modela el agente como un grafo dirigido donde cada nodo transforma un estado compartido. Empezamos definiendo ese estado:
from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage
import operator
class EstadoAgente(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
datos_cargados: bool
resumen_dataset: str
messages acumula el historial de conversación. operator.add le dice a LangGraph que agregue mensajes nuevos en vez de reemplazar la lista. Los otros campos le dan al agente memoria de trabajo entre turnos.
Construyendo las Herramientas
Las herramientas son las acciones que puede tomar el agente. Definimos tres:
import pandas as pd
import numpy as np
from langchain_core.tools import tool
# "Base de datos" en memoria para el demo
_df: pd.DataFrame | None = None
@tool
def cargar_dataset(path: str) -> str:
"""Carga un dataset CSV en memoria. Devuelve un resumen de columnas y forma."""
global _df
_df = pd.read_csv(path)
resumen = (
f"Dataset cargado: {_df.shape[0]} filas × {_df.shape[1]} columnas.\n"
f"Columnas: {', '.join(_df.columns)}\n"
f"Tipos:\n{_df.dtypes.to_string()}"
)
return resumen
@tool
def ejecutar_consulta(consulta: str) -> str:
"""
Ejecuta una consulta pandas sobre el dataset cargado.
Usa expresiones pandas estándar, por ejemplo:
- df[df['churn'] == 1]['cargo_mensual'].mean()
- df.groupby('plan')['churn'].value_counts()
La variable 'df' se refiere al dataset cargado.
"""
global _df
if _df is None:
return "No hay dataset cargado. Llama a cargar_dataset primero."
try:
resultado = eval(consulta, {'df': _df, 'pd': pd, 'np': np})
return str(resultado)
except Exception as e:
return f"Error en consulta: {e}"
@tool
def describir_columna(nombre_columna: str) -> str:
"""Devuelve estadísticas descriptivas para una columna específica del dataset cargado."""
global _df
if _df is None:
return "No hay dataset cargado."
if nombre_columna not in _df.columns:
return f"Columna '{nombre_columna}' no encontrada. Disponibles: {list(_df.columns)}"
stats = _df[nombre_columna].describe().to_string()
pct_nulos = _df[nombre_columna].isnull().mean() * 100
return f"{stats}\nValores nulos: {pct_nulos:.1f}%"
herramientas = [cargar_dataset, ejecutar_consulta, describir_columna]
Una advertencia sobre ejecutar_consulta: pasar código generado por el LLM a eval está bien para un demo local, pero nunca lo hagas en producción — una consulta con prompt injection podría ejecutar código arbitrario. Aísla la ejecución (proceso restringido, contenedor) o usa un evaluador seguro de expresiones.
Construyendo el Grafo
Ahora conectamos el agente usando LangGraph:
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
# LLM con herramientas vinculadas
llm = ChatOpenAI(model='gpt-4o-mini', temperature=0)
llm_con_herramientas = llm.bind_tools(herramientas)
PROMPT_SISTEMA = """Eres un asistente de análisis de datos. Tienes acceso a herramientas
que te permiten cargar datasets CSV, ejecutar consultas pandas y describir columnas.
Cuando el usuario haga una pregunta:
1. Carga el dataset si aún no está cargado.
2. Explora los datos para entender su estructura.
3. Ejecuta las consultas específicas necesarias para responder la pregunta.
4. Resume tus hallazgos claramente, con números.
Siempre verifica que tus consultas funcionaron antes de sacar conclusiones."""
def nodo_agente(state: EstadoAgente) -> EstadoAgente:
"""El nodo de razonamiento: llama al LLM para decidir la siguiente acción."""
messages = [SystemMessage(content=PROMPT_SISTEMA)] + list(state['messages'])
respuesta = llm_con_herramientas.invoke(messages)
return {'messages': [respuesta]}
def debe_continuar(state: EstadoAgente) -> str:
"""Función de enrutamiento: ¿continuar el ciclo o terminar?"""
ultimo_mensaje = state['messages'][-1]
if hasattr(ultimo_mensaje, 'tool_calls') and ultimo_mensaje.tool_calls:
return 'herramientas'
return END
# Construir el grafo
nodo_herramientas = ToolNode(herramientas)
grafo = StateGraph(EstadoAgente)
grafo.add_node('agente', nodo_agente)
grafo.add_node('herramientas', nodo_herramientas)
grafo.set_entry_point('agente')
grafo.add_conditional_edges('agente', debe_continuar)
grafo.add_edge('herramientas', 'agente') # Después de la llamada, vuelve al agente
agente = grafo.compile()
El grafo tiene dos nodos — agente y herramientas — conectados en un ciclo. Después de cada llamada a herramienta, el control regresa al nodo agente, que razona sobre el resultado y decide si llamar otra herramienta o detenerse.
Ejecutando el Agente
from langchain_core.messages import HumanMessage
def ejecutar_agente(pregunta: str, data_path: str | None = None):
mensaje_inicial = HumanMessage(
content=f"Ruta del dataset: {data_path}\n\nPregunta: {pregunta}"
if data_path else pregunta
)
resultado = agente.invoke(
{'messages': [mensaje_inicial], 'datos_cargados': False, 'resumen_dataset': ''},
config={'recursion_limit': 15},
)
mensaje_final = resultado['messages'][-1]
return mensaje_final.content
# Ejemplo: analizar un dataset de muestra
respuesta = ejecutar_agente(
pregunta="¿Cuál es la tasa de churn por número de productos? ¿Qué grupo tiene el mayor churn?",
data_path="datos/churn.csv",
)
print(respuesta)
Una salida de muestra (la respuesta final del agente después de múltiples llamadas a herramientas):
Cargué el dataset (2000 filas × 6 columnas) y ejecuté el análisis.
Tasa de churn por número de productos:
1 producto: 42.1% ← mayor churn
2 productos: 31.7%
3 productos: 22.4%
4 productos: 15.8%
5 productos: 9.3% ← menor churn
Los clientes con solo 1 producto tienen la mayor tasa de churn con 42.1%,
que es 4.5× más alta que los clientes con 5 productos. Esto sugiere que
la venta cruzada de productos adicionales mejora significativamente la retención.
Inspeccionando el Razonamiento del Agente
Para depurar qué hizo el agente en cada paso:
from langchain_core.messages import AIMessage, ToolMessage
resultado = agente.invoke({
'messages': [HumanMessage(content="Carga datos/churn.csv y dime el cargo mensual promedio para clientes que se fueron vs los que se quedaron.")],
'datos_cargados': False,
'resumen_dataset': '',
})
for msg in resultado['messages']:
if isinstance(msg, AIMessage) and msg.tool_calls:
for tc in msg.tool_calls:
print(f"→ Llamada: {tc['name']}({tc['args']})")
elif isinstance(msg, ToolMessage):
print(f"← Resultado: {msg.content[:200]}")
elif isinstance(msg, AIMessage):
print(f"✓ Respuesta final: {msg.content}")
→ Llamada: cargar_dataset({'path': 'datos/churn.csv'})
← Resultado: Dataset cargado: 2000 filas × 6 columnas...
→ Llamada: ejecutar_consulta({'consulta': "df.groupby('churn')['cargo_mensual'].mean()"})
← Resultado: churn\n0 58.3\n1 79.2
✓ Respuesta final: Los clientes que se fueron pagan significativamente más en promedio ($79.2/mes)...
Agentes vs. RAG: Cuándo Usar Cada Uno
| Escenario | Usar |
|---|---|
| "Resume este contrato de 50 páginas" | RAG |
| "¿Qué cláusula limita la responsabilidad?" | RAG |
| "¿Cuál fue nuestra tasa de churn en Q3?" | Agente |
| "Compara el churn entre los tres niveles de producto" | Agente |
| "Encuentra los clientes más propensos a irse el próximo mes" | Agente |
La regla general: si la pregunta requiere cómputo o razonamiento multi-paso sobre datos estructurados, usa un agente. Si requiere encontrar y parafrasear información de documentos, usa RAG. Y si lo que necesitas es extraer campos fijos de documentos — facturas, contratos, ofertas de trabajo — no necesitas un agente: la extracción de datos estructurados con LLMs lo resuelve con una sola llamada validada.
Conclusión
LangGraph te da control explícito sobre el ciclo del agente — estado, transiciones y ejecución de herramientas — sin ocultar complejidad detrás de magia. El resultado es un sistema que puedes inspeccionar, depurar y extender. Desde aquí, puedes agregar más herramientas (conectores de base de datos, librerías de graficación, llamadas a API), persistir estado entre sesiones, o construir arquitecturas multi-agente donde agentes especializados colaboran.
Sobre el autor
Rodrigo Arenas es arquitecto de software e ingeniero de machine learning en Medellín, Colombia. Construye productos, plataformas y software open source para IA, Data Engineering y Machine Learning — es el creador de Ciaren, sklearn-genetic-opt y PyWorkforce.
Lectura recomendada
Rodrigo Arenas — ML Engineer & Open Source Maintainer
¿Estás trabajando en algo similar? Puedo ayudarte a llevarlo a producción.