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.

Une reasoning loop ne survit à une seule requête que si son état est stocké en dehors du worker. Sans mémoire d’agent, l’agent ne peut ni reprendre un plan mis en pause, ni récupérer après un crash, ni se rappeler une préférence d’une session précédente. La Partie 1 présentait le flux de contrôle. Cet article identifie l’état requis par chaque tour ultérieur et l’emplacement où cet état doit être stocké.

J’utiliserai le Market Analyst Agent — un agent LangGraph de petite taille qui récupère des données de marché et rédige un rapport d’analyste — comme fil conducteur pour discuter des hot checkpoints. Les sections consacrées aux cold vectors et au Markdown brut décrivent des architectures illustratives indépendantes, correspondant à 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 simples fichiers Markdown sont adaptés.

Tous les stores ci-dessous sont lus par le harness, c’est-à-dire le code qui pilote la loop autour du modèle. Le harness décide lesquels de leurs contenus atteignent la context window ; les stores ne le font pas. Cet article porte sur l’emplacement où cet état réside avant que le harness ne le récupère. Les Parties 3 et 4 décrivent 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 conserver 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. En production, ce n’est pas une seule base vectorielle. Il s’agit d’un ensemble de hot checkpoints, de stores froids sémantiques ou structurés, et de document memory lisible par les humains.

BesoinChoix par défaut recommandéPourquoi
Mettre en pause et reprendre une exécutionPostgreSQL checkpoint storeDurable, interrogeable et simple à exploiter avec les données applicatives
État transitoire à faible latenceRedis checkpoint storeReprise rapide et état de courte durée, avec des compromis sur la persistance
Rappel sémantique inter-threadsQdrant ou pgvectorRécupération des mémoires par leur sens, pas uniquement par des clés exactes
Faits utilisateur structurésPostgreSQL ou key-value storeLes mises à jour déterministes sont préférables à la recherche floue pour les préférences et les identifiants
Conventions et procédures apprises du projetFichiers Markdown ou JSONLisibles, comparables avec diff et faciles à mettre à jour par les agents
Mémoire relationnelle multi-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. Cette conception échoue lorsque le produit doit prendre en charge l’un des comportements suivants :

  • Mise en pause et reprise : un utilisateur démarre une tâche de recherche, ferme son ordinateur et revient le lendemain. Sans état enregistré dans un checkpoint, l’agent recommence depuis le début.
  • 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 qu’il a collectées et des étapes du plan qu’il a 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é pour l’analyse et ses interactions passées.
  • Human-in-the-loop (HITL) : l’agent rassemble ses éléments de preuve puis 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, les données collectées et un rapport préliminaire. Lorsque l’utilisateur répond « looks good, but add competitor analysis », un checkpoint store permet à l’agent de charger l’état de la dernière étape terminée et d’ajouter l’étape consacrée aux concurrents. Sans état enregistré dans un checkpoint, il ne peut pas déterminer à quoi « looks good » fait référence et doit recommencer.

La mémoire long terme répond à un autre besoin. Si l’utilisateur revient une semaine plus tard et demande : « Update my NVDA analysis », l’agent peut devoir se rappeler une préférence pour des évaluations prudentes du risque et un intérêt pour les actions de sociétés de semi-conducteurs. Un store de mémoire reposant sur 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 à n’importe quel framework. LangGraph sépare la mémoire selon sa portée. Chaque exécution du graphe se déroule dans un thread, c’est-à-dire une conversation ou une tâche. L’état persisté dans ce thread constitue la short-term memory. L’état partagé entre les threads constitue la long-term memory. Plus bas, « thread » et « conversation » sont interchangeables ; j’évite « session » pour désigner cette portée, car la Partie 5 réserve ce terme au log durable d’une exécution, plusieurs exécutions pouvant s’accumuler dans un même thread. Le contexte courant du modèle et les variables en mémoire du processus forment la couche de working memory située au-dessus des deux stores.

Les six types de mémoire d’un agent et les trois niveaux de stockage auxquels ils se réduisentLes six types de mémoire d’un agent 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émoirePortéeDurée de vieExempleMode de stockage
WorkingÉtape couranteMillisecondesArguments des tool calls, réponse LLM couranteEn mémoire du processus (Python dict)
Short-termThread courantMinutes–heuresHistorique de conversation, progression du plan, données collectéesCheckpoint store
EpisodicInter-threadsJours–mois« La semaine dernière, l’utilisateur a demandé des informations sur les résultats de NVDA »Vector store / KV store
SemanticInter-threadsMois–permanente« L’utilisateur préfère les investissements prudents »Vector store / KV store
DocumentInter-threadsJours–permanenteNotes de projet, synthèses de recherche, patterns apprisFile store (Markdown/JSON)
ProceduralÀ l’échelle du systèmePermanente« Lors de l’analyse d’actions, toujours vérifier les dépôts auprès de la SEC »Config / system prompt

