LangChain4j 使用 MySQL 持久化 ChatMemory
模型不会自动记住上一次请求。所谓“记忆”,本质上是应用在下一轮请求前重新加载历史消息,并把这些消息加入 prompt。进程内记忆实现简单,但应用重启或多实例部署后会丢失状态;将记忆写入 MySQL,可以获得稳定的生命周期、备份和查询能力。
工作原理
一次带记忆的请求通常经过以下步骤:
- 根据用户或会话生成稳定的
memoryId。 - 从持久化仓储读取该 ID 的历史消息。
- 按消息窗口或 Token 预算裁剪历史。
- 将系统消息、历史消息和本轮用户消息组装成请求。
- 调用聊天模型并得到助手消息。
- 把本轮用户消息和助手消息写回仓储。
MySQL 仓储只负责保存和恢复消息,不负责决定上下文保留多长。窗口策略应放在 ChatMemory 层,否则历史会无限增长并最终超过模型上下文限制。
表结构示例
下面的表结构与具体框架无关,可作为自定义 JDBC 仓储的起点:
CREATE TABLE chat_memory_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
memory_id VARCHAR(128) NOT NULL,
message_index BIGINT NOT NULL,
role VARCHAR(32) NOT NULL,
content LONGTEXT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_memory_message (memory_id, message_index),
KEY idx_memory_created (memory_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;message_index 用于保证同一会话内的顺序稳定,LONGTEXT 可以容纳较长消息以及工具调用内容。生产环境还应增加租户字段、过期时间、脱敏或加密策略,并明确删除和保留期限。
依赖与数据源
版本号和持久化模块名称会随 LangChain4j 版本变化,发布前应以目标版本官方文档为准。基础依赖可以这样配置:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
</dependency>spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/ai_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME}
password: ${DB_PASSWORD}接入 ChatMemory
如果目标版本提供官方 JDBC/MySQL 仓储,优先使用它;下面示例用 MysqlChatMemoryStore 表示该仓储,类名和构造器需按实际版本调整:
ChatMemoryStore store = new MysqlChatMemoryStore(dataSource);
ChatMemory memory = MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(20)
.chatMemoryStore(store)
.build();
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.chatMemory(memory)
.build();
String answer = assistant.chat(userMessage);当目标版本没有现成实现时,可以实现 ChatMemoryStore 适配器。适配器至少需要支持按 ID 读取完整消息列表、保存新增消息和删除指定会话。序列化时必须保留角色、文本、工具调用和必要元数据,只保存纯文本会导致下一轮无法正确恢复工具消息。
多用户隔离
不要让一个 ChatMemory 单例承载所有用户的对话。Web 服务应通过 ChatMemoryProvider 或等价工厂按 userId、conversationId 创建独立实例:
ChatMemoryProvider provider = memoryId -> MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(20)
.chatMemoryStore(store)
.build();memoryId 必须经过授权校验,不能直接信任请求参数。否则攻击者只要猜到其他会话标识,就可能读取不属于自己的历史消息。多租户系统还应在查询条件中同时加入租户 ID。
并发写入
同一会话的并发请求必须保证消息序号单调递增。单实例可以使用进程内锁,但多实例部署时应使用数据库事务、悲观锁或带版本号的乐观锁。唯一键冲突后应重新读取最新历史并重试,而不是覆盖已有消息。
建议把“读取历史、生成序号、写入本轮消息”设计成清晰的事务边界,并为失败请求记录可重试状态。数据库写入成功而缓存未失效时,也要有一致性处理策略。
验证与排障
- 重启应用后,用同一个
memoryId提问,模型仍能引用上一轮事实。 - 换用另一个
memoryId,不能看到前一个会话的内容。 - 超过窗口上限后,最旧消息被裁剪,系统消息仍然保留。
- 删除会话后,数据库和缓存中都无法再读取该历史。
- 检查
utf8mb4、时区、连接池上限和慢查询。
常见问题包括只保存用户消息而漏保存助手消息、把无限历史全部放入 prompt、截断工具调用 JSON、将用户 ID 当作可猜测的数据库主键,以及没有处理并发写入冲突。
ChatMemory 与长期记忆
会话历史适合放在 ChatMemory。用户偏好、业务事实和跨会话知识则应经过提取、去重和权限过滤后写入独立的用户画像或检索系统。不要把所有历史消息无差别地长期注入 prompt,这会增加 Token 成本、上下文噪声和隐私风险。
小结
MySQL 持久化解决的是“历史消息可靠保存和恢复”问题,窗口记忆解决的是“本轮请求保留哪些消息”问题,多用户隔离和并发控制则决定了系统能否安全运行。先明确这三个边界,再根据 LangChain4j 目标版本选择官方仓储或自定义适配器,能避免把数据库误当成无限长期记忆。
