Engineering the Agentic Stack · Partie 2

Architecture de la mémoire des AI agents : checkpoints et vector stores

Traduction automatique Cet article a été traduit automatiquement depuis la version originale en anglais.

Mise à jour de l’article

Publié à l’origine le 14 février 2026. Relu et mis à jour le 6 septembre 2026. Cette mise à jour couvre la compaction du contexte, les benchmarks de mémoire et les APIs de stockage, et clarifie les distinctions entre l’état de travail, les checkpoints et la mémoire long terme.

Une reasoning loop ne survit à une requête que si son état est stocké en dehors du worker. Sans mémoire d’agent, l’agent ne peut pas reprendre un plan en pause, récupérer après un crash ni se souvenir d’une préférence issue d’une session antérieure. La Partie 1 décrivait le flux de contrôle. Cet article identifie l’état nécessaire à chaque tour suivant et l’emplacement où cet état doit résider.

J’utiliserai le Market Analyst Agent — un petit agent LangGraph qui récupère des données de marché et rédige un rapport d’analyste — pour illustrer la discussion sur les hot checkpoints. Les sections sur les cold vectors et le Markdown brut présentent des designs illustratifs indépendants, montrant des extensions que le projet actuel n’implémente pas encore. J’expliquerai ensuite dans quels cas PostgreSQL, Redis, Qdrant, les key-value stores et les fichiers Markdown classiques sont pertinents.

En bref : pour mettre en pause et reprendre une exécution, utilisez un checkpoint store. Pour les faits utilisateur exacts, utilisez un stockage structuré ; ajoutez la vector retrieval uniquement lorsque la formulation des questions varie. Utilisez des fichiers lorsque les utilisateurs doivent inspecter et modifier les connaissances accumulées sur un projet. Un checkpoint préserve l’état ; le graphe et le harness décident toujours de ce qu’il faut en faire.

Chaque store présenté ci-dessous est lu par le harness, c’est-à-dire le code qui pilote la boucle autour du modèle. Le harness décide quel contenu atteint la context window ; les stores ne le font pas. Cet article porte sur l’endroit où cet état réside avant que le harness ne le récupère. Les Parties 3 et 4 expliquent ensuite ce que le harness fait du prompt.


Qu’est-ce que la mémoire d’un AI agent ?

La mémoire d’un AI agent est la couche d’état qui permet à un agent de préserver la progression d’une tâche, de récupérer des connaissances antérieures et de mettre à jour ce qu’il sait entre plusieurs exécutions. Un design peut combiner des checkpoints, des stores sémantiques ou structurés et des documents lisibles par les humains. Ne choisissez que les stores nécessaires aux exigences de rappel et de récupération du produit.

BesoinValeur par défaut recommandéePourquoi
Mettre en pause et reprendre une exécutionPostgreSQL checkpoint storeDurable, interrogeable et facile à exploiter avec les données applicatives
État transitoire à faible latenceRedis checkpoint storeReprise rapide et état de courte durée, avec des compromis de persistance
Rappel sémantique cross-threadQdrant ou pgvectorRécupère les mémoires par leur sens, pas uniquement via des clés exactes
Faits utilisateur structurésPostgreSQL ou key-value storeLes mises à jour déterministes sont préférables à une retrieval floue pour les préférences et les IDs
Conventions de projet et procédures apprisesFichiers Markdown ou JSONLisibles par les humains, compatibles avec les diffs et faciles à mettre à jour par les agents
Mémoire des relations entre plusieurs entitésKnowledge graphUtile lorsque les relations comptent davantage que les faits individuels

Ne commencez pas par la mémoire parce qu’elle semble intelligente. Commencez par l’échec visible côté utilisateur : perte de progression, oubli d’une préférence, répétition d’une recherche ou incapacité à réutiliser une convention du projet.

Les échecs qui nécessitent une mémoire

Un agent stateless peut répondre à une question isolée, mais il oublie la requête dès que l’appel se termine. Ce design échoue lorsque le produit doit fournir l’un des comportements suivants :

  • Mettre en pause et reprendre : un utilisateur démarre une tâche de recherche, ferme son ordinateur et revient le lendemain. Sans état checkpointé, l’agent repart de zéro.
  • Cohérence multi-tour : au cours d’une longue conversation, l’agent doit se souvenir des tools qu’il a appelés, des données collectées et des étapes du plan déjà terminées.
  • Personnalisation : un utilisateur qui revient s’attend à ce que l’agent connaisse sa tolérance au risque, le niveau de détail souhaité et ses interactions précédentes.
  • Human-in-the-loop (HITL) : l’agent collecte ses éléments probants et attend qu’un humain approuve l’étape suivante. L’état « en attente » doit survivre aux redémarrages du processus.

Dans le Market Analyst Agent de la Partie 1, la requête « Analyze NVDA » produit un plan, cinq tool calls, des données collectées et un brouillon de rapport. Lorsque l’utilisateur répond « looks good, but add competitor analysis », un checkpoint restaure le plan et les recherches depuis la dernière étape terminée. Ajouter l’étape consacrée aux concurrents nécessiterait une interprétation et une replanification de suivi ; le companion n’implémente pas ce comportement. Le checkpoint fournit l’état antérieur, tandis que l’application doit décider de la manière dont la nouvelle requête modifie le plan.

La mémoire long terme répond à un autre cas. Si l’utilisateur revient une semaine plus tard et demande « Update my NVDA analysis », l’agent devra peut-être se rappeler une préférence pour les évaluations prudentes du risque et un intérêt pour les valeurs du secteur des semi-conducteurs. Un memory store adossé à des vecteurs peut récupérer ces faits entre les sessions sans les redemander.

Les exemples d’implémentation ci-dessous utilisent LangGraph, la bibliothèque open source de LangChain pour construire des agents sous forme de graphes d’état explicites ; les frontières de stockage qu’elle définit se généralisent à tous les frameworks. Considérez la conversation continue d’un utilisateur autour de « Analyze NVDA » comme un thread. Chaque exécution du graphe pour y répondre ou la poursuivre constitue un run dans ce thread. Tant qu’un run est actif, le contexte du modèle et les variables locales du programme constituent sa working memory ; ils disparaissent lorsque le travail s’arrête. LangGraph appelle short-term memory l’état sauvegardé pour ce thread et long-term memory les faits accessibles à d’autres threads. Ci-dessous, « thread » et « conversation » désignent la même chose. La Partie 5 utilise « session » pour désigner le journal durable d’un run ; cet article évite donc ce terme pour parler de la conversation.

Les six types de mémoire des agents et les trois niveaux de stockage auxquels ils se réduisentLes six types de mémoire des agents et les trois niveaux de stockage auxquels ils se réduisent


Une taxonomie de la mémoire des AI agents

Avant de passer à l’implémentation, il est utile de classer ce que les agents doivent retenir. Le framework CoALA — Cognitive Architectures for Language Agents (Sumers, Yao et al., 2023) — est une taxonomie largement citée, inspirée des sciences cognitives. J’ai présenté le scope de la mémoire dans mon article sur le context engineering ; je l’étends ici à six catégories :

Type de mémoireScopeDurée de vieExemplePattern de stockage
WorkingÉtape couranteMillisecondesArguments d’un tool call, réponse LLM couranteEn mémoire du processus (dictionnaire Python)
Short-termThread courantMinutes à heuresHistorique de conversation, progression du plan, données collectéesCheckpoint store
EpisodicCross-threadJours à mois« La semaine dernière, l’utilisateur a demandé les résultats de NVDA »Vector store / KV store
SemanticCross-threadMois à permanent« L’utilisateur préfère les investissements prudents »Vector store / KV store
DocumentCross-threadJours à permanentNotes de projet, synthèses de recherche, patterns apprisFile store (Markdown/JSON)
ProceduralÀ l’échelle du systèmePermanent« Lors de l’analyse d’actions, toujours vérifier les dépôts auprès de la SEC »Config / system prompt

La working memory contient les observations courantes, les faits récupérés et les résultats intermédiaires utilisés par le run actif. Certains résident dans des variables applicatives ; les messages sélectionnés et les tool results constituent l’entrée du modèle. Cette entrée doit tenir dans la context window du modèle, tandis que l’état applicatif peut être plus volumineux et persister sur plusieurs étapes. La mémoire du processus est perdue en cas de crash, sauf si elle est explicitement sauvegardée. Les autres niveaux alimentent cet état de travail.

La short-term memory correspond au checkpoint que LangGraph écrit après chaque unité d’exécution du graphe — un super-step, défini dans la section suivante. Les mémoires episodic et semantic persistent entre les threads. La document memory stocke les notes de projet, les synthèses de recherche et les conventions apprises dans des fichiers que les utilisateurs et les agents peuvent inspecter. La procedural memory comprend les instructions système, les définitions de tools et les procédures réutilisables qui peuvent être récupérées pour une tâche. Les durées indiquées dans le tableau sont illustratives ; la rétention suit la policy de l’application, et l’état de travail peut durer pendant tout un run actif.

Pour l’implémentation, cinq de ces six catégories se réduisent à trois niveaux de stockage. La short-term memory devient la hot memory, c’est-à-dire le checkpoint du thread courant. Les mémoires episodic et semantic deviennent la cold memory, utilisée pour le rappel entre les threads. La document memory conserve les connaissances accumulées sur le projet dans un format lisible et directement modifiable. La working memory est regroupée avec le niveau hot, car les checkpoints peuvent préserver l’état nécessaire à la reconstruction d’un run actif. Un checkpoint ne constitue pas le calcul interne complet du modèle. Les procédures peuvent être fournies avec l’agent ou stockées et récupérées dans des fichiers ou un autre store. Ces niveaux décrivent les choix d’implémentation de cet article, et non des types de mémoire mutuellement exclusifs.

