改造SpringAI的记忆机制,涵盖三阶段智能裁剪、读时窗口设计、文件系统持久化、五层复合 ID 会话隔离
本文目录 · 42 节
- 01、大模型的记忆
- 02、Spring AI 的记忆机制
- 03、三个生产级问题
- 04、三阶段智能裁剪
- 05、全量存储与读时裁剪
- 06、五层复合 ID 实现会话隔离
- 07、求职派如何写到简历上?
- ending
- 01、大模型的记忆
- 02、Spring AI 的记忆机制
- 03、三个生产级问题
- 04、三阶段智能裁剪
- 05、全量存储与读时裁剪
- 06、五层复合 ID 实现会话隔离
- 07、求职派如何写到简历上?
- ending
- 01、大模型的记忆
- 02、Spring AI 的记忆机制
- 03、三个生产级问题
- 04、三阶段智能裁剪
- 05、全量存储与读时裁剪
- 06、五层复合 ID 实现会话隔离
- 07、求职派如何写到简历上?
- ending
星球:Java进阶之路&二哥编程星球 | 专栏:求职派 OpenClaw 架构 | 作者:沉默王二 | 发布:2026-07-11
改造SpringAI的记忆机制,涵盖三阶段智能裁剪、读时窗口设计、文件系统持久化、五层复合 ID 会话隔离
改造SpringAI的记忆机制,涵盖三阶段智能裁剪、读时窗口设计、文件系统持久化、五层复合 ID 会话隔离
求职派同时对接了微信、钉钉、飞书三个渠道。
用户在微信上说“帮我推荐 Java 岗位”,紧接着追问一句“薪资 25k 以上的”——如果大模型没有记住第一句话,第二句话就变成了一个莫名其妙的筛选条件,结果很可能会乱掉。

多轮对话的底层逻辑其实并不复杂,每次调用大模型时把之前的对话历史一起塞进去就好了。
Spring AI 的 ChatMemory 已经把这件事封装好了。但有三个问题接踵而至:
-
对话轮数一多上下文窗口就容易爆炸
-
直接放内存,上下文就丢了
-
多用户、多渠道的会话会互相串联
这篇内容将从 Spring AI 的 ChatMemory 机制讲起,再拆解求职派在它基础上做的三层改造。
智能裁剪、文件持久化、复合 ID 隔离。
读完你就能搞清楚,一个生产级别的多 Agent 系统到底应该怎么管理聊天上下文。技术派: https://paicoding.com/jobclaw-spring-ai-memory
01、大模型的记忆
大模型本身是无状态的。
每一次 API 调用,对它来说都是一轮全新的对话。它不知道上一轮说了什么,也不关心对面是谁。
那为什么我们和 ChatGPT、DeepSeek 这些产品聊天时,它们似乎“记得”之前的内容?

答案很简单,客户端在每次发消息时,会把之前所有的对话历史一起拼进请求体发给大模型。大模型看到的不只是刚输入的那一句提示词,还有从第一轮到当前轮的完整对话文本。
换个说法,“记忆”不是大模型的能力,是调用方的职责。
谁负责把历史对话管理好、拼接好、传递好,谁就决定了多轮对话的质量。管理得好,大模型就表现得像一个有连贯思路的助手;管理得差,它每一轮都像刚入职的新人,什么都不知道。
Spring AI 把“管理对话历史”这个职责抽象成了一套模块化的记忆机制,来看看它具体怎么工作。
02、Spring AI 的记忆机制
Spring AI 的记忆机制分两层,存储层和注入层。

存储层 决定对话历史存在哪里。
Spring AI 内置了基于内存的实现,底层是 ConcurrentHashMap,开箱即用,服务重启就清零。
如果需要持久化,Spring AI 也提供了 JDBC、Cassandra、Neo4j 三种数据库方案,需要引入对应的 starter 依赖。
为了避免上下文窗口被无限撑大,Spring AI 提供了一个窗口管理器,默认保留最近 20 条消息,超出部分自动丢弃最早的消息(始终保留系统消息)。
窗口大小通过 maxMessages 参数调整,比如设为 10 就只保留最近 10 条。
注入层 决定对话历史怎么塞给大模型。Spring AI 通过 Advisor 机制自动完成这件事,提供了两种注入方式。

第一种是消息列表注入。每次调用大模型前,从存储层读取历史消息,原样加载到请求的消息列表里。
大模型收到的是完整的多轮对话结构,每条消息都带着角色标记(用户/助手/系统),这也是 Spring AI 默认推荐的方式。
第二种是文本拼接注入。把历史对话按“角色-内容”的格式拼成一段纯文本,追加到系统提示词末尾。
大模型收到的是两条消息,一条系统消息(包含历史文本)和一条用户消息。
这种方式信息密度低一些,但对某些不支持多轮消息格式的模型是必要的回退方案。
怎么用呢?
在构建客户端时注册一个记忆 Advisor 就行。
Java
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem("你现在是诗仙李白")
.defaultAdvisors(
MessageChatMemoryAdvisor
.builder(chatMemory).build()
)
.build();
注册之后,每次调用 client.prompt(msg).call() 时,Advisor 会自动完成“读历史 → 注入请求 → 调大模型 → 存新消息”这个完整循环,业务代码不需要手动管理任何对话历史。

如果需要多用户隔离,在调用时传入不同的会话 ID 就可以了。
Java
client.prompt().user(msg)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID, userId))
.call().content();

两种注入方式的差异,从大模型实际收到的数据结构可以直观看出。
消息列表注入是多条独立消息(System + User + Assistant 交替),文本拼接注入是把所有历史压缩进系统消息的 MEMORY 段落:

