☰
Agent记忆系统设计:从短期窗口到长期知识库的落地实践
2026/10/21 3:55:11 网站建设 项目流程

Agent一热,“记忆”就成了绕不开的话题。原因很简单:没有记忆的Agent,本质上就是一只脑容量只有几秒钟的金鱼——上一轮跟用户聊了什么它不记得,任务做到一半上下文被人为截断它不记得,用户明确说过“我不喜欢吃辣的”它也不记得。市面上以“记忆”为卖点的Agent框架越来越多,但真正把短期记忆和长期记忆分清、设计得当的方案并不多。这篇内容想做的,就是把Agent的长短期记忆从概念到落地彻底拆开聊透:它们各自解决什么问题、底层怎么设计、怎么从对话里自动沉淀长期记忆、检索时怎么把最有用的一段先捞出来,以及大厂系统设计面试围绕这套机制反复追问的几个点。适合正在做Agent应用开发的工程师、准备AI方向面试的同学,以及单纯想搞明白“Agent记忆到底应该怎么做”的人。

我去年完整做过一套带记忆的Agent,从对话记录、摘要压缩、向量检索到记忆合并都趟过一遍,踩了不少坑。下面这些内容不是我抄来的概念,是真实项目里验证过的方案和教训。

1. 为什么Agent必须有“两套”记忆,而不是一套超大的上下文

先把概念对齐。所谓Agent记忆,不是指大模型的参数量,也不是指训练语料里的知识,而是指Agent在运行过程中“自己产生的、与当前任务或用户相关的信息”——比如用户上一句话、三小时前订的餐厅、昨天否掉的设计方案。没有这套信息,Agent就是一个无状态函数,每次调用都是失忆的起点。

但现实是,大模型的上下文窗口再大也是有限的。今天有些模型支持几十万甚至上百万token,听起来可以塞下很多历史,但有两个致命问题:第一,成本不是线性的,长上下文在推理时的时间和费用都涨得很快;第二,信息过载。你把过去十天的全部聊天记录都塞进去,模型被海量无关细节干扰,反而容易忽略当下最需要关注的信息,指令遵循能力也会下降。经验上,上下文越长,模型在处理无关信息时的“注意力稀释”越明显。

所以正确的做法是把记忆拆成两层,对应人的两种认知机制:

  • 短期记忆:对应工作记忆,服务于“当前这次会话的连续性”。它的特点是高频读写、内容新鲜、容量有限,通常承载最近几轮到几十轮的对话上下文、中间推理结果、尚未完成的任务状态。
  • 长期记忆:对应情景记忆和语义记忆,服务于“跨会话的稳定性”。它的特点是低频写入、按需读取、容量几乎无限,存储用户的画像偏好、已确认的事实、关键事件结论、历史任务的最终成果。

为什么不能只用一层?做一个“无限上下文”的聊天机器人不难,但要做一个“越用越懂你”的Agent,长期记忆才是灵魂。短期记忆保证同一场对话不前言不搭后语,长期记忆保证下一次会话不是从零开始。两者配合,才是完整的记忆系统。

另外一个常被忽略的点:长期记忆不是简单地把短期记忆存一份,而是经过抽象、提炼、合并后的结构化信息。直接把原始聊天记录一股脑存进向量库,那不叫长期记忆,那叫日志备份。后面我会详细讲怎么从原始对话里“提炼”长期记忆。

2. 短期记忆的核心机制与设计要点

2.1 短期记忆的本质:上下文窗口管理

短期记忆的载体,说白了就是送进大模型的那段对话历史。你可以把它想成一张工作台:所有正在处理的信息都得放在上面,但台面大小是固定的,你必须决定放哪些、扔掉哪些、哪些压缩后再放。

最常见的短期记忆实现是滑动窗口:只保留最近N轮对话。这里有个很容易踩的坑:N取多大?轮数本身不是好度量,因为轮与轮之间的token数天差地别——用户可能发一个“好”字,也可能发2000字的项目文档。所以更稳妥的做法是按token预算来控制。

我项目里实际用的短期记忆结构是这样设计的:

@dataclass class ShortTermMemory: system_prompt: str # 系统指令,固定占用 recent_chat: list[dict] # 最近对话,按时间顺序 working_summary: str # 当前会话的滚动摘要 task_state: dict # 当前任务的中间状态 # token预算分配 max_total_tokens: int = 8000 system_tokens: int = 1500 summary_tokens: int = 1000 chat_tokens: int = 5000

每次新增一轮对话后,按顺序执行:

  1. 把新消息append进recent_chat;
  2. 统计recent_chat总token数;
  3. 如果超过chat_tokens预算,就把最早的消息摘出去,塞给摘要模块做压缩;
  4. 组装最终prompt时,先放system、再放浓缩后的working_summary、最后放recent_chat。

这个结构的核心思想是:历史被切割成“可完整保留的部分”和“被摘要压缩的部分”,摘要本身就是一种短期记忆退化后的形态。

2.2 摘要压缩的时机与写法

摘要模块目前基本依赖LLM来生成。但要注意:不要让摘要模块每次把对话重新看一遍,然后从头写摘要。正确做法是“增量摘要”——拿着旧的摘要和新增的对话片段,生成新的摘要。

举个例子,旧摘要写的是:“用户正在开发一个电商App,已确定使用某个支付网关,对页面加载速度有强烈要求。”新增对话是用户说:“我不想用那个支付网关了,换成另一家的。”那么增量摘要应该产出:“用户在开发电商App,支付网关已从原方案更换为新方案,对加载速度要求高。”而不是再来一版只描述新增内容的摘要。

增量压缩的一次实现:

def incremental_summarize(old_summary, new_messages): content = f""" 已有摘要: {old_summary} 新增对话: {format_messages(new_messages)} 请结合已有摘要,生成更新后的完整摘要。 要求:保留已有关键信息,合并新增信息,只输出摘要本身。 """ response = llm_call(content) return response

这里要注意几个细节:

  • 摘要不能太碎。多次增量压缩后,最早的关键信息可能被“稀释”掉。解决办法是设定一个极值:摘要超过某个长度时,做一次彻底重写,把全部对话记录翻出来合并成更紧凑的版本。
  • 用token估算做截断比用轮数做截断靠谱得多。中文场景下,我一般按“1个汉字≈1.5 token”粗略估算,但开发时用真实tokenizer验证才稳妥。
  • 每一轮都做摘要太慢,没必要。我会设定阈值:只有即将挤掉旧消息时才触发增量摘要,或者每累计新增N轮再触发。反过来,如果对话一直不超预算,就完全不动摘要,减少无用计算。

2.3 系统提示词里的“记忆位”

上面已经把短期记忆拆成“系统指令+当前会话摘要+原始最近对话”。实践中还有一个很有用的位子叫即时记忆(episodic buffer)——放那些“本回合刚发生但特别重要”的信息。比如用户刚给过一个明确指令:“以后所有文案都不要太严肃。”这是偏好类信息,可能直接送进长期记忆,但在还没送去之前,不能在下一次回复时丢失。

我的做法是在系统提示词里留一个字段叫cross_turn_notes:

system_prompt_blocks = { "role": "system", "content": f""" ...(常规指令) 【记忆要点】 - 用户偏好:{important_notes_from_user} - 当前任务约束:{active_task_rules} - 最近修正:{latest_revisions} """ }

这个字段同时被短期记忆管理器维护,并在每次对话更新时检查:如果发现用户的否定、纠偏、明确偏好表达,立刻提取出来放到这个位置。这样即使本轮对话后没有触发长期记忆写入,下一轮回复也天然带着这些约束。

2.4 一个常见争议:短期记忆要不要单独存

有人会问:既然每次发请求都要把对话历史发给模型,那短期记忆直接存在内存里不就行了,为什么还要持久化?

答案是:Agent的生命周期可能比一次HTTP请求长得多。可能在中间要执行多步工具调用,每一步都重新调用模型;也可能Agent在处理过程中崩溃、重启、被调度到另一台机器。如果短期记忆只在内存里,一旦进程重启,当前会话的连续性就断了。我在工程里至少会把它缓存到Redis一类的存储里,过期时间设为会话闲置30分钟或一天,兼顾恢复和成本。

