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.

Historique complet (persisté sur disque) T1 T2 T3 T4 T5 T6 hors fenêtre → à résumer fenêtre active → gardés tels quels Résumé 1 appel LLM, ~5 lignes Contexte envoyé au LLM [Résumé] + T4 + T5 + T6 + nouvelle question
Compacter, c'est toujours un compromis : le résumé coûte un appel LLM et perd du détail, mais borne la taille du contexte. Ne rien compacter garde tout, mais grossit sans limite.

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égiePerte d'infoCache de préfixeCoût
Aucune compactionAucuneToujours valide (append-only)Croît sans limite
Fenêtre glissanteTotale au-delà de N toursInvalidé à chaque troncatureNul (pas d'appel LLM)
Résumé périodiquePartielle, fidélité du résuméInvalidé à chaque compaction+1 appel LLM par compaction
Aucune des trois n'est strictement meilleure : le choix dépend de si le début de la conversation reste pertinent pour répondre plus tard.

Garde-fous à mettre en place

RisqueGarde-fou
Historique qui grossit sans borneFixer 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 clairFiltrer/rédiger avant save(), traiter le contenu externe comme non fiable — voir injection de prompt : défense
Résumé qui dérive silencieusement des faitsRelire ponctuellement les résumés générés, ou repasser en fenêtre glissante si l'enjeu est trop élevé
Comme pour la boucle de base, ce qui distingue un prototype d'une session qu'on peut laisser vivre plusieurs jours.

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.