Mémoire des AI agents : état guidé par schéma et provenance

Traduction automatique

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

Cet article s’adresse aux ingénieurs qui construisent des agents de longue durée ou multi-tenant. La mémoire sémantique simple n’a aucune règle pour dire quelle valeur d’un fait est la valeur courante, donc les agents renvoient des faits périmés. Quand un utilisateur modifie l’échéance de son passeport du 15 juillet au 30 juin, la recherche vectorielle peut retrouver les deux énoncés. Une couche de mémoire à état doit enregistrer que la valeur du 30 juin remplace la précédente.

Vous apprendrez à stocker la mémoire sous forme d’enregistrements typés avec fenêtres de validité, périmètre de tenant et liens vers les sources, à écrire une correction sans perdre l’historique, et à tester les lectures de l’état courant et à un instant donné.


Pourquoi le contexte et le rappel vectoriel renvoient des faits périmés

La fenêtre de contexte est l’entrée disponible pour un appel de modèle. Une application peut reporter des messages dans les appels suivants, mais c’est la politique de l’application qui doit décider quels anciens faits restent vrais et qui peut les voir.

Les agents de longue durée doivent mémoriser des préférences, l’état des tâches, des faits sur les clients, des décisions sur les outils, des notes de conformité et des erreurs passées. La version facile consiste à ajouter des résumés ou à déverser les anciennes notes dans un magasin vectoriel. Cela fonctionne jusqu’à ce qu’un des faits mémorisés change.

L’agent a alors deux échéances de passeport, deux formats préférés ou deux décisions de projet. La recherche sémantique peut retrouver les deux. Un résumé peut en écraser un. Un contexte long peut inclure le fait périmé à côté du fait actif. Ces conceptions renvoient du texte, mais ne peuvent pas dire quelle valeur est courante.

Le contrat de mémoire doit répondre à des questions concrètes :

  • Qu’est-ce qui est vrai maintenant ?
  • Qu’est-ce qui était vrai le 2 juin ?
  • Qui l’a dit ?
  • À quel tenant appartient-il ?
  • Quel fait plus ancien ce fait a-t-il remplacé ?
  • Puis-je le supprimer ou le faire expirer ?

Schema-Guided Agent Memory (SGAM) stocke ces réponses sous forme de champs et de relations, au lieu de les laisser implicites dans la prose.


Ce que signifie SGAM

Deux idées de recherche aux noms proches délimitent le périmètre de SGAM.

Schema-Guided Memory (SGM) est le terme de recherche utilisé par Mei et al. dans According to Me: Long-Term Personalized Referential Memory QA. L’article compare la Descriptive Memory (DM) en texte libre avec des éléments de mémoire clé-valeur à schéma fixe. Les deux représentations contiennent les mêmes informations sources dans des structures différentes. Un usage antérieur du nom, le Schema-Guided Dialogue dataset, suit l’état du dialogue pour des API de services et n’a aucun rapport avec la mémoire à long terme.

Dans cet article, j’utilise Schema-Guided Agent Memory (SGAM) pour désigner un pattern d’ingénierie dans lequel des schémas gouvernent les écritures, les mises à jour, la récupération et la suppression. Le schéma définit l’état applicatif et son cycle de vie.

ATM-Bench montre pourquoi la représentation compte. Il utilise environ quatre ans de données personnelles : e-mails, images et vidéos. Les questions exigent des références personnelles, un lieu, plusieurs éléments de preuve et des mises à jour dans le temps. SGM expose des champs tels que l’heure, la source, le lieu, les entités et les tags dans une représentation fixe. Sur le split difficile de 25 questions, le tableau 3 de l’article indique que le score global (QS) passe de 25,6 avec DM à 47,3 avec SGM lorsque le modèle de réponse reçoit les preuves de référence. Avec la récupération du système ATM-RAG des auteurs, le score du split difficile ne passe que de 14,1 à 16,3 : trouver les preuves reste donc le principal écart.

Le graphique place côte à côte les quatre scores du hard split. Comparez le gain apporté par les champs fixes avec la preuve de référence et avec la récupération.

