ENZH

记忆系统最大的盲区是「人」

记忆碎片聚合为完整人物档案的概念插画记忆碎片聚合为完整人物档案的概念插画

问题出在"人"上

第三篇里我说记忆系统是"皇冠上的宝石"——从 v1 原封不动搬过来的。这话没错,但遗漏了一个严重的盲区。

旧系统把所有东西存进 memories 表,每条记忆带一个 768 维的向量 embedding。用户说"我喜欢吃辣",存一条,下次问"用户口味偏好",余弦相似度搜得到。挺好的。

但人不是偏好。

用户在不同对话里提过关于小红的五件事——在腾讯当工程师、养了一只猫、最近加班很多、上周跟男朋友吵架了、下个月要来北京。这五条记忆散落在表里,彼此之间没有任何关联。

想查"关于小红的所有信息"?唯一的办法是 ILIKE '%小红%' 全表扫。

更要命的是语义鸿沟。"小红最近怎么样了"和"小红在腾讯当工程师",在 embedding 空间里几乎没有相似度——一个是近况询问,一个是职业描述,向量方向完全不同。语义搜索搜不到。

说白了,模型忘了它知道的关于用户朋友的信息。

对一个以"记得你"为核心卖点的伴侣来说(见第一篇),这是个挺尴尬的 bug。

方案:把人变成一等实体

思路很直接:建一张 contacts 表。用户生活里的人变成有索引的实体,不再是散落在记忆表里的事实碎片。

CREATE TABLE contacts (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL REFERENCES users(id),
  name          TEXT NOT NULL,
  relationship  TEXT,           -- "同事", "大学室友", "妈妈"
  description   TEXT,           -- LLM 生成的性格/近况描述
  attributes    JSONB DEFAULT '{}',  -- 结构化数据:职业、城市、生日...
  memory_ids    UUID[] DEFAULT '{}', -- 关联的记忆 ID
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  updated_at    TIMESTAMPTZ DEFAULT NOW()
);

关键是 memory_ids 这个 UUID 数组。它把联系人和相关记忆做了结构化关联——不用再靠 ILIKE 扫全表了。attributes 是 JSONB 字段,存 LLM 从对话中提取的结构化信息(职业、所在城市之类的)。

管道:从一句闲聊到一份档案

用户说"小红最近工作压力很大",之后发生三件事。

提取阶段(每约 10 条消息触发一次)。MemoryAccumulator 照常提取记忆,但现在会检查子类型。检测到跟人有关的记忆时,跑 syncContacts()——upsert 联系人记录,把新的 memory ID 关联上去。

合并阶段(每 50 条消息触发一次)。PersonConsolidator 拉出每个联系人关联的所有记忆碎片,让 LLM 生成一份结构化档案——性格描述、关键属性、时间线。然后把源碎片的重要性降到 0.05,让它们不再跟合并后的档案抢搜索结果。

检索阶段(每条消息)。用户消息里提到联系人名字时,aggregator 检测匹配,构建一张"联系人卡片"直接注入 system prompt。绕过语义搜索。

联系人卡片长这样:

[联系人: 小红]
关系: 大学室友
描述: 在腾讯当前端工程师,养了一只橘猫叫豆豆。
最近加班多,上周跟男朋友吵了一架。下个月来北京出差。
属性: {职业: "前端工程师", 公司: "腾讯", 城市: "深圳"}

直接塞进 system prompt,模型立刻就知道小红是谁。不需要语义搜索。

两步检索

实际查询策略分两步:

// 第一步:通过 contacts 表拿关联的 memory ID(O(1) 查找)
const contact = await db.contacts.findByName(userId, mentionedName)
const linkedMemories = await db.memories.findByIds(contact.memory_ids)

// 第二步:ILIKE 兜底,抓未关联的记忆
const unlinkedMemories = await db.memories.findByContent(
  userId, mentionedName,
  { exclude: contact.memory_ids }
)

第一步是精确的——通过联系人表直接拿到关联 ID,O(1)。第二步是兜底——ILIKE 扫描抓住那些还没被关联的记忆。

兜底为什么重要?有些记忆是在 syncContacts() 上线之前创建的,有些是同步失败漏掉的。随着越来越多记忆被正确关联,ILIKE 兜底触发的次数会越来越少。

系统在自我改善。

踩过的坑

真实 bug 列表,不美化。

数组下标错位——最严重的一个。storedIds[i]personMemories[i] 对不上,因为 storedIds 是混合 ADD/UPDATE 操作的返回结果,顺序跟输入数组不一致。结果:错误的记忆关联到了错误的联系人。改成从数据库查 content→ID 的映射,不再依赖数组下标。

UUID 空数组崩溃——传空的 UUID 数组给 Postgres 的 ANY() 操作符会报类型推断错误。拆成两个独立查询,空数组时跳过第一步。

重要性地板太高——GREATEST(0.1, importance * 0.5) 本意是给被合并的记忆降权,但 0.1 这个下限还是太高了,碎片在搜索结果里一直出现,跟合并后的档案抢位置。直接改成 0.05。

CJK 截断——.slice(0, 100) 在多字节中文字符中间切一刀,生成乱码。改成 [...text].slice(0, 100).join(''),按字符而不是字节截断。

重复合并——合并器反复处理已经合并过的记忆碎片,每次都生成一份新档案。加了 (metadata->>'consolidated_into') IS NULL 过滤条件,只处理还没被合并的。

没解决的问题

不包装,直接列:

  • 联系人匹配是精确字符串匹配——没有模糊匹配,"小红"和"红红"不会被识别为同一个人
  • 代词解析缺失——"她最近怎么样了"里的"她"不会解析成"小红"。联系人卡片注入 system prompt 后模型能自己猜,但前提是用户在同一轮对话里显式提过名字
  • 合并器是 O(N²)——要比较每对联系人的记忆集合,500+ 条记忆后开始变慢
  • PostgreSQL 内置分词器对中文基本没用,全文索引在中文场景下形同虚设
  • 并发提取有竞态条件——两个并行的提取任务可能同时创建同一个联系人的重复记录

可扩展性方面:假设用户每天 20 条消息,每年积累大约 600 条记忆,三年 1800 条。O(N²) 的合并器在到那个量级之前就会开始吃力。还没解决,先记着。


本文也有 English version

← PrevNext →

© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0