3. 长期记忆的数据建模与存储选型

3.1 建模之前,先搞清楚要记什么类型的“长期信息”

长期记忆不能是一个大杂烩容器。我一般按信息类型分成四个主类别:

类型典型内容适合存储方案
用户偏好“爱吃辣”“喜欢简洁回复”“预算5000元以内”键值表:属性级别精确读写
结构化事实用户是会员、公司规模、项目截止日期关系表或KV,便于条件查询
事件与对话摘要上次聊了方案A并否决了、最近一次联系人是谁向量库,便于语义检索
实体间关系用户与某个项目的归属、两个项目之间的依赖图存储,便于关系遍历

这里最关键的设计判断是:不是所有内容都适合进同一套存储。你用一个向量库存“用户爱吃辣”这种明确偏好,检索可靠性很差——语义检索擅长“模糊找相似”,不擅长“精确查条件”。反过来,你把长篇大论的项目背景存到KV里,用字段查就很别扭。所以长期记忆的第一步不是选技术栈,而是先定义存什么、怎么读。

3.2 记忆条目的最小结构定义

不管底层用哪种存储,记忆条目建议都带上统一元数据。我常用的结构:

{ "memory_id": "mem_8f3a1", "content_type": "preference | fact | event | relation", "content": "用户希望所有推荐结果控制在三个以内", "importance": 0.8, "confidence": 0.9, "created_at": 1710000000, "last_access_at": 1710060000, "source_session": "session_42", "status": "active", "embedding": [0.012, ...], "related_entities": ["user_123", "project_789"] }

几个字段的设计原因:

  • content_type决定检索路径和更新策略;
  • importance用于记忆整理、丢弃时的优先级判断,也是路由到长期记忆位的权重;
  • confidence记录这条信息的可信度。用户随口一句“我可能下个月去上海”和用户明确订购了上海某酒店机票,置信度完全不同;
  • last_access_at是实现“用则强、不用则弱”衰减机制的基础。

3.3 长期记忆的写入路径:提炼而非备份

这是最容易被做错的地方。新手方案往往是:把每一段对话都embedding后塞进向量库。后果是存储很快膨胀,检索结果里全是低质量的聊天噪音。

我实际验证有效的写入流程分四步:

  1. 候选识别:每次对话结束(或达到一定轮数),把这一轮对话交给抽取模型,让它输出“值得长期保留的信息”。
  2. 类型分类与结构化:判断每条候选是偏好、事实、事件还是关系,然后转成对应的结构化字段。
  3. 去重与合并:查一下库里有没有同一主体+同一属性的旧条目。有的话,按新旧信息和置信度做合并,而不是新增。
  4. 重要性打分:用一条prompt让LLM打分,或者用规则打分(比如包含“从不”“绝对”“以后都”这类强偏好词,重要性加权重)。

一次抽取的prompt模板示例:

EXTRACT_PROMPT = """你是记忆抽取模块。 从下面的对话中,提取值得长期记住的信息,要求: 1. 忽略客套话、临时性内容、已经过时的旧信息 2. 输出JSON数组,每个元素包含:type(preference/fact/event/relation)、content、importance(0-1) 3. 如无值得保存的内容,输出[] 对话: {dialogue} """

这里有个技巧:不要只靠LLM抽取。对于特别明确的偏好词,比如“不要再”“我喜欢”“我不吃”,可以先用正则规则做初步召回,再交给LLM判断,能大幅减少漏抽。

3.4 长期记忆的读取路径:混合检索+重排

读取比写入更影响用户体验。用户问了一个问题,Agent要从长期记忆里挑出最相关的2到5条,放进prompt。这里我强烈建议用混合检索而不是纯向量检索。

混合检索是把三种能力合并:

  • 向量检索:按语义相似度召回,适合“开放式”记忆,比如上次项目讨论的片段。
  • 关键词/属性检索:精确匹配“用户偏好=辣度”,适合确定性记忆。
  • 关系检索:从当前实体出发,找它关联的历史实体和事件。