CoALA classe les mémoires working, episodic, semantic et procedural. La survey Memory in the Age of AI Agents organise plutôt la mémoire selon sa forme, sa fonction et sa dynamique, en incluant les documents, les codebases et les workflows réutilisables. Les fichiers peuvent implémenter plusieurs de ces catégories. Cet article distingue la document memory pour rendre visibles ses responsabilités de stockage et de maintenance.

Le même pattern de stockage apparaît dans d’autres domaines. Un agent Minecraft (Voyager) stocke les compétences de jeu réutilisables sous forme de bibliothèques de code, tandis que les agents web induisent des workflows de navigation réutilisables à partir des runs réussis. J’y reviendrai plus loin. Les fichiers inspectables et la retrieval indexée peuvent coexister : Voyager récupère des programmes à l’aide des embeddings de leurs descriptions.

La mémoire gérée par l’agent se distingue également d’un pipeline RAG fixe par la personne ou le composant qui effectue l’écriture. L’agent ou son harness sélectionne ce qu’il faut stocker, mettre à jour et supprimer, puis choisit ultérieurement le moment de le récupérer.

L’article Generative Agents (Park et al., 2023) a montré jusqu’où cette approche pouvait aller : des agents simulés stockaient, analysaient et récupéraient leurs propres souvenirs. Son memory stream classait les candidats selon leur récence, leur importance et leur pertinence, un design qui reste une référence utile pour la retrieval de mémoire d’agent.


La compaction maintient une conversation exploitable

Une context window plus large ne supprime pas la nécessité de choisir ce qui doit survivre. Les APIs actuelles peuvent résumer une conversation ancienne avant qu’elle ne remplisse la fenêtre. La compaction côté serveur de Claude, encore en version beta au 06/09/2026, renvoie un bloc compaction que les requêtes suivantes utilisent à la place du contenu antérieur. Cela peut réduire le travail de summarization côté client, mais le résumé peut omettre un fait nécessaire ultérieurement.

Conservez l’état de tâche faisant autorité en dehors de ce résumé : effets terminés, approbations, références aux sources et contraintes exactes de l’utilisateur. Un checkpoint restaure l’exécution ; la compaction raccourcit le contexte du modèle ; la mémoire long terme sélectionne les connaissances pour une autre conversation. Testez séparément ces trois comportements. Forcez une compaction au milieu d’un test et vérifiez que l’action suivante respecte toujours une contrainte antérieure. N’utilisez pas un transcript compacté comme seul enregistrement de ce qui a été approuvé.

Mémoire court terme d’un agent : le checkpoint store

LangGraph checkpoint l’état du graphe aux frontières des super-steps — un nœud ou un ensemble de nœuds exécutés en parallèle. Avec le durability="async" par défaut, l’étape suivante peut s’exécuter pendant que l’écriture se termine ; durability="sync" attend la persistance avant de poursuivre, ce qui ajoute de la latence d’écriture. La récupération après crash utilise le dernier checkpoint persisté, qui n’est pas nécessairement l’étape la plus récemment terminée. C’est le fondement de la pause/reprise, du débogage en time travel et des workflows HITL.

Hot memory : un checkpoint écrit à chaque super-step et le chemin de récupération qui le rechargeHot memory : un checkpoint écrit à chaque super-step et le chemin de récupération qui le recharge

Un checkpoint contient l’état du graphe nécessaire à la reprise : le AgentState de la Partie 1 — messages, identité, profil utilisateur, étapes du plan, données de recherche et mode d’exécution. Après une interruption HITL ou un redémarrage du processus, LangGraph restaure le dernier état sauvegardé et utilise ses métadonnées de scheduling pour choisir le prochain nœud. Il reprend à la frontière d’un nœud terminé, et non à une ligne Python arbitraire. Les informations stockées comprennent un ID de checkpoint et un timestamp, une version pour chaque channel (le terme LangGraph désignant une clé d’état), ainsi que les versions des channels déjà vues par chaque nœud. Le numéro d’étape est une métadonnée de ce checkpoint. Un checkpoint est également différent d’un event log append-only ou d’une trace ; la Partie 5 distingue explicitement ces surfaces d’observabilité du runtime.

Fonctionnement du checkpointing dans LangGraph

Le BaseCheckpointSaver de LangGraph est une interface simple : put() écrit un checkpoint, get_tuple() lit le dernier checkpoint d’un thread et list() renvoie l’historique. Chaque checkpoint est indexé par (thread_id, checkpoint_ns, checkpoint_id), où thread_id identifie la conversation, checkpoint_ns gère le namespace des sous-graphes et checkpoint_id constitue une version unique.

La décision importante concerne le backend à placer derrière cette interface. PostgreSQL et Redis sont deux choix courants en production.

PostgreSQL ou Redis

Redis et PostgreSQL comme backends de checkpoint, comparés selon la latence, la durabilité et le modèle de requêtageRedis et PostgreSQL comme backends de checkpoint, comparés selon la latence, la durabilité et le modèle de requêtage

DimensionPostgreSQL (langgraph-checkpoint-postgres)Redis (langgraph-checkpoint-redis)
Modèle de durabilitéTransactions ACID, WAL et réplicationPersistance configurable : journal de commandes append-only (AOF) ou snapshots périodiques (RDB)
Historique des checkpointsHistorique durable pour la reprise et le débogageLa rétention dépend du saver et des paramètres d’eviction
Contrainte principaleLatence des écritures en base et croissance des tablesUtilisation de la RAM, eviction et configuration de la persistance
Adéquation opérationnelleÉquipes exploitant déjà des bases relationnellesÉquipes exploitant déjà Redis à haut débit
Valeur par défaut recommandée pourReprise durable et débogage reproductibleÉtat de session récupérable et sensible à la latence

Les benchmarks génériques de bases de données ne permettent pas de prédire les performances du checkpointing. Mesurez la taille de l’état sérialisé, la fréquence d’écriture, les paramètres de persistance et la concurrence de votre propre graphe.

PostgreSQL : le choix durable par défaut

PostgreSQL constitue le choix par défaut le plus sûr pour la plupart des équipes. Les checkpoints survivent aux crashs, vous bénéficiez de la sémantique complète des transactions et l’historique des checkpoints simplifie le débogage en time travel.

Une version simplifiée de la configuration des checkpoints dans memory/hot.py. Si un attaquant pouvait écrire des checkpoints, définissez LANGGRAPH_STRICT_MSGPACK=true ou configurez allowed_msgpack_modules. Cela limite la désérialisation aux types sûrs ou déclarés ; la valeur par défaut permissive avertit lorsque des types ne sont pas enregistrés, mais les autorise tout de même.

import asyncio
from contextlib import asynccontextmanager

from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

@asynccontextmanager
async def postgres_checkpointer(connection_string: str):
    """Yield a PostgreSQL-backed checkpoint store.

    PostgreSQL gives us ACID guarantees — if a checkpoint write succeeds,
    the state is durable even if the process crashes immediately after.
    `from_conn_string` is itself an async context manager: it owns the
    connection and closes it on exit, so the graph has to run inside it.
    """
    async with AsyncPostgresSaver.from_conn_string(connection_string) as checkpointer:
        # Create the checkpoint tables if they don't exist.
        # This is idempotent — safe to call on every startup.
        await checkpointer.setup()
        yield checkpointer

async def main(authenticated_user_id: str) -> None:
    # The graph lives inside the context manager's scope.
    async with postgres_checkpointer(
        "postgresql://user:pass@localhost:5432/agent_memory"
    ) as checkpointer:
        graph = create_graph(checkpointer=checkpointer)

        # Every invoke/stream call now persists state automatically.
        config = {"configurable": {"thread_id": "user-123-session-1"}}
        result = await graph.ainvoke(
            {"user_id": authenticated_user_id,
             "messages": [HumanMessage(content="Analyze NVDA")]}, config
        )

        # After the server authenticates the approver and validates approval
        # of this exact draft, update the companion's approval field.
        await graph.aupdate_state(config, {"report_approved": True})
        # Continue the static interrupt_before pause; new input starts a new run.
        result = await graph.ainvoke(None, config)

# Local fixture identity. A server supplies this only after authentication.
asyncio.run(main(authenticated_user_id="user-123"))

Le user_id fourni dans l’entrée du graphe provient du contexte serveur authentifié ; thread_id localise uniquement les checkpoints et n’établit ni l’identité ni l’autorisation d’accès à un thread. Le AsyncPostgresSaver utilise le package langgraph-checkpoint-postgres, qui crée quatre tables : checkpoints (l’état sérialisé), checkpoint_blobs (les données binaires volumineuses), checkpoint_writes (les écritures en attente pour la récupération après crash) et checkpoint_migrations (la version du schéma). Les writers concurrents sont séparés par la clé primaire (thread_id, checkpoint_ns, checkpoint_id) et des upserts plutôt que par un verrouillage — deux workers sur le même thread ne se corrompront pas mutuellement, mais ils ne se coordonneront pas davantage.

Redis : lorsque la latence est le goulot d’étranglement

