☰
第07课:Agent会话记忆持久化设计——如何将上下文记忆存入数据库,实现会话中断后恢复、跨设备同步
2026/9/27 22:27:02 网站建设 项目流程

1. 为什么内存版会话记忆一定会翻车

Agent 会话记忆持久化,说白了就是让 Agent 记住「你们聊过什么、干到哪一步了」,并且把这些上下文写进数据库,做到会话中断后能恢复、换台设备还能接着聊。它适合正在做 Java Agent 的开发者、需要多端同步的智能助手团队,以及被「重启就失忆」折磨过的同学。

我见过太多 Demo 阶段的 Agent,上下文全塞在一个ConcurrentHashMap<String, List<Message>>里,本地跑得飞起,一上线就出问题。最典型的三类翻车现场:

第一类是进程重启即失忆。用户让 Agent 写一个文件操作工具类,写到一半服务发了个版,重启后 Agent 一脸茫然,用户只能把需求重新描述一遍。第二类是跨设备断片。用户在电脑端发起了一个复杂编码任务,出门想用手机继续追问进度,结果手机端是全新的空会话,历史记录一条都读不到。第三类更隐蔽——多实例部署时,负载均衡把同一个会话的两次请求打到了不同节点,A 节点有记忆、B 节点没有,Agent 的回答前后矛盾,用户以为它「精神分裂」了。

这三个问题的根因是同一个:会话记忆的生命周期被绑定在了进程内存上,而不是绑定在会话 ID 上。工业级 Agent 的做法是把记忆从内存里「搬出来」,落到 Redis + MySQL 这类外部存储,用 SessionManager 统一调度。这样进程只是无状态的计算单元,谁挂了都不影响记忆,换设备只要带上同一个 sessionId 就能续上。

这节课我就按「表结构 → SessionManager 骨架 → 接入通道 → 验证 → 排障」的顺序,把一套可复制的持久化方案拆开讲。核心结论先放这儿:分层存储、会话隔离、高效读写——短期记忆进 Redis 扛高频读写,长期记忆进 MySQL 保不丢,用 sessionId 串起「用户-会话-记忆」三层关系。

2. 前置准备:TaoToken 统一 Key 与依赖清单

在写 SessionManager 之前,先把模型调用通道和工程依赖准备好。Agent 的每一轮推理都要调模型,如果 Key 管理散落在各个类里,后面做记忆回放和跨设备同步时会非常难排查。我建议统一走 TaoToken 的 API 通道,一个 Key 管所有模型调用,接入地址是https://taotoken.net/api,控制台里可以创建和管理 API Keys。

具体动作分三步。第一步,到官网https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=注册后进入控制台,在 API Keys 页面生成一个 Key,建议按环境区分(dev / prod 各一个),方便出问题时快速定位是哪套环境在刷量。第二步,把 Key 写进配置中心或环境变量,不要硬编码进代码,后面 SessionManager 读取配置时统一从agent.llm.api-key取。第三步,确认你的模型对话通道可用,可以先用模型对话页面手动发一条消息验证 Key 有效,再去写代码。

工程依赖这块,除了 Spring Boot 基础包,需要额外引入 Redis、MySQL 驱动和 Spring JDBC:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.43</version> </dependency>

配置文件里把三样东西配好:MySQL 连接、Redis 连接、TaoToken 的 API Key 与 base URL。这里有个细节,spring.redis在新版 Spring Boot 里已经改名为spring.data.redis,如果你用的是 2.7 以上版本,配置项要跟着改,否则启动时 Redis 相关 Bean 注入会失败,这个坑我在排障章节还会再提。

spring.datasource.url=jdbc:mysql://localhost:3306/agent_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=your_password spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.data.redis.host=localhost spring.data.redis.port=6379 spring.data.redis.database=0 agent.llm.base-url=https://taotoken.net/api agent.llm.api-key=${TAOTOKEN_API_KEY}

注意:API Key 通过环境变量注入,不要提交到 Git 仓库。团队协作时用配置中心下发,避免 Key 泄露后被滥用。

3. 可复制配置:表结构与 SessionManager 骨架

3.1 记忆表结构设计

先把 MySQL 表建好。这张表要同时承载长期记忆和会话元信息,字段设计上我加了memory_type区分短期/长期,加了expire_time支持过期清理,索引建在session_id和user_id上,因为这两个字段是查询的主入口。

