LangChain proyecto capstone

Experto
LangChain
LangChain
Actualizado: 02/12/2025

Visión general del proyecto

En este capstone construiremos un Asistente de Investigación Personal que integra todos los conceptos aprendidos durante el curso. Este agente combina búsqueda semántica en documentos propios mediante RAG, búsqueda web en tiempo real con Tavily, y memoria persistente para mantener el contexto de conversación y recordar preferencias del usuario.

El asistente permite al usuario cargar documentos de referencia, hacer preguntas sobre ellos, complementar la información con búsquedas web actualizadas, y guardar notas importantes para consultas futuras. Todo esto mientras muestra el progreso en tiempo real mediante streaming.

La arquitectura del sistema sigue este flujo:

[Usuario] → [Agente] → [Decisión: ¿qué herramienta usar?]
                            ↓
            ┌───────────────┼───────────────┐
            ↓               ↓               ↓
    [Búsqueda RAG]   [Búsqueda Web]   [Guardar Nota]
            ↓               ↓               ↓
            └───────────────┴───────────────┘
                            ↓
                    [Respuesta Final]

Configuración del entorno

Instalación de dependencias

Crea un archivo requirements.txt con las dependencias necesarias:

langchain>=1.1.0
langchain-openai>=0.3.0
langchain-chroma>=0.2.0
langchain-tavily>=0.1.0
langgraph>=0.4.0
python-dotenv>=1.0.0

Instala las dependencias ejecutando:

pip install -r requirements.txt

Variables de entorno

Crea un archivo .env en la raíz del proyecto con tus claves API:

OPENAI_API_KEY=tu_clave_openai
TAVILY_API_KEY=tu_clave_tavily

Estructura del proyecto

Organiza el proyecto con la siguiente estructura de archivos:

asistente_investigacion/
├── .env
├── requirements.txt
├── documentos/
│   └── (tus archivos .txt o .pdf)
├── datos/
│   └── chroma_db/
└── main.py

Configuración inicial del código

Comenzamos creando la estructura base del proyecto con las importaciones y carga de variables de entorno:

import os
from dotenv import load_dotenv

# Cargar variables de entorno
load_dotenv()

# Verificar configuración
if not os.getenv("OPENAI_API_KEY"):
    raise ValueError("OPENAI_API_KEY no configurada")
if not os.getenv("TAVILY_API_KEY"):
    raise ValueError("TAVILY_API_KEY no configurada")

print("Configuración cargada correctamente")

Creación de la base de conocimientos RAG

La base de conocimientos permite al agente consultar documentos propios mediante búsqueda semántica. Implementamos el pipeline RAG completo: carga de documentos, división en chunks, generación de embeddings y almacenamiento vectorial.

Carga y procesamiento de documentos

from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Cargar documentos desde el directorio
loader = DirectoryLoader(
    "documentos/",
    glob="**/*.txt",
    loader_cls=TextLoader
)
documents = loader.load()

# Dividir en chunks para mejor recuperación
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = text_splitter.split_documents(documents)

print(f"Documentos cargados: {len(documents)}")
print(f"Chunks generados: {len(chunks)}")

El RecursiveCharacterTextSplitter divide los documentos de forma inteligente respetando la estructura del texto. El overlap de 200 caracteres asegura que no se pierda contexto entre chunks adyacentes.

Creación del vector store con ChromaDB

from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

# Modelo de embeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# Crear o cargar el vector store persistente
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="datos/chroma_db",
    collection_name="base_conocimientos"
)

# Crear retriever con búsqueda MMR para diversidad
retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "fetch_k": 10}
)

Utilizamos MMR (Maximum Marginal Relevance) como estrategia de búsqueda para obtener resultados relevantes pero diversos, evitando documentos muy similares entre sí.

Herramienta de búsqueda RAG

Convertimos el retriever en una herramienta que el agente puede invocar:

from langchain.tools import tool
from typing import List

@tool(response_format="content_and_artifact")
def buscar_documentos(consulta: str) -> tuple[str, List[dict]]:
    """Busca información en la base de conocimientos local.
    
    Usa esta herramienta cuando el usuario pregunte sobre temas
    que podrían estar en los documentos cargados.
    
    Args:
        consulta: La pregunta o tema a buscar en los documentos
    """
    docs = retriever.invoke(consulta)
    
    if not docs:
        return "No se encontró información relevante en los documentos.", []
    
    # Formatear contenido para el modelo
    contenido = "\n\n".join([
        f"[Fuente: {doc.metadata.get('source', 'desconocida')}]\n{doc.page_content}"
        for doc in docs
    ])
    
    # Metadatos como artefacto
    metadatos = [
        {"fuente": doc.metadata.get("source"), "contenido": doc.page_content[:200]}
        for doc in docs
    ]
    
    return contenido, metadatos

