Crea un mini-proyecto: Agente de recomendación de libros

Video
25 min~14 min lectura

Reproductor de video

Introducción: Construyendo un Agente de IA con Estado y Contexto

En esta lección, pasaremos de la teoría a la práctica aplicando los fundamentos de LangGraph para construir un agente de IA especializado: un recomendador de libros. Este no será un simple chatbot que devuelve listas estáticas, sino un agente con memoria de conversación y la capacidad de utilizar herramientas (funciones) para acceder a datos externos, simular una base de conocimiento o interactuar con APIs. El objetivo es crear un sistema que mantenga un hilo conversacional, recuerde las preferencias del usuario a lo largo del diálogo y utilice herramientas específicas para buscar y filtrar recomendaciones de manera inteligente.

El proyecto integrará los conceptos centrales de LangGraph: la definición de un grafo de estado que modela el flujo de la conversación, el uso de nodos que representan funciones o acciones (como procesar una consulta o llamar a una herramienta), y las aristas (condicionales o fijas) que determinan el camino que sigue el agente basado en el resultado de cada paso. Este enfoque gráfico nos permite visualizar y controlar con precisión la lógica de nuestro agente, superando las limitaciones de los flujos lineales típicos de los chatbots simples.

Para este nivel avanzado, asumimos familiaridad con Python, conceptos básicos de LLMs (Large Language Models) y una comprensión inicial de LangChain o LangGraph. El agente final será capaz de entender consultas complejas como "Quiero un libro de ciencia ficción, pero nada de los que ya me recomendaste la última vez, y que sea adecuado para adolescentes", demostrando memoria, razonamiento y uso de herramientas.

Concepto Clave: El Grafo de Estado como el Cerebro del Agente

Imagina que estás planificando un viaje complejo. Tienes un mapa (el grafo) con diferentes ciudades (nodos) como "Decidir destino", "Reservar vuelo", "Buscar hotel" y "Actividades". Tu estado actual (presupuesto, fechas, preferencias) es una mochila que llevas contigo. En cada ciudad, consultas tu mochila, realizas una acción (como llamar a una aerolínea, que sería una herramienta), actualizas el contenido de tu mochila con los nuevos tickets o información, y luego, basándote en las reglas del mapa, decides a qué ciudad ir después. ¿Hotel reservado? Ve a "Actividades". ¿Presupuesto agotado? Ve directamente a "Fin del viaje".

En LangGraph, este mapa es el StateGraph. La "mochila" es el objeto de estado compartido, típicamente un diccionario de Python o una Pydantic model, que fluye a través de todos los nodos. Cada nodo es una función que lee y escribe en este estado. Las aristas son las reglas de transición. La potencia radica en los ciclos: puedes regresar al nodo "Decidir" si el usuario cambia de idea, creando un bucle conversacional. Este modelo es radicalmente diferente a un script lineal, ya que el camino no está predefinido, sino que emerge dinámicamente de la interacción entre el estado, las herramientas y las decisiones del LLM que actúa como el "orquestador" del grafo.

Para nuestro agente de libros, el estado contendrá la historia del chat, las preferencias extraídas del usuario (género, autor evitado, etc.) y los resultados de las búsquedas. Un nodo clave será el "orquestador", que decide si debe llamar a la herramienta de búsqueda o generar una respuesta final. Otro nodo será la propia herramienta de búsqueda, que consulta nuestra base de datos simulada. El grafo orquesta este baile entre la comprensión del lenguaje natural y la ejecución de código preciso.

Tip Clave: Piensa en el estado como la única fuente de verdad de tu agente. Todos los nodos deben ser funciones puras en relación a él: toman el estado como entrada, lo modifican y devuelven una actualización. Esto hace que el flujo sea predecible y fácil de depurar.

Cómo Funciona en la Práctica: Anatomía de Nuestro Agente de Libros

Vamos a desglosar el flujo paso a paso de una interacción típica. Primero, el usuario envía un mensaje: "Recomiéndame una novela de misterio". Este mensaje se añade al estado, en una clave como "messages" que contiene la lista completa del historial. El grafo comienza en un nodo especial llamado orquestador o router. Este nodo no suele hacer tareas pesadas; su rol es inspeccionar el último mensaje del usuario y el estado, y decidir qué acción tomar a continuación. Para ello, utiliza un LLM para clasificar la intención. En nuestro diseño, las opciones serán: "tool_biblioteca" (necesita buscar libros), "finalizar" (ya puede responder), o "aclara" (necesita más información del usuario).

