Spring AI企业级应用实战(4):Chat Memory、会话隔离、持久化与上下文压缩
文章摘要
前几篇已经完成Spring AI统一调用层和流式输出。本篇继续实现企业级多轮对话:使用MessageChatMemoryAdvisor管理近期消息,要求每次请求显式提供conversationId,通过PostgreSQL保存完整Chat History与持久化Memory,校验租户和用户所有权,并在上下文接近预算时生成结构化摘要。最终形成一个支持多实例部署、流式调用、审计和安全删除的会话系统。
一、本篇目标
完成以下能力:
创建会话
稳定conversationId
多租户所有权校验
Chat Memory持久化
完整Chat History
同步与流式调用
窗口控制
上下文摘要
清空与删除
可观测性
最终链路:
HTTP请求
→ 身份认证
→ 会话所有权校验
→ 写入Chat History
→ MessageChatMemoryAdvisor
→ ChatClient
→ 模型
→ 保存结果
→ 更新指标
二、项目结构
src/main/java/com/zyentor/ai
├── config
│ └── ChatMemoryConfig.java
├── conversation
│ ├── Conversation.java
│ ├── ConversationRepository.java
│ ├── ConversationService.java
│ └── ConversationController.java
├── history
│ ├── ChatMessageRecord.java
│ ├── ChatHistoryRepository.java
│ └── JdbcChatHistoryRepository.java
├── memory
│ ├── MemoryCompactionService.java
│ └── ConversationSummary.java
├── service
│ └── EnterpriseConversationAiService.java
└── security
└── UserContext.java
三、依赖
org.springframework.ai
spring-ai-bom
2.0.0
pom
import
org.springframework.ai
spring-ai-starter-model-deepseek
org.springframework.ai
spring-ai-starter-model-chat-memory-repository-jdbc
org.springframework.boot
spring-boot-starter-webflux
org.springframework.boot
spring-boot-starter-jdbc
org.postgresql
postgresql
runtime
具体Starter名称应以当前Spring AI 2.0.x文档和依赖清单为准。
四、配置数据源和模型
spring:
datasource:
url: jdbc:postgresql://localhost:5432/spring_ai_demo
username: ai_user
password: ${DB_PASSWORD}
ai:
model:
chat: deepseek
deepseek:
api-key: ${DEEPSEEK_API_KEY}
chat:
model: deepseek-v4-flash
temperature: 0.2
max-tokens: 2048
生产环境不要把密钥写进仓库。
五、配置Chat Memory
@Configuration
public class ChatMemoryConfig {
@Bean
ChatMemory chatMemory(
ChatMemoryRepository repository
) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(30)
.build();
}
@Bean("conversationChatClient")
ChatClient conversationChatClient(
ChatClient.Builder builder,
ChatMemory chatMemory
) {
return builder
.defaultSystem("""
你是企业AI助手。
只能使用当前用户有权访问的上下文。
不得泄露其他用户或租户的信息。
不确定时明确说明。
""")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(
chatMemory
).build()
)
.build();
}
}
maxMessages=30只是示例,生产应通过真实Token分布评测。
六、为什么每次请求必须传conversationId
Spring AI 2.0内置Memory Advisor要求显式提供:
ChatMemory.CONVERSATION_ID
调用:
chatClient.prompt()
.advisors(spec -> spec.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.user(message)
.call()
.content();
不要使用默认会话,也不要把conversationId写死在Advisor Bean中。
七、会话表
CREATE TABLE ai_conversation (
id VARCHAR(64) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
title VARCHAR(200),
status VARCHAR(20) NOT NULL,
memory_version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
memory_version可以用于:
- 缓存失效;
- 摘要版本;
- 调试;
- 会话重置。
八、完整消息表
CREATE TABLE ai_chat_message (
id VARCHAR(64) PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
request_id VARCHAR(64) NOT NULL,
model VARCHAR(100),
prompt_version VARCHAR(50),
input_tokens BIGINT,
output_tokens BIGINT,
created_at TIMESTAMP NOT NULL
);
Chat Memory表只服务模型上下文;该表保存完整历史。
九、会话创建
@Service
public class ConversationService {
private final ConversationRepository repository;
public Conversation create(UserContext user) {
Instant now = Instant.now();
Conversation conversation = new Conversation(
"conv_" + UUID.randomUUID(),
user.tenantId(),
user.userId(),
null,
ConversationStatus.ACTIVE,
1L,
now,
now
);
repository.save(conversation);
return conversation;
}
}
tenantId和userId来自认证上下文。
十、所有权校验
public Conversation requireOwned(
UserContext user,
String conversationId
) {
return repository.findOwned(
conversationId,
user.tenantId(),
user.userId()
).orElseThrow(() ->
new AccessDeniedException("无权访问该会话")
);
}
不能只验证会话存在。
十一、同步对话服务
@Service
public class EnterpriseConversationAiService {
private final ChatClient chatClient;
private final ConversationService conversationService;
private final ChatHistoryRepository historyRepository;
public String chat(
UserContext user,
String conversationId,
String message
) {
conversationService.requireOwned(
user,
conversationId
);
String requestId =
UUID.randomUUID().toString();
historyRepository.saveUserMessage(
user,
conversationId,
requestId,
message
);
try {
String answer = chatClient.prompt()
.advisors(spec -> spec
.param(
ChatMemory.CONVERSATION_ID,
conversationId
)
.param(
"tenantId",
user.tenantId()
)
)
.user(message)
.call()
.content();
historyRepository.saveAssistantMessage(
user,
conversationId,
requestId,
answer,
MessageStatus.COMPLETED
);
return answer;
}
catch (RuntimeException exception) {
historyRepository.markFailed(
conversationId,
requestId,
exception.getClass()
.getSimpleName()
);
throw exception;
}
}
}
十二、避免长数据库事务
模型调用可能持续数秒。
不要:
开启数据库事务
→ 写用户消息
→ 等模型十几秒
→ 写助手消息
→ 提交
推荐:
事务1:写用户消息
→ 模型调用
→ 事务2:写助手消息或失败状态
通过request_id关联两段记录。
十三、流式调用
public Flux> stream(
UserContext user,
String conversationId,
String message
) {
conversationService.requireOwned(
user,
conversationId
);
String requestId =
UUID.randomUUID().toString();
historyRepository.saveUserMessage(
user,
conversationId,
requestId,
message
);
StringBuilder buffer = new StringBuilder();
return chatClient.prompt()
.advisors(spec -> spec.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.user(message)
.stream()
.content()
.doOnNext(buffer::append)
.map(chunk -> ServerSentEvent.builder(
new ChatStreamEvent(
requestId,
"delta",
chunk
)
).build())
.doOnComplete(() ->
historyRepository
.saveAssistantMessage(
user,
conversationId,
requestId,
buffer.toString(),
MessageStatus.COMPLETED
)
)
.doOnCancel(() ->
historyRepository.markCancelled(
conversationId,
requestId,
buffer.toString()
)
);
}
需要继续验证Provider在取消订阅后是否真正终止上游生成。
十四、半段回答是否进入Memory
用户取消后可能只生成:
根据你的问题,主要有三个方面,第一……
不建议把这段内容当作完整Assistant消息进入稳定Memory。
可选策略:
取消时不写Memory
完整Chat History保存CANCELLED消息
下一轮提示用户上一轮已中断
十五、为什么需要上下文压缩
长期会话会不断消耗Token。
只扩大Message Window会导致:
- 成本上升;
- 延迟增加;
- 重要信息被噪声淹没;
- 超出上下文窗口。
推荐上下文:
结构化摘要
+最近若干轮消息
+当前RAG证据
十六、摘要表
CREATE TABLE ai_conversation_summary (
id VARCHAR(64) PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
summary_version BIGINT NOT NULL,
covered_until_message_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
created_at TIMESTAMP NOT NULL
);
结构化内容:
{
"goal": "构建企业RAG方案",
"facts": [
"客户使用PostgreSQL",
"数据不能离开私有云"
],
"decisions": [
"向量存储选择pgvector"
],
"openQuestions": [
"峰值并发尚未确认"
]
}
十七、什么时候触发压缩
可以使用:
消息数量阈值
Token预算阈值
会话持续时间
工具事件数量
推荐以Token预算为主:
预计上下文Token
> 可用历史预算的80%
→ 触发压缩
十八、压缩流程
1. 读取上次摘要
2. 读取摘要之后的新消息
3. 生成新结构化摘要
4. 校验Schema
5. 保存summary_version
6. 更新covered_until_message_id
7. 后续Prompt加载摘要和近期消息
原始消息不删除,用于审计和重新生成摘要。
十九、摘要不能替代业务事实
模型摘要可能错误。
以下信息必须从业务系统实时获取:
- 订单状态;
- 当前权限;
- 余额;
-审批结果; - 产品库存;
- 合同版本。
摘要只能作为对话背景,不能作为强一致事实来源。
二十、会话清空与删除
清空模型上下文
chatMemory.clear(conversationId);
同时增加memory_version,使缓存失效。
删除会话
流程:
标记DELETING
→ 删除Memory
→ 删除摘要和向量记忆
→ 处理附件
→ 按策略删除或匿名化History
→ 标记DELETED
二十一、监控指标
chat_conversation_created_count
chat_memory_message_count
chat_memory_load_duration
chat_memory_clear_count
conversation_cross_owner_denied_count
summary_generation_count
summary_validation_failed_count
stream_cancelled_count
history_memory_write_mismatch_count
二十二、测试场景
同一会话连续多轮
同一用户两个窗口
同租户两个用户
两个不同租户
应用重启
请求进入不同实例
流式取消
模型超时
Memory清空
摘要触发
非法conversationId访问
最重要的测试是多租户隔离。
二十三、本篇完整架构
认证用户
→ Conversation所有权
→ 完整History写入
→ 显式conversationId
→ MessageChatMemoryAdvisor
→ 持久化Memory Repository
→ ChatClient
→ 模型
→ 保存状态
→ Token预算触发摘要
总结
生产级Spring AI多轮对话不能只注册一个Memory Advisor。
完整方案必须同时解决:
稳定会话ID
+租户与用户隔离
+持久化Memory
+独立Chat History
+流式状态
+上下文压缩
+审计与删除
下一篇将继续实现:
Spring AI企业级应用实战(5):Tool Calling、权限校验、幂等与人工确认。
延伸阅读
如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。