有界与冻结:对话系统跨会话记忆双闸设计实战
发布时间:2026/10/15 5:25:09 锦皓数字建站

在对话系统和大模型应用里摸爬滚打久了你迟早会撞上一个尴尬场景用户上周明明跟你聊过“家里小孩对花生过敏”这周回来你热情推荐了一款含花生碎的曲奇配方。用户没发火但你心里清楚——这个系统失忆了。跨会话记忆不是什么新鲜概念早年的客服机器人就有“上下文关联”可真正要把长期记忆做好、做稳、做不出幺蛾子远比想象中复杂。我最近在给一套对话服务升级记忆模块核心方案定名为“Hermes Memory”它最值得拿出来聊的设计是两个闸门一个叫“有界”一个叫“冻结”。这两个词拆开看不难理解但把它们组合起来做跨会话记忆与 USER 建模能解决一批很实际的工程问题。这套设计适合谁参考正在做对话式 AI、智能助手、Agent 记忆模块的开发者或者准备给自己的大模型应用加长期记忆能力但还没理清头绪的同学。它要解决的核心问题有三个第一记忆不能无限膨胀否则检索会失效第二记忆不能盲目更新否则用户画像会被一次随口闲聊带偏第三记忆必须与 USER 建模打通不能只存流水账而要沉淀出可用的用户特征。下面我把这套“有界冻结”双闸设计从需求拆解到落地实现完整讲一遍全程结合我这段时间的实操记录。1. 记忆系统设计之前的三个老问题动手写代码之前先想明白一件事我们到底为什么需要跨会话记忆最直接的原因是体验。没有记忆的对话系统就像金鱼每次交互都要从零开始用户说过的偏好、聊过的背景、确认过的事实全部作废。但比“体验差”更深一层的是商业价值——如果一个对话系统无法积累对用户的认知它就永远停留在“检索工具”的层面做不了个性化推荐、做不到主动关怀、也构不成真正的用户粘性。可一旦决定上记忆模块问题就跟着来了我总结成三类基础矛盾。1.1 对话系统失忆的根源大模型本身是无状态的。每一次请求都是独立推理模型不会记得你上一句说了什么更不会记得三天前聊了什么。所谓“记忆”本质上是我们在模型外部挂了存储再把存储内容以 prompt 的形式塞回给模型。工程上通常分两层短期记忆指当前会话内的上下文窗口长期记忆指跨会话持久化存储的用户事实与行为偏好。短期记忆好办直接把历史消息堆进上下文窗口就行但有长度上限。长期记忆就麻烦多了存什么、怎么存、什么时候取、什么时候更新、取出来会不会污染当前话题。这些问题没想清楚记忆模块上线后反而会比没记忆时更难用——模型会一本正经地引用过期的、错误的、甚至是另一个用户的记忆来回答问题。1.2 为什么简单的“拼上下文”行不通有同学可能觉得把历史会话全部存下来下次直接拼到系统提示词里不就行了吗我早期也这么干过结果两个问题立刻暴露。第一是 token 成本爆炸。一个高频用户聊一周原始对话记录可能几十万字全塞进上下文既不现实也不经济。第二是检索精度灾难。用户的历史消息里90% 是“嗯”“好的”“那个多少钱”这类无信息量内容剩下有价值的偏好信息淹没在噪声里模型根本抓不住重点。所以跨会话记忆绝对不能做成“原文回放”必须做成“结构化提炼”。先抽取、再存储、后检索每一步都要有明确策略。这也就引出了我后来设计的核心思想给记忆条目加约束条件让系统在写入之前就想清楚这条记忆的生命周期和可更改性。1.3 Hermes Memory 的整体定位Hermes Memory 不是一个独立的服务而是一个嵌入对话系统的记忆管理层。它做的事情可以概括为四件事捕获、提炼、沉淀、召回。捕获发生在对话过程中提炼依赖于大模型的信息抽取能力沉淀就是写入记忆库召回是在后续会话中把相关记忆动态注入上下文。这里最关键的创新点在于“双闸”有界闸门约束记忆的边界包括数量、作用域、生命周期冻结闸门约束记忆的可变状态部分关键记忆不允许被后续对话轻易修改。听起来有点像数据库里的约束和锁但落在对话场景里它有自己独特的设计逻辑。接下来逐个拆解。2. “有界”闸门给记忆画边界“有界”这个词听起来像是在限制能力实际上恰恰是保护记忆系统不过载的核心手段。我见过太多记忆模块翻车原因都是同一个舍不得丢什么都记最后记忆库变成一个既查不准又信不过的垃圾堆。有界闸门管的不是“记什么”而是“记多少、覆盖多大范围、活多久”。三件事分别对应容量边界、作用域边界、生命周期边界。2.1 容量边界窗口与压缩首先是单条记忆的容量。原始对话不能直接作为记忆条目必须经过信息抽取压缩成结构化片段。比如用户说“我平时喝咖啡只喝美式不加糖不加奶”抽出来的记忆条目应该类似{ type: preference, topic: coffee, content: 偏好美式咖啡不加糖不加奶, userId: u_1024, createdAt: ..., updatedAt: ..., accessCount: 3, state: active }这条记忆的 content 字段是压缩后的语义信息而不是原文。这样做有双重好处一是节省存储与检索资源二是降低噪声——原文里可能还带着语气词、跑题内容、上下文依赖的指代而抽取后的结构化内容干净得多。压缩怎么实现我建议直接用大模型做抽取但要做二次校验。实践中我让模型输出 JSON再跑一个简单的 schema 校验器字段缺失或类型不对就丢弃绝不写入记忆库。抽取提示词里固定要求模型“只保留可验证的事实陈述不确定的不要写”。这个约束能过滤掉大量幻觉式记忆。再说记忆库总量。虽然有界不意味着硬性限制条数上限但没有上限就会失控。我目前的方案是每个用户的有效记忆条数上限为 200超过上限后触发合并操作把相似主题的多条记忆合并为一条更高层次的概括。例如“喜欢美式咖啡”“喜欢浅烘豆”“经常去某品牌咖啡店”三条合并为一条“咖啡偏好美式、浅烘豆常去某品牌咖啡店。合并时间XXX”。合并后的老条目标记为 archived不再参与日常检索。注意合并操作会丢失细节所以合并阈值要谨慎设置。我的经验值是同类目记忆超过 5 条才合并宁可多留几条也不要为了省空间把重要偏好强行概括成一句废话。2.2 作用域边界会话内、全局、用户级第二层有界是作用域。一个记忆条目必须明确它是哪一级的只对当前会话有效、对某类场景有效、还是对用户全局有效。作用域设错是记忆污染的常见来源典型的翻车现场就是用户在某一次关于装修的对话里提到“我预算有限”结果系统把“预算有限”记成了全局偏好下次用户咨询高端旅行产品时也被强制推荐廉价方案。Hermes Memory 对每个记忆条目标注 scope 字段可枚举三种值scope 值含义示例session仅当前会话内有效“本次想找三居室房源”scene特定场景/类目下有效“装修场景下预算敏感”user用户全局长期偏好“咖啡只喝美式”这个字段哪里来抽取阶段由大模型判断同时给出一条兜底规则对话中的临时性、条件性表述默认降级为 session 或 scene只有用户明确以“我总是/我一直/我从来不”等绝对化句式表达的内容才允许写入 user 级。兜底规则的意义在于防止模型自作主张把一次性偏好升级成全局画像。2.3 生命周期边界新鲜度与衰减第三层有界是时间。用户是会变的三个月前对某件事的态度很可能已经翻篇。如果记忆库里所有历史事实都一视同仁地被召回系统会变成一个“固执的旧朋友”总是提起用户已经不在乎的事情。我给每条记忆增加一个时间因子召回时结合新鲜度做加权。具体做法是维护一个衰减函数记忆在创建或最近更新后的 30 天内权重保持高位之后每 7 天衰减一次衰减权重乘以 0.9。超过 180 天且访问次数低于 3 次的记忆自动进入“待复核”状态——不直接删除但除非用户明确提及相关内容否则不再注入上下文。有个细节值得说生命周期边界不能只靠时间戳硬算记忆条目的语义稳定性也是一个维度。像“用户姓名”“家庭住址”这类高稳定事实衰减权重应该设得很低甚至不衰减“用户当前正在考虑购买什么产品”这类低稳定信息衰减要加速。所以每条记忆还有一个 stability 属性取值 0 到 1召回时的最终权重 基础权重 × stability × 时间衰减系数。2.4 边界设计入手为什么不是“多多益善”有人会问记忆难道不是越丰富越好吗答案是丰富不等于有效。我在测试过程中发现一个很反直觉的现象——给模型注入太多记忆条目时模型对每条记忆的信任度会被平均稀释反而更容易遗漏最关键的约束甚至出现幻觉性拼接把两条不相关的记忆组合成荒谬结论。打个比方你给一个实习生交代工作说十件注意事项他大概率记住前三件和最后一件你只说三件最重要的他反而执行得一丝不苟。大模型在 prompt 里的注意力分布也有类似规律尤其是记忆条目彼此主题分散时模型很难精准提取“此刻最相关的信息”。所以 Hermes Memory 的有界闸门本质上是在做“信息减负”宁可少而准不要多而杂。这个反常识的结论是我强烈建议所有做记忆系统的人记住的第一条经验。3. “冻结”闸门让关键记忆不被轻易改写如果说“有界”解决的是记忆的数量和范围问题那“冻结”解决的就是记忆的可靠性问题。我踩过最疼的一个坑是用户画像被一次异常对话彻底带偏。事情是这样的某用户长期偏好安静、非打扰式服务。有一天他在对话里跟朋友发牢骚说“我烦死这种一声不吭的服务了就不能主动一点吗”。系统把这条话当作新的偏好写入记忆覆盖了原有画像之后每次交互都变成高频率主动推送用户忍了三天终于卸载了。你说系统错了吗从行为主义角度看也是按“最近的用户表达”更新的但问题在于一次情绪宣泄、一句反讽本来就不具备覆盖长期画像的资格。3.1 冻结的本质写保护与只读冻结闸门的设计思想就是给部分记忆条目加一把“写保护锁”。被冻结的记忆条目不允许被后续对话直接覆盖或修改除非触发显式的解冻条件。这与“有界”配合起来非常顺有界管“能不能进来”冻结管“进来之后能不能被改”。落进代码层其实就是记忆条目上多一个 mutable 状态位。但它真正的难点不在于这个布尔值怎么存而在于“冻结什么内容”和“什么时候解冻”这两个策略问题。我建议把记忆条目按可变性分三类对应三类状态状态含义典型内容fluid可随对话自由更新临时性偏好、近期购买意向solid默认不可覆盖需高置信证据才更新长期偏好、生活习惯、家庭结构frozen完全写保护除非人工/显式指令修改用户身份信息、合规要求、安全红线状态是可以升级的一条记忆刚写进来时通常是 fluid随着被反复确认、或在多个会话中一致出现系统会把它升级为 solid而某些高风险字段比如过敏信息、宗教禁忌、财务限制直接初始化为 frozen不给升级过程留任何空间。3.2 状态机从写入到冻结到解冻状态迁移需要有明确的规则不能靠拍脑袋。Hermes Memory 里我用一个简单的状态机管理新写入的记忆条目初始状态为 fluidconfidence 初始值 0.6。每次该记忆被召回且用户表达确认例如用户说“对就是这样”“没错”confidence 提升 0.1。confidence 达到 0.8 时状态升级为 solidsolid 状态的记忆更新需要新的信息与旧记忆冲突且新信息的来源置信度需高于 0.9 才可覆盖。命中合规/安全类别的记忆直接置为 frozen。frozen 状态不接受任何自动化更新只能通过用户明确的管理指令如“我不喜欢这么叫我了”并附带二次验证后解冻。这套状态机写起来不复杂真正麻烦的是“用户确认”这个信号怎么识别。我最初用正则匹配各种“对”“是的”“嗯嗯”结果误判率极高——用户随口应一声不代表他认可你刚才引用的记忆。后来改成方案要求模型输出一个 review_flag 字段当模型判断对话中出现了“对既有事实的确认”时才更新 confidence。模型判断依然有误差但配合“单次更新幅度不得超过 0.1”的限速至少不会出现一次闲聊就把整条记忆从 fluid 拉满到 frozen 的极端情况。3.3 冻结策略怎么定哪些记忆必须冻这是很多人容易忽略的重点冻结不是技术问题而是策略问题。你总不能让系统把用户所有偏好都冻结住那跟没记忆有什么区别我的选择标准有三条第一与用户安全直接相关的信息必冻。比如过敏史、疾病史、用药禁忌。这类记忆一旦被错误覆盖后果不是推荐不准而是可能造成实际伤害。第二用户身份核心属性必冻。比如姓名、称谓偏好、地域、所在公司等。用户说“我不喜欢被叫X哥叫我名字就行”这条必须立刻冻结任何后续对话都不能翻盘。第三价值密度高且长期稳定的信息默认为可升级冻结候选。比如“家庭结构”“消费风格”这类内容一旦被多个会话反复验证就升级到 solid 甚至 frozen减少被单次情绪化表达干扰的概率。反过来那些不该冻结的内容也要划清楚临时行程、当下的心情偏好、本周在关注的事情这些天生是 fluid冻结了反而让系统失去灵活性。有一个经验很粗暴凡是用户在未来三个月内大概率会变的信息都不值得冻结。4. USER 建模记忆怎么变成用户画像光有记忆条目还不够对话系统需要的不是一堆散点状的事实而是一个相对完整的用户画像。USER 建模在这个体系里本质上就是“记忆的结构化汇聚层”把零散的记忆条目按照用户维度聚合、归纳、去重形成对用户特征的分层描述。4.1 从零散记忆到结构化 User Profile我做了这样一个分层结构供参考UserProfile ├── identity身份层姓名、称呼、地域、职业 ├── preference偏好层口味、风格、话题偏好、服务偏好 ├── context场景层当前关注事项、最近遇到的事 └── restriction限制层过敏、禁忌、不可触碰的边界这个结构不是一次性生成的而是记忆条目持续写入后由后台任务定期比如每天一次重新聚合而成。聚合动作包括把同主题记忆条目归并、抽取高频特征、淘汰低价值项、将符合条件的 fluid 升级为 solid。有人会问为什么不实时生成我试过实时生成效果不好。原因在于实时聚合的输入数据太少容易出现“用小样本刻画用户”的贴标签问题——用户可能只是今天心情好聊了两句美食系统就把他标记成资深吃货而这个标签之后会影响很长时间的推荐策略。定期聚合最大的好处是等数据充分之后再做判断大幅减少误判。4.2 推理时的“记忆注入”流程USER 建好之后它要真正参与推理才有价值。Hermes Memory 在每次对话请求进来时执行一个固定的注入流程拿当前用户 ID 加载 UserProfile 基础信息identity restriction。检索会话级上下文当前对话窗口。用本次对话的 query 做语义召回从记忆库中找出 topK 条相关记忆K 我一般设为 5 到 8。对召回到的记忆做重排剔除与当前话题无关的干扰项。把身份层、限制层、偏好摘要和召回记忆压缩成一段结构化的记忆上下文插入系统提示词。关键点在第三步和第四步召回依赖向量检索但向量相似度不完全等于“对当前对话有用”。用户聊“周末去哪儿玩”向量检索可能把他的“偏好安静咖啡馆”和“上次投诉过某公园人太多”都召回来后者虽然相关但属于负面记忆重排时要降权甚至滤除。我的重排策略不复杂对每条召回记忆计算一个 score 向量相似度 × 新鲜度权重 × state 权重frozen solid fluid。这保证同样相似的条件下更稳定、更新鲜的记忆排在更前面。4.3 用户建模的冲突消解记忆写多了不可避免会出现矛盾。比如周一用户说“想减肥戒奶茶”周三又聊“某品牌新出的奶茶想试试”。你怎么办直接更新成“爱喝奶茶”是错的保留“戒奶茶”也会让系统在周三显得不通人情。冲突消解需要区分信息类型我的处理规则如下事实型冲突如年龄、城市变动以最近一次经过验证的信息为准旧信息归档。偏好型冲突如“最爱的菜系”变化不覆盖旧偏好而是新增一条临时偏好并标注“弱置信”同时保留原偏好。等后续多个会话验证后再决定是否替换。安全型冲突如“对花生不过敏了”一律不接受自动修改必须走人工确认流程。这套规则听起来保守但在工程上是稳的。宁可让系统在某个偏好上反应慢半拍也不要让它把用户的长期画像改得面目全非。5. 实操一个可落地的双闸记忆管理器理论说再多最后还是要落到代码。下面我给出一个轻量级的 Memory Manager 实现思路它足够支撑中小型项目的记忆需求也方便你自己扩展。5.1 记忆条目数据结构首先定义一个记忆条目这是全系统最小的存储单位。之所以不用关系型表的宽松结构是因为记忆条目携带的 metadata 太多用 NoSQL 文档存储更顺手。我用的是 MongoDB当然你也可以换成任意 KV 存储或向量数据库。{ _id: mem_xxxx, user_id: u_1024, type: preference, # preference | identity | restriction | context scope: user, # session | scene | user state: solid, # fluid | solid | frozen content: 偏好美式咖啡不加糖不加奶, topic: coffee, source_session: s_8899, confidence: 0.85, stability: 0.9, created_at: 1700000000, updated_at: 1700000100, last_access_at: 1700000200, access_count: 12, embedding: [0.12, ...], # 向量检索用 attributes: {} # 扩展字段 }这里有个值得说的点我故意把 content 留成人话而不是结构化的键值对。原因是大模型对自然语言的理解力远强于对僵化的表格结构的理解力直接在 prompt 里放“偏好美式咖啡不加糖不加奶”比放{coffee: americano, sugar: false, milk: false}更不容易让模型产生误解。5.2 核心接口与代码示例Memory Manager 对外暴露的核心方法就五个写入、更新、冻结、召回、合并。我直接用 Python 写出这层接口框架用的是 FastAPI 加异步任务但核心逻辑跟框架本身无关你可以平移到任意语言。class MemoryManager: def __init__(self, collection, embedder): self.collection collection self.embedder embedder async def write_memory(self, user_id, mem_type, scope, content, topic, stability0.6, confidence0.6): # 有界闸门检查数量上限超标先合并 count await self.collection.count_documents( {user_id: user_id, state: {$ne: archived}} ) if count self.MAX_ACTIVE_MEMORIES: await self._merge_old_memories(user_id) entry { user_id: user_id, type: mem_type, scope: scope, state: fluid, # 默认新记忆是 fluid content: content, topic: topic, confidence: confidence, stability: stability, created_at: time.time(), updated_at: time.time(), last_access_at: time.time(), access_count: 0, embedding: self.embedder.encode(f{topic}: {content}), } # 冻结闸门安全和身份类直接置为 frozen if mem_type in (restriction, identity) or stability 0.95: entry[state] frozen await self.collection.insert_one(entry) return entry[_id] async def update_memory(self, memory_id, new_content, evidence_score0.5): mem await self.collection.find_one({_id: memory_id}) # 冻结闸门frozen 状态直接拒绝更新 if mem[state] frozen: return {status: rejected, reason: frozen} # solid 状态需要高置信证据 if mem[state] solid and evidence_score 0.9: return {status: rejected, reason: low_evidence} new_state mem[state] new_conf min(1.0, mem[confidence] evidence_score * 0.1) if new_conf 0.8 and mem[state] fluid: new_state solid await self.collection.update_one( {_id: memory_id}, {$set: { content: new_content, state: new_state, confidence: new_conf, updated_at: time.time() }} ) return {status: updated, state: new_state} async def freeze_memory(self, memory_id, reasonmanual): await self.collection.update_one( {_id: memory_id}, {$set: {state: frozen, freeze_reason: reason}} ) async def recall(self, user_id, query, top_k5): query_emb self.embedder.encode(query) pipeline [ {$match: {user_id: user_id, state: {$ne: archived}}}, {$addFields: { score: { $add: [ {$multiply: [$embedding_score, 0.6]}, {$multiply: [$freshness, 0.3]}, {$multiply: [$state_weight, 0.1]} ] } }}, {$sort: {score: -1}}, {$limit: top_k} ] # 实际实现时embedding_score 需在内存中与 query_emb 计算余弦相似度 # 这里省略向量检索细节可换成向量数据库的 ANN 查询 return await self.collection.aggregate(pipeline).to_list(top_k)上面代码里update_memory 体现了“冻结”的核心逻辑frozen 拒绝一切自动修改solid 要求高证据分。recall 的分数合成虽然注水了但思路是明确的——相似度、新鲜度、状态权重三者加权。实际生产里我建议把向量检索放到专业向量库里做比如 Qdrant、Milvus、pgvector减少循环内计算压力。5.3 两闸协同的完整时序流程最后梳理一下正常对话流程里双闸如何协同我用文字描述完整时序用户发出新消息。服务端调用 recall 获取该用户的相关记忆注入 prompt 上下文。大模型生成回复的同时抽取模块输出本次对话中值得记录的新记忆候选或对旧记忆的更新信号。每条新记忆候选先过“有界闸门”检查容量上限、作用域是否合法、内容是否满足稳定度要求。不合格的直接丢弃。如果候选是对已有记忆的更新走 update_memory 路径由“冻结闸门”决定是接受、拒绝还是降级为追加备注。记忆写入后异步更新 UserProfile 的聚合状态但聚合任务低频执行1 天一次。对外响应返回给用户的同时整个链路结束。这套时序最微妙的是第 4 步和第 5 步的分工有界闸门管入口冻结闸门管变更。两者不是同一个时间点执行的所以叫“双闸”而不是“一道锁”。我在最初的版本里把两件事都塞进一个函数里结果边界极其混乱后来拆成前后两段才理顺。这也是一个工程上值得记录的心得。6. 踩坑记录与排查经验实录任何记忆系统上线第一天和运行三个月后的表现完全可能是两回事。这里我把这段时间遇到的几个典型问题整理成速查表格附带我的排查思路和最终处理方案。现象根因分析处理方案系统总是引用过时信息时间衰减权重没生效旧记忆依然高权重召回在 recall 阶段强制给创建时间超过 90 天的条目乘以 0.5 惩罚系数用户画像被一次聊天带偏单次对话的临时偏好被写入 user 级作用域收紧抽取规则只有“我一直/我总是”类句式才允许写 user 级同主题记忆重复堆积抽取模块没有做去重判断写入前先做主题相似度检索相似度 0.85 时合并而非新增frozen 记忆被绕过更新有其他服务直接操作数据库跳过管理器数据库层面加校验触发器frozen 的文档不可被外部直接 update召回内容与当前话题完全无关向量检索只看了语义相似没看话题约束在向量检索基础上增加 topic 过滤先按话题粗筛再按向量精排记忆合并后丢失重要细节压缩策略太激进合并前做重要度评分重要细节以“附注”形式保留在 attributes 里除了表格里的内容我再补几个提示词层面的血泪教训。第一抽取阶段的提示词里一定要加“不确定就不要输出”这一条否则模型很容易把“我觉得用户可能喜欢”这种猜测写成确定事实。第二更新阶段的提示词要让模型区分“用户的情绪表达”和“用户的事实陈述”情绪表达永远不应该触发 solid 及以上状态的更新。第三所有记忆写入操作都要记录 source_session一旦后续发现某条记忆是错的可以顺着 source_session 找到原始对话做溯源。7. 复盘这套设计值不值得做回到开头那个问题跨会话记忆与 USER 建模到底怎么做才稳我的答案是“有界冻结”这组反直觉的约束组合。很多人做记忆系统时下意识觉得记忆越多越智能但真正上线后才会发现不加约束的记忆系统会迅速劣化成一个不可信的信息源。有界让系统保持清醒冻结让关键信息保持稳定两者的结合不是限制能力而是让能力朝着可靠的方向发挥。最后分享一个我在实际部署中发现的额外收益双闸设计不仅提升了记忆的准确率还大幅降低了记忆相关的调试成本。因为所有记忆条目的状态迁移都有明确记录出了问题可以直接查状态机日志不用像以前那样在一个盘根错节的无结构记忆库里瞎猜。如果你正准备给自己的对话系统加记忆能力我建议先把这两个闸门想明白再动手写存储层——这能省下未来好几个月填坑的时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。