
最近在体验 Spring AI 2.0 时,我最开始给自己定的目标并不复杂:在同一个 Spring Boot 项目中,同时接入两个 OpenAI-Compatible 接口。
两个渠道都使用类似 OpenAI 的请求格式,但它们的 baseUrl、apiKey 和模型名各不相同。按照直觉,只要分别配置两个客户端,然后调用不同的 Bean,事情似乎就结束了。
真正动手以后,我才发现"把模型接通"只是第一步。
当应用中出现第二个模型后,一连串更值得研究的问题也跟着出现了:
OpenAiChatModel和ChatClient到底是什么关系?- 两个模型 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_A 和 API_B。它们各自拥有独立的 baseUrl、apiKey 和模型标识:API_A 使用 gpt-5.6-terra,API_B 使用 gpt-5.5。
本项目为每个渠道创建一个 OpenAiChatModel,再配置一个供业务代码使用的 ChatClient。apiAChatClient 通过 apiAChatModel 访问 API_A,apiBChatClient 则通过 apiBChatModel 访问 API_B。
1. ChatModel、OpenAiChatModel 和 ChatClient
这三个对象很容易在第一次接触时混在一起。
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 依赖 apiAChatModel,apiBChatClient 依赖 apiBChatModel。
ChatClientBuilderConfigurer 则会把 Spring Boot 为 ChatClient.Builder 准备的统一配置和自定义器继续应用到手动创建的 Builder 上。对于要在 Spring 容器中长期管理的多个 ChatClient,这种写法比在业务方法中临时 ChatClient.create(...) 更容易保持一致。
三、三个问题,让对象边界逐渐清楚
1. Bean 名找到了,类型却不匹配
一开始,我只创建了两个 OpenAiChatModel,却在 Controller 中这样注入:
java
@Qualifier("apiBChatModel")
ChatClient apiBChatClient
IDE 很快提示:
text
无法自动装配。限定 Bean 必须为 'ChatClient' 类型。
类型在这里对不上:apiBChatModel 是 OpenAiChatModel,注入点要求的却是 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 中的 baseUrl 和 apiKey;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 负责衔接 ChatClient 与 SessionService,后者再通过 SessionRepository 读写会话。这套抽象不只保存一组 Message,还把对话进一步组织成 User、Session、Turn 和 Event。
这里可以先这样理解:
- Session:一段可独立定位的会话;
- Event:会话中发生的一条记录,例如用户消息、助手消息或工具结果;
- Turn:从一条用户消息开始,到下一条用户消息之前为止的一次完整交互。
⚠️注:普通问答中的一个 Turn 通常只有 UserMessage 和 AssistantMessage;涉及工具时,同一 Turn 还可能包含 Assistant Tool Call 和 Tool Response。
为什么要引入 Turn?因为未来加入 Tool Calling 后,一轮交互可能不再只是"一问一答"两条消息。它可能包含模型发起工具调用、工具返回结果、模型继续回答等多个 Event。按照 Turn 管理和压缩上下文,通常比简单地数消息更符合一次任务的语义边界。
SessionMemoryAdvisor 在每次调用中主要完成 4 件事:
- 根据
sessionId找到或创建 Session。 - 读取当前 Session 的 Event,并放到本次 Prompt 前面。
- 保存本轮
UserMessage和AssistantMessage。 - 满足条件时执行 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 层汇合。两个模型要共享上下文,需要同时满足三个条件:
- 两个
ChatClient都挂载SessionMemoryAdvisor。 - 它们最终访问同一套
SessionService和SessionRepository。 - 每次请求使用相同的
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 Memory 或 Session 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. maxEventsToKeep 与 overlapSize
递归摘要策略的两个关键参数是:
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_B(gpt-5.5)负责,摘要任务交给 API_A(gpt-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 会话,递归摘要会更有价值。
十一、小结
- 多渠道的边界首先由不同的
ChatModel决定,而不是由 Bean 名或ChatClient数量决定。 ChatClient是否具备记忆,取决于是否挂载了相应 Advisor,而不是容器里是否恰好存在 Memory Bean。- 跨模型共享上下文,是应用侧共享 Session 存储与会话标识的结果。
- Session 把对话从简单的消息列表扩展成 Event、Turn 和可压缩的上下文。
- 递归摘要可以延续较早事实,但会增加一次模型调用,也会引入有损压缩的风险。
下一篇预告:从"会聊天"到"能做事",探索 Spring AI 2.0 的 Agent 能力
接下来我们要处理的问题是:模型有了上下文之后,怎样真正调用外部能力,参与完成任务?
答案不是只能回答 Prompt 中的问题,而是让模型先提出工具调用,由应用执行工具,再把结果交给模型继续推理。
Spring AI 2.0 把工具执行循环从各个 ChatModel 的内部实现提升到了 ChatClient 的 Advisor Chain。模型仍负责产生 Tool Call,ToolCallingAdvisor 则驱动工具执行和结果回填,让日志、权限、记忆与工具调用可以在同一条链路中组合。
下一篇小名会从 Tool Calling 开始,继续探索 @Tool、ToolCallback 和 Agent 的关系;再看看 Spring AI Community 的 Agent Utils 如何提供可复用的 Agent 能力,以及 MCP 如何把外部的 Tool、Resource 和 Prompt 接入 Spring AI。
如果说这一篇解决的是"如何让 AI 记住并压缩上下文",下一篇要继续回答的就是:"如何让模型调用能力,并真正参与完成任务?"