☰
大模型长期记忆系统设计:从对话中提炼事实,让AI真正记住用户
2026/10/14 6:43:51 网站建设 项目流程

我做Mindbase这个项目的原因很普通:我负责的一个智能客服,用户上周明明在对话里说过“我只喝无糖的”,这周来问推荐饮料时,系统照样把含糖的选项推了出去。不是模型不行,而是它压根没有长期记忆。很多大模型应用都卡在这道坎上:API本质上是无状态的,每次会话都是全新的,上下文窗口再大也装不下几天前发生的事。于是我用一个周末起头,断断续续做了三个月,搞出了Mindbase——一个给大模型用的长期记忆系统。这篇文章会把整个设计过程、数据模型、抽取管线、召回策略、更新机制和上线后踩过的坑完整写出来,适合正在做智能助手、Agent、RAG应用,以及对“怎么让AI记住用户”这件事感兴趣的工程师。下面直接进入正题。

1. 先定义清楚:这个记忆系统究竟要记住什么

动手写代码之前,我把大量时间花在了“定义记忆”上。市面上很多所谓的AI记忆,本质上是把所有聊天记录倒进向量库,然后每次请求都做相似度搜索。这种方案能跑,但效果非常不稳定——它会记住大量没用的寒暄,也会漏掉真正重要的偏好变化。Mindbase的定位不是“聊天记录备份”,而是“对后续决策有影响的事实集合”。

1.1 LLM本身没有记忆,但塞进上下文不是长久之计

先说最基础的:大语言模型本身没有跨会话记忆,这是架构决定的。每次调用都是独立输入,模型只根据Prompt内容回答。所以很多人第一反应是“把历史记录全拼进Prompt不就行了?”我在早期Demo里试过,把过去三天的对话原文全部塞进上下文,效果确实能看出“记得一些事”,但很快暴露三个问题:

  • 成本随对话量线性上涨,用户聊得越多,每次请求的Token开销越大,很快就吃不消。
  • 上下文长度有限,真正需要的信息被海量寒暄稀释,模型反而抓不住重点。
  • 有些信息相互冲突,比如用户说过“我不喜欢甜食”,后来又点了一杯珍珠奶茶,原文堆在一起时模型根本无法判断哪个是当前偏好。

长期记忆系统的核心不是“无损存储”,而是“有损但有选择的存储”。我们需要从原始对话中提炼出结构化事实,把噪音丢掉,把关键信息留下,再在合适的时候把相关记忆带回Prompt里。

1.2 记忆的粒度:原子事实、场景索引、用户画像

我最初的模型是一张“用户偏好表”,后来发现不够用。用户说“我不吃香菜”和“昨天我去看牙医了”是两种完全不同的记忆:前者是稳定偏好,每次点餐都要考虑;后者是事件性事实,只在特定上下文里有用。如果不做区分,检索时就会互相干扰。

Mindbase把记忆拆成三个层次:

  • 用户画像(User Profile):稳定的长期偏好,比如“用户只喝无糖饮料”“用户喜欢靠窗座位”。
  • 情景片段(Episode):某次会话中发生的具体事件,比如“用户在5月10日预约了下周二上午的牙医”。
  • 规则与关系(Rule/Relation):用户明确提出的要求或人物关系,比如“以后所有邮件都抄送给李助理”“用户是某供应商的联系人”。

每个层次在存储和检索上的权重完全不同。用户画像几乎总是伴随用户请求召回;情景片段需要基于时间、地点、主题条件触发;规则与关系则依靠条件匹配。这个划分看起来简单,但直接影响后续表结构设计,也决定了记忆提取模型该输出什么字段。

2. Mindbase的数据模型:三张表外加一条日志

记忆系统不能把一切扔进一个“万能JSON字段”里就完事。我设计数据模型时反复推敲过:既要方便结构化管理,又要能追溯来源,还要支持向量检索。最终敲定的方案是:三张核心表加一张操作日志表。

2.1 事实表:用“主语-谓语-宾语”三元组存记忆

所有提取出来的记忆,统一落在一张事实表里。我没有存完整句子,而是拆成“主语-谓语-宾语”三元组。这样做的最大好处是支持结构化去重和冲突检测。