Lorsque la latence des checkpoints constitue le goulot d’étranglement, Redis peut servir au stockage d’un état récupérable. Mesurez la taille de l’état sérialisé, les paramètres de persistance et la concurrence avant de le choisir plutôt que PostgreSQL.

Une version simplifiée de la configuration des checkpoints dans memory/hot.py :

import asyncio
from contextlib import asynccontextmanager

from langgraph.checkpoint.redis.aio import AsyncRedisSaver

@asynccontextmanager
async def redis_checkpointer(redis_url: str):
    """Yield a Redis-backed checkpoint store.

    Redis keeps checkpoints in memory for low-latency access.
    Durability depends on RDB snapshots, AOF fsync policy, and replication.
    AOF with appendfsync everysec can still lose about one second of writes
    after a crash; enabling AOF alone is not a no-loss guarantee.
    """
    async with AsyncRedisSaver.from_conn_string(redis_url) as checkpointer:
        # Initialize Redis data structures
        await checkpointer.asetup()
        yield checkpointer

async def main() -> None:
    # Same graph API, different backend.
    async with redis_checkpointer("redis://localhost:6379") as checkpointer:
        graph = create_graph(checkpointer=checkpointer)

asyncio.run(main())

Le AsyncRedisSaver de langgraph-checkpoint-redis stocke chaque checkpoint dans son propre document RedisJSON, sous la même clé (thread_id, checkpoint_ns, checkpoint_id) que le saver Postgres. La refonte v0.1.0 a intégré les valeurs des checkpoints et remplacé la retrieval par channel par un chemin JSON.GET. Cette modification concerne la retrieval des valeurs, et non toutes les opérations de persistance ; les mesures de latence du fournisseur dépendent de sa charge de travail. Redis 8.0+ inclut RedisJSON et RediSearch par défaut — aucun module supplémentaire à installer.

Choisissez la policy de persistance et de fsync de Redis en fonction de la fenêtre de perte acceptable. RDB peut perdre les écritures depuis le dernier snapshot ; la policy AOF habituelle appendfsync everysec peut perdre environ une seconde. always échange de la latence d’écriture contre une persistance plus forte, tandis que no laisse le flush au système d’exploitation. Testez la récupération avec les paramètres réels de disque et de réplication.

Pour les déploiements limités en mémoire, ShallowRedisSaver ne conserve que le dernier checkpoint par thread — sans historique, mais avec une utilisation minimale de la RAM. Utilisez-le lorsque vous avez besoin de la pause/reprise, mais pas du débogage en time travel.

Quand utiliser lequel

Utilisez PostgreSQL lorsque :

  • Vous avez besoin de l’historique complet des checkpoints pour le débogage en time travel ou une reprise reproductible
  • La durabilité est non négociable (services financiers, santé)
  • PostgreSQL est déjà présent dans votre stack
  • Votre agent exécute de longues tâches pour lesquelles perdre l’état signifierait plusieurs heures de recalcul
  • Vous souhaitez un data store unifié — PostgreSQL avec pgvector peut servir de backend unique pour les checkpoints, la mémoire long terme et la recherche vectorielle

Utilisez Redis lorsque :

  • La latence des checkpoints est votre goulot d’étranglement (chat temps réel, UX en streaming)
  • Vous développez des voice bots ou des expériences en streaming où l’accès aux checkpoints se trouve sur un chemin critique dont la latence est mesurée
  • Vous avez besoin d’un scaling horizontal sur de nombreux threads indépendants. Si plusieurs agents modifient un état partagé, attribuez un propriétaire à cet état et coordonnez-vous en dehors du checkpoint saver.
  • Vous gérez des sessions de courte durée pour lesquelles la perte d’un checkpoint est récupérable
  • Vous souhaitez un semantic caching pour réduire les appels LLM redondants (Redis LangCache met en cache les requêtes sémantiquement similaires afin d’éviter des appels LLM répétés)

Autres options : langgraph-checkpoint-sqlite convient au développement local et aux déploiements à processus unique. Pour les stacks AWS-native, langgraph-checkpoint-aws fournit un DynamoDBSaver avec offloading automatique du payload — le saver documenté déporte les payloads dépassant son seuil de 350 Ko lorsqu’un bucket S3 est configuré. Ce seuil relève de la policy d’implémentation, et non de la limite de 400 Ko par item de DynamoDB. La tarification serverless et l’absence d’infrastructure à gérer rendent cette option intéressante pour les déploiements à charge variable.


Mémoire long terme : se souvenir entre les sessions

La hot memory gère la conversation courante. La mémoire long terme concerne l’utilisateur qui revient la semaine suivante : elle stocke des faits, des préférences et un historique d’interactions qui persistent entre les threads.

LangGraph fournit une interface Store pour la mémoire cross-thread via sa classe BaseStore. Chaque élément mémoire est une paire (namespace, key) contenant une valeur JSON et, éventuellement, un vector embedding. Le namespace encode généralement l’utilisateur ou l’organisation : ("user", "user-123", "preferences").

Le chemin de retrieval de la cold memory : embedder la requête, rechercher dans Qdrant avec un filtre utilisateur, rescoring, injectionLe chemin de retrieval de la cold memory : embedder la requête, rechercher dans Qdrant avec un filtre utilisateur, rescoring, injection

Stockage vectoriel : rappel sémantique avec Qdrant

Lorsque l’agent doit se rappeler des faits non structurés (« Qu’a dit l’utilisateur au sujet de son horizon d’investissement ? »), la recherche vectorielle fournit un rappel sémantique. Au lieu d’effectuer des recherches par clé exacte, l’agent interroge le store par le sens.

Qdrant est une base vectorielle spécialisée écrite en Rust, qui gère le stockage des embeddings, l’indexation (Hierarchical Navigable Small World, ou HNSW) et la recherche filtrée. J’ai détaillé HNSW et ses compromis dans mon article sur le search ranking. Qdrant propose également un MCP server qui agit comme une couche de mémoire sémantique — utile si votre framework d’agents prend en charge le Model Context Protocol.

Ce qui suit est un design Qdrant illustratif et indépendant. Il ne s’agit pas d’une version simplifiée du memory/long.py actuel. Le projet actuel stocke les profils utilisateur avec un filtrage exact via user_id et un vecteur nul comme placeholder. L’intégration réelle des embeddings reste à faire. Le request handler doit authentifier la requête et construire principal à partir de l’identité vérifiée ; le client ne doit jamais le fournir. Le filtre Qdrant définit le scope de retrieval, pas l’autorisation.

from qdrant_client import QdrantClient
from qdrant_client.models import (
    PointStruct, Distance, VectorParams, Filter, FieldCondition, MatchValue,
)
import hashlib
import json
from dataclasses import dataclass

@dataclass(frozen=True)
class AuthenticatedPrincipal:
    """Created by the server after authentication, never from request JSON."""
    user_id: str

class UserMemoryStore:
    """Long-term memory backed by Qdrant vector search.

    Stores user facts as embedded vectors for semantic retrieval.
    Each fact is a short natural-language statement about the user.
    """

    def __init__(self, qdrant_url: str, collection_name: str = "user_memory"):
        self.client = QdrantClient(url=qdrant_url)
        self.collection_name = collection_name
        self._ensure_collection()

    def _ensure_collection(self):
        """Create the collection if it doesn't exist."""
        collections = [c.name for c in self.client.get_collections().collections]
        if self.collection_name not in collections:
            self.client.create_collection(
                collection_name=self.collection_name,
                vectors_config=VectorParams(
                    size=1536,  # text-embedding-3-small dimensions
                    distance=Distance.COSINE,
                ),
            )

    def store_fact(
        self, principal: AuthenticatedPrincipal, fact: str, embedding: list[float]
    ):
        """Store a user fact with its embedding."""
        identity = json.dumps([principal.user_id, fact], ensure_ascii=False).encode()
        point_id = hashlib.sha256(identity).hexdigest()[:32]
        self.client.upsert(
            collection_name=self.collection_name,
            points=[PointStruct(
                id=point_id,
                vector=embedding,
                payload={"user_id": principal.user_id, "fact": fact},
            )],
        )

    def recall(
        self,
        principal: AuthenticatedPrincipal,
        query_embedding: list[float],
        top_k: int = 5,
    ):
        """Retrieve the most relevant facts for a user given a query."""
        results = self.client.query_points(
            collection_name=self.collection_name,
            query=query_embedding,
            query_filter=Filter(
                must=[FieldCondition(
                    key="user_id", match=MatchValue(value=principal.user_id)
                )]
            ),
            limit=top_k,
        )
        return [hit.payload["fact"] for hit in results.points]

L’ID du point hash un tableau JSON composé de l’ID utilisateur et du fait, afin que des délimiteurs présents dans l’une ou l’autre valeur ne puissent pas fusionner deux identités. Par exemple, l’utilisateur a:b associé au fait c doit être différent de l’utilisateur a associé au fait b:c. Les 32 caractères hexadécimaux sont compatibles avec la représentation des point IDs UUID de Qdrant.

Le flux comporte trois étapes. Dans ce design illustratif, un LLM extrait les faits clés de l’interaction (« l’utilisateur a une forte tolérance au risque », « l’utilisateur s’intéresse aux actions du secteur des semi-conducteurs »). Ces faits sont transformés en embeddings puis stockés dans Qdrant. Au début de la conversation suivante, le serveur fournit le principal authentifié et l’agent interroge Qdrant avec le nouveau message de l’utilisateur afin de récupérer le contexte pertinent. Le Market Analyst Agent actuel n’implémente pas encore ce flux d’extraction sémantique et d’embedding.

