DocuBot ya responde, hace streaming y sobrevive a los errores — pero olvida cada pregunta en cuanto la contesta. Si un agente de soporte pregunta "¿cómo exporto un proyecto a CSV?" y a continuación "¿y eso está disponible en el plan Starter?", el bot no tiene ni idea de qué es "eso". En esta lección construimos la capa de conversación: entenderemos por qué los LLMs no tienen memoria y cómo la ilusión de continuidad se fabrica reenviando el historial completo en cada llamada, implementaremos una clase Conversacion en Python, mediremos el problema que esto crea (un historial que crece devora ventana de contexto y presupuesto) y compararemos las estrategias para contenerlo. Cerraremos con el salto a multi-usuario: aislar la conversación de cada empleado de Nubelia.
Contenido
- Los LLMs no tienen memoria: la ilusión de la conversación
- La clase
Conversacionen Python - El historial crece: límites de ventana y coste creciente
- Estrategias de gestión del historial
- Multi-usuario: sesiones aisladas y persistencia simple
- Cuando lo que falta no es historial: adelanto de RAG
Los LLMs no tienen memoria: la ilusión de la conversación
Recuperemos la limitación que catalogamos en 01-04: un LLM es sin estado (stateless). La API no guarda nada entre llamadas: ni quién eres, ni qué preguntaste hace diez segundos. Cada petición es, para el modelo, la primera y la única.
Entonces, ¿cómo es que cualquier chat con un LLM "recuerda" lo que dijiste? Porque el cliente reenvía la conversación entera en cada llamada. La memoria no está en el modelo; está en tu lista messages:
# Llamada 1: el modelo ve UNA pregunta
messages = [
{"role": "user", "content": "¿Cómo exporto un proyecto a CSV?"},
]
# Llamada 2: el modelo ve TODA la conversación, otra vez desde cero
messages = [
{"role": "user", "content": "¿Cómo exporto un proyecto a CSV?"},
{"role": "assistant", "content": "Ve a Proyecto > Ajustes > Exportar > CSV..."},
{"role": "user", "content": "¿Y eso está disponible en el plan Starter?"},
]En la segunda llamada, el modelo puede resolver "eso" porque el turno anterior viaja en la petición. No lo recuerda: lo relee. Aquí encajan por fin los roles user/assistant que definimos en 02-01 — el rol assistant existe precisamente para reinyectar lo que el propio modelo dijo antes.
Tres consecuencias inmediatas de este diseño:
- Cada llamada paga la conversación entera como tokens de entrada. El turno 20 reenvía (y factura) los 19 anteriores.
- La conversación es tuya. El proveedor no la guarda por ti a efectos de la API: si tu proceso muere y no la persististe, no existió.
- Puedes editar el pasado. Como el historial es una lista que tú construyes, puedes acortarlo, resumirlo o filtrarlo. Esta flexibilidad es exactamente lo que explotaremos en la sección de estrategias.
La clase Conversacion en Python
Encapsulamos el patrón en una clase reutilizable. Decisiones de diseño: el system prompt va aparte del historial (viaja en su propio parámetro, no es un turno más), y la clase reutiliza el pipeline robusto de 03-02:
# conversacion.py
import anthropic
from cliente import MODELO, extraer_texto
from reintentos import llamar_con_reintentos
from prompts import SYSTEM_DOCUBOT
class Conversacion:
"""Conversación multi-turno con un LLM.
Mantiene el historial user/assistant y lo reenvía completo en cada
llamada. El system prompt se mantiene aparte: no forma parte de la
lista de mensajes.
"""
def __init__(self, system: str = SYSTEM_DOCUBOT,
modelo: str = MODELO, max_tokens: int = 1024):
self.system = system
self.modelo = modelo
self.max_tokens = max_tokens
self.mensajes: list[dict] = [] # solo turnos user/assistant
self.tokens_entrada_total = 0 # contabilidad acumulada
self.tokens_salida_total = 0
def enviar(self, texto_usuario: str) -> str:
"""Añade el turno del usuario, llama a la API y registra la respuesta."""
self.mensajes.append({"role": "user", "content": texto_usuario})
try:
respuesta = llamar_con_reintentos(
model=self.modelo,
max_tokens=self.max_tokens,
system=self.system,
messages=self.mensajes, # TODO el historial, cada vez
)
except anthropic.APIError:
# No dejar el historial desbalanceado: retirar el turno fallido
self.mensajes.pop()
raise
texto = extraer_texto(respuesta)
# El turno del asistente DEBE guardarse: es lo que le da memoria
self.mensajes.append({"role": "assistant", "content": texto})
self.tokens_entrada_total += respuesta.usage.input_tokens
self.tokens_salida_total += respuesta.usage.output_tokens
return texto
@property
def num_turnos(self) -> int:
"""Un turno = un par pregunta/respuesta."""
return len(self.mensajes) // 2Detalles que importan:
- El
pop()delexcept. Si la llamada falla, el turnouserya estaba añadido. Dejarlo produciría dosuserseguidos sin respuesta entre medias en el siguiente intento — historial corrupto. Deshacer en caso de error mantiene el invariante "user y assistant alternan". - Guardar la respuesta del asistente no es opcional. Si olvidas el
appenddelassistant, el modelo nunca ve sus propias respuestas anteriores y la "memoria" desaparece silenciosamente — el bug más común en esta capa. - La contabilidad de tokens (
tokens_entrada_total,tokens_salida_total) parece burocracia ahora; en la siguiente sección la usaremos para ver el problema del crecimiento, y en 03-04 para ponerle precio.
Uso:
chat = Conversacion()
print(chat.enviar("¿Cómo exporto un proyecto a CSV?"))
print(chat.enviar("¿Y eso está disponible en el plan Starter?")) # "eso" ya resuelve
print(f"Turnos: {chat.num_turnos}, tokens de entrada acumulados: {chat.tokens_entrada_total}")El historial crece: límites de ventana y coste creciente
La ilusión de memoria tiene un precio compuesto. Supongamos números ficticios pero realistas para DocuBot: system prompt ~600 tokens, cada pregunta ~50 tokens, cada respuesta ~250 tokens. La entrada de cada llamada es system + todo el historial + pregunta nueva:
| Turno | Historial previo (tokens) | Entrada de esta llamada | Entrada acumulada pagada |
|---|---|---|---|
| 1 | 0 | ~650 | ~650 |
| 5 | ~1.200 | ~1.850 | ~6.400 |
| 10 | ~2.700 | ~3.350 | ~19.500 |
| 25 | ~7.200 | ~7.850 | ~106.000 |
| 50 | ~14.700 | ~15.350 | ~400.000 |
Dos lecturas de la tabla:
- La entrada por llamada crece linealmente con los turnos... pero el gasto acumulado crece cuadráticamente, porque cada turno re-paga todos los anteriores. Una conversación de 50 turnos no cuesta 50 veces la primera llamada: cuesta cientos de veces más en tokens de entrada.
- La ventana de contexto es finita (01-04). Cuando system + historial + pregunta + hueco para la respuesta supere la ventana del modelo, la API devolverá el 400 de contexto excedido que catalogamos en 03-02. Con ventanas grandes tardará en llegar, pero es un muro determinista: sin gestión, toda conversación suficientemente larga muere ahí.
Y hay un tercer problema más sutil, que ya conoces de 01-04: "lost in the middle". Aunque quepa, un historial enorme diluye la atención — lo importante enterrado en el turno 12 de 40 pesa menos que lo que está al principio o al final. Más historial no es siempre mejor memoria; a partir de cierto punto es peor señal.
Conclusión: la clase Conversacion necesita una política de gestión del historial. No es una optimización — es supervivencia.
Estrategias de gestión del historial
Estrategia 1: truncado por turnos
La más simple: conservar solo los últimos N turnos completos.
def truncar_por_turnos(mensajes: list[dict], max_turnos: int = 10) -> list[dict]:
"""Conserva los últimos max_turnos pares user/assistant.
Corta SIEMPRE en frontera de par: el primer mensaje del resultado
debe ser un 'user' (la API exige empezar por user).
"""
max_mensajes = max_turnos * 2
if len(mensajes) <= max_mensajes:
return mensajes
recortado = mensajes[-max_mensajes:]
# Garantizar que empieza en 'user' (por si el corte cayó a mitad de par)
while recortado and recortado[0]["role"] != "user":
recortado = recortado[1:]
return recortadoSe aplica dentro de enviar(), justo antes de la llamada: messages=truncar_por_turnos(self.mensajes). Nota que truncamos lo que se envía, no necesariamente lo que se guarda — puedes conservar el historial íntegro en local (para auditoría o para la interfaz) y enviar solo la cola.
Ventaja: trivial, coste acotado, cero llamadas extra. Inconveniente: la memoria es amnesia diferida — lo dicho hace 11 turnos desaparece por completo, aunque fuera el dato clave ("mi empresa usa el plan Business").
Estrategia 2: resumen del historial con el propio LLM
Cuando el historial supera un umbral, se pide al propio modelo que comprima los turnos viejos en un resumen, y ese resumen sustituye a los turnos que resume:
PROMPT_RESUMEN = """Resume la siguiente conversación de soporte en menos de 150
palabras. Conserva: datos concretos del usuario (plan, configuración, nombres
de proyectos), el problema tratado y lo ya resuelto. Omite cortesías.
<conversacion>
{conversacion}
</conversacion>"""
def resumir_historial(mensajes: list[dict], conservar_turnos: int = 4) -> list[dict]:
"""Comprime los turnos antiguos en un resumen; conserva los recientes tal cual."""
limite = conservar_turnos * 2
if len(mensajes) <= limite:
return mensajes
antiguos, recientes = mensajes[:-limite], mensajes[-limite:]
texto = "\n".join(f"{m['role']}: {m['content']}" for m in antiguos)
respuesta = llamar_con_reintentos(
model=MODELO,
max_tokens=300,
messages=[{"role": "user", "content": PROMPT_RESUMEN.format(conversacion=texto)}],
)
resumen = extraer_texto(respuesta)
# El resumen se inyecta como un par user/assistant sintético al inicio
return [
{"role": "user", "content": f"[Resumen de la conversación anterior]\n{resumen}"},
{"role": "assistant", "content": "Entendido, tengo el contexto de la conversación anterior."},
] + recientesFíjate en el prompt de resumen: dice qué conservar (datos concretos, decisiones), no solo "resume". Un resumen genérico pierde justo lo que importaba. Aun así, el resumen es con pérdida: es el propio LLM decidiendo qué merece sobrevivir, y a veces se equivoca. Además cuesta una llamada extra cada vez que se compacta (por eso se hace por umbral, no en cada turno).
Estrategia 3: ventana deslizante (híbrida)
En la práctica, la configuración más usada combina las anteriores: un resumen acumulado al principio (memoria de largo plazo, comprimida) + los últimos N turnos literales (memoria de corto plazo, intacta). Cada vez que el historial reciente desborda, los turnos más viejos se funden en el resumen — la ventana "se desliza" dejando tras de sí un sedimento resumido. Es la estrategia 2 aplicada de forma incremental, con la estructura de la 1.
Comparativa
| Estrategia | Coste extra | Fidelidad de la memoria | Complejidad | Cuándo usarla |
|---|---|---|---|---|
| Enviar todo (sin gestión) | Creciente sin límite | Perfecta hasta chocar con la ventana | Nula | Prototipos; conversaciones garantizadas cortas |
| Truncado por turnos | Ninguno | Nula más allá de N turnos | Muy baja | Consultas mayormente independientes; DocuBot v1 |
| Resumen con LLM | Una llamada por compactación | Alta pero con pérdida (decide el LLM) | Media | Conversaciones largas donde el contexto antiguo importa |
| Ventana deslizante | Una llamada por deslizamiento | Reciente perfecta + antigua comprimida | Media-alta | Asistentes de sesión larga; la opción por defecto en producción |
Para DocuBot elegimos truncado a 10 turnos como política inicial: las consultas de soporte suelen resolverse en pocos turnos y la simplicidad gana. La decisión queda documentada y es reversible — si la evaluación de 02-04 (que ahora podemos correr contra conversaciones reales) muestra pérdida de contexto, la ventana deslizante es el siguiente paso. Apunte para 03-04: la estrategia de historial también es una palanca de coste, y el resumen jugará un segundo papel allí.
Multi-usuario: sesiones aisladas y persistencia simple
Hasta aquí, una sola conversación. Pero DocuBot servirá a todos los empleados de Nubelia a la vez, y esto impone una regla absoluta: la conversación de un usuario jamás puede contaminar la de otro. Que Ana pregunte por permisos no debe hacer que a Berta le respondan sobre permisos — y peor aún sería filtrar entre sesiones información de un cliente (privacidad, 01-04 y módulo 6).
La solución mínima: un diccionario de sesiones indexado por un identificador de usuario/sesión, con creación perezosa:
# sesiones.py
import json
import pathlib
class GestorSesiones:
"""Aísla una Conversacion por usuario/sesión. Persistencia simple en JSON."""
def __init__(self, directorio: str = "./sesiones"):
self.sesiones: dict[str, Conversacion] = {}
self.directorio = pathlib.Path(directorio)
self.directorio.mkdir(exist_ok=True)
def conversacion(self, id_sesion: str) -> Conversacion:
"""Devuelve la conversación de la sesión, creándola o cargándola si hace falta."""
if id_sesion not in self.sesiones:
self.sesiones[id_sesion] = self._cargar(id_sesion) or Conversacion()
return self.sesiones[id_sesion]
def preguntar(self, id_sesion: str, texto: str) -> str:
conv = self.conversacion(id_sesion)
respuesta = conv.enviar(texto)
self._guardar(id_sesion, conv) # persistir tras cada turno
return respuesta
# --- persistencia simple: un fichero JSON por sesión ---
def _ruta(self, id_sesion: str) -> pathlib.Path:
seguro = "".join(c for c in id_sesion if c.isalnum() or c in "-_")
return self.directorio / f"{seguro}.json"
def _guardar(self, id_sesion: str, conv: Conversacion):
self._ruta(id_sesion).write_text(
json.dumps(conv.mensajes, ensure_ascii=False), encoding="utf-8"
)
def _cargar(self, id_sesion: str) -> "Conversacion | None":
ruta = self._ruta(id_sesion)
if not ruta.exists():
return None
conv = Conversacion()
conv.mensajes = json.loads(ruta.read_text(encoding="utf-8"))
return convUso — dos empleados ficticios, cero interferencia:
gestor = GestorSesiones()
gestor.preguntar("empleada-ana", "¿Cómo doy permisos de edición a un colaborador?")
gestor.preguntar("empleado-bruno", "¿Cómo conecto el calendario?")
gestor.preguntar("empleada-ana", "¿Y cómo se los quito?") # resuelve con SU historialNotas de alcance honestas:
- El identificador de sesión debe venir de la autenticación real (el SSO de Nubelia, la sesión web), nunca de un campo que el usuario escriba. Un
id_sesionmanipulable es acceso al historial de otro. - La persistencia en JSON por fichero es didáctica y suficiente para un piloto; en producción, con concurrencia real, esto es una base de datos (aunque sea SQLite) y una política de expiración/borrado de sesiones — el historial de soporte contiene información interna y le aplican las normas de privacidad del módulo 6 (06-02).
- El
dictvive en memoria de un proceso. Con varios procesos o servidores, la persistencia compartida deja de ser opcional: es el único estado real.
Cuando lo que falta no es historial: adelanto de RAG
Cerremos con una distinción conceptual que ordena todo el curso. En esta lección hemos gestionado un tipo de contexto: lo que se ha dicho en esta conversación. Pero cuando DocuBot no sabe responder, casi nunca es porque le falte un turno antiguo — es porque le falta conocimiento: el fragmento correcto de la documentación de Nubelia.
La tentación ingenua sería "meter más cosas en el contexto": toda la documentación en el system prompt, historiales eternos. Ya sabes por qué fracasa — ventana finita, coste cuadrático, lost in the middle. La respuesta correcta es otra: buscar, para cada pregunta, solo los fragmentos relevantes de la documentación e inyectarlos en la llamada. Eso es la generación aumentada por recuperación (RAG), y es exactamente el módulo 4: embeddings y búsqueda semántica (04-01), bases de datos vectoriales (04-02), ingesta y chunking (04-03) y el pipeline completo (04-04). La regla mnemotécnica: historial para lo dicho, RAG para lo sabido. Hoy has construido la mitad de la ecuación; la otra mitad empieza en el próximo módulo — pero antes, una parada obligatoria: saber cuánto cuesta todo esto.
Errores Comunes y Consejos
- Olvidar guardar el turno del asistente. El síntoma es sutil: nada falla, pero el bot "no se entera" de sus propias respuestas anteriores. Verifica que el historial alterna user/assistant y crece de dos en dos.
- Meter el system prompt como primer mensaje
user. Funciona a medias y degrada el cumplimiento de reglas: el system prompt tiene su canal propio (02-01) y ahí debe ir. Además, mezclado en el historial acabaría truncado por tu propia política de gestión. - Truncar a mitad de par. Un historial que empieza por
assistanto con dosuserseguidos provoca errores de la API o comportamiento errático. Trunca siempre en frontera de turno completo. - Dejar el historial corrupto tras un error. Si la llamada falla después del
appenddel turnouser, retíralo (elpop()de la clase). Historial desbalanceado hoy = error extraño mañana. - Compartir una
Conversacionglobal entre usuarios "de momento". Es la fuga de contexto (y potencialmente de datos) más fácil de crear y más difícil de detectar. Aislamiento por sesión desde el primer día. - Consejo: registra
usage.input_tokenspor turno (la clase ya acumula). Si crece linealmente turno a turno, tu política de gestión no está funcionando; con truncado activo debería estabilizarse en meseta. - Consejo: al depurar "el bot ha olvidado X", imprime exactamente la lista
messagesque se envió en esa llamada. La respuesta siempre está ahí: o X ya no viaja (truncado/resumen agresivo), o viaja enterrado en medio de un historial enorme (lost in the middle).
Ejercicios
Ejercicio 1
Añade a Conversacion un parámetro max_turnos_enviados: int | None = None. Si no es None, enviar() debe pasar por truncar_por_turnos() lo que se envía a la API, pero conservando self.mensajes completo en memoria. Añade también una propiedad mensajes_enviados_ultima_llamada para poder inspeccionar qué vio realmente el modelo.
Ejercicio 2
Escribe una simulación (sin API real: usa longitudes estimadas, 1 token ≈ 0,75 palabras como en 01-02) que compare los tokens de entrada acumulados tras 30 turnos con tres políticas: sin gestión, truncado a 10 turnos y ventana deslizante (resumen de 200 tokens + 4 turnos literales). Imprime una tabla.
Ejercicio 3
Diseña el caso de prueba de aislamiento de sesiones: usando GestorSesiones, la sesión A establece un dato ("uso el plan Business") y la sesión B pregunta después "¿qué plan uso?". Define cuál es el comportamiento correcto de DocuBot en B según el system prompt de 02-01 y escribe la aserción (pista: la frase anti-alucinación exacta de 02-01 es detectable).
Soluciones
Solución 1:
class Conversacion:
def __init__(self, system=SYSTEM_DOCUBOT, modelo=MODELO,
max_tokens=1024, max_turnos_enviados=None):
# ... como antes ...
self.max_turnos_enviados = max_turnos_enviados
self._ultimo_envio: list[dict] = []
def enviar(self, texto_usuario: str) -> str:
self.mensajes.append({"role": "user", "content": texto_usuario})
a_enviar = self.mensajes
if self.max_turnos_enviados is not None:
a_enviar = truncar_por_turnos(self.mensajes, self.max_turnos_enviados)
self._ultimo_envio = a_enviar
# ... llamada con messages=a_enviar, resto igual ...
@property
def mensajes_enviados_ultima_llamada(self):
return self._ultimo_envioLa separación "historial completo en memoria / historial recortado en la petición" es el patrón correcto: la interfaz puede seguir mostrando toda la conversación aunque el modelo solo vea la cola.
Solución 2:
T_SYSTEM, T_PREGUNTA, T_RESPUESTA, T_RESUMEN = 600, 50, 250, 200
TURNO = T_PREGUNTA + T_RESPUESTA # 300 tokens por par
def entrada(turno: int, politica: str) -> int:
if politica == "sin_gestion":
historial = (turno - 1) * TURNO
elif politica == "truncado_10":
historial = min(turno - 1, 10) * TURNO
elif politica == "deslizante":
recientes = min(turno - 1, 4) * TURNO
historial = recientes + (T_RESUMEN if turno - 1 > 4 else 0)
return T_SYSTEM + historial + T_PREGUNTA
for politica in ("sin_gestion", "truncado_10", "deslizante"):
total = sum(entrada(t, politica) for t in range(1, 31))
print(f"{politica:>12}: entrada acumulada ~{total:,} tokens")Resultados aproximados: sin gestión ~150.000 tokens; truncado ~90.000; deslizante ~65.000 (más el coste de las llamadas de resumen, que esta simulación no incluye — inclúyelo mentalmente al decidir). La brecha se dispara con más turnos: a 100 turnos, sin gestión supera el millón y las otras dos siguen planas por turno.
Solución 3:
gestor = GestorSesiones()
gestor.preguntar("sesion-a", "Uso el plan Business. ¿Puedo exportar a CSV?")
respuesta_b = gestor.preguntar("sesion-b", "¿Qué plan uso yo?")
# Comportamiento correcto: B no tiene ese dato en SU historial ni en la
# documentación proporcionada, y la regla 3 de SYSTEM_DOCUBOT obliga a:
assert "No encuentro esa información en la documentación disponible." in respuesta_b
assert "Business" not in respuesta_b # el dato de A no debe filtrarse JAMÁSLa segunda aserción es la importante: si "Business" aparece en la sesión B, tienes una fuga de contexto entre sesiones — un bug de privacidad, no de calidad. Este caso pertenece al conjunto de evaluación permanente (02-04, tipo adversarial suave) y lo reencontraremos en el módulo 6.
Conclusión
DocuBot ya conversa: sabes que la memoria de un LLM es una ilusión fabricada por el cliente — cada llamada reenvía el historial completo, con el system prompt aparte —, tienes la clase Conversacion que mantiene ese historial con sus invariantes (alternancia de roles, deshacer en error, contabilidad de tokens), has visto por qué crece hasta chocar con la ventana de contexto y disparar el coste (lineal por llamada, cuadrático acumulado), y dispones de tres estrategias medibles para contenerlo — truncado, resumen con el propio LLM y ventana deslizante — más el GestorSesiones que aísla y persiste la conversación de cada empleado de Nubelia. Y dejamos plantada la distinción que abre el módulo 4: historial para lo dicho, RAG para lo sabido. Pero antes de recuperar conocimiento hay que cerrar la cuenta pendiente que esta lección ha hecho crecer: cada token del historial se paga, y todavía no sabemos cuánto. En la próxima lección ponemos números a DocuBot — precios por token, la proyección mensual que refina aquella estimación de ~400 €/mes de 01-04, las palancas para reducirla, la latencia como segunda moneda y el caching como la palanca que ataca a las dos.
Curso de IA Generativa y LLMs para Desarrolladores
Módulo 1: Fundamentos de la IA generativa
- Qué es la IA generativa y por qué importa a los desarrolladores
- Cómo funciona un LLM: tokens, embeddings y atención
- Panorama de modelos y proveedores
- Limitaciones y riesgos: alucinaciones, contexto y costes
Módulo 2: Prompt engineering
- Anatomía de un prompt: roles, instrucciones y contexto
- Técnicas de prompting: few-shot, cadena de razonamiento y plantillas
- Salidas estructuradas: JSON y control de formato
- Iteración y evaluación de prompts
Módulo 3: Integración de LLMs en aplicaciones
- Primera integración con la API de un LLM
- Streaming, errores y reintentos
- Conversaciones y gestión del contexto
- Costes, latencia y caching
Módulo 4: RAG - Generación aumentada por recuperación
- Embeddings y búsqueda semántica
- Bases de datos vectoriales
- Ingesta y chunking de documentos
- Construcción de un pipeline RAG completo
- Evaluación de sistemas RAG
Módulo 5: Function calling y agentes
- Function calling: conectar el LLM con tu código
- De LLM a agente: el bucle de razonamiento y acción
- Frameworks de orquestación
