1. 跨会话记忆的"三座大山":遗忘、污染与失真我做 AI 助手有一段时间了,最常被用户吐槽的一句话是:你怎么什么都不记得?用户上周刚说过的偏好,这周换一个会话进来,又要重新交代一遍。放在纯聊天场景里,这种体验顶多是"有点烦";一旦放到生产力工具里,比如写作助手、客服工作台、个人知识库,这种失忆基本就是劝退级别的缺陷。我最早的做法非常粗暴:把历史会话的向量全部存下来,新会话开始时做一次 top-k 检索,把最相似的内容塞进上下文。听起来很合理,跑起来却到处漏风。不是检索不准,而是"什么都记"这件事本身会引入新的问题。后来我把问题拆成三类,也就是我标题里说的三座大山:遗忘、污染、失真。1.1 遗忘:LLM 天生没有长期记忆这一点不用多说,大模型的上下文窗口再大,也是"这次对话"的窗口。会话一关,除了你主动写回外部存储的东西,模型对刚才说过的内容没有任何持续印象。跨会话记忆的本质,就是把会话之间真正有价值的信息,抽出来放到一个独立持久层里,下一次会话再注入回上下文。但这里埋着第一个坑:该抽什么、存什么,不是一句"全部存下来"能解决的。原始对话里大量是寒暄、临时安排、一次性情绪表达,这些如果全部进记忆库,不但浪费存储,还会在检索时疯狂干扰后续的上下文组装。1.2 污染:记忆仓库不是仓鼠窝有段时间我做了一个很"勤快"的记忆模块:每条会话里只要出现"我喜欢""我不喜欢""以后记得"这类句式,就抽取出来入库。结果呢?用户某天心情不好说了一句"以后别给我推这么长的文章",系统立刻把"厌恶长文"写进偏好;第二天用户看到一篇长文深度分析后说"这篇真有用",系统又写了一条"喜欢深度长文"。两条偏好在向量空间里距离很近,检索时经常同时命中,模型一会儿觉得用户要短文,一会儿觉得用户要长文,最终表现就是反复横跳。我把这种问题叫污染:一次性、情境性的信息,被当成长期特征写进了用户模型。污染的直接后果是上下文里填充了大量互相矛盾的"伪事实",模型只能二选一,无论选哪个都是错的。1.3 失真:越勤奋更新,画像越不稳定第三座大山更隐蔽。假设系统已经在用户模型里稳定记录了"偏好简洁回复"这条事实,某一天用户赶时间,连续说两次"麻烦说细一点,要步骤"。如果系统用"最近一次表达覆盖旧值"的策略,它会把"偏好简洁"改写成"偏好详细"。于是用户画像发生了漂移,而真相是:用户长期偏好简洁,只是当下那一件事需要详细步骤。这种失真最坑的地方在于,它经常发生在用户情绪化或赶时间的场景里。单次强信号不能代表长期特征,但如果系统只是"听话"地更新,用户模型就会不断被最新信号左右,永远不稳定。失真和污染的区别在于:污染是写入噪音,失真是把已有的正确信息改错。三座大山叠在一起,我意识到不能靠单一的检索策略或单一的更新策略解决。我需要两个独立但协作的"闸门":一个控制什么信息能进来、总量有多少,一个控制什么信息一旦确认就不能被轻易改掉。这就是 Hermes Memory 双闸设计的由来。2. 有界闸门:给记忆装上限位阀,而不是无限扩展有界闸门解决的是"进多少、留多少、淘汰什么"的问题。它管的是记忆系统的总量和准入,核心思路是:记忆不该是无限生长的仓库,而应该是一个有最大容量、有淘汰机制的缓存池。2.1 为什么"记全"是最容易踩的误区很多人第一反应是:反正现在向量数据库便宜,全存下来不就好了?短期确实没问题,但长期会踢到三块铁板。第一块是上下文预算。每次会话开场要从记忆库取内容注入上下文,而上下文窗口是固定预算。如果记忆库本身没有规模约束,取多少、怎么取就会变得不可控。我实测过,当知识内容规模上去之后,如果只是简单按相似度取 top-k,取回来的经常是结构重复、信息冗余的片段,真正重要的核心偏好反而被挤出窗口。第二块是注意力稀释。大模型的上下文注意力是有限的资源,塞进去的"记忆"越多,模型越容易迷失在细节里,反而忽略了当下用户对话的重点。我一直用的类比是:你进办公室时桌上堆了一百份无关文件,你最需要的那份合同反而要找半天。第三块是维护成本。记忆条目越多,去重、冲突检测、过时判断就越难做。到了后期,每次写入都要跟所有旧条目比对一遍,延迟和计算成本都会上涨,效果还不一定更好。所以我给自己定了一个硬性目标:记忆上下文在任何时刻都必须是一个有界集合。宁可漏掉一些可能有用的信息,不能让记忆库本身失控。2.2 打分函数:什么信息才有资格进入记忆有界不等于简单截断,关键是"怎么决定谁留下"。我给每条候选记忆算一个综合分,公式长这样:score = w1 * recency_norm + w2 * importance + w3 * confidence其中:recency_norm:归一化后的时间新鲜度,越近越高。importance:信息本身的重要性评级,由抽取环节根据语义和句式给出,比如"用户明确表达的偏好"比"用户随口提到的某个名词"重要得多。confidence:这条信息被反复确认的次数折算,确认越多越可信。我初期用的权重是w1=0.4, w2=0.4, w3=0.2。后来踩了坑,把旧信息保护也加了进去,这个我会在第五节的翻车现场详说。有界闸门的第一道判定就是:新候选记忆必须先有资格入库,再谈能不能顶掉别人。不是所有抽取出来的信息都能进记忆池,只有分数超过准入阈值的才放行。准入阈值我建议设成动态的,比如取当前池内分数的中位数,而不是一个固定值,否则池子整体分数升高后,门槛就失效了。2.3 两级存储与淘汰策略:热池与冷池光有一个池子是不够的。我一开始只有一个 list,所有记忆条目按分数倒排,超过容量就踢掉最后一名。结果发现一个问题:某些分数稳定的核心条目长期占着高位,而一些新出现的、可能重要但尚未确认的条目永远进不来。后来我把存储拆成了两层。热池(hot pool):最多保留 200 条,保存最近高频使用的、分数靠前的、尚未确认但新鲜度高的候选条目。这个池子的更新频率很高。冷池(cold pool / archive):保存已经确认、但暂时用不上的稳定事实。冷池不占上下文预算,只在特定检索条件下才会被拉回来。这样设计的逻辑是:热池负责承担大部分上下文组装和快速反应,冷池负责长期沉淀。有界闸门的淘汰动作主要发生在热池,每次新增条目时检查:若热池已满,则比较新条目与池内最低分条目,分数更高则淘汰旧条、纳入新条;否则拒绝写入。冷池虽然容量很大,也不是无限的,我用一个归档上限控制。但冷池里的条目不是被"永久遗忘",而是进入低优先级状态——除非被新的高置信信号重新激活,否则不再回到日常上下文。2.4 有界与冻结的结构性矛盾,留待双闸联动解决这里必须坦白一个设计上的悖论:如果某些条目可以被"冻结"从而免于淘汰,那么有界原则就被破坏了。冻结条目越多,池子就被越多的"不可驱逐者"占据,最终依然会膨胀。我一开始天真地想:冻结条目不参与淘汰不就行了?结果跑了一周,池子里冻结条目占了三分之一,且还在增长,有界形同虚设。后来我调整了思路,把"冻结"从"不可淘汰"改成"高淘汰门槛",又把冻结池单独设置了一个更小的硬顶,具体方案放在第四节里讲,因为它是整个实现链路的关键部分。3. 冻结闸门:用户模型的"防篡改"机制如果说有界闸门管的是"进多少",冻结闸门管的就是"已经进来的东西,不能被随便改"。3.1 用户画像最怕的其实是"太听话"做用户建模的人都知道,用户模型的价值在于稳定性和可预测性。但在实际运行中,传统更新策略往往会让模型变得过度适应短期状态。我前面举过一个例子:用户长期偏好简洁回复,某天赶时间说"说细一点",系统如果直接用新消息覆盖旧偏好,就失真了。这是一个非常典型的场景,但我发现更难防的是渐进式漂移:用户一周内有几次临时需求,每次都表达同一个方向的强度,模型就会一步步把核心偏好带到偏离方向。每一次单独看起来都"合理",连起来看就是灾难。冻结闸门的设计目标就是:对达到高置信度的核心用户特征,禁止被单次或低频信号直接覆盖。它不是不更新,而是"更新需要更高的门槛和更严谨的流程"。3.2 冻结的三个前置条件:置信度、确认次数、类型白名单我最终敲定了一条规则:一个条目要被冻结,必须同时满足三个条件。第一,置信度达到阈值。这个置信度来自抽取模型的语义判断,比如"用户明确表达"和"用户随口提到",置信度显然不同。第二,确认次数达到 N 次。我这里取的 N=3。也就是说,同一个事实必须被用户在不同的会话里明确确认过至少三次,才具备冻结资格。中间任何一次出现矛盾信号,确认计数就会回退。第三,条目类型在冻结白名单内。不是所有信息都值得冻结,我目前只冻结三类:preference:用户明确表达的长期偏好,如"我习惯看中文内容"。constraint:用户给出的边界约束,如"我的预算有限,别推太贵方案"。identity:用户身份相关的事实,如昵称、职业、使用场景。而一次性事件、临时状态、情绪表达,天然就不在冻结白名单里。3.3 冻结之后怎么处理"更新请求"这是很多人误解的地方。冻结不等于锁死,而是改变更新路径。一个已冻结条目收到新的冲突信息时,有三个处理分支:忽略:新信息置信度低,直接丢弃;挂起:新信息置信度较高,但不足以推翻冻结条目,就记为pending_override,等待后续更多信号;覆盖:新信息置信度极高,并且用户明确纠正了旧偏好(比如用户直接说"从现在开始我改看英文内容"),此时才允许覆盖,并保留旧版本的历史记录。我额外加了一个"风化机制":如果一个冻结条目连续 60 天没有被任何检索命中,也没收到过任何确认信号,它会被自动降级为普通已确认条目,重新进入可淘汰范围。这样能让冻结池自动吐掉一些已经过时的核心偏好。3.4 冻结池自己的硬顶前面说过,冻结不能破坏有界。我给冻结池单独设了上限:最多 30 条。超过上限时,新的候选冻结请求必须等待,系统优先执行"风化降级"或"显式纠正降级",腾出位置后才允许新的冻结。有人会问:如果 30 条不够呢?我的经验是,真正值得冻结的用户核心特征,不会超过几十条。如果你的系统里出现了几百条"必须冻结"的偏好,大概率是抽取环节没有做好区分,把太多短期偏好误判成了长期特征。这时候应该回头调抽取策略,而不是调高上限。4. 双闸联动:从消息进入到用户模型落盘的完整链路前面两节分别介绍了两个闸门的独立逻辑,但真正让 Hermes Memory 奏效的是它们如何协作。我花了最多调试时间的也是这里。4.1 先解决结构性矛盾:冻结条目不撑爆有界空间第二个章节里我留了一个伏笔:有界和冻结怎么共存。我的最终方案可以概括为三句话:冻结池有独立的硬顶(30 条),不算进热池的 200 条预算;冻结条目平时不参与热池得分排序,但每次会话开场组装上下文时,冻结池的核心字段会作为一个固定模块注入;冻结条目可以被"显式纠正"或"长时间风化"降级,降级后回到热池参加正常淘汰。这样,有界仍然成立:热池有限,冻结池也有限,总量是 230 条的常数。而不是无限膨胀。这个设计让"双闸"真正变成双闸,而不是两个互相打架的规则。4.2 写入链路:一条用户消息是如何"过闸"的每一条用户消息进来,都会走下面这条链路:消息进入 → 结构化抽取 → 有界闸打分准入 → 相似度去重 → 冲突检测 → 冻结闸校验 → 状态流转 → 写入持久化第一步,结构化抽取。我用一个小模型的抽取调用,把非结构化对话转成结构化的候选记忆条目,包括类型、内容、置信度、置信来源(明示/暗示/情绪化表达)。注意,这一步不要只做关键词匹配,一定要让模型输出置信度,它后面是两个闸门共同依赖的依据。第二步,有界闸打分准入。候选条目先按 2.2 节的公式打分,低于动态准入阈值的直接丢弃,不进入后续流程。这是第一道闸。第三步,相似度去重。把候选条目和池内已有条目做 embedding 相似度比对,超过阈值就视为同一个事实的重复表达,走"合并"而不是"新增"。合并时更新confirm_count和last_seen_at,这就是后面冻结闸判断确认次数的数据来源。第四步,冲突检测。如果候选条目和已有条目是同一类型、同一维度但语义冲突,比如"偏好简洁回复"和"偏好详细回复"同时存在,就进入冲突分支。第五步,冻结闸校验。这一步是第二道闸:如果候选条目对应的已有条目是冻结状态,就看 3.3 节里的三个处理分支;如果不是冻结状态,则进入普通更新路径,由新消息的置信度和确认次数决定是否覆盖旧值。第六步,状态流转。所有条目都在这时更新自己的状态机:candidate → confirmed → frozen,或者从冻结降级回confirmed。状态流转记录都会被保留,方便回溯。最后写入持久化存储。整个链路我用的是一个异步队列,因为中间的抽取和去重要调模型和向量检索,同步跑会拖慢主对话延迟。实测单条消息平均增加 300~500ms 的异步写入耗时,对用户无感,但对记忆质量提升非常明显。4.3 会话开场检索:从"全量注入"变成"三级拼装"另一个关键点是:新会话开始时,怎么把记忆注入上下文。我最初的方案是把热池 top-k 直接拼进 system prompt,效果不稳定。后来改成"三级拼装":第一级(固定模块):冻结池里的核心画像字段,数量少、信息密度高,每次必带,预算固定 400 token 左右。这些是用户模型的骨架,比如身份、语言偏好、核心约束。第二级(动态模块):热池里得分最高的前 20 条,按当前对话的主题关键字做一次相关性过滤,预算约 800 token。这一级提供的是"最近在关心什么"的上下文。第三级(检索模块):用当前用户的第一句话做一次语义检索,从冷池和热池里捞取 top-5 追加进去,预算约 400 token。这样设计的好处是:骨架永远不变、近期变化能感知、长尾知识按需取出。上下文预算被严格控制在约 1600 token 以内,大约是总窗口的 15%~20%。比例太高会喧宾夺主,太低则记忆感知不到,这个区间我实测下来比较稳。4.4 存储与数据结构:一张表加一个向量索引的最小组合我不喜欢一开始就把存储搞得花里胡哨。目前 Hermes Memory 的存储核心就是一张 SQLite 表加一个向量索引:CREATE TABLE memory_items ( id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, item_type TEXT NOT NULL, -- fact / preference / event / rule content TEXT NOT NULL, status TEXT NOT NULL, -- candidate / confirmed / frozen / archived score REAL DEFAULT 0, confirm_count INTEGER DEFAULT 0, last_seen_at TEXT, created_at TEXT, updated_at TEXT, source_session_id TEXT, override_of INTEGER, -- 如果覆盖了旧条目,记录旧条目 ID version INTEGER DEFAULT 1 ); CREATE INDEX idx_user_status ON memory_items(user_id, status);向量索引单独建,存(item_id, embedding),用于相似度去重和第三级检索。两张表配合,足够支撑个人级和中小团队级的使用量。等项目到了多用户大规模阶段,再迁移到专门向量数据库不迟,不要在项目一开始就过度设计。5. 实测中的意外收获与翻车现场理论讲完了,说点真实的。Hermes Memory 上线到模拟环境跑了一个多月,有变得特别好的场景,也有翻车翻到我想摔键盘的瞬间。5.1 哪些场景真的变好了最明显的是"回访用户"体验。一个用户隔了两周回来,开场就能叫出他上次交代过的项目代号,还记得他上一次提到的约束条件,用户的反应是"你怎么还记得",这种反馈比任何指标都直接。其次是风格一致性。以前用户每次进来,模型回复风格都像第一次见面,有时偏正式、有时偏随意。引入冻结闸之后,只要"偏好简洁/偏好口语化"这类条目被冻结,每次会话的风格重启问题就消失了,模型能稳定保持用户习惯的语气。第三是重复提问率下降。用户不需要每次会话都重新解释背景资料,第一级固定模块里的身份约束直接把很多背景信息打包交给了模型。我统计的三周内模拟对话中,用户重复解释背景的次数降低了约 60%。这个数字模拟环境里可能偏乐观,但趋势是明确的。5.2 翻车一:误冻结比不冻结更可怕有一次,一个用户连续两次在深夜吐槽"以后别给我推荐新东西了,我就想用我熟悉的方案"。系统按规则认为这是"偏好确认",把"不接受新方案推荐"冻结了。结果第三天用户白天主动问"最近有没有新工具推荐",系统因为冻结条目,还在继续回避推荐,用户被激怒。这个翻车让我明白:冻结一个错误事实的代价,远高于不冻结。规则再也不能只看"确认次数",还要看确认时的情绪和场景。我后来给抽取模型加了一个判断项:ephemeral_emotion,如果候选条目被判定为情绪化表达,它的确认次数需要乘以一个 0.5 的折扣系数,并且必须有至少一次是在非情绪化场景下确认的。半夜吐槽连续两次,权重会被显著拉低,不再轻易促成冻结。5.3 翻车二:打分权重把老用户的核心偏好挤出了热池我初期的打分公式里recency_norm权重是 0.4,导致很多高频讨论的新话题总是盖过老用户真正长期偏好。比如一个用户三个月里一直偏好"中文资料",但本周连续讨论了几个英文项目,结果"偏好中文资料"的得分被压到了热池后半部,险些被淘汰。后来我给公式加了一个importance_floor机制:已确认条目里,类型为 identity/constraint 的,得分保底在热池前 50 名以内,不参与常规淘汰。这本质上是一种"准冻结",用优先级替代分数竞争。这个改动之后,长期偏好被挤出的问题基本消失。我也把recency_norm的权重从 0.4 降到了 0.25,把importance的权重升到了 0.5。5.4 翻车三:语义去重把两个含义不同的条目合并了这大概是所有向量检索方案的通病。两个条目"用户偏好代码示例"和"用户偏好简短的代码示例",embedding 相似度可能超过 0.9,去重时被合并成一个,结果丢失了"简短"这个关键限定。我的解法是:去重不能只看 embedding 相似度,必须要求 item_type 字段一致,并且内容经过抽取模型的结构化拆分后再比对。比如把"简短"拆成一个独立的修饰属性,而不是混在一条大文本里做向量化。结构化的价值在这里体现得很明显:宁可抽取多花一点时间,也别让语义模糊害死后续两条闸。6. 抄作业指南:起步配置、监控手段与后续扩展最后给想在自己的项目里试这套双闸记忆方案的同学一份可直接参考的配置,以及我从踩坑里提炼出的运营建议。6.1 一份可以直接用的起步参数参数建议值说明热池容量200 条常规个人助手够用,容量小了容易抖动,大了浪费预算冻结池容量30 条独立于热池的硬顶,防止冻结破坏有界上下文记忆预算1600 token约占 8k 窗口的 15%~20%,窗口更大的按比例放大确认冻结阈值3 次必须是跨会话的独立确认,同会话重复不计语义去重阈值0.85高于此值且同类型才合并风化降级周期60 天冻结条目长时间无命中无确认则自动降级动态准入阈值池内分数中位数防止门槛随池子质量整体抬高而失效起始阶段不建议把数字调得太激进。先按这套参数跑两周,观察热池更新频率和冻结池数量,再根据自己的业务场景微调。6.2 监控与人工审计:别让记忆系统变成黑盒记忆系统一旦出错,用户很难自己发现,因为他们看不到后台到底记住了什么。我强烈建议加一块"记忆可视化面板":让用户(或者你自己内测时)能查看当前热池里存了哪些信息、哪些条目处于冻结状态、最近一次覆盖/降级发生在什么时候。这个面板既是 Debug 工具,也是用户信任的来源。另外,我每周会抽检一次冻结池,看有没有明显不合理的冻结条目。做法很简单:把冻结条目当作 prompt 的一部分丢给模型,问它"这三条用户画像之间有没有矛盾?有没有不符合常理的?"然后人工再过一遍。看似笨,但真能拦住不少漏网之鱼。6.3 还能往哪里延伸这套双闸设计不是终点。我目前正在做的扩展有几个方向:一是给不同用户类型建不同的冻结白名单,避免用同一套标准适配所有场景;二是把"显式纠正"做成用户可见的交互,比如允许用户在记忆面板里直接点赞或删除某条记忆,删除操作同样要过冻结闸,但用户的显式指令优先级最高;三是引入更平滑的衰减曲线,而不是硬性的 60 天一把切,让风化降级更自然。我个人实际跑下来的最大体会是:记忆系统的核心不是"记得更多",而是"记得稳、忘得准、改得难"。有界闸管住了规模,冻结闸管住了路径,两者配合,才让用户模型既有长期骨架,又不会被短期噪音带偏。如果你也在做类似的事情,建议先别急着堆功能,把这两个闸门的边界和协作关系理清楚,后面所有迭代都会顺很多。
1. 跨会话记忆的"三座大山":遗忘、污染与失真我做 AI 助手有一段时间了,最常被用户吐槽的一句话是:你怎么什么都不记得?用户上周刚说过的偏好,这周换一个会话进来,又要重新交代一遍。放在纯聊天场景里,这种体验顶多是"有点烦";一旦放到生产力工具里,比如写作助手、客服工作台、个人知识库,这种失忆基本就是劝退级别的缺陷。我最早的做法非常粗暴:把历史会话的向量全部存下来,新会话开始时做一次 top-k 检索,把最相似的内容塞进上下文。听起来很合理,跑起来却到处漏风。不是检索不准,而是"什么都记"这件事本身会引入新的问题。后来我把问题拆成三类,也就是我标题里说的三座大山:遗忘、污染、失真。1.1 遗忘:LLM 天生没有长期记忆这一点不用多说,大模型的上下文窗口再大,也是"这次对话"的窗口。会话一关,除了你主动写回外部存储的东西,模型对刚才说过的内容没有任何持续印象。跨会话记忆的本质,就是把会话之间真正有价值的信息,抽出来放到一个独立持久层里,下一次会话再注入回上下文。但这里埋着第一个坑:该抽什么、存什么,不是一句"全部存下来"能解决的。原始对话里大量是寒暄、临时安排、一次性情绪表达,这些如果全部进记忆库,不但浪费存储,还会在检索时疯狂干扰后续的上下文组装。1.2 污染:记忆仓库不是仓鼠窝有段时间我做了一个很"勤快"的记忆模块:每条会话里只要出现"我喜欢""我不喜欢""以后记得"这类句式,就抽取出来入库。结果呢?用户某天心情不好说了一句"以后别给我推这么长的文章",系统立刻把"厌恶长文"写进偏好;第二天用户看到一篇长文深度分析后说"这篇真有用",系统又写了一条"喜欢深度长文"。两条偏好在向量空间里距离很近,检索时经常同时命中,模型一会儿觉得用户要短文,一会儿觉得用户要长文,最终表现就是反复横跳。我把这种问题叫污染:一次性、情境性的信息,被当成长期特征写进了用户模型。污染的直接后果是上下文里填充了大量互相矛盾的"伪事实",模型只能二选一,无论选哪个都是错的。1.3 失真:越勤奋更新,画像越不稳定第三座大山更隐蔽。假设系统已经在用户模型里稳定记录了"偏好简洁回复"这条事实,某一天用户赶时间,连续说两次"麻烦说细一点,要步骤"。如果系统用"最近一次表达覆盖旧值"的策略,它会把"偏好简洁"改写成"偏好详细"。于是用户画像发生了漂移,而真相是:用户长期偏好简洁,只是当下那一件事需要详细步骤。这种失真最坑的地方在于,它经常发生在用户情绪化或赶时间的场景里。单次强信号不能代表长期特征,但如果系统只是"听话"地更新,用户模型就会不断被最新信号左右,永远不稳定。失真和污染的区别在于:污染是写入噪音,失真是把已有的正确信息改错。三座大山叠在一起,我意识到不能靠单一的检索策略或单一的更新策略解决。我需要两个独立但协作的"闸门":一个控制什么信息能进来、总量有多少,一个控制什么信息一旦确认就不能被轻易改掉。这就是 Hermes Memory 双闸设计的由来。2. 有界闸门:给记忆装上限位阀,而不是无限扩展有界闸门解决的是"进多少、留多少、淘汰什么"的问题。它管的是记忆系统的总量和准入,核心思路是:记忆不该是无限生长的仓库,而应该是一个有最大容量、有淘汰机制的缓存池。2.1 为什么"记全"是最容易踩的误区很多人第一反应是:反正现在向量数据库便宜,全存下来不就好了?短期确实没问题,但长期会踢到三块铁板。第一块是上下文预算。每次会话开场要从记忆库取内容注入上下文,而上下文窗口是固定预算。如果记忆库本身没有规模约束,取多少、怎么取就会变得不可控。我实测过,当知识内容规模上去之后,如果只是简单按相似度取 top-k,取回来的经常是结构重复、信息冗余的片段,真正重要的核心偏好反而被挤出窗口。第二块是注意力稀释。大模型的上下文注意力是有限的资源,塞进去的"记忆"越多,模型越容易迷失在细节里,反而忽略了当下用户对话的重点。我一直用的类比是:你进办公室时桌上堆了一百份无关文件,你最需要的那份合同反而要找半天。第三块是维护成本。记忆条目越多,去重、冲突检测、过时判断就越难做。到了后期,每次写入都要跟所有旧条目比对一遍,延迟和计算成本都会上涨,效果还不一定更好。所以我给自己定了一个硬性目标:记忆上下文在任何时刻都必须是一个有界集合。宁可漏掉一些可能有用的信息,不能让记忆库本身失控。2.2 打分函数:什么信息才有资格进入记忆有界不等于简单截断,关键是"怎么决定谁留下"。我给每条候选记忆算一个综合分,公式长这样:score = w1 * recency_norm + w2 * importance + w3 * confidence其中:recency_norm:归一化后的时间新鲜度,越近越高。importance:信息本身的重要性评级,由抽取环节根据语义和句式给出,比如"用户明确表达的偏好"比"用户随口提到的某个名词"重要得多。confidence:这条信息被反复确认的次数折算,确认越多越可信。我初期用的权重是w1=0.4, w2=0.4, w3=0.2。后来踩了坑,把旧信息保护也加了进去,这个我会在第五节的翻车现场详说。有界闸门的第一道判定就是:新候选记忆必须先有资格入库,再谈能不能顶掉别人。不是所有抽取出来的信息都能进记忆池,只有分数超过准入阈值的才放行。准入阈值我建议设成动态的,比如取当前池内分数的中位数,而不是一个固定值,否则池子整体分数升高后,门槛就失效了。2.3 两级存储与淘汰策略:热池与冷池光有一个池子是不够的。我一开始只有一个 list,所有记忆条目按分数倒排,超过容量就踢掉最后一名。结果发现一个问题:某些分数稳定的核心条目长期占着高位,而一些新出现的、可能重要但尚未确认的条目永远进不来。后来我把存储拆成了两层。热池(hot pool):最多保留 200 条,保存最近高频使用的、分数靠前的、尚未确认但新鲜度高的候选条目。这个池子的更新频率很高。冷池(cold pool / archive):保存已经确认、但暂时用不上的稳定事实。冷池不占上下文预算,只在特定检索条件下才会被拉回来。这样设计的逻辑是:热池负责承担大部分上下文组装和快速反应,冷池负责长期沉淀。有界闸门的淘汰动作主要发生在热池,每次新增条目时检查:若热池已满,则比较新条目与池内最低分条目,分数更高则淘汰旧条、纳入新条;否则拒绝写入。冷池虽然容量很大,也不是无限的,我用一个归档上限控制。但冷池里的条目不是被"永久遗忘",而是进入低优先级状态——除非被新的高置信信号重新激活,否则不再回到日常上下文。2.4 有界与冻结的结构性矛盾,留待双闸联动解决这里必须坦白一个设计上的悖论:如果某些条目可以被"冻结"从而免于淘汰,那么有界原则就被破坏了。冻结条目越多,池子就被越多的"不可驱逐者"占据,最终依然会膨胀。我一开始天真地想:冻结条目不参与淘汰不就行了?结果跑了一周,池子里冻结条目占了三分之一,且还在增长,有界形同虚设。后来我调整了思路,把"冻结"从"不可淘汰"改成"高淘汰门槛",又把冻结池单独设置了一个更小的硬顶,具体方案放在第四节里讲,因为它是整个实现链路的关键部分。3. 冻结闸门:用户模型的"防篡改"机制如果说有界闸门管的是"进多少",冻结闸门管的就是"已经进来的东西,不能被随便改"。3.1 用户画像最怕的其实是"太听话"做用户建模的人都知道,用户模型的价值在于稳定性和可预测性。但在实际运行中,传统更新策略往往会让模型变得过度适应短期状态。我前面举过一个例子:用户长期偏好简洁回复,某天赶时间说"说细一点",系统如果直接用新消息覆盖旧偏好,就失真了。这是一个非常典型的场景,但我发现更难防的是渐进式漂移:用户一周内有几次临时需求,每次都表达同一个方向的强度,模型就会一步步把核心偏好带到偏离方向。每一次单独看起来都"合理",连起来看就是灾难。冻结闸门的设计目标就是:对达到高置信度的核心用户特征,禁止被单次或低频信号直接覆盖。它不是不更新,而是"更新需要更高的门槛和更严谨的流程"。3.2 冻结的三个前置条件:置信度、确认次数、类型白名单我最终敲定了一条规则:一个条目要被冻结,必须同时满足三个条件。第一,置信度达到阈值。这个置信度来自抽取模型的语义判断,比如"用户明确表达"和"用户随口提到",置信度显然不同。第二,确认次数达到 N 次。我这里取的 N=3。也就是说,同一个事实必须被用户在不同的会话里明确确认过至少三次,才具备冻结资格。中间任何一次出现矛盾信号,确认计数就会回退。第三,条目类型在冻结白名单内。不是所有信息都值得冻结,我目前只冻结三类:preference:用户明确表达的长期偏好,如"我习惯看中文内容"。constraint:用户给出的边界约束,如"我的预算有限,别推太贵方案"。identity:用户身份相关的事实,如昵称、职业、使用场景。而一次性事件、临时状态、情绪表达,天然就不在冻结白名单里。3.3 冻结之后怎么处理"更新请求"这是很多人误解的地方。冻结不等于锁死,而是改变更新路径。一个已冻结条目收到新的冲突信息时,有三个处理分支:忽略:新信息置信度低,直接丢弃;挂起:新信息置信度较高,但不足以推翻冻结条目,就记为pending_override,等待后续更多信号;覆盖:新信息置信度极高,并且用户明确纠正了旧偏好(比如用户直接说"从现在开始我改看英文内容"),此时才允许覆盖,并保留旧版本的历史记录。我额外加了一个"风化机制":如果一个冻结条目连续 60 天没有被任何检索命中,也没收到过任何确认信号,它会被自动降级为普通已确认条目,重新进入可淘汰范围。这样能让冻结池自动吐掉一些已经过时的核心偏好。3.4 冻结池自己的硬顶前面说过,冻结不能破坏有界。我给冻结池单独设了上限:最多 30 条。超过上限时,新的候选冻结请求必须等待,系统优先执行"风化降级"或"显式纠正降级",腾出位置后才允许新的冻结。有人会问:如果 30 条不够呢?我的经验是,真正值得冻结的用户核心特征,不会超过几十条。如果你的系统里出现了几百条"必须冻结"的偏好,大概率是抽取环节没有做好区分,把太多短期偏好误判成了长期特征。这时候应该回头调抽取策略,而不是调高上限。4. 双闸联动:从消息进入到用户模型落盘的完整链路前面两节分别介绍了两个闸门的独立逻辑,但真正让 Hermes Memory 奏效的是它们如何协作。我花了最多调试时间的也是这里。4.1 先解决结构性矛盾:冻结条目不撑爆有界空间第二个章节里我留了一个伏笔:有界和冻结怎么共存。我的最终方案可以概括为三句话:冻结池有独立的硬顶(30 条),不算进热池的 200 条预算;冻结条目平时不参与热池得分排序,但每次会话开场组装上下文时,冻结池的核心字段会作为一个固定模块注入;冻结条目可以被"显式纠正"或"长时间风化"降级,降级后回到热池参加正常淘汰。这样,有界仍然成立:热池有限,冻结池也有限,总量是 230 条的常数。而不是无限膨胀。这个设计让"双闸"真正变成双闸,而不是两个互相打架的规则。4.2 写入链路:一条用户消息是如何"过闸"的每一条用户消息进来,都会走下面这条链路:消息进入 → 结构化抽取 → 有界闸打分准入 → 相似度去重 → 冲突检测 → 冻结闸校验 → 状态流转 → 写入持久化第一步,结构化抽取。我用一个小模型的抽取调用,把非结构化对话转成结构化的候选记忆条目,包括类型、内容、置信度、置信来源(明示/暗示/情绪化表达)。注意,这一步不要只做关键词匹配,一定要让模型输出置信度,它后面是两个闸门共同依赖的依据。第二步,有界闸打分准入。候选条目先按 2.2 节的公式打分,低于动态准入阈值的直接丢弃,不进入后续流程。这是第一道闸。第三步,相似度去重。把候选条目和池内已有条目做 embedding 相似度比对,超过阈值就视为同一个事实的重复表达,走"合并"而不是"新增"。合并时更新confirm_count和last_seen_at,这就是后面冻结闸判断确认次数的数据来源。第四步,冲突检测。如果候选条目和已有条目是同一类型、同一维度但语义冲突,比如"偏好简洁回复"和"偏好详细回复"同时存在,就进入冲突分支。第五步,冻结闸校验。这一步是第二道闸:如果候选条目对应的已有条目是冻结状态,就看 3.3 节里的三个处理分支;如果不是冻结状态,则进入普通更新路径,由新消息的置信度和确认次数决定是否覆盖旧值。第六步,状态流转。所有条目都在这时更新自己的状态机:candidate → confirmed → frozen,或者从冻结降级回confirmed。状态流转记录都会被保留,方便回溯。最后写入持久化存储。整个链路我用的是一个异步队列,因为中间的抽取和去重要调模型和向量检索,同步跑会拖慢主对话延迟。实测单条消息平均增加 300~500ms 的异步写入耗时,对用户无感,但对记忆质量提升非常明显。4.3 会话开场检索:从"全量注入"变成"三级拼装"另一个关键点是:新会话开始时,怎么把记忆注入上下文。我最初的方案是把热池 top-k 直接拼进 system prompt,效果不稳定。后来改成"三级拼装":第一级(固定模块):冻结池里的核心画像字段,数量少、信息密度高,每次必带,预算固定 400 token 左右。这些是用户模型的骨架,比如身份、语言偏好、核心约束。第二级(动态模块):热池里得分最高的前 20 条,按当前对话的主题关键字做一次相关性过滤,预算约 800 token。这一级提供的是"最近在关心什么"的上下文。第三级(检索模块):用当前用户的第一句话做一次语义检索,从冷池和热池里捞取 top-5 追加进去,预算约 400 token。这样设计的好处是:骨架永远不变、近期变化能感知、长尾知识按需取出。上下文预算被严格控制在约 1600 token 以内,大约是总窗口的 15%~20%。比例太高会喧宾夺主,太低则记忆感知不到,这个区间我实测下来比较稳。4.4 存储与数据结构:一张表加一个向量索引的最小组合我不喜欢一开始就把存储搞得花里胡哨。目前 Hermes Memory 的存储核心就是一张 SQLite 表加一个向量索引:CREATE TABLE memory_items ( id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, item_type TEXT NOT NULL, -- fact / preference / event / rule content TEXT NOT NULL, status TEXT NOT NULL, -- candidate / confirmed / frozen / archived score REAL DEFAULT 0, confirm_count INTEGER DEFAULT 0, last_seen_at TEXT, created_at TEXT, updated_at TEXT, source_session_id TEXT, override_of INTEGER, -- 如果覆盖了旧条目,记录旧条目 ID version INTEGER DEFAULT 1 ); CREATE INDEX idx_user_status ON memory_items(user_id, status);向量索引单独建,存(item_id, embedding),用于相似度去重和第三级检索。两张表配合,足够支撑个人级和中小团队级的使用量。等项目到了多用户大规模阶段,再迁移到专门向量数据库不迟,不要在项目一开始就过度设计。5. 实测中的意外收获与翻车现场理论讲完了,说点真实的。Hermes Memory 上线到模拟环境跑了一个多月,有变得特别好的场景,也有翻车翻到我想摔键盘的瞬间。5.1 哪些场景真的变好了最明显的是"回访用户"体验。一个用户隔了两周回来,开场就能叫出他上次交代过的项目代号,还记得他上一次提到的约束条件,用户的反应是"你怎么还记得",这种反馈比任何指标都直接。其次是风格一致性。以前用户每次进来,模型回复风格都像第一次见面,有时偏正式、有时偏随意。引入冻结闸之后,只要"偏好简洁/偏好口语化"这类条目被冻结,每次会话的风格重启问题就消失了,模型能稳定保持用户习惯的语气。第三是重复提问率下降。用户不需要每次会话都重新解释背景资料,第一级固定模块里的身份约束直接把很多背景信息打包交给了模型。我统计的三周内模拟对话中,用户重复解释背景的次数降低了约 60%。这个数字模拟环境里可能偏乐观,但趋势是明确的。5.2 翻车一:误冻结比不冻结更可怕有一次,一个用户连续两次在深夜吐槽"以后别给我推荐新东西了,我就想用我熟悉的方案"。系统按规则认为这是"偏好确认",把"不接受新方案推荐"冻结了。结果第三天用户白天主动问"最近有没有新工具推荐",系统因为冻结条目,还在继续回避推荐,用户被激怒。这个翻车让我明白:冻结一个错误事实的代价,远高于不冻结。规则再也不能只看"确认次数",还要看确认时的情绪和场景。我后来给抽取模型加了一个判断项:ephemeral_emotion,如果候选条目被判定为情绪化表达,它的确认次数需要乘以一个 0.5 的折扣系数,并且必须有至少一次是在非情绪化场景下确认的。半夜吐槽连续两次,权重会被显著拉低,不再轻易促成冻结。5.3 翻车二:打分权重把老用户的核心偏好挤出了热池我初期的打分公式里recency_norm权重是 0.4,导致很多高频讨论的新话题总是盖过老用户真正长期偏好。比如一个用户三个月里一直偏好"中文资料",但本周连续讨论了几个英文项目,结果"偏好中文资料"的得分被压到了热池后半部,险些被淘汰。后来我给公式加了一个importance_floor机制:已确认条目里,类型为 identity/constraint 的,得分保底在热池前 50 名以内,不参与常规淘汰。这本质上是一种"准冻结",用优先级替代分数竞争。这个改动之后,长期偏好被挤出的问题基本消失。我也把recency_norm的权重从 0.4 降到了 0.25,把importance的权重升到了 0.5。5.4 翻车三:语义去重把两个含义不同的条目合并了这大概是所有向量检索方案的通病。两个条目"用户偏好代码示例"和"用户偏好简短的代码示例",embedding 相似度可能超过 0.9,去重时被合并成一个,结果丢失了"简短"这个关键限定。我的解法是:去重不能只看 embedding 相似度,必须要求 item_type 字段一致,并且内容经过抽取模型的结构化拆分后再比对。比如把"简短"拆成一个独立的修饰属性,而不是混在一条大文本里做向量化。结构化的价值在这里体现得很明显:宁可抽取多花一点时间,也别让语义模糊害死后续两条闸。6. 抄作业指南:起步配置、监控手段与后续扩展最后给想在自己的项目里试这套双闸记忆方案的同学一份可直接参考的配置,以及我从踩坑里提炼出的运营建议。6.1 一份可以直接用的起步参数参数建议值说明热池容量200 条常规个人助手够用,容量小了容易抖动,大了浪费预算冻结池容量30 条独立于热池的硬顶,防止冻结破坏有界上下文记忆预算1600 token约占 8k 窗口的 15%~20%,窗口更大的按比例放大确认冻结阈值3 次必须是跨会话的独立确认,同会话重复不计语义去重阈值0.85高于此值且同类型才合并风化降级周期60 天冻结条目长时间无命中无确认则自动降级动态准入阈值池内分数中位数防止门槛随池子质量整体抬高而失效起始阶段不建议把数字调得太激进。先按这套参数跑两周,观察热池更新频率和冻结池数量,再根据自己的业务场景微调。6.2 监控与人工审计:别让记忆系统变成黑盒记忆系统一旦出错,用户很难自己发现,因为他们看不到后台到底记住了什么。我强烈建议加一块"记忆可视化面板":让用户(或者你自己内测时)能查看当前热池里存了哪些信息、哪些条目处于冻结状态、最近一次覆盖/降级发生在什么时候。这个面板既是 Debug 工具,也是用户信任的来源。另外,我每周会抽检一次冻结池,看有没有明显不合理的冻结条目。做法很简单:把冻结条目当作 prompt 的一部分丢给模型,问它"这三条用户画像之间有没有矛盾?有没有不符合常理的?"然后人工再过一遍。看似笨,但真能拦住不少漏网之鱼。6.3 还能往哪里延伸这套双闸设计不是终点。我目前正在做的扩展有几个方向:一是给不同用户类型建不同的冻结白名单,避免用同一套标准适配所有场景;二是把"显式纠正"做成用户可见的交互,比如允许用户在记忆面板里直接点赞或删除某条记忆,删除操作同样要过冻结闸,但用户的显式指令优先级最高;三是引入更平滑的衰减曲线,而不是硬性的 60 天一把切,让风化降级更自然。我个人实际跑下来的最大体会是:记忆系统的核心不是"记得更多",而是"记得稳、忘得准、改得难"。有界闸管住了规模,冻结闸管住了路径,两者配合,才让用户模型既有长期骨架,又不会被短期噪音带偏。如果你也在做类似的事情,建议先别急着堆功能,把这两个闸门的边界和协作关系理清楚,后面所有迭代都会顺很多。