Scoring de la retrieval : au-delà de la similarité cosinus

La similarité cosinus brute constitue un point de départ, mais les systèmes de mémoire en production nécessitent une retrieval plus riche. L’article Generative Agents (Park et al., 2023) a introduit une fonction de scoring combinant trois signaux :

  • Récence : décroissance fondée sur des règles afin que les mémoires récentes obtiennent un score supérieur. Une fonction de décroissance exponentielle fait passer un fait d’hier devant un fait équivalent vieux de six mois.
  • Importance : importance évaluée par un LLM sur une échelle de 1 à 10. « Le portefeuille de l’utilisateur a perdu 40 % » obtient un score supérieur à « l’utilisateur a dit bonjour ».
  • Pertinence : similarité cosinus entre l’embedding de la requête et celui du fait stocké.

L’article normalise les trois signaux sur des échelles comparables avant de les combiner. Faites de même avant d’ajuster les poids ; sinon, un score d’importance brut de 1 à 10 dominerait un signal compris entre 0 et 1. Le score final de retrieval est une somme pondérée : score = alpha * recency + beta * importance + gamma * relevance. Cela évite que des faits récents et importants soient noyés sous des faits obsolètes mais sémantiquement similaires. Pour un prototype d’analyse financière, je commencerais par alpha = 0.3 pour la récence, beta = 0.2 pour l’importance et gamma = 0.5 pour la pertinence, car la requête courante détermine généralement quel fait valide doit entrer dans le contexte. L’article Generative Agents utilisait des poids égaux ; ces valeurs constituent un point de départ proposé, et non une amélioration mesurée. Ajustez-les sur un jeu de recall conservé à part et au moyen de contrôles de qualité des tâches avant de vous y fier.

Alternatives à la recherche vectorielle

La recherche vectorielle est puissante, mais ce n’est pas toujours le bon outil. Voici quand utiliser d’autres approches :

ApprocheIdéale pourPrincipal coût opérationnel
Recherche vectorielle (Qdrant)Rappel sémantique de faits non structurésCycle de vie des embeddings et de l’index
Key-value store (Redis)Profils et préférences utilisateur structurésUtilisation mémoire et policy de persistance
Document store (fichiers)Connaissances de projet et notes gérées par l’agentConcurrence, permissions et recherche
Recherche full-text (PostgreSQL index GIN)Rappel par mots-clés dans l’historique des conversationsCroissance de l’index et réglage des requêtes
Knowledge graph (Neo4j)Relations entre entités et requêtes multi-hopModélisation du graphe et système de données supplémentaire
Hybride (vecteurs + mots-clés)Rappel lorsque l’intention de la requête varieDeux chemins de scoring à régler et à évaluer

Les key-value stores conviennent bien aux données structurées. Si votre mémoire long terme est un profil utilisateur — tolérance au risque, horizon d’investissement, secteurs préférés — un hash Redis ou une colonne JSONB PostgreSQL est plus simple et plus rapide que l’embedding et l’interrogation de vecteurs. Utilisez la recherche vectorielle lorsque la mémoire est non structurée et que la formulation de la requête varie.

Le Store intégré de LangGraph fournit une interface key-value basée sur des namespaces avec une recherche vectorielle optionnelle. L’API BaseStore est simple : put(), get(), search() et delete(), avec un scope hiérarchique des namespaces. Trois implémentations sont disponibles :

  • InMemoryStore — pour le développement et les tests (les données sont perdues à l’arrêt du processus)
  • PostgresStore — store persistant de production avec requêtage SQL complet
  • AsyncRedisStore — mémoire cross-thread avec recherche vectorielle, support du TTL et filtrage des métadonnées

La configuration index active la recherche vectorielle sur les éléments stockés à l’aide d’un modèle d’embedding configurable. Pour de nombreux cas d’usage, ce store intégré suffit sans recourir à une base vectorielle dédiée.

import asyncio
from langgraph.store.memory import InMemoryStore

# Create a store with vector search enabled
store = InMemoryStore(
    index={
        "dims": 1536,
        "embed": my_embedding_function,  # e.g., OpenAI text-embedding-3-small
    }
)

async def main() -> None:
    # Store a user preference (namespace scopes to user).
    await store.aput(
        namespace=("user", "user-123", "preferences"),
        key="risk-profile",
        value={"risk_tolerance": "high", "horizon": "long-term"},
    )

    # Semantic search across the user's memories.
    # The namespace prefix is positional here — `search`/`asearch` declare it
    # as positional-only `namespace_prefix`, unlike `aput`.
    results = await store.asearch(
        ("user", "user-123"),
        query="What is their investment style?",
        limit=5,
    )

asyncio.run(main())

Choisir une stratégie de mémoire long terme

Commencez par un key-value store si votre mémoire est structurée et bien définie (profils utilisateur, paramètres, entités nommées). Ajoutez la recherche vectorielle lorsque vous avez besoin de retrieval sémantique sur des faits non structurés ou lorsque la formulation des requêtes varie de manière imprévisible.

Les knowledge graphs deviennent pertinents lorsque les relations entre entités comptent, par exemple : « Quelles entreprises l’utilisateur a-t-il mentionnées et lesquelles sont concurrentes de NVDA ? » Le projet récent le plus intéressant dans ce domaine est Graphiti (par Zep), qui construit un knowledge graph sensible au temps et suit quand les faits étaient vrais, et pas seulement ce qui était vrai. Ses relations temporelles peuvent préserver les intervalles de validité et les valeurs remplacées ; la logique d’extraction et de mise à jour détermine toujours si un fait est actuel. L’article Zep rapporte une précision DMR de 94,8 % pour le système Zep évalué, propulsé par Graphiti avec GPT-4 Turbo, contre 94,4 % pour le contexte complet. DMR utilise des conversations de 60 messages et une tâche limitée de retrieval de faits. Cet écart réduit ne démontre pas un avantage général des graphes temporels.

Le revers est opérationnel. Exploiter une base de graphes n’est pas trivial et, pour la plupart des applications agentiques, la recherche vectorielle avec filtrage des métadonnées couvre le même besoin avec moins d’infrastructure.

Les frameworks de mémoire managée comme Mem0 et Letta (anciennement MemGPT) prennent en charge pour vous le pipeline d’extraction, de consolidation et de retrieval. L’approche de Mem0 est notable : un LLM extrait des mémoires candidates, un moteur de décision compare chaque nouveau fait aux entrées existantes du vector store et un resolver décide de l’ajouter, de le mettre à jour, de le supprimer ou de ne rien faire. Cela maintient un store cohérent et non redondant. Letta adopte une approche inspirée des systèmes d’exploitation : les agents gèrent eux-mêmes leur context window à l’aide de memory management tools et déplacent de façon autonome les données entre la « core memory » (dans le contexte) et l’« archival memory » (hors contexte). Les deux méritent une évaluation si vous cherchez un time-to-production plus court et n’avez pas besoin d’un contrôle complet du pipeline mémoire.


Document memory : le classeur de l’agent

Les vector stores et les backends key-value gèrent bien le rappel sémantique et les recherches structurées. Le contexte de projet accumulé — conventions, notes de recherche et décisions conservées entre les sessions — a souvent sa place dans des fichiers que les utilisateurs peuvent lire, relire et versionner.

Il s’agit de la document memory : l’agent lit et écrit des fichiers structurés (Markdown, JSON, YAML) dans un répertoire connu. Pas d’embeddings, pas de base de données, pas d’infrastructure. Seulement des fichiers sur disque que l’agent et le développeur peuvent cat, grep, git diff et modifier manuellement.

Dans une évaluation menée par un fournisseur, Letta a rapporté une précision de 74,0 % sur LoCoMo — un benchmark de question-réponse sur de longues conversations — pour un agent GPT-4o mini utilisant des fichiers attachés, des embeddings automatiques, une search_files sémantique et des règles imposant l’utilisation d’un outil de recherche. La meilleure variante graphique de Mem0 a obtenu 68,5 %. Il s’agit d’un fournisseur, d’un modèle, d’un benchmark et d’un harness particuliers. Cela montre qu’une interface orientée fichiers peut bien fonctionner dans cette configuration ; cela ne montre pas que le Markdown brut ou la recherche par mots-clés suffisent. L’avantage opérationnel est distinct : les développeurs peuvent lire, modifier et comparer directement les connaissances stockées.

Les context windows plus longues rendent également pratiques les lectures complètes de fichiers pour certains documents de projet. La retrieval par chunks reste adaptée aux gros corpus, mais un court fichier de conventions ou de handoff peut souvent être chargé directement. Le choix dépend de la taille des documents, de la précision de retrieval, du budget de contexte et de la fréquence à laquelle les utilisateurs doivent examiner ou modifier la mémoire.

Pourquoi utiliser des fichiers ?

Pour un projet d’agent de longue durée, utilisez un répertoire de notes bien organisé lorsque les utilisateurs ont besoin d’un historique révisable. Prenez l’exemple d’un coding agent qui travaille sur un même projet pendant plusieurs semaines :

  • Il apprend que le projet utilise Pydantic v2, et non v1
  • Il découvre que les tests doivent être exécutés avec pytest -x --tb=short
  • Il accumule des connaissances sur l’architecture de la codebase
  • Il apprend les préférences du développeur (« utilisez toujours pathlib, jamais os.path »)