Spring AI 的这套设计足够简洁,几行代码就能跑起来多轮对话。
但求职派在生产环境真正跑起来之后,仍然遇到了三个问题。
03、三个生产级问题

①、窗口很快就满了
求职派的求职推荐 Agent 一次完整对话经常超过 30 轮。
用户先描述期望岗位,Agent 追问薪资范围和工作地点,再返回推荐结果,用户挑选后再补充岗位详情。
默认 20 条的窗口很快被填满,早期的关键信息被丢弃。比如用户第 3 轮说的“我有 5 年分布式系统经验”,到第 25 轮 Agent 做推荐时已经不在上下文里了。
更棘手的是消息价值差异巨大。
用户回复“好的”“收到”“嗯”这类确认消息占了不少窗口位置,但不携带任何有用信息。默认的窗口管理器不区分消息价值,一视同仁地按时间顺序丢弃,高价值信息和低价值信息被同等对待。
②、重启即失忆
默认的内存存储用 ConcurrentHashMap,服务一重启全部清零。
求职派部署在云服务器上,版本更新、扩缩容、故障恢复都需要重启服务。
用户昨天和 Agent 聊了大半个小时的求职意向,今天打开全没了。对于一个需要持续跟进求职进度的产品来说,每次重启等于给所有用户“失忆”,体验极差。
③、隔离粒度不够
Spring AI 的会话 ID 是一个普通字符串,隔离维度只有一层。但求职派需要同时处理四个维度的隔离,用户之间不能串联、同一用户在不同渠道(微信/钉钉/飞书)不能串联、私聊和群聊不能串联、不同 Agent 之间的对话也不能串联。
一个用户在微信私聊里和求职推荐 Agent 的对话,绝对不能混进他在飞书群里和简历优化 Agent 的会话。
这三个问题推动了求职派对 Spring AI 记忆模块的全面改造。
04、三阶段智能裁剪
求职派没有直接改 Spring AI 的窗口管理器,而是在它外面封装了一层三阶段处理器。

每次从存储层读取对话历史后、注入大模型之前,消息要过三道关卡。
第一阶段,过滤无意义消息。
用户的确认性回复,比如“好的”“收到”“嗯”“ok”“是”,长度不足 5 个字符,不携带任何有效信息。
这一阶段用正则匹配把短消息直接剔除,腾出窗口空间给真正有价值的对话。
注意,只过滤用户发出的确认消息,大模型的回复全部保留。即使大模型的回复很短(比如“收到你的需求,我来帮你匹配”),它也是对上文的确认和状态推进,删掉会断裂上下文的因果关系。
实测数据,一个 30 轮的求职咨询对话里,平均有 6-8 条是用户的确认消息。过滤之后,等效窗口容量增加了 20% 以上,被丢弃的高价值消息明显减少。
第二阶段,按 token 估算截断
大模型的上下文窗口按 token 计费,不按消息条数。
只用条数做限制太粗糙,10 条短消息可能只占 500 token,10 条长消息可能占满 8000 token。求职派在源码里用了一套简洁的估算规则。
Java
// core/.../SmartWindowChatMemory.java
int chineseChars = text.replaceAll(
"[^\\u4e00-\\u9fa5]", "").length();
tokens += chineseChars * 2;
String[] words = englishText.split("\\s+");
tokens += (int) Math.ceil(words.length * 1.3);
tokens += 10;
中文每个字符算 2 个 token,英文每个单词算 1.3 个 token,每条消息加 10 个固定开销。
这个估算故意偏保守,宁可少放一条消息,也不冒 token 溢出的风险。默认的 token 预算是 8000,超出时从最早的消息开始移除,但无论如何保留最近 10 条,确保当前对话的连贯性不被打断。
第三阶段,硬性数量兜底
经过前两阶段后,再做一次数量检查,确保消息总数不超过 20 条。
这一阶段是防御性设计。万一 token 估算偏差导致通过了异常多的消息,数量限制能兜住,防止请求体过大导致 API 调用失败。
三个阶段的所有参数都可以通过配置文件调整,不用改代码。
YAML
agent:
context:
window:
max-messages: 20
keep-recent: 10
max-tokens: 8000
filter-short-messages: true
short-message-threshold: 5
无意义消息被提前清理,剩余消息按实际 token 消耗分配窗口,最后还有数量硬限兜底。
上下文里留下来的每一条消息信息密度都更高,大模型的回答质量自然也跟着上去了。
05、全量存储与读时裁剪
解决了窗口管理的问题,接下来是持久化。
求职派用文件系统替代了默认的内存存储,每个会话对应一个 YAML 文件,路径格式是 workspace/conversations/{用户ID}/chat-{渠道}-{会话哈希}.yaml。
消息以角色-内容的键值对格式序列化。
YAML
- user: |
帮我推荐 Java 岗位,薪资 25k 以上
- assistant: |
收到你的需求,我来帮你匹配合适的岗位
但这里有一个反直觉的设计选择,裁剪发生在读取时,不是写入时。
写入时新消息直接追加到 YAML 文件末尾,不做任何截断或过滤。
文件里保存的永远是从第一轮到最新一轮的完整对话历史,不丢任何一条消息。
只有在读取时——也就是准备调用大模型的那个时刻——才把全量数据交给三阶段裁剪器,返回一个裁剪后的窗口视图。

