Spring AI 2.0 探索:多 OpenAI-Compatible 模型接入,以及下一代 Session 记忆管理

最近在体验 Spring AI 2.0 时,我最开始给自己定的目标并不复杂:在同一个 Spring Boot 项目中,同时接入两个 OpenAI-Compatible 接口。

两个渠道都使用类似 OpenAI 的请求格式,但它们的 baseUrlapiKey 和模型名各不相同。按照直觉,只要分别配置两个客户端,然后调用不同的 Bean,事情似乎就结束了。

真正动手以后,我才发现"把模型接通"只是第一步。

当应用中出现第二个模型后,一连串更值得研究的问题也跟着出现了:

  • OpenAiChatModelChatClient 到底是什么关系?
  • 两个模型 Bean 为什么会触发注入与自动配置问题?
  • 从一个模型切到另一个模型以后,之前的上下文还能不能继续使用?
  • 对话越来越长时,是直接丢掉旧消息,还是先把旧消息压缩成摘要?
  • Memory、Session、Turn、Event 和 Compaction 分别解决什么问题?

所以这篇文章最终不只是在讲"如何配置两个模型"。我会先交代这次实验额外引入的 Community Session,再回到多模型接入,随后验证 Session 的共享、隔离和长对话压缩。

一、一个偶然的发现:Spring AI Community 的 spring-ai-session

在研究 Spring AI 2.0 Core 的短期记忆时,我发现了 Spring AI Community 提供的 spring-ai-session

它不是 Spring AI 2.0 Core 已经默认集成的能力,而是社区针对 AI 应用会话管理所做的一次探索。它关心的不只是"保留最近几条消息",还包括 Session、Turn、Event、上下文压缩和摘要记忆。

这些能力刚好对应了多模型接入之后会遇到的问题:如果两个模型需要延续同一段对话,上下文应该保存在哪里?对话越来越长以后,旧内容又该怎样退出当前 Prompt?

因此,小名会先使用 Spring AI Core 的 ChatMemory 解释短期记忆,再带大家进一步体验 spring-ai-session 的 Session 与 Compaction。实验项目使用 Java 21、Spring Boot 4.1.0、Spring AI 2.0.0 和 spring-ai-session 0.7.0,相关依赖如下:

xml 复制代码
<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.0</spring-ai.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-session</artifactId>
    </dependency>
</dependencies>

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

        <dependency>
            <groupId>org.springaicommunity</groupId>
            <artifactId>spring-ai-session-bom</artifactId>
            <version>0.7.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

spring-ai-session 构建在 Spring AI 2.0 的 ChatClient 和 Advisor 体系之上。本文标题里的"下一代 Session 记忆管理"描述的是这个社区项目所探索的方向,这并不代表 Session 已经属于 Spring AI 2.0 Core。

二、先把两个 OpenAI-Compatible 渠道接进来

为了隐藏具体服务信息,后文把两个渠道统一记作 API_AAPI_B。它们各自拥有独立的 baseUrlapiKey 和模型标识:API_A 使用 gpt-5.6-terraAPI_B 使用 gpt-5.5

本项目为每个渠道创建一个 OpenAiChatModel,再配置一个供业务代码使用的 ChatClientapiAChatClient 通过 apiAChatModel 访问 API_AapiBChatClient 则通过 apiBChatModel 访问 API_B

1. ChatModelOpenAiChatModelChatClient

这三个对象很容易在第一次接触时混在一起。

ChatModel 是 Spring AI 对聊天模型的统一抽象,OpenAiChatModel 是它面向 OpenAI 及兼容接口的一种实现;ChatClient 则是业务代码更常使用的调用入口,提供了 prompt()call()stream() 和 Advisor 等链式 API。

更准确地说,ChatClient 调用的是 ChatModel 接口;本文使用 OpenAiChatModel 作为具体实现,由它与 OpenAI-Compatible 服务通信。

真正决定请求发往哪个渠道的,是 OpenAiChatModel 中的连接配置,而不是 ChatClient 的 Bean 名。

同一个 ChatModel 可以创建多个 ChatClient,让它们分别拥有不同的 System Prompt 或 Advisor。