Ces faits pourraient résider dans un système vectoriel ou key-value. Les fichiers constituent ici un meilleur choix par défaut, car le développeur doit pouvoir lire, modifier, relire et versionner des notes liées. Ajoutez une recherche par mots-clés ou sémantique uniquement lorsque le corpus documentaire et le pattern de requête le nécessitent. Si l’agent apprend quelque chose d’incorrect, ouvrez le fichier et corrigez-le.

Claude Code, Cursor et Devin Desktop utilisent des variantes de ce pattern. Les exemples ci-dessous montrent comment chacun stocke et charge ses fichiers.

Implémenter un file memory store

L’implémentation est volontairement simple. L’agent dispose de quatre opérations : écrire un document, lire un document, lister les documents disponibles et rechercher un mot-clé dans tous les documents.

Ce qui suit est un raw-Markdown file store illustratif et indépendant. Il ne s’agit pas d’une version simplifiée du memory/document.py actuel. Le projet actuel utilise DocumentMemory, qui exige un namespace et une clé et écrit une enveloppe JSON contenant content, metadata et created_at. Ce sketch définit un design différent afin d’illustrer le compromis des fichiers Markdown lisibles par les humains :

from pathlib import Path
import json

class FileMemory:
    """Document memory backed by the local filesystem.

    Stores agent knowledge as human-readable files organized by topic.
    No embeddings, no database — just files that both the agent and
    the developer can read, edit, and version-control.
    """

    def __init__(self, base_dir: str | Path):
        self.base_dir = Path(base_dir).resolve()
        self.base_dir.mkdir(parents=True, exist_ok=True)

    def _resolve_path(self, path: str) -> Path:
        """Return a path inside base_dir, rejecting escapes and symlinks."""
        requested = Path(path)
        if requested.is_absolute() or ".." in requested.parts:
            raise ValueError("path must be relative to base_dir without traversal")
        resolved = (self.base_dir / requested).resolve()
        try:
            resolved.relative_to(self.base_dir)
        except ValueError as error:
            raise ValueError("path must stay inside base_dir") from error
        return resolved

    def write_doc(self, path: str, content: str, metadata: dict | None = None):
        """Write or overwrite a document at the given path.

        Paths are relative to base_dir. Directories are created automatically.
        Metadata (if provided) is stored as a JSON sidecar file.
        """
        full_path = self._resolve_path(path)
        full_path.parent.mkdir(parents=True, exist_ok=True)
        full_path.write_text(content, encoding="utf-8")

        if metadata:
            meta_path = self._resolve_path(
                str(full_path.relative_to(self.base_dir).with_suffix(full_path.suffix + ".meta"))
            )
            meta_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")

    def read_doc(self, path: str) -> str | None:
        """Read a document by path. Returns None if not found."""
        full_path = self._resolve_path(path)
        if full_path.exists():
            return full_path.read_text(encoding="utf-8")
        return None

    def list_docs(self, pattern: str = "**/*") -> list[str]:
        """List documents matching a glob pattern."""
        self._resolve_path(pattern)
        return [
            str(self._resolve_path(str(p.relative_to(self.base_dir))).relative_to(self.base_dir))
            for p in self.base_dir.glob(pattern)
            if self._resolve_path(str(p.relative_to(self.base_dir))).is_file()
            and not p.name.endswith(".meta")
        ]

    def search_docs(self, query: str, pattern: str = "**/*.md") -> list[dict]:
        """Search documents by keyword. Returns matching files with context.

        This is intentionally simple — grep-style keyword search.
        For semantic search, use a vector store instead.

        # ponytail: linear scan of file bytes; add an index when measured
        # latency, concurrency, or retrieval quality requires it.
        """
        self._resolve_path(pattern)
        results = []
        for path in self.base_dir.glob(pattern):
            path = self._resolve_path(str(path.relative_to(self.base_dir)))
            if not path.is_file() or path.name.endswith(".meta"):
                continue
            content = path.read_text(encoding="utf-8")
            if query.lower() in content.lower():
                # Return the paragraph containing the match for context
                for paragraph in content.split("\n\n"):
                    if query.lower() in paragraph.lower():
                        results.append({
                            "path": str(path.relative_to(self.base_dir)),
                            "match": paragraph.strip()[:500],
                        })
        return results

Le path helper est volontairement partagé par les lectures, les écritures et les résultats de glob : les chemins relatifs peuvent toujours sortir d’un répertoire via .. ou un symlink existant. Cette classe illustrative est destinée à un filesystem mono-utilisateur de confiance ou contrôlé. Elle vérifie un chemin résolu avant utilisation ; à une frontière multi-tenant hostile, utilisez des opérations no-follow relatives à un descripteur afin qu’une mutation du filesystem ne puisse pas entrer en compétition avec cette vérification. Exécutez ce petit test de régression après avoir copié la classe :

from tempfile import TemporaryDirectory

with TemporaryDirectory() as root:
    memory = FileMemory(root)
    memory.write_doc("notes/ok.md", "safe memory")
    assert memory.read_doc("notes/ok.md") == "safe memory"
    assert memory.list_docs() == ["notes/ok.md"]
    assert memory.search_docs("safe")[0]["path"] == "notes/ok.md"

    (Path(root) / "escape").symlink_to(Path(root).parent, target_is_directory=True)
    for operation in (
        lambda: memory.write_doc("../escape.md", "nope"),
        lambda: memory.read_doc("/tmp/escape.md"),
        lambda: memory.read_doc("escape/outside.md"),
        lambda: memory.list_docs("../**/*"),
        lambda: memory.search_docs("safe", "../**/*.md"),
    ):
        try:
            operation()
        except ValueError:
            pass
        else:
            raise AssertionError("FileMemory accepted an escaped path")

Structure des dossiers

L’essentiel de la valeur de la document memory vient de l’organisation du répertoire. Voici la structure que j’utiliserais pour un agent de recherche. Le Market Analyst Agent utilise des namespaces sous memory/documents/, mais son DocumentMemory actuel écrit chaque entrée sous forme d’enveloppe JSON avec une chaîne content, et non sous forme de Markdown brut. La structure raw-Markdown ci-dessous appartient au design illustratif indépendant FileMemory présenté plus haut :

.agent-memory/
    README.md                  # What this directory is, for human readers
    PROGRESS.md                # Handoff for the next session: what is done, what is next
    user-profiles/
        user-123.md            # Preferences, history, risk profile
        user-456.md
    research/
        NVDA-2026-02.md        # Research notes from recent analysis
        TSLA-2026-01.md
    conventions/
        analysis-format.md     # How to structure analysis reports
        data-sources.md        # Preferred data sources and API patterns
    learnings/
        common-errors.md       # Mistakes the agent has learned to avoid
        tool-patterns.md       # Effective tool call sequences

Le répertoire de document memory et les quatre opérations qu’un agent y exécute : read, write, list et searchLe répertoire de document memory et les quatre opérations qu’un agent y exécute : read, write, list et search

Dans le design FileMemory illustratif, chaque document est en Markdown et la fonction de chaque document est évidente d’après son chemin. Vous pouvez git diff l’intégralité du répertoire de mémoire pour voir ce que l’agent a appris au cours d’une session, git revert un apprentissage incorrect ou copier le répertoire vers un autre projet. Les enveloppes JSON du projet actuel conservent la structure namespace et clé, mais n’offrent pas la même expérience de diff en Markdown brut.

Quand utiliser document memory, vector store ou key-value store

Les trois backends mémoire répondent à des patterns d’accès différents :

DimensionVector StoreKey-Value StoreDocument Store
Pattern de requête« Trouver les faits similaires à X »« Récupérer la valeur de la clé »« Lire le document à ce chemin »
Idéal pourRappel non structuré et variableRecherches structuréesContexte de projet, notes
Lisible par les humainsPayloads textuels lisiblesPartiellement (JSON)Oui (Markdown)
DébogableInspection des payloads et scoresFacile (clés exactes)Inspection des fichiers et recherche
VersionnableVia des exports ou des change logsPossibleOui (git-native)
Infrastructure d’embeddingRequiseNon nécessaireNon nécessaire
Passe à l’échelle jusqu’àDes millions de faitsDes millions de clésDépend du volume en octets et de l’index
Capacité de rechercheSimilarité sémantiqueCorrespondance exacteChemin, mots-clés, index optionnel

Utilisez la document memory lorsque :

  • L’agent accumule des connaissances de projet sur plusieurs sessions
  • Les développeurs doivent inspecter, modifier ou remplacer ce que l’agent « sait »
  • Les connaissances sont structurées sous forme de documents (notes, synthèses, conventions), et non de faits isolés
  • Vous souhaitez versionner la mémoire de l’agent avec Git
  • L’absence totale d’infrastructure est une exigence stricte

Utilisez les vector stores lorsque :

  • Vous avez besoin d’une retrieval sémantique floue (« trouver les mémoires liées à X »)
  • La formulation des requêtes varie de manière imprévisible
  • Vous disposez de milliers à des millions de faits individuels

Utilisez les key-value stores lorsque :

  • Vous avez besoin de recherches exactes et rapides sur des données structurées (profils utilisateur, paramètres)
  • Le schéma des données est bien défini

Les trois stores peuvent coexister, mais ce n’est pas obligatoire. Le Market Analyst Agent actuel utilise des checkpoints PostgreSQL pour la hot memory, Qdrant pour le stockage exact des profils utilisateur avec des vecteurs placeholders et un document store à enveloppes JSON namespacées. Les variantes de rappel sémantique et de Markdown brut présentées dans cet article sont des extensions illustratives.