乍看有点浪费,写入时就裁剪不是更省磁盘空间吗?
但仔细推敲会发现三个实际好处。
-
第一,窗口参数可以随时调整而不需要重新处理历史数据。把 max-messages 从 20 改成 30,已有对话立刻就能读到更多历史轮次。
-
第二,求职派会异步用 LLM 对早期对话做摘要压缩,这需要完整的原始对话作为输入,裁剪过的残缺数据没法用。
-
第三,数据恢复有保底。任何时候裁剪策略出了 bug 或者需要回溯问题,完整历史都在磁盘上。
全量存储不是浪费磁盘,是给自己留了一条退路。
当对话轮数超过阈值(默认 20 轮、且消息数能被 10 整除时触发),系统异步执行一次会话摘要操作。
具体做法是把最早的那批消息(窗口保留的最近 10 条之外的部分)交给 LLM 压缩成一段文字,归档到 workspace/archives/ 目录下。
后续读取对话历史时,这段摘要会作为一条系统消息注入到上下文最前面。
这样即使早期对话已经不在窗口里了,大模型仍然能通过摘要了解之前聊了什么,上下文的连贯性能得到保障。
整个摘要和归档过程都是异步执行的,不阻塞用户的当前对话。写入消息后通过异步任务触发,用户完全无感知。
06、五层复合 ID 实现会话隔离
Spring AI 的会话 ID 只是一个字符串,怎么用、用什么格式完全由应用层决定。
求职派选择把五层信息编码进一个会话 ID,用冒号分隔。
纯文本
格式:{用户ID}:{渠道}:{会话哈希}:{群组标记}:{Agent名称}
示例:2:feishu:a1b2c3d4:0:job-recommend-agent
五层各管一个维度的隔离。

用户 ID 是最外层,不同用户的数据在文件系统上物理隔离,存储路径本身就按用户 ID 分目录,跨用户读取在文件系统层面就被阻断。
渠道层区分微信、钉钉、飞书,同一用户在不同渠道的对话各有独立的 YAML 文件。
会话哈希对应同一渠道下的不同对话窗口——比如用户在微信上同时有私聊和群聊两个入口,各自独立。
群组标记区分一对一私聊和群组对话场景。
Agent 名称确保同一用户同一渠道下,和求职推荐 Agent 的对话不会串到简历优化 Agent 那边。
这个设计让意图路由器在切换 Agent 时非常easy。
当用户从求职推荐场景切换到简历优化场景,新 Agent 自动获得一个全新的、独立的对话上下文,不会被上一个 Agent 的对话历史污染。
同时,系统消息里注入的用户画像信息(从身份系统读取的技能、经验等数据)是跨 Agent 共享的,确保每个 Agent 都了解用户的基本情况。
这套隔离策略的成本很低,只是在生成会话 ID 时多拼了几个字段,存储层和裁剪层完全不需要改动。
但它覆盖了求职派遇到的所有多维隔离需求,三端渠道、群聊私聊、多 Agent 并存,每一种场景都有精确到位的隔离粒度。
07、求职派如何写到简历上?
项目名称:求职派(JobClaw)
项目简介:基于 Spring AI + LangGraph4J 的多 Agent 求职助手系统,对接微信/钉钉/飞书三端,支持意图识别、求职推荐、简历优化等多种 Agent 场景
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J 1.6.0、H2/MySQL、React 19 / Next.js 15
核心职责:
-
基于 Spring AI ChatMemory 接口实现三阶段智能上下文裁剪(短消息过滤 + token 估算截断 + 滑动窗口限制),单次裁剪耗时控制在毫秒级,上下文信息密度提升 20% 以上
-
设计读时裁剪持久化架构,写入保存全量对话历史、读取时动态应用窗口策略,支持窗口参数热调整而不丢失任何历史数据
-
基于文件系统实现 YAML 格式的对话持久化方案,异步触发 LLM 驱动的会话摘要压缩与归档,解决默认内存存储重启丢失的问题
-
设计五层复合会话 ID 体系(用户 + 渠道 + 会话 + 群组 + Agent),实现微信/钉钉/飞书三渠道、私聊群聊双场景、多 Agent 并存的严格会话隔离
-
集成异步会话摘要机制,对话超过阈值自动压缩早期轮次并归档至独立存储,通过系统消息注入摘要保持上下文连贯性,在质量和 token 消耗之间取得平衡
ending
大模型不负责记忆,记忆是工程上的活。
Spring AI 提供了够用的脚手架,存储、注入、窗口管理,几行代码就能跑起来多轮对话。
但从“能跑”到“能在生产环境扛住”,中间隔着三层改造。
智能裁剪让窗口里的每条消息都有价值,全量持久化让数据不随重启消失,复合 ID 让多用户多渠道多 Agent 的会话互不干扰。
【让每一条塞进上下文窗口的消息,都值得占那个位置。】
我们下期见。
求职派同时对接了微信、钉钉、飞书三个渠道。
用户在微信上说“帮我推荐 Java 岗位”,紧接着追问一句“薪资 25k 以上的”——如果大模型没有记住第一句话,第二句话就变成了一个莫名其妙的筛选条件,结果很可能会乱掉。

多轮对话的底层逻辑其实并不复杂,每次调用大模型时把之前的对话历史一起塞进去就好了。
Spring AI 的 ChatMemory 已经把这件事封装好了。但有三个问题接踵而至:
-
对话轮数一多上下文窗口就容易爆炸
-
直接放内存,上下文就丢了
-
多用户、多渠道的会话会互相串联
这篇内容将从 Spring AI 的 ChatMemory 机制讲起,再拆解求职派在它基础上做的三层改造。
智能裁剪、文件持久化、复合 ID 隔离。
读完你就能搞清楚,一个生产级别的多 Agent 系统到底应该怎么管理聊天上下文。技术派: https://paicoding.com/jobclaw-spring-ai-memory
01、大模型的记忆
大模型本身是无状态的。
每一次 API 调用,对它来说都是一轮全新的对话。它不知道上一轮说了什么,也不关心对面是谁。
那为什么我们和 ChatGPT、DeepSeek 这些产品聊天时,它们似乎“记得”之前的内容?

