☰
AI Agent记忆系统设计:短期、长期与程序性记忆实战
2026/10/2 3:49:04 网站建设 项目流程

你有没有遇到过这种场景:跟一个 AI Agent 聊了半小时,你把通勤路线、咖啡口味、最近在搞的项目全交代了一遍,第二天打开同一款应用,它上来就是一句“你好,请问需要我帮你做什么?”。那一刻你会觉得之前全白聊了。这一篇要解决的,就是这件事——让 Agent 记住你。

前两篇我们分别聊了 Agent 的架构拆分和工具调用。跑通 Function Calling 之后,你大概率会撞上下一个问题:Agent 没有记忆,每次对话都是失忆重启。其实“记忆”不是一个玄学概念,而是可以在工程上拆解成存储、检索、注入、遗忘几个环节的系统设计。下面我会把这几个环节逐个讲透,并给你一份能直接跑起来的实现思路。这篇适合已经把 Agent 跑通、正准备做带用户状态应用的朋友,不管你是用 Spring AI 还是 LangGraph,核心思路都一样。

1. 先想清楚一个问题:大模型自己到底有没有记忆

1.1 无状态模型背后的真相

大模型本质是一个 function,输入 prompt,输出 token。它不会因为你昨天跟它聊过什么,今天就更懂你——除非你把昨天的内容重新放进今天的输入里。所有聊天软件之所以让你感觉“它记得”,是因为前端把历史消息一遍遍传给模型;一旦会话断开、请求里不再携带历史,模型立刻变回陌生人。这也解释了为什么“记忆”在 Agent 应用里是一个必须单独设计的系统工程,而不是模型能力的一部分。

有人会说:现在大模型支持超长上下文,直接每次把全部历史丢进去不就行了?理论上可以,但有两个现实问题。一是成本:历史越长,每次请求的 token 消耗越大,如果同时服务几千个用户,这部分成本会被无限放大。二是效果:研究里有个很常见的现象叫“lost in the middle”——当输入非常长时,模型对中间部分内容的注意力会明显下降,反而记得住开头和结尾;这导致即便你有 200K 的窗口,塞进去的 50 万字也可能只被用上一小部分。所以记忆工程的本质不是“塞得下”,而是“需要在合适的时机,把合适的信息搬回上下文里”。

1.2 把记忆拆成三类:短期、长期、程序性

我习惯把 Agent 的记忆拆成三类来设计。

记忆类型典型内容典型存储典型失效方式
短期记忆当前会话里的聊天记录、临时状态消息列表、Redis 缓存会话结束、窗口截断
长期记忆用户画像、跨会话的关键事实、历史行为偏好Redis、向量数据库TTL、用户删除、覆盖
程序性记忆工具定义、技能流程、任务规则文件、MCP 配置、配置中心版本升级、配置变更

很多人一上来就想搞长期记忆,结果忽略了短期记忆,这是本末倒置。短期记忆是地基,长期记忆要从短期对话里提炼,程序性记忆则决定了 Agent 有没有“干活的经验”。三者配合才能让 Agent 既记住“你是谁”,又知道“该怎么帮你干活”。

2. 短期记忆:别把所有聊天记录都塞进上下文

2.1 全量塞进上下文的三个后遗症

最原始的短期记忆实现,是维护一个 messages 数组,每次请求把 user、assistant、system 全部消息带过去。这个方案在 demo 里跑得通,但一旦会话变长,三个问题就会接踵而至。

第一是 Token 上涨导致成本失控。假设一条用户消息平均 100 token,Agent 回复 300 token,聊到第 50 轮时,光历史就有 2 万 token,再叠加工具返回、检索结果、System Prompt,单次请求轻松破 3 万 token。第二是响应变慢。大模型对输入长度是线性甚至超线性计算的,输入越长,首 token 延迟越高,用户会明显感觉到“卡”。第三是效果反而变差,就是上面说的“lost in the middle”,真正有用的信息被淹没在大段无关对话里。所以短期记忆必须做裁剪。

2.2 用 Spring AI 的 ChatMemory 快速实现

在 Spring AI 生态里,最省事的做法是用ChatMemory和MessageChatMemoryAdvisor。它帮你把历史消息自动传给模型,不需要手工塞 messages。

ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();

这段代码的意思是:只保留最近 20 条消息,超出后自动丢弃最早的。Advisor 是 Spring AI 的“拦截器”概念,它在每次请求前帮你从 ChatMemory 里加载历史,请求后把新消息写回。你业务代码里只需要调用chatClient.prompt().user("...").call(),记忆的读写全部封装掉了。

