DocuBot ja respon, fa streaming i sobreviu als errors — però oblida cada pregunta tan bon punt la contesta. Si un agent de suport pregunta "com exporto un projecte a CSV?" i tot seguit "i això està disponible al pla Starter?", el bot no té ni idea de què és "això". En aquesta lliçó construïm la capa de conversa: entendrem per què els LLMs no tenen memòria i com la il·lusió de continuïtat es fabrica reenviant l'historial complet a cada crida, implementarem una classe Conversa en Python, mesurarem el problema que això crea (un historial que creix devora finestra de context i pressupost) i compararem les estratègies per contenir-lo. Tancarem amb el salt a multiusuari: aïllar la conversa de cada empleat de Nubelia.
Contingut
- Els LLMs no tenen memòria: la il·lusió de la conversa
- La classe
Conversaen Python - L'historial creix: límits de finestra i cost creixent
- Estratègies de gestió de l'historial
- Multiusuari: sessions aïllades i persistència simple
- Quan el que falta no és historial: avançament de RAG
Els LLMs no tenen memòria: la il·lusió de la conversa
Recuperem la limitació que vam catalogar a 01-04: un LLM és sense estat (stateless). L'API no guarda res entre crides: ni qui ets, ni què vas preguntar fa deu segons. Cada petició és, per al model, la primera i l'única.
Llavors, com és que qualsevol xat amb un LLM "recorda" el que vas dir? Perquè el client reenvia la conversa sencera a cada crida. La memòria no és al model; és a la teva llista messages:
# Crida 1: el model veu UNA pregunta
messages = [
{"role": "user", "content": "Com exporto un projecte a CSV?"},
]
# Crida 2: el model veu TOTA la conversa, una altra vegada des de zero
messages = [
{"role": "user", "content": "Com exporto un projecte a CSV?"},
{"role": "assistant", "content": "Ves a Projecte > Configuració > Exportar > CSV..."},
{"role": "user", "content": "I això està disponible al pla Starter?"},
]A la segona crida, el model pot resoldre "això" perquè el torn anterior viatja a la petició. No ho recorda: ho rellegeix. Aquí encaixen per fi els rols user/assistant que vam definir a 02-01 — el rol assistant existeix precisament per reinjectar el que el mateix model va dir abans.
Tres conseqüències immediates d'aquest disseny:
- Cada crida paga la conversa sencera com a tokens d'entrada. El torn 20 reenvia (i factura) els 19 anteriors.
- La conversa és teva. El proveïdor no la guarda per tu a efectes de l'API: si el teu procés mor i no l'has persistit, no va existir.
- Pots editar el passat. Com que l'historial és una llista que tu construeixes, pots escurçar-lo, resumir-lo o filtrar-lo. Aquesta flexibilitat és exactament el que explotarem a la secció d'estratègies.
La classe Conversa en Python
Encapsulem el patró en una classe reutilitzable. Decisions de disseny: el system prompt va a part de l'historial (viatja al seu propi paràmetre, no és un torn més), i la classe reutilitza el pipeline robust de 03-02:
# conversa.py
import anthropic
from client import MODEL, extreure_text
from reintents import cridar_amb_reintents
from prompts import SYSTEM_DOCUBOT
class Conversa:
"""Conversa multi-torn amb un LLM.
Manté l'historial user/assistant i el reenvia complet a cada
crida. El system prompt es manté a part: no forma part de la
llista de missatges.
"""
def __init__(self, system: str = SYSTEM_DOCUBOT,
model: str = MODEL, max_tokens: int = 1024):
self.system = system
self.model = model
self.max_tokens = max_tokens
self.missatges: list[dict] = [] # només torns user/assistant
self.tokens_entrada_total = 0 # comptabilitat acumulada
self.tokens_sortida_total = 0
def enviar(self, text_usuari: str) -> str:
"""Afegeix el torn de l'usuari, crida l'API i registra la resposta."""
self.missatges.append({"role": "user", "content": text_usuari})
try:
resposta = cridar_amb_reintents(
model=self.model,
max_tokens=self.max_tokens,
system=self.system,
messages=self.missatges, # TOT l'historial, cada vegada
)
except anthropic.APIError:
# No deixar l'historial desbalancejat: retirar el torn fallit
self.missatges.pop()
raise
text = extreure_text(resposta)
# El torn de l'assistent S'HA de guardar: és el que li dona memòria
self.missatges.append({"role": "assistant", "content": text})
self.tokens_entrada_total += resposta.usage.input_tokens
self.tokens_sortida_total += resposta.usage.output_tokens
return text
@property
def num_torns(self) -> int:
"""Un torn = un parell pregunta/resposta."""
return len(self.missatges) // 2Detalls que importen:
- El
pop()de l'except. Si la crida falla, el tornuserja estava afegit. Deixar-lo produiria dosuserseguits sense resposta entremig al següent intent — historial corrupte. Desfer en cas d'error manté l'invariant "user i assistant alternen". - Guardar la resposta de l'assistent no és opcional. Si oblides l'
appendde l'assistant, el model mai no veu les seves pròpies respostes anteriors i la "memòria" desapareix silenciosament — el bug més comú en aquesta capa. - La comptabilitat de tokens (
tokens_entrada_total,tokens_sortida_total) sembla burocràcia ara; a la secció següent la farem servir per veure el problema del creixement, i a 03-04 per posar-li preu.
Ús:
xat = Conversa()
print(xat.enviar("Com exporto un projecte a CSV?"))
print(xat.enviar("I això està disponible al pla Starter?")) # "això" ja resol
print(f"Torns: {xat.num_torns}, tokens d'entrada acumulats: {xat.tokens_entrada_total}")L'historial creix: límits de finestra i cost creixent
La il·lusió de memòria té un preu compost. Suposem números ficticis però realistes per a DocuBot: system prompt ~600 tokens, cada pregunta ~50 tokens, cada resposta ~250 tokens. L'entrada de cada crida és system + tot l'historial + pregunta nova:
| Torn | Historial previ (tokens) | Entrada d'aquesta crida | 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 |
Dues lectures de la taula:
- L'entrada per crida creix linealment amb els torns... però la despesa acumulada creix quadràticament, perquè cada torn re-paga tots els anteriors. Una conversa de 50 torns no costa 50 vegades la primera crida: costa centenars de vegades més en tokens d'entrada.
- La finestra de context és finita (01-04). Quan system + historial + pregunta + espai per a la resposta superi la finestra del model, l'API retornarà el 400 de context excedit que vam catalogar a 03-02. Amb finestres grans trigarà a arribar, però és un mur determinista: sense gestió, tota conversa prou llarga mor allà.
I hi ha un tercer problema més subtil, que ja coneixes de 01-04: "lost in the middle". Encara que hi càpiga, un historial enorme dilueix l'atenció — allò important enterrat al torn 12 de 40 pesa menys que el que és al principi o al final. Més historial no és sempre millor memòria; a partir d'un cert punt és pitjor senyal.
Conclusió: la classe Conversa necessita una política de gestió de l'historial. No és una optimització — és supervivència.
Estratègies de gestió de l'historial
Estratègia 1: truncament per torns
La més simple: conservar només els últims N torns complets.
def truncar_per_torns(missatges: list[dict], max_torns: int = 10) -> list[dict]:
"""Conserva els últims max_torns parells user/assistant.
Talla SEMPRE en frontera de parell: el primer missatge del resultat
ha de ser un 'user' (l'API exigeix començar per user).
"""
max_missatges = max_torns * 2
if len(missatges) <= max_missatges:
return missatges
retallat = missatges[-max_missatges:]
# Garantir que comença en 'user' (per si el tall ha caigut a mig parell)
while retallat and retallat[0]["role"] != "user":
retallat = retallat[1:]
return retallatS'aplica dins d'enviar(), just abans de la crida: messages=truncar_per_torns(self.missatges). Fixa't que trunquem el que s'envia, no necessàriament el que es guarda — pots conservar l'historial íntegre en local (per a auditoria o per a la interfície) i enviar només la cua.
Avantatge: trivial, cost acotat, zero crides extra. Inconvenient: la memòria és amnèsia diferida — el que es va dir fa 11 torns desapareix del tot, encara que fos la dada clau ("la meva empresa fa servir el pla Business").
Estratègia 2: resum de l'historial amb el mateix LLM
Quan l'historial supera un llindar, es demana al mateix model que comprimeixi els torns vells en un resum, i aquest resum substitueix els torns que resumeix:
PROMPT_RESUM = """Resumeix la conversa de suport següent en menys de 150
paraules. Conserva: dades concretes de l'usuari (pla, configuració, noms
de projectes), el problema tractat i el que ja s'ha resolt. Omet cortesies.
<conversa>
{conversa}
</conversa>"""
def resumir_historic(missatges: list[dict], conservar_torns: int = 4) -> list[dict]:
"""Comprimeix els torns antics en un resum; conserva els recents tal qual."""
limit = conservar_torns * 2
if len(missatges) <= limit:
return missatges
antics, recents = missatges[:-limit], missatges[-limit:]
text = "\n".join(f"{m['role']}: {m['content']}" for m in antics)
resposta = cridar_amb_reintents(
model=MODEL,
max_tokens=300,
messages=[{"role": "user", "content": PROMPT_RESUM.format(conversa=text)}],
)
resum = extreure_text(resposta)
# El resum s'injecta com un parell user/assistant sintètic a l'inici
return [
{"role": "user", "content": f"[Resum de la conversa anterior]\n{resum}"},
{"role": "assistant", "content": "Entesos, tinc el context de la conversa anterior."},
] + recentsFixa't en el prompt de resum: diu què conservar (dades concretes, decisions), no només "resumeix". Un resum genèric perd just el que importava. Tot i així, el resum és amb pèrdua: és el mateix LLM decidint què mereix sobreviure, i de vegades s'equivoca. A més costa una crida extra cada vegada que es compacta (per això es fa per llindar, no a cada torn).
Estratègia 3: finestra lliscant (híbrida)
A la pràctica, la configuració més usada combina les anteriors: un resum acumulat al principi (memòria de llarg termini, comprimida) + els últims N torns literals (memòria de curt termini, intacta). Cada vegada que l'historial recent desborda, els torns més vells es fonen en el resum — la finestra "llisca" deixant al darrere un sediment resumit. És l'estratègia 2 aplicada de forma incremental, amb l'estructura de la 1.
Comparativa
| Estratègia | Cost extra | Fidelitat de la memòria | Complexitat | Quan usar-la |
|---|---|---|---|---|
| Enviar-ho tot (sense gestió) | Creixent sense límit | Perfecta fins a topar amb la finestra | Nul·la | Prototips; converses garantides curtes |
| Truncament per torns | Cap | Nul·la més enllà de N torns | Molt baixa | Consultes majoritàriament independents; DocuBot v1 |
| Resum amb LLM | Una crida per compactació | Alta però amb pèrdua (decideix el LLM) | Mitjana | Converses llargues on el context antic importa |
| Finestra lliscant | Una crida per lliscament | Recent perfecta + antiga comprimida | Mitjana-alta | Assistents de sessió llarga; l'opció per defecte en producció |
Per a DocuBot triem truncament a 10 torns com a política inicial: les consultes de suport solen resoldre's en pocs torns i la simplicitat guanya. La decisió queda documentada i és reversible — si l'avaluació de 02-04 (que ara podem executar contra converses reals) mostra pèrdua de context, la finestra lliscant és el pas següent. Apunt per a 03-04: l'estratègia d'historial també és una palanca de cost, i el resum hi jugarà un segon paper.
Multiusuari: sessions aïllades i persistència simple
Fins aquí, una sola conversa. Però DocuBot servirà tots els empleats de Nubelia alhora, i això imposa una regla absoluta: la conversa d'un usuari mai no pot contaminar la d'un altre. Que l'Anna pregunti per permisos no ha de fer que a la Berta li responguin sobre permisos — i encara pitjor seria filtrar entre sessions informació d'un client (privadesa, 01-04 i mòdul 6).
La solució mínima: un diccionari de sessions indexat per un identificador d'usuari/sessió, amb creació mandrosa:
# sessions.py
import json
import pathlib
class GestorSessions:
"""Aïlla una Conversa per usuari/sessió. Persistència simple en JSON."""
def __init__(self, directori: str = "./sessions"):
self.sessions: dict[str, Conversa] = {}
self.directori = pathlib.Path(directori)
self.directori.mkdir(exist_ok=True)
def conversa(self, id_sessio: str) -> Conversa:
"""Retorna la conversa de la sessió, creant-la o carregant-la si cal."""
if id_sessio not in self.sessions:
self.sessions[id_sessio] = self._carregar(id_sessio) or Conversa()
return self.sessions[id_sessio]
def preguntar(self, id_sessio: str, text: str) -> str:
conv = self.conversa(id_sessio)
resposta = conv.enviar(text)
self._desar(id_sessio, conv) # persistir després de cada torn
return resposta
# --- persistència simple: un fitxer JSON per sessió ---
def _ruta(self, id_sessio: str) -> pathlib.Path:
segur = "".join(c for c in id_sessio if c.isalnum() or c in "-_")
return self.directori / f"{segur}.json"
def _desar(self, id_sessio: str, conv: Conversa):
self._ruta(id_sessio).write_text(
json.dumps(conv.missatges, ensure_ascii=False), encoding="utf-8"
)
def _carregar(self, id_sessio: str) -> "Conversa | None":
ruta = self._ruta(id_sessio)
if not ruta.exists():
return None
conv = Conversa()
conv.missatges = json.loads(ruta.read_text(encoding="utf-8"))
return convÚs — dos empleats ficticis, zero interferència:
gestor = GestorSessions()
gestor.preguntar("empleada-anna", "Com dono permisos d'edició a un col·laborador?")
gestor.preguntar("empleat-bruno", "Com connecto el calendari?")
gestor.preguntar("empleada-anna", "I com els hi trec?") # resol amb el SEU historialNotes d'abast honestes:
- L'identificador de sessió ha de venir de l'autenticació real (l'SSO de Nubelia, la sessió web), mai d'un camp que l'usuari escrigui. Un
id_sessiomanipulable és accés a l'historial d'un altre. - La persistència en JSON per fitxer és didàctica i suficient per a un pilot; en producció, amb concurrència real, això és una base de dades (encara que sigui SQLite) i una política d'expiració/esborrat de sessions — l'historial de suport conté informació interna i li apliquen les normes de privadesa del mòdul 6 (06-02).
- El
dictviu en memòria d'un procés. Amb diversos processos o servidors, la persistència compartida deixa de ser opcional: és l'únic estat real.
Quan el que falta no és historial: avançament de RAG
Tanquem amb una distinció conceptual que ordena tot el curs. En aquesta lliçó hem gestionat un tipus de context: el que s'ha dit en aquesta conversa. Però quan DocuBot no sap respondre, gairebé mai no és perquè li falti un torn antic — és perquè li falta coneixement: el fragment correcte de la documentació de Nubelia.
La temptació ingènua seria "ficar més coses al context": tota la documentació al system prompt, historials eterns. Ja saps per què fracassa — finestra finita, cost quadràtic, lost in the middle. La resposta correcta és una altra: buscar, per a cada pregunta, només els fragments rellevants de la documentació i injectar-los a la crida. Això és la generació augmentada per recuperació (RAG), i és exactament el mòdul 4: embeddings i cerca semàntica (04-01), bases de dades vectorials (04-02), ingesta i chunking (04-03) i el pipeline complet (04-04). La regla mnemotècnica: historial per al que s'ha dit, RAG per al que se sap. Avui has construït la meitat de l'equació; l'altra meitat comença al proper mòdul — però abans, una parada obligatòria: saber quant costa tot això.
Errors Comuns i Consells
- Oblidar guardar el torn de l'assistent. El símptoma és subtil: res no falla, però el bot "no s'assabenta" de les seves pròpies respostes anteriors. Verifica que l'historial alterna user/assistant i creix de dos en dos.
- Ficar el system prompt com a primer missatge
user. Funciona a mitges i degrada el compliment de regles: el system prompt té el seu canal propi (02-01) i allà ha d'anar. A més, barrejat a l'historial acabaria truncat per la teva pròpia política de gestió. - Truncar a mig parell. Un historial que comença per
assistanto amb dosuserseguits provoca errors de l'API o comportament erràtic. Trunca sempre en frontera de torn complet. - Deixar l'historial corrupte després d'un error. Si la crida falla després de l'
appenddel tornuser, retira'l (elpop()de la classe). Historial desbalancejat avui = error estrany demà. - Compartir una
Conversaglobal entre usuaris "de moment". És la fuga de context (i potencialment de dades) més fàcil de crear i més difícil de detectar. Aïllament per sessió des del primer dia. - Consell: registra
usage.input_tokensper torn (la classe ja acumula). Si creix linealment torn a torn, la teva política de gestió no està funcionant; amb truncament actiu hauria d'estabilitzar-se en un altiplà. - Consell: en depurar "el bot ha oblidat X", imprimeix exactament la llista
messagesque es va enviar en aquella crida. La resposta sempre és allà: o X ja no viatja (truncament/resum agressiu), o viatja enterrat enmig d'un historial enorme (lost in the middle).
Exercicis
Exercici 1
Afegeix a Conversa un paràmetre max_torns_enviats: int | None = None. Si no és None, enviar() ha de passar per truncar_per_torns() el que s'envia a l'API, però conservant self.missatges complet en memòria. Afegeix també una propietat missatges_enviats_ultima_crida per poder inspeccionar què va veure realment el model.
Exercici 2
Escriu una simulació (sense API real: fes servir longituds estimades, 1 token ≈ 0,75 paraules com a 01-02) que compari els tokens d'entrada acumulats després de 30 torns amb tres polítiques: sense gestió, truncament a 10 torns i finestra lliscant (resum de 200 tokens + 4 torns literals). Imprimeix una taula.
Exercici 3
Dissenya el cas de prova d'aïllament de sessions: fent servir GestorSessions, la sessió A estableix una dada ("faig servir el pla Business") i la sessió B pregunta després "quin pla faig servir?". Defineix quin és el comportament correcte de DocuBot a B segons el system prompt de 02-01 i escriu l'asserció (pista: la frase anti-al·lucinació exacta de 02-01 és detectable).
Solucions
Solució 1:
class Conversa:
def __init__(self, system=SYSTEM_DOCUBOT, model=MODEL,
max_tokens=1024, max_torns_enviats=None):
# ... com abans ...
self.max_torns_enviats = max_torns_enviats
self._ultim_enviament: list[dict] = []
def enviar(self, text_usuari: str) -> str:
self.missatges.append({"role": "user", "content": text_usuari})
a_enviar = self.missatges
if self.max_torns_enviats is not None:
a_enviar = truncar_per_torns(self.missatges, self.max_torns_enviats)
self._ultim_enviament = a_enviar
# ... crida amb messages=a_enviar, la resta igual ...
@property
def missatges_enviats_ultima_crida(self):
return self._ultim_enviamentLa separació "historial complet en memòria / historial retallat a la petició" és el patró correcte: la interfície pot continuar mostrant tota la conversa encara que el model només vegi la cua.
Solució 2:
T_SYSTEM, T_PREGUNTA, T_RESPOSTA, T_RESUM = 600, 50, 250, 200
TORN = T_PREGUNTA + T_RESPOSTA # 300 tokens per parell
def entrada(torn: int, politica: str) -> int:
if politica == "sense_gestio":
historial = (torn - 1) * TORN
elif politica == "truncat_10":
historial = min(torn - 1, 10) * TORN
elif politica == "lliscant":
recents = min(torn - 1, 4) * TORN
historial = recents + (T_RESUM if torn - 1 > 4 else 0)
return T_SYSTEM + historial + T_PREGUNTA
for politica in ("sense_gestio", "truncat_10", "lliscant"):
total = sum(entrada(t, politica) for t in range(1, 31))
print(f"{politica:>12}: entrada acumulada ~{total:,} tokens")Resultats aproximats: sense gestió ~150.000 tokens; truncament ~90.000; lliscant ~65.000 (més el cost de les crides de resum, que aquesta simulació no inclou — inclou-lo mentalment en decidir). La bretxa es dispara amb més torns: a 100 torns, sense gestió supera el milió i les altres dues continuen planes per torn.
Solució 3:
gestor = GestorSessions()
gestor.preguntar("sessio-a", "Faig servir el pla Business. Puc exportar a CSV?")
resposta_b = gestor.preguntar("sessio-b", "Quin pla faig servir jo?")
# Comportament correcte: B no té aquesta dada al SEU historial ni a la
# documentació proporcionada, i la regla 3 de SYSTEM_DOCUBOT obliga a:
assert "No trobo aquesta informació a la documentació disponible." in resposta_b
assert "Business" not in resposta_b # la dada d'A no s'ha de filtrar MAILa segona asserció és la important: si "Business" apareix a la sessió B, tens una fuga de context entre sessions — un bug de privadesa, no de qualitat. Aquest cas pertany al conjunt d'avaluació permanent (02-04, tipus adversarial suau) i el retrobarem al mòdul 6.
Conclusió
DocuBot ja conversa: saps que la memòria d'un LLM és una il·lusió fabricada pel client — cada crida reenvia l'historial complet, amb el system prompt a part —, tens la classe Conversa que manté aquest historial amb els seus invariants (alternança de rols, desfer en error, comptabilitat de tokens), has vist per què creix fins a topar amb la finestra de context i disparar el cost (lineal per crida, quadràtic acumulat), i disposes de tres estratègies mesurables per contenir-lo — truncament, resum amb el mateix LLM i finestra lliscant — més el GestorSessions que aïlla i persisteix la conversa de cada empleat de Nubelia. I deixem plantada la distinció que obre el mòdul 4: historial per al que s'ha dit, RAG per al que se sap. Però abans de recuperar coneixement cal tancar el compte pendent que aquesta lliçó ha fet créixer: cada token de l'historial es paga, i encara no sabem quant. A la propera lliçó posem números a DocuBot — preus per token, la projecció mensual que refina aquella estimació de ~400 €/mes de 01-04, les palanques per reduir-la, la latència com a segona moneda i el caching com la palanca que ataca totes dues.
Curs d'IA Generativa i LLMs per a Desenvolupadors
Mòdul 1: Fonaments de la IA generativa
- Què és la IA generativa i per què importa als desenvolupadors
- Com funciona un LLM: tokens, embeddings i atenció
- Panorama de models i proveïdors
- Limitacions i riscos: al·lucinacions, context i costos
Mòdul 2: Prompt engineering
- Anatomia del prompt: rols, instruccions i context
- Tècniques de prompting: few-shot, cadena de raonament i plantilles
- Sortides estructurades: JSON i control de format
- Iteració i avaluació de prompts
Mòdul 3: Integració de LLMs en aplicacions
- Primera integració amb l'API d'un LLM
- Streaming, errors i reintents
- Converses i gestió del context
- Costos, latència i caching
Mòdul 4: RAG - Generació augmentada per recuperació
- Embeddings i cerca semàntica
- Bases de dades vectorials
- Ingesta i chunking de documents
- Construcció d'un pipeline RAG complet
- Avaluació de sistemes RAG
Mòdul 5: Function calling i agents
- Function calling: connectar el LLM amb el teu codi
- De LLM a agent: el bucle de raonament i acció
- Frameworks d'orquestració
