跨会话记忆是个听着简单、做起来容易翻车的功能。很多团队第一版只是把历史消息全塞进上下文,试了几天就发现Token暴涨、回答变慢、用户随口一句气话模型就当成事实记住了。我们后来在某个客服与导购场景里做了一个内部项目,代号 Hermes Memory,核心思路就是:跨会话记忆用“有界”管住长度,用户建模用“冻结”管住变化。所谓双闸,一道闸卡在外部信息进入模型上下文的入口,另一道闸卡在对话内容写入用户画像的入口。这篇文章把设计思路、核心参数、伪代码实现,以及我实际踩过的坑完整写一遍,适合正在做对话系统或智能助手、被“长期记忆越做越乱”困扰的团队参考。
1. 为什么跨会话记忆会“越做越乱”
1.1 记忆不是缓存,而是状态管理
早期版本里我们把记忆当缓存处理,只保留最近N轮消息塞给模型,短期效果还可以。真正的问题出现在对话超过20轮以后:模型开始“记混”。某次测试里,用户A明确说过喜欢简洁风格,模型却在另一个会话里把用户B的某次消费偏好当成A的事实,推荐完全跑偏。排查了很久,问题根源不是模型变笨了,而是我们把“最近对话”和“长期用户状态”混在了一起。
缓存只关心“最近发生了什么”,状态管理却要回答“这个用户长期是什么样的人”。这两件事的生命周期完全不一样。我用一个生活化类比来解释:对话记录像外卖订单,用后即弃;用户画像像账户里的收货地址和口味偏好,要长期维护,不能被一次差评覆盖。把订单和档案放在同一个篮子里,必然会出现互相污染。Hermes Memory 的整套设计,本质上就是先把这两类数据从物理上和流程上拆开。
1.2 “有界+冻结”到底是哪两道闸
有界闸(Bounded Gate)控制的是“外部记忆进入模型上下文”的边界。所有记忆必须满足长度、条数、相关性约束,超限内容不能直接进 Prompt,只能以摘要或结构化字段的形式存在。它从源头防止上下文无限膨胀,也防止模型抓住一条过期噪声当成当前依据。
冻结闸(Frozen Gate)控制的是“对话内容写入用户画像”的权限。已经冻结的画像字段默认只读,普通对话流程不能直接修改,只有后台画像管理流程在置信度达标、观察期满足后,才能更新。它用来防止画像漂移,避免用户某一次冲动发言把长期建模带偏。
打个比方,有界闸像餐厅后厨的出菜口,一次只能传出几道菜;冻结闸像客户档案柜,服务员能查阅但不能改,店长审核通过后才能更新。这两道闸都是应用层硬约束,不是靠提示词“请模型不要乱写”那种软约束。
1.3 这套设计适合什么场景
先看三类常见做法对比:
| 方案 | 优点 | 风险 | 适合阶段 |
|---|---|---|---|
| 全量历史拼接 | 实现简单,信息无损 | Token成本随轮数线性涨,噪声大 | 短期Demo |
| 无约束向量召回 | 可控制长度 | 写入不受控,画像容易漂移 | 中期可用,长期难稳 |
| 有界+冻结双闸 | 读和写都受控,长期稳定 | 实现复杂度高 | 长期跨会话场景 |
我们最终选择双闸,是因为业务里用户隔几天再回来继续咨询的比例超过三成。没有记忆,用户要把需求重新说一遍,体验很劝退;有了太“活”的记忆,风险更大,模型可能把一句话气话当成用户真实偏好。双闸设计还有一个好处:模型换版本时,应用层记忆规则可以不动,只需要重跑一次画像评估。
2. 整体架构:记忆从写入到召回的单行道
2.1 记忆生命周期
一条记忆在 Hermes Memory 里要经过:捕获、清洗、候选写入、有界校验、归档、冻结画像、召回只读、延迟更新。
拿一个真实例子拆解。用户说“上次那个蓝色保温杯还有货吗”。捕获层先提取实体:上次、蓝色保温杯、有货。清洗层过滤口语噪声,保留可验证事实:用户对蓝色保温杯关注度提升。有界校验时,如果档案区已有类似记录,就做合并;如果条目数超上限,先触发压缩。召回时,模型只读到结构化记忆,不能直接改写画像。整个链路是单行道,从源头杜绝模型随口一句话污染长期状态。
2.2 三层存储结构
对应这个生命周期,存储分成三层:
| 存储层 | 内容 | 生命周期 | 写入条件 | 模型权限 |
|---|---|---|---|---|
| 工作记忆区 | 最近N轮原始消息 | 短,按小时级TTL | 对话实时写入 | 可读,不可持久 |
| 档案存储区 | 事实条目、摘要 | 中长,按访问频率衰减 | 过清洗和有界校验 | 可召回,不可直接改 |
| 画像冻结区 | 用户属性、偏好、置信度 | 长,默认只读 | 观察期+置信度达标 | 只读,特殊流程可更新 |
为什么非要三层分开?如果所有消息都进长期存储,检索时会召回大量无关内容,且摘要和原始消息互相打架。三层分离本质上是把“原始数据”“可检索事实”“用户模型”这三种生命周期完全不同的东西分开管理。工作记忆区可以丢,档案存储区必须有取舍,画像冻结区必须稳定。
2.3 双闸在架构里的位置
有界闸放在召回路径和写入路径上。召回前先做候选筛选,写入前先做去重和长度检查。冻结闸放在档案区到画像区的边界上,画像区不直接接收对话流,只能通过后台画像评估服务写入。
这里要强调,双闸不是模型内部的注意力机制,而是应用层的数据治理规则。如果只靠提示词让模型“不要乱写记忆”,效果很不稳定,因为生成内容天然不可控。真正有效的方式是在代码层面硬约束:模型没有修改画像的接口,它只能查。
2.4 异步化写入
不要在模型推理线程里同步写记忆。原因有两个:第一,模型输出还没终稿,生成过程中抽取的信息可能是幻觉;第二,同步写会拖慢首字延迟,而且并发会话容易互相覆盖。
我们的做法是把完整对话发到异步队列,消费者负责抽取、去重、摘要、画像评估,写库失败可以重试。读取侧保持同步,因为召回要求快;写入侧异步,用户不会傻等“记忆写完”才看到回复。“读同步、写异步”是记忆系统里非常实用的稳定手段。
3. 有界闸:把无限对话约束到可控上下文
3.1 有界窗口大小怎么定
有界窗口不能拍脑袋定。我们在某个客服场景里用8K上下文的模型,给记忆部分的预算是3.2K Token,其中滚动摘要占2K,召回条目占1.2K,剩下4.8K留给当前对话和系统指令。即使模型上下文支持到32K,我也不建议无限放大记忆预算,因为每多塞一条旧记忆,模型对当前任务的注意力就会被稀释一点。
通用估算公式是:memory_quota = min(limit, context_limit - reserve_for_current - system_overhead)。limit 一般取 context_limit 的30%到40%。我们当时用40%效果比较稳,因为客服场景需要较多商品和历史订单信息;如果是纯闲聊,25%就够了。有界的本质是主动做取舍,而不是把模型能装下的全部塞进去。
3.2 摘要分层与关键字段
只做一层摘要是最常见的失误。我们分了会话摘要和整体摘要两层。会话摘要负责“这次发生了什么”,整体摘要负责“用户长期关心什么、有哪些未完成事项”。每12轮或者记忆Token接近阈值时触发压缩。
生成摘要时不能只让模型自由发挥,必须强制包含三个字段:明确事实、待办事项、关键实体。如果摘要里抽不出关键实体,就不落库,重新生成。否则就会出现用户隔三天问“上次那个杯子”,模型完全不知道“杯子”指的是哪款。把 SKU、时间、金额这类信息从自然语言摘要里单独拎出来存,能显著降低跨会话信息丢失。
3.3 召回排序与配额分配
召回时如果只按相关性排序,模型很容易被一条“很相关但不重要”的旧记忆带偏。我们把相关性、新鲜度、重要性三者做加权:score = rel × recency^alpha × importance^beta。alpha 通常取0.3,beta 取0.5,目的是让重要信息不容易被时间冲掉,同时新鲜信息也有一定优势。
同一主题最多召回2条,同一个实体的多条记录先合并再去重。每次最多召回10条记忆,超过上限就丢掉低分项。这里要有一个认知:召回丢失不叫失败,有界本身就是取舍。真正要监控的是“该召回的关键信息有没有被意外丢掉”,而不是“召回数量够不够多”。
3.4 边界检查的伪代码
interface MemoryItem { id: string; content: string; kind: 'fact' | 'summary' | 'event'; lastAccessAt: number; importance: number; tokenEstimate: number; } function buildBoundedContext( candidates: MemoryItem[], quota: number, maxItems: number ): MemoryItem[] { const now = Date.now(); const sorted = candidates .filter(item => !item.expired) .sort((a, b) => score(b, now) - score(a, now)); const selected: MemoryItem[] = []; let used = 0; for (const item of sorted) { if (selected.length >= maxItems) break; if (used + item.tokenEstimate > quota) break; selected.push(item); used += item.tokenEstimate; } return selected; }这段代码的要点是:先过滤过期,再按分数排序,最后按配额截断。tokenEstimate 在写入时就算好并缓存,不要在召回时现算,否则每次请求都会多出几十毫秒延迟。边界检查要同时放在写入和最终拼装两处,只校验候选列表不校验最终 Prompt,还是会超限。
3.5 摘要仍然超限怎么办
如果压缩完还是超出预算,就把最重要的结构化字段抽出来,原文只留锚点。例如“用户在下单页停留了很久”可以转成:cart_abandon: true, product: SKU-8823, time: 2025-06-01。模型需要细节时,通过查询函数补齐,而不是把所有细节都预埋进上下文。这是记忆系统从“塞得多”到“查得到”的关键转变。
4. 冻结闸:用户建模怎样不被一次对话带偏
4.1 画像字段的候选、验证、冻结
用户建模最忌讳“抽出来就写死”。用户说一句“我不喜欢太甜的”,可能是当天心情,也可能只是针对眼前这杯饮料。Hermes Memory 的做法是:先存观察区,记为候选字段,置信度0.4;7天内如果又出现两次“少糖”表达,置信度提升到0.8,才冻结进画像区。
这里必须区分“情绪表达”和“稳定偏好”。气话、抱怨、冲动评价都不能直接建模为事实。我们后来做了一个情绪门控,分数太低的抽取结果直接不进入候选区。所谓“聪明”的用户建模,不是抓住每句话,而是知道哪些话不值得长期记住。
4.2 冻结状态机
画像字段有四个状态:
| 状态 | 含义 | 使用方式 |
|---|---|---|
| candidate | 候选,置信度不足 | 可弱引用,提示模板标注“可能偏好,但不确定” |
| frozen | 已冻结,默认只读 | 推理时只读,模型不能直接修改 |
| stale | 过期或疑似过时 | 不参与召回,等待重新确认 |
| unlocked | 临时解冻 | 仅在显式修改流程中出现,改完重新冻结 |
每个画像字段还要记录 updatedAt、sourceSessionId、confidence。这样做的好处是,任何一次画像变更都能回溯到是哪一次对话引起的。没有来源溯源的画像系统,出了问题只能靠猜。
4.3 更新策略:多数一致与显式优先
更新画像遵循两条原则。第一,至少两次独立会话表达同一偏好,才升级为冻结字段;第二,用户显式说“以后都这样”权重最高,可以直接冻结。隐式推断永远不能压过显式确认。
如果用户某次说“我现在不喜欢这个了”,系统先把原 frozen 字段置为 stale,然后重新开始观察期,而不是立刻反向覆盖。为什么不立刻反向覆盖?因为用户可能只是对当前某个具体推荐不满,并不代表长期偏好反转。过快覆盖会导致画像剧烈震荡,比不更新更伤体验。
4.4 纠错流程与版本管理
冻结字段也会错。发现错误的途径通常是用户投诉或下游行为反馈。纠错流程是:收到错误信号 → 字段置为 unlocked → 人工或规则复核 → 修改 → 重新冻结。所有变更都写进审计日志,画像带版本号。
我们把画像版本号拼进召回上下文,模型回答时如果发现版本过旧,会自动触发查询最新画像。这能避免端上缓存了脏数据还继续使用。版本管理听起来重,但对长期运行的系统非常值,因为画像一旦出错,影响的是所有后续会话。
4.5 两道闸如何配合
很多系统只做召回、不做写入治理,长期记忆最后变成垃圾桶,什么都往里装。有界闸决定哪些记忆“够格”被模型看到,冻结闸决定哪些观察“够格”被写入画像。前者管“读”,后者管“写”,组合起来才是完整闭环。两道闸缺一个,长期记忆都会出问题:只有有界没冻结,上下文不爆了,但画像还是会漂移;只有冻结没有有界,画像稳定了,Prompt 还是会越来越大。
5. 核心实现要点:伪代码级落地参考
5.1 写入去重与边界检查
# 伪代码:记忆写入前检查 def should_write_memory(candidate, existed, user_stats): # 阈值过滤:琐碎内容不写 if candidate.salience < SALIENCE_THRESHOLD: return False, "low_salience" # 去重合并:相似事实合并,避免重复条目挤占配额 if existed is not None and similar(candidate, existed): merged = merge(existed, candidate) return True, merged # 边界检查:条目数超过上限先触发压缩 if user_stats.archive_count >= MAX_ARCHIVE_ITEMS: trigger_compaction(user_id) # 压缩完成前先拒绝本次写入,防止无界增长 return False, "need_compaction" return True, candidate关键点:写入不是把模型抽取结果直接落库,要先过显著性阈值、去重、数量边界三道关。很多团队踩坑都源于“能抽出来就写进去”,导致档案区全是垃圾信息。
5.2 冻结画像的只读实现
from types import MappingProxyType profile = { "user_id": "u_1003", "偏好": "少糖", "confidence": 0.82, "frozen_at": 1710000000, } # 转成只读映射,推理侧拿到的只能是只读对象 readonly_profile = MappingProxyType(profile) def get_frozen_profile(user_id): return readonly_profile # 只读,任何写操作都会抛 TypeError如果是动态类型语言,要避免在推理线程里直接调 profile["偏好"] = "xxx",因为不会有编译器拦住你。更好的做法是把画像封装成对象,只暴露查询方法,不暴露修改方法。想改画像,必须走画像管理服务的专用接口。
5.3 召回排序实现
def recall_score(item, now, alpha=0.3, beta=0.5): recency = max(0.0, 1.0 - (now - item.last_access_at) / HALF_LIFE) relevance = item.relevance # 由检索模型计算 importance = item.importance return relevance * (recency ** alpha) * (importance ** beta)HALF_LIFE 我们设为30天,意思是30天没访问的记忆,新鲜度只剩一半。重要性权重放在指数上,重要信息不容易被时间冲掉。线上运行一段时间后,要拿真实 Bad case 反推 alpha 和 beta 是否合适,而不是拍脑袋定完就不管。
5.4 一条记忆的完整链路
用户消息进来 → 抽取器提出事实 → 写入队列 → 消费者判断是否重复、是否达到阈值 → 有界校验 → 写入档案区。如果判断属于画像类字段,还要先走候选区观察,不直接冻结。
这条链路既是代码流程,也是治理规则。排查问题时,优先查队列消费日志,而不是先怀疑模型。大多数记忆问题都出在数据流动的某一段被写脏了,而不是模型理解能力不行。
6. 参数配置与调优记录
6.1 一组可参考的初始配置
下面这组参数是我们某个客服项目的起点,不代表标准答案,不同业务必须重新测:
| 参数 | 初始值 | 说明 |
|---|---|---|
| memory_quota | 3200 tokens | 记忆区总预算 |
| max_recall_items | 10 | 每次最多召回条目数 |
| summary_interval | 12轮 | 触发会话摘要压缩间隔 |
| candidate_confidence | 0.6 | 候选画像进入冻结的最低置信度 |
| observation_window | 7天 | 画像观察期 |
| stale_after | 180天 | 画像字段过期时间 |
| salience_threshold | 0.35 | 写入档案的最低显著度 |
| relevance_threshold | 0.55 | 召回最低相关性阈值 |
6.2 线上观察哪些指标
第一类是成本效率:平均单轮上下文 Token 量、每用户每周记忆存储增量、摘要压缩比。第二类是效果指标:用户重复描述率、画像准确率抽检、记忆命中率、Bad case 率。
我们最关注的是“用户重复描述率”。如果用户第二次回来说“我之前不是说过吗”,就说明记忆没起作用。这个指标可以直接从客服会话里做文本分类统计,比单纯看模型回答质量更贴近业务。只要重复描述率能持续下降,说明记忆系统确实在帮用户省事。
6.3 三轮真实调优记录
第一轮,摘要失真。现象是用户隔三天回来问“上次那个杯子”,模型不知道具体 SKU。原因不是模型傻,而是整体摘要没保存商品 ID。解决方法是摘要强制包含“关键实体”字段,实体单独结构化保存,不混在自然语言摘要里。改完之后这类 Bad case 下降约一半。
第二轮,画像污染。用户抱怨物流慢,模型后续回复变得过度道歉,甚至不敢推荐。原因是情绪化表达被当成了“用户对服务不满”的画像字段。解决办法是低情绪分内容不写档案,负向情绪要经过复核才能进入画像。这个规则上线后,误判型 Bad case 少了七成。
第三轮,过度冻结。用户已经明确转向极简风格,画像还认为用户喜欢复杂风格。原因是 frozen 字段没有过期机制。解决方法是给画像字段增加 stale 状态,用户连续三次对推荐内容不感兴趣,对应偏好字段自动进入待确认状态。注意,这里不是直接解冻,而是重新观察。
6.4 调参经验
一次只动一个变量,出了问题才知道是谁导致。先把 Bad case 按“没召回”“召回错”“摘要丢”“画像错”四类打标,再对症下药。如果集中在“没召回”,就调相关性阈值和衰减系数;如果集中在“画像错”,调冻结观察期和置信度阈值,调召回参数没用。调参最怕的是同时改八个参数,最后谁也不知道系统变好是哪个变量的功劳。
7. 常见问题与排查技巧实录
7.1 记忆串台
症状:用户 A 的对话里出现用户 B 的偏好。排查时先检查所有记忆读写是否都传了 user_id,是否存在全局缓存 key,是否并发用户共享了同一个画像对象。我们第一次遇到这个问题,是缓存 key 漏了 user_id,两个测试账号共享了一份画像。解决后给每条记忆加了 owner 字段,并在写入日志里打点,凡 owner 不一致的写入直接告警。
7.2 画像被单次极端情绪污染
症状:用户骂了一句,画像里多了一条“用户脾气差”。排查方法是看画像变更日志,定位 source_session_id;如果那条记录来自单次会话且情绪分数低,就是污染。解决方案是给抽取结果加情绪门控,低分不写入;同时要求画像字段必须经过观察期,禁止一条消息直接冻结。情绪过滤不是把负面词全屏蔽,而是不让情绪表达成为长期事实。
7.3 摘要丢失关键信息
症状:跨天会话后,模型记不住商品型号、时间、价格。回去看摘要原文,八成只有自然语言概述,没有结构化字段。方案是摘要模板固定三要素:明确事实、待办事项、关键实体。模型生成摘要后,再跑一次实体抽取做字段补全,抽不出关键实体的摘要不落库,重新生成。这个流程看起来多一次调用,但能省掉后面大量 Bad case。
7.4 记忆召回不到
症状:用户上周明明问过某件事,模型完全忘了。先看召回日志:候选列表里有没有这条记忆?如果没有,可能是写入时被阈值过滤掉了,或者去重合并掉了。如果有但没进最终上下文,可能是配额不够或排序分数太低。把问题二分到“写入侧丢了”还是“召回侧丢了”,能省大量排查时间。
7.5 上下文仍然超限
症状:启用了有界闸,Prompt 还是超过模型限制。建议检查最终拼装 Prompt 的代码,很可能模板里同时放了摘要和完整原始消息列表。有界闸只约束候选列表还不够,要在最终拼装入口做一次总长度断言,超过配额直接抛错,避免静默超限。我们加了断言之后,线上超限告警从每周十几起降到零。
7.6 冻结太久导致新偏好不识别
症状:用户明显已经改变习惯,画像却不更新。解决办法不是说把冻结机制去掉,而是加触发 stale 的场景。三个可用信号:用户主动纠正、连续行为反馈与画像相悖、画像字段超过有效期。stale 之后进入候选观察,而不是直接反转,这样既跟得上变化,又不会因为一次异常行为就大幅震荡。
表格速查:
| 问题 | 典型现象 | 快速排查方向 |
|---|---|---|
| 记忆串台 | A会话出现B偏好 | 检查 owner key |
| 画像污染 | 单次情绪变成画像 | 看 source_session_id |
| 摘要丢失 | 跨会话 SKU 记不住 | 验证摘要三要素 |
| 召回不到 | 上下文无旧记忆 | 写入丢还是召回丢 |
| 上下文超限 | Prompt 被截断 | 最终长度断言 |
| 画像不更新 | 与用户现状不符 | 查 stale 触发信号 |
8. 后续可以怎么做
如果只能带走一句话,我建议别急着把记忆做“聪明”,先把它做“稳”。有界和冻结本质上是在把记忆从不可控的模型行为变成可控的工程约束,有界治“无限”,冻结治“漂移”,应用层规则比提示词可靠得多。
后续值得尝试三个方向。第一,操作式召回,让模型在需要具体细节时才调用检索函数,再决定是否放进上下文,能进一步省 Token。第二,多 Agent 共享记忆,画像区全局共享、各 Agent 按权限读取,避免多个助手各记一份互相冲突。第三,给记忆做隐私分级,敏感字段单独加密,用户授权后才允许召回。这些方向我还没有完全跑通,目前最稳的还是先把双闸边界守住。后面如果做出新的结果,我再单独写一篇。