El formato content_and_artifact separa el contenido que ve el modelo de los metadatos que puede usar la aplicación.

Herramientas del agente

Además de la búsqueda RAG, el agente dispone de herramientas para buscar en la web y guardar notas importantes.

Búsqueda web con Tavily

from langchain_tavily import TavilySearch

# Configurar herramienta de búsqueda web
buscar_web = TavilySearch(
    max_results=5,
    search_depth="basic",
    include_answer=True,
    include_raw_content=False
)
buscar_web.name = "buscar_web"
buscar_web.description = """Busca información actualizada en Internet.
Usa esta herramienta para consultas sobre eventos recientes, 
noticias, precios actuales o cualquier información que requiera datos en tiempo real."""

TavilySearch proporciona resultados optimizados para modelos de lenguaje, con información estructurada y fuentes verificadas.

Herramienta para guardar notas con acceso al Store

En LangChain 1.1.0, las herramientas pueden acceder al store del agente mediante el parámetro ToolRuntime. Esto permite que las herramientas lean y escriban en la memoria a largo plazo:

from langchain.tools import tool, ToolRuntime
from dataclasses import dataclass

@dataclass
class AppContext:
    """Contexto de la aplicación con el ID del usuario."""
    user_id: str

@tool
def guardar_nota(titulo: str, contenido: str, runtime: ToolRuntime[AppContext]) -> str:
    """Guarda una nota importante para referencia futura.
    
    Usa esta herramienta cuando el usuario quiera recordar
    algo específico de la investigación.
    
    Args:
        titulo: Título breve de la nota
        contenido: Contenido detallado a guardar
    """
    store = runtime.store
    user_id = runtime.context.user_id
    
    nota = {"titulo": titulo, "contenido": contenido}
    store.put(
        namespace=("notas", user_id),
        key=titulo.lower().replace(" ", "_"),
        value=nota
    )
    return f"Nota '{titulo}' guardada correctamente."

@tool
def listar_notas(runtime: ToolRuntime[AppContext]) -> str:
    """Lista todas las notas guardadas por el usuario."""
    store = runtime.store
    user_id = runtime.context.user_id
    
    items = list(store.search(("notas", user_id)))
    
    if not items:
        return "No hay notas guardadas."
    
    resultado = "Notas guardadas:\n"
    for item in items:
        nota = item.value
        resultado += f"- {nota['titulo']}: {nota['contenido'][:100]}...\n"
    
    return resultado

El ToolRuntime proporciona acceso al store y al contexto de la aplicación, permitiendo que las herramientas interactúen con la memoria a largo plazo de forma segura.

Construcción del agente con memoria

Configuración del agente

Creamos el agente utilizando create_agent con todas las herramientas configuradas:

from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

# Checkpointer para memoria a corto plazo (estado de conversación)
checkpointer = InMemorySaver()

# Store para memoria a largo plazo (datos persistentes entre sesiones)
store = InMemoryStore()

# Lista de herramientas disponibles
tools = [
    buscar_documentos,
    buscar_web,
    guardar_nota,
    listar_notas
]

# System prompt del asistente
system_prompt = """Eres un asistente de investigación personal experto y metódico.

Tu objetivo es ayudar al usuario a investigar cualquier tema combinando:
1. Búsqueda en documentos locales (base de conocimientos del usuario)
2. Búsqueda web para información actualizada
3. Gestión de notas para guardar hallazgos importantes

Directrices de comportamiento:
- Primero busca en los documentos locales si el tema podría estar ahí
- Complementa con búsqueda web cuando necesites información actualizada
- Cita las fuentes de información cuando sea relevante
- Sugiere guardar notas cuando encuentres información valiosa
- Sé conciso pero completo en tus respuestas

Siempre indica qué herramientas usaste para obtener la información."""

# Crear el agente
agent = create_agent(
    model="gpt-5.1",
    tools=tools,
    checkpointer=checkpointer,
    store=store,
    context_schema=AppContext,
    system_prompt=system_prompt
)

El InMemorySaver actúa como checkpointer para mantener el estado de la conversación dentro de un hilo. El InMemoryStore permite almacenar datos que persisten entre diferentes hilos de conversación.

Configuración de memoria a corto plazo

