Hermes Memory双闸设计:有界与冻结的跨会话记忆机制
1. 从“聊完就忘”说起为什么跨会话记忆值得单独设计做过对话式智能体的人都有一个共同的痛用户昨天刚说过“我对花生过敏”今天再问餐厅推荐系统照样推了一道宫保鸡丁。这不是模型不够聪明而是记忆没有跨会话留存。单次会话内的上下文窗口再大一旦会话结束一切归零。用户每次都要重新自我介绍、重新交代偏好、重新纠正上一次已经纠正过的错误体验割裂得像每次都在跟一个失忆的人说话。Hermes Memory 要解决的就是这个问题。它的核心目标很明确让智能体在多次会话之间记住用户的关键信息并且基于这些信息构建一个持续演进的USER 画像用户模型。但真正让这套设计值得单独拿出来讲的不是“能记住”而是它在“记住”这件事上加了两道闸——有界和冻结。这两个词听起来抽象落到工程上其实非常具体有界说的是记忆容量和写入频率必须有上限冻结说的是某些记忆一旦写入就不能被随意覆盖。我见过太多团队在记忆系统上翻车翻车的方式高度一致一开始不加限制地往记忆库里塞东西跑了两周发现检索出来的全是噪音或者用户改了一次偏好旧偏好没被清理新老记忆打架模型输出开始精神分裂。Hermes Memory 的“有界冻结”双闸设计本质上是对这两类翻车的预防性约束。这篇文章我会把这套设计的思路、参数、实操细节和踩坑经验完整拆开讲适合正在做对话记忆、用户建模、智能体长期记忆的开发者参考也适合产品同学理解为什么记忆系统不能“随便存”。2. 整体设计思路为什么是“有界”加“冻结”而不是简单的读写2.1 记忆系统的三种常见失败模式在讲 Hermes Memory 的具体设计之前先说说记忆系统最容易死在哪三个地方这样你才能理解双闸设计的针对性。第一种是无限膨胀。每轮对话都把用户说的话抽成记忆条目写进去一周下来几千条检索时向量相似度最高的那几条可能全是“用户说了你好”“用户问了天气”这种废话。记忆库变成了垃圾场真正重要的“用户是素食主义者”反而被淹没。第二种是覆盖污染。用户某次随口说“今天想吃辣的”系统把它当成长期偏好写进画像结果下次用户说“我最近胃不好”新记忆和旧记忆同时存在模型不知道该信哪个。更糟的是如果直接覆盖用户偶尔的一次性表达会永久污染画像。第三种是检索失焦。记忆条目没有分层所有记忆平等参与检索导致每次对话都要把整个记忆库过一遍延迟高不说召回的还未必是当前场景需要的那条。Hermes Memory 的双闸设计分别对应前两种失败模式有界治膨胀冻结治污染。第三种靠的是记忆分层和检索策略属于配套设计后面会展开。2.2 “有界”到底界的是什么有界不是简单地说“最多存 100 条”。Hermes Memory 的有界体现在三个维度上每个维度都有独立的阈值和淘汰逻辑。容量有界记忆库对单个用户的记忆条目总数设上限比如 200 条。超过之后不是拒绝写入而是触发淘汰——按重要度、时效性、访问频次综合打分淘汰分数最低的。这里的关键是淘汰不能只看时间否则一条三个月前写入但极其重要的“用户有糖尿病”会被误删。写入频率有界不是每轮对话都允许写记忆。Hermes Memory 设置了一个写入冷却窗口比如同一会话内最多触发 3 次记忆写入且两次写入之间至少间隔 5 轮对话。这个设计的理由是用户在连续几轮里说的话往往是同一个话题的展开逐轮抽取会产生大量冗余条目。单条记忆长度有界每条记忆条目有字数上限比如 120 字。超出的内容要么被摘要压缩要么被拆分。这看起来是小事但实际跑起来你会发现不限制单条长度的话模型很容易把一整段对话摘要成一条 500 字的“记忆”检索时这条巨长的记忆会挤占上下文预算。提示容量有界的阈值不要拍脑袋定。我的经验是先跑一周全量记录统计每个用户的日均新增记忆条数和有效记忆占比再反推上限。200 条对高频用户可能只够两周对低频用户可能够半年。2.3 “冻结”冻结的是什么冻结的核心思想是不是所有记忆都平等有些记忆一旦确认就不能被自动流程改写。Hermes Memory 把记忆分成两类可写记忆和冻结记忆。可写记忆是常规的偏好、习惯、近期状态可以被新信息更新或淘汰。冻结记忆是那些一旦写入就锁定的事实性信息比如过敏源、慢性病、明确的禁忌。冻结记忆的写入需要满足更高的置信度门槛一旦写入自动流程只能读取不能修改要改必须走显式的用户确认流程。这个设计的现实依据很直接用户说“我今天不想吃辣”和用户说“我对辣椒过敏”是两件性质完全不同的事。前者是状态后者是事实。状态可以变事实不能随便变。如果系统把“对辣椒过敏”当成普通偏好某天用户说“今天想吃辣”就可能触发覆盖后果是灾难性的。冻结机制还解决了一个隐蔽问题防止模型自我强化错误。如果记忆可以被模型自己写入和修改模型可能在多轮对话中把自己的推测当成用户事实写进去然后下次又读到这条“事实”并进一步强化。冻结机制切断了这条自我污染链路——只有高置信度的、通常来自用户明确陈述的信息才能进入冻结区。2.4 双闸协同的工作流把两个闸放在一起看一次记忆写入的完整决策链是这样的对话产生候选记忆先过置信度过滤低置信度的直接丢弃过完置信度后判断类型如果是事实类且置信度极高走冻结写入如果是状态类或偏好类走可写记忆写入写入前检查容量和频率闸门超限则触发淘汰。检索时冻结记忆拥有更高的召回优先级和更强的注入权重。这套流程的价值在于它把“记什么”“记多久”“能不能改”三个问题解耦了。很多记忆系统的失败就是因为把这三个问题混在一起用一个逻辑处理结果哪个都没处理好。3. 核心细节拆解置信度、分层与淘汰的具体实现3.1 记忆抽取的置信度打分置信度是双闸设计的前置过滤器它决定了候选记忆有没有资格进入后续流程。Hermes Memory 的置信度打分不是单一模型输出而是几个信号的加权组合。第一个信号是陈述明确性。用户说“我是素食者”比“我最近在考虑少吃肉”明确得多。实现上可以用一个轻量分类模型判断句子是事实陈述、偏好表达还是状态描述事实陈述给高分状态描述给低分。第二个信号是重复出现次数。同一个信息在不同会话中被用户提到过多次置信度累加。这个信号很关键因为单次提及可能是口误或玩笑多次提及才值得固化为记忆。第三个信号是否定与条件检测。包含“不是”“除了”“只有在……时候”这类结构的句子置信度要打折因为这类表达的边界条件复杂容易被错误泛化。三个信号加权后的分数落在 0 到 1 之间。我的实践里阈值这样设比较稳低于 0.4 直接丢弃0.4 到 0.7 进入可写记忆候选高于 0.7 且被判定为事实类的才允许进入冻结候选。冻结候选还要再过一道人工规则或二次确认不能全自动。def confidence_score(statement, history, nlp_signals): explicitness nlp_signals.fact_score(statement) # 0~1 repetition min(history.count_similar(statement) / 3, 1.0) # 0~1 negation_penalty 0.7 if nlp_signals.has_negation(statement) else 1.0 raw 0.5 * explicitness 0.3 * repetition 0.2 * nlp_signals.specificity(statement) return raw * negation_penalty这段逻辑里明确性权重最高因为它是单次对话内就能拿到的强信号重复次数权重次之它需要跨会话累积具体性权重最低作为微调。否定惩罚是乘性的一旦命中直接打七折。3.2 记忆分层冻结区、稳定区、临时区Hermes Memory 把记忆库物理上分成三层每层的读写策略和检索权重都不同。冻结区存放事实性记忆条目数很少通常个位数到十几条。写入需要高置信度加确认写入后只读。检索时这一层永远优先注入且不受容量淘汰影响。稳定区存放长期偏好和习惯比如“喜欢靠窗座位”“常用中文交流”。这一层可写可淘汰容量占记忆库的大头比如 70%。淘汰时按综合分排序但稳定区的条目有最低保留期比如 30 天内不淘汰避免刚写入就被挤掉。临时区存放近期状态和一次性信息比如“这周在出差”“最近在减肥”。这一层生命周期短默认 7 天过期过期自动清理。检索时临时区权重最低只在当前会话明显相关时才注入。分层的意义在于它让检索有了优先级也让淘汰有了差异化策略。不分层的记忆库所有条目平等竞争结果就是重要的和不重要的互相挤占。层级存放内容容量占比生命周期检索权重可写性冻结区事实、禁忌、过敏源5%永久最高只读稳定区长期偏好、习惯70%30天起中可写可淘汰临时区近期状态、一次性25%7天低可写自动过期3.3 淘汰算法的综合打分容量有界触发淘汰时Hermes Memory 用的不是单一维度排序而是三个因子的加权和。重要度由写入时的置信度和层级决定冻结区不参与淘汰稳定区基础分 0.6临时区基础分 0.3。时效性越新越高但不是线性的。我用的是指数衰减半衰期 45 天也就是 45 天前的记忆时效分减半。这样既不会让老记忆瞬间归零也不会让新记忆无脑占优。访问频次这条记忆被检索命中并实际注入上下文的次数。频次高的记忆说明它真的有用应该保留。频次统计要带时间窗比如只统计最近 60 天避免历史高频但已过时的记忆赖着不走。def eviction_score(memory, now): importance memory.base_score recency 0.5 ** ((now - memory.created_at).days / 45) access min(memory.access_count_60d / 10, 1.0) return 0.4 * importance 0.35 * recency 0.25 * access淘汰时按这个分数升序排列从最低的开始删直到容量回到阈值以下。注意淘汰是批量触发的不是每写入一条就淘汰一条否则计算开销太大。我的做法是容量超过上限的 110% 时才触发一次批量淘汰一次淘汰到 90%留出缓冲。注意淘汰一定要有日志和可回滚机制。我踩过的坑是某次调整权重后一批重要记忆被误删因为没有备份用户画像直接退化。现在我的做法是淘汰前先快照保留最近三次淘汰的快照。3.4 冻结记忆的写入确认流程冻结记忆不能自动写入这是铁律。Hermes Memory 的流程是高置信度事实类候选先进入待确认队列在合适的时机比如会话自然停顿处向用户发起确认用户明确肯定后才写入冻结区。确认话术的设计有讲究。不能问“您是对花生过敏吗”这种封闭式问法用户可能随口答是。更好的做法是复述加确认“我记一下您对花生过敏以后推荐餐食我会避开这样可以吗”让用户有机会纠正。如果用户否认候选记忆直接丢弃并且在一定时间内不再对同一信息发起确认避免骚扰。如果用户确认写入冻结区并记录确认时间戳和确认方式。冻结记忆的修改同样要走确认。用户说“我现在能吃花生了”系统不能直接改而是发起确认“之前记录您对花生过敏现在要更新为不过敏吗”确认后才修改。这个流程看起来繁琐但对于医疗、安全相关的信息繁琐是必要的。4. 实操落地从零搭一套带双闸的记忆模块4.1 存储选型与表结构设计记忆存储我推荐用支持向量检索的数据库比如带 pgvector 的 PostgreSQL或者专门的向量库。但注意记忆系统不是纯向量检索它还需要结构化字段做过滤和排序所以纯向量库往往不够最好选支持混合查询的方案。核心表结构大概是这样记忆主表存 id、用户 id、层级、内容、向量、置信度、创建时间、访问次数、状态。另有一张确认队列表存待确认的冻结候选。淘汰日志表存每次淘汰的快照。CREATE TABLE memories ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, tier VARCHAR(16) NOT NULL, -- frozen / stable / temp content TEXT NOT NULL, embedding VECTOR(768), confidence FLOAT NOT NULL, base_score FLOAT NOT NULL, access_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP, status VARCHAR(16) DEFAULT active ); CREATE INDEX idx_user_tier ON memories(user_id, tier); CREATE INDEX idx_embedding ON memories USING ivfflat (embedding vector_cosine_ops);向量维度按你用的嵌入模型定768 是常见选择。索引用 ivfflat 还是 hnsw 看数据量十万条以内 ivfflat 够用再大考虑 hnsw。4.2 写入链路的完整实现写入链路我拆成五步每步都可以独立测试和调参。第一步候选抽取。每轮对话结束后把本轮对话喂给抽取模型输出候选记忆列表每条带内容、类型猜测、初步置信度。抽取模型不用太大7B 级别的指令模型微调一下就够。第二步置信度重算。用前面说的三信号加权结合历史重复次数算出最终置信度。低于阈值的丢弃。第三步类型判定与路由。事实类且置信度高于冻结阈值进待确认队列其余进可写流程。第四步闸门检查。可写流程先检查写入频率冷却窗口内不允许重复写入再检查容量超限触发淘汰。第五步写入与索引。写入数据库生成向量索引更新相关统计。def write_memory(user_id, candidates, session_ctx): for cand in candidates: score confidence_score(cand.text, load_history(user_id), cand.signals) if score 0.4: continue if cand.is_fact and score 0.7: enqueue_confirmation(user_id, cand) continue if not rate_limit_ok(user_id, session_ctx): continue if capacity_exceeded(user_id): evict(user_id) insert_memory(user_id, cand, score)这段是骨架实际跑还要处理并发写入、事务边界、失败重试。我的经验是写入用异步队列不要阻塞对话主流程否则用户会感觉到明显延迟。4.3 检索注入的策略与参数检索发生在每轮对话开始前目标是从记忆库里挑出最相关的几条注入上下文。Hermes Memory 的检索策略是分层加权加向量相似度的组合。先按层级取候选冻结区全取通常就几条稳定区取向量相似度 top 20临时区取 top 10。然后对候选统一算一个注入分向量相似度乘以层级权重冻结区权重 1.0稳定区 0.8临时区 0.5。按注入分排序取 top N 注入N 一般设 5 到 8。注入时的格式也有讲究。不要直接把记忆原文拼进去而是包一层说明“以下是关于该用户的已知信息供参考……”这样模型知道这是背景信息而非当前指令。冻结记忆要额外标注“此为确认事实不可违背”。提示注入条数不是越多越好。我实测下来超过 8 条之后边际收益急剧下降反而增加延迟和干扰。5 条左右是甜点区。4.4 参数调优的实操记录参数没有万能值但有一套调优方法。我的做法是准备一个评测集包含若干模拟用户和他们的多轮对话每个用户有预设的“应该记住的信息”和“不应该记住的噪音”。然后跑完整流程看召回率和准确率。容量上限从 100 开始试逐步加到 300观察召回率变化。我的场景下 200 是拐点再往上召回率不涨但延迟涨。写入冷却窗口从 3 轮试到 10 轮5 轮比较平衡。置信度阈值 0.4 和 0.7 这两个数也是试出来的0.35 会放进太多噪音0.45 会漏掉一些合理偏好。时效性半衰期 45 天是针对我的用户活跃度定的。如果你的用户是低频使用半衰期要拉长否则记忆还没被用上就衰减没了。5. 常见问题与排查技巧实录5.1 记忆不生效的排查路径最常见的反馈是“明明记了但模型不用”。排查按这个顺序走先看记忆有没有真的写进去查数据库确认再看检索有没有召回打印检索候选和注入分再看注入格式对不对有时候是格式问题导致模型忽略了最后看模型有没有遵循换个更强的模型试试。我遇到过一次诡异的情况记忆写进去了、召回了、注入了但模型就是不用。最后发现是注入位置太靠前被后续的长上下文冲淡了。把记忆注入挪到靠近当前用户输入的位置就好了。5.2 记忆冲突的处理冲突分两种同层冲突和跨层冲突。同层冲突比如稳定区里同时有“喜欢咖啡”和“不喜欢咖啡”这种靠时间戳取新的同时把旧的标记为待淘汰。跨层冲突比如冻结区说“对花生过敏”临时区说“今天想吃花生酱”这种冻结区优先但要在注入时给模型说明冲突让它知道临时状态不能违背冻结事实。5.3 冻结记忆误写的补救冻结记忆误写是最危险的情况。补救流程是立即把该条标记为失效从冻结区移除记录事件日志然后向用户致歉并重新确认正确信息。同时要回溯这条误写记忆在失效前被注入过多少次评估影响范围。预防上冻结写入的确认流程不能省我见过为了省事直接自动写冻结的后来出了大问题。问题现象可能原因排查动作解决方式记忆不生效未写入/未召回/格式错查库、打印检索、看注入逐环节定位修复记忆冲突新旧未清理/跨层矛盾查同层重复、查层级时间戳取新、冻结优先冻结误写确认流程被跳过查确认日志失效重确认、评估影响检索太慢候选太多/索引缺失看候选数、查索引限制候选、加向量索引记忆膨胀阈值太松/淘汰未触发统计条数、看淘汰日志收紧阈值、检查淘汰5.4 几个反直觉的实操心得第一个心得记忆不是越多越好少而准远胜多而杂。我早期追求高召回结果噪音把有效记忆淹没了后来主动收紧阈值效果反而更好。第二个心得冻结区要克制。不是所有事实都值得冻结冻结太多会让确认流程变得烦人用户会反感。我的标准是只冻结涉及安全、健康、明确禁忌的信息其他都放稳定区。第三个心得淘汰日志比记忆本身还重要。出问题时淘汰日志是唯一能还原“为什么这条记忆没了”的依据。别省这个存储。第四个心得用户建模不要过度。USER 画像的目的是让对话更顺畅不是给用户贴标签。我见过把用户画像做得极其精细的系统结果模型每句话都在迎合画像反而显得机械。画像要服务于对话不能反过来。这套双闸设计跑下来最深的体会是记忆系统的难点从来不是“怎么存”而是“怎么克制地存”。有界和冻结这两个约束本质上都是在对抗“什么都想记住”的冲动。真正好用的记忆是用户感觉不到它在工作但每次对话都恰到好处地接上了上一次。