2. yaml文件

为了让配置能够安全地进入代码,可以先把两个渠道放到外部配置中:

yaml 复制代码
app:
  ai:
    api-a:
      base-url: ${API_A_BASE_URL}
      api-key: ${API_A_API_KEY}
      model: ${API_A_MODEL:gpt-5.6-terra}
    api-b:
      base-url: ${API_B_BASE_URL}
      api-key: ${API_B_API_KEY}
      model: ${API_B_MODEL:gpt-5.5}

然后分别创建模型 Bean:

java 复制代码
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiModelConfig {

    @Bean("apiAChatModel")
    public OpenAiChatModel apiAChatModel(
            @Value("${app.ai.api-a.base-url}") String baseUrl,
            @Value("${app.ai.api-a.api-key}") String apiKey,
            @Value("${app.ai.api-a.model}") String model) {

        return OpenAiChatModel.builder()
                .options(OpenAiChatOptions.builder()
                        .baseUrl(baseUrl)
                        .apiKey(apiKey)
                        .model(model)
                        .build())
                .build();
    }

    @Bean("apiBChatModel")
    public OpenAiChatModel apiBChatModel(
            @Value("${app.ai.api-b.base-url}") String baseUrl,
            @Value("${app.ai.api-b.api-key}") String apiKey,
            @Value("${app.ai.api-b.model}") String model) {

        return OpenAiChatModel.builder()
                .options(OpenAiChatOptions.builder()
                        .baseUrl(baseUrl)
                        .apiKey(apiKey)
                        .model(model)
                        .build())
                .build();
    }
}

这里的 baseUrl 到底要不要包含 /v1,不能只凭经验判断。不同兼容网关对路径的约定可能不同,最终应以它实际实现的接口和应用真正发出的 URL 为准。

3. 再为两个模型创建 ChatClient

有了两个模型 Bean 后,再分别创建业务客户端:

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientBuilderConfigurer;
import org.springframework.beans.factory.annotation.Qualifier;

@Bean("apiAChatClient")
public ChatClient apiAChatClient(
        @Qualifier("apiAChatModel") OpenAiChatModel chatModel,
        ChatClientBuilderConfigurer configurer) {

    return configurer
            .configure(ChatClient.builder(chatModel))
            .build();
}

@Bean("apiBChatClient")
public ChatClient apiBChatClient(
        @Qualifier("apiBChatModel") OpenAiChatModel chatModel,
        ChatClientBuilderConfigurer configurer) {

    return configurer
            .configure(ChatClient.builder(chatModel))
            .build();
}

我这里使用方法参数注入,是为了让依赖关系直接出现在方法签名中:apiAChatClient 依赖 apiAChatModelapiBChatClient 依赖 apiBChatModel

ChatClientBuilderConfigurer 则会把 Spring Boot 为 ChatClient.Builder 准备的统一配置和自定义器继续应用到手动创建的 Builder 上。对于要在 Spring 容器中长期管理的多个 ChatClient,这种写法比在业务方法中临时 ChatClient.create(...) 更容易保持一致。

三、三个问题,让对象边界逐渐清楚

1. Bean 名找到了,类型却不匹配

一开始,我只创建了两个 OpenAiChatModel,却在 Controller 中这样注入:

java 复制代码
@Qualifier("apiBChatModel")
ChatClient apiBChatClient

IDE 很快提示:

text 复制代码
无法自动装配。限定 Bean 必须为 'ChatClient' 类型。

类型在这里对不上:apiBChatModelOpenAiChatModel,注入点要求的却是 ChatClient

@Qualifier 只能在候选 Bean 中进一步筛选,不能把一个 OpenAiChatModel 转换成 ChatClient。补上前面两个 ChatClient Bean 后,对象关系才完整。

2. 手动模型已经有 Key,为什么启动仍然报 credential 缺失

模型 Bean 配好以后,项目启动阶段又出现过类似错误:

text 复制代码
Error creating bean with name 'openAiSdkAudioSpeechModel'
At least one credential source must be specified: credential (apiKey)

