Journal
17 min de lecture

Vingt-quatre places : donner une mémoire à un LLM

Un personnage conversationnel qui se souvient pendant des semaines, ce n'est pas une grande fenêtre de contexte. C'est un chemin d'écriture, un chemin de lecture, et une série de seuils que j'ai dû mesurer un par un. Le système complet, avec le code et les chiffres.

Un modèle de langage ne se souvient de rien. Il relit.

Tout ce qu'il « sait » de toi tient dans le texte qu'on lui redonne à chaque tour. Tant que la conversation est courte, on lui redonne tout. Au bout de trois semaines, ça ne rentre plus, et il faut choisir.

Écho est un moteur de personnages conversationnels que j'héberge moi-même. Un personnage, une conversation qui dure, et une exigence : qu'il se rappelle ce que je lui ai dit le mois dernier sans que je le lui répète. Mon plus gros corpus compte 299 souvenirs. À chaque tour, il y en a 24 qui entrent dans le contexte.

Cet article démonte le système qui choisit ces 24. Il y a deux chemins : celui qui écrit un souvenir, celui qui le relit. Et à peu près chaque seuil, sur les deux, a été faux avant d'être mesuré.

Vue d'ensemble

ÉCRITURE
  message → réponse → réflexion (JSON) → filtres en code → dédoublonnage → SQLite

LECTURE
  message → candidats → scores sémantiques → sélection de 24 → contexte
                ↑
        index de vecteurs (dérivé, reconstruit en arrière-plan)

Trois partis pris tiennent tout le reste.

SQLite est la seule vérité. Les vecteurs ne sont qu'un index, recalculable à tout moment. Si le service de vecteurs tombe, la mémoire continue de fonctionner, en moins fin.

Le modèle propose, le code décide. Le modèle extrait des candidats. Ce qui devient un souvenir, ce qui fusionne, ce qui entre dans le contexte : c'est du Python déterministe, testable sans réseau.

Un tour ne doit jamais attendre la mémoire. Si elle est lente ou absente, on répond quand même.

Écrire

La réflexion vient après la réponse

Quand un message arrive, le personnage répond d'abord. Ensuite seulement, un second appel au modèle — la réflexion — relit l'échange et renvoie un JSON : souvenirs candidats, mise à jour du résumé, quelques deltas d'état bornés.

L'ordre compte. Une réflexion qui échoue ne peut pas faire échouer une réponse déjà livrée.

Chaque candidat arrive avec un type (fait, préférence, promesse, épisode), un périmètre (global, ou propre à l'histoire en cours) et un score de confiance.

Le filtre de confiance ne filtrait rien

Seuls les candidats à 0,85 et plus étaient gardés. L'idée suivante semblait évidente : récupérer la bande 0,60–0,85, les « peut-être », et me les soumettre pour confirmation.

Avant d'écrire l'interface, j'ai mesuré la bande. Vingt-quatre échanges inventés, avec un gradient délibéré : 6 faits nets, 6 hésitations (« je crois », « peut-être »), 6 faits seulement suggérés, 6 bavardages. Deux tirages, et la capture de tous les candidats, avant le filtre.

bandetirage 1tirage 2total
< 0,60000
0,60 – 0,85000
≥ 0,85111223

Vide. Les faits nets sortent 12 fois sur 12, le bavardage 0 fois sur 12, les hésitations 4 fois sur 12.

Le modèle ne dit pas « j'en suis sûr à 70 % ». Quand il doute, il se tait. Et la seule hésitation qu'il a émise portait sa réserve dans la phrase même, avec une confiance de 0,90. L'incertitude est encodée en mots, jamais en chiffre.

Le filtre à 0,85 est donc décoratif, et l'interface de confirmation n'aurait eu aucun client. Ticket fermé, zéro ligne écrite.

Un détail de méthode, parce que je me suis fait reprendre dessus. Ma première conclusion reposait sur un seul tirage : 0 candidat sur 11 dans la bande. Ça ne réfute rien. Si le vrai taux était de 15 %, on observerait quand même zéro sur onze une fois sur six (0,85¹¹ ≈ 17 %). Il a fallu le second tirage pour que la borne passe sous le seuil que je m'étais fixé.

Demander au modèle de s'abstenir ne marche pas

Un souvenir doit porter sur l'utilisateur. C'est écrit dans le prompt depuis le premier jour. Mesure : 10 % des souvenirs extraits parlaient du personnage ou du décor.

J'ai durci la consigne, avec une clause explicite : « si rien ne concerne l'utilisateur, ne renvoie rien ». Le taux a doublé.

C'était la troisième fois que je mesurais la même chose : une consigne qui demande au modèle de ne pas produire ne porte pas. Poussé à remplir un champ, il le remplit.

Le filtre est donc passé en code. Même chose pour deux autres défauts d'écriture, eux aussi mesurés :

  • environ une réflexion sur trois inversait une négation en recopiant (« n'aime pas X » devenait « aime X »), et les deux versions cohabitaient ensuite ;
  • la réflexion rangeait parfois une attitude du personnage comme un fait durable sur l'utilisateur.

Trois petits détecteurs lexicaux, chacun calibré sur les cas réels relevés, chacun avec ses faux positifs documentés. Ce n'est pas élégant. C'est décidable, et ça se teste.

Dédoublonner : un seuil au ras des vraies variantes

Sans dédoublonnage, un fait répété s'écrit à chaque fois. J'ai trouvé six formulations du même fait dans une base réelle. Six places prises sur vingt-quatre.

Le hash exact ne voit pas les variantes. Il faut une proximité, et elle doit être lexicale et synchrone : le chemin d'écriture ne peut pas dépendre de la disponibilité d'un modèle de vecteurs.

Le score est le maximum de trois mesures sur le texte normalisé :

  • le ratio de séquence (difflib), calculé dans les deux sens parce qu'il n'est pas symétrique — 0,817 d'un côté, 0,783 de l'autre sur une vraie paire, à cheval sur le seuil ;
  • le Jaccard des mots pleins, qui voit les permutations ;
  • l'inclusion mot pour mot du plus court dans le plus long.

Le seuil, 0,80, n'a pas été choisi. Je l'ai lu sur 1 326 paires, toutes les combinaisons de 52 souvenirs réels : à 0,80 et au-dessus, uniquement de vraies variantes ; la paire de faits distincts la plus proche est à 0,72 — même tournure de phrase, deux faits différents.

Entre 0,72 et 0,80, où placer le curseur ? Pas au milieu. Les deux erreurs n'ont pas le même prix :

  • rater une fusion coûte une redite ;
  • fusionner à tort efface une nuance, sans bruit.

Donc le seuil colle aux vraies variantes, pas au milieu de l'intervalle.

Un chiffre différent est un fait différent

Puis un banc adversarial a trouvé ceci. Deux souvenirs de la forme :

Le contrat prévoit 10 séances à 200 € chacune. Le contrat prévoit 3 séances à 50 € chacune.

Score : 0,963. Le second écrasait le premier. La correction que je venais de donner était perdue, en silence.

Les chiffres n'étaient même pas des mots pour le découpage. Les y ajouter n'a pas suffi : au-delà de 40 caractères, le ratio de séquence reprenait la main. La règle finale est plus brutale :

def _score(na, ta, nb, tb):
    ca, cb = _jetons_porteurs(na), _jetons_porteurs(nb)
    plafond = 1.0 if ca == cb else SIMILARITY_THRESHOLD - 0.01
    ratio = 0.0
    if min(len(na), len(nb)) >= MIN_RATIO_CHARS:
        ratio = max(
            difflib.SequenceMatcher(None, na, nb).ratio(),
            difflib.SequenceMatcher(None, nb, na).ratio(),
        )
    jaccard = len(ta & tb) / len(ta | tb) if ta | tb else 0.0
    brut = max(ratio, jaccard, 1.0 if _verbatim_contained(na, nb, ta, tb) else 0.0)
    return min(brut, plafond)

Les jetons porteurs sont ceux qui sont souvent le fait lui-même : chiffres, jours, mois, « demain », « ce soir ». S'ils diffèrent entre les deux textes, le score est plafonné juste sous le seuil, quoi que disent les autres mesures.

Cette liste s'est construite en cinq tours de relecture, chacun avec son contre-exemple mesuré :

  • « mardi prochain » fusionnait avec « jeudi prochain » à 0,938 ;
  • « ce soir » était écrasé par « demain soir » à 0,80 pile ;
  • en découpant « après-demain » pour y trouver « demain », les deux devenaient égaux, et un rendez-vous de demain était détruit par celui d'après-demain ;
  • « mais » se repliait en « mai » : la conjonction la plus banale du français devenait un mois fantôme.

Le coût est assumé, du bon côté : « 14h30 » et « 14 h 30 » font deux entrées au lieu d'une. Une redite de plus, jamais un fait effacé.

J'avais aussi essayé l'inclusion de mots plutôt que mot pour mot. Retirée : « A aime B » est un sous-ensemble de mots de « A aime discuter de ses problèmes avec B ». Un fait majeur aurait été absorbé par un mineur.

Le sens de la fusion

Quand deux souvenirs fusionnent, la formulation la plus récente gagne — sauf si elle est plus pauvre. Une variante courte qui arrive après une variante riche est absorbée sans rien réécrire.

Et l'absorption ne rafraîchit pas la date du souvenir. La date est une clé de classement à la lecture : un fait que je ressasse aurait fini par monopoliser le haut du classement, devant des souvenirs frais.

Lire

Vingt-quatre places, six passes

À chaque tour, tous les souvenirs actifs sont candidats. La sélection est une fonction pure : une liste en entrée, 24 souvenirs en sortie, et pour chacun la raison de sa présence. Cette raison s'affiche sur une page de diagnostic. Sans elle, je n'aurais rien pu régler.

Les passes, dans l'ordre :

  1. La réserve. On met de côté un quart des places pour les souvenirs globaux et sémantiques, avant tout le reste.
  2. Les épinglés, ceux que j'ai marqués à la main, jusqu'à la limite moins la réserve.
  3. Les globaux, jusqu'à leur quart.
  4. Les sémantiques, ceux que le dernier message rappelle, jusqu'à 16.
  5. Un quota par type : 6 faits, 5 préférences, 4 promesses, 5 épisodes.
  6. Le repêchage : ce qui reste, par ordre de rang (épinglé, propre à l'histoire, type, confiance, récence).

La réserve existe à cause d'une mesure. Une histoire avec 26 souvenirs épinglés et 10 souvenirs globaux pertinents donnait : 24 épinglés, 0 global, 0 sémantique. Tout ce que le personnage savait de durable sur moi avait disparu derrière l'histoire en cours.

reserve_candidates = sum(
    1
    for memory in ordered
    if not memory.pinned
    and (memory.scope == "global" or memory.id in semantic_ids)
)
reserve = min(
    max(1, round(limit * GLOBAL_CONTEXT_SHARE)),
    reserve_candidates,
)
for memory in ordered:
    if memory.pinned:
        if len(selected) >= limit - reserve:
            break
        add(memory, "Épinglé")

La réserve est bornée aux candidats réels : s'il n'y a que deux souvenirs globaux, on ne gèle pas six places pour rien.

Deuxième mesure, même mécanisme un étage plus haut. Il existait un type prioritaire, les règles que le personnage doit toujours respecter, servi avant tout le monde. Un profil en avait 19. Elles prenaient 19 des 24 places. Son rappel était devenu insensible à tous mes réglages.

Elles sont sorties du budget : toujours toutes dans le contexte, mais elles ne concourent plus pour une place. Les quatre places libérées n'ont pas été redistribuées entre les types : elles vont au repêchage. Rien ne me disait à qui les donner, et me tromper coûtait plus cher qu'attendre une mesure.

Le rappel sémantique, mesuré

Chaque souvenir a un vecteur. Le dernier message est vectorisé, comparé par cosinus à tous les autres, et ceux qui dépassent 0,32 sont éligibles à la passe sémantique.

Quatre questions se posaient. J'ai monté un banc : 18 souvenirs de référence, 6 requêtes, 10 pertinents déclarés, noyés dans des distracteurs jusqu'à la taille voulue, sur cinq jeux de distracteurs différents. La métrique est le rappel@k : les pertinents sont-ils dans les k premiers ? C'est exactement ce que fait le produit.

Les préfixes de tâche. Le modèle de vecteurs recommande de préfixer requêtes et documents. Sur 234 souvenirs :

variante@8@16pire rang
sans préfixe40/5050/5012 à 13
préfixe « recherche »36/5048/5016 à 17
préfixe « question-réponse »38/5045/5019 à 21
préfixe « similarité »40/5040/5021 à 24

Aucun ne bat l'absence de préfixe. Rejetés.

Ce qui est savoureux : le ticket d'origine citait une autre métrique, l'écart moyen entre pertinents et non-pertinents, qui concluait l'inverse. Elle ne mesure pas ce que le système fait.

Le décor. J'ajoutais à chaque requête le titre de l'histoire et la situation en cours, pour aider les messages elliptiques du genre « on continue ? ».

requêtesdécor@16
normalessans25/25
normalesavec15/25
elliptiquessans10/30
elliptiquesavec0/30

Le décor fait plusieurs fois la longueur du message. Il domine le vecteur, il est identique à chaque tour, et il rapproche toutes les requêtes du même point. Il n'aidait pas là où il devait servir : il ramenait le rappel à zéro. Retiré.

Le nombre de places sémantiques.

corpus@8@16@24
12050/5050/5050/50
23440/5050/5050/50
40040/5045/5050/50

La dégradation commence vers 200 souvenirs, et elle ne porte que sur la profondeur : les pertinents ne sortent jamais des 24 premiers, ils sortent des places sémantiques. Passage de 8 à 16.

Borner le nombre de candidats. Le scorage est du Python pur, sans bibliothèque de vecteurs : 1,5 ms pour 100 souvenirs, 13,4 ms pour 1 000, 40,4 ms pour 3 000. Contre un tour qui dure des secondes, borner n'achèterait rien et rendrait les vieux souvenirs inaccessibles. Rejeté.

Deux leçons de méthode, apprises à mes dépens sur ce banc :

  • Mon premier étiquetage déclarait la pertinence par thème. Il concluait que le modèle ne discriminait pas. C'était l'étiquetage qui était faux : le rappel@3 passait de 10/10 à 9/15 sans qu'un seul classement ait changé.
  • Sur 18 souvenirs, aucune variante ne dégradait rien. À cette taille, rien ne dégrade. C'est à 234 que les effets apparaissent.

Ne jamais faire attendre un tour

Le 19 août, entre 5 h 27 et 6 h 40, quinze tours ont été servis sans aucune mémoire sémantique. La machine qui servait les vecteurs dormait. Et le premier tour après chaque fenêtre de reprise payait deux tentatives de 120 secondes avant de renoncer.

Depuis, le tour ne vectorise plus que sa propre requête, avec cinq secondes et une seule tentative. La latence saine est de 0,2 à 0,8 seconde ; si la machine dort, réessayer ne la réveille pas.

query_vector = (
    await asyncio.wait_for(
        self.ollama.embed(
            [query.strip()[:2000]],
            model=self.settings.ollama_embedding_model,
            timeout=TURN_EMBEDDING_TIMEOUT,
            attempts=TURN_EMBEDDING_ATTEMPTS,
        ),
        timeout=TURN_EMBEDDING_TIMEOUT,
    )
)[0]

Le délai est écrit deux fois, et ce n'est pas une redite. Celui passé au client ne borne que la requête HTTP. Avant elle, l'appel attend un sémaphore et un verrou que l'indexation de fond peut tenir plusieurs minutes. C'est wait_for qui borne l'opération entière.

L'indexation, elle, est sortie du tour. Une boucle de fond passe toutes les 45 secondes, compare les empreintes de contenu, et ne vectorise que ce qui a changé. Un souvenir créé à l'instant n'est donc pas encore scoré au tour suivant : il est quand même servi par les passes déterministes, qui trient par récence.

En cas d'échec, la sélection se fait sans scores, et un compteur de tours dégradés s'incrémente. Le 19 août, seule une ligne de journal savait ce qui se passait.

Rendre l'oubli mesurable

« Quel souvenir existe mais ne remonte jamais ? » Je n'avais aucun moyen de répondre.

Chaque souvenir porte maintenant trois compteurs : combien de fois il a été candidat, combien de fois servi, et combien de fois servi par la passe sémantique. La distinction compte. Un souvenir admis parce qu'il est récent n'a pas été retrouvé, il a juste été trié.

Coût : 0,36 ms par tour sur un corpus de 300. Et un tour en panne ne compte rien : une machine endormie ne doit pas se lire comme un souvenir ignoré.

Les boucles

La position qui se cite elle-même

Le personnage a aussi des positions : ce qu'il pense d'un sujet, avec la phrase qui la justifie.

Un soir, il trouve une réplique parfaitement à propos. La réflexion la range, mot pour mot, comme position durable. Cinq heures et 31 messages plus tard, sur un déclencheur sans aucun rapport, la réplique ressort à l'identique.

Et la minute d'après, la réflexion re-valide la position en citant comme preuve… la réplique que la position venait de souffler.

Le texte stocké dicte la réplique, la réplique justifie le texte stocké. La date de mise à jour est repoussée à chaque tour : la position ne peut plus vieillir ni céder sa place.

Deux corrections. À l'écriture, le libellé du champ invitait littéralement à recopier (« la position, dans ses termes ») : reformulé, la recopie passe de 4 cas sur 12 à 0 sur 12. À la validation, une justification qui reprend 30 caractères ou plus de la position qu'elle prétend fonder ne la réancre plus. Seuil lu sur les 21 justifications réelles du journal : l'auto-citation en reprenait 53, la justification légitime la plus proche 13.

Le résumé qui oublie

Le résumé de l'histoire était réécrit en entier à chaque réflexion. Un fait que le modèle cessait de répéter disparaissait sans trace.

Ma première idée : des ancrages. Des faits identifiés, tenus par le code, que le modèle ne peut retirer qu'en donnant un motif. Le résumé libre ne garderait que le récent.

Mesuré sur 39 réflexions réelles : pire qu'avant. Un fait clé sur quatre conservé en fin de conversation, contre trois sur quatre avec l'ancien système. Le modèle ne promouvait presque rien en ancrage, trois fois en 39 tours, et tout ce qui ne vivait que dans le récent s'évaporait au tour suivant. La garde marchait parfaitement. Ce qui l'alimentait était trop étroit.

La version livrée garde le résumé complet, et pose les ancrages au-dessus, comme un filet. Une redite entre les deux est acceptée.

Ce que j'en retiens

Un seuil se lit, il ne se choisit pas. 0,80 pour fusionner, 30 caractères pour l'auto-citation, 16 places sémantiques : chacun vient d'une distribution réelle, pas d'une intuition. Et le filtre à 0,85, choisi à l'intuition, ne filtrait rien.

Deux erreurs n'ont jamais le même prix. Une redite se voit et se corrige. Un souvenir écrasé ne se rattrape que par hasard. Tout le chemin d'écriture penche du même côté.

On ne demande pas à un modèle de se taire. Trois mesures, trois fois le même résultat. Ce qui doit être exclu s'exclut en code.

La mémoire dégrade, elle ne bloque pas. Une réponse un peu moins informée vaut mieux qu'une réponse qui n'arrive pas. Mais la dégradation doit se compter.

Il faut mesurer à l'échelle. À 18 souvenirs tout marche. C'est à 200 que le système commence à avoir une opinion.

Les limites, pour finir. Les détecteurs lexicaux sont écrits pour le français et ne se transposent pas. Plusieurs bancs tournent sur des corpus inventés. Et tout ça sert un seul utilisateur : moi. Je ne sais pas ce que donne cette sélection à dix mille souvenirs, et le système non plus.

— Pato