Spring AI Agent 漫画实战
第 05 话 STATE

记忆的代价:ChatMemory 与会话状态治理 记忆不是免费的 —— 每一条历史消息,都是你下一次调用的账单

对话之所以"像人",是因为模型每次都收到完整历史。这一话我们把 ChatMemory 拆到底:滑动窗口的裁剪算法、 为什么工具消息必须整组保留、持久化选型、 conversationId 的多租户设计,以及长期记忆的窗口 / 摘要 / 检索三层策略。

老 K
记忆嘛,搞个 List<Message> 存起来,下次请求带上不就行了。
小 S
然后三个问题找上门:① 存哪儿?② 存多少?③ 谁在跟谁说话?
答错第 ② 个,你会收到厂商的 400 —— 而且是在上线三天后、某个长会话上才出现。

一、ChatMemory 接口与滑动窗口

public interface ChatMemory {
    String CONVERSATION_ID = "conversationId";                 //!hl
    String DEFAULT_CONVERSATION_ID = "default";
    void add(String conversationId, List<Message> messages);   //!hl
    List<Message> get(String conversationId);                 //!hl
    void clear(String conversationId);                        //!hl
}

只有三个方法,非常克制。ChatMemory 本身不做裁剪策略 —— 裁剪是 MessageWindowChatMemory 这个实现类的事,而存到哪是 ChatMemoryRepository 的事。两层职责分离,你可以自由组合。

@Bean
ChatMemory chatMemory() {
    return MessageWindowChatMemory.builder()
            .chatMemoryRepository(new InMemoryChatMemoryRepository())   // 仅测试 //!hl
            .maxMessages(20)                    // 默认就是 20,建议显式写出来 //!hl
            .build();
}
maxMessages 到底该设多少

20 条(约 10 轮)是"够用且不太贵"的经验值,但它没有考虑 token 长度: 10 轮里如果每轮都塞了 3000 字的代码,20 条就是 3 万 token。 正确做法是"条数 + token 预算"双闸门(见第五节)。另外注意: 用了工具调用的会话,一轮会产出 2~3 条消息(assistant + tool response), 所以条数要按"轮数 × 2.5"来估。

二、源码解剖:窗口裁剪与"整组保留"的深坑

源码解剖 · MessageWindowChatMemory#process(简化后)
private List<Message> process(List<Message> interimMessages) {
    List<Message> processedMessages = new ArrayList<>();
    // ① SystemMessage 永远保留,且排在最前面
    List<Message> systemMessages = interimMessages.stream()             //!hl
            .filter(m -> m.getMessageType() == MessageType.SYSTEM).toList();
    processedMessages.addAll(systemMessages);
    // ② 从最新往回倒着取,直到凑够 maxMessages
    int messageCount = processedMessages.size();
    for (int i = interimMessages.size() - 1; i >= 0; i--) {             //!hl
        Message message = interimMessages.get(i);
        if (message.getMessageType() == MessageType.SYSTEM) continue;
        if (messageCount >= this.maxMessages) break;                    //!hl
        processedMessages.add(message);
        messageCount++;
    }
    // ③ ② 是倒着加的,翻转回时间顺序(只翻非 system 部分)             //!hl
    Collections.reverse(processedMessages.subList(systemMessages.size(),
            processedMessages.size()));
    // ④ 移除"悬空"的 ToolResponseMessage                              //!hl
    return removeOrphanToolResponseMessages(processedMessages);         //!hl
}
private List<Message> removeOrphanToolResponseMessages(List<Message> messages) {
    List<Message> result = new ArrayList<>();
    Message previous = null;
    for (Message m : messages) {
        // 只有紧跟在 ASSISTANT 之后的 tool 响应才保留                   //!hl
        if (m.getMessageType() != MessageType.TOOL_RESPONSE
                || (previous != null && previous.getMessageType() == MessageType.ASSISTANT)) {
            result.add(m);
        }
        previous = m;
    }
    return result;
}
必踩之坑 · 工具消息不能拆

上面的 removeOrphanToolResponseMessages 只处理了"孤儿的 tool 响应"这一半。 另一半更凶险:如果窗口边界正好落在 ASSISTANT(tool_calls) 与它的 TOOL_RESPONSE 之间,你发出去的消息序列就是"assistant 声明要调工具,但没人响应", OpenAI 系会直接返回:

400 Bad Request: An assistant message with 'tool_calls' must be followed by
tool messages responding to each tool_call_id.

而且它只在"会话足够长且中途用过工具"时才出现 —— 你的联调、你的单元测试,全都不会触发。对策:

