【SAA实战】第 4 篇 · Agent 短期记忆:saver 让 Agent 跨轮记得住(threadId 隔离)

开篇先泼盆冷水------SAA 里最容易搞混的,是把"用 Redis 落盘的记忆"当成长期记忆 。本篇先把"短期记忆"这一层讲透:它就是 saver(按 threadId 隔离的一段会话上下文)。长期记忆(Store,跨会话用户画像)是另一套机制,本篇不展开,后面单独讲。

① 引子:Agent 凭什么记得"上一句"

做后端的都背过这个场景:同一段对话里多轮。"我叫小明,家在杭州" → 下一句"我家在哪" → Agent 答"杭州"。这不是模型自己记性,而是我们把这段会话的上下文重新喂回去了

难点在于:Agent 不是一行 prompt().user(...) 就完事,它内部要跑工具、要决策下一步节点。光"把历史消息塞回 prompt"在简单 ChatClient 里够用,但到了带图执行、带工具循环、可能还要人工确认的 Agent 这儿,上下文得连"跑到哪一步、工具调了啥、卡在哪个中断点"一起记------这就不是"消息列表"能兜住的了。

一句话定调:短期记忆 = 这一段会话的运行上下文,按 threadId 隔离,归 saver 管。

② 目标

读完你能做到四件事:

  1. saverReactAgent短期记忆 (同一 threadId 内多轮不丢);
  2. 说清 threadId 为什么是短期记忆的隔离主键 (换 threadId 即失忆);
  3. 分清 MemorySaver / RedisSaver / MysqlSaver 这整个 checkpointer 家族都是短期记忆------Redis/MySQL 只是后端不同,不是"长期";
  4. 说清 Spring AI ChatMemory 与 SAA saver 真实差在哪。

③ 最小代码

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/chatGET /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,都能跨重启------但它们存的是这段会话 的短期上下文。ChatMemoryconversationIdsaverthreadId 都是"会话级"主键,都不是"跨会话用户画像"。真正的长期记忆是另一套(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 套上护栏,把危险的、要留痕的操作管起来。

相关推荐
许彰午17 分钟前
22-DataCenter报文序列化
java·低代码·架构·状态模式
2601_9620652532 分钟前
[MySQL] SQL优化之性能分析
java·sql·mysql
阿kun要赚马内1 小时前
MySQL 索引基础
后端·mysql
小范同学_1 小时前
JDK1.7 与 JDK1.8 HashMap 底层原理对比 + 数组并发扩容死循环详解
java·开发语言
码事漫谈1 小时前
我啥都能做,却不知道做什么了
后端
geovindu1 小时前
CSharp: 万年历
开发语言·后端·c#·.net
予昊2 小时前
从零实现“在线五子棋对战“:WebSocket 实时通信 + 段位匹配
java·开发语言·网络·websocket
2601_962203512 小时前
【SpringAI入门】初识SpringAI
java
weixin_461408582 小时前
Mybatis-flex小记
java·开发语言·mybatis