Spring AI Chat Memory 实战:用 JDBC 给 Java Agent 加会话记忆

Spring AI Chat Memory 实战:用 JDBC 给 Java Agent 加会话记忆

摘要:很多 Java AI Demo 都是一次性问答:用户问一句,模型回一句,请求结束后上下文也没了。真实业务里的客服助手、需求分析助手、运维问答 Agent,往往需要按会话保存历史消息,让第二轮、第三轮回答能接上前面的语境。本文基于 Spring AI 官方 Chat Memory / Advisor 文档,演示如何用 MessageWindowChatMemoryJdbcChatMemoryRepositoryMessageChatMemoryAdvisor 给 Spring Boot + Spring AI 应用加上 JDBC 会话记忆,并补充会话隔离、记忆窗口、表结构、调用日志和生产边界。

验证状态:本文机制来自 Spring AI 官方 Chat MemoryAdvisorsChatClient 文档,当前机器没有 JDK / Maven,代码未在本机编译运行。示例按官方机制整理为工程骨架,具体依赖坐标、包名和 API 签名请以你项目实际 Spring AI 版本、IDE 提示和官方文档为准。本文重点是工程接入方式和生产边界,不把未编译样例写成"已本地跑通"。

1. 为什么 Java Agent 需要 Chat Memory

假设你做了一个内部知识库助手,第一轮用户问:

复制代码
我们项目的退款流程怎么走?

模型根据知识库回答了退款流程。第二轮用户继续问:

复制代码
那超时了怎么办?

如果没有会话记忆,模型只看到"超时了怎么办",很可能不知道"超时"指的是退款审批超时、接口超时、支付回调超时,还是 MQ 消费超时。

所以 Chat Memory 解决的不是"模型更聪明",而是让应用层把同一个会话里的上下文按规则带回模型:

复制代码
用户 A 的 conversationId
  -> 历史用户消息
  -> 历史助手回答
  -> 当前用户问题
  -> 拼成当前 Prompt
  -> 模型回答
  -> 新消息写回记忆

在 Java 后端里,这件事至少有三个工程问题:

问题 如果不处理会怎样
会话隔离 不同用户、不同工单、不同浏览器窗口的上下文混在一起
记忆窗口 历史消息无限增长,Token 成本和响应延迟不断上升
持久化 服务重启后上下文丢失,多实例部署时记忆不一致
数据安全 用户隐私、内部字段、敏感信息被长期保存
可观测性 线上回答异常时,不知道模型到底拿到了哪些上下文

Spring AI 的 Chat Memory 把这几个问题拆成了两个层次:

  1. ChatMemory:决定当前会话保留哪些消息,例如窗口大小;
  2. 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。

如果你自己设计扩展表,至少要保证:

  1. conversation_id 有索引;
  2. 按时间可排序;
  3. 能区分 user / assistant / system;
  4. 支持按租户、用户或业务域隔离;
  5. 敏感字段不要无限期保留。

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 做两件事:

  1. 调用模型前,根据当前 conversationId 读取历史消息,并放进请求上下文;
  2. 调用完成后,把本轮用户输入和模型回答写回 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 "显得更智能",而是补齐多轮应用最基础的上下文能力。

落地时抓住四个点就够了:

  1. conversationId 做会话隔离;
  2. MessageWindowChatMemory 控制最近消息窗口;
  3. JdbcChatMemoryRepository 解决重启和多实例问题;
  4. 用日志、脱敏、保留周期和窗口大小控制生产风险。

如果只是写 Demo,内存记忆就够了;如果要做真实客服、运维、需求分析、知识库助手,尽早把 Chat Memory 持久化,并把它和 RAG、Tool Calling、审计日志分清边界。

如果你关注 Java 后端、Spring AI、Agent 工程落地和线上问题排查,可以关注我的 CSDN 专栏。

相关推荐
凡人叶枫1 小时前
Effective C++ 条款40:明智而审慎地使用多重继承
java·数据库·c++·嵌入式开发·effective c++
放弃 治疗1 小时前
宝塔面板安装 JDK 完整教程|Java 环境配置详解
java·开发语言
摸鱼同学1 小时前
04-Hermes 三层记忆系统(上):会话记忆——让 AI 记住刚才聊了什么
ai·agent·hermes
至此流年莫相忘1 小时前
Spring 依赖注入三剑客:@Autowired、@Resource 与 @RequiredArgsConstructor 深度对比与实战指南
java·数据库·spring
零陵上将军_xdr2 小时前
为什么DCL单例要加volatile?——CPU乱序执行与内存屏障
java·linux
柏舟飞流2 小时前
Spring Boot 进阶实战:整合 MyBatis、Redis、JWT,搭一个更像真实项目的后端服务
spring boot·redis·mybatis
shushangyun_2 小时前
批发商城系统源码多少钱?2026最新报价一览
java·开发语言·人工智能·spring·spring cloud
阿泽·黑核2 小时前
05 keyflow 扩展设计方案:矩阵键盘/组合键/事件队列/中断驱动
线性代数·矩阵·计算机外设·嵌入式·agent·vibe coding
cfm_29142 小时前
JVM深度详解:Class常量池、运行时常量池、字符串常量池、包装类对象池
java·jvm