不过要注意,Spring AI 版本迭代非常快,不同版本里ChatMemory的包名和构造方式有差异。我上面用的是 1.0 系列的写法,如果你用的 M 系列,建议以官方文档为准。另外InMemoryChatMemory只管本地内存,重启即丢,生产环境需要自己实现一个持久化版本,把消息列表按 sessionId 存到 Redis 里,思路不复杂:数组转 JSON 存 key,读取时反序列化回来。

2.3 窗口之外的记忆:会话摘要压缩

滑窗裁剪看似简单,实则有信息缺口——被截掉的老对话里,可能藏着用户提过的重要需求。更精细的做法是对被裁掉的早期消息做摘要压缩,把摘要放在裁剪后的历史前面,相当于“大模型速记”。

if history长度 > MAX: retained = history[-MAX:] # 保留最近 N 条 condensed = summarize(history[0:-MAX]) # 早期内容浓缩成几句话 final_history = [system: "早期对话摘要:..." + condensed] + retained

这行逻辑虽然朴素,却是很多记忆框架的基础。摘要本身也会增长,所以可以给摘要也设一个最大长度,超过后被再次压缩,形成递归摘要。实测下来,把 50 轮对话压缩成 10 句话,信息保留率通常在 80% 以上,而 Token 消耗能降一个数量级。这套东西适合放在会话结束后异步执行,避免阻塞用户的下一轮提问。

3. 长期记忆:第二次见面,叫得出你的名字

3.1 用户画像适合 KV,不适合一上来就上向量库

我做长期记忆的第一步,是先把“用户画像”和“聊天记录级记忆”分开。用户画像是高度结构化的,比如姓名、城市、咖啡偏好、沟通风格。这种数据不适合用向量检索,因为用户问你“我的咖啡偏好是什么”时,你需要的是精确值,而不是“相关内容”。所以画像我首选 Redis 存 JSON。

{ "name": "小明", "city": "杭州", "coffee": "冰美式", "communication_style": "简洁直接" }

Key 的设计建议:agent:memory:profile:{userId}。每次对话开始前,取出这个 JSON 反序列化,再注入 System Prompt。更新也简单,整体覆盖写回。画像的变更要保留版本,比如加一个updatedAt字段,用户改偏好的时候,以最近一次为准。别小看这个字段,后面做“记忆冲突处理”时会非常有帮助。

3.2 聊天记录级记忆:用向量检索做“回忆”

用户画像能记住“事实”,但记不住“说过的话”。比如用户上次提到“这周末要去面试,岗位是 Java 后端开发”,这个信息不一定适合进画像,但下次聊到面试时 Agent 应该记得。这类非结构化、低频但相关的信息,适合放向量数据库。

流程分两步。写入时,把对话提炼成一条条“记忆片段”,embedding 后入库;查询时,把用户当前问题 embedding,做相似度检索,召回最相关的历史片段。用 Spring AI 的话,代码大概是:

// 写入记忆片段 vectorStore.add(List.of(new Document( "用户说自己这周末有 Java 后端面试,正在复习 Spring 和 MySQL。", Map.of("userId", "u_123", "timestamp", "2025-01-12") ))); // 召回相关历史 List<Document> memories = vectorStore.similaritySearch( SearchRequest.builder() .query("面试准备得怎么样?") .topK(5) .similarityThreshold(0.6) .build() );

为什么用相似度而不是 SQL 的 like?因为用户换个说法,比如“Java 后端岗位的面试”和“Spring/MySQL 复习”,字面上不完全匹配,但语义接近;向量检索可以把这种“回忆”过程中常见的同义表达覆盖掉。选向量库时,轻量场景用 Chroma 或 FAISS 就够,数据量大、需要团队协作再上 Milvus 或 pgvector;国内云环境用云厂商的向量检索服务也完全可行。关键参数是 topK 和 similarityThreshold,前者控制召回条数,后者过滤噪声;建议 topK 取 3~5,太少不够用,太多稀释注意力。

3.3 写入时机与抽取策略:对话结束后异步提炼

这个环节特别容易踩坑。很多初学者会把“用户每说一句话”都写入长期记忆,最后数据库里全是“今天天气不错”这类废话,真正有用的信息被淹没。我的做法是:让大模型做一次“记忆抽取”,只保存值得长期记住的信息。