Exemples réels

Ce pattern est déjà très répandu dans les assistants de programmation AI :

  • Claude Code lit les fichiers CLAUDE.md depuis la racine du projet et les répertoires parents, et maintient un fichier de mémoire par projet sous ~/.claude/projects/ pour les apprentissages cross-session. Le système mémoire repose sur de simples fichiers Markdown, et ceux du projet peuvent être commités avec le code.
  • Cursor charge les règles du projet depuis .cursor/rules sous forme de fichiers .mdc — conventions de code, préférences de framework et décisions d’architecture — avec un frontmatter qui contrôle le moment où chaque règle s’applique.
  • L’agent Cascade historique de Devin Desktop lit les règles depuis .devin/rules/, avec .windsurf/rules/ et le .windsurfrules à la racine conservés comme fallbacks historiques. Cascade stocke localement les mémoires générées automatiquement par workspace et les récupère ultérieurement ; l’agent Devin Local par défaut des nouveaux onglets ne conserve pas les mémoires.
  • L’outil de mémoire d’Anthropic pour l’API Claude est un outil côté client que le modèle pilote avec des opérations sur fichiers — view, create, str_replace, insert, delete et rename — sur un répertoire /memories. Votre application implémente chaque commande et décide donc de l’emplacement réel des fichiers (disque local, S3, base de données).

Les variantes basées sur des fichiers stockent les connaissances de l’agent sous forme de texte lisible par les humains, avec des opérations explicites de lecture et d’écriture, et aucune ne nécessite de pipeline d’embedding. L’agent décide quoi écrire ; lorsque ce texte se trouve dans un répertoire local géré par Git, le développeur peut le consulter et le modifier dans un git diff. Lorsqu’un handler de l’outil de mémoire Anthropic mappe /memories vers S3 ou une base de données, l’inspection et le versioning dépendent de cette implémentation.

Notes déclaratives et skills exécutables

Les connaissances basées sur des fichiers existent également en dehors des coding assistants, mais le format de stockage ne dit pas comment elles sont utilisées. Voyager stocke des programmes JavaScript réutilisables que l’agent peut exécuter. La méthode principale Agent Workflow Memory ajoute plutôt les workflows web induits au contexte du prompt comme directives pour les actions suivantes. Son expérience distincte AWM_AS expose les workflows comme des actions appelables. Une procédure décrite dans le contexte et une procédure exécutable nécessitent des contrôles différents.

Testez les skills appelables en les exécutant dans un environnement contrôlé et en vérifiant leurs effets. Examinez les notes de projet et les workflows contextuels pour comprendre les faits, les contraintes et les conseils d’action qu’ils fournissent, puis testez si ces instructions améliorent le comportement en aval. L’une ou l’autre forme peut conduire à une action nuisible ; aucune ne confère de permissions supplémentaires.

Cette même frontière distingue la mémoire des skills et des tools. Le standard Agent Skills utilise des fichiers SKILL.md pour expliquer à un agent comment exécuter une catégorie de tâches ; la mémoire enregistre les faits appris d’un projet ou d’un run antérieur. La Partie 3 trace la frontière voisine entre un skill et un tool. Choisissez un file store pour le contexte appris et inspectable ; choisissez un skill ou un tool uniquement lorsque le besoin porte sur une procédure ou une capacité réutilisable.

Faire passer la document memory en production

L’implémentation basée sur des fichiers présentée plus haut convient à un filesystem mono-utilisateur contrôlé. Plusieurs tenants et des writers concurrents nécessitent une gestion explicite des accès et une coordination des écritures, quel que soit le nombre de documents.

Le store brut ci-dessus ne fournit ni coordination des écritures concurrentes, ni modèle multi-tenant, ni index de recherche. Mesurez ces besoins avant de le remplacer. Une base de données ou un object store peut fournir des contrats différents en matière de concurrence et d’accès ; les fichiers peuvent également être indexés.

Trois approches courantes :

Approche A : hybride avec une fine couche de base de données

Conservez les fichiers pour l’authoring (les développeurs modifient localement le Markdown), mais servez les données depuis une base en runtime. Au déploiement, synchronisez les fichiers vers des lignes PostgreSQL. L’agent lit dans la base, pas sur le disque. Vous obtenez ainsi :

  • Ergonomie développeur (modifier du Markdown et le committer dans Git)
  • Performances de requêtage en production (lectures indexées en base)
  • Séparation nette entre authoring et serving

Approche B : object storage + sidecar d’index vectoriel

Stockez les documents dans S3/GCS sous forme d’objets, avec une collection Qdrant qui indexe leurs embeddings. L’agent interroge Qdrant pour obtenir les IDs des documents pertinents, puis récupère leur contenu dans l’object storage. Cette approche passe à l’échelle horizontalement et prend en charge la recherche sémantique, mais ajoute de la complexité : deux systèmes à gérer, un pipeline d’embedding à maintenir et une cohérence éventuelle entre le store et l’index.

Approche C : document store structuré avec PostgreSQL (recommandée)

Stockez les documents sous forme de lignes JSONB PostgreSQL avec une recherche full-text (index GIN) et des embeddings vectoriels optionnels (pgvector). Vous obtenez une recherche hybride (mots-clés + sémantique), des transactions ACID et un système opérationnel unique.

Voici un sketch de l’approche C. Le score combiné effectue un scoring exact sur un corpus tenant borné ; il n’utilise pas d’index approximate nearest-neighbor (ANN). pgvector exige un tri direct ascendant par distance avec LIMIT pour ce chemin d’index. Pour un corpus plus volumineux, récupérez séparément des candidats bornés par mots-clés et par vecteurs, puis fusionnez leurs rangs. Il s’agit d’un pattern RLS, et non de code applicatif prêt à l’emploi : son rôle de base de données doit être accessible uniquement au serveur applicatif de confiance. Le serveur authentifie la requête et construit principal ; il n’accepte pas d’ID de tenant fourni par l’appelant. PostgreSQL RLS rend ensuite ce scope contraignant, même si une requête omet ultérieurement son prédicat de tenant.

from typing import Optional
from dataclasses import dataclass
import asyncpg

@dataclass(frozen=True)
class AuthenticatedPrincipal:
    """The verified identity returned by the application's authentication layer."""
    tenant_id: str

class ProductionDocumentMemory:
    """Illustrative PostgreSQL document memory with hybrid search and RLS.

    Apply this schema and policy as the table owner during deployment:

        CREATE TABLE documents (
            id SERIAL PRIMARY KEY,
            tenant_id TEXT NOT NULL,
            path TEXT NOT NULL,
            content TEXT NOT NULL,
            metadata JSONB,
            embedding vector(1536),  -- pgvector extension
            ts_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
            created_at TIMESTAMPTZ DEFAULT NOW(),
            UNIQUE(tenant_id, path)
        );
        CREATE INDEX ON documents USING GIN(ts_vector);

        ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
        ALTER TABLE documents FORCE ROW LEVEL SECURITY;
        CREATE POLICY tenant_documents ON documents
            USING (tenant_id = current_setting('app.tenant_id', true))
            WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

    `FORCE` also subjects the table owner to the policy. Superusers and roles with
    `BYPASSRLS` still bypass it, so neither belongs in the application's pool.
    """

    def __init__(self, pool: asyncpg.Pool):
        self.pool = pool

    async def write(
        self,
        principal: AuthenticatedPrincipal,
        path: str,
        content: str,
        metadata: Optional[dict] = None,
        embedding: Optional[list[float]] = None,
    ):
        """Write or update a document.

        Sketch: on a real pool you must register codecs first, or asyncpg
        raises DataError — `set_type_codec` for the JSONB metadata column
        and pgvector's `register_vector` for the embedding.
        """
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                # true keeps this trusted context to this transaction only.
                await conn.execute(
                    "SELECT set_config('app.tenant_id', $1, true)", principal.tenant_id
                )
                await conn.execute(
                    """
                    INSERT INTO documents (tenant_id, path, content, metadata, embedding)
                    VALUES ($1, $2, $3, $4, $5)
                    ON CONFLICT (tenant_id, path) DO UPDATE
                    SET content = EXCLUDED.content,
                        metadata = EXCLUDED.metadata,
                        embedding = EXCLUDED.embedding
                    """,
                    principal.tenant_id, path, content, metadata, embedding,
                )

    async def search(
        self,
        principal: AuthenticatedPrincipal,
        query: str,
        embedding: Optional[list[float]] = None,
        limit: int = 5,
    ) -> list[dict]:
        """Hybrid search: full-text + optional vector similarity."""
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                await conn.execute(
                    "SELECT set_config('app.tenant_id', $1, true)", principal.tenant_id
                )
                if embedding:
                    # Hybrid scoring: 0.6 * text relevance + 0.4 * vector similarity
                    rows = await conn.fetch(
                        """
                        SELECT path, content, metadata,
                               (0.6 * ts_rank(ts_vector, plainto_tsquery('english', $1)) +
                                0.4 * COALESCE(1 - (embedding <=> $2), 0)) AS score
                        FROM documents
                        WHERE ts_vector @@ plainto_tsquery('english', $1)
                           OR (embedding <=> $2) < 0.5
                        ORDER BY score DESC
                        LIMIT $3
                        """,
                        query, embedding, limit,
                    )
                else:
                    # Full-text search only
                    rows = await conn.fetch(
                        """
                        SELECT path, content, metadata,
                               ts_rank(ts_vector, plainto_tsquery('english', $1)) AS score
                        FROM documents
                        WHERE ts_vector @@ plainto_tsquery('english', $1)
                        ORDER BY score DESC
                        LIMIT $2
                        """,
                        query, limit,
                    )
                return [dict(row) for row in rows]