La working memory correspond à ce avec quoi le LLM raisonne activement à cet instant : variables Python dans la fonction courante, contenu de la context window, arguments de tool calls en cours d’exécution. C’est la couche la plus rapide et la plus éphémère. Rien ne persiste au-delà de l’étape courante. La working memory est limitée par la context window du modèle, ce qui en fait le goulot d’étranglement. Tout ce que l’agent « sait » au moment de prendre une décision doit y tenir, que cela provienne du checkpoint store, d’une requête vectorielle ou de la lecture d’un fichier. Les autres niveaux existent pour alimenter la working memory avec les bonnes informations au bon moment.

La short-term memory est le 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 humains et les agents peuvent inspecter. La procedural memory réside dans les instructions système et les définitions des tools, plutôt que de changer pour chaque utilisateur.

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, le checkpoint du thread courant. Les mémoires episodic et semantic deviennent la cold memory, pour le rappel entre les threads. La document memory conserve les connaissances accumulées du projet sous une forme lisible et directement modifiable. La working memory est regroupée avec le niveau hot dans la taxonomie ci-dessus, mais c’est la seule qui n’est jamais réellement stockée : elle existe pendant une seule étape, dans le processus, et correspond à la context window dans laquelle les trois niveaux de stockage chargent leurs données. La procedural memory se situe en dehors des trois niveaux : elle réside dans le system prompt et les définitions des tools, et est donc livrée avec l’agent plutôt que stockée puis récupérée.

CoALA classe les mémoires working, episodic, semantic et procedural. La synthèse Memory in the Age of AI Agents met l’accent sur les vector stores et les knowledge graphs, tandis que LangGraph documente les checkpoints et son interface Store. Les connaissances de projet stockées dans des fichiers se situent en dehors de ces taxonomies, même si Claude Code, Cursor et Devin Desktop chargent tous des fichiers de projet persistants.

Le même modèle de stockage apparaît dans d’autres domaines. Un agent Minecraft (Voyager) stocke des compétences de jeu réutilisables sous forme de bibliothèques de code, des équipes participant à une compétition de questions-réponses sur des documents d’entreprise itèrent sur des documents de prompt procéduraux, et des agents web déduisent des workflows de navigation réutilisables à partir d’exécutions réussies. Je reviendrai sur ces trois exemples plus loin ; l’idée importante est que les fichiers rendent ces connaissances inspectables et versionnables sans service d’embedding distinct.

La mémoire gérée par un agent diffère également d’un pipeline RAG fixe par l’auteur de l’écriture. L’agent ou son harness sélectionne ce qu’il faut stocker, mettre à jour et supprimer, puis choisit ultérieurement quand 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, une conception qui reste une référence utile pour la récupération de mémoire d’agent.


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

Chaque fois que LangGraph termine un super-step — un nœud ou un ensemble de nœuds exécutés en parallèle — le framework sérialise l’état complet du graphe et l’écrit dans un checkpoint store. C’est le fondement des workflows de mise en pause/reprise, du débogage 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, mode d’exécution. LangGraph stocke également sa propre comptabilité : l’identifiant et l’horodatage du checkpoint, une version par channel (terme utilisé par LangGraph pour désigner une clé d’état individuelle), ainsi qu’un enregistrement distinct indiquant quelles versions de channel chaque nœud a déjà vues. Le numéro d’étape figure dans les métadonnées du checkpoint, et non dans le checkpoint lui-même. C’est en comparant ces deux éléments que le graphe détermine ce qui doit s’exécuter ensuite. Après une interruption HITL ou un redémarrage du processus, le graphe charge le checkpoint écrit à la dernière frontière terminée, puis réentre dans le nœud suivant. Il ne reprend pas à une ligne Python arbitraire. Un checkpoint diffère également d’un event log append-only ou d’une trace ; la Partie 5 sépare 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 correspond à 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 vs Redis

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

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 d’écriture de la 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
Meilleur choix par défautReprise durable et débogage reproductibleÉtat de session sensible à la latence et récupérable

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 est le choix par défaut le plus sûr pour la plupart des équipes. Les checkpoints survivent aux crashes, vous bénéficiez de la sémantique transactionnelle complète et l’historique des checkpoints simplifie le débogage time-travel.

Voici une version simplifiée de la configuration du checkpoint dans memory/hot.py. En production, définissez LANGGRAPH_STRICT_MSGPACK=true ou configurez une allowlist explicite allowed_msgpack_modules afin que la désérialisation des checkpoints n’autorise que les types sûrs ou déclarés ; la valeur permissive par défaut avertit lorsque des types ne sont pas enregistrés, mais les autorise.

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() -> 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(
            {"messages": [HumanMessage(content="Analyze NVDA")]}, config
        )

        # Resume later on the same thread_id — loads the latest checkpoint.
        result = await graph.ainvoke(
            {"messages": [HumanMessage(content="approved")]}, config
        )

asyncio.run(main())

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 écritures concurrentes sont séparées par la clé primaire (thread_id, checkpoint_ns, checkpoint_id) et par des upserts, plutôt que par un verrouillage : deux workers travaillant sur le même thread ne se corrompront pas mutuellement, mais ils ne se coordonneront pas non plus.

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