答案很简单,客户端在每次发消息时,会把之前所有的对话历史一起拼进请求体发给大模型。大模型看到的不只是刚输入的那一句提示词,还有从第一轮到当前轮的完整对话文本。
换个说法,“记忆”不是大模型的能力,是调用方的职责。
谁负责把历史对话管理好、拼接好、传递好,谁就决定了多轮对话的质量。管理得好,大模型就表现得像一个有连贯思路的助手;管理得差,它每一轮都像刚入职的新人,什么都不知道。
Spring AI 把“管理对话历史”这个职责抽象成了一套模块化的记忆机制,来看看它具体怎么工作。
02、Spring AI 的记忆机制
Spring AI 的记忆机制分两层,存储层和注入层。

存储层 决定对话历史存在哪里。
Spring AI 内置了基于内存的实现,底层是 ConcurrentHashMap,开箱即用,服务重启就清零。
如果需要持久化,Spring AI 也提供了 JDBC、Cassandra、Neo4j 三种数据库方案,需要引入对应的 starter 依赖。
为了避免上下文窗口被无限撑大,Spring AI 提供了一个窗口管理器,默认保留最近 20 条消息,超出部分自动丢弃最早的消息(始终保留系统消息)。
窗口大小通过 maxMessages 参数调整,比如设为 10 就只保留最近 10 条。
注入层 决定对话历史怎么塞给大模型。Spring AI 通过 Advisor 机制自动完成这件事,提供了两种注入方式。

第一种是消息列表注入。每次调用大模型前,从存储层读取历史消息,原样加载到请求的消息列表里。
大模型收到的是完整的多轮对话结构,每条消息都带着角色标记(用户/助手/系统),这也是 Spring AI 默认推荐的方式。
第二种是文本拼接注入。把历史对话按“角色-内容”的格式拼成一段纯文本,追加到系统提示词末尾。
大模型收到的是两条消息,一条系统消息(包含历史文本)和一条用户消息。
这种方式信息密度低一些,但对某些不支持多轮消息格式的模型是必要的回退方案。
怎么用呢?
在构建客户端时注册一个记忆 Advisor 就行。
Java
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem("你现在是诗仙李白")
.defaultAdvisors(
MessageChatMemoryAdvisor
.builder(chatMemory).build()
)
.build();
注册之后,每次调用 client.prompt(msg).call() 时,Advisor 会自动完成“读历史 → 注入请求 → 调大模型 → 存新消息”这个完整循环,业务代码不需要手动管理任何对话历史。

如果需要多用户隔离,在调用时传入不同的会话 ID 就可以了。
Java
client.prompt().user(msg)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID, userId))
.call().content();

两种注入方式的差异,从大模型实际收到的数据结构可以直观看出。
消息列表注入是多条独立消息(System + User + Assistant 交替),文本拼接注入是把所有历史压缩进系统消息的 MEMORY 段落:

Spring AI 的这套设计足够简洁,几行代码就能跑起来多轮对话。
但求职派在生产环境真正跑起来之后,仍然遇到了三个问题。
03、三个生产级问题

①、窗口很快就满了
求职派的求职推荐 Agent 一次完整对话经常超过 30 轮。
用户先描述期望岗位,Agent 追问薪资范围和工作地点,再返回推荐结果,用户挑选后再补充岗位详情。
默认 20 条的窗口很快被填满,早期的关键信息被丢弃。比如用户第 3 轮说的“我有 5 年分布式系统经验”,到第 25 轮 Agent 做推荐时已经不在上下文里了。
更棘手的是消息价值差异巨大。
用户回复“好的”“收到”“嗯”这类确认消息占了不少窗口位置,但不携带任何有用信息。默认的窗口管理器不区分消息价值,一视同仁地按时间顺序丢弃,高价值信息和低价值信息被同等对待。
②、重启即失忆
默认的内存存储用 ConcurrentHashMap,服务一重启全部清零。
求职派部署在云服务器上,版本更新、扩缩容、故障恢复都需要重启服务。
用户昨天和 Agent 聊了大半个小时的求职意向,今天打开全没了。对于一个需要持续跟进求职进度的产品来说,每次重启等于给所有用户“失忆”,体验极差。
③、隔离粒度不够
Spring AI 的会话 ID 是一个普通字符串,隔离维度只有一层。但求职派需要同时处理四个维度的隔离,用户之间不能串联、同一用户在不同渠道(微信/钉钉/飞书)不能串联、私聊和群聊不能串联、不同 Agent 之间的对话也不能串联。
一个用户在微信私聊里和求职推荐 Agent 的对话,绝对不能混进他在飞书群里和简历优化 Agent 的会话。
这三个问题推动了求职派对 Spring AI 记忆模块的全面改造。
04、三阶段智能裁剪
求职派没有直接改 Spring AI 的窗口管理器,而是在它外面封装了一层三阶段处理器。