CREATE TABLE episodes ( id BIGSERIAL PRIMARY KEY, session_id TEXT NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, content JSONB NOT NULL, source TEXT NOT NULL ); CREATE TABLE facts ( id BIGSERIAL PRIMARY KEY, subject TEXT NOT NULL, predicate TEXT NOT NULL, object TEXT NOT NULL, fact_type TEXT NOT NULL, -- user_profile / episode / rule source_episode_id BIGINT REFERENCES episodes(id), importance REAL NOT NULL DEFAULT 0.5, confidence REAL NOT NULL DEFAULT 0.6, first_seen_at TIMESTAMPTZ NOT NULL, last_updated_at TIMESTAMPTZ NOT NULL, version INT NOT NULL DEFAULT 1, is_active BOOLEAN NOT NULL DEFAULT TRUE, CONSTRAINT uniq_fact UNIQUE (subject, predicate, object) ); CREATE TABLE memory_log ( id BIGSERIAL PRIMARY KEY, fact_id BIGINT REFERENCES facts(id), operation TEXT NOT NULL, -- create / update / deactivate reason TEXT NOT NULL, happened_at TIMESTAMPTZ NOT NULL DEFAULT now() );

一个例子:用户说“我以后不喝含糖饮料了”,抽取后就是subject=["用户"],predicate=["无糖偏好"],object=["严格禁止含糖饮料"]。注意这里的predicate我故意用的是业务语义而非动词原形,因为后续检索时更关心“这个事实属于什么维度”,而不是一个自然语言短语。这个细节是在做了两版之后才想通的,第一版用“prefers_drinks_without_sugar”这种字段名,反而让系统失去了灵活性。

2.2 原始场景表:让每条记忆都有据可查

单独存三元组有个致命问题:时间久了,你不知道这条记忆是怎么来的,也不知道当时的上下文是什么。比如用户说“我现在不想喝甜的”,可能只是那一瞬间的心情,并不是长期偏好。如果只看提取结果,很难判断可靠程度。

所以每条事实都带了source_episode_id,指向episodes表里对应的原始会话片段。content字段存的是当时的那几轮对话,JSON格式,方便必要时让大模型重新阅读。memory_log表则记录了所有新增、修改、废弃操作,理由也会写进去。这套审计链路一开始我嫌麻烦,后来收获很大——尤其当用户质疑“AI为什么记得我根本没说过的话”时,我能直接从日志里翻出是哪个环节造成了误提取。

2.3 向量索引:自托管关系库加向量扩展的组合

事实表本身是关系型的,但召回时需要在语义层面做相似度搜索,这时候必须用到向量索引。我对比过几种方案:独立部署一个专用向量数据库,嵌入到已有数据库里的向量扩展。最后选了后者——在同一个库上加一个向量字段,然后用关系查询和向量查询混合过滤。

ALTER TABLE facts ADD COLUMN embedding vector(768); CREATE INDEX ON facts USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

我选择了768维的嵌入模型,而不是常用的1536维,原因很实际:从“多个维度的效果提升”和“内存占用”来看,768已经够用,尤其事实表里很多条目是短文本,高维向量带来的收益非常有限。索引类型选IVFFlat而不是HNSW,是因为前期数据量不大、更新频繁,IVFFlat对写入更友好,训练速度和内存开销也更可控。等到数据量上百万之后再换HNSW也不迟。

3. 记忆写入:从对话里“捞”出值得记的东西

写入管线是整个系统里最不能偷懒的部分。模型再好,如果喂进去的是垃圾,记下来的也是垃圾。我的管线分为三步:大模型结构化抽取、规则过滤、去重合并。

3.1 用大模型做结构化抽取,Prompt要约束输出

我没有训练专门的信息抽取模型,而是直接用大模型的函数调用能力。每轮对话结束后,取最近几轮消息,让大模型判断其中是否存在值得长期记忆的信息。这里的关键是给足少样本示例,并且强制使用结构化输出格式。

{ "memories": [ { "subject": "用户", "predicate": "饮品偏好", "object": "只喝无糖饮料", "fact_type": "user_profile", "importance": 0.85, "confidence": 0.9 }, { "subject": "用户", "predicate": "预约安排", "object": "5月20日下午2点预约牙医", "fact_type": "episode", "importance": 0.6, "confidence": 0.95 } ] }

我用的抽取Prompt模板大致是这样:

