记忆系统最大的盲区是「人」
记忆碎片聚合为完整人物档案的概念插画
问题出在"人"上
第三篇里我说记忆系统是"皇冠上的宝石"——从 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。