Scores du hard split d’ATM-Bench : avec la preuve de référence, DM 25,6 et SGM 47,3 ; avec la récupération d’ATM-RAG, DM 14,1 et SGM 16,3Scores du hard split d’ATM-Bench : avec la preuve de référence, DM 25,6 et SGM 47,3 ; avec la récupération d’ATM-RAG, DM 14,1 et SGM 16,3

SGM contre DM répond à une question de stockage : la mémoire doit-elle rester en texte libre, ou utiliser des champs nommés ? Un agent en production a un autre problème avant le stockage. Il doit transformer une conversation non structurée en une proposition de mise à jour de la mémoire. La section suivante montre comment Schema-Guided Reasoning (SGR) façonne cet appel de modèle et où SGAM prend le relais.


Séparer l’extraction par le modèle de la responsabilité de la mémoire

L’écriture en mémoire traverse trois couches. Structured Output (SO) impose la forme de l’objet candidat. Schema-Guided Reasoning (SGR) rend les champs prévus par le modèle et l’intention de sa décision inspectables dans une réponse structurée. Schema-Guided Agent Memory (SGAM) gère le candidat comme un état durable après l’appel au modèle. Des appels distincts, des validateurs et la politique de l’application imposent les dépendances, l’ordre et les règles de cycle de vie.

Des articles précédents appliquent SGR aux juges structurés, aux planificateurs et routeurs et aux grilles d’évaluation de RAG. Dans chacun, un verdict, une route ou un plan expire en général avec la requête en cours. Une autre exécution peut lire un candidat de mémoire plusieurs jours plus tard, ou s’en servir pour choisir un tool call. Cette durée de vie plus longue exige des règles de stockage que SGR ne fournit pas.

SGR dispose les champs d’une réponse du modèle dans l’ordre où le modèle doit les remplir. Pour une écriture en mémoire, la réponse peut inclure la preuve source, un sujet et un attribut normalisés, une comparaison avec l’état courant et une mise à jour proposée. Pydantic ou JSON Schema décrit ces champs. Le Structured Output natif du fournisseur ou un runtime de décodage guidé comme XGrammar maintient la réponse dans cette forme. La forme ne peut pas garantir une conclusion correcte ni prouver que le modèle a utilisé ces champs dans l’ordre.

SGAM décide de ce qui se passe une fois cet objet créé. Faut-il le stocker ? Remplace-t-il un fait plus ancien ? Quel tenant peut le voir ? Est-il courant ou historique ? Quel épisode source l’appuie ?

Le tableau présente la responsabilité et le mode de défaillance de chaque couche :

DimensionSOSGRSGAM
ObjectifRenvoyer un objet conforme à un schémaRendre inspectables les champs prévus et l’intention de la décisionGérer la mémoire durable après l’appel au modèle
PortéeUne réponse généréeUne réponse du modèle ; la politique inter-étapes peut couvrir plusieurs appelsEnregistrements utilisés entre les appels, les sessions et les exécutions
Rôle du schémaDéfinit les champs de sortie, les types et les valeurs permisesDécrit la preuve, les champs intermédiaires et la décision candidateDéfinit les enregistrements stockés, les relations et le cycle de vie
ApplicationLe constrained decoding bloque toute sortie invalide pour le schémaForme uniquement ; des appels distincts, des validateurs et la politique imposent les dépendances et l’ordreLa validation applicative, les contraintes de la base et les règles de conflit imposent le cycle de vie
Durée de vieL’appel en cours, sauf si l’application stocke l’objetLa trace de raisonnement est en général jetée après la décisionPersiste jusqu’à mise à jour, expiration ou suppression
Mode de défaillanceForme valide mais sens erronéLes champs sont présents, mais le raisonnement ou la gestion des dépendances peut rester erronéÉtat périmé, pollué, sans périmètre ou impossible à auditer

Le diagramme suit la correction de l’exemple ci-dessous jusqu’à une ligne stockée et montre qui définit chaque colonne : le modèle, le service d’écriture à partir de la requête, ou replace_fact.

La ligne stockée pour l’épisode e2 : l’appel au modèle définit subject, attribute, value et valid_from ; le service d’écriture ajoute tenant_id et source_episode_id à partir de la requête ; replace_fact définit valid_to et supersedes_fact_idLa ligne stockée pour l’épisode e2 : l’appel au modèle définit subject, attribute, value et valid_from ; le service d’écriture ajoute tenant_id et source_episode_id à partir de la requête ; replace_fact définit valid_to et supersedes_fact_id