你是一个记忆提取器。下面是一段对话历史。请提取其中值得长期记忆的信息。 只提取事实,不提取情绪、猜测、临时状态。 输出JSON数组,每个元素包含subject、predicate、object、fact_type、 importance(0到1)、confidence(0到1)。 如果没有任何值得记忆的信息,返回{"memories": []}。 示例: 对话:A: 我喜欢吃辣。B: 好的,下次推荐火锅。A: 不要太辣,微辣就行。 输出:[{"subject": "用户", "predicate": "吃辣偏好", "object": "可接受微辣,不喜欢太辣", ...}] 现在处理以下对话: {对话历史}

这里有一个很微妙的设计:让模型区分“临时状态”和“长期属性”。比如“我今天很累”不应该被记为长期事实,但“用户每天下午需要提醒喝水”则是值得记的规则。少样本不只要给正确的例子,还要给几个“不该提取”的例子,比如“今天天气不错”“谢谢”。这个做法在模型精度上提升明显。

3.2 规则过滤:临时情绪不记、隐私内容不记

不能完全信任大模型的抽取结果,我加了一道硬性规则层。规则分三类:

  • 临时性关键词:包含“今天”“现在”“这次”“刚刚”且不带“总是”“以后”“喜欢”等稳定标志词的句子,默认不提取。
  • 无意义表达:纯问候、道谢、感叹词,直接丢弃。
  • 隐私敏感信息:身份证号、银行卡号、密码等字段,即使大模型提取了,也不允许写入事实表。这个不只是产品问题,也是合规底线。

规则层用正则和词表就能实现大部分功能。它的价值不是替代大模型,而是兜底。大模型有创造力,会脑补出对话里根本没提到的信息,规则层负责把明显不该入库的东西拦在外面。我在测试里发现,加过滤规则后,记忆准确率能从75%提升到接近90%。

3.3 去重合并和置信度:记忆怎么从模糊变清晰

同一个用户可能在很多次对话里重复表达同一个偏好。比如第一次说“我比较喜欢喝气泡水”,第二次说“我只喝无气泡的”,第三次说“我喝水只喝苏打水”。如果不做合并,事实表里会积累大量互相矛盾的条目。

合并策略是我反复迭代后的版本:

  • 如果新事实和已有事实完全一致(三元组相同),直接把已有事实的last_updated_at刷新,并把confidence提高到min(1, 原值 + 0.1)。
  • 如果新事实和旧事实在逻辑上相近但不完全相同,且新事实来源时间更新、置信度更高,则把旧事实标记为is_active=false,然后新增一条事实。
  • 如果新事实与旧事实明显矛盾,则进入冲突处理流程,不盲目覆盖。

去重的关键依赖规范化。我在抽取阶段就要求大模型把主语统一成“用户”,而不是“我”“这位客户”“他”。遇到人称代词,抽取Prompt里专门有一句“对话中出现的用户一律用‘用户’代替”。这个规范看似简单,实际上让去重效果产生了质变。

4. 记忆读取:调度的艺术,上下文不是垃圾桶

记忆写入再完美,如果读取时一股脑把所有记忆塞进Prompt,系统依然会崩。读取阶段的核心是把当前对话和记忆库做一个“调度”:挑出此刻最相关的三五条记忆,而不是把整个脑子端上来。

4.1 混合检索打分:相似度、重要度、时间衰减

我最初只做纯向量相似度召回,效果惨不忍睹:用户问“今天天气怎么样”,系统把“用户三个月前说喜欢下雨天”这种旧记忆拉了过来,完全干扰判断。后来加入了混合打分公式:

score = 0.4 * cosine_sim + 0.3 * importance * decay(age) + 0.3 * context_relevance

cosine_sim是事实向量与当前查询向量的余弦相似度;importance是事实表里存的0到1的重要度;decay(age)是一个按时间衰减的函数,越久远得分越低;context_relevance则来自关系型条件过滤,比如fact_type=user_profile的恒为1,而fact_type=episode的只在查询里包含对应时间或地点时才为1。

def recall_memories(query_embedding, query_text, top_k=5): candidates = fetch_candidate_facts(query_text) scored = [] for fact in candidates: age = now() - fact.last_updated_at decay = math.exp(-age.days / 30.0) score = ( 0.4 * cosine_sim(fact.embedding, query_embedding) + 0.3 * fact.importance * decay + 0.3 * context_match(fact, query_text) ) scored.append((score, fact)) scored.sort(reverse=True) return scored[:top_k]

这个公式看起来很简单,但调参过程花了不少时间。decay里我用的半衰期是30天,也就是一个月之后,一条记忆的相关性自然衰减一半。具体数值因产品而异,如果是客服系统,用户偏好可以保留更久;如果是活动通知类的临时事件,半衰期就要缩短到几天。

