La boucle d'agent minimale vue précédemment accumule les tours dans une liste messages — mais cette liste vit en mémoire process : elle disparaît dès que le script se termine. Un vrai assistant conversationnel doit se souvenir d'une question à l'autre, potentiellement des jours plus tard. Deux problèmes distincts se cachent derrière « se souvenir » : persister l'historique entre deux exécutions, et empêcher cet historique de grossir indéfiniment. Le second est le plus souvent négligé, et c'est lui qui casse en prod.
Persister la session
response.content renvoyé par le SDK n'est pas directement sérialisable en JSON — ce sont des objets, pas des dicts. Il faut les convertir avant de les écrire sur disque, avec le même dispatch par match que dans la boucle d'origine :
import json
from pathlib import Path
def serialize_content(content) -> list[dict]:
"""Convertit les blocs de réponse du SDK en dicts JSON-sérialisables."""
blocs = []
for block in content:
match block.type:
case "text":
blocs.append({"type": "text", "text": block.text})
case "tool_use":
blocs.append({"type": "tool_use", "id": block.id,
"name": block.name, "input": block.input})
case _:
pass # blocs thinking, etc. : rien à persister
return blocs
class Session:
def __init__(self, path: Path):
self.path = path
self.messages: list[dict] = json.loads(path.read_text()) if path.exists() else []
def save(self) -> None:
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self.messages))
tmp.replace(self.path) # écriture atomique : jamais de fichier à moitié écrit si le process meurt en cours de write
Le détail qui compte : tmp.replace(self.path) plutôt qu'un write_text direct sur le fichier final. Path.replace() s'appuie sur un rename atomique du système de fichiers — soit l'ancienne version reste intacte, soit la nouvelle est complète. Sans ça, un crash pendant l'écriture laisse un JSON tronqué que le load suivant ne sait plus parser.
Le vrai problème : la croissance
Une session qui vit vingt tours accumule vingt allers-retours de contexte. Sans limite, trois choses se dégradent en même temps : le coût par appel (chaque tour renvoie tout l'historique), la latence, et à terme la fenêtre de contexte du modèle qui déborde purement et simplement. Il faut une stratégie de compaction — et elle a un effet de bord qu'on oublie souvent : modifier le début de l'historique invalide le cache de préfixe côté inférence. Un historique qui ne fait que grandir en ajoutant à la fin reste entièrement réutilisable d'un tour à l'autre ; dès qu'on tronque ou qu'on résume les premiers messages, tout ce qui suit doit être retraité depuis le début.
Stratégie 1 : fenêtre glissante
La plus simple — ne garder que les N derniers messages, jeter le reste sans chercher à le résumer. collections.deque avec maxlen fait exactement ça nativement : dès que la limite est atteinte, ajouter un élément à droite éjecte silencieusement celui de gauche.
from collections import deque
# 6 tours = 12 messages (user + assistant par tour)
historique: deque[dict] = deque(maxlen=12)
historique.append({"role": "user", "content": "question"})
# ... au-delà de 12 messages, les plus anciens disparaissent automatiquement
Idiome à noter : deque(maxlen=N) plutôt qu'un slice manuel
L'équivalent avec une list demanderait de retrancher explicitement après chaque ajout (messages = messages[-12:]) — correct, mais un slicing répété sur une liste qui grossit recopie une partie du buffer à chaque fois. deque(maxlen=N) encode la contrainte dans le type lui-même : impossible d'oublier la troncature quelque part dans le code, et l'éjection est en O(1). La contrepartie : deque n'a pas de slicing natif (deque[-3:] lève une TypeError), il faut passer par list(historique)[-3:] si besoin d'un sous-ensemble.
Stratégie 2 : résumé périodique
La fenêtre glissante perd intégralement ce qui sort de la fenêtre. Pour une conversation où le début compte encore (support technique avec des faits établis en tour 2 et utiles en tour 15), un résumé périodique garde l'essentiel à un coût borné :
def compacter_historique(messages: list[dict], client, garder_derniers: int = 6) -> list[dict]:
if len(messages) <= garder_derniers:
return messages
a_resumer, recents = messages[:-garder_derniers], messages[-garder_derniers:]
reponse = client.messages.create(
model="claude-sonnet-5",
max_tokens=300,
messages=a_resumer + [{
"role": "user",
"content": "Résume cette conversation en 5 lignes maximum, "
"en gardant les faits et décisions importantes.",
}],
)
resume = "".join(b.text for b in reponse.content if b.type == "text")
message_resume = {
"role": "user",
"content": f"[Résumé de la conversation précédente]\n{resume}",
}
return [message_resume] + recents
Ce n'est pas gratuit : chaque compaction est un appel LLM à part entière, avec son propre coût et sa propre latence. Et un résumé peut driver — reformuler un fait de façon subtilement fausse — sans qu'aucune exception ne le signale. C'est un risque à accepter consciemment, pas un détail d'implémentation.
| Stratégie | Perte d'info | Cache de préfixe | Coût |
|---|---|---|---|
| Aucune compaction | Aucune | Toujours valide (append-only) | Croît sans limite |
| Fenêtre glissante | Totale au-delà de N tours | Invalidé à chaque troncature | Nul (pas d'appel LLM) |
| Résumé périodique | Partielle, fidélité du résumé | Invalidé à chaque compaction | +1 appel LLM par compaction |
Garde-fous à mettre en place
| Risque | Garde-fou |
|---|---|
| Historique qui grossit sans borne | Fixer max_turns ou maxlen dès le départ, pas en réaction à un incident |
| Fichier de session corrompu par un crash en écriture | Écriture atomique (tmp.replace()) |
| Résultats d'outils sensibles persistés en clair | Filtrer/rédiger avant save(), traiter le contenu externe comme non fiable — voir injection de prompt : défense |
| Résumé qui dérive silencieusement des faits | Relire ponctuellement les résumés générés, ou repasser en fenêtre glissante si l'enjeu est trop élevé |
FAQ
Faut-il persister les messages bruts du SDK ou les convertir d'abord ?
Les convertir : response.content contient des objets du SDK, pas des dicts JSON-sérialisables. Un passage par match block.type qui ne garde que les champs utiles (texte, tool_use avec id/name/input) suffit et donne un format stable, indépendant des évolutions internes du SDK.
Pourquoi la compaction casse-t-elle le cache de contexte ?
Le cache de préfixe ne réutilise que le début du contexte tant qu'il est identique d'un appel à l'autre. Tronquer ou résumer les premiers messages change ce préfixe, donc invalide le cache pour tout ce qui suit — le modèle retraite l'historique compacté depuis le début.
Fenêtre glissante ou résumé : lequel choisir par défaut ?
La fenêtre glissante, sauf besoin explicite de mémoire longue : elle ne coûte rien et son comportement est prévisible. Passer au résumé seulement quand des faits établis tôt dans la conversation doivent rester exploitables bien après être sortis de la fenêtre.