Pourquoi les LLM n'ont pas de mémoire (et pourquoi c'est un problème)
Vous discutez avec un assistant IA depuis trente minutes. Il a compris votre architecture, vos contraintes techniques, le nom de votre projet. Tout va bien. Puis vous fermez la session. Le lendemain, vous rouvrez : il ne se souvient de rien. Vous devez tout réexpliquer. Encore.
Cette expérience, tout développeur utilisant des LLM la connaît. Elle n'est pas un bug — c'est une caractéristique fondamentale de l'architecture des grands modèles de langage.
Le problème : des modèles intrinsèquement stateless
Un LLM ne possède pas de mémoire native. Chaque appel à l'API est une transaction indépendante, un « instantané » où le modèle reçoit un texte (le prompt) et produit une réponse. Entre deux appels, tout est oublié. Le modèle ne dort pas, ne rêve pas, n'apprend pas. Il n'existe pas d'état persistant qui voyage d'une conversation à l'autre.
Ce que nous appelons improprement « mémoire » dans les interfaces comme ChatGPT ou Claude n'est qu'un artifice : le rappel des messages précédents dans le contexte. Chaque tour de dialogue renvoie l'historique complet — vos messages, ses réponses, les instructions système — dans une seule et même requête. C'est une astuce ingénieuse, mais qui repose entièrement sur la fenêtre de contexte (context window), cette limite de tokens que le modèle peut traiter en une fois.
La fenêtre de contexte : une illusion de mémoire
Selon le modèle utilisé, cette fenêtre varie de 4 000 à 200 000 tokens environ — soit quelques pages à un roman court. Pour une session de travail d'une heure, cela suffit. Mais pour un projet qui s'étend sur des semaines, ou pour une base de code entière, c'est dérisoire.
Les conséquences sont concrètes :
- Redondance systématique : chaque nouvelle session exige de réexpliquer le contexte du projet, les conventions de code, les décisions d'architecture déjà validées.
- Impossibilité de capitaliser : une information précieuse découverte dans une session — une configuration particulière, un bug résolu, une préférence — est définitivement perdue à la fermeture.
- Aucune persistance inter-projets : les connaissances acquises sur le projet A ne migrent pas vers le projet B, même s'ils partagent la même stack technique.
- Coût et latence : plus le contexte est long, plus la requête est coûteuse et lente.
Le cadre : OpenRouter et opencode
Ces limitations sont particulièrement visibles lorsqu'on utilise OpenRouter, une API qui fédère l'accès à des dizaines de modèles (GPT-4, Claude, Gemini, Llama, DeepSeek…) via un point d'entrée unique. OpenRouter est un proxy : il transmet vos prompts au modèle choisi et retourne la réponse. Rien de plus. Aucune couche de persistance n'existe entre le client et l'API.
C'est là qu'intervient opencode, un outil CLI qui dialogue avec OpenRouter depuis votre terminal. opencode gère le contexte de la conversation en cours, mais ne conserve rien d'une session à l'autre. Les deux outils, aussi puissants soient-ils, héritent de la même limitation fondamentale : l'absence de mémoire inter-sessions.
Architecture du plugin mémoire : SQLite + FTS5 + embeddings vectoriels
Si les LLM sont nativement stateless, rien n'empêche d'ajouter une couche de mémoire externe — un système capable de stocker, indexer et retrouver les informations pertinentes au moment opportun. Voici comment nous avons construit ce système avec opencode.
Structure des fichiers
Le plugin mémoire vit dans memory/ à la racine du projet, avec une interface publique exportée par __init__.py :
memory/ ├── __init__.py # Exporte MemoryStore et MemoryAgent ├── store.py # Couche basse : SQLite, FTS5, CRUD ├── agent.py # Couche haute : extraction automatique, contexte ├── schema.sql # Schéma de la base (créé à l'init) ├── demo.py # Démo interactive └── test_memory.py # Tests
La persistance est garantie par un fichier SQLite unique (memory.db), portable, sans serveur, versionnable.
Schéma de la base
La table facts stocke la mémoire structurée clé/valeur :
CREATE TABLE IF NOT EXISTS facts (
id TEXT PRIMARY KEY,
fact_key TEXT NOT NULL UNIQUE,
fact_value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0
CHECK(confidence BETWEEN 0 AND 1),
category TEXT DEFAULT 'general',
source TEXT DEFAULT 'manual',
embedding BLOB DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT DEFAULT NULL
);
- fact_key : clé unique lisible (ex:
user_name,tech_stack). - fact_value : valeur textuelle.
- confidence : score de 0 à 1, pondère les souvenirs (extraction auto = 0.8, déclaration explicite = 0.95).
- embedding : blob binaire contenant le vecteur de plongement (float32).
L'index plein texte FTS5 est maintenu automatiquement par des triggers SQL — pas de code applicatif à écrire pour synchroniser l'index.
Génération des embeddings
À chaque insertion, on calcule un vecteur de 384 dimensions :
import numpy as np
def compute_embedding(text: str, model=None) -> bytes:
if model is None:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
vec = model.encode(text, normalize_embeddings=True)
return np.array(vec, dtype=np.float32).tobytes()
Le blob est stocké dans la colonne embedding. On utilise normalize_embeddings=True pour pouvoir faire un simple produit scalaire au lieu d'une cosine similarity complète.
Partager la mémoire entre tous les projets
Un LLM sans mémoire recommence à zéro à chaque conversation. La solution d'opencode : une base SQLite unique, partagée par tous les projets, tous les agents, et toutes les sessions.
Emplacement global
La mémoire est stockée dans ~/.opencode/memory/memory.db — un chemin unique, quel que soit le projet depuis lequel opencode est lancé. Ce choix suit l'esprit de la spec XDG : un fichier global dans le home de l'utilisateur, accessible de partout, plutôt qu'une base par projet qui fragmenterait le contexte. Le plugin lui-même réside dans ~/.config/opencode/plugins/memory-plugin.js, découvert automatiquement par opencode sans configuration.
Résultat : que vous travailliez dans ~/projets/client-alpha ou ~/projets/site-perso, le même fichier memory.db est lu et écrit.
Agents, sessions, même base
Le plugin mémoire utilise un singleton global : la connexion à SQLite est ouverte une seule fois dans le processus opencode et partagée par tous les agents (explore, general, etc.). Le mode WAL (PRAGMA journal_mode=WAL) garantit les performances en lecture concurrente — plusieurs agents peuvent interroger la mémoire simultanément sans blocage.
Ainsi, un fait mémorisé par l'agent explore pendant une session est immédiatement disponible pour l'agent general dans une autre session, dans un autre projet.
Routage par catégories
Tous les faits partagent la même base, mais ils sont organisés par catégories :
| Catégorie | Usage |
|---|---|
identity |
Nom, rôle, localisation de l'utilisateur |
project |
Projet en cours, description, URL du dépôt |
tech |
Stack technique, langages, outils |
preferences |
Préférences (ton, horaires, format de réponse) |
general |
Tout ce qui ne rentre pas ailleurs |
Quand un agent interroge la mémoire avec memory_list --category project, il ne reçoit que les faits de projet — sans pollution par les préférences ou l'identité. La recherche vectorielle (memory_recall) scrute toutes les catégories par défaut, ce qui est souhaitable : si vous passez de Python à Rust, le rappel sémantique trouve « langage » dans tech même si vous cherchez depuis un contexte de projet.
Collisions et mises à jour
memory_store ne crée pas de doublon si la même clé existe déjà. La logique est simple : si une entrée avec la même key existe → UPDATE, sinon INSERT. La colonne key a une contrainte UNIQUE en base : les doublons sont impossibles, même en cas d'accès concurrent.
La confiance comme filtre
Chaque fait porte un score de confiance entre 0.0 et 1.0 :
- Fait directement énoncé par l'utilisateur →
confidence: 1.0 - Information inférée ou supposée →
confidence: 0.7
Lors du rappel, les résultats sont triés par confidence DESC, updated_at DESC : les faits les plus sûrs et les plus récents remontent en premier.
Le pipeline de retrieval : cosine similarity → FTS5 fallback
Le cœur du système mémoire repose sur un pipeline de recherche en deux étages. Quand un agent a besoin de contexte, il exécute un vrai retrieval pipeline qui maximise la pertinence des résultats.
Étape 1 — Recherche vectorielle par similarité cosinus
Tout commence par une phrase en langage naturel, par exemple : « quel était le stack du projet ecommerce ? ».
def memory_recall(query: str, limit: int = 5, threshold: float = 0.7) -> list[dict]:
- Embedding — La requête est passée à un modèle d'embedding (all-MiniLM-L6-v2) et transformée en vecteur de 384 dimensions.
- Similarité cosinus — On calcule la distance entre le vecteur requête et chaque vecteur stocké :
SELECT id, fact_key, fact_value, confidence, 1 - (embedding <#> ?) AS cosine_similarity FROM facts WHERE embedding IS NOT NULL ORDER BY cosine_similarity DESC LIMIT ? - Filtrage par seuil — Seuls les résultats dont la similarité dépasse 0.7 sont conservés.
Étape 2 — Fallback FTS5
Quand la recherche vectorielle échoue (requête trop spécifique, vocabulaire hors vocabulaire du modèle d'embedding), on tombe sur FTS5, l'index plein texte de SQLite :
def _fallback_fts5(query: str, limit: int = 5) -> list[dict]:
clean = _sanitize_fts_query(query)
# "stack du projet ecommerce" → "stack OR projet OR ecommerce"
rows = conn.execute(
"""SELECT content, source_type, source_id, rank
FROM memory_fts WHERE memory_fts MATCH ?
ORDER BY rank LIMIT ?""",
(clean, limit),
)
Fusion et ordonnancement
Les résultats des deux étages sont fusionnés dans une liste unique, dédoublonnée :
def memory_recall(query, limit=5, threshold=0.7):
vector_results = _vector_search(query, limit * 2, threshold)
if len(vector_results) >= limit:
return vector_results[:limit]
fts_results = _fallback_fts5(query, limit)
seen = {(r["source_type"], r["source_id"]) for r in vector_results}
merged = vector_results + [r for r in fts_results
if (r["source_type"], r["source_id"]) not in seen]
return merged[:limit]
L'ordre final : d'abord les résultats vectoriels (meilleure pertinence sémantique), puis les résultats FTS5 (correspondance textuelle exacte).
Flow complet
Entrée : "stack du projet ecommerce"
│
├─ Étape 1 : embedding → vecteur [0.12, -0.45, ..., 0.89]
│ cosine_similarity(faits) → top 3
│ user_project: "Django + PostgreSQL" (0.91) ✓
│ tech_stack: "React" (0.64) ✗
│ → 1 résultat, seuil non atteint pour limit=5
│
└─ Étape 2 : fallback FTS5
tokenize → "stack OR projet OR ecommerce"
memory_fts MATCH → 2 résultats
│
└─ Fusion : [user_project (cosine)] + [2 résultats FTS5]
→ 3 résultats retournés
Ce pipeline garantit qu'une requête trouve toujours quelque chose — même quand le modèle d'embedding ne capture pas le sens exact, les mots-clés bruts prennent le relais.
Cas d'usage concrets
1. Assistant de projet — rappel automatique des décisions d'architecture
Un développeur travaille sur une app React. En début de projet, il stocke une décision clé :
> Retiens qu'on utilise React Router v7 avec loader/action, pas de SWR, pas de Redux. → memory_store key="archi_react_router" value="React Router v7"
Chaque nouveau terminal ouvert, l'agent rappelle automatiquement :
> Rappel : React Router v7 avec loader/action (pas SWR, pas Redux). Loaders dans app/routes/*.loader.ts.
2. Veille concurrente automatisée
Le proactive-agent lance une tâche de veille toutes les 24h :
proact --task veille-concurrents → memory_store key="veille:2025-10-24:claude" value="Anthropic sort un outil de génération de tests E2E" category="veille"
Depuis n'importe quel projet :
> Quoi de neuf sur mes concurrents ? → memory_recall --query "veille concurrente cette semaine" Résultats : Anthropic : outil de tests E2E OpenAI : mise à jour du pricing GPT-4o Vercel : AI SDK v5 avec streaming amélioré
3. Debugging cross-projet
Un bug étrange survient dans le projet A : les appels fetch() plantent aléatoirement. Le développeur diagnostique un problème de connexion Keep-Alive, le stocke :
memory_store key="bug:keepalive" value="fetch plante aléatoirement → http.Agent keepAlive sockets mortes" category="bugs"
Deux mois plus tard, projet B. Même symptôme. L'agent rappelle automatiquement :
🔍 Bug pattern détecté : Projet A — fetch plante aléatoirement → Coupable : http.Agent keepAlive. Fix : keepAlive: false.
4. Profil utilisateur persistant
Plus jamais ressaisir son nom ou ses préférences :
> Je m'appelle Thomas, je code en Rust et TypeScript. → memory_store key="user_name" value="Thomas" category="identity" memory_store key="tech_stack" value="Rust, TypeScript" category="tech"
Session suivante, n'importe où :
> Tu te souviens qui je suis ? → user_name : Thomas tech_stack : Rust, TypeScript lang : fr (réponse en français)
Conclusion
Donner une mémoire long terme à un LLM, ce n'est pas compliqué en soi — une base SQLite, des embeddings, un fallback texte. Ce qui fait la différence, c'est l'intégration : que la mémoire soit globalisée (un fichier unique pour tous les projets), persistante (elle survit aux redémarrages), et accessible (quelques commandes suffisent à stocker et retrouver n'importe quelle information).
L'architecture décrite ici transforme OpenRouter + opencode d'un simple proxy LLM sans état en un véritable assistant qui apprend au fil du temps. Les quatre cas d'usage — rappel d'architecture, veille concurrente, debugging cross-projet, profil persistant — ne sont que la surface. Une fois que la mémoire est là, elle devient le socle de tout ce que l'agent fait.
Cet article a été rédigé en utilisant le système de sous-agents décrit dans l'article précédent — 5 agents spécialisés ont produit chaque section en parallèle, compilees dans un plan unique.