做 Agent 开发的人,迟早都会撞上同一堵墙:模型聊完上一个话题,转头就把你刚强调过的关键约束忘得一干二净。这不是模型智商不够,而是 Agent 默认只有一段“临时记忆”——上下文窗口。窗口一切断,所有信息跟着烟消云散。今天我想拆解的是工业级 Agent 背后的记忆机制,以及怎么参考大厂那套思路,从零搭一套属于自己的记忆系统,让 Agent 真正“越用越懂你”。
这篇文章适合正在做 Agent 应用开发的工程师、想在项目里引入长期记忆能力的技术负责人,也适合刚从 Prompt 工程往 Agent 框架方向进阶的同学。我会先讲清楚记忆机制的本质,再给你一套可以直接落地的代码骨架,最后把我踩过的坑一并列出来。内容偏实战,不堆概念。
1. 拆解工业级记忆机制:为什么“能记住”不等于“有记忆”
1.1 上下文窗口只是便利贴,不是记忆
很多刚入门的朋友会把“多轮对话”当成记忆,这是最典型的误区。多轮对话里模型之所以能引用前面说过的话,靠的是把全部历史文本一股脑塞进上下文窗口。这个过程既不区分信息重要程度,也没有任何结构性整理。窗口长度 128K、200K,听起来很大,但对话一长,照样被截断,截断之后最早的信息就没了。
我举一个真实场景:一个客服 Agent 在跟用户确认订单信息时,用户在前 10 轮说过“我下周出差,收货地址要改成公司”,后面 20 轮都在聊退换货流程。如果只是简单拼接历史,等聊到第 30 轮时,窗口可能已经因为塞满了退换货细节而把地址变更这条关键信息挤掉了。模型不是不想记,是它压根“看不见”这条信息。
所以工业级 Agent 不会拿上下文窗口当记忆用。它的做法是把记忆拆成不同层次,具备写入、读取、更新、淘汰的完整生命周期。这才是“记忆系统”和“聊天缓存”的本质区别。
1.2 四层记忆模型:大厂 Agent 记忆机制的通用骨架
我拆过不少头部云厂商和出海的 Agent 产品,它们命名各有不同,但底层思路高度一致,基本都参照认知科学里的记忆分层模型来设计。这套模型分四层:
工作记忆对应当前会话的上下文窗口,负责处理“此刻正在发生的事”。它容量最小、读写最快,通常是原始对话 + 系统提示词 + 工具返回结果的拼接。优化点在于如何省 token、如何排列关键信息优先级,而不是指望它记住跨天的事情。
情景记忆负责记录“过去发生过的事”。典型形式是会话摘要、事件记录、用户关键动作。这一类记忆必须做抽取和压缩,不能把原始对话直接倒进去。比如“用户 5 月 10 日要求把发票抬头改成某公司”,就是一条情景记忆。
语义记忆负责存稳定的、不随时间变化的事实。例如用户偏好、业务规则、领域知识。这类记忆通常以结构化方式(JSON、KV、表结构)保存,因为要支撑精确查询和覆盖更新,单靠向量模糊匹配不够可靠。
程序记忆负责“ Agent 知道怎么做事”,比如工具调用规范、任务处理流程、复杂任务的标准操作模板。在大模型系统里,程序记忆通常体现为 Prompt 模板、工具描述(Tool Schema)和编排逻辑,不一定需要向量库,但需要在运行时按需加载。
大厂 Agent 的差异主要体现在每一层的“抽取策略”和“存储选型”上。有的偏重向量召回,有的偏重结构化状态机,但四层记忆的协作逻辑是一致的:工作记忆不够用时去情景记忆里找回放,调用工具时从程序记忆里翻操作手册,做个性化回复时查语义记忆里的用户画像。
1.3 记忆系统本质上是一套 RAG 的工程变体
说句实话,记忆系统没有玄学,它就是检索增强生成(RAG)在“个性化历史数据”上的特化应用。普通 RAG 检索的是文档库、知识库,记忆系统检索的是用户历史、会话事件、偏好画像。
这么理解有个实际好处:RAG 领域踩过的坑,在记忆系统里一样会遇到。比如 Embedding 模型怎么选、向量库怎么配、检索结果怎么重排,这些问题都有成熟解法可以直接迁移。区别在于记忆系统的写入链路更复杂,因为你不是把现成的文档切块入库,而是要从非结构化的对话里抽取结构化信息再入库。这个“从对话到记忆”的加工过程,决定了整个系统的上限。
2. 落地前先备料:向量库、Embedding 与检索链路选型
2.1 向量数据库选型:别一上来就上重型组件
搭建记忆系统要先解决存储载体。工业实践里最常用的是向量数据库,但很多项目一上来就部署一个重型的分布式向量集群,业务还没跑起来,运维负担先压垮了团队。
我的建议是分阶段选型。早期原型阶段,用轻量级向量检索组件就够了。比如基于某开源库封装的内存索引、或者直接用支持向量索引的轻量数据库,单机跑,功能验证通过后再迁移。等数据量到了百万级向量、QPS 有硬指标时,再引入分布式向量库,或者直接用云托管的向量服务。
选型时重点看四个参数:支持的向量维度上限、索引类型(HNSW / IVF 等)、过滤能力(是否支持按元数据标签过滤)、以及是否支持增量更新。记忆系统是高频写入场景,用户每轮对话都可能产生新记忆,如果向量库只支持全量重建索引,那基本没法用。
2.2 Embedding 模型选择:影响记忆召回质量的第一个分水岭
记忆要能被召回,前提是语义相近的内容在向量空间里靠得近。Embedding 模型就是干这个的。我用过通用 Embedding,也换过专门针对对话场景优化的模型,差距非常明显。
选型时有三个考量维度:
维度与成本。高维度向量表达能力强,但存储和计算成本更高。常规场景 768 维到 1024 维已经够用,不必盲目追求 3072 维的大模型。
中文效果。很多通用模型在英文上表现很好,切到中文对话场景效果明显下降。如果你的用户是中文为主,务必用中文语料评测后再定,不要只看榜单分数。
输入上限。记忆写入时经常要 Embedding 一整段会话摘要,如果模型的输入上限只有 512 token,长摘要会被截断,语义信息丢失。建议选输入上限不低于 1024 token 的模型。
我现在的习惯是保留两个 Embedding 模型做对比,每隔一段时间拿一批真实用户对话跑一轮召回评测,效果差的直接替换。记忆系统是长期演进的,Embedding 模型也要持续迭代。
2.3 检索链路不是“查一下”那么简单
很多第一次搭记忆系统的人会以为,用户提问后把 query 转成向量,去向量库查 TopK 就完事了。实际工业级检索链路是:查询改写 → 混合检索 → 重排 → 组装。
查询改写很关键。用户在日常对话里不会用精确的关键词提问,比如他可能问“我之前说的那个发票的事”,这里的“发票的事”指代不明。如果不做改写,直接拿原句去检索,召回结果大概率乱七八糟。改写要做两件事:补全指代(把“那个”还原为具体对象)、拆出核心实体(用于结构化过滤)。
混合检索是另一个被低估的环节。纯向量检索擅长语义相近但表达不同的匹配,但在精确匹配场景(订单号、用户名、日期)上经常失手。所以工业实现普遍是向量 + 关键词(BM25)双路召回,再合并结果。这样既吃到了语义相似度红利,又保住了精确命中能力。
最后是重排。向量库返回 TopK 的粗排结果后,用一个轻量级 Rerank 模型重新打分,把真正相关的记忆顶到最前面。重排层的计算量比向量检索小一个量级,但效果提升非常明显,尤其在 Top5 以内精确率要求高的场景。
2.4 两个必须搞懂的参数:相似度阈值与 Top-K
在调召回参数时,最常用的是top_k和score_threshold。这俩参数是联动关系,单独调任何一个都会出问题。
top_k控制召回数量,调太大会把一堆弱相关记忆塞进上下文,干扰模型判断;调太小则可能漏掉关键记忆。score_threshold控制相似度底线,低于这个分数的结果直接丢弃。
我的经验是先固定top_k在 10 左右,然后根据评测集的召回精确率来调节阈值。一个常见错误是把阈值从 0.7 一路降到 0.5,结果召回了大量无关记忆。这本质上是把检索失败的责任推给了下游 LLM——模型本来就不是用来做相关性判断的,你硬塞一堆弱相关材料进去,它只会表现得像一个“什么都能扯”的话痨。
注意:不同向量库的相似度分数范围不一样,有的返回余弦相似度(-1 到 1),有的返回内积分数(无上限)。切换向量库之后,原来的阈值要重新标定,不能直接沿用。
3. 从零搭建记忆系统:分层存储设计与核心代码实现
3.1 整体架构:两层存储 + 一条管线
现在开始动手搭系统。我的设计分成两层存储和一条处理管线。两层存储分别是:短期的工作记忆(进程内缓存)和长期的持久记忆(向量库 + 结构化存储)。处理管线负责在对话过程中完成记忆的写入和召回。
整体流程是这样的:
- 用户每发一轮消息,先写入工作记忆缓冲区。
- 当缓冲区累积到一定轮数或 token 数,触发压缩:把原始对话提炼成摘要,抽取关键实体和用户偏好。
- 抽取结果经过去重合并后写入长期存储。
- 每次模型生成回复前,召回与当前 query 相关的历史记忆,组装进提示词。
这样设计的好处是职责清晰。工作记忆保证“即时连贯性”,长期记忆保证“跨会话稳定性”,管线负责把前者转化为后者。
3.2 记忆写入流程:从对话到结构化记忆的加工链
写入是记忆系统里最容易翻车的环节。直接存原始对话会爆炸,直接存 LLM 生成的摘要又容易丢失细节。我的做法是走四步加工链。
第一步,触发分段。不以“每轮对话”为单位写入,而是以“一个完整事件”为单位。比如客服场景里,用户从反馈问题到确认解决,这是一个完整事件,可能包含 5 轮对话。事件切分可以简单按轮数(如每 5 轮一段),也可以用主题聚类来做,前期用轮数切分足够。
第二步,事件摘要。把每一段对话交给 LLM,生成两层摘要:一层是 100 字内的核心事实摘要,另一层是结构化的字段抽取,包括涉及的实体、用户意图、关键时间、待办事项等。
第三步,去重与合并。写入前先在向量库里搜一下,看有没有语义相似的历史记忆。相似度超过 0.9 的直接丢弃新摘要,超过 0.8 的做合并更新,否则才新增。这一步能有效抑制记忆膨胀。
第四步,向量化入库。自定义一个记忆对象,包含 ID、内容、类型(情景/语义)、时间戳、来源会话 ID、embedding 向量。这个对象设计决定了后续召回时的过滤能力,type字段尤其重要。
下面是一个简化的写入逻辑示例,便于理解核心设计:
import uuid from datetime import datetime class Memory: def __init__(self, content, mem_type, session_id, metadata=None): self.id = str(uuid.uuid4()) self.content = content self.mem_type = mem_type # "episodic" / "semantic" self.session_id = session_id self.metadata = metadata or {} self.timestamp = datetime.utcnow().isoformat() self.embedding = None def build_event_memory(conversation_segments: list[str], user_profile: dict): """ 输入: 分段后的对话文本列表 输出: 待写入的 Memory 对象列表 """ memories = [] for seg in conversation_segments: # 1. LLM 抽取: 这里简化, 实际请用结构化输出解析 summary = llm_summarize(seg) # 返回摘要文本 extracted = llm_extract_entities(seg) # 返回 {entities, intent, todo} # 2. 构造记忆对象 mem = Memory( content=summary, mem_type="episodic", session_id=user_profile["session_id"], metadata=extracted ) # 3. 向量化 mem.embedding = embed_text(summary) memories.append(mem) return memories这里我把 LLM 抽取逻辑留成了函数占位,实际项目里建议用结构化输出(比如 JSON Mode)来保证extracted字段的格式稳定。抽取这一步不要省,因为原生的对话摘要信息密度太低,不加结构化字段的话,后续想按“用户 ID + 意图类型”过滤都无从下手。
3.3 记忆召回流程:不是所有对话都要翻旧账
召回有一个容易被忽略的原则:不是每一轮都需要长记忆。闲聊里的“今天天气不错”不需要历史记忆,但用户说“还是按上次说的那个方案改”就必须精确召回。所以召回流程第一步是判断触发条件。
我总结了几类必须触发记忆召回的场景:用户明确提到“之前/上次/刚才说”这类指代、当前任务需要用户偏好(比如推荐、定制)、当前输入包含业务关键实体(订单号、项目名)、或者工作记忆缓冲区已经快耗尽需要加载上下文。其余场景只走工作记忆,不查向量库,既省延迟又避免引入噪声。
触发后,执行三条召回路径:
- 按实体精确过滤:把 query 里的订单号、日期等结构化字段抽出来,在向量库里按 metadata 过滤。
- 向量语义召回:把 query 向量化,按相似度检索 TopK。
- 用户画像召回:如果用户有固定的画像 Profile(属于语义记忆),按固定阈值直接加载。
最后将三路结果统一做重排和去重,按相关度从高到低排序。
召回后的组装顺序也有讲究。我把工作记忆(当前对话)放在最前面,长期记忆块放在其后,并使用明确的提示词分隔符。不要让长期记忆淹没当前上下文,否则模型会分不清“现在”和“过去”。
# 记忆召回 + 组装示例 (伪代码) def recall_memories(query: str, working_memory: list[str], user_id: str, top_k=8): # 判断是否需要历史记忆 if not need_recall(query): return "\n".join(working_memory) # 1. 实体过滤 + 向量召回 scalar_filters = extract_scalar_filters(query) # {"intent": "refund", "session_id": "xxx"} candidate = vector_store.search( embedding=embed_text(query), top_k=top_k * 2, # 粗召回放大, 给重排留余量 filters=scalar_filters ) # 2. 重排: 用轻量Rerank模型或规则加权 ranked = rerank(candidate, query) # 3. 截断: 取前 top_k 条 memory_blocks = [m.content for m in ranked[:top_k] if m.score > 0.6] # 4. 组装 system_block = "以下是该用户的历史记忆, 仅作为辅助参考:\\n" for i, block in enumerate(memory_blocks): system_block += f"[记忆{i+1}] {block}\\n" return system_block + "\\n[当前对话]\\n" + "\\n".join(working_memory)代码里的need_recall函数值得单独写一个 Prompt 来判断,而不是写一堆 if 规则。规则只能覆盖你能想到的场景,LLM 判断能覆盖长尾表达。延迟方面,多一次 LLM 调用大概增加 200-500ms,记忆系统本身就不适合对实时性要求极高的场景,这个开销可以接受。
3.4 持久化与清理策略:让记忆系统“越用越准”而不是“越用越臃肿”
记忆系统跑久了,最大的问题就是存储里堆满了“过去的垃圾”。如果不做清理,召回质量必然下降,因为噪声盖过了信号。
我常用三种清理策略组合:
时效衰减。给每条记忆一个初始权重,随时间衰减。比如 7 天内的记忆权重为 1.0,30 天以上降为 0.4。召回排序时,最终得分 = 相似度 × 时间权重。这个策略很朴素,但特别适合处理“用户上个月的需求现在已经不适用”的情况。
容量上限。给每个用户的记忆条目数设上限,比如 500 条。超过上限后触发淘汰,先淘汰低重要度 + 长时间未访问的记忆。重要度可以在写入时由 LLM 打分,1 到 10 分,分数低的优先清掉。
矛盾折叠。如果检测到新旧记忆冲突(比如用户改过收货地址),保留新记忆,但旧记忆加一个superseded_by标记。不要直接物理删除,因为你在排查问题时还需要回溯。
清理逻辑通常是每日离线任务批量执行的,不要在线程上实时删。在线流程只做标记,真正的物理清理交给定时任务。
4. 记忆系统实战排雷:召回不准、记忆膨胀与上下文漂移
4.1 召回质量差:从“改阈值”到“查上游”的三步排查法
遇到召回结果乱七八糟,很多人的第一反应是调向量库的相似度阈值。我建议先别动阈值,按三层顺序排查。
第一层:看 query 改写是否合理。用户问“上次说的那个优惠还有吗”,如果改写模块没有补全指代,直接把原句发去检索,召回差是必然的。排查方法是把改写后的文本打出来看一眼,大概率一眼就发现问题。
第二层:看写入内容是否合理。如果向量库里存的摘要本身逻辑混乱、事实缺失,召回再好也白搭。检查写入阶段的 LLM 抽取 Prompt,看是不是摘要质量不稳定。我可以负责任地说,大部分召回问题根源在写入,不在检索。
第三层:才轮到调整检索参数。在确认前两层没问题后,再调混合检索的权重(向量 vs 关键词)、top_k和阈值。每一步只动一个参数,并且要有评测集,不要凭感觉调。
4.2 记忆膨胀:症状、根源与抑制手段
一个用户跑了三个月,记忆系统里堆积了 2000 条摘要,这个系统基本废了一半。召回时 TopK 里充斥着过期信息,模型每次都被带偏。
膨胀的根源有两个:写入触发太频繁,和去重机制失效。解决思路:一是用事件切分代替轮数切分,减少无效写入;二是在写入前做语义去重。如果同一个用户连续三次说“帮我查一下物流”,两次摘要几乎一样,第二次和第三次就应该合并而不是新增。
实际操作里,我还会写一个“记忆瘦身”的离线任务,每周把某个用户的记忆做一次聚类压缩,把多个相似记忆融合成一条更精炼的记忆。这相当于给记忆系统做“断舍离”。
4.3 上下文组装不当引发的模型行为漂移
记忆系统上线后最诡异的问题是:模型行为变得奇怪。比如客服 Agent 上一秒还在好好回答问题,下一秒突然用一种“老熟人”的口吻说话,或者过度引用某条记忆导致答非所问。
问题不在检索,在于组装方式没有跟模型讲清楚“怎么使用记忆”。单纯把记忆文本塞进上下文是不够的,模型不知道这些内容是“过去的记录”还是“当前必须要遵守的指令”,于是它有时会把旧记忆当成了新的用户指令来执行。
解决方案是在记忆块前后加边界指令,明确告诉模型这些内容是参考资料,不是当前事实,也不是指令。我在实践中的一个效果好但又容易被忽略的做法是:在 System Prompt 里写清楚“历史记忆可能存在过时或冲突,以当前对话和用户明确表达为准”,并且给每条记忆标注时间。模型看到时间戳后,对“你现在想做什么”和“用户过去做过什么”的区分能力会明显提升。
4.4 多会话一致性:用户改了信息,旧记忆还在打架
用户这周说“我不再使用电子发票了”,下周又问 Agent“把之前的电子发票记录导出来”。如果记忆系统只按相似度召回,可能同时召回“用户要求使用电子发票”和“用户不再使用电子发票”两条矛盾记忆,模型就懵了。
解决这个问题的思路是引入“状态覆盖”机制。对具备明确业务状态的记忆(如偏好设置、地址、发票类型),不要用向量库做召回,而是用结构化存储(一张状态表),写入时做 upsert(存在即覆盖)。召回时直接读当前状态,而不是从历史碎片里模糊推断。
我在自己搭的系统里把记忆分为两个池子:事实状态池和事件历史池。前者是覆盖式更新,后者是追加式记录。这样既保留历史可追溯性,又让当前状态清晰一致。这个分层是整套记忆系统里最值得花时间设计的部分。
最后分享一点我自己的体会
记忆系统这个东西,看着是技术问题,做久了你会发现它更像一个数据工程问题。最关键的从来不是向量库有多快、模型有多大,而是你有没有想清楚哪些信息值得记、记了怎么用、过期了怎么清。我见过太多项目在 Embedding 模型和向量库上砸了大量资源,最后却栽在抽取 Prompt 写得太随意上。
如果只能给你一个建议,我会说:先把“记忆对象”的数据结构设计好,再碰任何框架和组件。字段怎么定、类型怎么分、怎么标记冲突和过期,这些问题想透了,后面所有环节都会顺很多。反过来的话,你会陷入无穷无尽的召回调试里,而且大概率怎么调都别扭。