Mémoire conversationnelle#

Les grands modèles de langage (LLM) sont par nature sans état : chaque appel à l’API constitue une transaction indépendante, sans aucun souvenir des échanges précédents. Cette propriété, héritée de l’architecture Transformer sous-jacente, pose un défi fondamental dès lors que l’on souhaite construire des systèmes conversationnels cohérents sur la durée. Comment un assistant peut-il se souvenir de ce que l’utilisateur a dit cinq, dix ou cent tours de conversation plus tôt ?

La mémoire conversationnelle désigne l’ensemble des mécanismes qui permettent de maintenir un contexte au fil d’une intéraction multi-tours avec un LLM. Ces mécanismes vont du plus simple — stocker l’intégralité des messages — au plus sophistiqué — résumer, compresser, extraire des entités et persister des informations dans des bases de données externes. Le choix d’une stratégie de mémoire est un compromis entre fidélité (ne rien perdre d’important), efficacité (minimiser la consommation de tokens) et pertinence (ne fournir au modèle que les informations utiles à la requête courante).

Ce chapitre explore les principales stratégies de mémoire conversationnelle, depuis le tampon brut jusqu’aux architectures hiérarchiques à long terme. Pour chaque approche, nous formalisons le fonctionnement, analysons les compromis et fournissons une implémentation Python exécutable. Nous terminons par une comparaison empirique sur une conversation simulée de trente tours.

Le problème de la mémoire dans les LLM#

Un LLM de type Transformer traite une séquence de tokens en entrée et produit une séquence en sortie. A chaque appel, le modèle reçoit la totalité du contexte nécessaire dans le prompt : il n’a pas d’état interne persistant entre deux appels. Si l’on souhaite qu’un LLM « se souvienne » d’un échange précédent, il faut explicitement inclure cet échange dans le prompt de l’appel suivant.

Définition 45 (Mémoire conversationnelle)

La mémoire conversationnelle est un mécanisme logiciel qui gère l’historique des messages échangés entre un utilisateur et un LLM, et qui construit le contexte fourni au modèle à chaque nouveau tour de conversation. Formellement, soit \(\mathcal{H}_t = \{(r_1, a_1), \ldots, (r_t, a_t)\}\) la séquence des \(t\) paires (requête, réponse) échangées. La mémoire est une fonction

\[M : \mathcal{H}_t \to \mathcal{C}_t\]

qui transforme l’historique complet en un contexte \(\mathcal{C}_t\) de taille bornée, fourni au modèle pour générer la réponse \(a_{t+1}\).

La contrainte fondamentale est la fenêtre de contexte du modèle. Chaque LLM possède une longueur maximale \(L\) (en tokens) pour la séquence d’entrée. Même lorsque la fenêtre est très large (128k ou 1M tokens), inclure un historique volumineux a des conséquences directes :

  • Coût : la facturation des API est proportionnelle au nombre de tokens en entrée.

  • Latence : le temps d’inférence croit avec la longueur du contexte (complexité quadratique de l’attention).

  • Dégradation : la qualité des réponses se dégrade pour les contextes très longs, en particulier pour les informations situées au milieu de la fenêtre (lost in the middle).

Remarque 55

La fenêtre de contexte d’un LLM est analogue à la mémoire de travail en psychologie cognitive : elle est limitée en capacité et nécessite des stratégies actives de gestion pour être utilisée efficacement. Tout comme un humain ne peut pas retenir simultanément des centaines d’informations, un LLM ne peut pas traiter un contexte arbitrairement long sans perte de performance. Les stratégies de mémoire conversationnelle sont l’équivalent computationnel des stratégies mnémotechniques humaines.

Définition 46 (Taux de compression mémorielle)

Le taux de compression d’une stratégie de mémoire \(M\) au tour \(t\) est défini par :

