
你有没有遇到过这种场景跟一个 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 tokenAgent 回复 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) ))); // 召回相关历史 ListDocument 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, ListMemoryItem items); ListMemoryItem 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); ListMemoryItem 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 和用户的默契恰恰是从这种分寸感里长出来的。