/** 以"工具调用组"为原子单位裁剪:assistant(tool_calls) 与它的 responses 同生共死 */
static List<Message> trimByGroup(List<Message> all, int maxMessages) {
    List<Message> sys = all.stream()
            .filter(m -> m.getMessageType() == MessageType.SYSTEM).toList();   //!hl
    // 分组:TOOL_RESPONSE 并入上一组,其余开新组(一组 = 一轮原子对话)
    List<List<Message>> groups = new ArrayList<>();
    for (Message m : all) {
        if (m.getMessageType() == MessageType.SYSTEM) continue;
        if (m.getMessageType() == MessageType.TOOL_RESPONSE && !groups.isEmpty()) {
            groups.get(groups.size() - 1).add(m);                              //!hl
        } else { groups.add(new ArrayList<>(List.of(m))); }
    }
    int budget = maxMessages - sys.size();
    List<Message> kept = new ArrayList<>(sys);
    for (int i = groups.size() - 1; i >= 0; i--) {
        if (budget <= 0 || groups.get(i).size() > budget) break;  // 放不下就整组丢 //!hl
        kept.addAll(groups.get(i));
        budget -= groups.get(i).size();
    }
    Collections.reverse(kept.subList(sys.size(), kept.size()));   // 恢复时间顺序 //!hl
    return kept;
}

三、持久化:ChatMemoryRepository

实现依赖适用与注意
InMemoryChatMemoryRepository无(核心模块自带)仅用于测试与单例演示。进程重启即丢,水平扩容后会话随机丢失。
JdbcChatMemoryRepositoryspring-boot-starter-jdbc + 驱动绝大多数团队的首选。支持 MySQL / PostgreSQL / SQL Server / HSQL 方言。
CassandraChatMemoryRepositoryCassandra 驱动 + 对应 store 模块超大规模会话、写多读少、需要 TTL 原生支持的场景。
Neo4jChatMemoryRepositoryNeo4j 驱动 + 对应 store 模块需要把"实体—关系"一起持久化时(知识图谱 + 会话记忆合流)。

JDBC 版:建表 + 配置 + Bean

-- MySQL 8.0.16+(低版本不支持 CHECK,去掉 CHECK 那一行即可)
CREATE TABLE SPRING_AI_CHAT_MEMORY (
    conversation_id VARCHAR(36)  NOT NULL,
    content         TEXT         NOT NULL,      -- 序列化后的消息文本
    type            VARCHAR(10)  NOT NULL,      -- USER / ASSISTANT / SYSTEM / TOOL //!hl
    `timestamp`     TIMESTAMP    NOT NULL,
    CONSTRAINT PK_SPRING_AI_CHAT_MEMORY PRIMARY KEY (conversation_id, `timestamp`), //!hl
    CONSTRAINT CHK_TYPE CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))     //!hl
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX IDX_CID ON SPRING_AI_CHAT_MEMORY (conversation_id);   // 按会话清理用 //!hl
# application.yml —— initialize-schema: embedded(默认,仅内存库) | always | never //!hl
spring.ai.chat.memory.repository.jdbc.initialize-schema: never   # 生产交给 Flyway //!hl
---
@Configuration
class MemoryConfig {
    @Bean
    ChatMemory chatMemory(JdbcTemplate jdbcTemplate) {
        var repo = JdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate)
                .dialect(new MysqlChatMemoryRepositoryDialect())     //!hl
                .build();
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(repo).maxMessages(40).build(); //!hl
    }
}
注意

四、两种 Memory Advisor 与 conversationId 设计

Advisor历史消息的处理方式结论
MessageChatMemoryAdvisor把历史并入 messages 列表,保留 SYSTEM/USER/ASSISTANT/TOOL 各自的类型与顺序默认选它。工具调用、多模态、角色语义全部正确。
PromptChatMemoryAdvisor把历史拼成纯文本塞进 system prompt丢失角色结构与工具消息;仅用于不支持完整 messages 的老模型/网关。
VectorStoreChatMemoryAdvisor按语义相似度召回历史片段已废弃。需要检索记忆请自己组合"向量检索 + 消息注入",或等官方新 API。
    @Bean
    ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        return builder.defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory).build()).build();   //!hl
    }

conversationId:不要拿 userId 当会话 ID

// 推荐:租户 + 用户 + 会话 三段式,缺一不可
String conversationId = tenantId + ":" + userId + ":" + sessionId;         //!hl
chatClient.prompt().user(q)
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) //!hl
        .call().content();