4.2 注入Prompt的模板:记忆是配角不是主角

召回后怎么塞进Prompt?我的做法是放在System消息里,并且在记忆前面加一行说明,告诉模型这些是历史记忆,可能与当前问题相关,也可能不相关,需要自行判断。

你有以下几个关于用户的长期记忆,供参考: - [用户画像] 用户只喝无糖饮料(重要度0.85,更新时间2周前) - [情景片段] 用户在5月20日下午2点预约牙医(重要度0.6,更新时间1周前) 当前对话: {最新的几轮对话}

这里有个细节:每条记忆后面都带了重要度和更新时间。一开始我觉得没必要,后来发现模型非常聪明——只要满足条件,它会自动采用更重要、更新的记忆来做决策,甚至能主动忽略过期但相关的记忆。这比我们在代码里硬判断要灵活得多。

4.3 参数怎么定:先给初始值,再做回归调参

上面的打分公式里0.4/0.3/0.3不是拍脑袋拍出来的。我第一次跑通时用的全部是对半权重,效果很一般。后来我建了一个小小的评测集,用网格搜索跑了一遍,发现相似度权重过高会导致“什么都能拉出来”,重要度权重过高又会导致“只记得大事、忽略细节”。最终0.4/0.3/0.3是我测下来的一个稳定区间。

如果你的产品场景不同,不用照抄这组数。原则是:先给一组合理初始值,然后收集一批“用户提问 + 期望召回记忆”的标注数据,用简单的网格搜索或回归求出最佳权重。这事越早做越好,后期依赖纯人工调参,很容易陷入“改了A坏了B”的怪圈。

5. 记忆的更新与遗忘:被推翻的事要及时翻篇

长期记忆系统最难处理的是变化。用户今天说“我讨厌下雨”,下周又说“其实下雨天待在家里也不错”。如果系统坚持旧记忆,用户会觉得AI很笨;如果无脑覆盖,又可能丢失一些前提条件。

5.1 更正与否定:两种矛盾的处理思路

我把冲突分成两种。第一种是“更正”:用户改变了自己的偏好,新的陈述和旧记录矛盾,且新陈述明显更可信。这种情况直接把旧事实is_active=false,写入新事实。旧事实也不是彻底删除,而是保留在历史里,作为可追溯的审计信息。

第二种是“否定”:用户说他根本没说过某句话,或者旧记忆来源于一次错误的抽取。这时候单纯覆盖不够,还要在日志里写明“旧记忆因用户否认而废弃”,避免之后再次从原始对话片段中重复提取出同样的错误信息。

我还加了一个“待确认”状态。当新旧事实冲突,但两者置信度都不高时,不立即覆盖,而是生成一个待确认标记。下一次对话中,如果用户再次提到相关话题,系统会主动问一句“我记得你之前说过不喜欢吃香菜,现在还是这样吗?”这个确认逻辑一开始做得太激进,用户抱怨AI总爱自问自答,后来改成只在“重要度高且连续冲突至少两次”时才触发。

5.2 降级与遗忘:长期不用的记忆该让位

遗忘不是删除,而是降低优先级。我在事实表里增加了一个last_recalled_at字段,每次被召回时更新。当一个低重要度的事实超过30天没有被召回,它的检索权重会被进一步削弱;超过90天没有更新也没有召回,就自动标记为is_active=false。

这个机制很关键。如果没有遗忘,记忆库会被“用户喜欢灰色”这种正确但无用的信息占满,真正重要的“用户对花生严重过敏”反而可能被挤出TopK。遗忘是给重要信息腾地方。设计上不需要复杂算法,简单的LRU思路就够了,真正难的是决定“哪些东西值得被遗忘”——这个我倾向于让重要度低、召回频率低、且超过一定时间的三条规则同时生效。

6. 评测一个记忆系统:别满足于“好像记得”

做记忆系统最危险的事情是自欺欺人:手工测几条例子发现AI记得,就觉得大功告成。Mindbase上线前,我专门搭了评测流程,不然根本无法判断改动是变好还是变坏。

6.1 记忆评测集怎么造:模拟连续对话场景

我手工构造了50个用户场景,每个场景包含一个多会话时间线。比如:

