Module 6 — Mémoire de conversation
Notre assistant sait maintenant lire la politique et répondre. Il ne sait pas encore que « et pour Berlin ? » suit « quel est le plafond hôtel à Paris ? ». Il faut de la mémoire de conversation : conserver les échanges précédents, les réinjecter dans la consigne, et surtout ne pas laisser cette mémoire grossir jusqu'à saturer la fenêtre de contexte. Ce module fixe les trois stratégies pratiques et le patron RunnableWithMessageHistory qui les branche à une chaîne LCEL.
L'historique de messages, un objet simple
langchain-core expose BaseChatMessageHistory, une interface à trois méthodes : add_message, messages (getter), clear. L'implémentation la plus courante, InMemoryChatMessageHistory (ou son ancêtre ChatMessageHistory), stocke une liste en mémoire :
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import HumanMessage, AIMessage
historique = InMemoryChatMessageHistory()
historique.add_message(HumanMessage(content="Quel est le plafond hôtel à Paris ?"))
historique.add_message(AIMessage(content="180 EUR TTC par nuit selon l'article 4.2."))
print(historique.messages)
C'est un objet passif : il stocke, il ne décide rien. La logique de sélection (« quels messages passer au modèle ? ») vit dans la chaîne, pas dans l'historique.
RunnableWithMessageHistory : le patron moderne
RunnableWithMessageHistory enveloppe une chaîne LCEL et lui injecte automatiquement l'historique lié à une session :
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_openai import ChatOpenAI
consigne = ChatPromptTemplate.from_messages([
("system", "Vous êtes l'assistant de notes de frais. Répondez brièvement."),
MessagesPlaceholder(variable_name="historique"),
("human", "{question}"),
])
chaine = consigne | ChatOpenAI(temperature=0)
historiques = {}
def obtenir_historique(session_id: str):
if session_id not in historiques:
historiques[session_id] = InMemoryChatMessageHistory()
return historiques[session_id]
chaine_avec_memoire = RunnableWithMessageHistory(
chaine,
obtenir_historique,
input_messages_key="question",
history_messages_key="historique",
)
config = {"configurable": {"session_id": "employe-42"}}
chaine_avec_memoire.invoke({"question": "Plafond hôtel à Paris ?"}, config=config)
chaine_avec_memoire.invoke({"question": "Et pour Berlin ?"}, config=config)
Le MessagesPlaceholder insère la liste des messages précédents à la place réservée. La session_id isole les conversations : deux utilisateurs ne partagent jamais leur historique. Pour la production, on remplace le dict en mémoire par RedisChatMessageHistory, SQLChatMessageHistory ou une persistance maison.
La fenêtre glissante
Le premier réflexe, garder tous les messages, se paie très vite : chaque appel refacture tout l'historique en jetons, et la fenêtre de contexte finit par déborder. La stratégie la plus simple, la fenêtre glissante, ne garde que les k derniers messages :
from langchain_core.messages import trim_messages
filtreur = trim_messages(
max_tokens=1200,
strategy="last",
token_counter=ChatOpenAI(model="gpt-4o-mini"),
include_system=True,
allow_partial=False,
)
# À insérer dans la chaîne, avant l'appel modèle
trim_messages compte les jetons du modèle cible et coupe par le haut jusqu'à respecter le plafond, en conservant la consigne système. Simple, prévisible, borné. Le défaut : au-delà de la fenêtre, l'assistant oublie complètement.
Le résumé progressif
Pour une conversation longue où l'oubli complet gênerait, un résumé progressif condense les messages anciens en un unique message système :
from langchain.memory import ConversationSummaryBufferMemory
memoire_resumee = ConversationSummaryBufferMemory(
llm=ChatOpenAI(temperature=0),
max_token_limit=800,
return_messages=True,
)
Chaque fois que le tampon dépasse max_token_limit, le module génère un résumé des messages les plus anciens et les remplace par ce résumé. Un compromis : on garde l'essentiel de tout, on paie un appel LLM de résumé par déclenchement, on perd la formulation exacte. Réservé aux conversations qui dépassent 20 tours et pour lesquelles la continuité thématique importe.
Le piège du contexte saturé
Un utilisateur qui pose trente questions à la suite peut, sans fenêtre ni résumé, faire enfler l'historique jusqu'à saturer la fenêtre de contexte du modèle. Trois symptômes à savoir reconnaître :
- Coût qui gonfle sans raison apparente : chaque question refacture 4 000 jetons d'historique.
- Latence qui monte : plus il y a de jetons en entrée, plus l'appel prend de temps.
- Perdu au milieu : au-delà d'un certain volume, les modèles longs ne « voient » plus le milieu de leur contexte, et l'assistant devient incohérent.
La règle empirique : plafonner l'historique à 15 à 25 % de la fenêtre de contexte du modèle, réserver le reste au récupérateur (module 5) et à la consigne système. trim_messages avec un max_tokens cible est la première ligne de défense.
Combiner mémoire et récupération
Notre assistant de notes de frais a besoin des deux : la mémoire pour comprendre les questions de suivi, le récupérateur pour ancrer les faits. Le patron consiste à réécrire la question de l'utilisateur en une question autonome avant récupération :
consigne_reecriture = ChatPromptTemplate.from_messages([
("system", "Reformulez la question de suivi en question autonome, sans dépendre du contexte."),
MessagesPlaceholder("historique"),
("human", "{question}"),
])
reecriture = consigne_reecriture | ChatOpenAI(temperature=0) | StrOutputParser()
chaine_rag_conversationnelle = (
{"question": reecriture, "historique": lambda x: x["historique"]}
| RunnablePassthrough.assign(contexte=lambda x: retriever.invoke(x["question"]))
| consigne_reponse
| modele
| StrOutputParser()
)
« Et pour Berlin ? » devient « Quel est le plafond hôtel à Berlin ? » avant d'atteindre le récupérateur. Sans cette réécriture, la recherche vectorielle sur « et pour Berlin ? » remonte n'importe quoi.
InMemoryChatMessageHistory disparaît au redémarrage du processus. En production, brancher RedisChatMessageHistory (rapide, TTL natif) ou une table SQL (durable, jointure sur l'utilisateur). Aucune modification de la chaîne : seul le obtenir_historique(session_id) change d'implémentation.
En résumé
RunnableWithMessageHistoryenveloppe une chaîneLCELet lui injecte l'historique parsession_id; l'historique lui-même est un objet passif à trois méthodes.- Trois stratégies au choix : garder tout (proscrit au-delà de dix tours), fenêtre glissante avec
trim_messages, ou résumé progressif avecConversationSummaryBufferMemory. - Plafonner l'historique à 15-25 % de la fenêtre du modèle évite les trois symptômes du contexte saturé : coût, latence, effet « perdu au milieu ».
- Combiner mémoire et récupération demande une réécriture préalable des questions de suivi en questions autonomes, sinon le récupérateur reçoit une requête vide de sens.
Module suivant : donner à l'assistant la capacité d'agir en appelant des fonctions typées — l'appel d'outils natif.