Memoria y personalidad
"La personalidad de un bot no se entrena: se escribe. Y su memoria no existe: se reenvía."
Qué vas a aprender en este capítulo
Al final de este capítulo vas a tener el primer bot funcional de La Esquina: un programa de consola con el que podés mantener una conversación real — que recuerda lo que dijiste, que habla como empleado cordial de pupusería migueleña, que responde fluido (streaming) y que guarda la conversación en disco. Cumple los requisitos R6 y R7 de la tabla del capítulo 1.
En el camino vas a entender las dos piezas más malentendidas de los chatbots:
- La memoria: por qué es una lista que tu código acumula y reenvía, qué pasa cuando crece, y cómo recortarla sin que el bot se vuelva amnésico.
- La personalidad: cómo se escribe un system prompt profesional — no "sé amable", sino rol, tono, reglas, límites y formato, con ejemplos.
2.1 El loop de conversación
💡 Intuición
En el capítulo 1 demostraste (ejercicio 1) que el modelo solo sabe lo que va en messages. Entonces el algoritmo de un chat con memoria es casi vergonzosamente simple:
- Guardá una lista vacía.
- Cuando el usuario escriba algo, agregalo a la lista como turno
user. - Mandá la lista completa a la API.
- Agregá la respuesta a la lista como turno
assistant. - Volvé al paso 2.
La lista crece y crece, y en cada llamada el modelo "relee" toda la conversación desde el principio. Eso es la memoria: relectura, no recuerdo. Como un empleado nuevo en cada turno al que le pasás el cuaderno con todo lo conversado hasta ahora.
Vamos directo al código. Creá bot_v1.py:
"""Bot de La Esquina v1: loop de conversación con memoria."""
from dotenv import load_dotenv
import anthropic
load_dotenv()
SYSTEM_PROMPT = "Sos el asistente de La Esquina, una pupusería de San Miguel, El Salvador."
class Chatbot:
"""Un chatbot con memoria: acumula el historial y lo reenvía completo."""
def __init__(self, system_prompt: str, model: str = "claude-opus-4-8"):
self.client = anthropic.Anthropic()
self.system_prompt = system_prompt
self.model = model
self.historial: list[dict] = [] # la "memoria" vive aca
def enviar(self, mensaje_usuario: str) -> str:
# Paso 1: agregar el turno del usuario al historial
self.historial.append({"role": "user", "content": mensaje_usuario})
# Paso 2: mandar el historial COMPLETO (la API es stateless)
respuesta = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
messages=self.historial,
)
texto = respuesta.content[0].text
# Paso 3: agregar la respuesta al historial para el proximo turno
self.historial.append({"role": "assistant", "content": texto})
return texto
def main():
bot = Chatbot(SYSTEM_PROMPT)
print("Bot de La Esquina v1 — escribí 'salir' para terminar.\n")
while True:
entrada = input("Vos: ").strip()
if entrada.lower() in ("salir", "exit", "quit"):
print("¡Que le vaya bien!")
break
if not entrada:
continue
print(f"\nBot: {bot.enviar(entrada)}\n")
if __name__ == "__main__":
main()
Corrélo y probá la memoria:
Vos: Me llamo Beatriz y quiero pupusas de queso
Bot: ¡Hola Beatriz! Con gusto...
Vos: ¿Cómo me llamo?
Bot: ¡Te llamás Beatriz! 😊
Funciona. La "magia" es el append antes y después de cada llamada.
⚠️ Trampa común
El bot que olvida. El bug número uno de todo principiante es construir la lista nueva en cada turno:
def enviar(self, mensaje_usuario: str) -> str:
respuesta = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
messages=[{"role": "user", "content": mensaje_usuario}], # ❌ solo el ultimo turno
)
return respuesta.content[0].text
Este bot responde perfecto a cada mensaje individual y olvida absolutamente todo lo anterior. "¿Cómo me llamo?" → "No me has dicho tu nombre". El síntoma es sutil porque el bot parece funcionar. Las dos reglas que rompió:
- No acumula los turnos del usuario.
- No agrega la respuesta del assistant al historial (este es el olvido más frecuente: la gente agrega el turno
userpero olvida elappenddelassistant, y la API rechaza el siguiente request con error 400 porque encuentra dosuserseguidos... o peor, los combina y el modelo se confunde).
Verificá siempre: después de N intercambios, len(self.historial) debe ser 2*N.
📐 Fundamento
Las reglas formales de messages:
- Es una lista de
{"role": ..., "content": ...}. - El primer mensaje debe tener
role: "user". - Los roles deben alternar
user→assistant→user→ ... (si mandás dosuserconsecutivos la API responde 400 con"roles must alternate"). contentpuede ser un string (texto simple) o una lista de bloques (texto + imágenes + tool_use + tool_result — los bloques aparecen en el capítulo 3).- El
systemno va dentro demessages: es un parámetro aparte, y se reenvía igual en cada llamada.
El costo de la memoria. Si cada turno mide ~100 tokens, en el turno N el request lleva ~100·(2N−1) tokens de entrada. El costo de entrada de una conversación de N turnos crece cuadráticamente con N (cada token viejo se re-factura en cada turno nuevo). Una conversación de 50 turnos puede costar más que 10 conversaciones de 5. Por eso el recorte de historial (sección 2.4) no es opcional en producción.
2.2 El system prompt: la personalidad por escrito
El bot v1 responde, pero su comportamiento es genérico. Hora de cumplir el requisito R6: tono cordial salvadoreño, reglas claras, y — crítico — qué no debe hacer.
💡 Intuición
Pensá en el system prompt como el manual de inducción que le darías a un empleado nuevo en su primer día: quién sos, cómo tratamos al cliente, qué información manejamos, qué hacemos ante situaciones raras, y qué cosas jamás se hacen (inventar precios, discutir de política, prometer lo que no podemos cumplir). La diferencia: el modelo lee su manual de inducción en cada mensaje, así que más vale que esté bien escrito.
Un mal system prompt es vago: "Sé amable y ayudá al cliente". Uno bueno es específico, estructurado y con límites explícitos. Los modelos actuales siguen las instrucciones con mucha fidelidad — lo que escribas, eso hace. La calidad de tu bot es, en gran parte, la calidad de este texto.
Este es el system prompt real del bot de La Esquina. Leelo completo — cada sección existe por una razón:
SYSTEM_PROMPT = """Sos "Esquinita", el asistente virtual de La Esquina, pupusería \
con 3 sucursales en San Miguel, El Salvador.
# Tu rol
Atendés clientes por chat: respondés preguntas sobre el menú, los precios y los \
horarios, y ayudás a armar y registrar pedidos.
# Tono
- Cordial y cercano, al estilo salvadoreño: "con gusto", "fíjese que", "¡cómo no!".
- Tratá al cliente de "usted".
- Respuestas CORTAS: 1 a 3 oraciones para preguntas simples. Esto es un chat,
no una carta.
- Podés usar un emoji ocasional (😊 🫓), máximo uno por mensaje.
# Reglas del negocio
- Sucursales: Centro, Roosevelt y Metrocentro.
- Si el cliente quiere pedir, necesitás: los items, su nombre y la sucursal.
Pedí SOLO los datos que falten, de uno en uno, sin interrogatorios.
- Los precios y el menú los consultás con tus herramientas cuando las tengás
disponibles; si todavía no las tenés, decí que vas a confirmar el dato.
# Qué NO hacés (importante)
- NUNCA inventés precios, promociones ni platillos. Si no sabés un dato, decilo:
"Fíjese que ese dato no lo tengo a mano, con gusto se lo confirmo".
- No hablés de temas ajenos a la pupusería (política, religión, otros negocios).
Redirigí con amabilidad: "Yo solo le puedo ayudar con cositas de La Esquina 😊".
- No prometás tiempos de entrega exactos ni descuentos.
- Si el cliente está molesto, disculpate una vez, no discutás, y ofrecé que
un encargado lo contacte.
# Formato
- Cuando confirmés un pedido, listá los items con viñetas y el total al final.
- Nunca uses tablas ni encabezados markdown: esto se lee en una burbuja de chat.
"""
📐 Fundamento
Anatomía de un system prompt profesional. Las cinco secciones del prompt de Esquinita son un patrón general que podés reutilizar en cualquier bot:
| Sección | Pregunta que responde | Error si falta |
|---|---|---|
| Rol | ¿Quién sos y para qué existís? | El bot divaga o se presenta distinto cada vez |
| Tono | ¿Cómo hablás? (registro, longitud, emojis) | Respuestas de 5 párrafos en una burbuja de chat |
| Reglas del negocio | ¿Qué proceso seguís? ¿Qué datos necesitás? | El bot improvisa flujos de pedido inconsistentes |
| Límites (qué NO) | ¿Qué está prohibido? | Inventa precios, opina de política, promete descuentos |
| Formato | ¿Cómo se ve la salida? | Tablas markdown rotas en el frontend |
Detalles de oficio:
- Los límites en positivo cuando se pueda. "Redirigí con amabilidad" funciona mejor que solo "NO hablés de otra cosa", porque le da al modelo qué hacer en lugar de lo prohibido.
- Dale una salida digna a la ignorancia. La frase exacta "Fíjese que ese dato no lo tengo a mano" le da al modelo una alternativa concreta a inventar. Sin esa válvula de escape, la presión por "ser útil" empuja al modelo a alucinar.
- El system prompt no es una caja fuerte. No pongás secretos ahí (keys, precios internos, datos de otros clientes): un usuario insistente puede lograr que el modelo lo revele. Volvemos a esto en el capítulo 5 con la inyección de prompts.
- Iterá con casos reales. Escribí 10 mensajes de prueba (cliente confundido, cliente enojado, pregunta fuera de tema, pedido enredado) y corrélos cada vez que toqués el prompt. Es tu "suite de tests" de personalidad.
2.3 Streaming: que se sienta vivo
Con respuestas de 2-3 segundos, el usuario mira una pantalla congelada y duda si el bot murió. Streaming resuelve esto: la API va enviando la respuesta token a token a medida que se genera, y vos la mostrás al instante — como ver escribir a alguien.
def enviar_streaming(self, mensaje_usuario: str) -> str:
"""Como enviar(), pero muestra la respuesta token a token."""
self.historial.append({"role": "user", "content": mensaje_usuario})
with self.client.messages.stream(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
messages=self.historial,
) as stream:
for texto in stream.text_stream:
print(texto, end="", flush=True) # mostrar cada fragmento YA
print() # salto de linea final
mensaje = stream.get_final_message() # el objeto Message completo
texto_completo = mensaje.content[0].text
self.historial.append({"role": "assistant", "content": texto_completo})
return texto_completo
Tres detalles que importan:
with ... as stream— el context manager garantiza que la conexión HTTP se cierre bien aunque haya una excepción a mitad del stream.flush=True— sin esto, Python guarda la salida en un buffer y la imprime a borbotones; el streaming se vería igual de congelado que antes.stream.get_final_message()— al terminar el stream, te da el objetoMessagecompleto, conusageystop_reasonincluidos. De ahí sacás el texto para el historial — no vayas concatenando fragmentos a mano, que es frágil.
🛠️ En la práctica
El streaming no hace la respuesta más rápida — el tiempo total es el mismo. Lo que mejora brutalmente es la latencia percibida: el primer token llega en ~0.5-1s y el usuario ya está leyendo mientras el resto se genera. En métricas de producto esto se llama TTFT (time to first token) y es LA métrica de experiencia en interfaces de chat. Todos los chats de LLM que usás (Claude.ai, ChatGPT) hacen streaming; un bot sin streaming en 2026 se siente roto. En el capítulo 5 llevamos este mismo stream al navegador con Server-Sent Events.
2.4 Cuando la memoria crece: ventana deslizante
Cada turno agranda el historial. Tres problemas crecen con él:
- Costo: cada token viejo se re-factura como entrada en cada turno nuevo.
- Latencia: requests más grandes tardan más en procesarse.
- Límite duro: la ventana de contexto del modelo (1M tokens en Opus 4.8 — lejana para un chat de pupusería, pero existe).
La solución más simple y usada: ventana deslizante — conservar solo los últimos K turnos.
MAX_TURNOS = 20 # turnos = entradas en el historial (10 intercambios)
def _recortar_historial(self) -> list[dict]:
"""Devuelve el historial recortado a los ultimos MAX_TURNOS turnos.
Garantiza que el primer mensaje del recorte sea 'user', porque la API
exige que messages empiece con un turno user.
"""
if len(self.historial) <= MAX_TURNOS:
return self.historial
recorte = self.historial[-MAX_TURNOS:]
# Si el recorte quedo empezando en 'assistant', descartamos ese turno:
if recorte[0]["role"] == "assistant":
recorte = recorte[1:]
return recorte
Y en enviar_streaming, cambiás messages=self.historial por messages=self._recortar_historial(). Ojo: el historial completo se sigue guardando en self.historial (para persistirlo, para auditoría) — lo que se recorta es lo que se envía.
📐 Fundamento
Trade-offs de la ventana deslizante. Es la técnica más simple, no la mejor para todo:
| Ventana deslizante | Alternativas | |
|---|---|---|
| Qué hace | Tira los turnos más viejos | — |
| Costo | Acotado: nunca más de K turnos | — |
| Pierde | TODO lo anterior a la ventana, incluyendo el nombre del cliente o el pedido inicial | — |
| Cuándo alcanza | Chats cortos y transaccionales (pedir pupusas: 6-12 turnos típicos) | — |
| Cuándo NO alcanza | Conversaciones largas donde lo del principio importa | Resumen: cada K turnos, pedirle al propio modelo que resuma lo viejo en un párrafo y reemplazar esos turnos por el resumen. Más caro (una llamada extra) pero conserva la esencia. |
| Memoria estructurada: extraer datos clave (nombre, pedido, sucursal) a un dict y reinyectarlos en el system prompt. Lo más robusto para datos transaccionales. |
Para Esquinita, la ventana deslizante con K=20 sobra: una conversación de pedido rara vez pasa de 12 turnos. Pero fijate en la trampa de abajo.
⚠️ Trampa común
Recortar y romper la alternancia (o la primera regla). Dos bugs clásicos al recortar:
- Cortar la lista en un índice que deja un
assistantprimero → error 400 (first message must be user). Por eso elif recorte[0]["role"] == "assistant"del código. - En el capítulo 3, los turnos de herramientas (
tool_use/tool_result) vienen en pares atados por un ID. Si tu recorte separa el par — deja eltool_resultsin sutool_use— la API rechaza el request. Regla práctica: recortá solo en fronteras de intercambio completo (user → ... → respuesta final), nunca a mitad de un ciclo de herramientas.
2.5 Persistir la conversación en JSON
Si el programa se cierra, la conversación muere. Para retomarla (y para que doña Carmen pueda revisar qué piden los clientes), la guardamos en disco. El historial ya es una lista de dicts — JSON nativo:
import json
from datetime import datetime, timezone
from pathlib import Path
def guardar(self, ruta: str) -> None:
"""Guarda la conversacion completa en un archivo JSON."""
datos = {
"guardado_en": datetime.now(timezone.utc).isoformat(),
"model": self.model,
"system_prompt": self.system_prompt,
"historial": self.historial,
}
Path(ruta).parent.mkdir(parents=True, exist_ok=True)
with open(ruta, "w", encoding="utf-8") as f:
json.dump(datos, f, ensure_ascii=False, indent=2)
def cargar(self, ruta: str) -> None:
"""Restaura una conversacion guardada."""
with open(ruta, encoding="utf-8") as f:
datos = json.load(f)
self.historial = datos["historial"]
self.system_prompt = datos.get("system_prompt", self.system_prompt)
ensure_ascii=False mantiene las tildes y eñes legibles en el archivo; encoding="utf-8" en ambas direcciones evita el clásico desastre de caracteres en Windows. Guardamos también el system prompt: si mañana lo cambiás, querés saber con cuál se generó cada conversación vieja.
2.6 El bot completo: bot_v2.py
Todo junto — este es el código íntegro del bot de consola de La Esquina al final del capítulo 2. Copialo y corrélo:
"""Bot de La Esquina v2: memoria + personalidad + streaming + recorte + JSON."""
import json
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
import anthropic
load_dotenv()
MAX_TURNOS = 20
SYSTEM_PROMPT = """Sos "Esquinita", el asistente virtual de La Esquina, pupusería \
con 3 sucursales en San Miguel, El Salvador.
# Tu rol
Atendés clientes por chat: respondés preguntas sobre el menú, los precios y los \
horarios, y ayudás a armar y registrar pedidos.
# Tono
- Cordial y cercano, al estilo salvadoreño: "con gusto", "fíjese que", "¡cómo no!".
- Tratá al cliente de "usted".
- Respuestas CORTAS: 1 a 3 oraciones para preguntas simples.
- Podés usar un emoji ocasional (😊 🫓), máximo uno por mensaje.
# Reglas del negocio
- Sucursales: Centro, Roosevelt y Metrocentro.
- Si el cliente quiere pedir, necesitás: los items, su nombre y la sucursal.
Pedí SOLO los datos que falten, de uno en uno.
- Los precios y el menú los consultás con tus herramientas cuando las tengás
disponibles; si todavía no las tenés, decí que vas a confirmar el dato.
# Qué NO hacés
- NUNCA inventés precios, promociones ni platillos. Si no sabés, decilo.
- No hablés de temas ajenos a la pupusería. Redirigí con amabilidad.
- No prometás tiempos de entrega exactos ni descuentos.
- Si el cliente está molesto, disculpate una vez y ofrecé que un encargado
lo contacte.
# Formato
- Pedidos confirmados: items con viñetas y el total al final.
- Nada de tablas ni encabezados markdown: esto es una burbuja de chat.
"""
class Chatbot:
"""Chatbot con historial, streaming, recorte y persistencia."""
def __init__(self, system_prompt: str, model: str = "claude-opus-4-8"):
self.client = anthropic.Anthropic()
self.system_prompt = system_prompt
self.model = model
self.historial: list[dict] = []
self.tokens_entrada = 0
self.tokens_salida = 0
# ---------- memoria ----------
def _recortar_historial(self) -> list[dict]:
if len(self.historial) <= MAX_TURNOS:
return self.historial
recorte = self.historial[-MAX_TURNOS:]
if recorte[0]["role"] == "assistant":
recorte = recorte[1:]
return recorte
# ---------- conversacion ----------
def enviar(self, mensaje_usuario: str) -> str:
"""Envia un mensaje y muestra la respuesta con streaming."""
self.historial.append({"role": "user", "content": mensaje_usuario})
try:
with self.client.messages.stream(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
messages=self._recortar_historial(),
) as stream:
for texto in stream.text_stream:
print(texto, end="", flush=True)
print()
mensaje = stream.get_final_message()
except anthropic.RateLimitError:
self.historial.pop() # deshacer el turno: no hubo respuesta
return "⏳ Demasiados mensajes seguidos. Esperá un minuto."
except anthropic.APIConnectionError:
self.historial.pop()
return "🔌 Sin conexión. Revisá tu internet."
texto_completo = mensaje.content[0].text
self.historial.append({"role": "assistant", "content": texto_completo})
self.tokens_entrada += mensaje.usage.input_tokens
self.tokens_salida += mensaje.usage.output_tokens
return texto_completo
# ---------- costo ----------
def costo_acumulado(self) -> float:
"""Costo en USD de la sesion (precios Opus 4.8, junio 2026)."""
return (self.tokens_entrada * 5.00 + self.tokens_salida * 25.00) / 1_000_000
# ---------- persistencia ----------
def guardar(self, ruta: str) -> None:
datos = {
"guardado_en": datetime.now(timezone.utc).isoformat(),
"model": self.model,
"system_prompt": self.system_prompt,
"historial": self.historial,
}
Path(ruta).parent.mkdir(parents=True, exist_ok=True)
with open(ruta, "w", encoding="utf-8") as f:
json.dump(datos, f, ensure_ascii=False, indent=2)
def cargar(self, ruta: str) -> None:
with open(ruta, encoding="utf-8") as f:
datos = json.load(f)
self.historial = datos["historial"]
self.system_prompt = datos.get("system_prompt", self.system_prompt)
def main():
bot = Chatbot(SYSTEM_PROMPT)
ruta = "conversaciones/ultima.json"
if Path(ruta).exists():
if input("¿Retomar la conversación anterior? (s/n): ").lower() == "s":
bot.cargar(ruta)
print(f"({len(bot.historial)} turnos restaurados)\n")
print("🫓 Esquinita v2 — 'salir' para terminar.\n")
while True:
entrada = input("Vos: ").strip()
if entrada.lower() in ("salir", "exit", "quit"):
bot.guardar(ruta)
print(f"\nConversación guardada. Costo de la sesión: "
f"${bot.costo_acumulado():.4f} USD. ¡Que le vaya bien!")
break
if not entrada:
continue
print("Bot: ", end="", flush=True)
bot.enviar(entrada)
print()
if __name__ == "__main__":
main()
Fijate en un detalle de robustez: cuando una llamada falla, hacemos self.historial.pop() para deshacer el turno del usuario que acabábamos de agregar. Si no, el historial queda con un user colgado sin respuesta, y el siguiente mensaje del usuario crearía dos user consecutivos → error 400.
Resumen visual
Mermaid
sequenceDiagram participant U as Usuario participant B as Chatbot (tu código) participant A as API de Claude
U->>B: "Me llamo Beatriz, 3 de queso"
B->>B: historial.append(user)
B->>A: system + historial completo
A-->>B: stream token a token
B-->>U: muestra en vivo (flush)
B->>B: historial.append(assistant)
Note over B: turno 2 en adelante:<br/>recortar a MAX_TURNOS<br/>(sin romper alternancia)
U->>B: "¿Cómo me llamo?"
B->>A: system + TODO el historial otra vez
A-->>B: "¡Beatriz!" (lo leyó en el historial)
Note over B: al salir: guardar(ruta) → JSON
Estado del proyecto — R6 (personalidad y tono) ✅ y R7 (memoria) ✅. El bot conversa, recuerda, suena a La Esquina y persiste. Pero si le preguntás el precio de la pupusa de ayote, te va a decir (correctamente, gracias al prompt) que no lo tiene a mano. En el capítulo 3 le damos manos: herramientas para consultar el menú real y registrar pedidos.
Ejercicios
✏️ Ejercicio 1 — El bot olvida: ¿qué línea falta?
Un compañero te muestra este método y se queja de que su bot "responde bien pero no se acuerda de nada":
def enviar(self, mensaje_usuario: str) -> str:
self.historial.append({"role": "user", "content": mensaje_usuario})
respuesta = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
messages=self.historial,
)
return respuesta.content[0].text
¿Qué línea falta? ¿Qué dos síntomas produce el bug (uno de memoria y uno de error de API)?
✅ Solución
Falta agregar la respuesta al historial antes del return:
self.historial.append({"role": "assistant", "content": respuesta.content[0].text})
Síntomas: (1) el modelo nunca ve sus propias respuestas anteriores, así que se contradice y "olvida" lo que él mismo dijo; (2) peor: al siguiente enviar(), el historial queda [user, user] — dos turnos user consecutivos — y la API devuelve error 400 (roles must alternate). El bug de memoria más común del mundo de los chatbots, y por eso el capítulo insiste: append antes Y después.
✏️ Ejercicio 2 — Modo grosero (no lo dejés en producción)
Para comprobar cuánto manda el system prompt: cambiá solo la sección de Tono para que Esquinita responda de mal humor y con desgano (sin insultos). Corré los mismos 3 mensajes de prueba con ambas versiones y compará. Después contestá: ¿por qué las empresas tratan el system prompt como un artefacto versionado (con git, con revisiones), igual que el código?
✅ Solución
Algo como - Respondé con desgano, monosílabos cuando se pueda, y suspiros ("ay...") transforma al bot por completo: mismas capacidades, personalidad opuesta.
Se versiona como código porque es código: un cambio de una línea altera el comportamiento ante miles de usuarios, puede introducir "bugs de personalidad" (prometer descuentos, salirse del tema), y necesitás poder responder "¿qué prompt estaba activo cuando pasó este incidente?". Por eso bot_v2.py guarda el system prompt junto con cada conversación.
✏️ Ejercicio 3 — Ventana con resumen
Implementá _recortar_con_resumen(): cuando el historial pase de MAX_TURNOS, tomá los turnos que van a descartarse, pedile al modelo (una llamada aparte, con max_tokens=150) que los resuma en un párrafo conservando nombre del cliente y pedidos mencionados, y devolvé [{"role": "user", "content": "[Resumen de lo conversado: ...]"}, *últimos turnos*]. ¿Qué cuidado hay que tener con el rol del mensaje-resumen?
✅ Solución
def _recortar_con_resumen(self) -> list[dict]:
if len(self.historial) <= MAX_TURNOS:
return self.historial
viejos = self.historial[:-MAX_TURNOS]
recientes = self.historial[-MAX_TURNOS:]
if recientes[0]["role"] == "assistant":
viejos.append(recientes[0])
recientes = recientes[1:]
texto_viejo = "\n".join(f"{m['role']}: {m['content']}" for m in viejos
if isinstance(m["content"], str))
resumen = self.client.messages.create(
model=self.model,
max_tokens=150,
system="Resumí esta conversación en un párrafo. Conservá nombres, "
"items pedidos y sucursal si aparecen.",
messages=[{"role": "user", "content": texto_viejo}],
).content[0].text
return ([{"role": "user", "content": f"[Resumen de lo conversado antes: {resumen}]"}]
+ recientes)
El cuidado: el mensaje-resumen debe tener role: "user" y los recientes deben empezar con assistant o el resumen debe fusionarse con cuidado — la lista final tiene que seguir alternando y empezar con user. La versión simple de arriba funciona porque tras el descarte recientes[0] es user... que quedaría después de otro user (el resumen). Solución práctica: concatenar el resumen al primer mensaje user de recientes en vez de agregarlo como turno aparte. Probálo y ajustá — pelearse con la alternancia es parte del aprendizaje 😉.
✏️ Ejercicio 4 — Medidor de inflación
Agregá al bot un comando especial /costo que, en vez de ir a la API, imprima: turnos en el historial, tokens de entrada del próximo request (usá client.messages.count_tokens con el historial recortado), y el costo acumulado de la sesión. Mantené una conversación de 15 mensajes y anotá cómo crece el tamaño del request. ¿En qué turno se estabiliza y por qué?
✅ Solución
En el main, antes de llamar a bot.enviar():
if entrada == "/costo":
conteo = bot.client.messages.count_tokens(
model=bot.model,
system=bot.system_prompt,
messages=bot._recortar_historial() + [{"role": "user", "content": "x"}],
)
print(f"Turnos: {len(bot.historial)} | Próximo request ≈ "
f"{conteo.input_tokens} tokens | Sesión: ${bot.costo_acumulado():.4f}")
continue
El tamaño crece linealmente turno a turno hasta que el historial alcanza MAX_TURNOS (20): a partir de ahí la ventana deslizante descarta lo viejo y el request se estabiliza (~system prompt + 20 turnos). Sin recorte, seguiría creciendo sin techo. Ver ese número aplanarse es ver el requisito R9 (presupuesto) hacerse alcanzable.
Para profundizar
- Guía de system prompts — platform.claude.com/docs, sección Prompt engineering: patrones oficiales para roles, tono y límites, con ejemplos buenos y malos.
- Streaming en el SDK de Python — platform.claude.com/docs, sección Streaming: todos los eventos del stream (acá usamos solo
text_stream; hay eventos más finos para UIs avanzadas). - Capítulo 5 de este libro — el mismo streaming, pero hacia el navegador con Server-Sent Events.
jsonen la biblioteca estándar — docs.python.org/3/library/json.html: si la persistencia en archivos se te queda corta, el paso natural es SQLite (visto en Bases de Datos I).