La memoria a corto plazo se gestiona mediante el thread_id en la configuración:

# Configuración para una sesión de conversación
config = {
    "configurable": {
        "thread_id": "sesion_investigacion_1"
    }
}

# Contexto con el ID del usuario
context = AppContext(user_id="usuario_1")

# El agente recuerda el contexto dentro del mismo thread
respuesta1 = agent.invoke(
    {"messages": [{"role": "user", "content": "Busca información sobre machine learning"}]},
    config=config,
    context=context
)

# En la siguiente pregunta, el agente recuerda el contexto anterior
respuesta2 = agent.invoke(
    {"messages": [{"role": "user", "content": "¿Qué aplicaciones tiene?"}]},
    config=config,
    context=context
)

Al mantener el mismo thread_id, el agente conserva todo el historial de la conversación y puede responder preguntas de seguimiento correctamente.

Streaming y respuestas estructuradas

Implementación de streaming

El streaming permite mostrar el progreso del agente en tiempo real:

def ejecutar_con_streaming(pregunta: str, config: dict, context: AppContext):
    """Ejecuta una consulta mostrando el progreso en tiempo real."""
    
    print(f"\n{'='*50}")
    print(f"Pregunta: {pregunta}")
    print(f"{'='*50}\n")
    
    for modo, chunk in agent.stream(
        {"messages": [{"role": "user", "content": pregunta}]},
        config=config,
        context=context,
        stream_mode=["updates", "messages"]
    ):
        if modo == "updates":
            for paso, datos in chunk.items():
                ultimo_mensaje = datos["messages"][-1]
                
                # Mostrar llamadas a herramientas
                if hasattr(ultimo_mensaje, "tool_calls") and ultimo_mensaje.tool_calls:
                    for tool_call in ultimo_mensaje.tool_calls:
                        print(f"[Herramienta] Usando: {tool_call['name']}")
                        print(f"[Herramienta] Args: {tool_call['args']}\n")
                
        elif modo == "messages":
            token, metadata = chunk
            # Mostrar tokens de respuesta final
            if metadata.get("langgraph_node") == "model":
                if hasattr(token, "content") and token.content:
                    print(token.content, end="", flush=True)
    
    print("\n")

Esta función muestra qué herramientas está usando el agente y va imprimiendo la respuesta token a token.

Respuestas estructuradas

Para ciertos casos, podemos solicitar respuestas en formato estructurado:

from pydantic import BaseModel, Field
from typing import List, Optional

class ResumenInvestigacion(BaseModel):
    """Estructura para resúmenes de investigación."""
    tema: str = Field(description="Tema principal de la investigación")
    puntos_clave: List[str] = Field(description="Puntos clave encontrados")
    fuentes_consultadas: List[str] = Field(description="Fuentes de información")
    proximos_pasos: Optional[List[str]] = Field(default=None, description="Siguientes pasos sugeridos")

# Usar con el modelo directamente para respuestas estructuradas
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-5.1")
llm_estructurado = llm.with_structured_output(ResumenInvestigacion)

def generar_resumen(tema: str, contenido: str) -> ResumenInvestigacion:
    """Genera un resumen estructurado de la investigación."""
    prompt = f"""Genera un resumen estructurado sobre: {tema}
    
    Contenido de la investigación:
    {contenido}
    """
    return llm_estructurado.invoke(prompt)

Aplicación completa

Código integrado final

A continuación el código completo del asistente de investigación:

import os
from dotenv import load_dotenv
from typing import List
from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.tools import tool, ToolRuntime
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_tavily import TavilySearch
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

# Cargar configuración
load_dotenv()

# === CONTEXTO DE APLICACIÓN ===
@dataclass
class AppContext:
    user_id: str

# === CONFIGURACIÓN RAG ===
def inicializar_vectorstore():
    """Inicializa o carga el vector store."""
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    
    if os.path.exists("documentos/") and os.listdir("documentos/"):
        loader = DirectoryLoader("documentos/", glob="**/*.txt", loader_cls=TextLoader)
        documents = loader.load()
        
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000, chunk_overlap=200
        )
        chunks = text_splitter.split_documents(documents)
        
        vectorstore = Chroma.from_documents(
            documents=chunks,
            embedding=embeddings,
            persist_directory="datos/chroma_db",
            collection_name="base_conocimientos"
        )
        print(f"Base de conocimientos creada con {len(chunks)} chunks")
    else:
        vectorstore = Chroma(
            embedding_function=embeddings,
            persist_directory="datos/chroma_db",
            collection_name="base_conocimientos"
        )
        print("Base de conocimientos vacía inicializada")
    
    return vectorstore

