开篇先泼盆冷水------SAA 里最容易搞混的,是把"用 Redis 落盘的记忆"当成长期记忆 。本篇先把"短期记忆"这一层讲透:它就是
saver(按threadId隔离的一段会话上下文)。长期记忆(Store,跨会话用户画像)是另一套机制,本篇不展开,后面单独讲。
① 引子:Agent 凭什么记得"上一句"
做后端的都背过这个场景:同一段对话里多轮。"我叫小明,家在杭州" → 下一句"我家在哪" → Agent 答"杭州"。这不是模型自己记性,而是我们把这段会话的上下文重新喂回去了。
难点在于:Agent 不是一行 prompt().user(...) 就完事,它内部要跑工具、要决策下一步节点。光"把历史消息塞回 prompt"在简单 ChatClient 里够用,但到了带图执行、带工具循环、可能还要人工确认的 Agent 这儿,上下文得连"跑到哪一步、工具调了啥、卡在哪个中断点"一起记------这就不是"消息列表"能兜住的了。
一句话定调:短期记忆 = 这一段会话的运行上下文,按 threadId 隔离,归 saver 管。
② 目标
读完你能做到四件事:
- 用
saver给ReactAgent加短期记忆 (同一threadId内多轮不丢); - 说清
threadId为什么是短期记忆的隔离主键 (换threadId即失忆); - 分清
MemorySaver/RedisSaver/MysqlSaver这整个 checkpointer 家族都是短期记忆------Redis/MySQL 只是后端不同,不是"长期"; - 说清 Spring AI
ChatMemory与 SAAsaver真实差在哪。
③ 最小代码
3.1 短期记忆:saver + threadId
核心就两行------构建时挂 .saver(...),调用时带 RunnableConfig 里的 threadId。下面用 MemorySaver;
java
// 短期记忆:快照放在 JVM 堆里,按 threadId 隔离这一段会话
ReactAgent agent = ReactAgent.builder()
.name("short_term")
.model(chatModel)
.systemPrompt("你是一个记住用户偏好的智能助手,回答简洁。")
.saver(new MemorySaver()) // ← 短期记忆(会话级)
.build();
RunnableConfig cfg = RunnableConfig.builder().threadId("u1_s1").build(); // threadId = 短期记忆主键
agent.call("我叫小明,家在杭州,记住了。", cfg);
AssistantMessage r = agent.call("我刚才说我家在哪个城市?", cfg); // 模型知道"杭州"
就这两行,Agent 已经能在 u1_s1 这个会话里跨轮记得住。注意:没有任何"让模型记住"的魔法 ,saver 做的是把这段会话的运行快照存下来,下一轮调用时按 threadId 原样加载回去。
3.2 关键澄清:RedisSaver 是"数据库版短期记忆",不是长期记忆
生产环境你不会用 MemorySaver(重启即失),换成落 Redis 的 RedisSaver:
java
// RedisSaver 只是把"短期记忆"持久化到 Redis,重启/多实例能恢复------但它仍按 threadId 隔离,属于短期记忆
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
@Bean
public RedisSaver redisSaver(RedissonClient redissonClient) {
return RedisSaver.builder()
.redisson(redissonClient)
.stateSerializer(new SpringAIStateSerializer()) // Spring AI 消息兼容的序列化器
.build();
}
ReactAgent agent = ReactAgent.builder()
.name("prod_agent")
.model(chatModel)
.saver(redisSaver) // ← 还是短期记忆,只是后端耐久
.build();
记住这句话 :MemorySaver / RedisSaver / MysqlSaver / ... 这整个 checkpointer 家族,全都是短期记忆 。Redis 只是让短期记忆"更扛造",它不会改变"按 threadId 隔离、管一段会话"的本质。跨会话、跟着用户走的长期画像,是另一套机制(Store),本篇不讲。
同理,落关系库的 MysqlSaver 也是短期记忆:
java
// MysqlSaver:短期记忆落 MySQL,按 threadId 隔离。createOption=CREATE_IF_NOT_EXISTS 会自动建
// GRAPH_THREAD / GRAPH_CHECKPOINT 两张表;DataSource 交给 Spring Boot 自动配置即可。
@Bean
public BaseCheckpointSaver shortTermSaver(DataSource dataSource) {
return MysqlSaver.builder()
.dataSource(dataSource) // javax.sql.DataSource
.stateSerializer(new SpringAIStateSerializer()) // Spring AI 消息兼容的序列化器
.createOption(CreateOption.CREATE_IF_NOT_EXISTS)
.build();
}
3.3 生产后端:JDBC MySQL 的 MysqlSaver
把上面 shortTermSaver 挂进 Agent 即可,业务代码一行不用动------升级"内存→MySQL"只是换后端:
java
ReactAgent agent = ReactAgent.builder()
.name("short_term")
.model(chatModel)
.saver(shortTermSaver) // ← MysqlSaver(JDBC),按 threadId 落 MySQL
.build();
MysqlSaver 启动时会连库、自动建 GRAPH_THREAD / GRAPH_CHECKPOINT 两张表(CREATE_IF_NOT_EXISTS),不建库、不手写 DDL。库要自己先建好(见第 ⑦ 节)。
④ 跑起来:用 REST 接口看清"短期记得住 / 换 threadId 即失忆"
1) 短期记忆------同一个 threadId 连发两句,第二句靠 JDBC 快照记得住(实测返回):
bash
curl -X POST localhost:8080/api/memory/short-term/chat -H 'Content-Type: application/json' \
-d '{"threadId":"t1","message":"我叫小明,家在杭州,记住了。"}'
# {"threadId":"t1","reply":"好的,小明,杭州人~已记下!"}
curl -X POST localhost:8080/api/memory/short-term/chat -H 'Content-Type: application/json' \
-d '{"threadId":"t1","message":"我刚才说我家在哪个城市?"}'
# {"threadId":"t1","reply":"杭州"}
2) 隔离------换 threadId 即失忆(同一个 Agent,不同会话互不可见):
bash
curl -X POST localhost:8080/api/memory/short-term/chat -H 'Content-Type: application/json' \
-d '{"threadId":"t2","message":"我刚才说我家在哪个城市?"}'
# {"threadId":"t2","reply":"抱歉,你还没告诉过我家在哪个城市。"} ← t2 是全新会话,看不到 t1 说的杭州
3) 查存储------直接读 saver 里的检查点,证明数据真落 MySQL:
bash
curl "localhost:8080/api/memory/short-term/history?threadId=t1"
# {"threadId":"t1","hasCheckpoint":true,"checkpointId":"...","nodeId":"...","stateKeys":["messages",...]}
t1 聊过之后 history 能读出一条运行时快照;t2 没聊过则返回 hasCheckpoint:false。你也可以直接 SELECT * FROM GRAPH_CHECKPOINT 在 MySQL 里看到这条记录------短期记忆就是这么落库的。
接口清单:
POST /short-term/chat、GET /short-term/history?threadId=。
⑤ 知识点
5.1 saver 全家桶都是短期
MemorySaver / RedisSaver / FileSystemSaver / MysqlSaver / MongoSaver / PostgresSaver / OracleSaver / VersionedMemorySaver------清一色短期记忆 。区别只在"后端放哪、脆不脆"。RedisSaver/MysqlSaver 只是把短期记忆落盘,别因为它们用了 Redis/MySQL 就误以为是长期。
5.2 隔离主键是 threadId
saver 存的东西按 threadId 分桶。同一个 threadId 多轮共享上下文;换 threadId 就是另一个会话,彼此看不见。这跟后端无关------内存版、Redis 版、MySQL 版都是 threadId 隔离。所以 threadId 必须从登录态/会话标识统一生成,别每轮手搓不同串(否则永远失忆)。
5.3 存的是"运行时快照",不是聊天记录
saver 存的是运行时快照:消息 + 工具输入输出 + 图执行节点 + 中断点。正因为它厚,HITL(人工确认后接着跑)、回滚、取消才成立------这些能力都建立在"能完整恢复一段执行现场"之上。第 5 篇讲 Hooks、后面讲 HITL 时会回头用到这点。
5.4 短期会撑爆上下文窗口
saver 无脑保留这一段会话的全部历史,长对话会把历史越滚越大,最终撑爆 LLM 上下文窗口。这是「上下文工程」(第 8 篇)要解决的------裁剪/总结/压缩。短期记忆解决"记得住",但"记太多"要另外治理。
5.5 模型无状态,记忆是外层加载的
模型本身永远不"记住"任何东西。短期记忆靠 saver 把快照加载回来、重新喂给模型;模型每次调用都是无状态的。理解了这点,就不会去纠结"模型是不是记住了"------它只是每次都拿到了正确的上下文。
⑥ Spring AI 原写法 vs SAA 写法(诚实对照)
你心里一定有锚:ChatClient + ChatMemory 也能多轮。这一节正面比------只比短期这一层。
6.1 Spring AI ChatClient + ChatMemory(锚点)
java
ChatMemory memory = new InMemoryChatMemory(); // 或 RedisChatMemory / JdbcChatMemory(可跨重启)
String answer = ChatClient.create(model)
.prompt()
.advisors(new MessageWindowChatMemoryAdvisor(memory, "u1_s1", 100)) // conversationId = 隔离单位
.user("我刚才说我家在哪个城市?")
.call().content();
ChatMemory 存的是消息列表 (短期),模型仍无状态。配 RedisChatMemory 时消息也能跨重启------和 SAA 的 RedisSaver 是同一层(短期)。
6.2 SAA ReactAgent(saver 短期)
java
ReactAgent agent = ReactAgent.builder()
.model(chatModel)
.saver(mysqlSaver) // 短期:快照级,threadId 隔离(MysqlSaver / RedisSaver 都行)
.build();
6.3 两处本质差异(重点)
| 维度 | Spring AI ChatClient + ChatMemory |
SAA ReactAgent(saver) |
|---|---|---|
| 多轮记忆 | ✅ 消息级(conversationId) | ✅ 快照级(threadId) |
| 短期持久化 | ✅ RedisChatMemory / JdbcChatMemory(消息) |
✅ RedisSaver / MysqlSaver(快照) |
| 存的粒度 | 消息列表 | 运行时快照(消息+工具IO+节点+中断点) |
| 隔离单位 | conversationId | threadId |
| 带来的能力 | 聊得续 | 聊得续 + HITL/回滚/取消(因快照厚) |
逐条拆:
① 短期多轮两边都有,SAA 增量是"快照厚度" 。ChatMemory 只管"聊得续",存的是消息列表;SAA 把短期记忆从"消息列表"升级成"运行时快照",所以 HITL(第 9 篇)、回滚、取消才成立。saver 家族里不管内存/Redis/MySQL,存的都是这同一种快照。
② 别拿"持久化"当成"长期" 。Spring AI 有 RedisChatMemory、SAA 有 RedisSaver,都能跨重启------但它们存的是这段会话 的短期上下文。ChatMemory 的 conversationId 和 saver 的 threadId 都是"会话级"主键,都不是"跨会话用户画像"。真正的长期记忆是另一套(Store,按 namespace+user_id),本篇不展开。
一句话:差异不在"记不记",而在"短期记的是对话上下文(saver 存快照、threadId 隔离),SAA 相对 ChatMemory 的真实增量是快照厚度带来的可恢复/可治理"。
⑦ 踩坑 & 排错
1. threadId 必须每次都传、且一致(短期记忆主键) 忘了传或每次手写不同串,每轮都是新会话,Agent 永远失忆。建议从登录态统一生成。
2. 别把 RedisSaver / MysqlSaver 当长期记忆(本篇最该记住的一点) 它们只是"耐久短期",按 threadId 隔离,跨用户/跨会话不算长期。跨会话用户记忆是另一套(Store),后面单独讲。
3. 短期记忆会撑爆上下文窗口 长对话别无脑保留全部历史,该裁剪/总结时交给「上下文工程」(第 8 篇)。
⑧ 小结 & 预告
本篇要点:
- SAA 的短期记忆 =
saver(checkpointer 家族),按threadId隔离,管一段会话的运行上下文; MemorySaver/RedisSaver/MysqlSaver/ ... 全是短期,Redis/MySQL 只是后端耐久,不是长期;saver存的是运行时快照(消息+工具IO+节点+中断点),所以 HITL/回滚/取消才成立;- 和 Spring AI
ChatMemory诚实对照:短期多轮两边都有,SAA 增量是"快照厚度",不是"多了持久化"。
跨会话、跟着用户走的长期记忆(Store)是另一套机制,本篇刻意没讲,后面单独成文。
下一篇《Hooks 护栏》 :记忆让 Agent 跨轮记得住,但"这个工具要不要调、调之前要不要人工确认、每次调用要不要留审计日志"是另一回事。下篇讲 Agent 级的 Hooks------在 before / after / onError 三个时机给 Agent 套上护栏,把危险的、要留痕的操作管起来。