这次报错来自 OpenAI Starter 自动配置的其他能力,例如语音模型,并非我们手动创建的聊天模型。

需要分清两套配置:手动创建的 OpenAiChatModel 使用当前 OpenAiChatOptions 中的 baseUrlapiKey;Spring Boot 自动配置的 OpenAI Bean 读取的是 spring.ai.openai.*

一个模型实例中的 Key,不会自动变成其他自动配置 Bean 的全局 Key。如果项目还要使用自动配置出来的 OpenAI 能力,就应该通过环境变量正确配置 spring.ai.openai.*;如果只准备使用手动创建的聊天模型,则可以关闭不需要的 OpenAI 自动配置,而不是为了让应用启动就在仓库里放一个占位或真实密钥。

例如,可以按项目实际需要排除未使用的自动配置:

yaml 复制代码
spring:
  autoconfigure:
    exclude:
      - org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration

是否排除这些类取决于项目确实需要哪些能力,这不是所有 Spring AI 应用都必须照抄的固定配置。

如果异常栈中出现 com.openai.errors.*com.openai.core.*openai-java-*,也不必奇怪:Spring AI 2.0 的 OpenAI 实现底层已经使用 OpenAI 官方 Java SDK,部分 HTTP 与协议异常会从这些包中抛出。

3. Controller 正常,为什么上游仍然返回 404

项目能启动后,我在真实调用中还遇到过:

text 复制代码
com.openai.errors.NotFoundException: 404: Not Found

这个 404 通常来自兼容网关,不是本地 Controller。常见原因是最终路径不符合网关约定:实际请求地址由配置的 baseUrl 和 SDK 使用的 /chat/completions 路径共同决定。

我通过在本地添加log,通过调用记录查看错误原因:

java 复制代码
import org.springframework.ai.openai.http.okhttp.OpenAiHttpClientBuilderCustomizer;

@Bean
public OpenAiHttpClientBuilderCustomizer requestLogger() {
    return builder -> builder.interceptor(chain -> {
        var request = chain.request();
        System.out.println("[AI请求] " + request.method() + " " + request.url());

        var response = chain.proceed(request);
        var body = response.peekBody(1024 * 1024).string();
        System.out.println("[AI响应] HTTP " + response.code() + " " + body);

        return response;
    });
}

把它应用到模型 Builder 后,就可以确认请求到底发到了哪里:

java 复制代码
OpenAiChatModel.builder()
        .httpClientBuilderCustomizer(requestLogger())
        .options(...)
        .build();

这三个问题刚好形成了一个很实用的排查顺序:先确认对象类型与 Bean 关系,再检查 Spring Boot 自动配置,最后核对真正发出的 HTTP 请求。

到这里,两个渠道已经可以分别回答问题。但它们仍然只是两条独立的、无状态的模型调用链路。

四、模型无状态,Spring AI Core 如何补充上下文

LLM 本身并不会因为我们连续调用同一个接口,就自动记住上一轮内容。每次请求真正交给模型的,仍然是本次组装出来的一组消息。

如果第二次只问"我叫什么名字?",模型并不知道第一轮已经说过自己叫"进阶的小名"。

所谓的短期记忆:就是将内容存在内存空间,调用模型之前取出历史消息,把它们和本次输入一起放进 Prompt;调用完成后,再把本轮消息写回存储。

Spring AI Core 的基础记忆方案由下面三个组件协作完成:

  • MessageChatMemoryAdvisor 负责在模型调用前后读写历史消息;
  • ChatMemory 负责决定保留哪些消息;
  • ChatMemoryRepository 负责消息存在哪里。

默认的 MessageWindowChatMemory 是窗口式记忆,maxMessages 默认值为 20。裁剪时 System Message 会被保留,旧的非 System 消息则从完整的 User Turn 边界开始淘汰,所以活动消息数不一定恰好是 20,也不是机械截取列表末尾 20 条。

这里有两个很容易误解的点。

第一,Spring 容器中存在一个 ChatMemory Bean,不代表某个 ChatClient 已经启用了记忆。真正把二者接起来的是 Memory Advisor:

java 复制代码
.defaultAdvisors(
        MessageChatMemoryAdvisor.builder(chatMemory).build()
)