Le schéma suivant définit l’objet transmis de l’extraction au service d’écriture SGAM. Il requiert Pydantic. L’application place la valeur actuellement stockée dans le prompt, afin que le modèle puisse la comparer à la nouvelle preuve :

from datetime import datetime
from typing import Literal

from pydantic import BaseModel, Field

class MemoryUpdate(BaseModel):
    evidence: str = Field(description="Exact quote from the episode")
    subject: str = Field(description="Normalized entity ID, e.g. mira")
    attribute: str = Field(description="Property being updated")
    current_value: str | None = Field(description="Stored value shown in the prompt")
    change: Literal["new", "update", "no_change"]
    value: str | None = Field(description="New value; None when change is no_change")
    valid_from: datetime = Field(description="When the new value starts to apply")

Le service d’écriture écarte les résultats no_change, ajoute tenant_id et source_episode_id à partir de la requête, et transmet le reste à replace_fact dans l’exemple ci-dessous. Le modèle ne choisit jamais le tenant ni l’épisode source.


Les chemins d’écriture et de lecture ont des rôles différents

Seul le chemin d’écriture modifie l’état stocké. Le chemin de lecture sélectionne les enregistrements pour la requête en cours.

Le flux d’ingestion est le chemin d’écriture :

  1. Capturez un épisode brut à partir de messages, de tool results ou d’événements métier. Enregistrez son tenant et sa classe de rétention.
  2. Extrayez des candidats typés avec du structured output.
  3. Validez le payload avec Pydantic ou la couche de schéma que votre stack utilise déjà, et rejetez les écritures mal formées.
  4. Résolvez les conflits avant l’insertion : décidez si le nouvel enregistrement remplace un ancien, et clôturez le fait périmé.
  5. Conservez un pointeur de source vers l’épisode brut, le tool result, le fichier, le ticket ou la confirmation de l’utilisateur qui a produit l’enregistrement.
  6. Enregistrez durablement l’enregistrement dans le store SGAM avec son schema_version, au lieu de laisser la version seulement dans le code de l’application.

Le flux de requête est le chemin de lecture :

  1. Partez de la question de l’utilisateur.
  2. Décidez si la question demande l’état courant ou l’état à un instant donné.
  3. Filtrez par tenant, type de mémoire, sujet, attribut et fenêtre de validité.
  4. N’ajoutez une expansion vectorielle ou par graphe que si la recherche exacte de l’état ne suffit pas. Utilisez-la pour les preuves complémentaires, les entités liées et les exemples, pas comme source d’autorité pour les faits courants.
  5. Assemblez le plus petit contexte cité pour le modèle.

Le chemin d’écriture de l’épisode e2 ferme la ligne e1 et insère la ligne e2 dans memory_facts ; une lecture de l’état courant des mêmes lignes renvoie 2026-06-30 et une lecture au 2 juin renvoie 2026-07-15Le chemin d’écriture de l’épisode e2 ferme la ligne e1 et insère la ligne e2 dans memory_facts ; une lecture de l’état courant des mêmes lignes renvoie 2026-06-30 et une lecture au 2 juin renvoie 2026-07-15

Lisez ce diagramme de haut en bas. Le chemin d’écriture ferme e1 et insère e2 dans un seul store. Le chemin de lecture choisit un filtre d’état courant ou à un instant donné, puis sélectionne des lignes dans ce même store.


Ce que contient un schéma de mémoire

Un enregistrement SGAM minimal demande plus que text. Chaque champ répond à l’une des questions de la première section ou remplit une fonction dans le cycle de vie :