每次从存储层读取对话历史后、注入大模型之前,消息要过三道关卡。
第一阶段,过滤无意义消息。
用户的确认性回复,比如“好的”“收到”“嗯”“ok”“是”,长度不足 5 个字符,不携带任何有效信息。
这一阶段用正则匹配把短消息直接剔除,腾出窗口空间给真正有价值的对话。
注意,只过滤用户发出的确认消息,大模型的回复全部保留。即使大模型的回复很短(比如“收到你的需求,我来帮你匹配”),它也是对上文的确认和状态推进,删掉会断裂上下文的因果关系。
实测数据,一个 30 轮的求职咨询对话里,平均有 6-8 条是用户的确认消息。过滤之后,等效窗口容量增加了 20% 以上,被丢弃的高价值消息明显减少。
第二阶段,按 token 估算截断
大模型的上下文窗口按 token 计费,不按消息条数。
只用条数做限制太粗糙,10 条短消息可能只占 500 token,10 条长消息可能占满 8000 token。求职派在源码里用了一套简洁的估算规则。
Java
// core/.../SmartWindowChatMemory.java
int chineseChars = text.replaceAll(
"[^\\u4e00-\\u9fa5]", "").length();
tokens += chineseChars * 2;
String[] words = englishText.split("\\s+");
tokens += (int) Math.ceil(words.length * 1.3);
tokens += 10;
中文每个字符算 2 个 token,英文每个单词算 1.3 个 token,每条消息加 10 个固定开销。
这个估算故意偏保守,宁可少放一条消息,也不冒 token 溢出的风险。默认的 token 预算是 8000,超出时从最早的消息开始移除,但无论如何保留最近 10 条,确保当前对话的连贯性不被打断。
第三阶段,硬性数量兜底
经过前两阶段后,再做一次数量检查,确保消息总数不超过 20 条。
这一阶段是防御性设计。万一 token 估算偏差导致通过了异常多的消息,数量限制能兜住,防止请求体过大导致 API 调用失败。
三个阶段的所有参数都可以通过配置文件调整,不用改代码。
YAML
agent:
context:
window:
max-messages: 20
keep-recent: 10
max-tokens: 8000
filter-short-messages: true
short-message-threshold: 5
无意义消息被提前清理,剩余消息按实际 token 消耗分配窗口,最后还有数量硬限兜底。
上下文里留下来的每一条消息信息密度都更高,大模型的回答质量自然也跟着上去了。
05、全量存储与读时裁剪
解决了窗口管理的问题,接下来是持久化。
求职派用文件系统替代了默认的内存存储,每个会话对应一个 YAML 文件,路径格式是 workspace/conversations/{用户ID}/chat-{渠道}-{会话哈希}.yaml。
消息以角色-内容的键值对格式序列化。
YAML
- user: |
帮我推荐 Java 岗位,薪资 25k 以上
- assistant: |
收到你的需求,我来帮你匹配合适的岗位
但这里有一个反直觉的设计选择,裁剪发生在读取时,不是写入时。
写入时新消息直接追加到 YAML 文件末尾,不做任何截断或过滤。
文件里保存的永远是从第一轮到最新一轮的完整对话历史,不丢任何一条消息。
只有在读取时——也就是准备调用大模型的那个时刻——才把全量数据交给三阶段裁剪器,返回一个裁剪后的窗口视图。

乍看有点浪费,写入时就裁剪不是更省磁盘空间吗?
但仔细推敲会发现三个实际好处。
-
第一,窗口参数可以随时调整而不需要重新处理历史数据。把 max-messages 从 20 改成 30,已有对话立刻就能读到更多历史轮次。
-
第二,求职派会异步用 LLM 对早期对话做摘要压缩,这需要完整的原始对话作为输入,裁剪过的残缺数据没法用。
-
第三,数据恢复有保底。任何时候裁剪策略出了 bug 或者需要回溯问题,完整历史都在磁盘上。
全量存储不是浪费磁盘,是给自己留了一条退路。
当对话轮数超过阈值(默认 20 轮、且消息数能被 10 整除时触发),系统异步执行一次会话摘要操作。
具体做法是把最早的那批消息(窗口保留的最近 10 条之外的部分)交给 LLM 压缩成一段文字,归档到 workspace/archives/ 目录下。
后续读取对话历史时,这段摘要会作为一条系统消息注入到上下文最前面。
这样即使早期对话已经不在窗口里了,大模型仍然能通过摘要了解之前聊了什么,上下文的连贯性能得到保障。
整个摘要和归档过程都是异步执行的,不阻塞用户的当前对话。写入消息后通过异步任务触发,用户完全无感知。
06、五层复合 ID 实现会话隔离
Spring AI 的会话 ID 只是一个字符串,怎么用、用什么格式完全由应用层决定。
求职派选择把五层信息编码进一个会话 ID,用冒号分隔。
纯文本
格式:{用户ID}:{渠道}:{会话哈希}:{群组标记}:{Agent名称}
示例:2:feishu:a1b2c3d4:0:job-recommend-agent
五层各管一个维度的隔离。