第二,仅仅在一次请求中传入 conversationId,也不会凭空创建记忆能力。这个 ID 只是告诉已经挂载的 Advisor:这次应该读取和写入哪一段上下文。

基础窗口记忆适合快速建立多轮对话,但它也带来一个新问题:如果旧消息被直接淘汰,里面的重要事实也会随之离开模型上下文。

这正是我继续尝试 Session 的原因。

五、从 ChatMemory 走向 Community Session

上面咱们讨论到的,两个 ChatClient 最终没有挂载 MessageChatMemoryAdvisor,而是共同挂载了 Spring AI Community 提供的 SessionMemoryAdvisor

在 Session 方案中,SessionMemoryAdvisor 负责衔接 ChatClientSessionService,后者再通过 SessionRepository 读写会话。这套抽象不只保存一组 Message,还把对话进一步组织成 User、Session、Turn 和 Event。

这里可以先这样理解:

  • Session:一段可独立定位的会话;
  • Event:会话中发生的一条记录,例如用户消息、助手消息或工具结果;
  • Turn:从一条用户消息开始,到下一条用户消息之前为止的一次完整交互。

⚠️注:普通问答中的一个 Turn 通常只有 UserMessageAssistantMessage;涉及工具时,同一 Turn 还可能包含 Assistant Tool Call 和 Tool Response。

为什么要引入 Turn?因为未来加入 Tool Calling 后,一轮交互可能不再只是"一问一答"两条消息。它可能包含模型发起工具调用、工具返回结果、模型继续回答等多个 Event。按照 Turn 管理和压缩上下文,通常比简单地数消息更符合一次任务的语义边界。

SessionMemoryAdvisor 在每次调用中主要完成 4 件事:

  1. 根据 sessionId 找到或创建 Session。
  2. 读取当前 Session 的 Event,并放到本次 Prompt 前面。
  3. 保存本轮 UserMessageAssistantMessage
  4. 满足条件时执行 Compaction。

与允许使用默认会话的模糊做法不同,当前版本的 SessionMemoryAdvisor 要求每次请求都显式提供 Session ID。遗漏时,它会在调用上游模型之前抛出异常,避免多个用户意外落进同一段默认会话。

六、让两个 ChatClient 共享 Session,再切换模型验证

先创建一个共享的 SessionMemoryAdvisor。下面直接展示加入递归摘要后的最终配置,其中 Trigger 和 Strategy 的参数会在第八节结合实验解释:

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.session.DefaultSessionService;
import org.springframework.ai.session.InMemorySessionRepository;
import org.springframework.ai.session.advisor.SessionMemoryAdvisor;
import org.springframework.ai.session.compaction.RecursiveSummarizationCompactionStrategy;
import org.springframework.ai.session.compaction.TurnCountTrigger;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;

@Bean
public SessionMemoryAdvisor sessionMemoryAdvisor(
  			// 用于总结对话的模型,这里也可以用上文提到的定义好的 OpenAiChatModel
        @Qualifier("apiAChatModel") OpenAiChatModel summaryModel) {

    var sessionRepository = InMemorySessionRepository.builder().build();
    var sessionService = DefaultSessionService.builder()
            .sessionRepository(sessionRepository)
            .build();

    var summaryClient = ChatClient.builder(summaryModel).build();

    return SessionMemoryAdvisor.builder(sessionService)
            .compactionTrigger(new TurnCountTrigger(1))
            .compactionStrategy(
                    RecursiveSummarizationCompactionStrategy
                            .builder(summaryClient)
                            .maxEventsToKeep(2)
                            .overlapSize(1)
                            .build()
            )
            .build();
}

然后把同一个 Advisor 挂到两个客户端:

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientBuilderConfigurer;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.session.advisor.SessionMemoryAdvisor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;

@Bean("apiAChatClient")
public ChatClient apiAChatClient(
        @Qualifier("apiAChatModel") OpenAiChatModel chatModel,
        SessionMemoryAdvisor sessionMemoryAdvisor,
        ChatClientBuilderConfigurer configurer) {

    return buildChatClient(chatModel, sessionMemoryAdvisor, configurer);
}