ChampQuestion à laquelle il répond ou fonction qu’il remplit
tenant_idÀ quel tenant appartient-il ? Chaque lecture et chaque écriture filtrent dessus.
memory_idUn ID stable vers lequel pointent les autres enregistrements et les tâches de suppression.
subject, attributeLa clé : quelle entité et quelle propriété, par exemple mira et passport_deadline.
valueLa valeur stockée.
memory_typeLe type de mémoire : sémantique (faits stables et profils), épisodique (événements passés et traces) ou procédurale (instructions et règles de prompt), comme les décrit LangGraph.
schema_versionQuel schéma a écrit l’enregistrement, afin qu’une migration puisse retrouver et convertir les anciens enregistrements.
valid_from, valid_toQu’est-ce qui est vrai maintenant ? valid_from <= now et (valid_to est null ou now < valid_to). Qu’est-ce qui était vrai le 2 juin ? L’intervalle contient cet instant.
supersedes_memory_idQuel fait plus ancien ce fait a-t-il remplacé ?
source_episode_idQui l’a dit ? Il pointe vers l’épisode brut.
confidenceUn score que le service d’écriture compare à un seuil : promouvoir le candidat en état courant, ou demander à l’utilisateur de le confirmer.
retention_policyPuis-je le supprimer ou le faire expirer ? Elle indique aux tâches de suppression ce qu’elles doivent retirer d’autre.

Avec ces champs, une nouvelle échéance de passeport peut clôturer l’échéance précédente sans effacer l’historique. La même table peut répondre aux requêtes sur l’état courant et à un instant donné, puis remonter du résultat à son épisode source.

Utilisez RAG pour récupérer des documents et SGAM pour maintenir un état modifiable. La recherche vectorielle garde sa place dans le système pour le rappel approximatif, le clustering et l’expansion. La valeur courante de mira.passport_deadline doit provenir d’un enregistrement de mémoire délimité par tenant, et non du chunk qui s’est trouvé en tête du classement.


Un exemple de fait périmé

Prenons une trace synthétique de deux épisodes, représentée par une baseline DM et un registre SGAM :

e1: Mira prefers concise answers. Her passport deadline is 2026-07-15.
e2: Mira corrected the deadline. It is now 2026-06-30.

Une baseline DM garde les deux épisodes en texte libre : la recherche textuelle peut donc renvoyer e1, parce qu’il contient les bons mots. SGAM extrait un fait typé de chaque épisode, identifié par tenant, sujet et attribut. Il doit renvoyer e2 comme état courant et garder e1 pour une requête historique.

Le script ci-dessous répond à deux des questions précédentes : ce qui est vrai maintenant, et ce qui était vrai le 2 juin. Regardez replace_fact : il clôture l’ancienne ligne au lieu de la supprimer, et il enregistre quelle ligne la nouvelle remplace. L’appelant doit normaliser chaque horodatage stocké ou utilisé dans une requête dans un format de chaîne UTC unique, avec une précision fixe à la seconde : YYYY-MM-DDTHH:MM:SSZ. SQLite compare ces valeurs TEXT dans l’ordre lexicographique ; mélanger les décalages horaires ou les précisions peut donc placer les faits dans le mauvais ordre. Chaque ligne est valide à partir de valid_from jusqu’à valid_to exclu. La table rejette les intervalles invalides et possède un index unique partiel qui garantit un seul fait ouvert par tenant, sujet et attribut. replace_fact gère sa propre transaction et la démarre avec BEGIN IMMEDIATE avant de lire une ligne. Appelez-la sur une connexion sans transaction active. SQLite sérialise les écritures concurrentes, donc les appelants doivent relancer toute la transaction après une erreur de verrou ou d’unicité. Une correspondance de mots-clés tient lieu de recherche vectorielle à la fin. Tout classement qui ignore la correction peut renvoyer e1.

import sqlite3
from dataclasses import dataclass

@dataclass(frozen=True)
class MemoryFact:
    tenant_id: str
    subject: str
    attribute: str
    value: str
    valid_from: str
    source_episode_id: str

def open_db() -> sqlite3.Connection:
    db = sqlite3.connect(":memory:")
    db.row_factory = sqlite3.Row
    db.executescript(
        """
        create table memory_facts (
            fact_id integer primary key,
            tenant_id text not null,
            subject text not null,
            attribute text not null,
            value text not null,
            valid_from text not null,
            valid_to text,
            supersedes_fact_id integer references memory_facts (fact_id),
            source_episode_id text not null,
            check (valid_to is null or valid_from < valid_to)
        );
        create unique index one_open_fact
        on memory_facts (tenant_id, subject, attribute)
        where valid_to is null;
        """
    )
    return db