Supongamos que el orquestador elige "tool_biblioteca". El grafo dirige el flujo al nodo que invoca la herramienta buscar_libros. Esta herramienta es una función Python que recibe los parámetros extraídos (como "misterio") y consulta una base de datos simulada (por ahora, una lista de diccionarios en memoria). Los resultados (una lista de libros) se escriben en el estado, en una clave como "resultados_busqueda". Luego, el grafo, por una arista fija, devuelve el control al nodo orquestador. Ahora, el estado tiene novedades: los resultados de la búsqueda.

El orquestador se ejecuta de nuevo. Esta vez, al ver que hay resultados en el estado y que el usuario pidió una recomendación, probablemente decida que la ruta es "finalizar". El flujo va entonces al nodo generar_respuesta. Este nodo toma el historial de mensajes y los resultados de la búsqueda, construye un prompt contextualizado para un LLM (por ejemplo, "Basándote en estos libros, recomienda uno y explica por qué") y devuelve la respuesta generada. Esta respuesta se añade como un nuevo mensaje al estado, y el grafo llega a un punto final, entregando el estado final al usuario. En la próxima interacción, el ciclo comienza de nuevo, con todo el historial ya disponible en el estado, permitiendo que el agente recuerde: "Ah, este es el usuario al que ya le recomendé 'El nombre del viento'."

Código en Acción: Implementación del Grafo del Recomendador

A continuación, presentamos la implementación completa y funcional de nuestro agente. Requiere la instalación de langgraph, langchain-openai (o el proveedor de LLM de tu elección) y pydantic. Usaremos un LLM de OpenAI como orquestador y generador, y simularemos una base de datos de libros.

1. Definición del Estado y Herramientas

Primero, definimos la estructura del estado usando Pydantic para tener validación de tipos. Luego, creamos la herramienta de búsqueda simulada.

from typing import List, Dict, Any, Annotated, Optional
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, AIMessage
import operator

# 1. Definición del Estado del Grafo
class EstadoAgente(TypedDict):
    """El estado compartido que fluye por el grafo."""
    messages: Annotated[List[Any], operator.add]  # Historial de mensajes (se acumula)
    genero: Optional[str]  # Preferencia de género extraída
    resultados_busqueda: Optional[List[Dict]]  # Resultados de la última búsqueda
    necesita_aclaracion: Optional[bool]  # Flag para pedir más datos

# 2. Base de Datos Simulada de Libros
BASE_LIBROS = [
    {"titulo": "El nombre del viento", "autor": "Patrick Rothfuss", "genero": "Fantasía", "nivel": "Adulto"},
    {"titulo": "Cien años de soledad", "autor": "Gabriel García Márquez", "genero": "Realismo mágico", "nivel": "Adulto"},
    {"titulo": "Jurassic Park", "autor": "Michael Crichton", "genero": "Ciencia Ficción", "nivel": "Joven Adulto"},
    {"titulo": "El hobbit", "autor": "J.R.R. Tolkien", "genero": "Fantasía", "nivel": "Todas las edades"},
    {"titulo": "Asesinato en el Orient Express", "autor": "Agatha Christie", "genero": "Misterio", "nivel": "Adulto"},
    {"titulo": "Ready Player One", "autor": "Ernest Cline", "genero": "Ciencia Ficción", "nivel": "Joven Adulto"},
    {"titulo": "Los juegos del hambre", "autor": "Suzanne Collins", "genero": "Distopía", "nivel": "Joven Adulto"},
    {"titulo": "It", "autor": "Stephen King", "genero": "Terror", "nivel": "Adulto"},
]