会话1:用户提到自己有糖尿病,需要控制饮食。 会话2:用户问“咖啡可以喝吗?”,期望系统使用会话1的记忆回答“建议无糖或咨询医生”。 会话3:用户说“我现在可以吃一点甜食了”,期望系统更新饮食偏好。 会话4:用户再次问“我可以吃蛋糕吗?”,期望系统基于更新后的记忆回答“可以少量尝试,但要节制”。

我把这些时间线写成一个JSON数据集,然后用自动化脚本模拟一个又一个会话,跑完整条链路,查看最后一步的输出是否包含正确的记忆信号。这里有个小技巧:我会在每轮会话结束时统计事实表内容,对比期望写入的历史事实,这样可以定位是“没记住”还是“记了没用上”。

6.2 指标和对照实验:用数据说话

评测指标我主要看四个:

  • 记忆需求覆盖度:在所有应该调用历史记忆的场景中,系统成功召回相关记忆的比例。
  • 回忆准确率:被召回的记忆中,真正和当前问题相关的占比。
  • 上下文污染率:召回里包含了干扰信息,并导致最终回答质量下降的比例。
  • 更新正确率:用户改变偏好后,系统在下一轮能遵循新记忆的比例。

我记得第一次正式评测的结果很伤人:没有记忆系统时,覆盖度基本为0;把最近几轮对话塞进上下文,覆盖度约45%,污染率却高达30%;而Mindbase跑下来,覆盖度76%,污染率只有9%。虽然离完美还差很远,但已经能明显看出“有调度、有遗忘的记忆”比“暴力塞上下文”好了一个身位。

我把对照实验做成了表:

方案覆盖度污染率更新正确率单次平均Token成本
无记忆2%0%35%低
最近对话窗口45%30%42%中
全量历史塞入62%44%40%极高
Mindbase76%9%83%中

表格里Token成本一栏最有说服力:Mindbase虽然比单纯窗口模式多了一些数据库调用和向量计算,但单次Prompt的Token成本反而低了,因为系统每次只放进5条左右的高价值记忆,而不是塞几百行历史记录。

7. 上线三个月踩过的坑

最后写几个我在实际过程中遇到的、文档里很难查到的坑。这些坑每一个都让系统“看起来能用,但一上线就露馅”。

7.1 查不到的“伪记忆”:同指消解没做干净

最折磨人的一个bug是:入坑者自己也说不清“AI到底记了什么”。用户上周说“我女朋友喜欢猫”,抽取出的三元组是“用户-关系-女朋友”;这周用户说“她喜欢狗”,系统把“她”当成了新的主语,没有合并到女朋友身上。结果事实表里出现“她-喜欢-狗”这种毫无意义的僵尸记忆。

解决方案是双管齐下。抽取Prompt里强制加入实体归一化规则:“代词一律替换为最近对话中出现的实际指代对象,无法确认的丢弃”。同时在后处理阶段加了一个同指消解小模型,把“她”映射回“用户的女朋友”。这种细节不做,系统永远是个半成品。

7.2 删记忆比写记忆更难

有一次测试用户很生气地问“为什么AI还知道我上次骂人的话?”我才意识到系统只设计了写入和召回,没有设计“用户主动删除记忆”的入口。后来补了一个管理API,至少支持按事实ID删除、按类型批量清空,并且删除后需要在记忆库中插入一条“屏蔽词”,防止之后聊天中再次把相同内容写回来。

这里的教训是:凡是涉及用户数据的系统,都必须在一开始就把“遗忘权”做进设计里。等到用户投诉再补,心理压力和开发成本都会高很多。

7.3 异步批量抽取:把成本压下去

最初版本是在每次对话后立刻同步调用大模型做记忆抽取。用户多说几句话,token消耗就多一截,页面延迟也变高。后来改成异步队列:对话先返回,记忆抽取在后台跑,同一个会话的抽取任务合并成一批,批次内只取最近10轮,而不是每轮各跑一次。这个改动把抽取成本降了接近60%,体验反而更好,因为用户不会觉得AI“边说边想”。

Mindbase做下来,我的最大体会是:AI长期记忆这个选题,难点不在算法有多高深,而在于你怎么定义“值得记”,怎么在召回时克制住“什么都想给模型看”的冲动。如果你也想做类似系统,我的建议是从一个小范围开始:先支持用户画像,别的都先不做。把一个用户的稳定偏好存清楚、召回准,比做一个什么都能记但什么都记不准的“大而全”要实在得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询