@Bean("apiBChatClient")
public ChatClient apiBChatClient(
        @Qualifier("apiBChatModel") OpenAiChatModel chatModel,
        SessionMemoryAdvisor sessionMemoryAdvisor,
        ChatClientBuilderConfigurer configurer) {

    return buildChatClient(chatModel, sessionMemoryAdvisor, configurer);
}

private ChatClient buildChatClient(
        OpenAiChatModel chatModel,
        SessionMemoryAdvisor sessionMemoryAdvisor,
        ChatClientBuilderConfigurer configurer) {

    var builder = ChatClient.builder(chatModel)
            .defaultAdvisors(
                    sessionMemoryAdvisor,
                    SimpleLoggerAdvisor.builder().build()
            );

    return configurer.configure(builder).build();
}

这样,两条模型调用链会在 Session 层汇合。两个模型要共享上下文,需要同时满足三个条件:

  1. 两个 ChatClient 都挂载 SessionMemoryAdvisor
  2. 它们最终访问同一套 SessionServiceSessionRepository
  3. 每次请求使用相同的 sessionId

只有 sessionId 相同还不够。如果两个 Advisor 分别持有互不相干的内存 Repository,即使 ID 一样,也无法读到对方保存的事件。

每次调用都传入 Session ID

Controller 可以这样写:

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.session.advisor.SessionMemoryAdvisor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/ai")
public class ChatController {

    private static final String DEMO_SESSION_ID = "user-10001";
    private static final String DEMO_USER_ID = "xiaoming";

    private final ChatClient apiAChatClient;
    private final ChatClient apiBChatClient;

    public ChatController(
            @Qualifier("apiAChatClient") ChatClient apiAChatClient,
            @Qualifier("apiBChatClient") ChatClient apiBChatClient) {
        this.apiAChatClient = apiAChatClient;
        this.apiBChatClient = apiBChatClient;
    }

    @GetMapping("/chat/api-a")
    public String chatWithApiA(@RequestParam String message) {
        return ask(apiAChatClient, message);
    }

    @GetMapping("/chat/api-b")
    public String chatWithApiB(@RequestParam String message) {
        return ask(apiBChatClient, message);
    }

    private String ask(ChatClient chatClient, String message) {
        return chatClient
                .prompt()
                .advisors(this::withDemoSession)
                .user(message)
                .call()
                .content();
    }

    private void withDemoSession(ChatClient.AdvisorSpec advisor) {
        advisor
                .param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY,
                        DEMO_SESSION_ID)
                .param(SessionMemoryAdvisor.USER_ID_CONTEXT_KEY,
                        DEMO_USER_ID);
    }
}

切换模型以后,为什么还能记得名字

完成上面的配置后,小名做了一个很简单的跨模型测试。

第一轮请求走 apiAChatClient

text 复制代码
我叫"进阶的小名",请记住我的名字。

模型名和回答内容:

json 复制代码
{
  "model": "gpt-5.6-terra",
  "choices": [{"message": {"role": "assistant", "content": "你好,进阶的小名。"}}]
}

第二轮切换到 apiBChatClient

text 复制代码
我叫什么名字?

这一次,可以看到响应中的模型标识已经变成 gpt-5.5,尽管切换到了apiB且模型也换成了gpt-5.5但它仍然答出了第一轮保存的名字:

json 复制代码
{
  "model": "gpt-5.5",
  "choices": [{"message": {"role": "assistant", "content": "你叫"进阶的小名"。"}}]
}

第一轮由 apiAChatClient 写入姓名,第二轮换成 apiBChatClient 后仍然得到正确答案。第二个模型并不是"读取了第一个模型的记忆":第一轮结束时,SessionMemoryAdvisor 已经把事件写入 Repository;第二轮开始时,同一个 Advisor 按照相同的 sessionId 取出历史,再把它和当前问题一起交给另一个模型。

这说明短期上下文属于应用侧的 Memory / Session 管理,而不是绑定在某一个远程模型、某一个 API Key 或某一个 baseUrl 上。