用户 ID 是最外层,不同用户的数据在文件系统上物理隔离,存储路径本身就按用户 ID 分目录,跨用户读取在文件系统层面就被阻断。
渠道层区分微信、钉钉、飞书,同一用户在不同渠道的对话各有独立的 YAML 文件。
会话哈希对应同一渠道下的不同对话窗口——比如用户在微信上同时有私聊和群聊两个入口,各自独立。
群组标记区分一对一私聊和群组对话场景。
Agent 名称确保同一用户同一渠道下,和求职推荐 Agent 的对话不会串到简历优化 Agent 那边。
这个设计让意图路由器在切换 Agent 时非常easy。
当用户从求职推荐场景切换到简历优化场景,新 Agent 自动获得一个全新的、独立的对话上下文,不会被上一个 Agent 的对话历史污染。
同时,系统消息里注入的用户画像信息(从身份系统读取的技能、经验等数据)是跨 Agent 共享的,确保每个 Agent 都了解用户的基本情况。
这套隔离策略的成本很低,只是在生成会话 ID 时多拼了几个字段,存储层和裁剪层完全不需要改动。
但它覆盖了求职派遇到的所有多维隔离需求,三端渠道、群聊私聊、多 Agent 并存,每一种场景都有精确到位的隔离粒度。
07、求职派如何写到简历上?
项目名称:求职派(JobClaw)
项目简介:基于 Spring AI + LangGraph4J 的多 Agent 求职助手系统,对接微信/钉钉/飞书三端,支持意图识别、求职推荐、简历优化等多种 Agent 场景
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J 1.6.0、H2/MySQL、React 19 / Next.js 15
核心职责:
-
基于 Spring AI ChatMemory 接口实现三阶段智能上下文裁剪(短消息过滤 + token 估算截断 + 滑动窗口限制),单次裁剪耗时控制在毫秒级,上下文信息密度提升 20% 以上
-
设计读时裁剪持久化架构,写入保存全量对话历史、读取时动态应用窗口策略,支持窗口参数热调整而不丢失任何历史数据
-
基于文件系统实现 YAML 格式的对话持久化方案,异步触发 LLM 驱动的会话摘要压缩与归档,解决默认内存存储重启丢失的问题
-
设计五层复合会话 ID 体系(用户 + 渠道 + 会话 + 群组 + Agent),实现微信/钉钉/飞书三渠道、私聊群聊双场景、多 Agent 并存的严格会话隔离
-
集成异步会话摘要机制,对话超过阈值自动压缩早期轮次并归档至独立存储,通过系统消息注入摘要保持上下文连贯性,在质量和 token 消耗之间取得平衡
ending
大模型不负责记忆,记忆是工程上的活。
Spring AI 提供了够用的脚手架,存储、注入、窗口管理,几行代码就能跑起来多轮对话。
但从“能跑”到“能在生产环境扛住”,中间隔着三层改造。
智能裁剪让窗口里的每条消息都有价值,全量持久化让数据不随重启消失,复合 ID 让多用户多渠道多 Agent 的会话互不干扰。
【让每一条塞进上下文窗口的消息,都值得占那个位置。】
我们下期见。
求职派同时对接了微信、钉钉、飞书三个渠道。
用户在微信上说“帮我推荐 Java 岗位”,紧接着追问一句“薪资 25k 以上的”——如果大模型没有记住第一句话,第二句话就变成了一个莫名其妙的筛选条件,结果很可能会乱掉。

多轮对话的底层逻辑其实并不复杂,每次调用大模型时把之前的对话历史一起塞进去就好了。
Spring AI 的 ChatMemory 已经把这件事封装好了。但有三个问题接踵而至:
-
对话轮数一多上下文窗口就容易爆炸
-
直接放内存,上下文就丢了
-
多用户、多渠道的会话会互相串联
这篇内容将从 Spring AI 的 ChatMemory 机制讲起,再拆解求职派在它基础上做的三层改造。
智能裁剪、文件持久化、复合 ID 隔离。
读完你就能搞清楚,一个生产级别的多 Agent 系统到底应该怎么管理聊天上下文。技术派: https://paicoding.com/jobclaw-spring-ai-memory
01、大模型的记忆
大模型本身是无状态的。
每一次 API 调用,对它来说都是一轮全新的对话。它不知道上一轮说了什么,也不关心对面是谁。
那为什么我们和 ChatGPT、DeepSeek 这些产品聊天时,它们似乎“记得”之前的内容?

答案很简单,客户端在每次发消息时,会把之前所有的对话历史一起拼进请求体发给大模型。大模型看到的不只是刚输入的那一句提示词,还有从第一轮到当前轮的完整对话文本。
换个说法,“记忆”不是大模型的能力,是调用方的职责。
谁负责把历史对话管理好、拼接好、传递好,谁就决定了多轮对话的质量。管理得好,大模型就表现得像一个有连贯思路的助手;管理得差,它每一轮都像刚入职的新人,什么都不知道。
Spring AI 把“管理对话历史”这个职责抽象成了一套模块化的记忆机制,来看看它具体怎么工作。
02、Spring AI 的记忆机制
Spring AI 的记忆机制分两层,存储层和注入层。

存储层 决定对话历史存在哪里。
Spring AI 内置了基于内存的实现,底层是 ConcurrentHashMap,开箱即用,服务重启就清零。
如果需要持久化,Spring AI 也提供了 JDBC、Cassandra、Neo4j 三种数据库方案,需要引入对应的 starter 依赖。
为了避免上下文窗口被无限撑大,Spring AI 提供了一个窗口管理器,默认保留最近 20 条消息,超出部分自动丢弃最早的消息(始终保留系统消息)。
窗口大小通过 maxMessages 参数调整,比如设为 10 就只保留最近 10 条。
注入层 决定对话历史怎么塞给大模型。Spring AI 通过 Advisor 机制自动完成这件事,提供了两种注入方式。

