做Agent这件事,越做到后面越会撞上一个尴尬事实:模型能力不够,你可以换更强的底座;工具调用不行,你可以把工具描述写得更好;但Agent"转头就忘"这个问题,不是换个模型就能解决的。AI Agent记忆系统,本质上是给"一次性的对话"接上一个能跨时间、跨会话存取的状态层。它分短期和长期两条线:短期负责当前任务的工作记忆,长期负责把对话沉淀成可检索、可复用、可更新的知识。这篇文章把短期记忆和长期记忆的架构设计与工程实现拆开揉碎,从设计思路到具体代码,给正在做或者准备做Agent的开发者一份能直接抄作业的参考。
适合看这篇的人有三类:刚用大模型API写聊天机器人、但每次都靠用户重复背景信息才能干活的;已经用Agent框架搭了多轮对话、但对话一长就上下文爆炸的;以及想给Agent做个性化记忆能力的产品和算法工程人员。下面我会按"架构分层→短期记忆实现→长期记忆存储检索→协同调度→实战避坑"的顺序讲,尽量让新手能照做,也让有经验的同行能挑到可复用的设计细节。
1. 记忆系统整体架构与记忆分层设计
1.1 为什么Agent必须有一套记忆系统
先把根因说清楚。大模型对话的本质是无状态函数:你给它一段文本,它回你一段文本,上下文一关,状态清零。Agent不一样,Agent要完成任务,就得能在多轮里保持目标、积累信息、引用之前的结论。没有记忆的Agent,每次开口都在"失忆重启",用户得把背景资料讲三遍。
我见过很多团队的第一版Agent都是"暴力塞上下文":把历史对话全量拼进system prompt,对话轮次一多,上下文窗口直接被撑爆,更别提成本了。这是治标不治本。真正的解法是给Agent设计一套独立的记忆系统,把"模型无状态"的短板用外部状态补齐。这套系统的目标就一句话:在正确的时机,把正确的那一小部分过去,放回模型面前。
这句话听起来简单,落地却难。难在"正确"两个字:短期记忆要留多少?长期记忆调哪些?两者的优先级怎么排?这些都得在架构层面定好规则,而不是靠拼字符串。
1.2 短期记忆和长期记忆的边界怎么划
用人类认知做类比最好懂。短期记忆像桌面上的便签:正在处理的任务、上一步的结论、用户刚说的关键偏好,都是随手要用的,速度要快;长期记忆像档案柜:用户画像、领域知识、历史项目、以往决策理由,平时不用全摊开,需要时按索引去查。
工程上两者的边界会体现在存储介质和生命周期上:
| 维度 | 短期记忆(工作记忆) | 长期记忆(长期知识) |
|---|---|---|
| 内容 | 最近对话、任务中间态、上下文变量 | 用户画像、历史事件、领域事实、经验规律 |
| 生命周期 | 单次会话,会话结束即销毁或压缩 | 跨会话,持久保存,按策略更新与遗忘 |
| 存储形态 | 内存对象、键值缓存、对话窗口 | 向量数据库、结构化表、JSON文件 |
| 访问方式 | 全量注入或摘要注入 | 检索召回,按相关性动态写入 |
| 更新方式 | 每次对话追加、截断、压缩 | 抽取写入、去重合并、冲突覆盖 |
划边界时最容易犯的错,是把长期记忆当成"短期记忆的备份"。短期记忆里的历史对话是一份时间线,长期记忆是语义层,存的是从时间线里提炼出来的结论。打个比方,昨天你帮用户配置了服务器SSH,短期记忆记录的是"昨天下午捣鼓了SSH的详细步骤",长期记忆记录的是"该用户使用的是某家的云主机,偏好密钥登录,对安全要求高"。前者是流水账,后者才是能复用的资产。
1.3 读写两条主链路:架构设计的核心骨架
记忆系统的架构不用搞得很复杂,核心是两条链路:
- 写路径:用户输入和Agent输出进入短期记忆 → 短期记忆做抽摘要/抽取 → 关键信息结构化后写入长期记忆。
- 读路径:新用户输入触发查询 → 先从长期记忆召回相关记忆 → 与短期记忆按配额组装 → 拼进上下文给模型。
这两条链路本身不复杂,复杂的是让它们协同。写路径不能每轮都写,否则长期记忆里全是重复碎片;读路径不能什么记忆都召回,否则上下文噪声会淹掉真正重要的信息。
一个比较稳妥的做法,是把记忆调度做成独立的中间层,不要和业务逻辑耦合在一起。业务层只负责说"当前用户是U,当前会话是S,请给我组装上下文",记忆层负责"去长期库里捞什么、短期库留多少、怎么拼"。这样后续换存储、调策略都不用动业务代码。
2. 短期记忆:工作记忆区的工程实现
短期记忆在工程上最直接的载体是上下文窗口,但上下文窗口是稀缺资源,所以短期记忆的核心不是"存多少",而是"怎么聪明地丢"。
2.1 上下文配额管理:给记忆定预算
第一步要做的,是给上下文的各个部分定配额,否则长期记忆越塞越多,最后又把窗口撑爆。我习惯把上下文分成三个区:系统指令区、长期记忆摘要区、短期对话区。
系统指令区放Agent人设、任务说明、工具定义,这部分基本不变。长期记忆摘要区放从长期库召回并压缩过的记忆,这里要做严格的条数限制。短期对话区放最近的对话窗口,超出的部分进入截断或摘要。
一个可参考的配额模型(按4K上下文举例):
- 系统指令:1200 token
- 长期记忆摘要:800 token
- 短期对话区:1800 token
- 缓冲余量:200 token
这里的核心思路是"预算是写死的,内容是动态的"。对话区超了,就压缩或截断对话区;长期摘要超了,就压缩摘要,而不是反过来把系统指令挤掉。系统指令一旦被挤掉,Agent会连自己是谁、要干什么都忘掉,这种错误非常低级但很容易犯。
2.2 滑动窗口截断:守住最近对话时间线
在实现短期对话区时,最常用的是滑动窗口。它的逻辑很简单:无论历史多长,只保留最近的N轮。
# 一个简单的滑动窗口实现 class SlidingWindowBuffer: def __init__(self, max_turns=10): self.max_turns = max_turns self.messages = [] def add_turn(self, user_msg, assistant_msg): self.messages.append({"role": "user", "content": user_msg}) self.messages.append({"role": "assistant", "content": assistant_msg}) self._trim() def _trim(self): if len(self.messages) > self.max_turns * 2: overflow = len(self.messages) - self.max_turns * 2 self.messages = self.messages[overflow:]实际操作中,单纯截断有个问题:早期对话里的关键信息会直接丢。比如用户第三轮说"记住我用的是月度订阅",第五十轮再问"我这个月账单到期日是什么时候",滑动窗口早把那条信息挪出去了。
所以窗口截断只适合低要求场景。更稳的做法是配合摘要:被截断的部分,不是直接删除,而是在截断前生成摘要挂到长期记忆里。等用户再提到相关话题时,靠检索把它召回来。
2.3 滚动摘要压缩:让上下文持续瘦身
滚动摘要的思路是:每次对话进行到一定量,就把之前的历史交给模型总结成一段短摘要,之后把原文替换成摘要,继续跑。
典型实现长这样:
def get_running_summary(summary_so_far, new_messages, llm): if summary_so_far is None: content = "\n".join([m["content"] for m in new_messages]) else: content = f"已有摘要:\n{summary_so_far}\n\n追加对话:\n" + "\n".join( [m["content"] for m in new_messages] ) prompt = [ { "role": "system", "content": ( "你是记忆整理助手。把已有摘要和新对话合并成最新摘要。" "保留用户明确说过的偏好、事实和未完成任务。" "不要加入你的推测。控制在200字以内。" ), }, {"role": "user", "content": content}, ] summary = llm.chat(messages=prompt) return summary滚动摘要有几个细节需要注意。第一,触发时机不能只看轮次,还要看token量。我建议按token数触发,比如累计超过1500 token就做一次摘要。第二,摘要要从"未来使用"的角度写,不是复述发生了什么,而是提炼出"哪些信息后续还可能会用"。比如"用户提到了服务器在某某地域"比"用户和我们聊了服务器的价格"有用得多。第三,摘要会叠加误差,所以每次都不应该完全抛弃原文,而是在摘要后保留最近一两轮原文作为缓冲。
2.4 结构化工作状态:别把所有记忆都压进自然语言
短期记忆里还有一类信息不适合塞自然语言:任务中间状态。比如Agent在做一个多步骤任务,现在已经完成了哪几步、下一步是什么、有哪些可选参数,这些用自然语言描述又长又容易乱,但用结构化的JSON挂到会话状态里非常清晰。
{ "session_id": "s_1024", "task": { "name": "服务器迁移", "current_step": "备份数据", "done_steps": ["确认源机器", "创建快照"], "params": { "src_host": "10.x.x.x", "region": "ap-east" } }, "user_intent": "迁移后检查服务可用性", "pending_input": null }每次调用模型前,把这个JSON压缩成一行文本注入上下文;每轮结束后,根据模型输出更新它。这其实就是老生常谈的"结构化状态管理",但放到Agent语境下特别容易被忽略。很多人把所有东西都写成对话文本,上下文又长又乱,还不好解析,得不偿失。
3. 长期记忆:存储设计与检索优化
长期记忆是Agent真正的"增量资产",也是记忆系统里最需要花心思设计的一层。短期记忆的坑主要在配额管理,长期记忆的坑则在数据质量和检索准确性。
3.1 存什么:事实、事件、语义三种记忆
长期记忆我习惯拆成三类,分类不同,写法和用法也不同:
| 记忆类型 | 内容 | 示例 | 检索方式 |
|---|---|---|---|
| 事实记忆 | 用户属性、偏好、配置 | 用户使用月付套餐;用户偏好邮件通知 | 结构化KV,按实体查询 |
| 事件记忆 | 发生过的事、任务结果 | 8月3日完成过一轮服务器巡检 | 时间线+向量检索 |
| 语义记忆 | Agent学习到的规律、概念 | 用户常用方案A而非方案B;脚本部署前必须备份 | 向量检索,抽象关联 |
事实记忆和事件记忆的分工要特别说明一下。事实记忆是"当前为真"的信息,可以直接覆盖;事件记忆是"过去发生"的记录,只能追加,不能覆盖。这个区分很重要:用户改了地址,事实记忆里地址要更新,但事件记忆里"2025年初用户曾在A地"的记录不应该被删掉,因为它是历史证据。
3.2 写入流程:从对话到可检索的记忆条目
长期记忆不是把对话原文复制进去就完事,而是要经历"抽取→去重→向量化→入库"四个步骤。
抽取是其中最关键的环节。我通常的做法是:在对话到达某个边界点(比如完成了一个子任务,或者用户明确表达了偏好)时,调用一次抽取模型,把记忆点转成结构化JSON:
{ "memory_type": "fact", "entity": "user", "attribute": "plan_type", "value": "monthly", "confidence": 0.95, "source_message_id": "m_233", "timestamp": "2026-01-17T10:32:00Z" }抽取提示词要注意两点。第一,禁止推测。模型很容易在抽取时脑补用户没说过的话,必须在提示词里明确"只抽取用户明确表达的,不要总结用户没有给出的信息"。第二,要有去重要求。抽取结果里如果已经存在相同实体+属性的旧记忆,要返回"update"而不是新建。
去重之后做向量化。这里我建议把"实体+属性+值+上下文"拼成一句话再向量化,而不是只向量化值。比如"用户 plan_type 等于 monthly"和"用户目前选择按月订阅方案",语义相近的靠向量才能召回。
3.3 检索排序:不只是向量TopN
很多Agent记忆的第一版都直接做"向量Top5召回塞进上下文",但效果往往很差,因为向量检索只看语义相似度,不看时间的紧迫性和事实的确定性。长期记忆的检索排序至少要叠加三个因素:语义相关性、时间衰减、置信度。
一个参考公式:
score = similarity × decay_factor × confidence
- similarity:向量相似度,范围0~1。
- decay_factor:时间衰减系数,比如当天为1,一周后衰减为0.6,一个月后0.2。
- confidence:抽取阶段模型给的置信度,0~1。
举例说明。用户两年前说过"喜欢喝美式",昨天说"现在改喝拿铁了"。用户不提咖啡的前提下,两条记忆的语义相似度可能都在阈值内,但旧记忆被覆盖后就不该再返回;如果没做覆盖,就必须靠时间衰减把旧记忆的分数拉下来。
检索还有个工程细节:尽量做混合检索,关键词命中 + 向量召回,再用上面的公式统一排序。有些事实记忆是精确值("套餐类型=月度"),向量召回容易漂,但关键词倒排索引一查就中。
3.4 记忆更新与冲突处理:覆盖、合并和版本化
长期记忆最复杂的部分不是让它"记住",而是让它"接受新信息、放下旧信息"。冲突处理我总结成三种策略:
| 冲突场景 | 策略 | 示例 |
|---|---|---|
| 同一属性的值被更新 | 直接覆盖旧值,保留历史版本 | 地址从A地改为B地,当前值用B,A进历史 |
| 不同记忆条目含义相近 | 合并去重,保留置信度高的核心词 | "喜欢简约风格"和"不爱复杂设计"合并 |
| 事件记忆新增 | 追加,不覆盖旧事件 | 历史巡检记录持续追加,不删旧记录 |
实现上,给每条记忆加version字段和status字段(active/superseded/deleted)会好办很多。查询只取active,更新时把旧条目置为superseded,再新建一条active。这样既能保证当前记忆干净,也能保留完整的演变轨迹,后续做调试和审计非常有用。
3.5 存储选型:按数据量分阶段
存储永远是跟着规模走的。我给一个选型参考,不一定适用所有人,但能给选择困难的人一个方向:
| 阶段 | 数据规模 | 推荐方案 | 备注 |
|---|---|---|---|
| 小项目/原型 | 百级用户 | JSON文件或单机内存KV | 开发迭代快,不用上重型组件 |
| 中规模单机 | 万级用户 | 嵌入式向量库+关系型数据库 | 注意备份和持久化 |
| 大规模生产 | 百万级以上 | 独立向量检索服务+分布式存储 | 需要独立运维,考虑容量规划 |
这里要强调一点:不要在原型阶段就上一套复杂的分布式存储,没必要。记忆系统的难点在逻辑设计,不在存储组件。用文件把逻辑先跑通,再迁移到正式存储,这个路径最省力。
4. 短期与长期协同:一条完整的记忆调度链路
两边分开设计好了,接下来就是组合拳。这一节用一条完整的带记忆会话串起来。
4.1 一次完整的带记忆会话过程
场景:用户第二次打开Agent,说"上次那个迁移任务,帮我看看卡在哪一步了"。
def handle_new_session(user_id, session_id, user_input, memory_store, llm): # 1. 读长期记忆:用户画像 + 最近的活跃任务 long_term_memories = memory_store.query(user_id, query_text=user_input, top_k=5) # 2. 建短期工作区 short_term = ShortTermBuffer() short_term.add_turn(user_input, None) # 3. 组装系统提示词 system_prompt = build_system_prompt( user_profile=long_term_memories["facts"], task_state=long_term_memories["active_task"], conversation_summary=long_term_memories["recent_events"], ) # 4. 调模型 response = llm.chat(messages=[system_prompt] + short_term.messages) # 5. 写回长期记忆:本轮的新状态 memory_store.write(user_id, extract_memories(user_input, response)) return response这个流程看起来不复杂,但每一步都有讲究。第一步的检索query,用的是当前用户输入而不只是"该用户的所有记忆",因为上下文空间有限,必须按需求召回。第三步组装的system prompt,不是"列出用户所有信息",而是"只放和当前任务相关的画像和事件",否则就像进了档案室把所有柜子都打开,反而是灾难。
4.2 注入剂量与时机控制
记忆注入最经典的翻车现场,就是"召回了一堆记忆,每条都对,但模型被这堆记忆干扰得不知道怎么回答"。原因在于剂量过大。我做经验值:长期记忆摘要的总量,建议控制在上下文窗口的20%~30%;召回条数控制在3~8条之间。
召回条数多了,模型会试图"雨露均沾"地引用每条记忆,输出变得又长又碎。召回条数少了,关键信息漏掉,回答就没针对性。具体调优时,可以先从top_k=5起步,观察回答质量再增减。
时机上记住一个原则:不是每轮都要做长期检索。如果当前用户输入可以靠短期窗口就解决,就不必多一次向量查询。只有两类情况值得触发长期检索:一是新会话开始,上下文是空的;二是当前输入里出现了"之前提到过""我上次说的"这类回溯指代。
4.3 遗忘机制:记忆需要定期清理和刷新
长期记忆不等于永远记住。没有遗忘机制的记忆系统,最终会变成一个越存越脏的垃圾场。我在线上系统里会做三件事:
- 定期衰减扫描:每周跑一次,对超过一定时间且没有访问的条目降低置信度或归档。
- 冲突清理:对superseded状态的条目定期物理清理或者归档,减少查询量。
- 用户主动删除:提供"清除我的记忆"之类的能力,这是对用户数据和隐私的尊重,也是很多场景合规的底线。
遗忘机制的工程实现不复杂,难的是判断标准。我的经验是:不要按固定天数一刀切,而是结合"最后访问时间"和"是否与活跃任务相关"。一个用户三个月前用的项目管理偏好可能已经过期,但同一时间设定的"自动备份策略"如果还在运转,就仍然是有效记忆。
4.4 可观测性设计:记忆系统怎么调试
记忆系统是隐性的故障多发区。模型输出错了你可以追prompt,但记忆坏了往往很难察觉。所以从第一天起就把可观测性做好,能省后面大量排查时间。
我自己的做法是三个"全记录":一是记忆写入全记录,谁在什么时间写入了一条什么记忆,原始来源是哪条消息;二是召回过程全记录,每次查询召回的前十名是哪些、分数分别多少、有没有被过滤掉;三是最终注入结果记录,模型实际看到的记忆摘要长什么样。
这三个记录单独存一份调试日志,不进生产上下文。等出问题时,把"用户输入→召回结果→注入内容→模型输出"四段拼在一起,问题出在哪个环节基本一眼就能看出来,不像黑盒只能靠猜。
5. 实战避坑:我踩过的五个典型问题
这一节整理我在实际项目里遇到过的问题,每个都配了症状和解决思路。
5.1 上下文爆炸,越跑越慢
症状:运行时token消耗逐渐走高,响应延迟明显变长,偶尔直接报上下文超限。
原因:短期记忆和长期摘要的配额没有写死,有的工程师把摘要越拼越长,把长期记忆固定拼进system,从几百token涨到几千token。
解法:把配额收敛到一个配置中心,统一管理"系统指令/长期摘要/短期窗口/缓冲"的token上限,并在每次拼装后做token长度校验,超了就强制走压缩分支。这个校验不是可选项,是必须项。
5.2 召回了一堆记忆,模型反而答非所问
症状:长短期记忆都正常工作,但Agent回答明显跑偏。
原因:召回结果里的相关性阈值太低,塞了太多弱相关记忆;或者是召回条数太多,模型被噪声干扰。
解法:把相关性阈值从0.6左右往上调,召回条数限制在5条以内,同时做一次重排。重排的意思是对召回的候选记忆,先用轻量规则筛掉明显不相关的,再进排序公式,最后注入时只保留高分段。宁可少给,不要乱给。
5.3 用户改了偏好,旧记忆还在干扰
症状:用户说了"我现在不用邮件通知了",但Agent还在提醒"您偏好邮件通知"。
原因:事实记忆没有做覆盖更新。新偏好写进去了,但旧偏好的条目还是active状态。
解法:写路径必须带覆盖逻辑。当抽取到"user.notification_preference"的新值时,先查库里有没有同实体同属性的active条目,有的话把它置为superseded再写入新条目。这个过程一步都不能省,省了后面全是脏数据。
5.4 记忆系统成为成本刺客
症状:线上账单里,essages token消耗比预期高一截。
原因:每轮都触发摘要、每轮都做长期检索并召回、抽取模型用在了所有对话轮次上。这些操作单独看都不大,量一大就是实打实的成本。
解法:做异步化。对话线上只做必要的短期窗口更新;摘要和抽取放进异步队列,批量处理。比如固定每10轮或者每满一定token才跑一次摘要,长期检索只在识别到"回溯指代"或"新会话启动"时才触发。记忆系统的延迟可以容忍,没必要和在线对话抢时间。
5.5 隐私与数据边界
做记忆系统一定要明确一个问题:用户记忆不是免费的资源,它携带敏感信息。工程上至少要保证三件事:
- 长期记忆存储加密,尤其是画像里可能存在的联系方式、地址之类敏感字段。
- 支持用户查看和删除自己的记忆条目,并且删除应该是物理删除或彻底置灰,而不是留在后台。
- 写入时做内容过滤,对明显敏感的信息打标,默认不进入长期记忆;如果业务确实需要,必须有明确授权。
合规不是把锅甩给法务,而是架构师在系统设计时就得把数据边界画出来。早期不管,后面改起来是大工程。
最后分享一点我个人做成这件事后的最大体会。记忆系统真正让用户觉得"这Agent懂我"的时刻,不是某条记忆检索得有多准,而是"中断之后接得上"。我之前在做个人知识助手类Agent时,把记忆回写改成了独立的优先级队列,不让它和在线对话抢资源,用户在前一轮中断后隔了一天才回来说"继续上次那个方案",它能快速接上上下文,那一刻我才觉得记忆系统的价值真正兑现了。
再送一个小技巧:给每条记忆都加上source_message_id,记录它是从哪一条消息抽取出来的。这个字段平时看着没用,一旦做错需要回溯时,它能帮你少排查几个小时。别偷懒,加上它。