Lorsque la latence des checkpoints est le goulot d’étranglement, Redis peut servir à stocker 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.

Voici une version simplifiée de la configuration du checkpoint 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.
    Trade-off: less durable than PostgreSQL unless you enable AOF,
    Redis's append-only log — snapshots alone lose recent writes on a crash.
    """
    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 remplacé plusieurs opérations de recherche par un unique appel JSON.GET, réduisant sensiblement la latence. Redis 8.0+ inclut RedisJSON et RediSearch par défaut : aucun module supplémentaire n’est nécessaire.

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

Quand utiliser l’un ou l’autre

Utilisez PostgreSQL lorsque :

  • Vous avez besoin de l’historique complet des checkpoints pour le débogage 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 des tâches longues, dont la perte d’état entraînerait des heures de recalcul
  • Vous souhaitez un unified data store — PostgreSQL avec pgvector peut constituer un 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 en temps réel, UX en streaming)
  • Vous construisez des voice bots ou des expériences en streaming où l’accès au checkpoint se situe sur un chemin critique dont la latence est mesurée
  • Vous avez besoin d’un passage à l’échelle horizontal sur de nombreux threads concurrents
  • Vous avez des patterns de fan-out à forte concurrence où plusieurs agents partagent un état
  • Vous gérez des sessions de courte durée, pour lesquelles la perte d’un checkpoint est récupérable
  • Vous souhaitez utiliser le semantic caching pour réduire les appels LLM redondants (Redis LangCache met en cache les requêtes sémantiquement similaires afin d’éviter les 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 externalisation automatique des payloads : les petits checkpoints (<350 Ko) restent dans DynamoDB, tandis que les plus volumineux sont déportés vers S3. Sa tarification serverless et l’absence d’infrastructure à gérer le rendent intéressant 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 les faits, les préférences et l’historique des interactions qui persistent entre les threads.

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

Le chemin de récupération de la cold memory : embedder la requête, rechercher dans Qdrant avec un filtre utilisateur, rescoring, injectionLe chemin de récupération 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 retrouver 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 sur des clés exactes, l’agent interroge le système par le sens.

Qdrant est une base vectorielle conçue spécifiquement pour cet usage et écrite en Rust. Elle 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 serveur MCP qui agit comme une couche de mémoire sémantique — utile si votre framework d’agents prend en charge le Model Context Protocol.

La conception Qdrant suivante est un exemple illustratif 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 sur user_id et un vecteur nul comme placeholder. L’intégration réelle des embeddings reste à venir. Le request handler doit authentifier la requête et construire principal à partir de l’identité vérifiée ; le client ne le fournit jamais. Le filtre Qdrant définit le scope de récupération, pas l’autorisation.

from qdrant_client import QdrantClient
from qdrant_client.models import (
    PointStruct, Distance, VectorParams, Filter, FieldCondition, MatchValue,
)
import hashlib
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."""
        point_id = hashlib.md5(f"{principal.user_id}:{fact}".encode()).hexdigest()
        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]

Le flux comporte trois étapes. Dans cette conception illustrative, un LLM extrait les faits importants de l’interaction (« l’utilisateur a une forte tolérance au risque », « l’utilisateur s’intéresse aux actions de sociétés de semi-conducteurs »). Ces faits sont transformés en embeddings et 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 récupération : au-delà de la similarité cosinus

La similarité cosinus brute constitue un point de départ, mais les systèmes de mémoire de production nécessitent une récupération 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 avant un fait équivalent datant de six mois.
  • Importance : importance évaluée par un LLM sur une échelle de 1 à 10. « Le portefeuille de l’utilisateur a baissé de 40 % » obtient un score supérieur à « l’utilisateur a dit bonjour ».
  • Pertinence : similarité cosinus des embeddings entre la requête et le fait stocké.

Le score final est une somme pondérée : score = alpha * recency + beta * importance + gamma * relevance. Cela évite que des faits récents et importants soient enfouis sous des faits obsolètes mais sémantiquement similaires. Pour un agent comme le Market Analyst Agent, je commencerais par alpha = 0.3 pour la récence, beta = 0.2 pour l’importance et gamma = 0.5 pour la pertinence, puisque l’intention de la requête courante de l’utilisateur est la plus importante. Ces poids sont des points de départ adaptés de l’article Generative Agents (qui utilisait une pondération égale) ; j’ai constaté que privilégier la pertinence fonctionnait mieux pour les requêtes d’analyse financière, mais ces valeurs reposent sur l’intuition et ne sont pas optimisées empiriquement.

Alternatives à la recherche vectorielle

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

