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

  1. Els LLMs no tenen memòria: la il·lusió de la conversa
  2. La classe Conversa en Python
  3. L'historial creix: límits de finestra i cost creixent
  4. Estratègies de gestió de l'historial
  5. Multiusuari: sessions aïllades i persistència simple
  6. 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) // 2

Detalls que importen:

  • El pop() de l'except. Si la crida falla, el torn user ja estava afegit. Deixar-lo produiria dos user seguits 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'append de 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 retallat

S'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."},
    ] + recents

Fixa'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 historial

Notes 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_sessio manipulable é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 dict viu 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 assistant o amb dos user seguits 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'append del torn user, retira'l (el pop() de la classe). Historial desbalancejat avui = error estrany demà.
  • Compartir una Conversa global 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_tokens per 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 messages que 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_enviament

La 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 MAI

La 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.

© Copyright 2026. Tots els drets reservats