vectorstore = inicializar_vectorstore()
retriever = vectorstore.as_retriever(search_type="mmr", search_kwargs={"k": 4})

# === HERRAMIENTAS ===
@tool(response_format="content_and_artifact")
def buscar_documentos(consulta: str) -> tuple[str, List[dict]]:
    """Busca información en la base de conocimientos local."""
    docs = retriever.invoke(consulta)
    if not docs:
        return "No se encontró información en los documentos.", []
    
    contenido = "\n\n".join([
        f"[{doc.metadata.get('source', 'documento')}]\n{doc.page_content}"
        for doc in docs
    ])
    metadatos = [{"fuente": doc.metadata.get("source")} for doc in docs]
    return contenido, metadatos

buscar_web = TavilySearch(max_results=5, include_answer=True)
buscar_web.name = "buscar_web"
buscar_web.description = "Busca información actualizada en Internet."

@tool
def guardar_nota(titulo: str, contenido: str, runtime: ToolRuntime[AppContext]) -> str:
    """Guarda una nota para referencia futura."""
    store = runtime.store
    user_id = runtime.context.user_id
    store.put(("notas", user_id), titulo.lower().replace(" ", "_"), 
              {"titulo": titulo, "contenido": contenido})
    return f"Nota '{titulo}' guardada."

@tool
def listar_notas(runtime: ToolRuntime[AppContext]) -> str:
    """Lista las notas guardadas."""
    store = runtime.store
    user_id = runtime.context.user_id
    items = list(store.search(("notas", user_id)))
    if not items:
        return "No hay notas guardadas."
    return "\n".join([f"- {i.value['titulo']}" for i in items])

# === AGENTE ===
checkpointer = InMemorySaver()
store = InMemoryStore()

agent = create_agent(
    model="gpt-5.1",
    tools=[buscar_documentos, buscar_web, guardar_nota, listar_notas],
    checkpointer=checkpointer,
    store=store,
    context_schema=AppContext,
    system_prompt="""Eres un asistente de investigación. Combina búsqueda en documentos 
    locales y web para responder. Cita fuentes y sugiere guardar notas importantes."""
)

# === INTERFAZ ===
def chat():
    """Loop principal de interacción."""
    config = {"configurable": {"thread_id": "sesion_1"}}
    context = AppContext(user_id="usuario_1")
    
    print("\nAsistente de Investigación Personal")
    print("Comandos: 'salir' para terminar, 'nueva' para nueva sesión\n")
    
    while True:
        pregunta = input("Tú: ").strip()
        
        if pregunta.lower() == "salir":
            print("Hasta pronto.")
            break
        elif pregunta.lower() == "nueva":
            config["configurable"]["thread_id"] = f"sesion_{hash(pregunta)}"
            print("Nueva sesión iniciada.\n")
            continue
        elif not pregunta:
            continue
        
        print("\nAsistente: ", end="")
        for modo, chunk in agent.stream(
            {"messages": [{"role": "user", "content": pregunta}]},
            config=config,
            context=context,
            stream_mode=["updates", "messages"]
        ):
            if modo == "messages":
                token, meta = chunk
                if meta.get("langgraph_node") == "model" and hasattr(token, "content"):
                    print(token.content, end="", flush=True)
        print("\n")

if __name__ == "__main__":
    chat()

Ejemplo de sesión de uso

Asistente de Investigación Personal
Comandos: 'salir' para terminar, 'nueva' para nueva sesión

Tú: Busca información sobre redes neuronales en mis documentos

Asistente: He encontrado información en tus documentos sobre redes neuronales...
[Muestra contenido de los documentos locales]

Tú: ¿Cuáles son las últimas novedades en este campo?

Asistente: Según búsquedas recientes en la web, las principales novedades son...
[Muestra información actualizada de Tavily]

Tú: Guarda una nota con los puntos más importantes

Asistente: He guardado la nota "Puntos clave redes neuronales" con el resumen...

Tú: salir
Hasta pronto.

Este proyecto capstone demuestra cómo integrar RAG, herramientas personalizadas, búsqueda web, memoria a corto y largo plazo, y streaming en una aplicación cohesiva y funcional con LangChain 1.1.0. Los conceptos aprendidos durante el curso se combinan para crear un asistente de investigación completo que puede adaptarse a diferentes casos de uso.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, LangChain es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de LangChain

Explora más contenido relacionado con LangChain y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje