事实写进数据库,不代表智能体能在正确的问题下想起它。长期记忆需要同时处理检索线索、事实冲突、时间衰减和主动遗忘,并通过回放评测验证效果。

一、写入成功不等于记住了

很多智能体的“长期记忆”最初只是一个追加日志:对话结束后抽取事实,下一轮再按关键词或向量相似度查询。问题是,用户的表达和记忆中的表述往往并不一致。

例如,数据库里保存的是“用户当前居住城市是杭州”,用户却问“他现在住哪”。事实已经写入,但“住哪”和“居住城市”没有直接重合,检索阶段就可能漏掉它。即便使用向量检索,也还要面对另外几类问题:

问题典型表现需要的机制
提取失败写的是“居住城市”,问的是“住哪”查询改写、别名和检索线索扩展
重复记忆同一事实被多次写入规范化键、幂等写入
事实冲突先住北京,后来搬到杭州新旧版本关联、替代策略
记忆过期临时行程长期参与回答过期时间、时间衰减
无法验证只凭几次手工对话判断效果固定问题集、Hit@K 与 MRR

因此,长期记忆更接近一个带生命周期的检索系统,而不是聊天记录的存档。本文使用结构化事实加词法线索实现一个最小版本。它不依赖外部模型,便于先验证数据流;数据规模扩大后,可以再把候选召回替换为 FTS 或向量索引。

二、先定义记忆的结构与生命周期

示例将一条记忆拆成 subjectpredicateobject。三者用于判重与冲突判断,content 用于注入模型上下文,searchable 保存扩展后的检索线索。

每条记忆还包含四类控制字段:

  • importance:业务侧给出的重要程度;
  • confidence:抽取器对事实的置信度;
  • expires_at:明确的失效时间,适合行程、验证码偏好等临时信息;
  • superseded_by:指向替代它的新记忆,保留变更历史但不再参与召回。

冲突策略不能一概而论。“居住城市”通常是单值属性,适合用新值替代旧值;“喜欢的食物”可以有多个值,应采用追加模式。也就是说,归并规则最好由谓词或业务类型决定,而不是看到同一主体就覆盖。

时间衰减与过期也不同。过期是硬过滤,到期后不再返回;衰减只是降低旧记忆的排序分数,重要且匹配度高的旧事实仍有机会被召回。

三、用 Node.js 与 SQLite 实现记忆层

下面的程序基于 Node.js 20+ 和真实存在的 better-sqlite3 API。创建空目录后执行:

npm init -y
npm install better-sqlite3

将以下内容保存为 memory.mjs。程序包含建表、线索生成、幂等写入、冲突替代、检索评分、逻辑遗忘和回放评测,可以直接运行。

import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';

const db = new Database('memory.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS memories (
  id TEXT PRIMARY KEY,
  scope TEXT NOT NULL,
  kind TEXT NOT NULL,
  subject TEXT NOT NULL,
  predicate TEXT NOT NULL,
  object TEXT NOT NULL,
  content TEXT NOT NULL,
  searchable TEXT NOT NULL,
  importance REAL NOT NULL,
  confidence REAL NOT NULL,
  created_at TEXT NOT NULL,
  last_accessed_at TEXT,
  expires_at TEXT,
  superseded_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_memory_lookup
ON memories(scope, subject, predicate, superseded_by, expires_at);
`);

const aliases = new Map([
  ['住哪', ['居住地', '居住城市', '搬家']],
  ['在哪生活', ['居住地', '居住城市']],
  ['爱吃', ['喜欢', '食物', '口味']],
  ['偏好', ['喜欢', '习惯']]
]);

function makeClues(parts) {
  const out = new Set();
  for (const raw of parts) {
    const text = String(raw).toLowerCase().trim();
    if (!text) continue;
    out.add(text);
    for (const token of text.split(/[\s,。!?、:;,.!?;:]+/u)) {
      if (token) out.add(token);
    }
    for (const match of text.matchAll(/[\p{Script=Han}]{2,}/gu)) {
      const run = match[0];
      for (let size = 2; size <= Math.min(4, run.length); size++) {
        for (let i = 0; i + size <= run.length; i++) {
          out.add(run.slice(i, i + size));
        }
      }
    }
  }
  return [...out];
}

function expandQuery(query) {
  const normalized = query.toLowerCase();
  const clues = new Set(makeClues([query]));
  for (const [phrase, additions] of aliases) {
    if (normalized.includes(phrase)) {
      for (const item of additions) clues.add(item);
    }
  }
  return [...clues].slice(0, 24);
}

const writeMemory = db.transaction((input) => {
  const now = new Date().toISOString();
  const activeSql = `
    scope = ? AND subject = ? AND predicate = ?
    AND superseded_by IS NULL
    AND (expires_at IS NULL OR expires_at > ?)`;

  const same = db.prepare(`
    SELECT id FROM memories
    WHERE ${activeSql} AND object = ?
    LIMIT 1
  `).get(input.scope, input.subject, input.predicate, now, input.object);

  if (same) {
    db.prepare(`
      UPDATE memories
      SET confidence = MAX(confidence, ?), last_accessed_at = ?
      WHERE id = ?
    `).run(input.confidence ?? 0.8, now, same.id);
    return same.id;
  }

  const id = randomUUID();
  const content = input.content ??
    `${input.subject}的${input.predicate}是${input.object}`;
  const searchable = makeClues([
    input.subject,
    input.predicate,
    input.object,
    content,
    ...(input.clues ?? [])
  ]).join('\n');

  db.prepare(`
    INSERT INTO memories (
      id, scope, kind, subject, predicate, object,
      content, searchable, importance, confidence,
      created_at, expires_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id, input.scope, input.kind ?? 'fact', input.subject,
    input.predicate, input.object, content, searchable,
    input.importance ?? 0.5, input.confidence ?? 0.8,
    now, input.expiresAt ?? null
  );

  if ((input.mode ?? 'replace') === 'replace') {
    db.prepare(`
      UPDATE memories SET superseded_by = ?
      WHERE ${activeSql} AND id <> ?
    `).run(
      id, input.scope, input.subject, input.predicate, now, id
    );
  }
  return id;
});

function retrieve(scope, query, limit = 3, halfLifeDays = 30) {
  const terms = expandQuery(query);
  if (terms.length === 0) return [];

  const matches = terms.map(() => 'instr(searchable, ?) > 0').join(' OR ');
  const now = new Date();
  const rows = db.prepare(`
    SELECT * FROM memories
    WHERE scope = ?
      AND superseded_by IS NULL
      AND (expires_at IS NULL OR expires_at > ?)
      AND (${matches})
    LIMIT 100
  `).all(scope, now.toISOString(), ...terms);

  const ranked = rows.map((row) => {
    const hitCount = terms.filter((term) => row.searchable.includes(term)).length;
    const lexical = hitCount / terms.length;
    const ageDays = Math.max(0, now - new Date(row.created_at)) / 86400000;
    const decay = 0.5 ** (ageDays / halfLifeDays);
    const score = lexical * 0.55 + decay * 0.20
      + row.confidence * 0.15 + row.importance * 0.10;
    return { ...row, score };
  }).sort((a, b) => b.score - a.score).slice(0, limit);

  const touch = db.prepare(
    'UPDATE memories SET last_accessed_at = ? WHERE id = ?'
  );
  const touchAll = db.transaction((items) => {
    for (const item of items) touch.run(now.toISOString(), item.id);
  });
  touchAll(ranked);
  return ranked;
}

function compact(retentionDays = 30) {
  const now = new Date().toISOString();
  const cutoff = new Date(Date.now() - retentionDays * 86400000).toISOString();
  return db.prepare(`
    DELETE FROM memories
    WHERE (expires_at IS NOT NULL AND expires_at <= ?)
       OR (superseded_by IS NOT NULL AND created_at < ?)
  `).run(now, cutoff).changes;
}

function evaluate(cases, k = 3) {
  let hits = 0;
  let reciprocalRank = 0;
  for (const item of cases) {
    const result = retrieve(item.scope, item.query, k);
    const rank = result.findIndex((row) => row.id === item.expectedId) + 1;
    if (rank > 0) {
      hits++;
      reciprocalRank += 1 / rank;
    }
    console.log(item.query, '=>', result.map((row) => row.content));
  }
  return {
    cases: cases.length,
    hitAtK: hits / cases.length,
    mrr: reciprocalRank / cases.length
  };
}

const scope = 'demo-user';
writeMemory({
  scope, subject: '用户', predicate: '居住城市', object: '北京',
  content: '用户曾居住在北京', clues: ['住在北京']
});
const cityId = writeMemory({
  scope, subject: '用户', predicate: '居住城市', object: '杭州',
  content: '用户当前居住在杭州', clues: ['搬到杭州', '居住地'],
  importance: 0.8, mode: 'replace'
});
const foodId = writeMemory({
  scope, subject: '用户', predicate: '喜欢的食物', object: '清蒸鱼',
  content: '用户喜欢吃清蒸鱼', clues: ['口味', '爱吃'],
  mode: 'append'
});

console.log(evaluate([
  { scope, query: '他现在住哪?', expectedId: cityId },
  { scope, query: '用户爱吃什么?', expectedId: foodId }
]));
console.log('已物理清理:', compact(), '条');
db.close();

运行命令如下:

node memory.mjs

这里用 instr 做候选过滤,是为了让示例在中文环境下保持行为明确。它适合验证机制,不适合直接承担大规模扫描。生产环境可以保留相同的数据结构和评分逻辑,把候选生成替换为 SQLite FTS5、独立搜索引擎或向量数据库。

四、提取、归并和遗忘要分层处理

记忆写入前通常还有一个由 LLM 完成的事实提取步骤。模型输出不应直接拼接 SQL,而应先校验为固定结构,并限制可写入的 scope、谓词类型、置信度范围和过期时间。对于“用户可能喜欢辣味”这类不确定表述,可以降低置信度,而不是改写成确定事实。

示例使用“主体 + 谓词”作为冲突域,并通过 mode 控制行为:

  • replace:新记忆写入后,旧值设置 superseded_by
  • append:允许同一谓词存在多个值;
  • 相同对象再次写入:不新增行,只强化已有记录的置信度。

保留被替代记录有两个好处:一是可以追踪模型为什么改变回答,二是可以在写入错误时回滚。但历史数据不必永久保存,compact 会清理已经过期的记录,以及超过保留期的旧版本。

实际项目还应区分事件发生时间和写入时间。比如用户今天说“我去年住在北京”,这条记忆的 created_at 是今天,事件时间却在去年。若业务依赖时间顺序,应增加 valid_fromvalid_toobserved_at,不要用数据库写入时间代替事实时间。

五、用回放评测代替主观试聊

记忆检索最容易出现的问题,是修改了线索或评分后,只验证眼前的一个问题,却让其他问法退化。回放评测需要保存一组稳定的测试用例,每个用例至少包含用户范围、问题、期望记忆和 K 值。

示例计算了两个基础指标:

  • Hit@K:期望记忆是否出现在前 K 条结果中;
  • MRR:期望记忆排名倒数的平均值,第一名计 1,第二名计 0.5。

不要为指标预设一个脱离业务的“合格数字”。更可靠的做法是在版本迭代时固定数据集,比较线索词典、半衰期、权重和冲突策略变更前后的差异。测试集还应包含反例,例如“以前住哪”可能需要召回被替代记录,而“现在住哪”只能命中当前记录。这会促使检索接口显式支持历史查询,而不是简单取消 superseded_by 过滤。

评测时最好记录候选集、各项子分数和最终排名。只保存模型最终回答,很难判断失败发生在事实提取、候选召回、排序,还是上下文生成阶段。

总结

智能体记忆不是把对话写进数据库,而是建立一条可观察、可验证的数据链路:

  • 用结构化主体、谓词和对象支持判重与冲突判断;
  • 用别名、短语和查询扩展解决“写法不同但含义相关”的召回问题;
  • 用硬过期排除失效事实,用时间衰减调整旧记忆的优先级;
  • 对单值属性执行替代,对多值偏好执行追加,避免粗暴覆盖;
  • 保留旧版本用于审计,再通过保留期完成物理遗忘;
  • 使用 Hit@K、MRR 和固定回放集验证每次改动。

这个 SQLite 版本不是最终架构,但它把记忆层最重要的行为变成了可以运行、检查和测试的代码。只有先确定什么应被写入、如何被唤起、何时失效,再引入向量检索或更复杂的模型,长期记忆才不会退化成一个不断膨胀却很少真正有用的存档。