为什么不能直接用 userId
滑动窗口裁剪 SYSTEM 系统提示(永远保留) USER #1 (超出窗口,丢弃) ASSISTANT #1(tool_calls) TOOL_RESPONSE #1 必须与上一条同命运 USER #2 ASSISTANT #2(tool_calls) TOOL_RESPONSE #2 窗口内 裁剪以「组」为单位:assistant 与 tool 响应不可拆 → 否则 400 长期记忆三层 ① 窗口 Window最近 10~20 条原文,零损失、成本最高 ② 摘要 Summary廉价模型压缩早期 → 1 条 SystemMessage ③ 检索 Retrieval向量库召回 top-k 片段(第 07 话) 口诀:近期靠窗口、中期靠摘要、远期靠检索;窗口裁剪必须以「工具调用组」为原子单位。

五、长期记忆三层策略与 token 预算

1. 摘要压缩:骨架实现

/** 装饰者:在窗口之外再加一层"超量即压缩" */
public class SummarizingChatMemory implements ChatMemory {
    private final ChatMemory delegate;      // MessageWindowChatMemory //!hl
    private final ChatClient summarizer;    // 廉价模型,如 gpt-4o-mini //!hl
    private final int triggerSize;          // 触发压缩的条数,如 40
    private final int keepRecent;           // 压缩后保留的近期条数,如 16
    @Override
    public void add(String conversationId, List<Message> messages) {
        this.delegate.add(conversationId, messages);
        List<Message> all = this.delegate.get(conversationId);
        if (all.size() <= this.triggerSize) return;
        List<Message> older  = all.subList(0, all.size() - this.keepRecent);
        List<Message> recent = all.subList(all.size() - this.keepRecent, all.size());
        // 关键:摘要素材里剔除工具消息,否则会喂给模型一大段 JSON 噪音 //!hl
        String transcript = older.stream()
                .filter(m -> m.getMessageType() != MessageType.TOOL_RESPONSE)
                .map(m -> m.getMessageType() + ": " + m.getText())
                .collect(Collectors.joining("\n"));
        String summary = this.summarizer.prompt()
                .system("把以下对话压缩为不超过 300 字的中文摘要;保留已确认的事实、做出的决策、未完成的待办;忽略寒暄与重复。")
                .user(transcript)
                .options(ChatOptions.builder().withTemperature(0.0).build()) //!hl
                .call().content();
        this.delegate.clear(conversationId);                          //!hl
        this.delegate.add(conversationId,
                List.of(new SystemMessage("【历史摘要】" + summary)));   //!hl
        this.delegate.add(conversationId, recent);
    }
    @Override public List<Message> get(String conversationId) { return this.delegate.get(conversationId); }
    @Override public void clear(String conversationId) { this.delegate.clear(conversationId); }
}
摘要层的三个工程细节

2. token 预算:超预算先摘要再截断

// 精确计数:com.knuddels:jtokkit:1.1.0(与 OpenAI 的 BPE 一致)
static final Encoding ENC = Encodings.newDefaultEncodingRegistry()
        .getEncoding(EncodingType.O200K_BASE);                              //!hl
static int countTokens(List<Message> ms) {
    return ms.stream().mapToInt(m -> ENC.countTokens(m.getText()) + 7).sum(); //!hl
}
// 粗估版(零依赖,够用):中文约 1.5 字符/token,英文约 4 字符/token
static long estimateTokens(String text) {
    if (text == null || text.isEmpty()) return 0;
    long cjk = text.chars().filter(c -> c > 0x2E80).count();                //!hl
    return Math.round(cjk / 1.5 + (text.length() - cjk) / 4.0) + 4;
}
工程化建议 · 上下文预算的处置顺序
  1. 预留输出位:budget = 上下文上限 − maxTokens − 系统提示 − 工具定义。忘了减工具定义,是"明明没超却报超长"的常见原因。
  2. 超预算先压缩再截断:先尝试摘要(信息损失小),仍超则从最老的组开始整组丢。
  3. 给用户可见反馈:压缩时回一句"上下文已压缩,更早的对话已转为摘要"。静默丢失记忆是最伤信任的缺陷。
  4. 埋点:记录 promptTokens 与压缩次数,超阈值告警(第 10 话接进监控面板)。

六、踩坑清单与上线前检查表

踩坑
上线检查表
小 S
一模一样 —— 记忆就是个有状态、有账本、会丢数据的分布式存储问题。 下一话换个更"Agent"的话题:让模型真的能动手 —— @Tool 注解、 ToolCallback,以及"模型决定调这个工具"这件事到底是怎么发生的。
← 上一话:Advisor 拦截机制 目录 下一话:工具调用 →