一个实际案例:用户说“帮我继续上次那个活动方案”,Agent先通过实体识别定位“活动方案”,用关系检索找到对应的project_id,再用project_id精确查事件记忆,同时用“活动方案”做向量召回。三条路合并后,去重、按权重排序,最终只把得分最高的三条注入上下文。

注入时还要做一步重排。这一步不只是简单按相关性排,还要结合时效性:用户3天前提到的偏好比30天前更值得放进上下文。我的排序分大致是:

score = 0.6 * 语义相关分 + 0.3 * 时效分 + 0.1 * 重要度分

时效分的计算可以简单点:越近越高分,30天以上指数递减。

4. 短期记忆到长期记忆的桥接:记忆巩固与遗忘机制

4.1 记忆巩固的触发时机

人类记忆不是聊天时实时存入长期记忆的,而是在休息、睡眠时巩固。Agent也一样,逐条实时写入既贵又容易写脏,我建议采用“事件触发”或“周期触发”。

三种在实践中好用的触发方式:

  • 对话结束触发:每次会话结束时,把整段对话做一次统一的记忆更新。这是性价比最高的时机,因为信息收口完整。
  • 会话内高价值事件触发:比如用户刚完成下单、取消订阅、明确给出一个强偏好,这时立即写长期记忆,防止后续会话状态丢失。
  • 定时批量触发:适合长会话不停跑的场景,比如每隔30分钟对新增内容做一次抽取汇总。

我用下来最舒服的组合是:“立即高价值事件 + 结束统一收口”。如果对话特别长,还需要在中间做一次会话摘要巩固,否则长会话里的早期关键信息会先被滑动窗口挤出去。

4.2 怎么给记忆打分:规则+LLM混合

重要性评分看着很玄,其实可以做得非常工程化。我的方案是双层打分:

第一层是规则层。内置明确规则表,例如:

  • 包含“以后都”“再也不”“我习惯”等强偏好词 → +0.4
  • 包含时间、金额、地点等结构化实体 → +0.2
  • 来自用户主动纠错(与之前表述矛盾) → 强制保留,且重要性+0.3
  • 来自系统本身的推理结果、工具执行结果 → 基础分0.3

第二层用LLM兜底。规则层解决高频候选,LLM解决复杂长文本的信息提炼和置信度判断。例如一段话里用户聊了很多背景,规则看不出主次,LLM能判断出“用户真正想强调的是预算卡得很死”。

4.3 记忆冲突与更新策略

长期记忆最常见的灾难是:库里已有一条“用户不喜欢赶时间”,新对话里用户又说“这次越快越好”。如果你直接新增一条,不处理冲突,下次检索两条同时出来,模型就懵了。

我的做法是引入一个轻量的“记忆评审”步骤。当新记忆与旧记忆主体相同、属性冲突时:

  • 比较updated_at:一般来说新信息覆盖旧信息,因为用户的新表达通常代表当前意图。
  • 比较confidence:如果旧记忆置信度极高且是明确偏好,新记忆只是临场变化,则旧记忆保留,新记忆标记为“临时覆盖”。
  • 用户主动纠错时,强制新信息覆盖旧信息,并把旧信息标记为superseded而不是删除,保留历史轨迹以便回溯。

另外,记忆合并要小心实体对齐。同一个人可能在不同轮次被称作“用户”“他”“老王”,抽取阶段最好先做实体归一化,否则合并逻辑会失效。

4.4 遗忘机制:降低记忆噪音的必修课

记忆不是越多越好。过多的低价值记忆会让检索信噪比急剧下降。三个实操手段:

  • 衰减降权:长期未访问的记忆,检索分数逐步乘以一个衰减系数。到零点以下自动进入低优先级区,但不删除。
  • 归档切换:把status标记从active改为archived。印象中超过180天没人访问的非关键偏好,直接归档。
  • 事实过期校验:结构化的敏感信息(如联系地址、会员等级),定期用LLM做纠正性复核,或直接引导用户在合适时机确认。