\[\rho_t = \frac{|\mathcal{C}_t|}{|\mathcal{H}_t|} = \frac{\text{taille du contexte fourni au modèle}}{\text{taille de l'historique complet}}\]

Pour la mémoire tampon, \(\rho_t = 1\) (aucune compression). Pour une fenêtre de taille \(K\), \(\rho_t = \min(1,\, K/t)\). Un bon mécanisme de mémoire minimise \(\rho_t\) tout en maximisant la rétention d’information pertinente.

Hide code cell source

# Import des librairies Python
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.patches import Patch
from dataclasses import dataclass, field

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Hide code cell source

# Illustration : croissance du contexte sans gestion de mémoire
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

turns = np.arange(1, 51)
tokens_per_turn = 150
cumulative_tokens = turns * tokens_per_turn

fig, ax = plt.subplots(figsize=(9, 4))
ax.fill_between(turns, cumulative_tokens, alpha=0.3, color="steelblue")
ax.plot(turns, cumulative_tokens, color="steelblue", lw=2, label="Tokens cumulés")
ax.axhline(y=4096, color="coral", ls="--", lw=1.5, label="Fenêtre 4K tokens")
ax.axhline(y=8192, color="orange", ls="--", lw=1.5, label="Fenêtre 8K tokens")
ax.set_xlabel("Tour de conversation")
ax.set_ylabel("Tokens dans le contexte")
ax.set_title("Croissance linéaire du contexte sans gestion de mémoire")
ax.legend()
plt.show()
_images/e95581a74a86ed39af79655adc502b073ee55fb8093eab614472a51de5b95868.png

Le graphique illustre le problème : sans gestion de mémoire, le nombre de tokens croit linéairement. Après environ 27 tours (à 150 tokens par tour), on dépasse la fenêtre de 4096 tokens.

Mémoire tampon (buffer memory)#

La stratégie la plus naive consiste a conserver l’integralité des messages échangés et à les injecter tels quels dans le prompt.

Définition 47 (Mémoire tampon)

La mémoire tampon (buffer memory) stocke l’historique complet sans transformation. Le contexte fourni au modèle est la concatenation de tous les messages :

\[\mathcal{C}_t = \bigoplus_{i=1}^{t} (r_i \| a_i)\]

\(\oplus\) designe la concaténation et \(\|\) le séparateur entre requête et réponse. La taille du contexte est \(|\mathcal{C}_t| = \sum_{i=1}^{t} (|r_i| + |a_i|)\), qui croit linéairement avec \(t\).

Cette approche a le mérite de la fidélité totale : aucune information n’est perdue. Cependant, lorsque \(|\mathcal{C}_t| > L\), il faut tronquer les messages les plus anciens.

Exemple 36 (Implémentation d’une mémoire tampon)

La classe ConversationBufferMemory stocke les messages et retourne un contexte tronqué par l’avant si la limite de tokens est atteinte :

memory = ConversationBufferMemory(max_tokens=4096)
memory.add_message("user", "Bonjour, je suis Alice.")
memory.add_message("assistant", "Bonjour Alice !")
context = memory.get_context()  # tout l'historique, tronque si > 4096 tokens

Hide code cell source

@dataclass
class ConversationBufferMemory:
    """Mémoire tampon : stocke l'intégralité des messages."""
    max_tokens: int = 4096
    messages: list = field(default_factory=list)

    @staticmethod
    def _count_tokens(text: str) -> int:
        return len(text) // 4  # estimation : 1 token ~ 4 caracteres

    def add_message(self, role: str, content: str) -> None:
        self.messages.append({"role": role, "content": content,
                              "tokens": self._count_tokens(content)})

    def get_total_tokens(self) -> int:
        return sum(m["tokens"] for m in self.messages)

    def get_context(self) -> str:
        ctx = list(self.messages)
        total = sum(m["tokens"] for m in ctx)
        while total > self.max_tokens and len(ctx) > 1:
            total -= ctx.pop(0)["tokens"]
        return "\n".join(f"[{m['role']}] {m['content']}" for m in ctx)

    def reset(self) -> None:
        self.messages.clear()


# Démonstration
buffer = ConversationBufferMemory(max_tokens=500)
sample_exchanges = [
    ("user", "Bonjour, je m'appelle Alice et je travaille chez Acme Corp."),
    ("assistant", "Bonjour Alice ! Ravi de vous rencontrer. Comment puis-je vous aider ?"),
    ("user", "Je cherche des informations sur le machine learning."),
    ("assistant", "Le machine learning est un sous-domaine de l'IA qui permet aux systèmes d'apprendre."),
    ("user", "Quels sont les principaux types d'apprentissage ?"),
    ("assistant", "On distingue l'apprentissage supervisé, non supervisé et par renforcement."),
]
for role, content in sample_exchanges:
    buffer.add_message(role, content)

print(f"Messages : {len(buffer.messages)} | Tokens : {buffer.get_total_tokens()}")
print(f"\n{buffer.get_context()}")
Messages : 6 | Tokens : 95

[user] Bonjour, je m'appelle Alice et je travaille chez Acme Corp.
[assistant] Bonjour Alice ! Ravi de vous rencontrer. Comment puis-je vous aider ?
[user] Je cherche des informations sur le machine learning.
[assistant] Le machine learning est un sous-domaine de l'IA qui permet aux systèmes d'apprendre.
[user] Quels sont les principaux types d'apprentissage ?
[assistant] On distingue l'apprentissage supervisé, non supervisé et par renforcement.

Fenêtre glissante (sliding window)#

Pour éviter la croissance indéfinie du contexte, une approche courante consiste a ne conserver que les \(K\) derniers tours de conversation.

Définition 48 (Mémoire à fenêtre glissante)

La mémoire à fenêtre glissante (sliding window memory) conserve uniquement les \(K\) derniers echanges :

\[\mathcal{C}_t = \bigoplus_{i=\max(1,\, t-K+1)}^{t} (r_i \| a_i)\]

La taille du contexte est bornée : \(|\mathcal{C}_t| \leq K \cdot \bar{s}\) ou \(\bar{s}\) est la taille moyenne d’un échange.

L’avantage principal est la prévisibilité : la consommation de tokens est bornée indépendamment de la longueur de la conversation. En revanche, toute information mentionnée avant les \(K\) derniers tours est définitivement perdue.

Remarque 56

La perte d’information dans la fenêtre glissante est abrupte : un message est soit entièrement présent, soit entièrement absent du contexte. Cela pose problème lorsque des informations critiques (nom de l’utilisateur, sujet initial) ont été mentionnées dans les premiers tours. Le modèle « oublie » soudainement ces informations, ce qui peut produire des réponses incohérentes.

Remarque 57

Le choix de la taille de fenêtre \(K\) dépend du domaine d’application. Pour un chatbot de support technique, \(K = 5\) a \(10\) suffit souvent car chaque question est relativement indépendante. Pour un tuteur pédagogique ou un assistant de rédaction, \(K\) doit être plus grand car la cohérence sur la durée est essentielle. En pratique, on ajuste \(K\) en fonction du budget de tokens disponible : \(K \leq \lfloor L / \bar{s} \rfloor\)\(L\) est la taille de la fenêtre de contexte et \(\bar{s}\) la taille moyenne d’un échange.

Hide code cell source

@dataclass
class SlidingWindowMemory:
    """Mémoire à fenêtre glissante : conserve les K derniers echanges."""
    window_size: int = 5
    messages: list = field(default_factory=list)

    @staticmethod
    def _count_tokens(text: str) -> int:
        return len(text) // 4

    def add_message(self, role: str, content: str) -> None:
        self.messages.append({"role": role, "content": content,
                              "tokens": self._count_tokens(content)})

    def get_total_tokens(self) -> int:
        return sum(m["tokens"] for m in self.messages[-(self.window_size * 2):])

    def get_context(self) -> str:
        window = self.messages[-(self.window_size * 2):]
        return "\n".join(f"[{m['role']}] {m['content']}" for m in window)

    def get_lost_count(self) -> int:
        return max(0, len(self.messages) - self.window_size * 2)

    def reset(self) -> None:
        self.messages.clear()


sliding = SlidingWindowMemory(window_size=2)
for role, content in sample_exchanges:
    sliding.add_message(role, content)

print(f"Messages totaux : {len(sliding.messages)} | Dans la fenêtre : "
      f"{min(len(sliding.messages), sliding.window_size * 2)} | Perdus : {sliding.get_lost_count()}")
print(f"\nContexte (K=2) :\n{sliding.get_context()}")
Messages totaux : 6 | Dans la fenêtre : 4 | Perdus : 2

Contexte (K=2) :
[user] Je cherche des informations sur le machine learning.
[assistant] Le machine learning est un sous-domaine de l'IA qui permet aux systèmes d'apprendre.
[user] Quels sont les principaux types d'apprentissage ?
[assistant] On distingue l'apprentissage supervisé, non supervisé et par renforcement.

Le nom « Alice » et l’entreprise « Acme Corp » ont disparu du contexte : le modèle ne pourrait plus y faire référence.

Résumé de conversation (summary memory)#

Une strategie plus intelligente consiste à résumer les anciens messages plutôt que de les supprimer. On conserve une version compressée de l’historique combinée aux messages récents.

Définition 49 (Mémoire par résumé)

La mémoire par résumé (summary memory) maintient deux composantes :

  1. Un résumé courant \(S_t\) qui condense les échanges anciens.

  2. Un tampon récent \(B_t\) contenant les \(K\) derniers messages non résumés.

Le contexte est \(\mathcal{C}_t = S_t \oplus B_t\). Périodiquement, les messages anciens du tampon sont résumés :

\[S_{t+P} = \text{LLM}(S_t \oplus B_t^{\text{ancien}})\]

\(\text{LLM}(\cdot)\) désigne un appel au modèle pour générer le résumé.

Remarque 58

Le résumé réalise une compression avec perte. Un échange de 500 tokens peut être résumé en 50, mais certaines nuances sont inévitablement perdues. L’efficacité dépend de la qualité du LLM utilise pour le résumé. Un bon résumé préserve les faits clés, les décisions prises et les préférences exprimées, tout en éliminant les formules de politesse et les répétitions.

Exemple 37 (Chaine de résumé)

En production, le résumé est généré par un appel LLM avec un prompt tel que :

Résumez la conversation suivante de manière concise.
Préservez : noms, décisions, préférences, questions en suspens.

Conversation : {messages_anciens}
Résumé existant : {resume_precedent}
Nouveau résumé :

Le résumé est stocké et utilisé comme préfixe du contexte dans les appels suivants.

Hide code cell source

@dataclass
class SummaryMemory:
    """Mémoire par résumé : compresse les anciens messages."""
    summary_threshold: int = 4
    summary: str = ""
    messages: list = field(default_factory=list)

    @staticmethod
    def _count_tokens(text: str) -> int:
        return len(text) // 4

    def _simulate_summarize(self, messages: list) -> str:
        """Simule un résumé LLM (en production : appel réel au modèle)."""
        facts = [m["content"][:60] + "..." for m in messages if len(m["content"]) > 30]
        new = " ; ".join(facts)
        return (self.summary + " | " + new) if self.summary else "Résumé : " + new

    def add_message(self, role: str, content: str) -> None:
        self.messages.append({"role": role, "content": content,
                              "tokens": self._count_tokens(content)})
        if len(self.messages) > self.summary_threshold:
            self.summary = self._simulate_summarize(self.messages[:2])
            self.messages = self.messages[2:]

    def get_total_tokens(self) -> int:
        s = self._count_tokens(self.summary) if self.summary else 0
        return s + sum(m["tokens"] for m in self.messages)

    def get_context(self) -> str:
        parts = [f"[résumé] {self.summary}"] if self.summary else []
        parts += [f"[{m['role']}] {m['content']}" for m in self.messages]
        return "\n".join(parts)

    def reset(self) -> None:
        self.messages.clear()
        self.summary = ""


summary_mem = SummaryMemory(summary_threshold=4)
for role, content in sample_exchanges:
    summary_mem.add_message(role, content)

print(f"Résumé : {summary_mem.summary}")
print(f"Tampon : {len(summary_mem.messages)} messages | Tokens : {summary_mem.get_total_tokens()}")
print(f"\n{summary_mem.get_context()}")
Résumé : Résumé : Bonjour, je m'appelle Alice et je travaille chez Acme Corp.... ; Bonjour Alice ! Ravi de vous rencontrer. Comment puis-je vou...
Tampon : 4 messages | Tokens : 98

[résumé] Résumé : Bonjour, je m'appelle Alice et je travaille chez Acme Corp.... ; Bonjour Alice ! Ravi de vous rencontrer. Comment puis-je vou...
[user] Je cherche des informations sur le machine learning.
[assistant] Le machine learning est un sous-domaine de l'IA qui permet aux systèmes d'apprendre.
[user] Quels sont les principaux types d'apprentissage ?
[assistant] On distingue l'apprentissage supervisé, non supervisé et par renforcement.

Compression de contexte#

Au-delà du résumé simple, des techniques plus sophistiquées permettent de comprimer le contexte de manière structurée, en extrayant des informations spécifiques plutôt qu’en produisant un résumé narratif.

Définition 50 (Compression de contexte)

La compression de contexte (context compression) désigne une famille de techniques qui transforment l’historique conversationnel en une représentation plus compacte :

  • Extraction d’entités : identifier et stocker les entités clés (noms, lieux, concepts) et leurs attributs.

  • Suivi d’entités (entity tracking) : maintenir un registre des entités et de leur état courant.

  • Résumé hiérarchique : résumer à plusieurs niveaux de granularité (session, thème, fait).

  • Compression par embeddings : représenter les messages anciens par leurs vecteurs et sélectionner les passages pertinents par similarité sémantique.

Remarque 59

Le suivi d’entités est particulièrement utile dans les conversations ou l’utilisateur mentionne des personnes, des projets ou des concepts specifiques dont les attributs évoluent. Par exemple, si l’utilisateur dit « mon budget est passé de 10k à 50k » au tour 15, un suivi d’entités mettra à jour l’attribut budget sans conserver l’intégralité du tour 15 dans le contexte. Cela permet une compression structurée qui préserve les informations factuelles tout en réduisant le nombre de tokens.

Hide code cell source

@dataclass
class EntityMemory:
    """Mémoire par extraction d'entités : suivi structuré des faits clés."""
    entities: dict = field(default_factory=dict)
    recent_messages: list = field(default_factory=list)
    max_recent: int = 4

    @staticmethod
    def _count_tokens(text: str) -> int:
        return len(text) // 4

    def _extract_entities(self, content: str) -> dict:
        """Extraction simplifiée (en production : appel LLM ou NER)."""
        extracted = {}
        lower = content.lower()
        if any(w in lower for w in ("appelle", "nom", "suis")):
            for mot in content.split():
                if mot[0].isupper() and mot.lower() not in ("je", "bonjour", "le", "la"):
                    extracted.setdefault("personnes", []).append(mot.strip(".,!?"))
        if any(w in lower for w in ("travaille", "entreprise")):
            for mot in content.split():
                if mot[0].isupper() and len(mot) > 2:
                    extracted.setdefault("organisations", []).append(mot.strip(".,!?"))
        if "machine learning" in lower:
            extracted.setdefault("sujets", []).append("machine learning")
        return extracted

    def add_message(self, role: str, content: str) -> None:
        self.recent_messages.append({"role": role, "content": content,
                                     "tokens": self._count_tokens(content)})
        for cat, vals in self._extract_entities(content).items():
            self.entities.setdefault(cat, set()).update(vals)
        if len(self.recent_messages) > self.max_recent:
            self.recent_messages = self.recent_messages[-self.max_recent:]

    def get_total_tokens(self) -> int:
        e = self._count_tokens(str(self.entities))
        return e + sum(m["tokens"] for m in self.recent_messages)

    def get_context(self) -> str:
        parts = []
        if self.entities:
            parts.append("[entités] " + str({k: list(v) for k, v in self.entities.items()}))
        parts += [f"[{m['role']}] {m['content']}" for m in self.recent_messages]
        return "\n".join(parts)

    def reset(self) -> None:
        self.entities.clear()
        self.recent_messages.clear()


entity_mem = EntityMemory(max_recent=4)
for role, content in sample_exchanges:
    entity_mem.add_message(role, content)

print("Entités extraites :")
for cat, vals in entity_mem.entities.items():
    print(f"  {cat}: {list(vals)}")
print(f"\n{entity_mem.get_context()}")
Entités extraites :
  personnes: ['Corp', 'Alice', 'Acme', 'Bonjour']
  organisations: ['Corp', 'Alice', 'Acme', 'Bonjour']
  sujets: ['machine learning']

[entités] {'personnes': ['Corp', 'Alice', 'Acme', 'Bonjour'], 'organisations': ['Corp', 'Alice', 'Acme', 'Bonjour'], 'sujets': ['machine learning']}
[user] Je cherche des informations sur le machine learning.
[assistant] Le machine learning est un sous-domaine de l'IA qui permet aux systèmes d'apprendre.
[user] Quels sont les principaux types d'apprentissage ?
[assistant] On distingue l'apprentissage supervisé, non supervisé et par renforcement.

Mémoire à long terme et hiérarchique#

Les stratégies précédentes opèrent au sein d’une session unique. Dans de nombreuses applications (assistants personnels, support client, tuteurs intelligents), il faut persister des informations entre les sessions, ce qui nécessite une mémoire à long terme.

Exemple 38 (Mémoire hiérarchique)

Une architecture de mémoire hiérarchique organise l’information à trois niveaux :

  1. Mémoire de travail : les \(K\) derniers messages (fenêtre glissante).

  2. Mémoire épisodique : les résumés des sessions précédentes, stockés dans une base vectorielle et récupérés par similarité sémantique.

  3. Mémoire sémantique : les faits stables extraits (entités, préférences), stockés dans une base structurée.

Requête utilisateur
       │
       ▼
┌─────────────────┐
│ Mémoire de      │ ◄── derniers K messages
│ travail         │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ Mémoire         │ ◄── recherche par similarité
│ épisodique      │     dans la base vectorielle
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ Mémoire         │ ◄── lookup dans la base
│ sémantique      │     structurée (entités)
└────────┬────────┘
         │
         ▼
   Contexte enrichi → LLM → Réponse

Propriété 13 (Compromis des stratégies de mémoire)

Les stratégies de mémoire présentent des compromis selon quatre axes :

  1. Fidélité : proportion d’information originale préservée dans le contexte.

  2. Coût en tokens : nombre de tokens consommés par tour (coût financier et latence).

  3. Complexité d’implementation : difficulté technique de mise en oeuvre.

  4. Passage à l’échelle : capacité à gérer des centaines de tours ou des interactions multi-sessions.

Aucune stratégie ne domine les autres sur tous les axes. Le tampon est maximal en fidélité mais minimal en passage à l’échelle. La fenêtre glissante est simple et bornée mais perd de l’information. Le résumé offre un bon compromis mais introduit du bruit. La mémoire hiérarchique est la plus complete mais aussi la plus complexe.

Remarque 60

La mémoire épisodique par base vectorielle offre un avantage unique : elle permet de retrouver des informations anciennes sans les conserver en permanence dans le contexte. Au lieu de fournir un résumé global potentiellement hors sujet, on récupère spécifiquement les passages pertinents pour la requête courante. Cette approche, à l’intersection de la mémoire conversationnelle et du RAG (chapitre suivant), est particulièrement efficace pour les conversations longues et thématiquement variées.

Hide code cell source

# Visualisation : comparaison des stratégies (tableau)

fig, ax = plt.subplots(figsize=(10, 3.5))
ax.axis("off")

strategies = ["Tampon (buffer)", "Fenêtre glissante", "Résumé", "Entités", "Hiérarchique"]
criteres = ["Fidélité", "Coût tokens", "Complexité", "Passage\nà l'échelle"]
data = [
    ["Totale",    "Élevé",  "Faible",  "Faible"],
    ["Partielle", "Borné",  "Faible",  "Moyenne"],
    ["Bonne",     "Modéré", "Moyenne", "Bonne"],
    ["Structurée","Faible", "Moyenne", "Bonne"],
    ["Optimale",  "Adapté", "Élevée",  "Excellente"],
]
cmap = {"Totale": "#2ecc71", "Partielle": "#e67e22", "Bonne": "#3498db",
        "Structurée": "#9b59b6", "Optimale": "#2ecc71", "Élevé": "#e74c3c",
        "Borné": "#2ecc71", "Modéré": "#f39c12", "Faible": "#2ecc71",
        "Adapté": "#3498db", "Moyenne": "#f39c12", "Élevée": "#e74c3c",
        "Excellente": "#2ecc71"}

table = ax.table(cellText=data, rowLabels=strategies, colLabels=criteres,
                 cellLoc="center", rowLoc="center", loc="center")
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1.0, 1.6)
for (row, col), cell in table.get_celld().items():
    if row == 0:
        cell.set_facecolor("#34495e")
        cell.set_text_props(color="white", weight="bold")
    elif col == -1:
        cell.set_facecolor("#ecf0f1")
        cell.set_text_props(weight="bold")
    else:
        cell.set_facecolor(cmap.get(data[row - 1][col], "#ffffff") + "33")
ax.set_title("Comparaison des stratégies de mémoire conversationnelle", fontsize=13, pad=20)
plt.show()
_images/1436a54fab92716b96696cf0fc3240b99c950701d2c595967b8142164899ac1d.png

Hide code cell source

# Evolution des tokens sur 50 tours pour chaque strategie
n_turns = 50
tpm = 75  # tokens par message
buf_tok = np.cumsum(np.full(n_turns * 2, tpm)).reshape(n_turns, 2).sum(axis=1)
win_tok = np.minimum(buf_tok, 5 * 2 * tpm)
sum_tok = np.full(n_turns, 100 + 4 * tpm)
sum_tok[:4] = buf_tok[:4]

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(range(1, n_turns + 1), buf_tok, lw=2.5, label="Tampon (tout garder)", color="#e74c3c")
ax.plot(range(1, n_turns + 1), win_tok, lw=2.5, label="Fenêtre glissante (K=5)", color="#3498db")
ax.plot(range(1, n_turns + 1), sum_tok, lw=2.5, label="Résumé + récent", color="#2ecc71")
ax.axhline(y=4096, color="gray", ls=":", lw=1, alpha=0.7)
ax.text(n_turns + 0.5, 4096, "4K", va="center", fontsize=9, color="gray")
ax.set_xlabel("Tour de conversation")
ax.set_ylabel("Tokens dans le contexte")
ax.set_title("Consommation de tokens selon la stratégie de mémoire")
ax.legend(loc="upper left")
plt.show()
_images/512c31b0b6e8f8120f84e93204c98fd857c7a461ddad80d35c836f51539f6c75.png

Hide code cell source

# Diagramme de retention d'information
fig, axes = plt.subplots(3, 1, figsize=(9, 11))
tv, ck, cl, cs = 10, "#2ecc71", "#e74c3c", "#f39c12"

ax = axes[0]
ax.barh(range(tv), [1]*tv, color=ck, edgecolor="white", height=0.7)
ax.set_yticks(range(tv)); ax.set_yticklabels([f"Tour {i+1}" for i in range(tv)])
ax.set_xlim(0, 1.2); ax.set_xticks([]); ax.set_title("Tampon", weight="bold"); ax.invert_yaxis()

ax = axes[1]
ax.barh(range(tv), [1]*tv, color=[cl]*(tv-3)+[ck]*3, edgecolor="white", height=0.7)
ax.set_yticks(range(tv)); ax.set_yticklabels([f"Tour {i+1}" for i in range(tv)])
ax.set_xlim(0, 1.2); ax.set_xticks([]); ax.set_title("Fenêtre (K=3)", weight="bold"); ax.invert_yaxis()

ax = axes[2]
ax.barh(range(tv), [0.3]*(tv-3)+[1]*3, color=[cs]*(tv-3)+[ck]*3, edgecolor="white", height=0.7)
ax.set_yticks(range(tv)); ax.set_yticklabels([f"Tour {i+1}" for i in range(tv)])
ax.set_xlim(0, 1.2); ax.set_xticks([]); ax.set_title("Résumé + récent", weight="bold"); ax.invert_yaxis()

fig.legend(handles=[Patch(facecolor=ck, label="Conservé"), Patch(facecolor=cl, label="Perdu"),
                    Patch(facecolor=cs, label="Résumé")],
           loc="lower center", ncol=3, fontsize=10, bbox_to_anchor=(0.5, -0.05))
fig.suptitle("Rétention de l'information selon la stratégie", fontsize=13, y=1.02)
plt.show()
_images/7ddd40c5a7cd4eae515e1f076c0c0997ee156f0bbad15c469f30da0faf3bf42f.png

Implémentation et patterns#

En pratique, les systèmes de production combinent plusieurs stratégies pour obtenir le meilleur compromis. Cette section presente les patterns les plus courants.

Mémoire hybride (resume + tampon recent)#

Le pattern le plus répandu consiste à combiner un résumé progressif des anciens messages avec un tampon des messages récents. Ce schéma est utilisé par défaut dans des frameworks comme LangChain ou LlamaIndex.

Hide code cell source

@dataclass
class HybridMemory:
    """Memoire hybride : resumé des anciens + tampon des récents."""
    max_recent: int = 6
    summary: str = ""
    recent_messages: list = field(default_factory=list)
    all_messages: list = field(default_factory=list)

    @staticmethod
    def _count_tokens(text: str) -> int:
        return len(text) // 4

    def _simulate_summarize(self, messages: list) -> str:
        facts = [m["content"][:60] + "..." for m in messages if len(m["content"]) > 30]
        new = " ; ".join(facts)
        return (self.summary + " | " + new) if self.summary else "Résumé : " + new

    def add_message(self, role: str, content: str) -> None:
        msg = {"role": role, "content": content, "tokens": self._count_tokens(content)}
        self.all_messages.append(msg)
        self.recent_messages.append(msg)
        if len(self.recent_messages) > self.max_recent:
            self.summary = self._simulate_summarize(self.recent_messages[:2])
            self.recent_messages = self.recent_messages[2:]

    def get_total_tokens(self) -> int:
        s = self._count_tokens(self.summary) if self.summary else 0
        return s + sum(m["tokens"] for m in self.recent_messages)

    def get_context(self) -> str:
        parts = [f"[résumé] {self.summary}"] if self.summary else []
        parts += [f"[{m['role']}] {m['content']}" for m in self.recent_messages]
        return "\n".join(parts)

    def reset(self) -> None:
        self.recent_messages.clear(); self.all_messages.clear(); self.summary = ""

Mémoire avec RAG#

Un pattern avancé consiste à indexer l’historique dans une base vectorielle et à récupérer par similarité les passages pertinents pour la requête courante. Plutôt que de fournir l’intégralité de l’historique ou un résumé statique, on recherche dans l’historique les messages pertinents. Cette approche, à l’intersection de la mémoire et du RAG (chapitre suivant), est particulièrement efficace pour les conversations longues.

Exemple 39 (Pattern memoire + RAG)

L’intégration de la mémoire avec le RAG suit un schéma en trois étapes :

  1. Indexation : à chaque nouveau message, on calcule son embedding et on l’insère dans la base vectorielle.

  2. Retrieval : à chaque requête, on récupère les \(k\) messages les plus proches sémantiquement.

  3. Augmentation : le contexte fourni au LLM combine les messages récupérés et le tampon recent.

# Schéma conceptuel (en production : FAISS, Chroma, Pinecone...)
embedding = model.encode(new_message)
vector_store.add(embedding, metadata={"content": new_message, "turn": t})
relevant = vector_store.search(query_embedding, top_k=5)
context = format_context(summary, relevant, recent_messages)

Gestion de la mémoire en production#

En environnement de production, la gestion de la mémoire doit prendre en compte des contraintes supplémentaires :

  • Persistance : les sessions doivent être sauvegardées (base de données, Redis) pour survivre aux redémarrages.

  • Multi-utilisateurs : chaque utilisateur doit avoir son propre espace mémoire isolé.

  • TTL : les conversations anciennes doivent être archivées selon les politiques de rétention.

  • Sécurité : les données conversationnelles contiennent potentiellement des informations sensibles.

Remarque 61

En production, le choix de la stratégie de mémoire est souvent dicté par des contraintes non techniques : budget API, exigences réglementaires (RGPD, droit à l’oubli), politique de rétention des données. Un système déployé en Europe doit par exemple permettre la suppression complête de l’historique d’un utilisateur sur demande, ce qui impose une architecture de mémoire oè chaque session est identifiable et supprimable indépendamment.

Hide code cell source

# Simulation complête : 30 tours à travers chaque stratégie
n_sim = 30

topics = [
    "Je m'appelle Marie et je travaille chez TechCorp sur un projet d'IA.",
    "Notre projet concerne la détection de fraude dans les transactions bancaires.",
    "Nous utilisons un modèle XGBoost avec 50 features extraites des logs.",
    "Le taux de fraude est d'environ 0.1%, très déséquilibré.",
    "Nous avons essayé SMOTE mais les résultats sont mitigés.",
    "Le recall est à 0.85 mais la précision n'est qu'à 0.30.",
    "Mon manager veut déployer le modèle en production d'ici la fin du mois.",
    "L'infrastructure est sur AWS avec des lambdas pour l'inférence.",
    "Nous avons aussi un pipeline Airflow pour le retraining mensuel.",
    "Le budget GPU est limité, nous ne pouvons pas utiliser de gros modèles.",
]
sim_msgs = []
for i in range(n_sim):
    u = topics[i % len(topics)] if i < len(topics) else f"Question de suivi numéro {i+1}."
    a = f"Réponse détaillée au tour {i+1} avec des recommandations adaptées."
    sim_msgs += [("user", u), ("assistant", a)]

mems = {"Tampon": ConversationBufferMemory(max_tokens=100000),
        "Fenêtre (K=5)": SlidingWindowMemory(window_size=5),
        "Résumé": SummaryMemory(summary_threshold=6),
        "Hybride": HybridMemory(max_recent=6)}
history = {k: [] for k in mems}

for role, content in sim_msgs:
    for m in mems.values():
        m.add_message(role, content)
    if role == "assistant":
        for k, m in mems.items():
            history[k].append(m.get_total_tokens())

for name, m in mems.items():
    ctx = m.get_context()
    print(f"{'=' * 60}\n{name}{m.get_total_tokens()} tokens\n{'=' * 60}")
    print(ctx[:200] + "...\n")
============================================================
Tampon — 749 tokens
============================================================
[user] Je m'appelle Marie et je travaille chez TechCorp sur un projet d'IA.
[assistant] Réponse détaillée au tour 1 avec des recommandations adaptées.
[user] Notre projet concerne la détection de frau...

============================================================
Fenêtre (K=5) — 110 tokens
============================================================
[user] Question de suivi numéro 26.
[assistant] Réponse détaillée au tour 26 avec des recommandations adaptées.
[user] Question de suivi numéro 27.
[assistant] Réponse détaillée au tour 27 avec des re...

============================================================
Résumé — 674 tokens
============================================================
[résumé] Résumé : Je m'appelle Marie et je travaille chez TechCorp sur un proj... ; Réponse détaillée au tour 1 avec des recommandations adaptée... | Notre projet concerne la détection de fraude dans ...

============================================================
Hybride — 674 tokens
============================================================
[résumé] Résumé : Je m'appelle Marie et je travaille chez TechCorp sur un proj... ; Réponse détaillée au tour 1 avec des recommandations adaptée... | Notre projet concerne la détection de fraude dans ...

Hide code cell source

# Graphique final : évolution comparée sur 30 tours
fig, ax = plt.subplots(figsize=(10, 5))
palette = {"Tampon": "#e74c3c", "Fenêtre (K=5)": "#3498db", "Résumé": "#2ecc71", "Hybride": "#9b59b6"}
for name, tokens in history.items():
    ax.plot(range(1, n_sim + 1), tokens, lw=2.5, label=name, color=palette[name])
ax.set_xlabel("Tour de conversation")
ax.set_ylabel("Tokens dans le contexte")
ax.set_title("Évolution du contexte sur 30 tours — comparaison des stratégies")
ax.legend(loc="upper left")
plt.show()
_images/be56a11311b7438859649ecaded086f2b4022a35bd8a9cb3742ff7b6cb2d06d2.png

Le graphique confirme les propriétés théoriques : le tampon diverge, la fenêtre glissante et les approches par résumé restent bornées. La mémoire hybride offre un bon compromis entre coût en tokens et rétention d’information.

Résumé#

Ce chapitre a présenté les principales stratégies de mémoire conversationnelle pour les LLM :

  1. Les LLM sont sans état : chaque appel est indépendant, et la fenêtre de contexte constitue la seule « mémoire de travail » du modèle. Sans mécanisme explicite, le contexte croit linéairement et dépasse la capacité du modèle.

  2. La mémoire tampon conserve l’intégralité des messages. Elle offre une fidelité maximale mais une consommation de tokens non bornée, inadaptée aux longues conversations.

  3. La fenêtre glissante conserve les \(K\) derniers tours. Elle borne la consommation de tokens mais introduit une perte d’information abrupte pour tout ce qui précède la fenêtre.

  4. La mémoire par résumé compresse les anciens messages en un résumé narratif combiné avec un tampon récent. La compression est efficace mais avec perte.

  5. La compression de contexte englobe des techniques structurées (extraction d’entités, suivi d’entités, résumé hiérarchique) qui préservent l’information sous une forme compacte et interrogeable.

  6. La mémoire à long terme persiste entre les sessions via des bases de données, des stores vectoriels (mémoire épisodique) et des stores structurés (mémoire sémantique).

  7. Les patterns de production combinent plusieurs stratégies : mémoire hybride (résumé + tampon récent), mémoire avec RAG pour la récupération sémantique, et gestion des contraintes opérationnelles (persistance, multi-utilisateurs, sécurité).

  8. Le choix d’une stratégie est un compromis entre fidélité, coût en tokens, complexité et passage à l’échelle. Les systèmes les plus performants utilisent une architecture hiérarchique combinant mémoire de travail, mémoire épisodique et mémoire sémantique.