# 3. Herramienta de Búsqueda
@tool
def buscar_libros(genero: str, nivel: str = None) -> List[Dict]:
    """
    Busca libros en la base de datos por género y nivel de edad.
    Args:
        genero: El género literario (e.g., 'Fantasía', 'Ciencia Ficción').
        nivel: (Opcional) Nivel de edad ('Adulto', 'Joven Adulto', 'Todas las edades').
    Returns:
        Una lista de diccionarios con información de los libros.
    """
    resultados = []
    for libro in BASE_LIBROS:
        if libro["genero"].lower() == genero.lower():
            if nivel is None or libro["nivel"] == nivel:
                resultados.append(libro)
    # Si no hay resultados exactos, hacemos una búsqueda más flexible
    if not resultados and nivel is None:
        for libro in BASE_LIBROS:
            if genero.lower() in libro["genero"].lower():
                resultados.append(libro)
    return resultados[:5]  # Limitar a 5 resultados

2. Construcción de los Nodos del Grafo

Definimos las funciones que actuarán como nodos: orquestador, invocador de herramientas y generador de respuestas.

# 4. Configuración del LLM (Orquestador y Generador)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm_con_herramientas = llm.bind_tools([buscar_libros])

# 5. Nodo Orquestador (Router)
def nodo_orquestador(estado: EstadoAgente) -> Dict[str, str]:
    """
    Decide el próximo paso basado en el último mensaje del usuario.
    """
    last_message = estado["messages"][-1]
    # Si el último mensaje es del asistente, significa que acabamos de ejecutar una herramienta.
    # En ese caso, debemos generar una respuesta para el usuario.
    if isinstance(last_message, AIMessage) and last_message.tool_calls:
        return {"proximo_paso": "generar_respuesta"}

    # Si no, el último mensaje es del usuario. Usamos el LLM para decidir.
    prompt = f"""
    Eres el orquestador de un agente recomendador de libros.
    Historial: {estado['messages'][-3:]}
    ¿Cuál es el próximo paso?
    Opciones:
    - 'tool_biblioteca': Si el usuario pide una recomendación, sugiere un libro, o necesitas buscar en la base de datos. También si aclara sus preferencias (género, etc.).
    - 'finalizar': Si el usuario hace un comentario general, saluda, o si ya tienes información suficiente para responder sin buscar.
    - 'aclara': Si el usuario no ha especificado un género o la petición es demasiado vaga para buscar.

    Responde SOLO con una de las palabras: 'tool_biblioteca', 'finalizar' o 'aclara'.
    """
    decision_msg = HumanMessage(content=prompt)
    respuesta = llm.invoke([decision_msg])
    decision = respuesta.content.strip().lower()

    # Lógica simple para mapear la decisión
    if decision == "tool_biblioteca":
        return {"proximo_paso": "invocar_herramienta"}
    elif decision == "finalizar":
        return {"proximo_paso": "generar_respuesta"}
    else:  # 'aclara' o cualquier otra cosa
        return {"proximo_paso": "pedir_aclaracion"}

# 6. Nodo para Invocar la Herramienta
def nodo_invocar_herramienta(estado: EstadoAgente) -> Dict:
    """
    Usa un LLM para extraer parámetros de la conversación y llama a la herramienta de búsqueda.
    Los resultados se guardan en el estado.
    """
    # Usamos un LLM con capacidad de herramientas para extraer parámetros
    mensaje_actual = estado["messages"][-1]
    # Creamos un mensaje de sistema para guiar la extracción
    sistema = "Extrae el género literario y, si se menciona, el nivel de edad (Adulto, Joven Adulto) de la petición del usuario. Responde en formato JSON con claves 'genero' y 'nivel' (puede ser null)."
    mensaje_extractor = [{"role": "system", "content": sistema}, {"role": "user", "content": mensaje_actual.content}]

    # Usamos un modelo para extraer la estructura. En una versión más robusta, usarías una función de extracción.
    # Para simplicidad, usaremos un prompt.
    extract_prompt = f"""Del siguiente mensaje, extrae género y nivel:
    Mensaje: {mensaje_actual.content}
    Ejemplo de salida: {{"genero": "Fantasía", "nivel": "Joven Adulto"}}
    Salida:"""
    respuesta_extractor = llm.invoke([HumanMessage(content=extract_prompt)])
    # NOTA: En producción, usarías .parse() o una herramienta de extracción estructurada. Esto es una simulación.
    try:
        import json
        params = json.loads(respuesta_extractor.content)
        genero = params.get("genero", "Fantasía")  # Valor por defecto
        nivel = params.get("nivel")
    except:
        genero = "Fantasía"
        nivel = None

    # Llamada REAL a la herramienta
    resultados = buscar_libros.invoke({"genero": genero, "nivel": nivel})

    # Actualizamos el estado
    return {
        "resultados_busqueda": resultados,
        "genero": genero,
        "messages": [AIMessage(content=f"[Sistema: Busqué libros de {genero}. Encontré {len(resultados)} resultados.]")]
    }