遗忘不是失败的表现,是记忆系统健康的必要组成部分。一个从不遗忘的Agent,用一个月后就会变成“什么都记得,但什么都用不好”的状态。

5. 一套可直接复用的最小实现:代码与接线

5.1 总体模块划分

我把记忆系统分成三个模块:

  • memory_manager:对外统一接口,负责协调短期/长期记忆的读写顺序。
  • short_term:负责窗口管理、摘要压缩、跨轮要点注入。
  • long_term:负责存储调用、写入提炼、检索合并、冲突处理。

调用时序大致是:

user_query = "上次那个方案呢?" # 1. 读短期记忆(最近对话+会话摘要+要点) short = short_term.read_context(user_query) # 2. 读长期记忆(混合检索,返回Top3) long = long_term.retrieve(user_query, top_k=3) # 3. 组装Prompt prompt = build_prompt( system_instruction, short, # 包含即时记忆+滚动摘要+最近对话 long, # 注入的长期记忆片段 user_query ) # 4. 调模型拿到回复 reply = llm_call(prompt) # 5. 更新短期记忆 short_term.add_user_message(user_query) short_term.add_assistant_message(reply) # 6. 判断是否需要写入/更新长期记忆 if high_value_trigger(user_query, reply): long_term.update_from_dialogue(user_query, reply)

5.2 短期记忆模块核心代码

class ShortTermMemoryManager: def __init__(self, chat_token_budget=5000): self.messages = [] self.summary = "" self.notes = [] self.chat_token_budget = chat_token_budget def add_message(self, role, content): self.messages.append({"role": role, "content": content}) self.notes.extend(extract_important_notes(role, content)) self._maybe_compress() def _maybe_compress(self): if estimate_tokens(self.messages) <= self.chat_token_budget: return # 把最早的一半消息交给摘要模块 overflow = self.messages[:len(self.messages)//2] self.messages = self.messages[len(self.messages)//2:] self.summary = incremental_summarize(self.summary, overflow) def read_context(self): blocks = [] if self.summary: blocks.append({"role": "system", "content": f"会话摘要:{self.summary}"}) if self.notes: blocks.append({"role": "system", "content": f"记忆要点:{self.notes}"}) return blocks + self.messages

5.3 长期记忆模块核心代码

class LongTermMemoryManager: def __init__(self, store, embed_fn): self.store = store # 向量库/KV等存储后端 self.embed_fn = embed_fn # 向量化函数 def update_from_dialogue(self, user_msg, assistant_msg): dialogue = f"用户:{user_msg}\n助手:{assistant_msg}" candidates = extract_memory_candidates(dialogue) # LLM+规则抽取 for cand in candidates: merged = self._merge_with_existing(cand) # 冲突合并 if merged.should_save(): self.save(merged) def retrieve(self, query, top_k=3): # 混合检索:向量+属性+关系 vec_hits = self.store.vector_search(self.embed_fn(query), top_k*2) attr_hits = self.store.attr_search(query) # 精确匹配 all_hits = deduplicate(vec_hits + attr_hits) scored = [(item, self._rank(query, item)) for item in all_hits] scored.sort(key=lambda x: x[1], reverse=True) return [item for item, _ in scored[:top_k]] def _rank(self, query, item): sim = semantic_sim(query, item.content) time_score = time_decay(item.last_access_at, half_life_days=30) return 0.6 * sim + 0.3 * time_score + 0.1 * item.importance

5.4 记忆可视化与调试

最后提醒一个关键工程实践:记忆系统必须能被“看到”。我给整个系统加了一个debug接口,输出“当前检索到了哪几条记忆、各条的分数来源、为什么被选上”。这个接口在调优时几乎是救命的——否则你根本分不清模型回答不好,是因为记忆没存对、检索没召回,还是prompt组装顺序不对。

调试时我最常用的手段是:把一次请求的完整记忆快照dump下来,包括短期窗口内容、摘要、注入的长期记忆条目、检索分数,然后人工比对。这个工作有点枯燥,但找几个案例后,整个系统的优化方向就会非常清楚。

6. 大厂面试高频考点与答题框架

