跨会话记忆双闸设计:有界与冻结如何实现长期用户建模
跨会话记忆是个听着简单、做起来容易翻车的功能。很多团队第一版只是把历史消息全塞进上下文试了几天就发现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.3beta 取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.47天内如果又出现两次“少糖”表达置信度提升到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, alpha0.3, beta0.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_quota3200 tokens记忆区总预算max_recall_items10每次最多召回条目数summary_interval12轮触发会话摘要压缩间隔candidate_confidence0.6候选画像进入冻结的最低置信度observation_window7天画像观察期stale_after180天画像字段过期时间salience_threshold0.35写入档案的最低显著度relevance_threshold0.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 按权限读取避免多个助手各记一份互相冲突。第三给记忆做隐私分级敏感字段单独加密用户授权后才允许召回。这些方向我还没有完全跑通目前最稳的还是先把双闸边界守住。后面如果做出新的结果我再单独写一篇。