ApprocheAdaptée àPrincipal 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 de la mémoire et politique de persistance
Document store (fichiers)Connaissances du 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 (vecteur + 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 fondé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êtes SQL complètes
  • AsyncRedisStore — mémoire inter-threads avec recherche vectorielle, prise en charge du TTL et filtrage sur les 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 d’une récupération sémantique sur des faits non structurés ou lorsque la formulation de la requête varie de manière imprévisible.

Les knowledge graphs deviennent pertinents lorsque les relations entre les entités sont importantes, par exemple : « Quelles entreprises l’utilisateur a-t-il mentionnées qui 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. Chaque arête porte des intervalles de validité ; ainsi, un changement de tolérance au risque de l’utilisateur invalide l’ancienne valeur au lieu de l’écraser silencieusement. Graphiti annonce une précision de 94,8 % sur le benchmark DMR — Deep Memory Retrieval, un test de rappel sur de longues conversations — et son modèle bi-temporel traite le problème des mémoires obsolètes au niveau des données.

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 sur les métadonnées couvre le même besoin avec moins d’infrastructure.

Les managed memory frameworks comme Mem0 et Letta (anciennement MemGPT) prennent en charge pour vous le pipeline d’extraction, de consolidation et de récupération. 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 la cohérence du store mémoire et évite les redondances. 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, en déplaçant de manière autonome les données entre la « core memory » (in-context) et la « archival memory » (out-of-context). Ces deux solutions méritent d’être évaluées si vous souhaitez réduire le time-to-production sans avoir besoin d’un contrôle total 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. Il existe une troisième catégorie de connaissances d’agent qu’ils ne servent pas correctement : le contexte de projet accumulé, c’est-à-dire les conventions, notes de recherche et décisions dont l’agent a besoin entre les sessions. Ces connaissances gagnent à être lisibles par les humains et contrôlées par version.

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.

Cette approche est davantage adoptée par les produits qu’elle n’est couverte par les taxonomies de la mémoire. Dans une évaluation menée par un fournisseur, Letta a annoncé une précision de 74,0 % sur LoCoMo — un benchmark de questions-réponses sur de longues conversations — pour un agent reposant sur un filesystem et exécuté avec GPT-4o mini, contre 68,5 % pour la meilleure variante graph de Mem0. Il s’agit d’un fournisseur, d’un modèle, d’un benchmark et d’un harness : interprétez ce résultat comme un signe que l’approche est compétitive, et non comme un classement. L’avantage opérationnel ne dépend pas du benchmark : les développeurs peuvent lire, modifier et comparer directement les connaissances stockées.

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

Pourquoi des fichiers ?

Pour les workflows d’agents de longue durée, le pattern le plus efficace que j’ai observé n’est pas une base vectorielle. C’est un répertoire de notes bien organisé. Voyez ce qui se produit lorsqu’un agent de programmation travaille sur un projet pendant plusieurs semaines :

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

Ces faits sont trop structurés pour la recherche vectorielle (il faut un rappel exact, et non une similarité floue) et trop interconnectés pour un key-value store : ils se lisent comme des documents qui se référencent entre eux, et non comme des valeurs isolées récupérées par clé. Ce sont aussi des faits que le développeur veut pouvoir consulter et modifier directement. Si l’agent apprend quelque chose d’erroné, il suffit d’ouvrir le fichier et de le corriger.

C’est ainsi que fonctionnent CLAUDE.md de Claude Code et le répertoire .claude/. L’agent lit les fichiers CLAUDE.md au niveau du projet pour connaître les conventions et les instructions, et conserve un fichier d’auto-memory distinct par projet — sous ~/.claude/projects/<project-slug>/memory/ — pour les apprentissages intersessions. Les deux sont de simples fichiers Markdown : vous pouvez les lire, les modifier et committer ceux du projet dans git, puis les partager avec votre équipe. Les project rules de Cursor et les rules et memories de Devin Desktop suivent le même pattern. Cursor lit les fichiers .mdc depuis .cursor/rules ; Devin Desktop (anciennement Windsurf) lit .windsurf/rules/ et honore toujours l’ancien fichier unique .windsurfrules. Dans tous les cas, il s’agit de texte brut sur disque que l’agent charge au démarrage pour récupérer le contexte du projet.

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 les documents.

Le file store raw-Markdown suivant est un exemple illustratif 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 une autre conception pour illustrer les 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.

        NOTE: This is a sketch for demonstration. A simple substring check
        won't scale beyond a few hundred documents. For production with 500+
        documents, use TF-IDF/BM25 scoring (e.g., rank_bm25) or a full-text
        search backend (PostgreSQL GIN index, Elasticsearch).
        """
        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 helper de chemin 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 par l’intermédiaire d’un symlink existant. Cette classe illustrative est destinée à un filesystem de confiance, utilisé par un seul utilisateur ou dans un environnement contrôlé. Elle vérifie un chemin résolu avant utilisation ; à une frontière multi-tenant hostile, utilisez des opérations relatives aux descripteurs et sans suivi de symlink, 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

Une grande partie 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 plutôt qu’en Markdown brut. L’organisation raw-Markdown ci-dessous appartient à la conception illustrative indépendante FileMemory présentée 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 la conception illustrative FileMemory, chaque document est au format Markdown et l’objectif de chaque document est évident d’après son chemin. Vous pouvez git diff l’ensemble du répertoire mémoire pour voir ce que l’agent a appris pendant une session, git revert un apprentissage erroné ou copier le répertoire vers un autre projet. Les enveloppes JSON du projet actuel conservent la structure namespace/clé, mais n’offrent pas la même expérience de diff en Markdown brut.

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

Les trois backends mémoire répondent à des modes 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 »
Adapté àRappel non structuré et variableRecherches structuréesContexte et notes de projet
Lisibilité humaineNon (embeddings)Partielle (JSON)Oui (Markdown)
DébogabilitéDifficile (scores de similarité)Facile (clés exactes)Triviale (ouvrir le fichier)
Contrôle de versionNonPossibleOui (git-native)
Infrastructure d’embeddingNécessaireNon requiseNon requise
Passe à l’échelle jusqu’àDes millions de faitsDes millions de clésDes milliers de documents
Capacité de rechercheSimilarité sémantiqueCorrespondance exactePar mots-clés / par chemin

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 en documents (notes, synthèses, conventions), et non en faits isolés
  • Vous voulez 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 récupération sémantique floue (« trouver les mémoires associées à X »)
  • La formulation de la requête varie de manière imprévisible
  • Vous avez des 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 de données est bien défini

En pratique, les agents de production combinent souvent les trois. 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 d’enveloppes JSON organisé par namespace. Les variantes de rappel sémantique et de Markdown brut présentées dans cet article sont des extensions illustratives.

Exemples réels

Le pattern est déjà largement 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 gère un fichier mémoire par projet sous ~/.claude/projects/ pour les apprentissages intersessions. Le système mémoire repose sur de simples fichiers Markdown, et ceux du niveau projet sont commités avec votre code.
  • Cursor charge les règles du projet depuis .cursor/rules sous forme de fichiers .mdc — conventions de code, préférences de framework, décisions d’architecture — avec un frontmatter qui contrôle le moment où chaque règle s’applique.
  • Devin Desktop (anciennement Windsurf) lit les règles depuis .windsurf/rules/, honore toujours l’ancien fichier .windsurfrules à la racine et écrit les memories générées automatiquement dans un store local que l’agent consulte lors des exécutions ultérieures.
  • L’Anthropic memory tool pour l’API Claude est un tool côté client que le modèle pilote au moyen d’opérations sur des fichiers — view, create, str_replace, insert, delete et rename — dans un répertoire /memories. Votre application implémente chaque commande et décide donc de l’emplacement réel des fichiers (disque local, S3 ou base de données).

Tous ces systèmes stockent les connaissances de l’agent sous forme de fichiers texte lisibles par les humains, avec des opérations explicites de lecture et d’écriture ; aucun n’a besoin d’un pipeline d’embedding. L’agent décide quoi écrire, le développeur peut tout voir et tout modifier, et l’ensemble du système tient dans un git diff.

Au-delà des assistants de programmation

La document memory ne se limite pas aux agents de programmation. Ce pattern apparaît également dans d’autres domaines d’agents :

  • Agents de jeu en monde ouvert : Voyager (Wang et al., 2023) construit une bibliothèque persistante de compétences sous forme de programmes JavaScript vérifiés qu’un agent Minecraft accumule au fil du temps. Il collecte 3,3 fois plus d’objets uniques et atteint les jalons 15,3 fois plus vite que les baselines. Les compétences sont transférées vers de nouveaux mondes sans réentraînement. JARVIS-1 étend cette approche avec une mémoire multimodale combinant plans textuels et observations visuelles, et s’avère cinq fois plus fiable que les meilleurs agents précédents sur la tâche ObtainDiamondPickaxe à long horizon.

    Une distinction mérite d’être faite : les skill libraries sont une mémoire exécutable (des fichiers de code importés et exécutés), tandis que la document memory des assistants de programmation est déclarative (du Markdown injecté dans les prompts). Les modes d’échec diffèrent. Un mauvais code exécutable fait crasher l’agent ; un texte déclaratif erroné provoque des erreurs de raisonnement. Mais le pattern de stockage et les avantages opérationnels (débogabilité, contrôle de version) sont les mêmes.

  • Automatisation de workflows d’entreprise : l’ERC3 competition — la troisième Enterprise RAG Challenge — a vu ses équipes gagnantes utiliser la document memory pour affiner itérativement leurs prompts. Une équipe gagnante a fait itérer ses agents Analyzer et Versioner sur 80 versions de prompt stockées sous forme de documents procéduraux. Une autre équipe de premier plan a construit plus de 20 modules d’enrichissement sous forme de connaissances procédurales documentaires. LEGOMem (2025) formalise cette approche pour les systèmes multi-agent sous le nom de modular procedural memory : les trajectoires de tâches passées sont décomposées en unités mémoire réutilisables, ensuite placées soit auprès de l’orchestrateur qui planifie et délègue, soit auprès des agents qui exécutent les étapes. Sur le benchmark OfficeBench, la mémoire de l’orchestrateur s’est révélée déterminante pour la décomposition des tâches, tandis qu’une mémoire d’agent plus fine améliorait la précision d’exécution.

  • Automatisation web : Agent Workflow Memory (Wang et al., 2024) permet à des agents web d’induire des workflows réutilisables à partir d’épisodes réussis, avec une amélioration relative de 51,1 % du taux de réussite sur WebArena. SkillWeaver (2025) va plus loin : les agents synthétisent des tools API réutilisables à partir de leur exploration, avec un gain relatif de 31,8 % du taux de réussite. Les compétences apprises se transfèrent également à des modèles plus faibles (jusqu’à 54,3 % d’amélioration relative), de sorte que la mémoire accumulée par un agent plus puissant peut améliorer un agent plus petit.

  • Support client : Gartner prévoit que l’agentic AI résoudra de manière autonome 80 % des problèmes courants de support client sans intervention humaine d’ici 2029. Ces agents s’appuient sur des SOP, des playbooks et l’historique client, qui sont autant de formes de document memory.

Le workshop MemAgents à l’ICLR 2026 montre notamment que la communauté de recherche commence à rattraper ce que les praticiens ont déjà construit.

Les skills utilisent des documents pour encapsuler des instructions procédurales. Le standard Agent Skills stocke ces instructions dans des fichiers SKILL.md avec un frontmatter YAML et un corps Markdown. Cela ressemble à la document memory au niveau du stockage, mais le rôle diffère : un skill indique à l’agent comment réaliser une classe de tâches, tandis que la mémoire consigne les faits appris d’un projet ou d’une exécution précédente. La Partie 3 trace la frontière voisine entre un skill et un tool.

MCP (Model Context Protocol) propose une interface procédurale apparentée : tools/list renvoie des objets tool dont inputSchema est un JSON Schema, et un agent en invoque un avec tools/call. La découverte n’autorise pas un appel. Avant d’invoquer un tool produisant des effets ou manipulant des données privées, l’hôte doit implémenter l’authentification, l’autorisation et le consentement explicite de l’utilisateur ; le serveur doit également appliquer ses propres contrôles d’accès. MCP ne peut pas imposer ces contrôles au niveau du protocole. Une revue de décembre 2025 consacrée à la première année de MCP évaluait à 97 millions par mois les téléchargements des SDK Python et TypeScript, avec une adoption par OpenAI, Google DeepMind et Microsoft. MCP n’est pas spécifique au code. Ces mêmes serveurs connectent les agents à des bases de données, des APIs internes et des systèmes d’entreprise.

Les deux approches rendent les interfaces procédurales inspectables : les skills stockent les instructions dans des documents, tandis que MCP expose des tool schemas et des calls lisibles par machine. MCP, désormais gouverné par l’Agentic AI Foundation, est ce qui se rapproche le plus d’un standard d’interopérabilité dans l’écosystème des agents.

Faire passer la document memory en production

L’implémentation basée sur des fichiers présentée plus haut fonctionne bien sur les ordinateurs portables de développeurs seuls et dans les petits déploiements. Une production multi-tenant comptant des centaines d’utilisateurs et des milliers de documents nécessite une architecture différente.

La limite d’un filesystem sur un seul nœud devient vite évidente : les I/O fichiers ne peuvent pas être mises à l’échelle horizontalement, les écritures concurrentes nécessitent des verrous et la gestion des permissions entre tenants est pénible. La production a besoin d’un backing store qui gère correctement la concurrence, la recherche et le multi-tenancy.

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 depuis une base de données au runtime. Lors du déploiement, synchronisez les fichiers vers des lignes PostgreSQL. L’agent lit la base de données, et non le disque. Vous obtenez ainsi :

  • Une bonne ergonomie développeur (modifier du Markdown, committer dans git)
  • De bonnes performances de requête en production (lectures sur base indexée)
  • Une séparation claire 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 identifiants 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 : structured document store 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 bénéficiez d’une recherche hybride (mots-clés + sémantique), de transactions ACID et d’un système opérationnel unique.

Voici un sketch de l’approche C. 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’identifiant de tenant fourni par l’appelant. PostgreSQL RLS rend ensuite ce scope applicable, 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);
        CREATE INDEX ON documents USING ivfflat(embedding vector_cosine_ops);

        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 * (1 - (embedding <=> $2))) 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 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 le système hybride. Avec uniquement le prédicat @@, un document sémantiquement pertinent mais ne partageant aucun mot-clé avec la requête serait filtré avant même le calcul du score : il s’agirait d’une récupération par mots-clés avec semantic reranking, et non d’une récupération hybride. Le seuil de distance est un paramètre : réduisez-le si la branche vectorielle sature les résultats, augmentez-le si les correspondances sémantiques n’apparaissent jamais.

La régression suivante décrit le comportement à tester sur une base réelle après les migrations. Avec tenant-a, une lecture de tenant-b ne renvoie aucune ligne et une insertion inter-tenant directe échoue à cause de 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;

Résultat :

  • Recherche hybride : correspondance par mots-clés (index GIN) + similarité sémantique (pgvector), combinées dans le scoring
  • Multi-tenancy : identité dérivée du serveur et RLS appliqué par la base de données
  • Garanties ACID : aucun problème de cohérence éventuelle
  • Système opérationnel unique : aucune base vectorielle distincte à gérer
  • Passage à l’échelle horizontal : read replicas pour la charge de requête, partitionnement par tenant pour la capacité d’écriture

Les fichiers sont excellents pour les workflows d’un développeur seul. Pour une production multi-tenant, un structured document store sur PostgreSQL offre généralement le meilleur équilibre 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 montre un flux illustratif allant de la requête utilisateur à la réponse, avec toutes les couches mémoire actives.

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

L’architecture comporte trois chemins mémoire :

  1. Hot path (checkpoint store) : LangGraph écrit l’état du graphe permettant la reprise 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 et, à son retour, le graphe reprend depuis le checkpoint. Les event logs et les traces du runtime sont des préoccupations de production distinctes.

  2. Cold path (long-term store) : dans cette architecture illustrative, l’agent interroge le long-term store au début de chaque conversation afin de récupérer le contexte utilisateur pertinent. Cette lecture se trouve sur le chemin critique : le planner ne peut pas personnaliser son comportement avant son retour. Une approche fondée sur les vecteurs peut inclure l’embedding de la requête et la récupération dans l’index ; un lookup key-value, non. L’écriture n’est pas critique : une fois la conversation terminée, un job en arrière-plan extrait et stocke les nouveaux faits, et ce job ne doit jamais bloquer la reasoning loop.

  3. Document path (file store) : au démarrage, l’agent charge les conventions du projet et les notes de recherche pertinentes depuis le document store. Pendant l’exécution, il écrit sur disque les nouvelles synthèses de recherche et les patterns appris. Ces lectures sont également sur le chemin critique, car elles influencent la tâche courante ; leur coût dépend du filesystem, de la taille des fichiers et de l’état du cache. Les écritures peuvent être différées.

Le câblage dans LangGraph est direct : le checkpoint store et le long-term store sont transmis lors de la compilation du graphe, tandis que le document store est injecté comme dépendance. Le sketch local ci-dessous utilise InMemoryStore afin de conserver un snippet court ; 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:
        graph = create_graph(
            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

Voici ce qui se produit lorsqu’un utilisateur qui revient envoie « Analyze TSLA » au Market Analyst Agent :

  1. Chargement de la document memory : au démarrage, l’agent lit dans le document store les conventions du projet : préférences de format d’analyse, sources de données préférées et patterns d’utilisation des tools. Elles définissent le comportement de référence.

  2. Rappel de la cold memory : dans ce flux illustratif, avant l’exécution du nœud router, le graphe interroge le long-term store avec le message 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 » et « L’utilisateur a déjà étudié NVDA et AMD ».

  3. Router + Planner : le router classe la requête comme DEEP_RESEARCH. Le planner crée un plan de recherche en cinq étapes, personnalisé selon les préférences récupérées. Il inclut une étape d’analyse concurrentielle, car l’historique de l’utilisateur montre qu’il en souhaite une. Le plan respecte le format défini dans le document de conventions.

  4. Executor loop (hot memory) : chaque étape s’exécute selon le pattern ReAct de la Partie 1 — think, act, observe, répétés jusqu’à la fin de l’étape. Après chaque super-step (router, planner, puis chaque étape de l’executor exécutée séquentiellement ici), LangGraph écrit un checkpoint dans PostgreSQL. Si le processus crashe après l’étape 3 sur 5, redémarrez-le et poursuivez à partir de l’étape 4.

  5. Interruption HITL : le reporter rédige un brouillon, un evaluator avec un contexte vierge — une seconde session de modèle sans historique de l’exécution — vote, puis le graphe atteint le nœud publish avec interrupt_before et se met en pause. Le checkpoint contient le brouillon ainsi que l’avis de l’evaluator ; l’humain examine donc les deux au lieu d’arbitrer la recherche brute. Il le consulte quelques heures plus tard, puis le graphe charge le checkpoint et publie le rapport.

  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 proprement les responsabilités. Le checkpoint store gère la durabilité et la reprise : c’est de l’infrastructure. Le long-term store gère la personnalisation : c’est de la logique produit. Le document store conserve les connaissances de projet accumulées : c’est le carnet de notes de l’agent.


Compromis et points d’attention

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

  • Coût des embeddings : chaque fait stocké dans une base vectorielle nécessite un appel à une API d’embedding. En septembre 2026, OpenAI facture text-embedding-3-small $0,02 par million de tokens, le coût par fait est donc négligeable, mais il s’accumule sur des milliers d’utilisateurs et de sessions. Regroupez les appels d’embedding et mettez les résultats en cache. Au moment de la requête, le rappel vectoriel peut inclure l’embedding de la requête ainsi que la latence de l’index et du réseau ; ce n’est pas le cas d’un lookup key-value. Mesurez ce chemin dans votre déploiement, puis mettez en cache les embeddings des requêtes fréquentes ou utilisez un modèle d’embedding local si la latence est critique.

  • Mémoire obsolète : les préférences utilisateur évoluent. Un fait stocké il y a six mois (« l’utilisateur préfère les investissements prudents ») peut ne plus être exact. Définissez des politiques d’expiration. Dans l’une de mes conceptions, j’utilise à titre d’exemples provisoires 365 jours pour les préférences et 90 jours pour les événements épisodiques ; ce ne sont pas des valeurs par défaut universelles. L’article sur le context engineering rejette les règles de rétention fixes comme politique portable. L’expiration est la version rudimentaire. Le schema-guided typed state traite la version plus précise : validité temporelle et provenance pour chaque fait, afin qu’une valeur remplacée perde face à la valeur courante lors de la récupération, plutôt qu’au moment de l’expiration.

  • Overhead 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é au score de pertinence.

  • Confidentialité et conformité : la mémoire long terme stocke des données utilisateur. Vous avez besoin de redacter les PII avant stockage, de politiques 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 les checkpoints ancêtres ainsi que leurs enregistrements de writes/blobs pour reconstruire un checkpoint conservé. Utilisez uniquement une API de pruning prise en charge par le saver, après l’avoir vérifiée avec la version exacte du saver installé et son contrat de récupération des delta channels. Si cette prise en charge est absente, 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 : au fil du temps, les mémoires épisodiques détaillées doivent ê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 intégralement les trois conversations. Cela rappelle la consolidation de la mémoire humaine et maintient le store à une taille raisonnable. Mem0 et Graphiti gèrent automatiquement cette opération ; si vous construisez votre propre système, planifiez des jobs de consolidation périodiques.

  • Cold start problem : les nouveaux utilisateurs ne disposent d’aucune mémoire long terme. L’agent doit se dégrader proprement et poser des questions de clarification au lieu de formuler des hypothèses. La mémoire est additive, pas obligatoire.

  • Empoisonnement de la mémoire : tout ce qui se trouve dans la context window de l’agent peut servir de 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 véritable surface d’attaque. 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 dispose d’aucune déduplication ni résolution automatique des conflits. Avec le temps, les documents accumulent des contradictions : un fichier indique « utiliser pytest », tandis qu’un autre indique « utiliser unittest ». Planifiez des revues périodiques (ou laissez l’agent les effectuer) pour élaguer et consolider le contenu. Dans un vector store, l’obsolescence reste cachée ; dans un répertoire de fichiers, vous pouvez grep les contradictions.

  • La document memory ne passe pas à l’échelle jusqu’à des millions d’éléments : la mémoire basée sur des fichiers convient à quelques centaines ou quelques milliers de documents. Si votre agent doit effectuer des rappels flous parmi des millions de faits, vous avez besoin d’un vector store. La document memory sert aux connaissances de projet structurées, et non à la longue traîne de chaque interaction utilisateur.


Points clés

  1. La mémoire d’un agent se compose de plusieurs stores aux modes d’accès différents. Séparez les checkpoints permettant la reprise, les faits structurés, le rappel sémantique et les documents du projet.
  2. Implémentez la mise en pause et la reprise avant la personnalisation. La perte de la progression d’une tâche est le premier échec mémoire révélé par un agent de longue durée.
  3. Placez les faits déterministes dans un stockage structuré. Utilisez la recherche vectorielle lorsque la requête est floue et que sa formulation varie.
  4. Utilisez des fichiers pour les connaissances de projet que les humains doivent inspecter, modifier, versionner ou examiner dans un diff.
  5. Donnez à chaque type de mémoire une règle d’expiration, de conflit et de suppression. Une mémoire que le système ne peut pas corriger devient une dette produit.
  6. Limitez ce qui est renvoyé au modèle. La mémoire stockée n’a de valeur que lorsque la récupération place les bons éléments de preuve dans le contexte courant.

La couche suivante est l’action

Les Parties 5 et 6 reviennent sur la mémoire sous l’angle opérationnel, chacune couvrant une moitié différente. 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 : ce que signifie le travail et ce qu’il reste à faire, consigné sous forme de document memory pour la prochaine session du modèle — une séquence continue de contexte modèle, selon la terminologie précisée par la Partie 5. Restaurer le processus ne revient pas à restaurer la tâche.

Références

Articles scientifiques

Documentation LangGraph

Backends de checkpoints

Bases vectorielles et tools mémoire

  • 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 par similarité vectorielle pour PostgreSQL
  • Graphiti — Moteur open source de knowledge graph temporel par Zep

Mémoire documentaire et basée sur des fichiers

  • Claude Code Memory — CLAUDE.md et le répertoire mémoire par projet
  • Anthropic Memory Tool — Mémoire côté client basée sur des fichiers 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 basées sur des fichiers et memories générées automatiquement (anciennement Windsurf)

Frameworks de mémoire

  • Mem0 — Couche mémoire gérée avec pipeline d’extraction et de consolidation
  • Letta (MemGPT) — Gestion virtuelle du contexte inspirée des systèmes d’exploitation
  • LangMem SDK — Tools de gestion de la 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