如果业务希望两个渠道共享上下文,当前设计就是有意的。如果希望它们相互隔离,可以把渠道维度放进 Session ID:

java 复制代码
String sessionId = apiChannel + ":" + userSessionId;

是否共享不是技术框架替我们决定的,而是业务中的会话边界决定的。

七、Chat Memory 不等于完整聊天记录

在继续讨论压缩之前,还有一个概念必须分清:模型记忆和产品中的完整聊天记录,不是一回事。

假设一个用户已经和 AI 聊了 500 条消息,业务上可能希望把这 500 条全部保存在数据库中,用于会话列表、历史回看、搜索、审计或重新生成。但模型下一次回答问题时,通常没有必要把 500 条消息原封不动地全部发送过去(主要这样 Token 的成本也很高😂)。

Chat History 面向产品与用户,负责完整、可追溯地保存聊天记录;Chat MemorySession Context 面向模型调用,负责选出当前回答真正需要的上下文。

窗口淘汰、摘要压缩等操作可以发生在模型上下文中,但不应该删除产品需要保存的完整历史。

咱们现在使用的 InMemorySessionRepository,应用重启后 Session 就会消失;它也不承担完整聊天记录的存储职责。

八、当上下文越来越长,Compaction 开始介入

随着每轮携带的历史不断增加,Prompt 会越来越长,Token 消耗和响应时延也会随之上升,最终还可能超过模型的上下文限制。

最直接的做法是只保留最近一段窗口,旧事件直接删除。但这样一来,较早出现的姓名、偏好、约定和未完成任务也会一起消失。

Session 的 Compaction 把两个问题分开处理:CompactionTrigger 决定何时启动压缩,CompactionStrategy 决定历史事件具体怎样被压缩。

当前 spring-ai-session 中可以看到几类不同策略:

  • SlidingWindowCompactionStrategy:保留最近的真实 Event,直接归档更早的 Event;
  • TurnWindowCompactionStrategy:按完整 Turn 保留最近窗口;
  • TokenCountCompactionStrategy:根据估算 Token 数控制上下文;
  • RecursiveSummarizationCompactionStrategy:调用模型,把较早事件压缩成滚动摘要。

接下来小名要演示的是最后一种,因为我想验证的不是硬截断,而是:旧事实变成摘要后,是否还能参与后续回答。

1. TurnCountTrigger(1) 到底表示什么

配置中使用了:

java 复制代码
.compactionTrigger(new TurnCountTrigger(1))

它的准确含义是:当当前会话的 Turn 数量 大于 1 时,触发压缩流程。第一轮结束时只有一个 Turn,不触发;第二轮结束后超过阈值,开始调用压缩策略。

Advisor 会在每轮响应保存完成后评估 Trigger,而 Strategy 自己还会判断是否真的存在可压缩的事件。因此,"进入压缩流程"和"最终一定调用摘要模型"也不是完全相同的概念。

阈值设成 1 只是为了在三轮对话内看见结果,生产取值会在第九节集中讨论。

2. maxEventsToKeepoverlapSize

递归摘要策略的两个关键参数是:

java 复制代码
.maxEventsToKeep(2)
.overlapSize(1)

maxEventsToKeep(2) 以最近两条真实根事件作为活动窗口预算,再把切点调整到下一个 User Turn 边界。在普通一问一答中,这通常就是最近一轮 User 和 Assistant;含工具或分支事件时,不应机械理解为最终恰好两条消息。

overlapSize(1) 表示生成摘要时,把保留窗口开头的一条事件也提供给摘要模型,帮助它理解新旧上下文如何衔接。

overlapSize 只是进入摘要 Prompt 的连续性参考,并不会在最终 Session 中"额外多保留一条"真实事件。

这两个值必须满足 overlapSize < maxEventsToKeep

如果写成:

java 复制代码
.maxEventsToKeep(2)
.overlapSize(2)

Bean 会在创建阶段失败,并提示:

text 复制代码
overlapSize (2) must be less than maxEventsToKeep (2)

3. 为什么叫"递归摘要"

