Donner une mémoire long terme à OpenRouter (et la partager entre plusieurs projets)

Octobre 2025 · Lecture ∼12 min

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 :

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
);

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 :

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]:
  1. Embedding — La requête est passée à un modèle d'embedding (all-MiniLM-L6-v2) et transformée en vecteur de 384 dimensions.
  2. 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 ?
  3. 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.

← Retour au blog