def replace_fact(db: sqlite3.Connection, fact: MemoryFact) -> None:
    with db:
        db.execute("BEGIN IMMEDIATE")
        exact = db.execute(
            """
            select fact_id
            from memory_facts
            where tenant_id = ? and subject = ? and attribute = ? and valid_from = ?
            """,
            (fact.tenant_id, fact.subject, fact.attribute, fact.valid_from),
        ).fetchone()
        if exact:
            raise ValueError("equal valid_from requires an application conflict policy")

        containing = db.execute(
            """
            select fact_id, valid_to
            from memory_facts
            where tenant_id = ?
              and subject = ?
              and attribute = ?
              and valid_from < ?
              and (valid_to is null or valid_to > ?)
            limit 1
            """,
            (
                fact.tenant_id,
                fact.subject,
                fact.attribute,
                fact.valid_from,
                fact.valid_from,
            ),
        ).fetchone()
        if containing:
            db.execute(
                "update memory_facts set valid_to = ? where fact_id = ?",
                (fact.valid_from, containing["fact_id"]),
            )
            successor_boundary = containing["valid_to"]
        else:
            successor = db.execute(
                """
                select valid_from
                from memory_facts
                where tenant_id = ? and subject = ? and attribute = ? and valid_from > ?
                order by valid_from
                limit 1
                """,
                (fact.tenant_id, fact.subject, fact.attribute, fact.valid_from),
            ).fetchone()
            successor_boundary = successor["valid_from"] if successor else None

        db.execute(
            """
            insert into memory_facts
                (tenant_id, subject, attribute, value, valid_from, valid_to,
                 supersedes_fact_id, source_episode_id)
            values (?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                fact.tenant_id,
                fact.subject,
                fact.attribute,
                fact.value,
                fact.valid_from,
                successor_boundary,
                containing["fact_id"] if containing else None,
                fact.source_episode_id,
            ),
        )

def fact_at(
    db: sqlite3.Connection, tenant_id: str, subject: str, attribute: str, timestamp: str
) -> sqlite3.Row | None:
    return db.execute(
        """
        select fact_id, value, valid_from, supersedes_fact_id, source_episode_id
        from memory_facts
        where tenant_id = ?
          and subject = ?
          and attribute = ?
          and valid_from <= ?
          and (valid_to is null or valid_to > ?)
        limit 1
        """,
        (tenant_id, subject, attribute, timestamp, timestamp),
    ).fetchone()

KEY = ("acme", "mira", "passport_deadline")

db = open_db()
replace_fact(db, MemoryFact(*KEY, "2026-07-15", "2026-06-01T09:00:00Z", "e1"))
replace_fact(db, MemoryFact(*KEY, "2026-06-30", "2026-06-03T10:00:00Z", "e2"))
current = fact_at(db, *KEY, "2026-06-04T00:00:00Z")
assert (current["value"], current["source_episode_id"]) == ("2026-06-30", "e2")

historical = fact_at(db, *KEY, "2026-06-02T00:00:00Z")
assert (historical["value"], historical["source_episode_id"]) == ("2026-07-15", "e1")
assert current["supersedes_fact_id"] == historical["fact_id"]

# A delayed extraction predates e1. It ends at e1's existing boundary,
# rather than closing e2, which remains current.
replace_fact(db, MemoryFact(*KEY, "2026-08-01", "2026-05-30T08:00:00Z", "e0"))
delayed = fact_at(db, *KEY, "2026-05-31T00:00:00Z")
assert (delayed["value"], delayed["source_episode_id"]) == ("2026-08-01", "e0")
current = fact_at(db, *KEY, "2026-06-04T00:00:00Z")
assert (current["value"], current["source_episode_id"]) == ("2026-06-30", "e2")
intervals = db.execute("select valid_from, valid_to from memory_facts").fetchall()
assert all(row["valid_to"] is None or row["valid_from"] < row["valid_to"] for row in intervals)

episodes = (
    "e1: Mira prefers concise answers. Her passport deadline is 2026-07-15.",
    "e2: Mira corrected the deadline. It is now 2026-06-30.",
)
naive_match = next(episode for episode in episodes if "passport deadline" in episode)
assert naive_match.startswith("e1:")

print("Naive text memory:")
print(f"  returned episode: {naive_match}")
print()
print("SGAM current state:")
print(f"  mira.passport_deadline = {current['value']}")
print(f"  valid_from={current['valid_from']}, source={current['source_episode_id']}")
print()
print("SGAM point-in-time state:")
print(f"  on 2026-06-02, mira.passport_deadline = {historical['value']}")

L’exécution du script affiche :

Naive text memory:
  returned episode: e1: Mira prefers concise answers. Her passport deadline is 2026-07-15.

SGAM current state:
  mira.passport_deadline = 2026-06-30
  valid_from=2026-06-03T10:00:00Z, source=e2

SGAM point-in-time state:
  on 2026-06-02, mira.passport_deadline = 2026-07-15

replace_fact rejette d’abord un valid_from identique : ce conflit demande une politique applicative, comme la priorité des sources ou une révision confirmée par l’utilisateur, plutôt qu’un écrasement silencieux. Sinon, la fonction cherche l’intervalle qui contient l’horodatage entrant. Si elle en trouve un, elle clôture ce prédécesseur à la borne entrante, donne à la ligne insérée l’ancienne fin du prédécesseur, et enregistre le prédécesseur dans supersedes_fact_id. Si l’horodatage précède tous les intervalles stockés, elle utilise le valid_from de l’intervalle suivant comme fin de la nouvelle ligne. Ainsi, un fait tardif antérieur à e1 devient [2026-05-30, 2026-06-01) et laisse intacts e1 et e2, qui reste courant. L’index unique partiel est la garde de la base pour une seule ligne ouverte. DM n’a pas d’étape de mise à jour équivalente : l’ancien texte peut donc encore passer devant la correction.

La chronologie montre les lignes après chaque appel à replace_fact dans le script, puis les trois lectures. Regardez l’étape 2, où e2 ferme e1, et l’étape 3, où le e0 tardif se termine là où e1 commence.

Lignes de mira.passport_deadline sur un axe de dates après chaque appel à replace_fact : e1 ouverte à partir du 1er juin ; puis e1 fermée au 3 juin et e2 ouverte ; puis e0 ajoutée du 30 mai au 1er juin ; les lectures au 31 mai, au 2 juin et au 4 juin renvoient e0, e1 et e2Lignes de mira.passport_deadline sur un axe de dates après chaque appel à replace_fact : e1 ouverte à partir du 1er juin ; puis e1 fermée au 3 juin et e2 ouverte ; puis e0 ajoutée du 30 mai au 1er juin ; les lectures au 31 mai, au 2 juin et au 4 juin renvoient e0, e1 et e2

En production, associez cette transaction à une extraction structurée sur le chemin d’écriture. La transaction de base de données met à jour la validité temporelle. Le modèle extrait un fait candidat, mais ne décide pas quelle ligne stockée reste courante.


Les choix de stockage suivent le pattern de récupération

Les projets emploient plusieurs noms pour des parties de ce pattern : memory stores, context graphs, profils, stores à long terme, graph RAG et agents à état.

Outil ou frameworkCouche de stockage principaleMécanisme d’état temporelMécanisme de schémaÀ utiliser quand
GraphitiNeo4j, FalkorDB et Amazon Neptune ; Kuzu est dépréciéIntervalles de validité des faits plus provenance par épisode sourceTypes d’entités et d’arêtes Pydantic, arêtes temporelles, provenanceVous avez besoin d’intervalles de validité et de parcours de graphe, et vous exploiterez la base de graphe
ZepContext Graph Engine propriétaireContext Graphs temporels managés qui conservent les faits changeantsTypes d’entités et d’arêtes par défaut et personnalisésVous voulez le même modèle de graphe temporel sous forme de service managé
LangGraph / LangMemStores LangGraph, stores adossés à PostgresHorodatages et champs gérés par l’application dans les enregistrements du storeStores JSON plus extraction de profils ou de collections avec PydanticVous utilisez déjà LangGraph et acceptez de gérer vous-même les horodatages et les règles de conflit
Mem0Stack managée, Valkey / Redis / backends vectoriels dans les installations OSSMises à jour de la mémoire ; la politique temporelle reste à la charge de l’applicationTypes de mémoire, catégories personnalisées, prompts d’extractionVous voulez des opérations managées d’ajout, de recherche, de mise à jour et de suppression, délimitées par utilisateur, agent, application ou exécution, et vous gérerez la politique temporelle
Letta / MemGPTÉtat d’agent et blocs de mémoire adossés à une base de donnéesBlocs modifiables sans intervalles de validité par champBlocs de mémoire étiquetés et modifiablesL’agent modifie lui-même ses blocs de mémoire, ou plusieurs agents ont besoin d’un bloc partagé que tous voient
CogneeGraphe plus backends vectoriels et relationnelsL’historique dépend de l’ontologie et du backend choisiExtraction et validation orientées ontologieVous validez l’extraction par rapport à une ontologie métier
LlamaIndex property graphStores de property graph plus magasins vectorielsLes champs de temps dépendent du schéma du graphe et du storeSchemaLLMPathExtractor avec entités et relations autoriséesVous extrayez un property graph à partir de documents ou de traces

LangGraph sépare les checkpoints de thread, qui reprennent une conversation, des stores inter-threads, qui contiennent des données que les threads suivants lisent. L’article Zep rapporte des gains sur Deep Memory Retrieval et LongMemEval, et l’article Mem0 en rapporte sur LoCoMo. Chaque éditeur a rédigé son propre article : lisez ces chiffres comme des résultats d’éditeur, pas comme des garanties pour votre charge de travail.

Commencez par le modèle de données. Si la recherche exacte de faits est l’opération principale, une table relationnelle avec des payloads JSON, des colonnes de validité, des index de tenant et un vector sidecar suffit en général. Ajoutez un graphe quand les requêtes du produit suivent des relations.


Construire le chemin d’écriture avant le graphe

Décidez d’abord ce que le produit a le droit de mémoriser. Le choix entre graphe et vecteurs vient ensuite.

Un agent de support peut mémoriser le niveau du compte, les cas ouverts et les préférences de contact durables. Il ne doit pas promouvoir en état de profil chaque remarque agacée. Un agent de code peut mémoriser les conventions du dépôt et les tâches non résolues. Il ne doit pas garder indéfiniment une note privée parce qu’elle a été récupérée une fois.

Le premier store SGAM peut être une table relationnelle avec une colonne JSON et quelques index, alimentée par le chemin d’écriture en six étapes ci-dessus. Un graphe devient utile quand le produit doit parcourir des relations comme client-compte, compte-politique, tâche-artefact ou projet-décision.

Hot path et écritures en arrière-plan

L’extraction immédiate vaut le coup quand le tour suivant dépend de la nouvelle mémoire. Si l’utilisateur dit « retiens que je préfère les réponses courtes », le système ne doit pas attendre une tâche nocturne pour changer de comportement.

La plupart des tours n’exigent pas d’écriture immédiate. Enregistrez l’épisode brut avec les métadonnées de tenant, de session et d’outil, puis laissez un worker en arrière-plan extraire les candidats plus tard. Avec une consolidation par récurrence, le worker met en tampon les signaux faibles et ne promeut un fait qu’après répétition d’une preuve similaire ou confirmation de l’utilisateur. Cela ajoute un décalage de fraîcheur. C’est acceptable pour « l’utilisateur demande souvent des exports CSV » et risqué pour « le client a changé son adresse de livraison ».

Traitez la migration de schéma comme un changement de produit, car elle modifie ce que l’agent peut rappeler, citer ou supprimer. Elle peut aussi changer les faits historiques considérés comme courants. Planifiez dans la même version les scripts de migration, les backfills, les fenêtres de double lecture et le comportement de suppression.


Quand SGAM justifie sa complexité

Utilisez SGAM quand les faits peuvent changer dans le temps :

  • préférences utilisateur qui peuvent être mises à jour ou révoquées
  • faits sur les clients ou les comptes soumis à des exigences d’audit
  • état des tâches pour les assistants de longue durée
  • mémoire de projet d’un agent de code
  • état partagé multi-agent
  • notes de conformité où la provenance compte
  • questions temporelles comme « que croyions-nous avant la migration ? »

SGAM est excessif quand la mémoire est éphémère, exploratoire ou peu coûteuse à recalculer. Si l’agent n’a besoin que de quelques tours de continuité, un checkpoint et un historique de messages tronqué suffisent. L’interrogation de documents statiques peut se contenter de RAG. Et si le domaine est si instable que le schéma change tous les jours, la mémoire typée ralentira l’équipe.

La grille place chaque conception selon deux questions : vos faits changent-ils, et vos requêtes suivent-elles des liens entre enregistrements ?

Grille deux par deux de conceptions de mémoire : historique et RAG, un graphe de documents, une table de validité marquée « commencer ici » et un graphe temporel avec Graphiti ou ZepGrille deux par deux de conceptions de mémoire : historique et RAG, un graphe de documents, une table de validité marquée « commencer ici » et un graphe temporel avec Graphiti ou Zep


Checklist d’évaluation

Évaluez le cycle de vie de la mémoire en plus de la réponse finale. Un système peut produire une réponse plausible après avoir écrit un mauvais fait, récupéré un fait périmé ou franchi une frontière de tenant.

J’utilise la même découpe étape par étape que dans mon article sur l’évaluation de RAG. Mesurez l’étape où une défaillance peut survenir plutôt que de limiter l’évaluation au texte généré. La discipline de traçage de l’article sur l’évaluation des agents s’applique aussi, car un bug de mémoire apparaît souvent dans l’historique d’exécution avant d’atteindre la réponse.

Je testerais SGAM par rejeu. Injectez une séquence fixe d’épisodes dans le service d’écriture de mémoire et inspectez le registre après chaque tour significatif. Posez ensuite des questions sur l’état courant et sur l’état à un instant donné au store obtenu.

CoucheDéfaillance recherchéeMesures
Extraction à l’écritureL’agent a manqué un fait, en a inventé un ou a produit une forme invalideTaux d’écritures valides pour le schéma, précision et rappel d’extraction, couverture des épisodes sources
Gestion des conflitsUn fait périmé est resté courant ou un ancien fait valide a été écraséExactitude du remplacement, taux de doublons, exactitude de l’invalidation des faits périmés
Isolation et politiqueLa mémoire a fuité entre utilisateurs ou a survécu à sa fenêtre de politiqueÉchecs d’isolation entre tenants, exactitude des suppressions, conformité de la rétention
Récupération à la lectureLe bon enregistrement existe mais le lecteur ne l’a pas récupéréExactitude de l’état courant, exactitude à un instant donné, recall@k sur les enregistrements de mémoire
Ancrage de la réponseLa réponse a utilisé la mémoire sans appui ou cité la mauvaise sourceAppui des affirmations par les épisodes sources, exactitude des citations, exactitude de la résolution des conflits
ExploitationLe chemin de mémoire est trop lent, trop périmé ou trop coûteuxLatence d’écriture p95, décalage de fraîcheur, latence de lecture, coût par requête

Des benchmarks comme LoCoMo, LongMemEval et ATM-Bench fournissent des cas de test publics. Ils ne remplacent pas une suite de tests propre à votre domaine. Un assistant de code, un bot de support client et un copilote de conformité ont besoin de schémas, de filtres, de règles de rétention et de tests de défaillance différents.

From traces to better memory exécute une boucle d’amélioration sur un outil de mémoire qui stocke des faits avec un propriétaire, un événement source et un intervalle de validité, en suivant ces règles.


Réserves

SGAM est mon étiquette pour un pattern, pas un standard. Les outils du tableau ci-dessus utilisent d’autres noms pour des parties de ce pattern : memory stores, context graphs temporels, blocs de mémoire et graphes de connaissances. Microsoft GraphRAG présente une partie voisine du problème comme de la récupération par graphe sur des documents.

Un profil utilisateur, un journal d’épisodes, un graphe de documents et un bloc de mémoire modifiable par l’agent résolvent des problèmes différents de récupération et de mise à jour. Je réserve SGAM à la mémoire durable qui représente l’état courant de l’application et qui a donc besoin de schéma, de validité, de provenance, de gestion des conflits, de rétention et de migration.

Une mémoire typée peut quand même être fausse. Un schéma rend les mauvaises écritures plus faciles à inspecter ; il ne les rend pas dignes de confiance. Il vous faut toujours de la confiance dans les sources, une confirmation de l’utilisateur pour les faits sensibles, une politique de conflit, de la suppression et de la supervision.


Références