第一次压缩时,摘要模型读取较早的真实事件并生成摘要。下一次再压缩时,它不会回头重新读取所有已经归档的原始事件,而是把上一次摘要和新变旧的事件继续合并成下一版摘要,由此形成持续滚动的上下文。

最终放回 Session 的不是一条孤立文本,而是一对合成事件:

text 复制代码
User:Summarize the conversation we had so far.
Assistant:此前对话的摘要内容

后面再接仍然保留的最近真实事件。这样模型看到的消息角色仍然保持完整的 User / Assistant 交替结构。

九、✨✨✨三轮真实请求日志,验证递归摘要确实生效✨✨✨

为了快速触发压缩,这次测试沿用了上一节的低阈值配置。

下面的内容来自这三轮请求的 SimpleLoggerAdvisor 与 HTTP 响应日志。为了便于阅读,我删除了响应 ID、时间戳和 Token 统计,并压缩了与结论无关的字段;用户输入、模型标识和回答内容保持不变。

这组测试改用了一个全新的 Session ID;重启应用、清空 InMemorySessionRepository 也能达到相同效果。它没有沿用上一节跨模型测试已经写入姓名的 DEMO_SESSION_ID,否则下面的"第一轮 Prompt 只有本轮消息"就不成立。

主对话由 API_Bgpt-5.5)负责,摘要任务交给 API_Agpt-5.6-terra)。这不是必须的设计,只是顺便验证:负责回答用户和负责维护上下文,可以使用不同的模型客户端。这里的模型名来自兼容网关响应,不代表本文在确认它们是 OpenAI 官方公开型号。

第一轮:写入姓名

用户输入:

text 复制代码
我叫"进阶的小名",请记住我的名字。只回复:已记住。

主模型收到的 Prompt 只有本轮用户消息,并返回:

text 复制代码
已记住。

此时 Session 中有一轮真实对话:

text 复制代码
User:我叫"进阶的小名"......
Assistant:已记住。

只有一个 Turn,因此 TurnCountTrigger(1) 还不会触发。

第二轮:写入颜色,并触发第一次摘要

第二轮输入:

text 复制代码
我喜欢蓝色,请记住。只回复:已记住。

在主模型调用前,SessionMemoryAdvisor 已经把第一轮历史放进 Prompt:

text 复制代码
messages=[
  User:我叫"进阶的小名",请记住我的名字。只回复:已记住。
  Assistant:已记住。
  User:我喜欢蓝色,请记住。只回复:已记住。
]

主模型返回:

text 复制代码
已记住。

第二轮结束后,会话已经超过一个 Turn,同时四条真实事件也超过了 maxEventsToKeep(2)。递归摘要策略于是额外调用摘要客户端,脱敏日志中的摘要内容是:

text 复制代码
用户表示自己叫"进阶的小名",并要求记住;助手已回复"已记住"。

这次额外请求不是在替用户回答问题,而是在维护 Session。第一轮的两条真实事件被归档,活动上下文变成:

text 复制代码
合成 User:Summarize the conversation we had so far.
合成 Assistant:用户表示自己叫"进阶的小名"......
真实 User:我喜欢蓝色,请记住......
真实 Assistant:已记住。

第三轮:摘要和最近原文一起参与回答

第三轮询问:

text 复制代码
我叫什么名字?我喜欢什么颜色?

这次 SimpleLoggerAdvisor 打印出的请求已经能看到压缩后的结构:

text 复制代码
messages=[
  UserMessage{
    content='Summarize the conversation we had so far.'
  },
  AssistantMessage{
    textContent='用户表示自己叫"进阶的小名",并要求记住;助手已回复"已记住"。'
  },
  UserMessage{
    content='我喜欢蓝色,请记住。只回复:已记住。'
  },
  AssistantMessage{
    textContent='已记住。'
  },
  UserMessage{
    content='我叫什么名字?我喜欢什么颜色?'
  }
]

主模型最终回答:

text 复制代码
你叫"进阶的小名",喜欢蓝色。

这个结果同时命中了两类上下文:姓名来自已经压缩的旧对话摘要,颜色来自最近保留的真实对话。