你是记忆抽取器。从下面的对话中提取用户的长期偏好、关键事实和偏好变化。 只保留用户明确表达的、对未来对话可能有帮助的信息。 输出 JSON 数组,字段:type(profile/fact/preference)、field、value、confidence。 如果用户表示不要记录,返回 []。

这个抽取动作放在对话完成后异步执行,不阻塞用户响应。抽取结果根据 type 分流:profile 类进 Redis 画像,fact/preference 类进向量库。每一条记忆都带上 userId、timestamp、confidence、sourceSessionId 这几个 metadata,后面做遗忘和审计都用得上。

4. 程序性记忆与技能持久化:记住你是谁,也要记住怎么干活

4.1 Agent Skill 和 MCP:把“经验”变成结构化的文件

聊完“你是谁”,再聊“怎么干活”。程序性记忆解决的是 Agent 操作层面的稳定性问题。同样是写周报,一个“熟练工 Agent”和一个“新手 Agent”差别巨大;区别不在于模型,而在于有没有沉淀出一套可复用的技能包。

我理解的 Agent Skill,是把完成某类任务的说明、步骤、工具调用模板打包成结构化文件。比如一个“周报生成”技能包含触发条件、数据来源、组织模板、发送规则。MCP 则提供了一套标准化的工具注册协议,让 Agent 能发现并调用外部能力。这些东西虽然不长在你的 Redis 里,但本质上是你的 Agent 的“肌肉记忆”——没有它们,每次任务都要从零推理,答案会飘忽不定。

所以在记忆设计里,我会把技能文件路径、技能描述、关联的 MCP 工具清单也纳入记忆体系:Agent 在对话开始前不仅查用户档案,也会问自己一句“我有哪些能力可以用”。这部分通常不需要大模型检索,而是由 Agent 调度层根据用户意图选择;但技能描述写得越清晰、越接近用户表达习惯,选得就越准。可以把它想象成“简历”,程序性记忆就是一份经常要更新、版本要管理的简历。

4.2 注入 System Prompt 的正确姿势:分区块,限量,可解释

把长期记忆一股脑儿拼进 System Prompt,是最常见的错误。我见过有人把几十条用户偏好全塞进去,最后模型每条都参考,每条都表现不好。正确做法是给记忆分区块,并且给每个区块设上限。

你是用户的 AI 助手。 [用户画像] 姓名:小明 城市:杭州 沟通风格:简洁直接 (最多 20 行) [相关历史记忆] - 上次提到准备 Java 后端面试,正在复习 Spring - 对含糖饮品兴趣不大 (最多 5 条) 回答前优先参考以上记忆;如果与用户当前说法冲突,以当前说法为准。

注意最后一句:以当前说法为准。这句能解决很多记忆冲突。另外,可解释性很重要——用户随时可能问“你怎么知道我喜欢冰美式?”,所以 Agent 在对话中要能调出记忆来源(哪条、什么时候、哪次会话)。这既是体验问题,也是信任问题。

5. 实战:一个“记住你”的记忆模块,从接口到效果

5.1 定义存储接口,不绑定具体数据库

实战部分给一套可运行的骨架。先定义一个最简接口,核心方法不超过五个。

public interface MemoryStore { void saveProfile(String userId, UserProfile profile); UserProfile getProfile(String userId); void saveMemories(String userId, List<MemoryItem> items); List<MemoryItem> searchMemories(String userId, String query, int topK); void deleteUser(String userId); }

Redis 实现里,profile 按 userId 做 key 存 JSON,给 90 天 TTL 保证自动弱化;聊天记录级记忆走向量库(实现类里注入 VectorStore)。这样上层对话服务完全不感知底层存储,将来从本地方案迁云,只需要换实现类。

public void saveProfile(String userId, UserProfile profile) { String json = objectMapper.writeValueAsString(profile); stringRedisTemplate.opsForValue().set( "agent:memory:profile:" + userId, json, Duration.ofDays(90)); } public UserProfile getProfile(String userId) { String json = stringRedisTemplate.opsForValue().get("agent:memory:profile:" + userId); return json == null ? UserProfile.empty() : objectMapper.readValue(json, UserProfile.class); }

5.2 对话链路:读取画像、检索历史、组装提示、异步抽取

有了 MemoryStore,对话服务的主流程就可以这样写:

@PostMapping("/chat") public Completion chat(@RequestBody ChatRequest req) { String userId = req.userId(); String userText = req.message(); UserProfile profile = memoryStore.getProfile(userId); List<MemoryItem> memories = memoryStore.searchMemories(userId, userText, 5); String systemPrompt = MemoryPromptBuilder.build(profile, memories); String reply = chatClient.prompt() .system(systemPrompt) .user(userText) .call() .content(); memoryExtractor.extractAndSaveAsync(userId, userText, reply); return Completion.of(reply); }

关键点在第 5 步:抽取和落库一定要异步。你可以用@Async、CompletableFuture,或者丢队列。为什么呢?因为抽取也是调一次大模型,通常要几百毫秒到 1 秒多;如果同步执行,用户的回复会被拖慢。而用户真正关心的是他这句话的回答,不需要等记忆保存完。异步之后,用户体感零成本,记忆在后台自己沉淀。

MemoryPromptBuilder做的事很单纯:把画像和记忆片段按区块拼成 System Prompt。profile 为空时跳过画像区块,memory 为空时跳过记忆区块。注意不要硬拼出两个空区块占 token。

5.3 实测效果:换了个会话,它依然知道你是谁

跑一个端到端的例子。第一次对话:

用户:我叫小明,在杭州工作,平时喜欢喝冰美式。 Agent:好的,小明,我记住了,随时可以聊聊你感兴趣的咖啡。

这次对话结束后,后台异步抽取器大概率会生成三条画像记忆:name=小明、city=杭州、coffee=冰美式。它们被写进 Redis 的 profile。

第二次对话,我把 sessionId 换成全新的,模拟隔天重新打开应用:

用户:推荐一杯适合上午的咖啡吧。 Agent:小明,按你的口味,上午的冰美式挺合适;如果想换换感觉,燕麦拿铁热量更低,也可以试试。

为什么它能叫出小明、知道冰美式?并不是它聪明,而是对话前那两步——读取画像、注入 System Prompt——把记忆搬回了上下文。这就是记忆工程的全部意义。

6. 三个绕不开的坑:记忆污染、遗忘机制、隐私边界

6.1 记忆污染:它会把一句玩笑当成你的偏好

长期记忆的最大风险不是丢,而是记错。用户偶尔说一句“咖啡我真是喝够了”,如果抽取器不够稳健,它可能把“讨厌咖啡”写进 profile 覆盖原来的“冰美式”,后面所有推荐都跑偏。这种事我遇到过不止一次。

根治思路是分级防护。核心画像字段用 pinned 标记,比如coffee这个字段只有用户显式说“我改喝拿铁了”这类明确变更才允许覆盖,普通抽取不能动。抽取器还要加置信度阈值,低于 0.7 的不写。最后加一个人工管理后台,用户能看到 Agent 记了什么、一键删除。记忆就像代码仓库,必须能 diff、能回滚,否则重建信任的成本很高。

6.2 真正的遗忘策略:过期、优先级、覆盖写入

记忆不是越多越好。我用三管齐下的方式来控制长期记忆的数据膨胀和失效。

  • Redis 画像用 TTL,比如 90 天,最近活跃过就续期,长期不活跃自动清掉。
  • 向量记忆定期清理:低置信度、超过一定时间且没有再次命中的片段,可以直接删;频繁命中的片段提升优先级,相当于人的“反复回忆会加强记忆”。
  • 冲突时以最近一次为准,但保留旧版本用于追溯。用户改了口味,不能同时保留“爱喝冰美式”和“改喝拿铁”两个记忆并存互相打架。

这套策略的核心理念是:遗忘不是缺陷,而是记忆系统正常工作的组成部分。大模型上下文有限,存储也不是无限的,筛选并存好最有效的记忆,效果反而比什么都存好。

6.3 敏感信息不“记忆”:这是红线

最后一点不是技术建议,是原则。无论产品怎么做,明文密码、卡号、身份证号等敏感信息都不进入长期记忆。抽取器需要有一层敏感信息过滤:识别到这类内容直接丢弃,或者返回给业务系统加密处理。同时产品要赋予用户“查看、导出、删除记忆”的完整权限,不能把用户数据闷在数据库里。现在大家对数据主权的意识越来越强,一味讨好模型能力而牺牲用户控制感,早晚会被反噬。

我在做这类功能时的实际体会是:好的记忆系统总在克制自己。它清楚什么该记、什么该忘、什么绝对不能碰。Agent 和用户的默契,恰恰是从这种分寸感里长出来的。

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

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

立即咨询