第一种是消息列表注入。每次调用大模型前,从存储层读取历史消息,原样加载到请求的消息列表里。
大模型收到的是完整的多轮对话结构,每条消息都带着角色标记(用户/助手/系统),这也是 Spring AI 默认推荐的方式。
第二种是文本拼接注入。把历史对话按“角色-内容”的格式拼成一段纯文本,追加到系统提示词末尾。
大模型收到的是两条消息,一条系统消息(包含历史文本)和一条用户消息。
这种方式信息密度低一些,但对某些不支持多轮消息格式的模型是必要的回退方案。
怎么用呢?
在构建客户端时注册一个记忆 Advisor 就行。
Java
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem("你现在是诗仙李白")
.defaultAdvisors(
MessageChatMemoryAdvisor
.builder(chatMemory).build()
)
.build();
注册之后,每次调用 client.prompt(msg).call() 时,Advisor 会自动完成“读历史 → 注入请求 → 调大模型 → 存新消息”这个完整循环,业务代码不需要手动管理任何对话历史。

如果需要多用户隔离,在调用时传入不同的会话 ID 就可以了。
Java
client.prompt().user(msg)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID, userId))
.call().content();

两种注入方式的差异,从大模型实际收到的数据结构可以直观看出。
消息列表注入是多条独立消息(System + User + Assistant 交替),文本拼接注入是把所有历史压缩进系统消息的 MEMORY 段落:

Spring AI 的这套设计足够简洁,几行代码就能跑起来多轮对话。
但求职派在生产环境真正跑起来之后,仍然遇到了三个问题。
03、三个生产级问题

①、窗口很快就满了
求职派的求职推荐 Agent 一次完整对话经常超过 30 轮。
用户先描述期望岗位,Agent 追问薪资范围和工作地点,再返回推荐结果,用户挑选后再补充岗位详情。
默认 20 条的窗口很快被填满,早期的关键信息被丢弃。比如用户第 3 轮说的“我有 5 年分布式系统经验”,到第 25 轮 Agent 做推荐时已经不在上下文里了。
更棘手的是消息价值差异巨大。
用户回复“好的”“收到”“嗯”这类确认消息占了不少窗口位置,但不携带任何有用信息。默认的窗口管理器不区分消息价值,一视同仁地按时间顺序丢弃,高价值信息和低价值信息被同等对待。
②、重启即失忆
默认的内存存储用 ConcurrentHashMap,服务一重启全部清零。
求职派部署在云服务器上,版本更新、扩缩容、故障恢复都需要重启服务。
用户昨天和 Agent 聊了大半个小时的求职意向,今天打开全没了。对于一个需要持续跟进求职进度的产品来说,每次重启等于给所有用户“失忆”,体验极差。
③、隔离粒度不够
Spring AI 的会话 ID 是一个普通字符串,隔离维度只有一层。但求职派需要同时处理四个维度的隔离,用户之间不能串联、同一用户在不同渠道(微信/钉钉/飞书)不能串联、私聊和群聊不能串联、不同 Agent 之间的对话也不能串联。
一个用户在微信私聊里和求职推荐 Agent 的对话,绝对不能混进他在飞书群里和简历优化 Agent 的会话。
这三个问题推动了求职派对 Spring AI 记忆模块的全面改造。
04、三阶段智能裁剪
求职派没有直接改 Spring AI 的窗口管理器,而是在它外面封装了一层三阶段处理器。

每次从存储层读取对话历史后、注入大模型之前,消息要过三道关卡。
第一阶段,过滤无意义消息。
用户的确认性回复,比如“好的”“收到”“嗯”“ok”“是”,长度不足 5 个字符,不携带任何有效信息。
这一阶段用正则匹配把短消息直接剔除,腾出窗口空间给真正有价值的对话。
注意,只过滤用户发出的确认消息,大模型的回复全部保留。即使大模型的回复很短(比如“收到你的需求,我来帮你匹配”),它也是对上文的确认和状态推进,删掉会断裂上下文的因果关系。
实测数据,一个 30 轮的求职咨询对话里,平均有 6-8 条是用户的确认消息。过滤之后,等效窗口容量增加了 20% 以上,被丢弃的高价值消息明显减少。
第二阶段,按 token 估算截断
大模型的上下文窗口按 token 计费,不按消息条数。
只用条数做限制太粗糙,10 条短消息可能只占 500 token,10 条长消息可能占满 8000 token。求职派在源码里用了一套简洁的估算规则。
Java
// core/.../SmartWindowChatMemory.java
int chineseChars = text.replaceAll(
"[^\\u4e00-\\u9fa5]", "").length();
tokens += chineseChars * 2;
String[] words = englishText.split("\\s+");
tokens += (int) Math.ceil(words.length * 1.3);
tokens += 10;
中文每个字符算 2 个 token,英文每个单词算 1.3 个 token,每条消息加 10 个固定开销。
这个估算故意偏保守,宁可少放一条消息,也不冒 token 溢出的风险。默认的 token 预算是 8000,超出时从最早的消息开始移除,但无论如何保留最近 10 条,确保当前对话的连贯性不被打断。
第三阶段,硬性数量兜底
经过前两阶段后,再做一次数量检查,确保消息总数不超过 20 条。
这一阶段是防御性设计。万一 token 估算偏差导致通过了异常多的消息,数量限制能兜住,防止请求体过大导致 API 调用失败。
三个阶段的所有参数都可以通过配置文件调整,不用改代码。
YAML
agent:
context:
window:
max-messages: 20
keep-recent: 10
max-tokens: 8000
filter-short-messages: true
short-message-threshold: 5
无意义消息被提前清理,剩余消息按实际 token 消耗分配窗口,最后还有数量硬限兜底。
上下文里留下来的每一条消息信息密度都更高,大模型的回答质量自然也跟着上去了。
05、全量存储与读时裁剪
解决了窗口管理的问题,接下来是持久化。
求职派用文件系统替代了默认的内存存储,每个会话对应一个 YAML 文件,路径格式是 workspace/conversations/{用户ID}/chat-{渠道}-{会话哈希}.yaml。
消息以角色-内容的键值对格式序列化。
YAML
- user: |
帮我推荐 Java 岗位,薪资 25k 以上
- assistant: |
收到你的需求,我来帮你匹配合适的岗位
但这里有一个反直觉的设计选择,裁剪发生在读取时,不是写入时。
写入时新消息直接追加到 YAML 文件末尾,不做任何截断或过滤。
文件里保存的永远是从第一轮到最新一轮的完整对话历史,不丢任何一条消息。
只有在读取时——也就是准备调用大模型的那个时刻——才把全量数据交给三阶段裁剪器,返回一个裁剪后的窗口视图。