set_config(..., true) est limité à la durée de la transaction, de sorte qu’une connexion issue d’un pool ne puisse pas conserver le contexte d’un tenant pour la requête suivante. Le OR de la première branche est ce qui rend la recherche hybride. COALESCE conserve dans l’ensemble de résultats un document correspondant aux mots-clés mais dépourvu d’embedding, avec son score textuel ; il ne contribue pas à la similarité vectorielle. Avec uniquement le prédicat @@, un document sémantiquement pertinent mais ne partageant aucun mot-clé avec la requête est filtré avant même le calcul du score — il s’agit alors de keyword retrieval avec semantic reranking, et non de retrieval hybride. Les poids 0,6/0,4 sont illustratifs : le rang textuel et la similarité cosinus ont des échelles différentes. Normalisez-les à partir de votre évaluation de retrieval ou utilisez une fusion de rangs avant d’interpréter ces poids comme des importances relatives. Le seuil de distance est un paramètre : réduisez-le si la branche vectorielle submerge les résultats, augmentez-le si les correspondances sémantiques n’apparaissent jamais.

La régression suivante correspond au comportement à tester sur une base réelle après les migrations. Sous tenant-a, une lecture de tenant-b ne renvoie aucune ligne et un insert cross-tenant direct échoue sur RLS :

BEGIN;
SELECT set_config('app.tenant_id', 'tenant-a', true);
SELECT path FROM documents WHERE tenant_id = 'tenant-b'; -- 0 rows
INSERT INTO documents (tenant_id, path, content)
VALUES ('tenant-b', 'leak.md', 'must fail'); -- ERROR: row-level security policy
ROLLBACK;

Vous obtenez :

  • Recherche hybride : correspondance par mots-clés (index GIN) + similarité sémantique (pgvector), scorées ensemble
  • Multi-tenancy : identité dérivée du serveur et RLS appliqué par la base de données
  • Garanties ACID : les transactions sur le primary sont commitées atomiquement ; les lectures sur une replica peuvent être en retard
  • Système opérationnel unique : pas de base vectorielle distincte à gérer
  • Scaling : les read replicas peuvent servir des requêtes tolérant des données obsolètes. Le partitioning natif peut aider à l’élagage et à la maintenance, mais ne distribue pas les écritures entre les serveurs ; cela nécessite un design explicite de sharding. Acheminez les chemins read-after-write vers le primary ou mesurez une policy synchrone appropriée

Les fichiers sont excellents pour les workflows d’un développeur unique. En production multi-tenant, un document store structuré sur PostgreSQL constitue généralement le meilleur compromis entre simplicité, performances et maturité opérationnelle.


Assembler le tout : l’architecture complète

Voici comment les trois niveaux de mémoire peuvent fonctionner ensemble dans une architecture inspirée du Market Analyst Agent. Le diagramme illustre un flux proposé, de la requête utilisateur à la réponse, avec toutes les couches mémoire actives.

Les trois niveaux de mémoire reliés autour d’un agent, avec leurs chemins de lecture et de mise à jourLes trois niveaux de mémoire reliés autour d’un agent, avec leurs chemins de lecture et de mise à jour

L’architecture comporte trois chemins mémoire :

  1. Chemin hot (checkpoint store) : LangGraph écrit l’état du graphe reprenable dans le checkpoint store à chaque frontière de super-step. Lorsque le graphe atteint un nœud interrupt_before (comme le nœud publish de la Partie 1), l’exécution se met en pause. L’utilisateur peut fermer l’application ; à son retour, le graphe reprend depuis le checkpoint. Les event logs et les traces du runtime sont des préoccupations de production distinctes.

  2. Chemin cold (store long terme) : après que le router a choisi un chemin, le planner interroge le store long terme pour obtenir le contexte utilisateur pertinent. Le planner ne peut pas personnaliser la réponse avant que cette lecture ne soit terminée. Une recherche adossée à des vecteurs peut inclure l’embedding de la requête et la retrieval depuis l’index ; une recherche key-value non. Les nouveaux faits peuvent être extraits et stockés après la fin de la conversation, afin que cette écriture ne ralentisse pas la reasoning loop.

  3. Chemin document (file store) : pendant la planification, l’agent lit les conventions du projet et les notes de recherche nécessaires à la requête. Pendant l’exécution, il écrit les synthèses de recherche et les patterns appris sur le disque. Ces lectures alimentent la tâche courante ; la taille des fichiers, la vitesse du filesystem et l’état du cache influencent donc le temps de réponse. Ne mettez en cache que si le cache dispose de règles claires d’invalidation et d’isolation des tenants. Les écritures peuvent être différées.

Le câblage dans LangGraph est simple — le checkpoint store et le store long terme sont transmis lors de la compilation du graphe, tandis que le document store est injecté comme dépendance. Le sketch local ci-dessous compile un builder StateGraph déjà configuré, avec notamment des nœuds qui acceptent le store. Cela étend le câblage du graphe ; les helpers create_graph de la Partie 1 et du companion n’acceptent pas d’argument store. Le snippet utilise InMemoryStore pour rester concis ; la topologie Docker de référence utilise Qdrant pour le même rôle de rappel sémantique.

import asyncio
from langgraph.store.memory import InMemoryStore

# Cold memory: local sketch with vector search
# (The reference Docker topology uses Qdrant for persistent recall.)
memory_store = InMemoryStore(
    index={"dims": 1536, "embed": embedding_function}
)

# Document memory: illustrative file-based store for project knowledge
# FileMemory is the illustrative class defined above, not the project's current
# DocumentMemory implementation.
doc_memory = FileMemory(base_dir=".agent-memory")

async def main() -> None:
    # Hot memory: PostgreSQL for durable checkpoints. postgres_checkpointer() is
    # the async context manager defined earlier, so the graph runs inside it.
    async with postgres_checkpointer(pg_connection_string) as checkpointer:
        # builder is the configured StateGraph for this extended design.
        # The Part 1/companion create_graph helper does not accept store.
        graph = builder.compile(
            checkpointer=checkpointer,
            store=memory_store,
        )
        # ... run the graph here, while the connection is still open

asyncio.run(main())

# The store is accessible inside any node via the store parameter
def planner_node(state: AgentState, *, store: BaseStore) -> dict:
    """Plan with user context from long-term memory."""

    # Recall relevant user facts from vector store.
    # Namespace prefix is positional — see the store example above.
    user_memories = store.search(
        ("user", state.user_id),
        query=state.messages[-1].content,
        limit=5,
    )

    # Load project conventions from document memory
    conventions = doc_memory.read_doc("conventions/analysis-format.md")

    # Inject both into planning context
    # Each stored value is a dict; render whatever keys it carries
    memory_context = "\n".join(str(m.value) for m in user_memories)
    # ... rest of planning logic with personalized context and conventions

Le flux complet

Dans le design étendu ci-dessus, la requête « Analyze TSLA » d’un utilisateur qui revient pourrait suivre ce flux. Le rappel sémantique et l’extraction asynchrone des faits sont des extensions proposées, et non des comportements actuels du companion :

  1. Chargement de la document memory : lorsque le planner s’exécute, il lit les conventions du projet dans le document store : préférences de format d’analyse, sources de données préférées et patterns d’utilisation des tools. Ces éléments définissent le comportement de base du plan.

  2. Router : le router classe la requête comme DEEP_RESEARCH. Dans cet exemple, le routage utilise la requête elle-même, et non les préférences long terme.

  3. Rappel cold memory + planner : le planner interroge le store long terme avec le message de l’utilisateur. Il récupère : « L’utilisateur a une forte tolérance au risque », « L’utilisateur préfère une analyse détaillée des concurrents », « L’utilisateur a déjà étudié NVDA et AMD ». Il crée ensuite un plan de recherche en cinq étapes, personnalisé selon ces préférences. Il inclut une étape d’analyse des concurrents parce que l’historique de l’utilisateur montre qu’il en souhaite une. Le plan suit le format défini dans le document de conventions.

  4. Boucle d’exécution (hot memory) : chaque étape s’exécute selon le pattern ReAct de la Partie 1 — réfléchir, agir, observer, jusqu’à ce que l’étape soit terminée. LangGraph crée un checkpoint à chaque super-step (router, planner, puis chaque étape séquentielle de l’executor). La récupération démarre depuis le dernier checkpoint persisté. Si l’écriture de l’étape 3 est terminée, le graphe peut poursuivre à l’étape 4 ; avec une persistance asynchrone, un crash peut imposer de répéter une étape déjà terminée.

  5. Interruption HITL : le reporter rédige un brouillon. Une session distincte du modèle, sans historique du run, lit le brouillon et enregistre une évaluation. Le graphe atteint ensuite publish, où interrupt_before le met en pause quelle que soit cette évaluation. Le checkpoint contient à la fois le brouillon et l’évaluation ; l’humain les examine avant de décider de publier. Plusieurs heures plus tard, le graphe recharge le checkpoint et suit cette décision.

  6. Mises à jour de la mémoire : après la fin de la conversation, un processus asynchrone extrait de nouveaux faits utilisateur (« l’utilisateur suit désormais TSLA », « l’utilisateur a approuvé le format du rapport ») et les stocke dans le vector store long terme. L’agent écrit également une synthèse de recherche dans le document store (research/TSLA-2026-02) pour référence ultérieure.