# 7. Nodo para Generar Respuesta Final
def nodo_generar_respuesta(estado: EstadoAgente) -> Dict:
    """
    Genera una respuesta amigable para el usuario basada en el historial y los resultados.
    """
    historial = estado["messages"]
    resultados = estado.get("resultados_busqueda", [])

    prompt = f"""
    Eres un bibliotecario experto y amable.
    Historial de la conversación reciente:
    {historial[-4:]}

    {"Tienes los siguientes resultados de búsqueda para recomendar:" + str(resultados) if resultados else "No tengo resultados específicos de búsqueda."}

    Genera una respuesta útil y natural para el usuario. Si hay libros, recomienda 1 o 2 y explica brevemente por qué. Si no hay resultados, sugiere otros géneros o pide más detalles.
    Respuesta:
    """
    respuesta_final = llm.invoke([HumanMessage(content=prompt)])

    # Añadimos la respuesta del asistente al historial
    return {"messages": [AIMessage(content=respuesta_final.content)]}

# 8. Nodo para Pedir Aclaración
def nodo_pedir_aclaracion(estado: EstadoAgente) -> Dict:
    """Pide al usuario que aclare su petición."""
    mensaje_aclaracion = "¡Claro! Para recomendarte el libro perfecto, ¿podrías decirme qué género te apetece leer? (Por ejemplo: Fantasía, Ciencia Ficción, Misterio, Terror...)"
    return {"messages": [AIMessage(content=mensaje_aclaracion)], "necesita_aclaracion": True}

3. Ensamblaje y Ejecución del Grafo

Finalmente, conectamos todos los nodos con aristas condicionales para crear el grafo ejecutable.

# 9. Construcción del Grafo
grafo = StateGraph(EstadoAgente)

# Añadir nodos
grafo.add_node("orquestador", nodo_orquestador)
grafo.add_node("invocar_herramienta", nodo_invocar_herramienta)
grafo.add_node("generar_respuesta", nodo_generar_respuesta)
grafo.add_node("pedir_aclaracion", nodo_pedir_aclaracion)

# Establecer el punto de entrada
grafo.set_entry_point("orquestador")

# Añadir aristas CONDICIONALES desde el orquestador
grafo.add_conditional_edges(
    "orquestador",
    lambda estado: estado.get("proximo_paso", "aclara"),
    {
        "invocar_herramienta": "invocar_herramienta",
        "generar_respuesta": "generar_respuesta",
        "pedir_aclaracion": "pedir_aclaracion"
    }
)

# Aristas FIJAS desde otros nodos
grafo.add_edge("invocar_herramienta", "orquestador")  # Tras buscar, volver a orquestar
grafo.add_edge("generar_respuesta", END)  # Respuesta final -> Fin
grafo.add_edge("pedir_aclaracion", END)   # Tras pedir aclaración, espera nueva entrada del usuario

# Compilar el grafo
app = grafo.compile()

# 10. Función de Ejecución para el Usuario
def chatear_con_agente(mensaje_usuario: str, estado_previo: Dict = None):
    """
    Ejecuta un ciclo del agente con un mensaje del usuario.
    """
    if estado_previo is None:
        estado_inicial = {
            "messages": [HumanMessage(content=mensaje_usuario)],
            "genero": None,
            "resultados_busqueda": None,
            "necesita_aclaracion": False
        }
    else:
        estado_inicial = estado_previo
        estado_inicial["messages"].append(HumanMessage(content=mensaje_usuario))

    # Ejecutar el grafo
    resultado_final = app.invoke(estado_inicial)

    # Mostrar la última respuesta del asistente
    for msg in resultado_final["messages"]:
        if isinstance(msg, AIMessage) and msg.content and not msg.tool_calls:
            print(f"Asistente: {msg.content}")
            break

    return resultado_final

