如果你也做过对话类产品,一定见过这种尴尬:用户昨天才说完“我对芒果过敏”,今天助手却贴心推荐了芒果慕斯。问题不是模型不够聪明,而是它根本没有长期记忆。我在给 Hermes 助手设计记忆模块时,决定不再简单把历史消息塞进 Prompt,而是专门做了一层独立的 Hermes Memory。核心目标就两个:跨会话记住用户,并用它稳定地做 USER 建模。真正动手后我发现,难点不是存储,而是取舍——该记什么、不记什么、什么时候必须忘。最终落地的方案,就是我常说的“有界+冻结”双闸设计。这篇文章把设计思路、核心代码和踩过的坑完整拆一遍,给准备做长期记忆的朋友一个可参考的模板。
1. 为什么跨会话记忆需要一个“双闸”
1.1 会话记忆与长期记忆的本质区别
很多人做记忆系统时,第一反应是“把聊天记录存起来,下次再塞给模型”。这其实混淆了两类东西:会话记忆(Session Memory)和长期记忆(Long-term Memory)。
会话记忆解决的是“当前这一轮对话上下文”,比如用户刚才说过“我正在看某款手机”,接下来的回答要基于这个手机展开。它天然是短命的,对话一结束就可以丢。长期记忆要解决的是“用户上一次找你是什么时候、提过什么要紧事、有哪些长期偏好”。比如用户三个月前说“我在备考,周末没空”,三个月后你再推周末活动就要小心。
两者最大的区别在于生命周期和稳定性。会话记忆可以接受噪声,错误信息过几分钟就被冲走了;长期记忆一旦写错,可能会在后续几个月里反复误导系统。所以长期记忆不能只是缓存,它必须有边界、有约束、有更新规则。我在 Hermes Memory 里最早就定了一个原则:长期记忆必须经过两道闸门才能落库,一道管“能不能存”,一道管“能不能改”。
1.2 用户建模的难题:画像漂移与隐私边界
所谓 USER 建模,说白了就是给每个用户建一份“画像”,让系统在不同会话之间记住这个人的身份、偏好、禁忌、场景。它通常包含两类信息:一类是显式事实,比如“用户叫小林,住在南方”;另一类是隐式偏好,比如“用户晚上更活跃,喜欢简洁回复”。
用户建模的难度不在于存,而在于画像会漂移。用户今天说喜欢喝甜的,明天体检完说要控糖;上周还爱吃辣,这周胃不舒服就忌口了。如果不加约束,画像会像墙头草一样被最新对话带偏,甚至被错误信息污染。我见过一个客服系统,因为用户某次误点了一句“我不需要售后”,后面半年都没再收到售后推送,这就是典型的画像污染。
另外还有一个绕不开的问题是隐私边界。长期记忆本质上是用户的私有信息,记什么、存多久、要不要给用户看,这些必须在设计层面就定义清楚。如果系统无限记录一切,不但会让用户产生被监控感,也会让数据管理变得不可控。“有界”这个概念,某种意义上也是给隐私设置一条水位线。
1.3 双闸的感性理解:水闸与水库模型
不要被“双闸”这个词吓到,我拿一个生活里的场景类比。想象你要建一个水库:
第一道闸叫做“进水闸”,控制水量。水库的容量是有限的,你不能无限制往里灌水,否则会漫堤。对应到记忆系统里,就是“有界”闸:设定记忆条目的数量上限、Token 预算、时间衰减规则,防止记忆无限膨胀。
第二道闸叫做“分水闸”,控制水质。水库旁边有几个特殊的保护区,里面的水不能和普通来水混合,比如饮用水源区必须冻结。对应到记忆系统里,就是“冻结”闸:用户的核心画像、不可变事实、明确声明过的禁忌,不能因为一次新的对话就随便改写。
两道闸的任务完全不同。第一个管“量”,第二个管“质”。没有“有界”,记忆系统会变成垃圾场;没有“冻结”,记忆系统会变成失忆症患者。两种毛病我都踩过,所以在 Hermes Memory 里一上来就同时设计了这两道闸。
2. 第一道闸:“有界”设计——给记忆划定边界
2.1 记忆容量如何量化:条目预算与 Token 预算
“有界”的第一层含义是容量上界。我在最初设计时,试过“不限制条数,只限制 Prompt 大小”,结果发现根本不可控:每条记忆长短不一,抽出来的事实经常互相重复,最后 Prompt 照样爆炸。后来我改成了双层预算。
第一层是条目预算。每个用户最多维护一定数量的长期记忆条目,我这边线上常用的配置是 300 条以内。超过之后,必须先淘汰或合并旧记忆,才能写入新的。第二层是 Token 预算。每条记忆在写入前会被压缩成结构化文本,控制单条长度,比如 64 到 128 Token 之间。这样即使一次取 20 条,也不会把主模型上下文塞满。
计算预算时,我建议给不同信息类型分配不同的权重。比如“核心事实”权重高,“临时兴趣”权重低。我自己的打分会用加权形式:
Score = 0.4 × 时效性 + 0.3 × 重要度 + 0.2 × 类型权重 + 0.1 × 置信度
时效性看最近一次被提及的时间,重要度是写入模型给的一个 0 到 1 的分数,类型权重则是“身份 > 偏好 > 临时动态”的递减关系。这样做的目的是让系统在淘汰记忆时有依据,而不是简单地按 FIFO 或 LRU 一刀切。我试过纯 LRU,结果用户三年前提的一个“腰部旧伤”被挤掉了,后来推荐运动方案时翻了大车。
2.2 时间衰减与优先级排序:遗忘曲线落地
有界不只是数量限制,还包括时间维度。很多记忆是“偶尔提一次、但很重要”的,比如“用户的妈妈下周三过生日”。这类信息如果不做时间衰减,会永远占着一个坑;但如果你只依赖最近访问,它又很容易被误删。
我参考了简化的遗忘曲线做法,给每条记忆加一个衰减因子。每次用户提到这条记忆相关的内容,它的“热度”会被刷新;但即使不刷新,重要度高的信息也会衰减得慢一点。简化公式是:
R = exp(-Δt / τ)
其中 Δt 是距离上次访问的时间差,τ 是不同类型记忆的半衰期。比如“身份信息”的 τ 可以设置成 365 天,“临时计划”的 τ 只有 7 天。这样筛选时,一条“半年前提到过但没再提”的临时兴趣会自然落到末端,而身份类信息会长期保留。
线上最常见的问题是把时间衰减做成“绝对过期”。我不建议设定一个硬性的“90 天过期”,因为用户有可能 180 天后又回到原来的偏好。更稳的做法是让衰减影响排序,而不是直接删除;只有被压缩或淘汰时才真正删掉低分记忆。这样既保证记忆池不会堆积,又给用户留了回头路。
2.3 摘要归并:把流水账变成关键事实
长期记忆里最常见的冗余是“同一件事换个说法又记了一遍”。用户第一周说“我在学吉他”,第二周说“吉他课要练到十一月底”,第三周说“最近在练一首弹唱曲”。如果三条都存,就占了三倍的预算。
所以我做了一个“归并”阶段,在写入新记忆前,先做一次相似度匹配。相似度高的旧条目不是直接丢弃,而是合并成一条更完整的结构化事实:
- 旧条目 1:用户在学习吉他
- 旧条目 2:吉他课持续到十一月底
- 新条目:最近在练习弹唱曲
- 合并结果:用户正在学吉他,课程到十一月底,目前偏好弹唱曲
不要小看这个步骤。我一开始觉得“多存几条也没事”,结果跑了两个星期,300 条预算里有 40% 都是重复信息。加入归并机制后,预算占用下降了一半,检索准确率也明显提高。需要注意的是,归并不意味着用大模型做“自由摘要”,因为大模型可能会编造原对话里没有的细节。我更喜欢结构化的“槽位合并”:只把相同维度的内容放到一起,不同维度的内容保留原文证据。
3. 第二道闸:“冻结”设计——保护用户画像不被污染
3.1 哪些信息必须冻结
如果说“有界”是控制记忆的数量,那么“冻结”就是锁定最关键的那部分用户画像。不是所有信息都有资格被长期信任,也不是所有新对话都有资格改写对用户的定义。我在 Hermes Memory 里把“必须冻结”的信息分成几个大类:
- 身份主数据:姓名、性别、所在城市、职业等稳定属性。
- 健康与安全:过敏原、慢性病、用药忌口、行动障碍。
- 明确声明过的边界:用户说过“不要给我推某个话题”“我不想讨论某类内容”。
- 承诺类事件:比如“我下个月要考试”“我在还房贷”,这类信息短期影响大,冻结期通常到事件结束。
判断标准就一条:如果这条信息被错误改写,系统会在后续很长一段时间里提供错误服务,那它就应该冻结。用户说自己住在北京,因为一次对话里误选了上海,后面所有本地服务都推错了。这类代价系统承担不起。
还需要特别强调,冻结不是“永久不变”,而是“非经特定流程不能变”。用户自己改口不算错误,所以要留解冻通道。我在设计状态机时,把所有记忆条目标注为 draft(草稿)、committed(生效)、frozen(冻结)三种状态。frozen 状态只能由显式确认、用户主动修改或系统人工复核来变更。
3.2 怎么判断什么时候冻结:置信度、验证次数与复核
冻结听起来简单,但难点在于“什么时候冻结”。如果用户在闲聊中随口说“我这人脾气不好”,你就把它冻结成“用户脾气不好”,那系统就真的太草率了。我一般用三道关卡判断:
第一是置信度。写入口会对抽取出的信息给一个置信度分数,比如“用户说自己住在上海”这种明确陈述,置信度可以到 0.9;而“用户可能喜欢吃辣”这种推测式内容,置信度只有 0.4。低置信度永远不进冻结池。
第二是验证次数。一条信息如果只被提到一次,哪怕置信度很高,我也倾向于先进入“待冻结”而不是直接冻结。至少两次独立会话中提到相同结论,才自动进入冻结流程。这条规则挡住了很多“用户今天心情不好”带来的虚假断言。
第三是人工复核。对于价值观、偏好、健康类信息,我坚持保留人工复核入口。A/B 两天的数据都指向“用户讨厌打电话沟通”,我会让运营人员在后台点击确认后,这条才会真正冻结。听起来慢,但长期下来,画像的稳定性和可信度远远高于全自动流程。
3.3 解冻与复核:动态更新不堵死
冻结最大的风险是“刻舟求剑”。用户三个月前确实说过不吃辣,但现在因为喜欢的人爱吃辣,开始尝试吃微辣。如果系统死守着旧冻结,就会显得很笨。
我的解法是给冻结画像建立“版本链”。每次检测到用户的新表述与冻结画像冲突时,不直接改,而是生成一个冲突记录。系统会在回复里主动询问一句:“你之前说过不吃辣,现在要改成尝试微辣吗?”如果用户确认,旧冻结画像进入历史版本,新画像进入待冻结流程。这样既保留了稳定性,又允许用户主动进化。
我在这个环节踩过不少坑。最普遍的问题是“过度顺从新对话”——用户哪天心情不好说了一句“我再也不想用这个产品了”,如果系统立刻更新画像,后面一周都会特殊对待这位用户。正确的姿势是把临时输入和长期偏好分开,只有经过验证和用户确认,才能触碰冻结层。
4. 双闸协同工作的完整链路
4.1 写入链路:每一步都过闸
记忆系统的核心是写入链路。我把 Hermes Memory 写入流程拆成了五步:
- 会话快照进入抽取模块,抽出候选事实,并打上时间戳、来源会话 ID。
- 候选事实做归一化和去重,先看能否与已有记忆合并。
- 过“有界”闸:计算当前总权重和新增权重,决定是直接写入、合并还是淘汰。
- 过“冻结”闸:与现有 frozen 画像做冲突检测。若有冲突,进入待确认队列,不能直接覆盖。
- 通过后写入长期记忆库,同时更新用户画像版本。
这套链路最容易被忽略的是第 4 步。很多团队在写记忆时只做“去重”,不做“冲突检测”,结果旧偏好和新说法互相打架。比如画像里已有“用户喜欢安静”,新对话说“今天想听摇滚”,系统直接把“安静”改成“摇滚”。这就错了,因为“今天想听摇滚”是一个上下文相关需求,不是长期偏好的变更。冲突检测的作用就是拦住这种误更新。
4.2 读取链路:检索与闸门后的生成
写入要过闸,读取其实也要过闸。我在读取侧做了类似的双层过滤:
第一层是召回。根据用户当前的问题,同时走关键词检索和语义向量检索,把最相关的记忆候选拉出来。这个阶段允许宁多勿缺。
第二层是排序过滤。先做“有界过滤”:只保留在预算权重和时效性范围内的条目;再做“冻结过滤”:如果候选记忆与用户当前请求冲突,比如用户明确说了“今天不要提考试”,那即使候选记忆里有“正在备考”,也要压低权重或直接剔除。
最后才是拼装 Prompt。我建议把冻结画像放在 Prompt 的“稳定上下文”区,把动态记忆放在“参考上下文”区。这样主模型会优先遵循“用户不可变事实”,同时参考近期动态信息。这个细节很关键,因为有些模型对 Prompt 中部的信息关注度不稳定,位置变化会对最终回答产生明显影响。
4.3 隐私与合规:有界可删、冻结可控
双闸设计在隐私保护上也有天然优势。“有界”意味着系统不会无限堆数据,超过预算就自动淘汰或压缩,相当于默认做数据最小化。“冻结”意味着用户核心画像的变更要有迹可循,可以追溯审计。我强烈建议在产品层面给用户提供一个“记忆管理界面”,让用户能查看当前系统对自己信息的记忆、删除某条印象、或主动修正画像。
我在线上做过一个小实验:给用户开放记忆编辑权限后,主动发起修改请求的用户比例不算高,但一旦用户使用过,后续对话的满意度明显提升。原因很简单:当用户知道系统能记住,并且自己可以控制时,他们对 AI 的信任度会高很多。没有双闸时,记忆系统像黑盒;有了双闸与后端记录,至少每一笔写入和每一次冻结都有明确的上下文来源。
5. 项目落地:数据模型、代码与评估
5.1 数据模型与存储选型
直接给一套可落地的数据设计。我的底层用的是关系库存储用户画像和记忆元数据,向量库负责语义检索。关系库不一定要很长,最基本的表如下:
CREATE TABLE memory_items ( id BIGINT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, item_type VARCHAR(32) NOT NULL, importance FLOAT DEFAULT 0.5, confidence FLOAT DEFAULT 0.5, state VARCHAR(16) DEFAULT 'draft', version INT DEFAULT 1, last_access_at TIMESTAMP, created_at TIMESTAMP, source_session VARCHAR(128) ); CREATE TABLE profile_items ( id BIGINT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, field_name VARCHAR(64) NOT NULL, field_value TEXT NOT NULL, state VARCHAR(16) DEFAULT 'committed', version INT DEFAULT 1, locked_at TIMESTAMP, expires_at TIMESTAMP, confirmed_by VARCHAR(64) );我解释几个关键字段:importance和confidence一起决定“有界”闸里的分数;state标记 draft / committed / frozen;version用于并发控制。没有加外键关联,是因为很多记忆条目会在归并时重写,硬关联会拖慢流程。
存储选型上,单机项目可以直接用 SQLite,规模上来后我迁到了 PostgreSQL。向量索引选择开源方案就好,关键是对结构化画像和自由文本记忆分开建索引。自由文本记忆走语义召回,结构化画像走字段匹配,两者在读取链路再合并排序。
5.2 核心代码片段的实现解读
写一段最简核心逻辑,展示双闸在写入侧是怎么运作的。这段代码会省略一些工程细节,但流程是完整的:
BUDGET = 2000 # 每个用户的记忆总权重预算 def write_memory(user_id, new_item): # 第一道闸:有界检查 current_weight = sum_memory_weight(user_id) new_weight = compute_weight(new_item) if current_weight + new_weight > BUDGET: merged = try_merge_similar(user_id, new_item) if not merged: evict_lowest_score(user_id, needed=new_weight) # 第二道闸:冻结冲突检查 conflict = check_frozen_conflict(user_id, new_item) if conflict: enqueue_confirmation(user_id, new_item, conflict) return {"status": "pending", "conflict": True} # 写入记忆,并更新画像版本 insert_memory(user_id, new_item) bump_profile_version(user_id) return {"status": "committed"}try_merge_similar是我上面说的归并模块,evict_lowest_score用的是加权分数淘汰,而check_frozen_conflict会去查profile_items里 state='frozen' 的字段。代码看起来短,但真正的工程量都在每个函数内部。比如check_frozen_conflict,不只要做字段同名匹配,还要做语义冲突,比如“不吃辣”和“喜欢川菜”就有潜在冲突。我最终是用大模型配合少量规则来做的,但会给冲突检测加一个“疑似”标签,绝不自动覆盖。
5.3 线上评估与调参经验
双闸设计跑起来之后,需要一套评估指标来验证到底有没有用。我常用的离线指标有三个:
- 记忆召回率:用户问到某个历史事实时,系统能否在 Top-20 候选里检索到。
- 画像漂移率:一段时间内被推翻的用户画像数量 / 总画像数量,用来检测“冻结”闸是否有效。
- 上下文注入长度:实际注入 Prompt 的记忆 Token 数,用来检测“有界”闸是否有效。
在线指标则看用户显式反馈。我会在对话里随机插入一个轻量的“刚才我记错你的情况了吗”的反馈按钮,用户点“记错了”之后,系统要能定位到具体记忆条目并生成修正事件。这个反馈回路非常重要,因为离线测试永远模拟不了真实用户的反复无常。
调参上,我最大的经验是“不要追求完美”:把有界预算设得太小,重要的长期信息会被挤掉;设得太大,Prompt 会变长、噪音会变多。我在线上用的 BUDGET 值,经历了从 600 到 4000 再到 2000 的过程。最终选择 2000,是因为它能覆盖约 20 条核心记忆,又不至于影响主模型的响应速度。冻结置信度阈值我稳定在 0.85,低于这个值的自动冻结基本都会出问题。
6. 实战中的坑与排查手册
6.1 重要信息被“有界”截掉的补救
我遇到过最常见的问题是:用户留了手机号,但因为长期没再提,被系统当低优先级记忆淘汰了。等到用户下一次问“你还记得我电话吗”,系统哑火,体验非常糟糕。
解决办法是在记忆写入时增加“白名单类型”。联系方式、家庭地址、医疗信息、用户名等强制进入冻结区,不参与普通有界淘汰。这类信息的数量通常很少,不会破坏“有界”的整体预算。我还做了一个“置顶”功能,允许运营人员在后台把某条记忆固定为 intern 或者 core,即使权重偏低也不参与 eviction。总而言之,有界不是一刀切,一定要给关键信息留逃生通道。
6.2 冻结模型与用户意愿的冲突
另一个坑出现在“冻结得太死”上。有一次,系统根据用户过去两个月的表现冻结了“偏好文字回复”,但用户在那天刚好需要语音播报,并向助手抱怨“别总打字”。助手却因为冻结画像,仍然自动把回复改成了文字,导致用户觉得系统完全不听人话。
这件事让我意识到,冻结层必须对“当前指令”保持灵敏。正确做法是:冻结画像用于理解长期偏好,但每次会话的显式指令优先于冻结画像。如果用户说“现在换语音”,系统应该立刻切换当前交互方式,同时生成一个“偏好变更待确认”的事件,而不是僵化地执行冻结规则。用户永远需要觉得自己能控制 AI,而不是被 AI 的定义控制。
6.3 并发写入与画像版本冲突
多会话并发写入时,画像版本冲突几乎是必然的。用户可能在手机上让 AI 更新“住在北京”,同时网页端又提交了“经常去上海出差”,两个会话各写各的,后写的人覆盖先写的人,最终画像丢失了一半信息。
我用版本号 + 乐观锁解决。每次更新profile_items时带上当前version,更新语句里加WHERE version = 旧版本,如果影响行数为 0,说明冲突,就拉起合并线程。合并规则是“字段级别合并”,比如城市字段有冲突,就记录为“常住北京 / 近期常去上海”,而不是粗暴覆盖。这套机制在写入链路的代码里并不复杂,但能避免大量脏数据。
6.4 冷启动用户的记忆稀疏问题
新用户没有任何历史记忆,双闸设计会显得“反应迟钝”。我踩过的坑是:在最开始的对话里问太多个人问题,想快速填满画像,结果用户觉得被打探隐私,直接流失。
后来我改成“引导式初期采集”:第一轮对话只让用户自然表达 2 到 3 个最在意的问题,比如“最近在忙什么”“有什么特别的需求”,然后从回答中抽取结构化事实。并且保证这些抽取结果对用户可见,最好在回复里带一句“我记住你对芒果过敏了,之后会注意”。这样既完成了冷启动,又不会让用户觉得被窥探。
如果跑了十轮对话用户画像还是很稀疏,我会在系统里启用“公共默认画像”兜底:比如默认用户喜欢简洁回复、默认不推荐高风险内容。有一个兜底,至少比完全空白要稳。
最后再分享一个我个人的体会:记忆系统做了大半年,最深的感触是,记忆的价值不在于“存得多”,而在于“在关键时候能拿出对的那一条”。有界让系统保持清醒,不会淹没在历史里;冻结让系统有原则,不会被噪声轻易带偏。如果你也在做跨会话记忆,建议先想清楚这两道闸,再去折腾更花哨的向量检索和模型微调。后面我打算继续优化的是“用户主动管理记忆”的可视化界面,这可能是整个系统里最能赢得信任的部分。