切换主题
L06:Memory 生命周期——什么时候读、什么时候写,失败为什么会留下半轮对话
版本基准:Spring AI Core
1.1.3
建议时长:40~45 分钟
1. 本节只解决一个问题
下面这次请求如果模型调用失败,Memory 中会留下什么?
java
chatClient.prompt()
.advisors(a -> a
.param(ChatMemory.CONVERSATION_ID, conversationId))
.user("解释 Advisor Chain")
.call()
.content();很多人会凭直觉认为:
text
模型失败
→ 整轮对话都不会保存在 Spring AI 1.1.3 的 MessageChatMemoryAdvisor 中,这个判断可能是错的。
真实顺序是:
text
读取历史消息
→ 拼接当前请求
→ 先保存当前 UserMessage
→ 调用下游 Advisor 和模型
→ 模型成功返回
→ 再保存 AssistantMessage因此模型失败时可能出现:
text
Memory 中已经有 UserMessage
但没有对应的 AssistantMessage这就是“半轮对话”。
2. 先划清三个角色
ChatMemory
它定义对话记忆能力:
java
void add(String conversationId, List<Message> messages);
List<Message> get(String conversationId);
void clear(String conversationId);它关心:
text
按 conversationId 读消息
按 conversationId 写消息
按 conversationId 清理消息MessageChatMemoryAdvisor
它把 Memory 接入 ChatClient 调用链。
它关心:
text
调用前从 ChatMemory 读取历史
把历史消息合并进 Prompt
保存当前用户或工具响应消息
调用后保存 AssistantMessageChatMemoryRepository
具体实现可能把数据存到:
text
内存
JDBC
MongoDB
Neo4j
其他持久化介质注意:
text
Memory Advisor 决定什么时候读写
ChatMemory 决定保留哪些消息
Repository 决定消息怎样持久化这三层不能混在一起。
3. conversationId 从哪里来
MessageChatMemoryAdvisor 不会从用户文本中猜 conversationId。
它从 Advisor context 中查找:
java
ChatMemory.CONVERSATION_ID在 1.1.3 中,该 key 的值是:
text
chat_memory_conversation_id推荐写法:
java
chatClient.prompt()
.advisors(a -> a
.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(question)
.call()
.content();如果 context 中没有 conversationId,Advisor 会退回到默认 conversationId。
Spring AI 1.1.3 的默认值是:
text
default生产事故模型
假设两个用户都没有传 conversationId:
text
用户 A → default
用户 B → default结果:
text
A 和 B 可能共享同一份历史消息这不是“小概率问题”,而是明确的数据隔离风险。
生产要求:
text
conversationId 必须由业务层生成和校验
不能依赖 ChatMemory.DEFAULT_CONVERSATION_ID
不能直接信任前端随意传入的 conversationId推荐组成:
text
tenantId:userId:sessionId或者业务生成不可预测的会话 ID,并在服务端校验归属关系。
4. before() 的真实四步
MessageChatMemoryAdvisor.before() 可以压缩成四步。
第一步:确定 conversationId
java
String conversationId = getConversationId(
request.context(),
defaultConversationId
);第二步:读取历史消息
java
List<Message> memoryMessages =
chatMemory.get(conversationId);第三步:历史在前,当前请求在后
java
List<Message> processedMessages =
new ArrayList<>(memoryMessages);
processedMessages.addAll(
request.prompt().getInstructions()
);随后它会确保 SystemMessage 位于第一位。
简化结果:
text
SystemMessage
→ 历史 UserMessage / AssistantMessage
→ 当前 UserMessage 或 ToolResponseMessage第四步:先保存当前消息
它会从新 Prompt 中找到最后一个:
text
UserMessage
或
ToolResponseMessage然后立即执行:
java
chatMemory.add(conversationId, userMessage);注意时间点:
text
还没有调用 ChatModel
当前用户消息已经进入 Memory5. 为什么要在调用模型前保存 UserMessage
这不是随便写的。
一种合理考虑是:
text
当前问题本身已经发生
应该成为后续对话上下文特别是在工具调用循环中,最后一条可能是:
text
ToolResponseMessageMemory Advisor 需要把这类消息也纳入会话轨迹。
但这个设计也带来一致性问题:
text
用户消息写入成功
模型调用失败
AssistantMessage 没有写入这说明 ChatMemory 写入不是一次天然原子的“问答轮次事务”。
6. after() 保存什么
模型成功返回后:
java
MessageChatMemoryAdvisor.after(response, chain)会从:
text
ChatClientResponse
└─ ChatResponse
└─ List<Generation>
└─ AssistantMessage提取 Assistant 消息列表,然后:
java
chatMemory.add(conversationId, assistantMessages);因此完整成功轮次通常是:
text
before:保存 UserMessage
模型调用
after:保存 AssistantMessage多 Generation 场景
如果模型返回多个 Generation,Advisor 会收集多个 AssistantMessage。
业务系统如果只展示第一条,却把所有 Generation 都写入 Memory,后续上下文可能和用户实际看到的内容不一致。
生产中需要确认:
text
模型是否可能返回多个候选
业务展示了哪一条
Memory 应保存哪一条7. 半轮对话怎样产生
考虑调用:
text
MemoryAdvisor.before
→ 保存 UserMessage
→ ChatModelCallAdvisor
→ DeepSeekChatModel
→ 网络超时
→ 抛异常因为同步 Advisor 默认路径中:
text
nextCall 抛异常
→ after 不执行最终 Memory:
text
历史轮次
UserMessage("解释 Advisor Chain")缺少:
text
AssistantMessage下一次请求读取 Memory 时,这条孤立 UserMessage 会再次进入 Prompt。
如果用户重试相同问题,可能形成:
text
UserMessage:解释 Advisor Chain
UserMessage:解释 Advisor Chain模型看到的是重复提问,而业务界面可能只展示一次。
8. 第一版解决方案:失败时删除最后一条 UserMessage
直觉方案:
text
模型失败
→ 删除刚刚保存的 UserMessage问题是 ChatMemory 接口只有:
text
add
get
clear没有标准的:
text
removeLast
rollback
replace如果失败后执行:
java
chatMemory.clear(conversationId);会把整个会话历史清空,显然不可接受。
所以不能指望通用 ChatMemory 接口天然支持精确回滚。
9. 第二版方案:业务状态与模型上下文分离
更稳妥的工程设计是区分两种数据。
业务会话记录
作为真实事实来源:
text
messageId
conversationId
role
content
status
createdAt
errorCode
model状态示例:
text
PENDING
SUCCEEDED
FAILED
CANCELLED模型上下文 Memory
只负责生成下一次 Prompt 所需的有效上下文。
生成上下文时只选:
text
已经确认完成的历史轮次
+
当前正在执行的 UserMessage这样模型失败后:
text
业务记录仍然保留 FAILED
但下一次模型上下文不一定带入失败轮次这比强行把 ChatMemory 当业务聊天数据库更可靠。
10. 第三版方案:自定义轮次级 Memory
如果业务强依赖问答轮次一致性,可以设计:
text
ConversationTurn
├─ turnId
├─ userMessage
├─ assistantMessage
├─ status
└─ metadata调用过程:
text
1. 创建 PENDING Turn
2. 调用模型
3. 成功:补 AssistantMessage,状态改 SUCCEEDED
4. 失败:状态改 FAILED
5. 下一次组装 Prompt 时,只加载符合策略的 Turn可选策略:
text
只加载 SUCCEEDED
加载 SUCCEEDED + 最近一次 FAILED UserMessage
失败轮次由用户确认后再重试这是生产级对话系统比默认 Memory Advisor 多出来的一层业务治理。
11. 流式场景为什么更复杂
流式调用不是一次性拿到完整 AssistantMessage。
模型持续返回:
text
chunk 1
chunk 2
chunk 3
...
finish chunk如果每个 chunk 都写 Memory,会得到碎片:
text
AssistantMessage("你")
AssistantMessage("好")
AssistantMessage(",")
...因此 MessageChatMemoryAdvisor 的流式实现会:
text
一边把 chunk 继续发给下游
一边旁路聚合完整响应
聚合完成后调用 after()
保存完整 AssistantMessage用户取消时的边界
如果前端主动断开:
text
SSE 连接关闭
→ 上游 Flux cancel
→ 没有正常聚合完成
→ AssistantMessage 可能不写入 Memory但 UserMessage 已经在 before() 中保存。
于是流式取消也可能产生半轮对话。
需要明确产品语义:
text
用户取消后,已生成的部分内容是否算一次有效回答?
是否展示?
是否保存?
是否进入下次模型上下文?Spring AI 默认 Advisor 无法替业务决定。
12. DeepSeek reasoningContent 是否应该进入 Memory
DeepSeek 1.1.3 返回:
text
content
reasoningContent它们被保存在同一个 DeepSeekAssistantMessage 的不同字段中。
默认 Message Memory 保存的是 AssistantMessage 对象。
但持久化实现、序列化方式和后续重新转请求时,是否完整保留 reasoningContent,需要结合具体 Memory Repository 和消息序列化验证。
更重要的是业务取舍:
保存 reasoningContent 的收益
text
排错时能看到模型推理过程
可能帮助后续兼容 DeepSeek 特殊调用链保存 reasoningContent 的风险
text
数据量巨大
包含敏感内部推理
增加日志和存储成本
重新注入模型可能产生协议兼容问题
用户未必有权限查看生产建议:
text
业务聊天历史默认保存最终 content
reasoningContent 单独受控存储或不持久化
不要未经设计就把 reasoningContent 混入普通上下文具体仍要结合你们当前 DeepSeek V4-Pro 兼容实现验证。
13. conversationId 的工程约束
一个可用的 conversationId 方案至少满足:
text
唯一性
租户隔离
用户归属校验
不可被简单枚举
日志可追踪
必要时可以清理不推荐:
text
conversationId = userId因为一个用户可能同时有多个会话。
也不推荐直接使用前端传来的任意字符串而不校验归属。
推荐业务模型:
text
Conversation
├─ conversationId
├─ tenantId
├─ userId
├─ title
├─ status
├─ createdAt
└─ updatedAt请求进来后:
text
校验 conversationId 属于当前 tenantId + userId
→ 再放入 Advisor context14. 最小诊断 Advisor
下面的 Advisor 用于观察 Memory 前后消息数量。
java
public final class MemoryTraceAdvisor implements BaseAdvisor {
private final int order;
public MemoryTraceAdvisor(int order) {
this.order = order;
}
@Override
public ChatClientRequest before(
ChatClientRequest request,
AdvisorChain chain) {
String conversationId = String.valueOf(
request.context().get(ChatMemory.CONVERSATION_ID)
);
System.out.printf(
"before conversationId=%s, messageCount=%d%n",
conversationId,
request.prompt().getInstructions().size()
);
return request;
}
@Override
public ChatClientResponse after(
ChatClientResponse response,
AdvisorChain chain) {
int generationCount = response.chatResponse() == null
? 0
: response.chatResponse().getResults().size();
System.out.printf(
"after generationCount=%d%n",
generationCount
);
return response;
}
@Override
public int getOrder() {
return order;
}
}要观察 Memory 增强前后的差异,可以注册两个 Probe:
text
ProbeBeforeMemory:order 小于 MemoryAdvisor
ProbeAfterMemory:order 大于 MemoryAdvisor前者看到原始 Prompt,后者看到已经加入历史消息的 Prompt。
这是一种很实用的源码验证方式。
15. 生产级故障矩阵
| 场景 | UserMessage | AssistantMessage | 风险 |
|---|---|---|---|
| 正常非流式完成 | 已写入 | 已写入 | 正常完整轮次 |
| 模型调用前 Advisor 失败,且失败发生在 Memory 前 | 未写入 | 未写入 | 无新记忆 |
| 模型调用前 Advisor 失败,且失败发生在 Memory 后 | 可能已写入 | 未写入 | 半轮对话 |
| DeepSeek 超时 | 已写入 | 未写入 | 重试可能重复 UserMessage |
| 流式正常完成 | 已写入 | 聚合后写入 | 完整轮次 |
| 流式中途取消 | 已写入 | 可能未写入 | 半轮对话或部分内容丢失 |
| Memory Repository 写入失败 | 不确定 | 不确定 | 模型成功但历史不完整 |
| 多实例并发写同一 conversationId | 依实现而定 | 依实现而定 | 顺序错乱、覆盖或重复 |
最后一项尤其重要。
默认接口没有承诺:
text
跨实例强顺序
轮次事务
幂等写入
版本冲突检测这些要由具体 Repository 和业务层补齐。
16. 掌握验证
回答下面问题:
- Memory Advisor 在调用模型前还是调用模型后保存 UserMessage?
- AssistantMessage 在哪里保存?
- DeepSeek 超时后为什么可能出现半轮对话?
- 下一次请求会不会读到这条孤立 UserMessage?
- 不传 conversationId 时有什么生产风险?
- 流式取消后能否保证 AssistantMessage 已保存?
- ChatMemory 能否天然作为业务聊天记录数据库?
- reasoningContent 是否应该默认混入下一轮 Prompt?
- 多实例并发写同一个 conversationId 时还缺哪些保证?
17. 本节结论
Spring AI 1.1.3 的 MessageChatMemoryAdvisor 不是一个问答轮次事务管理器。
它的默认节奏是:
text
before:读历史、增强 Prompt、保存当前 User 或 Tool 消息
模型调用
after:保存 AssistantMessage所以必须接受并治理这些边界:
text
模型失败可能形成半轮
流式取消可能形成半轮
默认 conversationId 会造成串会话
ChatMemory 不等于业务聊天数据库
跨实例顺序和幂等需要业务补齐后续进入 Tool Calling 时,这套认知还会继续使用:
ToolResponseMessage 也可能参与 Memory,而一次用户请求可能经历多次模型调用和多次工具响应。