因此,证明压缩链路生效的关键不只是"模型答对了"。更直接的证据是:第三轮真正交给主模型的 Prompt 中,已经明确出现了"姓名摘要 + 颜色原文"。

第三轮完成后,摘要模型还会继续生成新的滚动摘要:

text 复制代码
用户表示自己叫"进阶的小名",喜欢蓝色,并要求记住;助手均已确认记住。

这就是"递归"的含义:旧摘要继续参与下一次摘要,而不是每次重新读取全部原始历史。

十、递归摘要与滑动窗口,应该怎么选

两种策略都能限制活动上下文长度,但代价完全不同。

SlidingWindowCompactionStrategy 直接把旧事件移出最近窗口,不需要额外调用模型,简单且便宜;代价是被移出的事实不再进入后续上下文。

RecursiveSummarizationCompactionStrategy 会先把旧事件归纳成滚动摘要,有机会保留其中的重要事实;代价是增加模型调用、响应时延、费用和摘要误差。

如果只是短对话或一次性问答,滑动窗口通常已经足够。如果是长期任务、用户偏好、阶段性决策或持续 Agent 会话,递归摘要会更有价值。

十一、小结

  1. 多渠道的边界首先由不同的 ChatModel 决定,而不是由 Bean 名或 ChatClient 数量决定。
  2. ChatClient 是否具备记忆,取决于是否挂载了相应 Advisor,而不是容器里是否恰好存在 Memory Bean。
  3. 跨模型共享上下文,是应用侧共享 Session 存储与会话标识的结果。
  4. Session 把对话从简单的消息列表扩展成 Event、Turn 和可压缩的上下文。
  5. 递归摘要可以延续较早事实,但会增加一次模型调用,也会引入有损压缩的风险。

下一篇预告:从"会聊天"到"能做事",探索 Spring AI 2.0 的 Agent 能力

接下来我们要处理的问题是:模型有了上下文之后,怎样真正调用外部能力,参与完成任务?

答案不是只能回答 Prompt 中的问题,而是让模型先提出工具调用,由应用执行工具,再把结果交给模型继续推理。

Spring AI 2.0 把工具执行循环从各个 ChatModel 的内部实现提升到了 ChatClient 的 Advisor Chain。模型仍负责产生 Tool Call,ToolCallingAdvisor 则驱动工具执行和结果回填,让日志、权限、记忆与工具调用可以在同一条链路中组合。

下一篇小名会从 Tool Calling 开始,继续探索 @ToolToolCallback 和 Agent 的关系;再看看 Spring AI Community 的 Agent Utils 如何提供可复用的 Agent 能力,以及 MCP 如何把外部的 Tool、Resource 和 Prompt 接入 Spring AI。

如果说这一篇解决的是"如何让 AI 记住并压缩上下文",下一篇要继续回答的就是:"如何让模型调用能力,并真正参与完成任务?"

相关推荐
卷无止境1 小时前
FastAPI 的 Metadata 到底是什么,又牵动了哪些核心概念
后端·python
卷无止境1 小时前
FastAPI 调试实战,从断点到生产环境的排错心法
后端·python
敢敢のwings2 小时前
智元 GO-2 与 AgiBot-World 深度解读
开发语言·后端·golang
新知图书2 小时前
8.4 处理智能体的工具调用与输出解析《LangGraph开发AI Agent实践》
人工智能·agent·ai agent·智能体
yaoxin5211232 小时前
503. Java 反射 - 编写 ServiceFactory 类
java·开发语言·python
冬奇Lab2 小时前
开源项目第197期:skill-up — 阿里巴巴出品的 Agent Skills 评测与进化工具,评测闭环 + 自动修复
人工智能·开源·资讯
玫瑰互动GEO2 小时前
海外GEO优化案例-ChatGPT搜索关键词排名GEO优化案例详解(含RAG机制与Tokenization技术拆解)
人工智能·ai·chatgpt·geo优化
冬奇Lab2 小时前
Code Agent 解剖(10):agent 崩了怎么恢复,对话历史存在哪?
人工智能·开源·agent
IanSkunk2 小时前
视光中心建设复盘:从流程断层到组织能力的落地路径
大数据·人工智能