Skip to content

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
保存当前用户或工具响应消息
调用后保存 AssistantMessage

ChatMemoryRepository

具体实现可能把数据存到:

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
当前用户消息已经进入 Memory

5. 为什么要在调用模型前保存 UserMessage

这不是随便写的。

一种合理考虑是:

text
当前问题本身已经发生
应该成为后续对话上下文

特别是在工具调用循环中,最后一条可能是:

text
ToolResponseMessage

Memory 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 context

14. 最小诊断 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. 生产级故障矩阵

场景UserMessageAssistantMessage风险
正常非流式完成已写入已写入正常完整轮次
模型调用前 Advisor 失败,且失败发生在 Memory 前未写入未写入无新记忆
模型调用前 Advisor 失败,且失败发生在 Memory 后可能已写入未写入半轮对话
DeepSeek 超时已写入未写入重试可能重复 UserMessage
流式正常完成已写入聚合后写入完整轮次
流式中途取消已写入可能未写入半轮对话或部分内容丢失
Memory Repository 写入失败不确定不确定模型成功但历史不完整
多实例并发写同一 conversationId依实现而定依实现而定顺序错乱、覆盖或重复

最后一项尤其重要。

默认接口没有承诺:

text
跨实例强顺序
轮次事务
幂等写入
版本冲突检测

这些要由具体 Repository 和业务层补齐。


16. 掌握验证

回答下面问题:

  1. Memory Advisor 在调用模型前还是调用模型后保存 UserMessage?
  2. AssistantMessage 在哪里保存?
  3. DeepSeek 超时后为什么可能出现半轮对话?
  4. 下一次请求会不会读到这条孤立 UserMessage?
  5. 不传 conversationId 时有什么生产风险?
  6. 流式取消后能否保证 AssistantMessage 已保存?
  7. ChatMemory 能否天然作为业务聊天记录数据库?
  8. reasoningContent 是否应该默认混入下一轮 Prompt?
  9. 多实例并发写同一个 conversationId 时还缺哪些保证?

17. 本节结论

Spring AI 1.1.3 的 MessageChatMemoryAdvisor 不是一个问答轮次事务管理器。

它的默认节奏是:

text
before:读历史、增强 Prompt、保存当前 User 或 Tool 消息
模型调用
 after:保存 AssistantMessage

所以必须接受并治理这些边界:

text
模型失败可能形成半轮
流式取消可能形成半轮
默认 conversationId 会造成串会话
ChatMemory 不等于业务聊天数据库
跨实例顺序和幂等需要业务补齐

后续进入 Tool Calling 时,这套认知还会继续使用:

ToolResponseMessage 也可能参与 Memory,而一次用户请求可能经历多次模型调用和多次工具响应。

源码入口

Built with VitePress. Deployed on Cloudflare Pages.