做AI Agent的兄弟应该都有过类似的体验:模型明明很强,但一聊长就“失忆”,用户上一轮说的事下一轮就忘了,来回掰扯几轮之后用户耐心直接归零。我自己在给一个客服型Agent接记忆能力的时候,对比了Hermes、Mem0、Honcho、Hindsight这四个名字很像的记忆方案,最后发现一个有意思的事——它们的API设计和数据流几乎长在同一个骨架上。今天就把我对Memory Provider的实战拆解,以及它们为什么都这样设计的原因一次说清楚。
这篇文章适合两类人:一类是正在做Agent应用、被上下文窗口折磨得头疼的开发者,另一类是想自己设计记忆模块,但不知道从哪里下手的架构师。我不会只讲概念,会把核心接口、写入读取链路、触发时机、召回方式这些可落地的东西都摊开讲,最后还会给一段可以抄的Python实现。
1. Memory Provider 到底在解决什么问题
1.1 上下文窗口是Agent记忆的“天花板”
所有大模型的上下文窗口都是有限的,哪怕现在有支持128K甚至更长窗口的模型,也不能真的把所有历史对话一股脑塞进去。原因很直接:Token费用会随对话长度指数起飞,推理时间会越拉越长,而且长上下文里的关键信息往往被淹没在无关内容里,模型的注意力反而会分散。我做过一个简单测试,把十轮客服对话全部拼接进Prompt,结果模型对第一轮提到的订单号记忆准确率只有不到60%,而同样内容单独检索出来再放进去,准确率能到90%以上。
这就引出了Memory Provider的核心价值:它不是让模型“记住”全部内容,而是提供一个外部记忆系统,让Agent在需要的时候主动去查。就像人不会把每天发生的所有事都刻在脑子里,但遇到相关场景时能回忆起关键细节。上下文窗口是短期工作记忆的容量上限,而Memory Provider就是补上长期记忆这块短板。
1.2 记忆的三种层级:工作记忆、情景记忆、语义记忆
我在设计记忆系统时,习惯把Agent的记忆分成三个层级,对应认知科学里对记忆的分类方法。第一层是工作记忆,就是当前正在进行的对话上下文,通常保留最近几轮原始消息即可。第二层是情景记忆,记录过去发生的具体事件和对话片段,比如“用户上周三反馈过登录超时”。第三层是语义记忆,是提炼出来的事实、偏好、规则,比如“用户偏好简洁回复”“用户所在城市是上海”。
Hermes、Mem0这些方案表面看各不相同,但它们的数据模型都离不开这三个层级。有的方案侧重情景记忆,直接存储对话片段并建立索引;有的方案侧重语义记忆,会从对话中抽取实体和关系。真正好的Memory Provider不会只存储一种,而是让情景记忆和语义记忆互为补充,用情景片段支撑细节,用语义记忆支撑推理。为什么都要这么分层?因为单纯存储原始对话会导致召回时关键事实被噪声干扰,而单纯存储结构化实体又会丢失上下文语感,两者结合才稳妥。
1.3 为什么不能用Prompt硬拼一个记忆系统
你可能觉得,既然模型本身很聪明,那每次把历史记录格式化塞进Prompt不就行了?表面上看确实是最快路径,但只要你做过两轮以上真实用户对话,就会发现这条路根本走不通。首先是成本问题,假设每轮用户消息500字,助手回复800字,保留20轮的话,单是历史就得塞进26000字上下文,长期运行费用非常可观。其次是准确性问题,模型对长篇上下文的中间和末尾部分感知更强,早期的关键信息很容易被“冲淡”。
更重要的是,硬拼方案完全没有“记忆管理”概念。你没法对记忆做增删改查,没法设置有效期,没法按相关性排序,更没法在多个会话之间共享记忆。而Memory Provider本质上是一个带索引、带检索、带更新策略的存储层,它让记忆成为Agent可以用标准接口操作的一等公民。所以我一直跟团队说:不要把记忆写死在Prompt模板里,要把记忆做成一个独立的服务。
2. 四个主流记忆方案的设计密码
2.1 Hermes:消息级别的轻量记忆
先声明一下,我这里讨论的Hermes是指作为Memory Provider出现的轻量级记忆模块,不是网上那个同名开源大模型。Hermes的设计思路非常朴素:把每一条用户消息和助手回复都按“消息”为单位存入记忆库,每条消息带角色、时间戳、会话ID。检索时按相关度和时间贴近度把相关消息捞出来,拼成一个“记忆片段”塞进Prompt。
它的优点是好理解、易集成,整个库只需要一张消息表和一套向量索引。缺点是缺少对信息的提炼,原样存储的消息里经常混着寒暄、语气词、无关内容,召回时容易把噪声也带出来。如果你只是做一个个人助理类Demo,Hermes这种方案完全够用,因为它最接近“给Agent配一个对话记录本”的直觉。
2.2 Mem0:以实体为中心的长期记忆
Mem0是目前社区讨论度最高的Memory Provider之一,它的核心思路是把记忆从“消息”提升到“实体”。系统会从对话中抽取用户提到的偏好、任务状态、人物关系等,整理成实体和属性,例如“用户:张三,偏好:简洁回复,最近任务:申请退款”。每个实体可以有创建时间、更新时间、重要度,记忆库本质上变成了一张动态维护的知识图谱或结构化表。
这种设计最大的好处是可以做到定点更新。比如用户中途改了口,说“其实我喜欢更详细的回复”,系统只需要更新“偏好”这个实体的值,而不需要追溯之前的对话。Mem0还提供了管理API支持人工干预,记忆可以被人工确认、删除或修改,这在合规场景很重要。我实际体验下来,Mem0对客服、销售、CRM这类需要长期跟踪用户信息的场景相当契合,因为它的数据模型天然支持“用户画像”这种高层抽象。
2.3 Honcho:面向多Agent对话的“对话记忆”
Honcho这个名字听起来很特别,它的定位也更偏向对话场景本身。它不只是记录“谁说了什么”,还会维护对话中多个参与者之间的互动关系。比如在客服场景里有用户和AI助手,在协作场景里可能有多个Agent协作,Honcho会给每个参与者建立独立的记忆上下文,并记录他们之间的对话图。
Honcho对我印象最深的是它强调“re-contextualization”,也就是每次对话前都会基于当前参与者身份和历史互动,生成一个动态的“对话背景”。举个例子,同一个用户上午和下午分别来咨询,Honcho能让下午的AI自动想起上午聊到的方案,而且不会把另一个用户的记忆串进来。它的设计体现了多用户多会话隔离的重要性,这也是我一直强调的点:记忆Provider不能只做一个全局大仓库,必须按用户、会话、角色做好边界。
2.4 Hindsight:事后回顾式摘要记忆
Hindsight这个方案的名字就已经说明了一半:它是用“后见之明”来生成记忆。具体做法是,不在对话进行中逐条插入记忆,而是等一段对话结束后,调用大模型对这段对话做一次回顾,提炼出发生了什么、用户表达了什么偏好、有没有遗留的任务,然后把这些摘要作为结构化记忆存起来。这很像我们人类在一天结束后,躺在床上回忆今天开会说了什么,再决定哪些事情值得记住。
这种事后摘要思路有几个明显好处:第一,减少写入频率,对话过程不打断主流程,性能开销低;第二,生成的记忆质量更高,因为它有全局视角,能看到整个对话的起承转合;第三,天然支持“遗忘”,因为摘要本身就是一种信息压缩,无关细节会被自动丢弃。代价是如果需要实时回忆中间某个细节,可能因为摘要粒度太粗而丢失。Hindsight让我意识到,记忆Provider可以引入一个“总结器”角色,而不仅仅是存储和索引。
2.5 对比表:四个方案共享了哪些核心元素
为了更直观地看它们的共性,我把四个方案的几个关键维度放在一张表里:
| 方案 | 记忆粒度 | 主要触发时机 | 存储重点 | 召回方式 |
|---|---|---|---|---|
| Hermes | 消息级 | 实时每轮 | 原始消息片段 | 向量+时间加权检索 |
| Mem0 | 实体级 | 实时抽取+更新 | 实体属性/关系图 | 结构化查询+语义检索 |
| Honcho | 会话/参与者级 | 对话开始前和结束后 | 参与者关系与互动上下文 | 按参与者身份重构建 |
| Hindsight | 摘要级 | 对话结束后批量生成 | LLM提炼的结构化摘要 | 摘要检索+细节回查 |
看完这张表你会发现,四者根本的差异只在记忆粒度和触发时机,而底层都依赖三件事:内容提取、向量化存储、相关性召回。这恰恰说明Memory Provider的标准化程度远比我们想象得高,也意味着我们可以提炼出一套通用的实现范式。
3. 核心设计模式拆解:为什么这些方案殊途同归
3.1 写读分离:异步管道是标配
四个方案里,除了消息级的Hermes可能在边写边读,其他几个都把写入和读取拆成了独立的两条链路。写入链路负责从对话流中提取记忆、生成向量、写入存储;读取链路负责根据当前用户问题生成检索条件、召回记忆、重排、拼接Prompt。这两条链路不共享同一份代码,往往还跑在不同的触发点上。
为什么都要这么做?因为写入和读取的性能目标完全不同。写入希望低入侵,最好在后台异步完成,不要阻塞对话响应;读取希望低延迟,必须在几百毫秒内完成召回。如果把它们混在一起,比如每来一条消息就同步抽取一次实体并查询一次记忆库,那么Agent的每次响应都会被拖慢,而且容易因为异常导致对话中断。所以我在设计自己的Memory Provider时,第一件事就是把写入管道和读取管道彻底解耦,写入走异步队列,读取走同步接口。
3.2 记忆需要“分层缓存”:没有谁只用一个库
你会发现这些方案表面上都叫“长期记忆”,但实际上没有一个方案是只用一个全局数据库从头存到尾的。Hermes会先保存最近几轮消息在工作上下文中,然后才把更老的消息向量化到长期库;Mem0会把最近对话里的临时实体短暂保存在内存里,等确认了再合并到持久化实体表中;Honcho每次会话开始时就从长期存储里恢复对话背景;Hindsight则先把整段对话存进暂存区,等结束后再异步生成摘要。
这个设计可以类比成CPU的L1/L2/L3缓存:工作记忆是最热的数据,直接放Prompt;短期记忆是最近几天的高频数据,放在快速存储里;长期记忆是经过提炼的冷数据,放慢速但容量大的存储。为什么非要分这么多层?因为成本和召回质量的平衡点在中间。如果所有记忆都走向量库召回,最简单,但是每次调用都要多一次网络IO和向量计算;如果所有记忆都塞进Prompt,又回到了成本爆炸的老路。折中方案就是把“最近几轮”和“历史关键信息”分开处理,只有需要时才去翻长期记忆。
3.3 混合召回:语义检索+时间衰减+关键词过滤
很多刚开始做记忆系统的人以为,记忆召回就是给用户当前query做一个向量检索,取top5塞进Prompt就完事了。实际跑过之后会发现纯向量召回效果非常不稳定,有的是因为embedding模型对名词实体不敏感,有的是因为语义相近但上下文角色不对,导致把A用户的记忆串到B用户身上。
成熟的Memory Provider在召回时都会做混合策略:第一路是语义向量召回,通过embedding算相似度;第二路是关键词/实体验配,把用户问题里的实体名、商品名、订单号直接到索引里匹配;第三路是时间衰减,通常会给记忆一个时间权重,越近的记忆分数越高。最后用线性加权把这些分数融合起来。比如我常用的打分公式大致是:final_score = 0.5 * semantic_score + 0.3 * entity_match_score + 0.2 * recency_score。这个权重需要根据场景调,客服场景里实体匹配权重可以调高,而闲聊场景里语义相似度更重要。
3.4 结构化提取与自然语言摘要并存
为什么几乎每个方案最后都选择既存结构化数据,又存自然语言摘要?因为两种形式各有不可替代性。结构化数据适合精确查询和更新,比如“用户的收货城市变了”这种操作,在实体表里改一个字段就行;自然语言摘要适合模糊回忆和全局概括,比如“用户对我们的售后流程感到不满”,这种信息没法彻底拆成字段。
以Mem0为代表的实体型方案会把核心事实抽成结构化实体,但也保留原始对话片段作为佐证;以Hindsight为代表的摘要型方案主要用自然语言摘要,但会把摘要里的关键实体单独抽出建立索引。我自己在实现时会设计两种存储:一张实体表用于精确查询,一个向量库用于摘要召回。写入时先抽取实体,再生成一段摘要,两者都存,读取时再把两部分合并到一起作为记忆上下文。这也就是为什么这四个方案在外观上差异很大,内部却越来越像的原因。
4. 实战:从零手写一个可用的Memory Provider
4.1 抽象接口设计:不要一上来就选型
很多人一上来就急着选Mem0还是Hindsight,我建议先自己抽象一套接口,把选型推迟到后面。因为无论是哪个现成方案,底层都逃不过写入、召回、删除、更新这四类操作。我先定义一个MemoryProvider的基类,只暴露最小必要接口。
from abc import ABC, abstractmethod from typing import Optional, List, Dict class MemoryProvider(ABC): @abstractmethod def add_memory(self, content: str, metadata: Optional[Dict] = None, memory_type: str = "episodic") -> str: """写入一条记忆,返回记忆ID""" pass @abstractmethod def search_memory(self, query: str, top_k: int = 5, filters: Optional[Dict] = None) -> List[Dict]: """根据query召回相关记忆""" pass @abstractmethod def update_memory(self, memory_id: str, new_content: str, metadata: Optional[Dict] = None) -> None: """更新一条记忆的内容""" pass @abstractmethod def delete_memory(self, memory_id: str) -> None: """删除一条记忆""" pass为什么要这样设计接口?因为在Agent主循环里,你只关心“把这段对话记下来”和“根据当前问题取出记忆”,并不关心底层是向量库、SQL数据库还是简单的JSON文件。这样设计之后,你可以从最简单的文件存储版本起步,等数据量大了再平滑替换成Mem0或自己用向量库实现。
4.2 写入链路:提取+过滤+入库
写入链路是整个Memory Provider里最容易失控的部分,如果每轮对话都调大模型提取记忆,成本会瞬间失控。所以我总结了一个低成本高覆盖的写入策略:先判断这段对话里是否有值得长期记忆的信息,再决定是否触发提取。
def extract_memories(conversation: List[Dict]) -> List[Dict]: # 用LLM判断哪些信息值得记忆:用户偏好、事实、任务、重要事件 # 这里省略具体prompt,正常会返回 [{content, memory_type, importance}, ...] prompt = f""" 你是一个记忆提取器。下面是一段对话,请提取值得长期记忆的信息。 只提取可验证的事实和明确的用户偏好,不要提取寒暄和情绪化内容。 每条记忆给出内容、类型(episodic/semantic)和重要度(1-10)。 对话内容: {conversation} """ raw = llm_call(prompt) memories = parse_llm_result(raw) return [m for m in memories if m["importance"] >= 6]过滤规则也很重要。我加的规则包括:不存超过500字的原始内容,不存纯语气词输出,不存任何临时状态如“用户正在输入中”。写入时把同一条记忆的向量embedding生成好,同时存原始文本和metadata,metadata里必须有session_id和timestamp,方便未来做隔离和衰减。异步化处理就是把整个提取过程丢进一个任务队列,Agent主循环只负责把对话原文暂存到临时缓冲区,等对话间隔期再统一提取。
4.3 读取链路:查询改写+混合召回+排序
读取链路的质量直接影响Agent的回答质量。这里我会先做一个小小的查询改写:把用户的原始问题转成一组检索词,包括问题本身、命中的实体、用户ID、当前会话状态。比如用户说“我之前那个订单怎么样了”,检索词就应该是“订单状态+用户ID+最近订单”,而不是整句话原样去向量库碰运气。
def search_memory(self, query: str, top_k: int = 5, filters: Optional[Dict] = None) -> List[Dict]: # 1. 查询改写,生成多个候选检索键 retrieval_queries = [query] if filters and "user_id" in filters: # 会把查询和用户id拼成一条带过滤条件的查询 retrieval_queries.append(f"{query} user_id:{filters['user_id']}") all_hits = [] for q in retrieval_queries: hits = vector_store.search(q, top_k=top_k * 2, where=filters) all_hits.extend(hits) # 2. 合并去重,按 score 排序 all_hits = dedup_and_sort(all_hits) return all_hits[:top_k]召回之后,我通常还会再让一个快速分类器过滤掉与当前任务无关的记忆,比如用户问售后时,不要返回和闲聊娱乐相关的内容。这一步可做可不做,但如果你的Agent经常串味,加这个过滤非常有效。排序完成后,把记忆按固定模板拼接到Prompt里,格式一般是“相关信息:\n- 记忆内容\n- 时间:xxx\n- 来源:xxx”。
4.4 集成进Agent主循环
把Memory Provider接到Agent里其实只需要改三个位置:对话开始前读取,对话结束后写入,每轮处理时拼接记忆上下文。下面是一段最小可运行示例。
from custom_memory import SimpleMemoryProvider memory = SimpleMemoryProvider() def agent_run(user_message: str, history: List[Dict]) -> str: # 1. 读取长期记忆,保留最近3轮工作记忆 relevant_memories = memory.search_memory(user_message, top_k=5) recent_window = history[-3:] memory_text = "\\n".join( f"- {mem['content']} (时间:{mem['timestamp']})" for mem in relevant_memories ) # 2. 组装Prompt prompt = f""" 你是智能客服。这是与用户相关的长期记忆: {memory_text} 这是最近几轮对话: {recent_window} 用户当前问题:{user_message} """ response = call_llm(prompt) # 3. 对话暂存到缓冲区,由异步写入器定期提取记忆 memory.buffer_message("user", user_message) memory.buffer_message("assistant", response) return response这段代码最关键的地方在于“最近3轮工作记忆+长期记忆混合”。我试过只加长期记忆不加最近对话,模型经常答非所问,因为有些信息依赖前文的指代;我也试过只保留最近对话不加长期记忆,用户两周前表达过的偏好全丢。混合之后效果最稳。
4.5 小规模评测:没有记忆和有记忆的差距
我在一个模拟客服数据集上简单测过效果:连续对话十轮,第一轮用户说“我可能会申请商品退款,请用邮件通知我”,后续轮次里用户突然说“退款的事请联系我邮箱”。没有记忆的Agent完全不知道“联系我邮箱”的邮箱地址是什么,只能套话让用户再提供一次;而加了Memory Provider的Agent能自动提取“退款需邮件通知”这个偏好语义,在后面对话中直接调用邮件发送逻辑。最终没有记忆的版本任务完成率只有35%,有记忆的版本完成率提升到了82%。
这种差距不是模型能力造成的,而是信息是否被“跨轮传递”造成的。要做到跨轮传递,Memory Provider的写入链路必须从原始对话中抽取决定性信息,而不是机械地存整个对话。这也是我建议使用类似Hindsight事后提取设计的原因。
5. 踩坑实录与调优建议
5.1 写入风暴:每次对话都调LLM提取记忆,成本炸了
我第一版实现图省事,直接在每轮对话结束后调用一次LLM做记忆提取,结果一天下来Token消耗比主模型还多。后来我改成两个策略:一是只有检测到“新实体”“用户明确偏好”“任务状态变更”时才触发提取,其余时间只把消息暂存到缓冲区;二是把提取任务丢进异步队列,用便宜的小模型或专用模型来做,而不是每次都让对话用的旗舰模型干这活。这两步直接把记忆相关成本砍掉了70%。
5.2 召回串味:检索到无关记忆导致回答前后矛盾
这个问题非常高发。用户问A,但你召回了一条跟A沾边但属于另一个主题的记忆,模型就会被带偏。排查时我建议先打印召回结果,看看分数排名前几的记忆到底是什么。常见原因有三个:向量相似度阈值设得太低,例如0.7以下就放行;没有在检索时带用户ID过滤器,导致跨用户召回;没有对记忆做“主题标签”分类,召回时无法精确过滤。我的解决方案是:提高阈值到0.78,所有检索都强制带session_id或user_id限制,并给每条记忆加一个topic字段,检索时用实体匹配先缩小候选集。
5.3 遗忘策略:记忆会过期,不能只增不减
很多人在做记忆系统时只盯着写入,忘了设计遗忘。我问你一个问题:如果用户三年前的地址还存在记忆库里,每次对话都把它捞出来,是不是一种污染?所以我在Memory Provider里加了两个机制:一是TTL,普通情景记忆默认30天过期,语义记忆除非被用户主动确认,否则1年过期;二是重要度衰减,每次被召回的频率会提升记忆重要度,长期不被召回的自动降权。这基本就是模拟人脑遗忘曲线,效果还不错。
5.4 集成前的自查清单
给别人做完方案评审之后,我习惯给一份自查清单。这里写几个最关键的检查点:是否支持按用户隔离记忆?是否所有记忆都有时间戳?写入链路是否异步?是否有去重机制?是否支持用户手动删除记忆?召回时是否能解释“为什么召回这条”?最后一条尤其重要,如果召回结果无法解释,那么出问题时你根本没法调试。哪怕现成方案没有完整的可解释性,也要自己在日志里记录每条记忆的score和来源。
最后分享一个我自己的小经验
这几个方案我最终只在生产环境里用了两个:轻量场景直接用自己写的文件版MemoryProvider,复杂场景选了一个带管理API的方案作为核心存储。踩过几次坑之后,我越来越觉得Memory Provider不是越复杂越好,而是先想清楚你的Agent会在什么场景下需要哪类记忆,再决定是上一套完整平台还是自己封装一个简单模块。如果只是做Demo,文件系统加简单向量检索完全够用,生产环境再上带管理语义的组件也来得及。最好的一点是,只要把读写接口抽象好,后面换后端是完全不慌的。希望这篇文章能把你的记忆设计之旅拉直一点,少走我走过的弯路。