Le pattern à trois niveaux sépare clairement les responsabilités. Le checkpoint store gère la durabilité et la reprise ; c’est de l’infrastructure. Le store long terme gère la personnalisation ; c’est de la logique produit. Le document store conserve les connaissances accumulées sur le projet ; c’est le carnet de notes de l’agent.


Compromis et points d’attention

La mémoire apporte de la valeur, mais aussi des coûts et de la complexité :

  • Coût des embeddings : chaque fait stocké dans une base vectorielle nécessite la génération d’un embedding. Un fournisseur d’embeddings hébergé ajoute un appel API, un coût dépendant du fournisseur et de la latence réseau ; en septembre 2026, OpenAI facture text-embedding-3-small $0,02 par million de tokens. Le coût d’un modèle hébergé par fait est négligeable, mais il s’accumule sur des milliers d’utilisateurs et de sessions. Regroupez les appels hébergés et mettez les résultats en cache. Au moment de la requête, la retrieval vectorielle peut inclure l’embedding de la requête, la retrieval depuis l’index et la latence réseau ; une recherche key-value non. Mesurez ce chemin dans votre déploiement, puis mettez en cache les embeddings de requêtes fréquentes ou utilisez un modèle d’embedding local s’il est sensible à la latence.

  • Mémoire obsolète : les préférences utilisateur changent. Un fait stocké il y a six mois (« l’utilisateur préfère les investissements prudents ») peut ne plus être exact. Définissez des policies d’expiration. Par exemple, une équipe pourrait faire expirer les préférences après 365 jours et les événements épisodiques après 90 jours si ses règles de confidentialité, son rythme de mise à jour et son évaluation de retrieval justifient ces fenêtres ; ces valeurs constituent une policy proposée, et non des defaults portables. L’article sur le context engineering rejette les règles fixes de rétention comme policy portable. L’expiration est la version brutale. Le typed state guidé par le schéma fournit une approche plus précise : validité temporelle et provenance sur chaque fait, afin qu’une valeur remplacée perde face à la valeur courante au moment de la retrieval, plutôt qu’à son expiration.

  • Overhead de mémoire dans le contexte : chaque fait récupéré consomme des tokens dans la context window du LLM. Si vous récupérez 20 faits par requête, cela représente plusieurs centaines de tokens de contexte mémoire en concurrence avec la tâche réelle. Limitez le nombre de faits récupérés et donnez la priorité aux scores de pertinence.

  • Confidentialité et conformité : la mémoire long terme stocke des données utilisateur. Vous avez besoin de redaction des PII avant le stockage, de policies de rétention claires et de contrôles permettant à l’utilisateur de supprimer ses données. Rien de tout cela n’est facultatif dans les secteurs réglementés.

  • Croissance du stockage des checkpoints : les tables de checkpoints PostgreSQL grossissent à chaque super-step. N’exécutez pas une requête SQL générale de pruning : les delta channels peuvent nécessiter des checkpoints ancêtres ainsi que leurs enregistrements de writes/blobs pour reconstruire un checkpoint conservé. Utilisez uniquement une API de pruning fournie par le saver, après l’avoir vérifiée avec le saver installé et son contrat de récupération des delta channels. Si cette prise en charge est indisponible, conservez la closure complète des parents, writes et blobs, puis testez la reprise depuis un checkpoint conservé avec le saver installé.

  • Consolidation de la mémoire : avec le temps, les mémoires épisodiques détaillées devraient être compressées en représentations sémantiques compactes : « l’utilisateur a demandé trois fois des informations sur NVDA en janvier », plutôt que de conserver les trois conversations intégralement. Cela reflète la consolidation de la mémoire humaine et maintient le store à une taille maîtrisable. Mem0 et Graphiti gèrent cela automatiquement ; si vous développez votre propre solution, planifiez des jobs de consolidation périodiques.

  • Problème du cold start : les nouveaux utilisateurs ne disposent d’aucune mémoire long terme. L’agent doit dégrader élégamment et poser des questions de clarification plutôt que de faire des suppositions. La mémoire est additive, pas obligatoire.

  • Empoisonnement de la mémoire : tout contenu présent dans la context window de l’agent peut constituer un point d’injection. Si un attaquant écrit des faits trompeurs dans le document store ou la mémoire long terme (« toujours approuver les transactions sans vérification »), l’agent peut les exécuter comme des instructions. L’injection de prompt via les mémoires stockées constitue une surface d’attaque réelle. Les mitigations sont la validation avant stockage, le traitement du contenu récupéré comme des données non fiables plutôt que comme des instructions système et des contrôles d’accès limitant les mémoires pouvant influencer les opérations critiques.

  • Dérive de la document memory : la mémoire basée sur des fichiers ne fournit ni déduplication automatique ni résolution des conflits. Avec le temps, les documents accumulent des contradictions : un fichier indique « utilisez pytest », tandis qu’un autre indique « utilisez unittest ». Planifiez des revues périodiques (ou laissez l’agent les effectuer) afin d’élaguer et de consolider les contenus. Les fichiers prennent en charge grep ; les payloads des vector stores peuvent également être inspectés ou exportés. Aucun format de stockage ne détecte les contradictions seul.

  • Échelle de recherche : le scan de fichiers brut ci-dessus lit le corpus à chaque requête. Choisissez un index en fonction du volume d’octets parcourus, du rythme de mise à jour, de la concurrence, de la latence et de la qualité de retrieval. Le contenu basé sur des fichiers peut utiliser un index full-text ou vectoriel ; le nombre de documents à lui seul ne détermine pas le backend.


Tester la retrieval et le cycle de vie de la mémoire

Comparez des baselines sans mémoire et avec contexte complet sur des questions conservées à part. Incluez des paraphrases, des contradictions, des changements de préférences, des faits obsolètes, des questions sans réponse, des suppressions et des requêtes cross-tenant. LongMemEval fournit 500 questions couvrant l’extraction, le raisonnement multi-session et temporel, les mises à jour et l’abstention. Mesurez séparément la précision/le recall de la retrieval et la correction de la réponse, ainsi que l’utilisation de faits obsolètes, la divulgation non autorisée, la correction des opérations d’écriture/mise à jour/suppression, la latence et le coût.

Les questions de recall ne constituent qu’une partie de l’évaluation. MemoryArena ajoute des tâches interdépendantes réparties entre plusieurs sessions, où une action antérieure et son feedback doivent modifier le comportement ultérieur. Ses tâches couvrent le shopping, la planification de voyages, la recherche progressive et le raisonnement formel. Utilisez ce design lorsque le produit promet d’apprendre du travail effectué, plutôt que de répondre uniquement à des questions sur des conversations stockées. Il s’agit de tâches de recherche, et non de mesures d’un memory service déployé.

EvoMemBench distingue également les connaissances de l’expérience d’exécution, ainsi que la mémoire intra-épisode de la mémoire inter-épisodes. Sa comparaison de 15 méthodes ne trouve aucune forme de mémoire uniformément supérieure ; les baselines à long contexte restent compétitives selon son protocole. Cela plaide pour conserver les baselines simples dans votre évaluation, et non pour remplacer chaque store par le dernier framework à la mode.

Conservez la provenance et la validité à côté des faits récupérés. Les scores d’importance ne peuvent établir ni la confiance ni les permissions. La policy de suppression doit couvrir les index, les résumés mis en cache et les artefacts conservés, en plus de l’enregistrement original.

La couche suivante est l’action

Les Parties 5 et 6 reviennent sur la mémoire depuis le point de vue opérationnel et en traitent deux moitiés différentes. Le runtime possède le checkpoint : l’endroit où l’exécution s’est arrêtée et la manière de la redémarrer. Le harness possède le handoff : la signification du travail et ce qu’il reste à faire, consignés comme document memory pour la prochaine session du modèle — un segment continu de contexte modèle, selon la terminologie précisée par la Partie 5. Restaurer le processus n’est pas la même chose que restaurer la tâche.

Références

Articles

Documentation LangGraph

Checkpoint backends

Bases vectorielles et memory tools

  • Qdrant — Base vectorielle open source avec indexation HNSW et filtrage
  • Qdrant Agentic Builders Guide — Guide pratique pour construire la mémoire d’un agent avec Qdrant
  • pgvector — Extension de recherche de similarité vectorielle pour PostgreSQL
  • Graphiti — Moteur open source de knowledge graph temporel par Zep

Mémoire basée sur des documents et des fichiers

  • Claude Code Memory — CLAUDE.md et répertoire de mémoire par projet
  • Anthropic Memory Tool — Mémoire basée sur des fichiers côté client pour les agents de l’API Claude
  • Cursor Rules — Règles de projet sous forme de fichiers .mdc dans .cursor/rules
  • Devin Desktop Memories — Règles Cascade et mémoires générées automatiquement au niveau du workspace ; Devin Local par défaut ne les conserve pas

Frameworks de mémoire

  • Mem0 — Couche de mémoire managée avec pipeline d’extraction/consolidation
  • Letta (MemGPT) — Gestion virtuelle du contexte des agents inspirée des systèmes d’exploitation
  • LangMem SDK — Tools de gestion de mémoire pour LangGraph

Workshops

Projet de démonstration

  • Market Analyst Agent — Implémentation de référence pour les chemins de stockage actuels des checkpoints et des profils/documents