# 11. Ejemplo de Interacción
if __name__ == "__main__":
    print("--- Bienvenido al Agente Recomendador de Libros (Escribe 'salir' para terminar) ---")
    estado = None
    while True:
        user_input = input("\nTú: ")
        if user_input.lower() == 'salir':
            break
        estado = chatear_con_agente(user_input, estado)

Errores Comunes y Cómo Evitarlos

Al construir agentes con LangGraph, es fácil caer en ciertos patrones problemáticos. Aquí detallamos los más frecuentes y sus soluciones.

1. Estado Sobrecargado o Mal Diseñado: El error más grave es usar el estado como un cajón de sastre. Añadir docenas de claves hace que el grafo sea inmantenible y difícil de depurar. Solución: Diseña el estado con una estructura mínima y clara, preferiblemente usando una clase Pydantic o TypedDict. Agrupa datos relacionados. Usa anotaciones como Annotated[List, operator.add] para listas que se acumulan automáticamente, simplificando la lógica de los nodos.

2. Nodos con Efectos Secundarios Incontrolados: Si un nodo modifica recursos externos (archivos, APIs) de manera no manejada por el estado, puedes generar inconsistencias. Solución: Aísla todos los efectos secundarios en herramientas bien definidas (@tool). Los nodos principales (orquestador, generador) deben ser funciones puras que solo lean y escriban en el estado. Esto hace el flujo determinista y testeable.

3. Mala Gestión del Ciclo de Vida de los Mensajes: Olvidar añadir los mensajes del asistente al estado, o hacerlo en el formato incorrecto, rompe la memoria de conversación. Solución: Usa consistentemente los tipos de mensaje de langchain_core (HumanMessage, AIMessage, ToolMessage). Asegúrate de que cada nodo que produce una salida para el usuario la añada a state["messages"]. El patrón return {"messages": [nuevo_mensaje]} con el operador add en la definición del estado lo gestiona automáticamente.

4. Lógica Condicional Excesivamente Compleja en las Aristas: Anidar muchas condiciones en las funciones lambda de las aristas condicionales hace el grafo ilegible. Solución: Delegar la decisión a un nodo orquestador dedicado (como hicimos). Este nodo devuelve una cadena simple ("tool_biblioteca", "finalizar") que la arista condicional usa para enrutar. Mantén la lógica de decisión dentro de los nodos, no en las conexiones entre ellos.

5. No Manejar Fallos en Herramientas: Asumir que la llamada a una herramienta siempre tendrá éxito. Si la API falla o no hay resultados, el agente puede bloquearse o dar respuestas sin sentido. Solución: Implementa manejo de excepciones dentro de la función de la herramienta. Además, diseña el nodo que procesa los resultados para verificar si resultados_busqueda está vacío y actuar en consecuencia, por ejemplo, desviando el flujo a un nodo que pida más información o sugiera alternativas.

Tip de Depuración: Usa app.get_graph().draw_mermaid() para visualizar tu grafo. Ver la imagen te ayuda a identificar nodos huérfanos, ciclos infinitos o decisiones condicionales mal conectadas. Es la herramienta más poderosa para entender lo que realmente construiste.

Checklist de Dominio de la Lección

Antes de pasar al siguiente módulo, verifica que puedes realizar o explicar cada uno de estos puntos:

  • Definir una clase de Estado (con TypedDict o Pydantic) para un agente que incluya historial de mensajes y datos específicos de la aplicación.
  • Crear y decorar una herramienta (@tool) que interactúe con datos externos (simulados o reales).
  • Construir al menos tres nodos funcionales: un orquestador/ruter, un invocador de herramientas y un generador de respuestas.
  • Conectar nodos en un StateGraph usando tanto aristas fijas (add_edge) como aristas condicionales (add_conditional_edges).
  • Explicar el flujo de ejecución de un ciclo completo del agente: entrada de usuario -> orquestador
De lección a portfolio

Convertí esta lección en una habilidad visible para entrevistas.

Guardá el curso, completá los ejercicios y conectá esta habilidad con una ruta de empleo, data, IA, programación o marketing.

Newsletter Cursalo

Recibí rutas y cursos nuevos

Sumate para recibir recursos orientados a empleo y portfolio.

  • Rutas de empleo
  • Cursos prácticos
  • Portfolio y entrevistas

Sin spam. También podés entrar con tu cuenta para guardar progreso. Iniciá sesión