Mémoire courte, résumé et mémoire long terme
Un LLM n'a aucune mémoire
Chaque appel à call_llm est indépendant : le modèle ne « se souvient » que de ce que vous lui renvoyez dans messages. La mémoire d'un agent est donc entièrement de votre ressort.
flowchart LR
subgraph COURT["Mémoire courte (une session)"]
H["Historique messages"] --> T["Tronquer ou résumer"]
end
subgraph LONG["Mémoire long terme (entre sessions)"]
F["Faits du client (fichier, base)"]
end
T --> P["Contexte envoyé au LLM"]
F --> P
P --> L["call_llm"]
| Niveau | Contenu | Durée de vie | Stockage typique |
|---|---|---|---|
| Historique | tous les messages de la session | une conversation | liste en mémoire, cache |
| Résumé | les faits importants des anciens tours | une conversation | un message dans l'historique |
| Long terme | préférences, adresse habituelle, dernière commande | des mois | base de données |
| État | panier en cours, étape du processus | une conversation | dictionnaire structuré, pas du texte |
Niveau 1 : garder l'historique d'une question à l'autre
Jusqu'ici, agent() repartait de zéro à chaque question. On garde les messages dans un objet (tronquer est définie juste après) :
import json
from pathlib import Path
from agent import SYSTEM, TOOLS, call_llm, run_tool
class Conversation:
def __init__(self, llm=call_llm):
self.llm = llm
self.messages = [{"role": "system", "content": SYSTEM}]
def demander(self, question, max_steps=6):
self.messages.append({"role": "user", "content": question})
self.messages = tronquer(self.messages)
for _ in range(max_steps):
msg = self.llm(self.messages, TOOLS)
self.messages.append(msg)
calls = msg.get("tool_calls") or []
if not calls:
return msg.get("content") or ""
for call in calls:
self.messages.append(run_tool(call))
return "Désolé, je n'ai pas abouti. Je transmets à un conseiller."Niveau 2 : tronquer, puis résumer
L'historique grossit à chaque tour, et tout l'historique est refacturé à chaque appel. Première solution, la plus simple : ne garder que les derniers messages, en commençant toujours par un message user (un message tool privé de son tool_calls provoque une erreur 400 chez la plupart des fournisseurs).
def tronquer(messages, garder=20):
if len(messages) <= garder + 1:
return messages
recent = messages[-garder:]
while recent and recent[0]["role"] != "user": # jamais de message tool orphelin
recent = recent[1:]
return [messages[0]] + recentQuand l'oubli pose problème (le client a donné sa référence au début), on résume les anciens tours avec le LLM lui-même :
def compacter(messages, garder=10, llm=call_llm):
if len(messages) <= garder + 1:
return messages
cut = len(messages) - garder
while cut < len(messages) and messages[cut]["role"] != "user":
cut += 1
ancien = messages[1:cut]
texte = "\n".join(f"{m['role']}: {m.get('content') or m.get('tool_calls')}"
for m in ancien)
resume = llm([{"role": "user", "content":
"Résume en 5 lignes maximum les faits utiles de cet échange "
"(produits, références, quantités, décisions) :\n" + texte}], [])
return [messages[0],
{"role": "user", "content": "Résumé de la conversation précédente : "
+ (resume.get("content") or "")},
{"role": "assistant", "content": "Bien noté."}] + messages[cut:]Pourquoi ne pas simplement garder tout l'historique, puisque les modèles récents acceptent de très longs contextes ?
Ce n'est ni interdit ni limité à 20 messages. Mais le coût d'un tour est proportionnel à la taille du contexte, qui est renvoyé en entier à chaque appel, et la qualité peut baisser quand l'information utile est perdue au milieu d'un long contexte.
Niveau 3 : la mémoire long terme
Certains faits doivent survivre à la session : « livrer le samedi », « allergique aux arachides » (attention, donnée sensible, voir plus bas). On les stocke par client et on les injecte dans le prompt système.
MEMOIRE = Path("memoire")
def lire_faits(client_id):
f = MEMOIRE / f"{client_id}.json"
return json.loads(f.read_text()) if f.exists() else []
def ajouter_fait(client_id, fait):
if not client_id.isalnum(): # client_id finit dans un chemin de fichier
raise ValueError("client_id invalide")
MEMOIRE.mkdir(exist_ok=True)
faits = lire_faits(client_id)
if fait not in faits:
faits.append(fait)
(MEMOIRE / f"{client_id}.json").write_text(json.dumps(faits, ensure_ascii=False))Qui alimente cette mémoire ? Le code (après chaque commande, on enregistre l'adresse), plus prévisible, ou le modèle via un outil memoriser(fait), plus souple.
Où doit vivre le panier en cours d'un client (références et quantités) ?
Un panier est un état métier : il doit être exact, et une troncature ou un résumé ne doit pas pouvoir le perdre. On le garde dans une structure de données que les outils lisent et modifient. Ce n'est pas une préférence durable.
Exercice : relier la mémoire long terme à la conversation
Modifiez Conversation.__init__ pour qu'il prenne un client_id, lise ses faits et les ajoute au prompt système sous la forme « Ce que l'on sait du client : … ». Puis écrivez oublier(client_id).
Voir la solution
class Conversation:
def __init__(self, client_id, llm=call_llm):
self.llm = llm
faits = lire_faits(client_id)
system = SYSTEM
if faits:
system += "\nCe que l'on sait du client : " + " ; ".join(faits)
self.messages = [{"role": "system", "content": system}]
# demander() est inchangée
def oublier(client_id):
(MEMOIRE / f"{client_id}.json").unlink(missing_ok=True)Si vous laissez le modèle mémoriser lui-même via un outil memoriser(fait), liez cet outil au client_id dans le code (par une fonction définie dans __init__) : le modèle ne doit jamais pouvoir choisir dans quelle mémoire il écrit. C'est ce que fait ToolContext dans ADK (leçon 3) et le projet final.
Je sais faire
- Expliquer pourquoi la mémoire d'un agent est entièrement gérée par le code
- Tronquer un historique sans laisser de message
toolorphelin - Résumer les anciens tours avec le LLM
- Stocker et injecter des faits long terme, avec une possibilité d'effacement