Java 转 Agent 开发之路(五):Memory——短期上下文的边界与长期记忆的检索
上一篇的终点,是 Planning 让 Agent 能把事办完。但办完事之后,有一个问题没有被回答:这件事的“痕迹”存在哪里,下次还能不能用?
一次多轮 Planning 加工具调用的会话,消息列表会迅速膨胀。一个需要跨会话记住用户偏好的助手,不能只靠“把历史全塞进 Prompt”。短期上下文有硬边界,长期记忆有检索成本,两者不能混为一谈。这一篇要把 Memory 从“加一个 ChatMemory”的直觉操作,拆解成分层、有边界、可检索的系统设计。
一、先把“记忆”拆成四层,而不是两层
第一篇说 Memory 分短期和长期。这个二分法在概念上没错,但在工程上不够用。一篇面试题式的答案会说“短期 Redis,长期向量库”,但 Spring AI 的默认装配根本不是这个样子的——默认短期记忆是 JVM 内的 ConcurrentHashMap,窗口截到 20 条,进程没了记忆就没了。把“短期 Redis、长期向量”当作标准答案,对的是别的系统,不是 Spring AI 的默认路径。
更准确的拆法是四层:
第一层,当前轮次的上下文。 本轮用户消息、本轮工具调用的结果、模型正在处理的中间状态。这是最窄、最临时的那一层。
第二层,短期对话记忆。 最近若干轮的消息历史,由 ChatMemory 管理。它的边界是消息窗口或 Token 预算,目标是让模型在多轮对话中保持连贯。
第三层,长期记忆。 跨会话、跨轮次仍然值得保留的信息:用户偏好、历史结论、经过确认的事实。它不由 ChatMemory 自动管理,需要显式的写入决策和检索策略。
第四层,任务状态。 上一篇的 TaskState——当前走到第几步、某步的产出是什么、是否等待审批。任务状态不应该塞进自然语言记忆,它应该由确定性的数据库字段表示,而不是依赖模型从聊天记录中“猜”出来。
这四层的边界,决定了这一篇的论述顺序:先讲短期记忆的窗口为什么比想象中更脆,再讲长期记忆的写入为什么比检索更难。
二、短期记忆:MessageWindowChatMemory 的窗口,不是一个安全的窗口
Spring AI 默认的短期记忆实现是 MessageWindowChatMemory,配合 InMemoryChatMemoryRepository 使用 ConcurrentHashMap 存储,默认窗口大小为 20 条消息。窗口策略的细节是:超过上限时,SystemMessage 一律保留,非系统消息会先算出一个截断点,然后向前滑动到下一个 UserMessage 才真正截断——窗口必须从完整的一轮开始,不能只留下一条 assistant 回复或一条 tool 结果,把引出这一轮的 user 消息切掉。
这个设计已经考虑到了工具调用场景的一部分问题。但它有一个更隐蔽的缺口:当一轮工具调用本身比窗口还长的时候,非系统消息可能被清空,直到下一条 UserMessage 进来才能恢复。文档里有一句话写得很直白:“一轮 tool calling 比窗口还长的时候,非系统消息可能被清空”。
这意味着什么?假设用户说“帮我调研一下这个项目的性能瓶颈”,模型连续调用了读取代码、分析数据库、检查缓存三个工具。如果这三个工具调用加上模型的中间推理超过了窗口容量,MessageWindowChatMemory 的截断逻辑找不到一个可用的 UserMessage 作为截断点,结果可能是把整轮工具调用的痕迹全部清掉——模型下一轮醒来时,看到的是一个空白的非系统上下文。
这不是一个可以通过“把窗口调大”解决的问题。窗口调大意味着每一轮都要把更多的历史传给模型,Token 消耗是乘法效应,Planning 循环叠加工具循环时成本会迅速失控。
短期记忆的真正边界:Token 预算,不是消息条数
maxMessages(20) 是一个消息条数的约束,但模型的上下文窗口是Token 计量的。一条带工具结果的消息可能比十条普通用户消息还长。用消息条数做窗口,相当于用“文件个数”来限制磁盘占用,粒度完全对不上。
更合理的短期上下文管理是按 Token 预算分层分配:系统规则占用固定额度,当前问题占用固定额度,最近消息、摘要、外部证据各自有上限,最后 fit(maxTokens) 统一裁剪。Spring AI 的 MessageWindowChatMemory 提供了窗口的骨架,但预算分配的逻辑需要在应用层补充,或者在 Advisor 层做二次裁剪。
摘要压缩:窗口耗尽之前的主动选择
当窗口快要撑满时,有两个选择:丢掉最旧的消息,或者把最旧的消息压缩成摘要。
MessageWindowChatMemory 默认做的是前者。更工程化的做法是后者:在窗口达到某个阈值时,把即将被淘汰的若干轮交给一个更便宜的模型,压缩成一段摘要,拼到窗口最前面,然后继续滑动。一个实际的效果参考是:压缩后 20 轮对话的 Token 消耗从 8200 降到 1800,成本降低 78%。
但摘要不是事实数据库。摘要由模型生成,可能遗漏否定条件,也可能把不确定的内容写成确定的陈述。高风险信息——金额、权限、审批状态——不应该只存在于摘要里,它们应该回到结构化字段和原始证据。摘要解决的是“模型还记不记得聊过什么”,不解决“系统还记不记得事实是什么”。
三、MessageChatMemoryAdvisor 与 PromptChatMemoryAdvisor:一个语义差别,不是风格差别
知道记忆存在哪里之后,下一个问题是:它怎么进入 Prompt? Spring AI 提供了两个 Advisor 实现,它们的名字很像,但做的事情有本质区别。
MessageChatMemoryAdvisor 把历史消息作为独立的 Message 对象列表注入 Prompt,保留角色边界,符合现代 Chat Completion API 的消息模型。PromptChatMemoryAdvisor 把历史消息序列化成纯文本,拼接到 System Prompt 里。后者的兼容性更好,但丢失了消息的结构性——模型看到的是“一段描述对话的文字”,而不是“user 说过什么、assistant 说过什么”的结构化记录。
Spring AI 2.0 已经移除了 PromptChatMemoryAdvisor,用 MessageChatMemoryAdvisor 替代,builder API 相同,但语义不同。MessageChatMemoryAdvisor 是官方推荐的方向,因为它保留的是真正的 chat messages,而不是 system prompt 里的纯文本。
这个迁移提醒了一件更大的事:记忆的“注入形态”决定了模型如何使用它。 当历史是以结构化消息注入时,模型可以精确区分“这是用户说的”和“这是我自己上一轮说的”;当历史是以纯文本拼进 System Prompt 时,模型面对的是“一段关于对话的描述”,角色边界的感知会弱化。在 Agent 场景中,工具调用消息、助手中间推理、工具结果都有角色差异,结构化注入不是可选项,是必需品。
四、Memory Advisor 在 Advisor 链中的位置:默认在工具循环之外,这是有意的
这是这一篇最容易被忽略、但对 Agent 行为影响最大的一个工程细节。
ToolCallingAdvisor 的默认 order 是 HIGHEST_PRECEDENCE + 300,而 MessageChatMemoryAdvisor 的默认 order 是 HIGHEST_PRECEDENCE + 200。数值越小,在链中越靠外,所以 Memory Advisor 默认位于工具调用循环之外。
这意味着什么?默认情况下,Memory Advisor 在工具循环开始之前读取历史并注入,在工具循环结束之后保存本轮的用户消息和最终回复。它看到的是循环的“入口”和“出口”,看不到中间每一次工具调用的往返。
这个默认设计是有意的。大多数 ChatMemoryRepository 实现(包括 JdbcChatMemoryRepository)不支持工具调用消息:带 tool calls 的 AssistantMessage 和 ToolResponseMessage 在保存时会被静默过滤掉。如果把 Memory Advisor 硬塞进工具循环内部,这些仓库会在每次工具往返时抛出异常或丢失数据。
但默认位置也意味着一个代价:工具调用的中间结果不会进入短期记忆。 如果下一轮对话需要引用“上一轮工具查到的某个具体数据”,而该数据只出现在工具结果中、没有被模型写入最终回复,那么它在短期记忆里是不存在的。
官方提供的另一条路是:把 Memory Advisor 的 order 调到工具循环内侧,同时关闭 ToolCallingAdvisor 的内部会话历史(conversationHistoryEnabled=false)。但正如前面所说,这条路只有在使用支持工具消息的存储后端时才成立。社区的 spring-ai-session 项目采用了事件溯源式的会话存储,原生支持工具消息,被预告将在 2.1 接替内置 ChatMemory。一个务实的原则是:默认行为够用就不要动;确有循环内记忆的需求,直接上支持工具消息的存储方案,而不是手动调 order 然后踩仓库不兼容的坑。
五、长期记忆:写入的准入门槛,比检索的相似度更关键
短期记忆管理的是“最近聊了什么”,长期记忆管理的是“什么值得跨会话记住”。
这里有一个常见的工程误区:把长期记忆等同于“把 ChatMemory 换成 VectorStore”。实际上,VectorStoreChatMemoryAdvisor 只是把检索结果拼进 System Prompt 的一个 Advisor,它不负责决定什么该写入,也不负责过滤检索结果的权限和时间边界。长期记忆的工程核心不在向量库,在写入策略和检索前的结构化过滤。
不是每一句话都值得进入长期记忆
一个可持续的长期记忆系统,写入门槛应该至少包含以下条件之一:用户明确表达了“请记住”或主动保存;信息具有稳定性,不是临时情绪或一次性任务;信息有来源、时间和置信度;不包含不必要的敏感信息;写入后用户可以查看、修改和删除。
用代码表达这个门槛,比用 Prompt 表达更可靠:
public Optional<MemoryCandidate> extract(String conversation, UserIntent intent) {
if (!intent.explicitSaveRequest()) {
return Optional.empty();
}
MemoryCandidate candidate = memoryExtractor.parse(conversation);
if (candidate.sensitive() || candidate.content().length() > 500) {
return Optional.empty();
}
return Optional.of(candidate.withExpiration(Duration.ofDays(180)));
}
“用户喜欢简洁回答”可以作为偏好候选;“用户的身份证号是……”不应该默认写入长期记忆。记忆提取的结果要经过规则校验,必要时请求用户确认后再保存。写入策略也需要显式选择:APPEND_ONLY 保留独立历史,RECONCILE 替换同一 memoryKey 下的活跃值并发出取代事件,DIRECT 直接写入而不做去重判断。
检索:向量相似度不能替代结构化过滤
长期记忆的检索有一个容易被低估的工程要求:先按结构化条件过滤,再做语义相似度检索。
权限、时间、可信度必须在向量检索之前或同时生效。每次请求不是把所有记忆都取出来然后按相似度排序,而是先根据当前问题、用户身份、租户边界召回候选,再按来源、可信度、时间和权限筛选。向量相似度擅长“找语义相关的内容”,但它不擅长也不应该被用来判断“这条记忆该不该给这个用户看”。一个高相似度的记忆如果属于另一个租户,它就不应该出现在候选集里,这不是相似度排序能解决的问题。
Spring AI 的 VectorStore 抽象支持在查询时附加过滤表达式,企业的多租户场景需要在检索请求中动态拼接租户过滤条件,把隔离逻辑下沉到数据库层,在余弦相似度计算之前完成候选裁剪。高相似度不等于有权限,这一点在长期记忆中比在任何 RAG 场景中都更致命,因为长期记忆里存的是用户自己的历史,不是公开文档。
长期记忆的另一个边界是:检索失败时,Agent 应该仍然能完成普通任务。 记忆是增强项,不是依赖项。如果向量库超时或没有命中,系统应该降级为无长期记忆的普通对话,而不是直接失败。
六、记忆与 Planning、Tool Calling 在 Advisor 链中的共存
把这一篇和前两篇放在一起,Advisor 链上会出现三个不同层级的“循环”或“上下文管理者”:
ToolCallingAdvisor 管理工具执行的递归循环,PlanningAdvisor 管理任务级的重规划循环,MessageChatMemoryAdvisor 管理跨轮次的上下文注入与保存。它们的顺序关系不是随意配置的,而是由每个 Advisor 的职责边界决定的。
一个典型的顺序策略是:Memory Advisor 放在最外层(order 最小),它包裹整个工具循环和 Planning 循环。这样,Planning 的每一轮重规划、工具的每一次往返,都在同一个对话上下文之下运行,但中间的工具消息不会被自动写入短期记忆(取决于存储后端是否支持)。
如果 Planning Advisor 需要看到工具循环的每一次迭代,它应该放在 ToolCallingAdvisor 的内侧(order 更大)。如果它只需要包裹整个工具循环、在循环结束后判断是否需要重规划,它应该放在外侧。这就是上一篇说的“任务级 Planning 包裹工具循环,步骤级反思进入循环内部”在 Advisor 链上的具体体现。
一个容易踩的坑是递归 Advisor 中的 Token 用量累积。Planning Advisor 如果是一个递归 Advisor,它在一次 adviseCall 中会执行多次模型调用。如果只返回最后一次的用量,成本核算就是错的。Spring AI 提供了 UsageAccumulator:每轮调用 addRoundResponse 累积用量,最后 applyAccumulatedUsage 写入最终响应。Memory Advisor 虽然通常不是递归的,但当它与递归 Advisor 共存时,必须理解谁在累积、谁在报告——否则你看到的 Token 数字只是循环最后一轮的消耗,不是整条链的真实成本。
七、收束:Memory 的工程本质,是决定“什么不该进上下文”
这一篇从短期记忆的窗口边界,讲到长期记忆的写入门槛,再讲到 Advisor 链中的顺序关系,核心结论可以收束为三句话。
第一,短期记忆的默认窗口不是一个安全的容器。 MessageWindowChatMemory 的截断逻辑在简单多轮对话中够用,但在工具调用密集的 Agent 场景中,窗口可能清空整轮工具痕迹。Token 预算分层和主动摘要压缩,是比“把窗口调大”更可持续的方案。
第二,长期记忆的难点不在检索,在写入和过滤。 向量库只是存储层,VectorStoreChatMemoryAdvisor 只是注入层。真正决定长期记忆质量的,是什么信息有资格被写入、写入时用什么策略去重和取代、检索时用什么结构化条件做前置过滤。
第三,Memory 在 Advisor 链中的位置,是由“它应该看到什么、不应该看到什么”决定的。 默认在工具循环之外,意味着工具调用的中间结果不进入短期记忆;要改变这个行为,代价是存储后端必须支持工具消息。顺序不是调参,是架构决策。
Agent 的五个模块到这里已经拆完了四个:模型、工具、规划、记忆。剩下最后一块拼图——编排框架。前面每一篇都在 Spring AI 的 Advisor 链和 ChatClient 上工作,但还没有系统地回答:一个完整的 Agent 运行时,如何把模型切换、工具注册、记忆后端、规划循环和可观测性组装成一个可部署、可扩展、可运维的应用。Advisor 链是骨架,但骨架之上还有配置、生命周期、多租户、部署和评测。
下一篇进入框架与编排:从 ChatClient 的 Advisor 链,到生产级 Agent 运行时的完整装配。
- 点赞
- 收藏
- 关注作者
评论(0)