乍看有点浪费,写入时就裁剪不是更省磁盘空间吗?
但仔细推敲会发现三个实际好处。
-
第一,窗口参数可以随时调整而不需要重新处理历史数据。把 max-messages 从 20 改成 30,已有对话立刻就能读到更多历史轮次。
-
第二,求职派会异步用 LLM 对早期对话做摘要压缩,这需要完整的原始对话作为输入,裁剪过的残缺数据没法用。
-
第三,数据恢复有保底。任何时候裁剪策略出了 bug 或者需要回溯问题,完整历史都在磁盘上。
全量存储不是浪费磁盘,是给自己留了一条退路。
当对话轮数超过阈值(默认 20 轮、且消息数能被 10 整除时触发),系统异步执行一次会话摘要操作。
具体做法是把最早的那批消息(窗口保留的最近 10 条之外的部分)交给 LLM 压缩成一段文字,归档到 workspace/archives/ 目录下。
后续读取对话历史时,这段摘要会作为一条系统消息注入到上下文最前面。
这样即使早期对话已经不在窗口里了,大模型仍然能通过摘要了解之前聊了什么,上下文的连贯性能得到保障。
整个摘要和归档过程都是异步执行的,不阻塞用户的当前对话。写入消息后通过异步任务触发,用户完全无感知。
06、五层复合 ID 实现会话隔离
Spring AI 的会话 ID 只是一个字符串,怎么用、用什么格式完全由应用层决定。
求职派选择把五层信息编码进一个会话 ID,用冒号分隔。
纯文本
格式:{用户ID}:{渠道}:{会话哈希}:{群组标记}:{Agent名称}
示例:2:feishu:a1b2c3d4:0:job-recommend-agent
五层各管一个维度的隔离。

用户 ID 是最外层,不同用户的数据在文件系统上物理隔离,存储路径本身就按用户 ID 分目录,跨用户读取在文件系统层面就被阻断。
渠道层区分微信、钉钉、飞书,同一用户在不同渠道的对话各有独立的 YAML 文件。
会话哈希对应同一渠道下的不同对话窗口——比如用户在微信上同时有私聊和群聊两个入口,各自独立。
群组标记区分一对一私聊和群组对话场景。
Agent 名称确保同一用户同一渠道下,和求职推荐 Agent 的对话不会串到简历优化 Agent 那边。
这个设计让意图路由器在切换 Agent 时非常easy。
当用户从求职推荐场景切换到简历优化场景,新 Agent 自动获得一个全新的、独立的对话上下文,不会被上一个 Agent 的对话历史污染。
同时,系统消息里注入的用户画像信息(从身份系统读取的技能、经验等数据)是跨 Agent 共享的,确保每个 Agent 都了解用户的基本情况。
这套隔离策略的成本很低,只是在生成会话 ID 时多拼了几个字段,存储层和裁剪层完全不需要改动。
但它覆盖了求职派遇到的所有多维隔离需求,三端渠道、群聊私聊、多 Agent 并存,每一种场景都有精确到位的隔离粒度。
07、求职派如何写到简历上?
项目名称:求职派(JobClaw)
项目简介:基于 Spring AI + LangGraph4J 的多 Agent 求职助手系统,对接微信/钉钉/飞书三端,支持意图识别、求职推荐、简历优化等多种 Agent 场景
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4J 1.6.0、H2/MySQL、React 19 / Next.js 15
核心职责:
-
基于 Spring AI ChatMemory 接口实现三阶段智能上下文裁剪(短消息过滤 + token 估算截断 + 滑动窗口限制),单次裁剪耗时控制在毫秒级,上下文信息密度提升 20% 以上
-
设计读时裁剪持久化架构,写入保存全量对话历史、读取时动态应用窗口策略,支持窗口参数热调整而不丢失任何历史数据
-
基于文件系统实现 YAML 格式的对话持久化方案,异步触发 LLM 驱动的会话摘要压缩与归档,解决默认内存存储重启丢失的问题
-
设计五层复合会话 ID 体系(用户 + 渠道 + 会话 + 群组 + Agent),实现微信/钉钉/飞书三渠道、私聊群聊双场景、多 Agent 并存的严格会话隔离
-
集成异步会话摘要机制,对话超过阈值自动压缩早期轮次并归档至独立存储,通过系统消息注入摘要保持上下文连贯性,在质量和 token 消耗之间取得平衡
ending
大模型不负责记忆,记忆是工程上的活。
Spring AI 提供了够用的脚手架,存储、注入、窗口管理,几行代码就能跑起来多轮对话。
但从“能跑”到“能在生产环境扛住”,中间隔着三层改造。
智能裁剪让窗口里的每条消息都有价值,全量持久化让数据不随重启消失,复合 ID 让多用户多渠道多 Agent 的会话互不干扰。
【让每一条塞进上下文窗口的消息,都值得占那个位置。】
我们下期见。