Spring AI Chat Memory 实战:用 JDBC 给 Java Agent 加会话记忆
摘要:很多 Java AI Demo 都是一次性问答:用户问一句,模型回一句,请求结束后上下文也没了。真实业务里的客服助手、需求分析助手、运维问答 Agent,往往需要按会话保存历史消息,让第二轮、第三轮回答能接上前面的语境。本文基于 Spring AI 官方 Chat Memory / Advisor 文档,演示如何用 MessageWindowChatMemory、JdbcChatMemoryRepository 和 MessageChatMemoryAdvisor 给 Spring Boot + Spring AI 应用加上 JDBC 会话记忆,并补充会话隔离、记忆窗口、表结构、调用日志和生产边界。
验证状态:本文机制来自 Spring AI 官方
Chat Memory、Advisors、ChatClient文档,当前机器没有 JDK / Maven,代码未在本机编译运行。示例按官方机制整理为工程骨架,具体依赖坐标、包名和 API 签名请以你项目实际 Spring AI 版本、IDE 提示和官方文档为准。本文重点是工程接入方式和生产边界,不把未编译样例写成"已本地跑通"。
1. 为什么 Java Agent 需要 Chat Memory
假设你做了一个内部知识库助手,第一轮用户问:
我们项目的退款流程怎么走?
模型根据知识库回答了退款流程。第二轮用户继续问:
那超时了怎么办?
如果没有会话记忆,模型只看到"超时了怎么办",很可能不知道"超时"指的是退款审批超时、接口超时、支付回调超时,还是 MQ 消费超时。
所以 Chat Memory 解决的不是"模型更聪明",而是让应用层把同一个会话里的上下文按规则带回模型:
用户 A 的 conversationId
-> 历史用户消息
-> 历史助手回答
-> 当前用户问题
-> 拼成当前 Prompt
-> 模型回答
-> 新消息写回记忆
在 Java 后端里,这件事至少有三个工程问题:
| 问题 | 如果不处理会怎样 |
|---|---|
| 会话隔离 | 不同用户、不同工单、不同浏览器窗口的上下文混在一起 |
| 记忆窗口 | 历史消息无限增长,Token 成本和响应延迟不断上升 |
| 持久化 | 服务重启后上下文丢失,多实例部署时记忆不一致 |
| 数据安全 | 用户隐私、内部字段、敏感信息被长期保存 |
| 可观测性 | 线上回答异常时,不知道模型到底拿到了哪些上下文 |
Spring AI 的 Chat Memory 把这几个问题拆成了两个层次:
ChatMemory:决定当前会话保留哪些消息,例如窗口大小;ChatMemoryRepository:决定消息存在哪里,例如内存、JDBC。
2. 核心组件怎么分工
先把几个名字讲清楚,否则后面代码容易混。
| 组件 | 作用 | 可以类比成 |
|---|---|---|
ChatClient |
负责发起模型调用 | AI 调用入口 |
Advisor |
在调用前后增强请求/响应 | 拦截器 / Filter |
MessageChatMemoryAdvisor |
自动把历史消息加入请求,并在调用后写回记忆 | 会话记忆拦截器 |
ChatMemory |
管理某个会话可用的消息窗口 | 记忆管理器 |
MessageWindowChatMemory |
只保留最近 N 条消息 | 滑动窗口记忆 |
ChatMemoryRepository |
持久化消息 | Repository 层 |
JdbcChatMemoryRepository |
把消息写入数据库 | JDBC 存储实现 |
ChatMemory.CONVERSATION_ID |
当前请求对应的会话 ID 参数 | 会话路由键 |
典型调用链是这样的:
Controller
-> ChatClient.prompt()
-> advisors(param conversationId)
-> MessageChatMemoryAdvisor 读取历史消息
-> ChatModel 调用大模型
-> MessageChatMemoryAdvisor 写入用户消息和模型回答
-> JdbcChatMemoryRepository 持久化
这套机制的好处是业务代码不需要每次手动查询历史消息、拼 Prompt、再写入数据库。只要给当前请求传入稳定的 conversationId,Advisor 会帮你做大部分重复工作。
3. 依赖和配置:先准备 Spring Boot + Spring AI + JDBC
下面是一个工程骨架。依赖坐标请按你的 Spring AI 版本调整,核心方向是:
- Spring Boot Web;
- Spring AI Chat Model Starter,例如 OpenAI / Ollama / Azure OpenAI 等;
- Spring AI JDBC Chat Memory Repository;
- H2 或 PostgreSQL / MySQL 驱动。
Maven 示例骨架:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 示例:使用 OpenAI 模型。也可以替换为 Ollama、Azure OpenAI 等。 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- JDBC Chat Memory Repository。具体 artifactId 以当前 Spring AI 官方文档为准。 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
application.yml 示例:
spring:
application:
name: spring-ai-chat-memory-demo
datasource:
url: jdbc:h2:mem:ai_memory;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
如果你在线上使用 PostgreSQL,可以把数据源换成:
spring:
datasource:
url: jdbc:postgresql://127.0.0.1:5432/ai_memory
username: ai_user
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
注意:这里的模型配置只是示例。生产环境通常还要补超时、重试、限流、审计日志、成本统计和模型降级。
4. 表结构:会话记忆到底存什么
Spring AI 官方 JDBC Repository 会维护消息持久化表。不同版本的自动建表方式和字段名可能略有差异,核心数据一般围绕这几类字段:
| 字段 | 含义 |
|---|---|
conversation_id |
会话 ID,用来隔离不同上下文 |
content |
消息内容 |
type / message_type |
用户消息、助手消息、系统消息等类型 |
sequence_id |
同一会话内的消息顺序,用于按对话顺序读取 |
created_at / timestamp |
写入时间,用于展示和审计;官方文档提到时间可从消息 metadata 中读取 |
metadata |
可能保存额外元数据 |
Spring AI 官方文档还提醒:JDBC Chat Memory 会按升序取回消息,适合作为 LLM conversation history;但它不适合保存 Tool Calling 的工具调用消息和工具响应消息,相关消息可能不会进入最终取回的会话历史。
为了理解机制,可以把它想成下面这种简化表:
sql
`CREATE TABLE ai_chat_memory (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
conversation_id VARCHAR(128) NOT NULL,
message_type VARCHAR(32) NOT NULL,
content CLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_ai_chat_memory_conversation
ON ai_chat_memory(conversation_id, created_at);
`
说明:上面是便于理解的简化表,不是要求你照抄覆盖 Spring AI 官方初始化脚本。生产项目应优先使用当前版本官方提供的 schema 或自动初始化能力,并把表结构纳入数据库迁移工具,例如 Flyway / Liquibase。
如果你自己设计扩展表,至少要保证:
conversation_id有索引;- 按时间可排序;
- 能区分 user / assistant / system;
- 支持按租户、用户或业务域隔离;
- 敏感字段不要无限期保留。
5. 定义 ChatMemory Bean
下面是核心配置类。我们把 JDBC Repository 注入进来,再构造一个窗口型记忆:
java
`package com.example.aimemory.config;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiMemoryConfig {
@Bean
public ChatMemory chatMemory(JdbcChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(10)
.build();
}
}
`
这里最重要的是 maxMessages(10)。
它表示每个会话最多拿最近 10 条消息参与上下文。为什么不是无限?因为历史越长,问题越多:
| 历史消息过长的问题 | 影响 |
|---|---|
| Token 成本上升 | 每次请求都带大量历史,调用成本变高 |
| 响应变慢 | Prompt 变长,模型推理时间增加 |
| 噪声变多 | 很早以前的信息可能干扰当前问题 |
| 隐私风险增加 | 敏感内容长期进入模型上下文 |
所以 Chat Memory 不是"存得越多越好",而是要保留对当前回答最有用的一小段历史。
6. 给 ChatClient 加上 MessageChatMemoryAdvisor
接下来创建一个带默认 Advisor 的 ChatClient:
java
`package com.example.aimemory.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultSystem("""
你是一个面向 Java 后端开发者的技术助手。
回答要基于上下文,但不要编造用户没有提供的信息。
如果上下文不足,要明确说明需要补充什么。
""")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
}
}
`
MessageChatMemoryAdvisor 做两件事:
- 调用模型前,根据当前
conversationId读取历史消息,并放进请求上下文; - 调用完成后,把本轮用户输入和模型回答写回
ChatMemory。
如果你不用 Advisor,也可以手动 chatMemory.get(conversationId)、拼 Prompt、再 chatMemory.add(...)。但在业务系统里,这很容易散落到多个 Service 里,后期不好维护。
7. Controller:每次请求传入 conversationId
现在写一个最小接口:
java
`package com.example.aimemory.web;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/agent")
public class AgentChatController {
private final ChatClient chatClient;
public AgentChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@PostMapping("/chat")
public ChatResponse chat(
@RequestHeader("X-Conversation-Id") String conversationId,
@RequestBody ChatRequest request
) {
String content = chatClient.prompt()
.user(request.message())
.advisors(advisor -> advisor
.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
return new ChatResponse(conversationId, content);
}
public record ChatRequest(String message) {}
public record ChatResponse(String conversationId, String content) {}
}
`
关键是这一行:
java
`.param(ChatMemory.CONVERSATION_ID, conversationId)
`
它告诉 Chat Memory Advisor:这次请求属于哪个会话。
如果不传这个参数,很多问题都会出现:
- 所有用户可能共用默认会话;
- 多个浏览器标签页上下文串在一起;
- 用户刷新页面后找不到历史;
- 线上排查时无法定位某次回答对应的上下文。
生产环境里,conversationId 不建议完全相信前端随机传。更稳妥的做法是:
tenantId + userId + businessScene + chatSessionId
例如:
t_1001:u_8899:refund-assistant:s_20260615_001
这样既能隔离租户,又能隔离业务场景和具体会话。
8. 用 curl 验证多轮会话
如果项目已经启动,可以用下面的方式模拟。
第一轮:
curl -X POST 'http://localhost:8080/api/agent/chat' \
-H 'Content-Type: application/json' \
-H 'X-Conversation-Id: demo-001' \
-d '{"message":"我想做一个退款流程助手,先帮我记住:我们的退款单有 CREATED、AUDITING、REFUNDED 三个状态。"}'
预期返回类似:
{
"conversationId": "demo-001",
"content": "已记住。退款单状态包括 CREATED、AUDITING、REFUNDED..."
}
第二轮继续问:
curl -X POST 'http://localhost:8080/api/agent/chat' \
-H 'Content-Type: application/json' \
-H 'X-Conversation-Id: demo-001' \
-d '{"message":"那第二个状态一般适合做哪些校验?"}'
如果记忆生效,模型应该知道"第二个状态"指的是 AUDITING,而不是重新问你上下文。
再换一个会话 ID:
curl -X POST 'http://localhost:8080/api/agent/chat' \
-H 'Content-Type: application/json' \
-H 'X-Conversation-Id: demo-002' \
-d '{"message":"第二个状态一般适合做哪些校验?"}'
这一次,模型不应该知道前一个会话里的退款状态。如果它仍然能回答出 AUDITING,说明会话隔离可能有问题。
9. 调用日志应该怎么看
建议在测试环境给每次 AI 调用打出结构化日志。不要把完整敏感 Prompt 长期落盘,但至少要能看出:
conversationId=demo-001
memoryMessageCount=6
maxMessages=10
model=gpt-4o-mini
userMessageLength=28
responseLength=312
latencyMs=1480
一个比较实用的日志格式:
java
`log.info("ai_chat_call conversationId={} memoryWindow={} model={} latencyMs={} inputChars={} outputChars={}",
conversationId,
10,
modelName,
latencyMs,
request.message().length(),
content.length());
`
如果要排查"模型为什么答偏了",可以在测试环境临时打印脱敏后的历史消息摘要:
memory[0]=USER: 我想做一个退款流程助手...
memory[1]=ASSISTANT: 已记住退款单状态...
memory[2]=USER: 那第二个状态一般适合...
生产环境不要默认打印完整上下文,尤其是用户输入可能包含手机号、订单号、合同号、内部接口地址时。
10. 常见坑:会话记忆不是越多越好
10.1 conversationId 设计太粗
错误示例:
conversationId = userId
这会导致用户在不同业务场景里的上下文混在一起。例如同一个用户上午问退款,下午问发票,晚上问订单导出,模型可能把不相关信息串起来。
更好的设计:
conversationId = tenantId + userId + scene + sessionId
10.2 把敏感信息直接写入长期记忆
如果用户输入里有身份证、手机号、Token、内部系统地址,直接写入 Chat Memory 会带来合规风险。
建议至少做三层处理:
| 层级 | 处理方式 |
|---|---|
| 入库前 | 对手机号、邮箱、Token、身份证等做脱敏或拒存 |
| 调用前 | 控制哪些历史消息可以进入模型上下文 |
| 运维侧 | 设置保留周期和删除机制 |
10.3 把 Chat Memory 当成 RAG
Chat Memory 保存的是"当前会话历史",不是知识库。
| 能力 | 适合保存什么 | 不适合保存什么 |
|---|---|---|
| Chat Memory | 最近几轮对话、用户当前意图、上下文指代 | 公司制度、接口文档、长期知识 |
| RAG / Vector Store | 文档、FAQ、产品手册、技术规范 | 临时聊天上下文 |
如果用户问"退款流程规则是什么",这类稳定知识应该走 RAG 或数据库查询;如果用户问"那刚才那个状态怎么办",这类指代才适合由 Chat Memory 解决。
10.4 多实例部署时只用内存记忆
本地 Demo 用内存实现没问题,但线上多实例会有明显问题:
第 1 轮请求 -> 实例 A
第 2 轮请求 -> 实例 B
实例 B 没有实例 A 的内存
上下文丢失
除非你做了粘性会话,否则生产环境更建议使用 JDBC、Redis 或其他共享存储。
10.5 忘记控制记忆窗口
如果每次都把全量历史带给模型,会出现三个问题:成本高、慢、容易被旧上下文污染。
建议从 6 到 10 条消息开始,根据场景调整:
| 场景 | 建议窗口 |
|---|---|
| 普通客服问答 | 最近 6-10 条 |
| 代码评审助手 | 最近 4-8 条 + 当前 diff |
| 长流程任务 Agent | 最近 10-20 条 + 状态摘要 |
| 高敏感业务 | 更小窗口 + 明确脱敏 |
11. 生产落地建议
如果要把 Chat Memory 用在真实业务,建议按下面顺序做:
第一步:先接入 MessageWindowChatMemory
-> 验证 conversationId 隔离
第二步:换成 JDBC / Redis 等共享存储
-> 支持服务重启和多实例
第三步:加日志和观测指标
-> 记录 memoryMessageCount、latency、token/cost
第四步:加敏感信息处理
-> 入库前脱敏,调用前过滤
第五步:引入摘要记忆
-> 长会话不保存全部细节,而是阶段性压缩摘要
一个更完整的工程结构可以是:
web/AgentChatController
-> service/AgentChatService
-> ai/ChatClient
-> advisor/MessageChatMemoryAdvisor
-> memory/ChatMemory
-> repository/JdbcChatMemoryRepository
-> db/ai_chat_memory
同时建议预留以下开关:
| 开关 | 用途 |
|---|---|
ai.memory.enabled |
出问题时可以快速关闭会话记忆 |
ai.memory.max-messages |
调整记忆窗口 |
ai.memory.retention-days |
控制历史保留周期 |
ai.memory.mask-sensitive |
控制是否启用脱敏 |
ai.memory.debug-log |
测试环境排查上下文 |
12. 小结
Spring AI Chat Memory 的价值,不是让 Java Agent "显得更智能",而是补齐多轮应用最基础的上下文能力。
落地时抓住四个点就够了:
- 用
conversationId做会话隔离; - 用
MessageWindowChatMemory控制最近消息窗口; - 用
JdbcChatMemoryRepository解决重启和多实例问题; - 用日志、脱敏、保留周期和窗口大小控制生产风险。
如果只是写 Demo,内存记忆就够了;如果要做真实客服、运维、需求分析、知识库助手,尽早把 Chat Memory 持久化,并把它和 RAG、Tool Calling、审计日志分清边界。
如果你关注 Java 后端、Spring AI、Agent 工程落地和线上问题排查,可以关注我的 CSDN 专栏。