这套系统在大厂Agent相关岗位的面试里出现频率很高。我梳理几个最常见的追问,每个都给你一套可以直接用的答题思路。

6.1 “如果上下文塞满了怎么办?”

这是最经典的入门题。答的时候不要只说“用滑动窗口”,要突出分层处理:

先把上下文分三类:必须保留的(当前指令、未完成任务状态)、可压缩的(历史对话)、可以外置的(长期记忆知识)。塞满后优先压缩历史对话生成摘要,保留最近N轮原始消息;如果还超,再缩减长期记忆注入条数。最终要保障的是“当前任务连续性”,而不是“历史细节完整性”。

6.2 “短期记忆和长期记忆为什么不合并成一套?”

这题考的是对系统设计的理解。核心答案是两个字:成本。短期记忆的读写频率高、延迟敏感,如果每次都走向量库检索,延迟不可控;长期记忆如果做成直接塞上下文,又会信息过载。两者本质上的取舍是“快速访问当前上下文”和“按需检索历史信息”的trade-off。回答时结合真实场景举例,比如“用户改口了偏好”这类动作走的是短期记忆里的即时记录通道,而不是等离线批处理。

6.3 “怎么评估一个Agent记忆系统的好坏?”

我建议给出三层指标体系:

  • 记忆准确性:抽取出的长期记忆是否与对话事实一致(可以抽样人工标注)。
  • 召回利用率:注入prompt的记忆有多少真正被模型用在了回复里,可以靠log分析。
  • 任务成功率:对同样的任务,开启记忆和关闭记忆对比完成率。长期记忆的收益最直观的体现就是同一类任务第二次做明显更顺。

另外可以提到“记忆污染率”——即错误或过期记忆造成回复质量下降的比例。这个概念面试官通常很买账。

6.4 常见追问:冲突、隐私、扩展性

这三个进阶问题也高频出现:

  • 记忆冲突:回答参考我上面4.3的策略,强调时间戳+置信度+用户主动纠正的优先级。
  • 隐私:区分PII(个人信息)与非PII,敏感字段做加密存储、掩码处理,记忆抽取时主动过滤敏感文本,并提供用户“删除记忆”的入口。
  • 扩展性:长期记忆量达到千万级时,先做垂直切分(用户ID分片、会话分片),再做记忆条目的生命周期管理。检索层面要引入粗排+精排两阶段,避免全量扫描。

面试时不要只背答案。最好结合你自己做过的某个模块讲,哪怕是最小的一个摘要压缩功能,讲清楚设计取舍,都会比泛泛而谈有说服力得多。

7. 我在项目里踩过的一些坑与总结

最后分享几个真实项目中验证过的教训,希望你能避开。

第一,不要让长期记忆未经筛选就进入上下文。我早期版本把所有Top3记忆一股脑塞进去,结果模型经常被无关历史带偏。后来加了类型过滤和重排序,效果改善非常明显。

第二,摘要模块的输入顺序影响很大。生成增量摘要时,如果旧摘要和新增对话顺序颠倒,模型产出的摘要质量稳定性会下降。我固定采用“先旧摘要、后新增对话”的顺序,并且要求模型先提取新增信息的要点,再合并旧摘要,效果最稳。

第三,token估算不能拍脑袋。我用过字符数乘系数的方法,中文场景下偏差很大。后来直接接tokenizer计数,虽然多了一点计算开销,但窗口管理精准多了。

第四,记忆系统的调试必须可视化。没有调试视图之前,我经常对着一个错误回答无从下手。给每条记忆加来源会话ID和写入时间之后,回溯变得非常轻松。这个投入很值得。

Agent记忆这块目前还没有所谓的标准答案,各家方案差别很大,但核心的设计取舍是共通的:短期记忆保连续性,长期记忆保一致性,中间靠一套巩固与检索机制做桥接。把这套逻辑想清楚,不管底层用什么存储、什么框架,你都能搭出一套能用的记忆系统。如果这篇文章对你有帮助,建议照着第5节的代码在你的项目里跑一遍,动手踩过坑之后,面试时聊这套东西会自信得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询