1. 先聊聊记忆系统的失控现场为什么“双闸”设计能救场做 AI 对话系统做到一定阶段你会发现一个挺反直觉的现象记忆功能做得越强系统反而越容易翻车。我接手过一个智能客服项目一开始只做单轮问答后面老板要求“记住用户上次说过什么”。这需求一开整个系统的复杂度直接翻了好几倍。最开始是给对话加了一个简单的存储层把用户说的话、系统的回复全部塞进数据库每次对话开头把历史记录调出来拼到 Prompt 里。Demo 阶段一切正常用户夸“它记得我”但跑了两周之后问题接踵而至会话记录越攒越多Prompt 一个月就从 2000 token 膨胀到 8000 token响应速度肉眼可见地下降。用户三个月前随口说的一句“我准备换工作”每次对话都被模型当作重要上下文反复引用导致推荐内容严重跑偏。更尴尬的是用户已经明确改了地址和偏好系统还在坚持用旧信息做判断怎么都“纠正”不过来。根子在哪我当时的实现只有一扇门记忆的写入闸门也就是什么信息可以进记忆库。但完全没有考虑记忆的存续边界和信息的稳定层。结果就是记忆库成了一个只进不出的仓库越堆越乱最后把整个系统的判断力都拖垮了。后来我把这套记忆子系统推到重做参考了当时社区里讨论比较多的记忆管理思路最终落地的核心就是标题里说的这套“有界 冻结”双闸设计。有界闸门管的是“记忆能活多久、能占多大地方”冻结闸门管的是“哪些用户模型信息一旦确定就不再被轻易改写”。两个闸门一配合记忆系统才真正从“能存”进化到“存得稳、用得准”。这篇文章就围绕这套设计展开适合正在做对话机器人、AI Agent 记忆层、用户画像建模的开发者和产品同学。我会把设计动机、两层闸门的具体实现逻辑、两者如何协同、以及落地时容易踩的坑全部拆开讲。2. “有界”闸门给跨会话记忆划定 token 上限与优先级疆界先讲有界闸门。它的核心任务一句话就能概括别让记忆无限膨胀。2.1 为什么“无限记忆”是个伪需求很多做对话系统的人一听“记忆”两个字第一反应是“记越多越好”。但从工程角度看这条思路有三个硬伤Transformer 的注意力复杂度是 O(n²)Prompt 越长单次推理时延和成本上涨得越厉害。实测下来一份 5000 token 的历史记录和 2000 token 相比首 token 延迟能差 40% 以上。信息密度随长度衰减。模型对超长上下文的注意力分布是稀疏的真正有用的关键信息可能被淹没在海量历史回复中。业务侧也不好解释。用户隐私边界、数据保留周期、合规审查“永久记忆所有内容”在产品和法务层面基本过不了关。所以有界设计不是无奈妥协而是一个主动的架构选择。核心思路是给记忆系统划定明确的资源上限并在这个上限内做优先级淘汰。2.2 三层有界结构短期工作台、长期条目库、用户画像层我当时把记忆系统拆成了三个物理层每一层都有自己的“边界规则”层次存储单元典型容量上限淘汰策略短期工作台当前会话内的消息序列最近 20~30 轮约 3000~4000 token滑动窗口最早消息直接丢弃长期条目库跨会话的关键事实条目用户说过的重要信息500~1000 条按重要性衰减低分清理 同义合并用户画像层结构化属性字段姓名、偏好、地址等固定字段数不随对话增长冻结闸门接管见下一章短期工作台最简单就是一个带长度限制的队列。每一轮新消息进来如果总长度超过阈值就把最早的那条弹出去。这就叫滑动窗口。长期条目库做得稍微复杂一些。每一次用户消息进来会有一个抽取模型可以用小模型也可以直接用主模型把其中的“持久性事实”提炼成一条条目比如{ user_id: u_1234, fact: 用户偏好深色主题, importance: 0.8, last_accessed: 2024-11-02 }这个importance分数是核心。我从一开始就没打算做“绝对重要的记忆”——重要性一定要跟“最近使用”挂钩。假设一条事实半年没被用到就算它当初重要性是 0.9我都应该把它降权甚至清掉。条目的清理策略我用了“双因子衰减”effective_score importance * decay_factor(now - last_accessed)。每天调用一次清理任务把所有effective_score低于阈值的条目删掉同时候选合并表达不同但含义相同的条目比如“用户喜欢喝美式”和“用户习惯点美式咖啡”合并成一条。合并能极大缓解条目数的膨胀速度。2.3 “有界”不是简单截断而是分层取舍在实现过程中我踩过一个很深的坑刚开始我以为“有界”就是把超出部分的 token 直接截掉。结果某一轮用户明确说“我不喜欢吃辣上次说的微辣也去掉”因为“微辣”已经是三个月前的记录早被滑出去了系统完全不知道用户改过口味还在按旧偏好推荐。这个教训让我意识到截断只适合短期工作台长期记忆必须先进行“语义减重”再淘汰。丢了旧事实没关系但必须在丢之前确认它没有和当前状态冲突。所以我加了一个“冲突检测”子模块新事实写入前会扫一遍裁剪池中的同实体条目如果发现语义冲突就把旧条目提前标成“过时”并归档新条目才落库。这个做法显著降低了“系统记忆和用户当前状态不一致”的投诉。3. “冻结”闸门用户模型不是越新越好该冻就得冻有界闸门解决了“记忆太多”的问题但还有一类更隐蔽的问题记忆太活。3.1 用户画像“漂移”现象如果你把所有闲聊中抽取的用户属性都实时写进画像表会出现一个现象我叫它“画像漂移”用户周一在家说“我喜欢安静的生活”你把他写成了“偏好安静型”周三他在公司群里跟同事说“周末要去音乐节蹦迪”你又想把他更新成“喜欢热闹”。如果每次更新都是全量覆盖用户的画像会随着最晚一次对话的情绪和场合来回横跳模型对用户的理解反而越来越不稳定。这里有个很反直觉的结论——在某些维度上用户画像更新的频率应该越低越好。3.2 冻结层的三个字段分类我在用户画像层引入了冻结闸门把所有画像字段分成三类冻结字段Frozen一旦经过“双次确认”或“权威来源确认”写入之后不再被后续对话的模糊信息覆盖。比如用户手机号、所在城市、职业身份这类高确定性属性。除非用户通过明确指令例如“我搬家了”要求修改否则该字段冻结不变。半冻结字段Semi-Frozen可以更新但更新需要触发“升级条件”。比如用户兴趣偏好如果在一段对话里被重复表达两次以上才允许覆盖旧值单次表达只加入一个“候选值”队列。活跃字段Active随会话变化比如用户当前的情绪状态、当前所在场景家里/公司/通勤这些信息时效性极短本来就不该长期冻结每次会话都重新识别覆盖。这套分类的核心逻辑是把“置信度”和“时效性”分开考虑高置信 长时效 → 冻结防漂移低置信 长时效 → 半冻结候选确认任意置信 短时效 → 活跃随写随弃3.3 冻结闸门的防抖与解冻机制半冻结字段要有一个“防抖机制”**不然还是会漂移。我的实现方式是在画像层维护一张候选值表{ field: food_preference, current_value: 川菜, candidates: [ {value: 粤菜, mention_count: 1, first_mentioned_at: 2024-11-01}, {value: 日料, mention_count: 2, first_mentioned_at: 2024-11-02} ], frozen: false }只有mention_count达到阈值我这边设的是 2且时间跨度超过一定窗口候选值才会“转正”覆盖当前值。这个设计也顺带解决了“用户偶尔一次嘴瓢”导致画像被污染的问题。至于冻结字段的解冻我设计了两个入口显式修改用户消息里带明确的变更意图例如“我搬家到上海了”用一个意图识别模块判断出这是“属性更新意图”直接解锁字段重写。众数投票如果一段时间内候选值持续出现并且累计次数远超当前冻结值系统会触发“人工审核”或者要求用户确认而不是自动覆盖。提示自动覆盖永远只用于半冻结和活跃字段。冻结字段任何自动变更都必须经过确认流程这是这套设计的底线。4. 双闸协同一次完整记忆写入/读出的生命周期拆解有界闸门和冻结闸门不是各自独立跑的它们是在同一条数据流水线上协同工作的。我通过一次具体的写入—读出流程把整个状态流转拆给大家看。4.1 写入路径从原始对话到结构化记忆步骤 1消息进入短期工作台用户消息先进入短期工作台这一步不做任何信息抽取只是保留原始文本供当前上下文使用。如果工作台满了最早的消息被滑出但滑出的消息会进入一个“候选抽取池”不是直接丢弃。步骤 2候选抽取池触发信息抽取候选抽取池攒够一批消息后调用抽取模型识别其中的持久性事实。抽取结果分两类属性类如“用户表示自己是产品经理”→ 进入画像层候选表事实类如“用户说上周去青岛出差”→ 进入长期条目库候选区步骤 3有界闸门检查容量长期条目库写入前先看总量是否到达容量上限。如果满了执行“双因子衰减”清理把effective_score最低的条目删掉或合并腾出空间。如果清理后仍然超量就拒绝写入优先级较低的新事实。步骤 4冻结闸门检查字段状态如果抽取结果是属性类需要根据字段类型走不同分支冻结字段忽略本次写入除非检测到显式修改意图半冻结字段更新候选值表mention_count 1达到阈值才转正活跃字段直接覆盖好这一步最关键的执行顺序是先有界后冻结。容量不够时新事实根本进不了候选区更谈不上覆盖画像字段。这个顺序保证画像层不会被低频垃圾信息塞满也保护了高优先级记忆的存活率。4.2 读出路径生成回答前的记忆组装对话生成阶段系统需要从三个层里取信息并组装成最终 Prompt。短期工作台直接拼最近 20 轮原始对话。长期条目库根据当前用户 query 做相关度检索取 Top 10~20 条最相关记忆并带上last_accessed时间给模型一个“时间感知”。比如模型能看到“用户 2024-09-14 提过正在准备面试”它会更自然地结合时效性做回应。用户画像层输出时同样区分字段类型冻结字段原样输出半冻结字段带候选变化提示例如“用户近期偏好可能转向粤菜但尚未确认”活跃字段仅反映当前会话识别结果。组装顺序上我把画像层放在最前条目库放中间工作台原始会话放最后。这么做是因为画像是最稳定的结构化信息模型应该优先依赖原始会话噪声最大放最后避免带偏。4.3 协同中的状态流转示例我画一条完整的链路说明文字描述不贴图用户对新客服说“我想把收货地址从北京改成上海。”显式修改意图短期工作台记录这条消息。抽取池识别出这是画像层 [收货地址] 字段的显式更新意图。冻结闸门收到意图信号检查该字段属于“冻结字段”但匹配到显式修改入口因此允许覆盖。收货地址从“北京”更新为“上海”并写入一个新的冻结时间戳。有界闸门检查长期条目库把旧的“用户收货地址是北京”相关条目执行合并/淘汰避免库里同时存在两条冲突事实。下一次对话读取画像层时模型会看到“收货地址上海2024-11-05 更新”同时长期条目库里已没有北京地址的残留信息。这个流程看着简单但每一步的先后顺序都是我在实际调试中一点点抠出来的。尤其是第 4 步更新画像字段的同时必须清理长期条目库里的冲突项否则两个层的数据会打架模型在生成时容易“精神分裂”前一句按上海推后一句又引用了北京的旧条目。5. 落地经验参数调优、典型坑位与选型建议最后这一章把我在实际项目中反复调整过的参数、踩过的不明显坑以及工具选型经验一并列出来。这些内容不太会出现在官方文档里但对想把这套设计落地的朋友非常有用。5.1 参数设置的推荐起点这套设计里的核心参数较多直接给一份我调过之后比较稳的初始值参数推荐初始值调整依据短期工作台窗口最近 20 轮对话超过 20 轮后信息密度急剧下降长期条目库上限1000 条 / 用户配合每周清理任务低于 500 时停止衰减重要性初始值0.8范围 0~1抽取模型给的置信度比纯启发式更准有效分数淘汰阈值0.3低于此值基本可判定为过时记忆半冻结字段确认阈值mention_count 2一次提及大概率是噪声冻结字段显式修改意图阈值意图分类置信度 0.85太低会导致误覆盖注意以上参数不是一次调好的。我的经验是先按一个合理初值跑两周把系统产生的标注数据拉出来看再根据实际场景微调。5.2 三个容易踩的坑位抽取模型的“过度提炼”问题。用主模型做抽取时模型容易把用户随口猜测也当成确定事实。比如用户说“我可能下个月去深圳出差”抽取模型可能写成“用户下个月去深圳出差”直接丢进长期条目库变成既定事实。解决方案是抽取时额外输出一个“确定性标记”certain/uncertainuncertain 的事实只能进入“临时候选区”经过二次确认才落库。清理任务的时间窗口不能太频繁。我一开始每天跑一次清理发现条目的last_accessed频繁变动导致大量条目还没真正老化就被刷新了。后来改成每 3 天跑一次效果更好。这背后的逻辑是记忆的冷热评估需要一个统计窗口太频繁的清理反而会打乱热度分布。不要把冻结闸门做成永久冻结。用户会变这是必然的。如果一个字段被冻结后没有任何解冻机制半年后用户搬家了、换工作了、口味变了系统还按老数据走体验会非常糟糕。所以一定要留显式修改入口和手动覆盖手段。我自己的做法是冻结字段每次使用时都会在上下文中附一个更新时间戳让模型自己判断这个信息的时效性是否还可靠。5.3 数据表设计与存储选型参考跨会话记忆系统对存储的要求不算高但也不建议直接塞关系型数据库里硬造。我当时用的是这个组合Redis JSON短期工作台和活跃字段存 Redis设置 TTL比如 24 小时读写快、不占用持久化空间。PostgreSQL 单表存 JSONB长期条目库和用户画像层用一张user_memory表字段包括user_id、memory_typefact / attribute / frozen_attribute、contentJSONB、importance_score、last_accessed_at。JSONB 的好处是结构灵活做原型验证很方便。定时任务每天凌晨跑一次“双因子衰减”清理和候选值转正逻辑。至于要不要上向量数据库我的建议是——早期别上。条目库在 1000 条以内时直接基于关键词 规则做检索完全够用响应还快。等条目量级到了十万级以上再引入向量检索不迟。5.4 关于“通用记忆框架”的一个提醒不少读者看到这套设计可能会想“那我直接用某个跨会话记忆框架好了”。框架能帮你省掉很多基础代码但有个问题很多框架默认只实现了“写入”和“检索”并没有内置有界和冻结的完整策略。你依然需要在业务层自己加容量控制、衰减清理和字段冻结逻辑。我的态度是框架可以当底座但双闸设计一定要自己实现。因为“哪些信息该被冻结”“哪些记忆该被淘汰”极度依赖你的业务场景。比如医疗问答里的用户病历信息和电商客服里的收货地址显然后者的冻结阈值、更新策略都要完全不一样的。最后再分享一个落地时的小技巧在双闸设计跑通之后我发现一个特别好用的调试手段给每条记忆加一个“可解释来源”字段。具体来说就是每条长期记忆和每个画像字段除了存内容之外还要记录它在哪一轮对话里出现的、当时的原文是什么。这样在线上如果发现模型引用了错误记忆可以一键回溯来源快速确认是抽取错了、还是更新顺序错了而不是对着一个孤零零的事实条目干瞪眼。我当时就是靠这个字段定位到一个非常隐蔽的 bug半冻结字段转正时因为候选表的first_mentioned_at没跟上导致同一条信息反复转正覆盖用户画像在一个月内来回翻转了三次。加了来源字段后这类问题基本都能在五分钟内定位。跨会话记忆的“双闸”设计说到底是一套关于取舍的哲学有界是跟时间妥协冻结是跟变化妥协。两个闸门守住了记忆系统的稳定性和可用性让模型既能记住该记的又不会被不该记的拖垮。如果你正在做相关系统强烈建议先把这套机制搭起来再迭代优化参数。