Miguel Amaya Camacho
Ingeniero Informático. Socio fundador de Tallanix S.A.C y de Xprende Tech. Activista del Software Libre y miembro fundador de la Comunidad Piurana de Software Libre VICUX y de la Comunidad de Programadores Python Piura.
Ing. José Miguel Amaya Camacho
Python Piura - Piura AI
miguel.amaya99@gmail.com
Los chatbots deben dar respuestas coherentes y útiles. Sin memoria, un chatbot "olvida" lo que el usuario le dijo antes, obligándolo a repetir datos en cada mensaje.
La memoria permite al bot recordar contexto y detalles, es una función cognitiva clave que ayuda al chatbot a adaptarse y ofrecer respuestas más relevantes
Incorporaremos memoria de corto y largo plazo en en un chatbot de Telegram. Usando Langchain y LangGraph y almacenando datos de memoria en MongoDB.
# INTRODUCCIÓN
# FUJO DE TRABAJO
🧑 Usuario
▼
💬 Telegram Bot
▼
📦 GraphBot (orquestador)
├── 🧠 Memoria a corto plazo (MongoDB) ├── 🧠 Memoria a largo plazo (MongoDB)
└── 🔁 LangGraph y LangChain
├── 📄 Prompt Templates
├── 🔗 Chains (LLM + lógica)
└── 🤖 Llamada al modelo (OpenAI, etc.)
▼
📝 Genera respuesta
▼
📩 Se envía de vuelta al usuario por Telegram
# LANGCHAIN
Telegram es una app de mensajería que permite crear bots conversacionales fácilmente.
Usamos un webhook en FastAPI que permite recibir actualizaciones de Telegram en tiempo real.
Usuario->>Telegram: Envía mensaje.
Telegram->>Webhook:
POST /webhook/{token}
Webhook->>Bot: Procesa actualización
Bot->>Usuario: Envía respuesta
# LANGCHAIN
LangChain es una herramienta para construir aplicaciones que usan LLMs(grandes modelos de lenguaje).
Hace fácil conectar el modelo con otras cosas:
🔗 Bases de datos
🧠 Memoria de conversación
📄 Documentos
🧰 Herramientas externas (como buscadores o APIs)
# LANGCHAIN
💡 Piensa en LangChain como el "cerebro" + "conectores"
👉 Tú defines:
Qué información darle al modelo
Qué hacer con su respuesta
📦 Ejemplos de lo que puedes crear:
Chatbots
Agentes autónomos
Asistentes que leen PDFs
# LANGCHAIN
Un prompt es el texto que le das al modelo para guiar su respuesta.
Piensa en el prompt como la personalidad + instrucciones para el bot.
CHARACTER_PROMPT = """
Estás a punto de jugar a un juego de prueba de Turing como parte de un experimento.
Te emparejarán con un humano en una conversación de chat.
Tu tarea es interpretar al siguiente personaje, convenciendo al humano de que eres un humano real.
## Biografía de Lucía
## Personalidad de Lucía
## Antecedentes del usuario
Esto es lo que sabes sobre el usuario gracias a conversaciones anteriores:
{memory_context}
Proporciona respuestas de texto simple, sin indicadores de formato ni metacomentarios.
"""# LANGCHAIN
Usamos también un prompt para extraer los datos relevantes del usuario y guardarlos en la memoria de largo plazo.
MEMORY_ANALYSIS_PROMPT = """Extraiga y formatee datos personales importantes del usuario a partir de su mensaje.
Céntrese en la información real, no en metacomentarios ni solicitudes.
Los datos importantes incluyen:
- Datos personales (nombre, edad, ubicación)
- Información profesional (trabajo, formación, habilidades)
- Preferencias (gustos, disgustos, favoritos)
- Circunstancias vitales (familia, relaciones)
- Experiencias o logros significativos
- Metas o aspiraciones personales
Examples:
Input: "Oye, ¿podrías recordar que me encanta Star Wars?"
Output: {{
"is_important": true,
"formatted_memory": "Le encanta Star Wars"
}}
"""# LANGCHAIN
Las Chains son secuencias de operaciones que procesan y transforman entradas para producir salidas específicas. Son como "tuberías" que conectan diferentes componentes.
En nuestro proyecto tenemos dos chains principales:
Memory Chain: Analiza mensajes para extraer información importante.
Character Chain: Genera respuestas en personaje.
# LANGCHAIN
def get_memory_chain():
model = ChatOpenAI(model="gpt-4o", temperature=0).with_structured_output(MemoryAnalysis)
prompt = ChatPromptTemplate.from_template(MEMORY_ANALYSIS_PROMPT)
return prompt | model
def get_character_chain():
model = ChatOpenAI(model="gpt-4o", temperature=0.5)
system_message = CHARACTER_PROMPT
prompt = ChatPromptTemplate.from_messages(
[
("system", system_message),
MessagesPlaceholder(variable_name="messages"),
]
)
return prompt | model# LANGGRAPH
LangGraph es una "extensión" de LangChain para crear agentes con lógica compleja
💡 Usa grafos para definir flujos de decisión entre pasos
📌 Cada nodo del grafo hace algo:
Llama al modelo
Consulta memoria
Toma decisiones
Guarda estado
# LANGGRAPH
Soporta memoria de largo plazo
Fácil de visualizar y depurar
Ideal para bots con múltiples habilidades
# GRAFO
def create_workflow_graph(memories_retriever=None):
graph_builder = StateGraph(StateBot)
if memories_retriever is None:
memories_retriever = get_retriever_mongodb(
k=5, collection_name="memories", index_name="memories-vector-index",
filters=["chat_id"]
)
graph_builder.add_node("memory_extraction_node",
partial(memory_extraction_handler, retriever=memories_retriever))
graph_builder.add_node("memory_injection_node",
partial(memory_injection_handler, retriever=memories_retriever))
graph_builder.add_node("generate_response", generate_response)
graph_builder.add_edge(START, "memory_extraction_node")
graph_builder.add_edge("memory_extraction_node", "memory_injection_node")
graph_builder.add_edge("memory_injection_node", "generate_response")
graph_builder.add_edge("generate_response", END)
return graph_builder# ESTADO
from langgraph.graph import MessagesState
class StateBot(MessagesState):
memory_context: str# NODOS
# MEMORIA
En la imagen se ve cómo la memoria a corto plazo (short-term) conserva el diálogo reciente de un mismo hilo conversacional, mientras que la memoria a largo plazo (long-term) guarda conocimientos generales (p.ej. datos personales) en un almacén externo.
# SHORT TERM
Contexto y mensajes recientes de una sola conversación. LangGraph la trata como parte del estado interno del agente, persistido con un checkpoint (MemorySaver) por cada hilo de conversación. Esto permite retomar la sesión sin perder el hilo.
# LONG TERM
Conocimientos que se comparten entre conversaciones distintas. Son datos permanentes (p.ej. perfil del usuario, hechos guardados) que no dependen de un solo hilo. LangChain provee stores especiales para guardar y recuperar estos recuerdos semánticos
# LONG TERM
# MEMORIA
# SUPABASE
# SUPABASE
Usamos AsyncMongoDBSaver de langgraph.checkpoint.mongodb para conectarnos a la DB_URI de supabase.
Cada conversación se guarda con un thread_id
async def reply(self, chat_id, text=None):
config = {"configurable": {"thread_id": str(chat_id), "chat_id": str(chat_id)}}
async with AsyncMongoDBSaver.from_conn_string(
settings.MONGO_DB_URL,
db_name=settings.MONGO_DB_NAME
) as checkpointer:
graph = self.graph_builder.compile(checkpointer=checkpointer)
await graph.ainvoke({"messages": [HumanMessage(content=text)]}, config)
output_state = await graph.aget_state(config=config)
response_message = output_state.values["messages"][-1].content
return response_message# SUPABASE
El hilo de la conversación
El estado del grafo del flujo del bot: mensajes y otros.
Todo se guarda automáticamente al usar:
checkpointer=checkpointer
Se generan las tablas de la imagen
# SUPABASE
Lo que hace
🔠 Texto completo: busca palabras y frases en documentos.
🧠 Búsqueda vectorial: encuentra resultados por similitud semántica.
⚙️ Filtros combinados: mezcla búsquedas por texto y campos (ej. chat_id).
# SUPABASE
# VECTOR STORE
class MongoDBVectorStore:
_instances: ClassVar[Dict[str, MongoDBAtlasVectorSearch]] = {}
EMBEDDING_DIMENSION = 1536
@classmethod
def get_instance(
cls,
collection_name: str,
index_name: str,
filters: Optional[list] = None
) -> MongoDBAtlasVectorSearch:
instance_key = f"{collection_name}:{index_name}"
if instance_key not in cls._instances:
collection = cls._get_collection(collection_name)
vector_store = cls._create_vector_store(collection, index_name)
cls._ensure_vector_index(vector_store, collection, index_name, filters)
cls._instances[instance_key] = vector_store
return cls._instances[instance_key]# VECTOR STORE
_get_collection() → conecta y obtiene la colección
_create_vector_store() → crea el vector store
_ensure_vector_index() → verifica o crea el índice
@staticmethod
def _get_collection(collection_name: str):
MongoDBConnection.connect_to_sync_mongo()
db = MongoDBConnection.get_sync_db()
return db[collection_name]
@staticmethod
def _create_vector_store(collection, index_name: str) -> MongoDBAtlasVectorSearch:
return MongoDBAtlasVectorSearch(
collection=collection,
embedding=get_embeddings(),
index_name=index_name,
relevance_score_fn="cosine"
)# VECTOR STORE
@classmethod
def _ensure_vector_index(
cls,
vector_store: MongoDBAtlasVectorSearch,
collection,
index_name: str,
filters: Optional[list]
):
existing_indexes = collection.list_search_indexes()
existing_names = [idx["name"] for idx in existing_indexes]
if index_name not in existing_names:
try:
vector_store.create_vector_search_index(
dimensions=cls.EMBEDDING_DIMENSION,
filters=filters or []
)
except Exception as e:
print(f"Warning: No se pudo crear el índice vectorial: {e}")# RETRIEVER
@lru_cache
def get_mongo_db_vector_store() -> MongoDBVectorStore:
return MongoDBVectorStore()# RETRIEVER
def get_retriever_mongodb(
k: int, collection_name: str, index_name: str, filters: list
):
vector_store = get_mongo_db_vector_store().get_instance(
collection_name=collection_name,
index_name=index_name,
filters=filters
)
return vector_store.as_retriever(search_kwargs={"k": k})# EXTRACCIÓN
async def memory_extraction_node(
state: StateBot, retriever, config: RunnableConfig
):
chain = get_memory_chain()
response = await chain.ainvoke({"message": state["messages"][-1]})
if response.is_important and retriever:
config = config.get("configurable")
chat_id = config.get("chat_id")
try:
await retriever.vectorstore.aadd_texts(
texts=[response.formatted_memory],
metadatas=[{"chat_id": chat_id}]
)
except Exception as e:
print(f"Error almacenando memoria: {str(e)}")
return {}# INYECCIÓN
async def memory_injection_node(
state: StateBot, retriever, config: RunnableConfig
):
if not retriever:
return {"memory_context": ""}
config = config.get("configurable")
chat_id = config.get("chat_id")
last_message = state["messages"][-1].content
try:
relevant_docs = await retriever.ainvoke(
last_message,
pre_filter={"chat_id": chat_id}
)
memory_context = "\n".join(doc.page_content for doc in relevant_docs)
return {"memory_context": memory_context}
except Exception as e:
print(f"Error recuperando memorias: {str(e)}")
return {"memory_context": ""}# DOCUMENTS
# GENERACIÓN
async def generate_response(state: StateBot, config: RunnableConfig):
memory_context = state.get('memory_context')
chain = get_character_chain()
response = await chain.ainvoke(
{
"messages": state['messages'],
"memory_context": memory_context,
},
config,
)
return {"messages": response}# RESULTADOS
En la imagen siguiente le pregunté mi nombre sin habérselo dado, cuando ya se lo doy lo recuerda perfectamente.
# RESULTADOS
En la imagen siguiente borré las tablas que guardan la memoria de corto plazo, pero dejé las de largo plazo
By Miguel Amaya Camacho
Ingeniero Informático. Socio fundador de Tallanix S.A.C y de Xprende Tech. Activista del Software Libre y miembro fundador de la Comunidad Piurana de Software Libre VICUX y de la Comunidad de Programadores Python Piura.