CREATE TABLE `agent_memory` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `session_id` varchar(128) NOT NULL COMMENT '会话ID', `user_id` varchar(64) NOT NULL COMMENT '用户ID', `memory_type` tinyint(4) NOT NULL COMMENT '1=短期 2=长期', `role` varchar(16) NOT NULL COMMENT 'user/assistant/tool', `content` mediumtext NOT NULL COMMENT '记忆内容JSON', `token_count` int(11) DEFAULT 0 COMMENT 'token估算', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, `expire_time` datetime DEFAULT NULL COMMENT '过期时间', PRIMARY KEY (`id`), KEY `idx_session` (`session_id`, `create_time`), KEY `idx_user` (`user_id`, `memory_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent会话记忆表';

这里role字段很关键。Agent 的上下文不只是用户说的话,还包括 assistant 的回复和 tool 的调用结果,三者要按时间顺序拼回一个完整的消息列表,才能喂给模型。如果只存 content 不存 role,恢复出来的上下文顺序和角色就乱了,模型会答非所问。

3.2 SessionManager 配置骨架

SessionManager 是整个持久化系统的调度中心,职责有四块:创建会话、写入记忆、恢复会话、跨设备查询。下面是一个可运行的骨架,我把它拆成接口和实现两部分,方便你替换存储方案。

@Component public class SessionManager { @Resource private StringRedisTemplate redisTemplate; @Resource private JdbcTemplate jdbcTemplate; private static final String SHORT_KEY_PREFIX = "agent:mem:short:"; private static final Duration SHORT_TTL = Duration.ofHours(2); /** 创建会话,返回全局唯一 sessionId */ public String createSession(String userId) { String sessionId = "s-" + userId + "-" + UUID.randomUUID().toString().replace("-", ""); // 初始化一条空的短期记忆占位,标记会话已激活 redisTemplate.opsForValue().set(SHORT_KEY_PREFIX + sessionId, "[]", SHORT_TTL); return sessionId; } /** 写入记忆:短期进Redis,长期进MySQL */ public void appendMemory(String sessionId, String userId, String role, String content, boolean longTerm) { MemoryRecord record = new MemoryRecord(sessionId, userId, role, content); if (longTerm) { jdbcTemplate.update( "INSERT INTO agent_memory(session_id,user_id,memory_type,role,content," + "create_time,update_time) VALUES(?,?,?,?,?,?,?)", sessionId, userId, 2, role, content, LocalDateTime.now(), LocalDateTime.now()); } else { String key = SHORT_KEY_PREFIX + sessionId; String existing = redisTemplate.opsForValue().get(key); List<MemoryRecord> list = JSON.parseArray(existing, MemoryRecord.class); list.add(record); redisTemplate.opsForValue().set(key, JSON.toJSONString(list), SHORT_TTL); } } /** 恢复会话:短期 + 长期合并,按时间排序 */ public List<MemoryRecord> restoreSession(String sessionId) { List<MemoryRecord> result = new ArrayList<>(); String shortJson = redisTemplate.opsForValue().get(SHORT_KEY_PREFIX + sessionId); if (shortJson != null) { result.addAll(JSON.parseArray(shortJson, MemoryRecord.class)); } List<MemoryRecord> longList = jdbcTemplate.query( "SELECT session_id AS sessionId, user_id AS userId, role, content, create_time AS createTime " + "FROM agent_memory WHERE session_id=? AND memory_type=2 ORDER BY create_time ASC", new BeanPropertyRowMapper<>(MemoryRecord.class), sessionId); result.addAll(longList); result.sort(Comparator.comparing(MemoryRecord::getCreateTime)); return result; } /** 跨设备同步:查用户所有会话 */ public List<String> listUserSessions(String userId) { return jdbcTemplate.queryForList( "SELECT DISTINCT session_id FROM agent_memory WHERE user_id=? AND memory_type=2", String.class, userId); } }

MemoryRecord就是一个普通 POJO,字段和表结构对应,createTime用LocalDateTime。注意短期记忆我用了「读-改-写」的方式追加,这在单会话低并发下没问题,但如果同一个会话有并发写入,需要换成 Redis 的 List 结构用RPUSH原子追加,避免覆盖丢失。这个点后面排障会展开。

3.3 接入 TaoToken 通道

记忆恢复出来后,要把上下文拼成消息列表发给模型。这里统一走 TaoToken 的 API 通道,base URL 用https://taotoken.net/api,Key 从配置读。下面是一个最小调用示例,把恢复的记忆转成 messages 数组:

public String chatWithMemory(String sessionId, String userId, String userInput) { // 1. 先落库用户输入 appendMemory(sessionId, userId, "user", userInput, true); // 2. 恢复完整上下文 List<MemoryRecord> history = restoreSession(sessionId); List<Map<String, String>> messages = history.stream() .map(r -> Map.of("role", r.getRole(), "content", r.getContent())) .collect(Collectors.toList()); // 3. 调用模型 Map<String, Object> body = Map.of( "model", "claude-sonnet-4-5", "messages", messages, "max_tokens", 2048 ); String resp = webClient.post() .uri(baseUrl + "/v1/messages") .header("x-api-key", apiKey) .header("anthropic-version", "2023-06-01") .bodyValue(body) .retrieve() .bodyToMono(String.class) .block(); // 4. 落库模型回复 String reply = extractText(resp); appendMemory(sessionId, userId, "assistant", reply, true); return reply; }

这样一轮对话结束后,用户输入和模型回复都进了 MySQL,短期记忆在 Redis 里也有副本。下次无论从哪台设备带同一个 sessionId 进来,restoreSession都能把完整上下文拼回来。

4. 验证请求:中断恢复与跨设备读取

配置写完必须验证,不然你不知道记忆到底有没有落库。我一般分三步测。

第一步,验证写入。启动应用后创建一个会话,发一条消息,然后直接查数据库:

SELECT session_id, role, LEFT(content, 50) AS preview, create_time FROM agent_memory WHERE session_id = 's-dev001-xxxx' ORDER BY create_time;

正常应该看到两条记录,一条 role=user,一条 role=assistant,时间戳递增。如果只有一条,说明模型回复没落库,检查appendMemory的调用位置是不是在异常分支里被跳过了。

第二步,验证中断恢复。手动重启应用(模拟服务宕机),然后用同一个 sessionId 调restoreSession,打印返回的消息条数。预期条数和重启前一致,且顺序正确。这一步能过,说明记忆真的持久化了,不是靠内存撑着。

第三步,验证跨设备同步。换一个「设备」——其实就是换一个 HTTP 客户端或另一台机器,带上同一个 userId 调listUserSessions,应该能列出该用户的所有 sessionId。再挑其中一个 sessionId 调restoreSession,能读到完整历史,就说明跨设备读取通了。

# 用 curl 模拟另一台设备读取会话列表 curl -X GET "http://your-host:8080/agent/sessions?userId=dev-001" \ -H "Authorization: Bearer <your-token>"

返回结果类似:

{ "userId": "dev-001", "sessions": ["s-dev001-a1b2c3", "s-dev001-d4e5f6"], "count": 2 }

拿到 sessionId 后再请求/agent/session/{sessionId}/history,返回的消息列表如果和电脑端一致,跨设备同步就算验证通过。实测下来,这套流程在单机 Redis + MySQL 环境下,恢复 100 条记忆的耗时在 20ms 以内,对交互体验基本无感。

5. 本篇常见错排查

5.1 恢复出来的上下文顺序错乱

最常见的原因是短期记忆和长期记忆合并时没排序。Redis 里的短期记忆是追加的,MySQL 里的长期记忆是按插入时间查的,两边拼起来如果不按createTime统一排序,模型收到的消息顺序就是乱的。解决办法就是在restoreSession最后加一次sort,用createTime升序。另外要确保createTime在写入时就固定下来,不要用查询时的当前时间。

5.2 并发写入导致短期记忆丢失

前面提过,短期记忆用「读-改-写」在并发下会丢数据。假设同一会话两个请求同时进来,都读到[msg1],各自追加后写回,后写的会覆盖先写的,结果只剩一条。修复方式有两种:一是改用 Redis List,用RPUSH原子追加,读取时用LRANGE;二是给会话加分布式锁,串行化写入。生产环境我推荐第一种,性能更好。

// 改用 List 结构原子追加 redisTemplate.opsForList().rightPush(SHORT_KEY_PREFIX + sessionId, JSON.toJSONString(record)); redisTemplate.expire(SHORT_KEY_PREFIX + sessionId, SHORT_TTL);

5.3 Redis 配置项不生效

如果你用的是 Spring Boot 2.7 及以上,spring.redis.*已经废弃,要改成spring.data.redis.*。配置没生效时,应用启动不会报错,但连接的是默认 localhost:6379,如果你 Redis 在别的机器上,就会一直连不上。排查方法是启动时看日志里 Redis 的连接地址,或者直接在代码里打印redisTemplate.getConnectionFactory()的配置。

5.4 长期记忆无限增长拖慢查询

agent_memory表只增不删,跑几个月后单表几百万行,restoreSession的查询会越来越慢。解决办法是加定期清理任务,把超过 N 天的长期记忆归档或删除,同时给session_id + create_time建联合索引(前面表结构里已经建了)。清理任务可以用 Spring 的@Scheduled每天凌晨跑一次:

@Scheduled(cron = "0 0 3 * * ?") public void cleanExpired() { jdbcTemplate.update( "DELETE FROM agent_memory WHERE memory_type=2 AND create_time < ?", LocalDateTime.now().minusDays(90)); }

5.5 模型调用失败导致记忆只写了一半

如果用户输入已经落库,但模型调用抛异常,assistant 的回复就不会写入,恢复出来的上下文里 user 消息后面直接跟下一条 user 消息,模型会困惑。建议在chatWithMemory里用 try-catch 包住模型调用,失败时写入一条 role=assistant、content 为错误提示的占位记录,保证消息序列完整。同时把失败请求记到日志,方便后续重放。

6. 下一步:把记忆接进你的 Agent 主循环

到这里,一套可运行的会话记忆持久化方案就搭完了:表结构管长期记忆,Redis 管短期记忆,SessionManager 负责调度,TaoToken 统一通道负责模型调用。你可以先把restoreSession接进 Agent 的主循环,在每轮推理前恢复上下文、推理后追加记忆,跑通「中断-恢复」这条链路。

如果你打算把这套方案用到长期编码或 Agent 场景,建议顺手把 Coding Plan 配上,让模型调用和记忆管理解耦,后续换模型或加工具都不用动存储层。接入过程中遇到 Key 或通道问题,直接翻接入文档对照排查,比在代码里猜要快得多。记忆持久化这层做扎实了,后面加多轮工具调用、任务断点续跑都会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询