1. 概述
Spring AI Session 围绕会话(Session) 管理,定义了四个紧密关联的核心概念,它们共同描述了"一次完整的 AI 对话"如何被存储、组织和随时间演进。这四个概念分别是:
- 会话(Session):对话的身份与生命周期容器。
- 会话事件(SessionEvent):对话中的每一条消息(含元数据)。
- 轮次(Turn):一次用户输入及其引发的所有后续响应的原子单元。
- 合成摘要轮次(Synthetic Summary Turn):由压缩策略生成的两条合成消息,用于替换旧历史。
理解这些概念是正确使用 Spring AI Session 的基础。下面我们一一展开。
2. 会话(Session)
会话 代表一个用户与 AI 代理之间单次、连续对话 的标识和生命周期容器。它是一个不可变的值对象(immutable value object) ------仅持有元数据(metadata),不包含具体的事件列表 。事件日志单独存储在 SessionRepository 中,按需查询。
会话的字段
| 字段 | 描述 |
|---|---|
id |
全局唯一会话标识符 |
userId |
所属用户或代理标识,必填,用于隔离不同用户 |
createdAt |
创建时间戳 |
expiresAt |
过期时间点。默认从创建时刻起算,使用配置的默认 TTL(60 天);若为 null 则表示永不过期。构建器会拒绝设置过去的时间值 |
metadata |
任意键值对,用于存放模型信息、标签等扩展数据 |
Session 只包含元数据
将事件列表从 Session 中剥离,使得 Session 对象可以轻量地在系统边界间传递,而压缩策略也能将事件列表作为显式参数接收,无需从 Session 对象中提取。这保持了 Session 的不可变性,也方便了后续的事件变更操作。
创建会话示例
java
SessionService service = new DefaultSessionService(InMemorySessionRepository.builder().build());
Session session = service.create(
CreateSessionRequest.builder()
.id("my-session-id") // 可选,不指定则自动生成 UUID
.userId("alice")
.timeToLive(Duration.ofHours(2)) // 可选,默认 60 天
.metadata("agentType", "research-assistant")
.build()
);
3. 会话事件(SessionEvent)
会话事件 是对 Spring AI 中 Message 类型的不可变包装 ,它补充了 Message 本身所缺少的信息:身份标识、归属关系、时序顺序以及框架内部标记。
事件的字段
| 字段 | 描述 |
|---|---|
id |
事件唯一标识(默认 UUID) |
sessionId |
所属会话 ID,用于归属和隔离 |
timestamp |
时间戳(默认 Instant.now()),用于时间排序 |
message |
原始的 Spring AI 消息对象,不复制其内容 |
metadata |
框架标志,如 METADATA_SYNTHETIC(合成标记)和 METADATA_COMPACTION_SOURCE(压缩来源) |
branch |
点分隔的代理路径(例如 "orch.researcher"),null 表示根级事件 |
消息类型与合成标记
| 消息类型 | isSynthetic() |
含义 |
|---|---|---|
UserMessage(真实) |
false |
真实用户输入 |
AssistantMessage(无工具调用) |
false |
代理的文本回复 |
AssistantMessage(含工具调用) |
false |
代理发起工具调用 |
ToolResponseMessage |
false |
工具返回结果 |
UserMessage(合成) |
true |
合成摘要轮次中的"用户"提示(影子提示) |
AssistantMessage(合成) |
true |
合成摘要轮次中的"助手"摘要文本 |
构建事件示例
java
// 根级事件(无分支),所有代理可见,时间戳默认当前时间
SessionEvent event = SessionEvent.builder()
.sessionId(sessionId)
.message(new UserMessage("你好"))
.build();
// 分支事件 ------ 归属于特定子代理
SessionEvent branched = SessionEvent.builder()
.sessionId(sessionId)
.message(new AssistantMessage("研究结果是..."))
.branch("orch.researcher")
.build();
// 携带自定义元数据
SessionEvent custom = SessionEvent.builder()
.sessionId(sessionId)
.message(new AssistantMessage("回复内容"))
.metadata("model", "gpt-4o")
.metadata("latencyMs", 230)
.build();
// 测试时使用确定性时间戳
SessionEvent deterministic = SessionEvent.builder()
.sessionId(sessionId)
.timestamp(Instant.parse("2025-06-01T12:00:00Z"))
.message(new UserMessage("Hello"))
.build();
构建器默认值 :
id随机 UUID,timestamp为Instant.now(),metadata为空,branch为null。仅sessionId和message为必填项。
4. 轮次(Turn)
轮次 是对话中的原子单元,定义如下:
一个
UserMessage及其之后、直到下一个UserMessage之前的所有后续事件(包括助手回复、工具调用、工具结果)共同组成一个轮次。
需要轮次而非按消息计数
- 确保压缩(compaction)不会把工具调用 与其对应的工具结果拆散。
- 避免删除某条助手回复却保留引发该回复的用户问题,从而破坏对话的因果连贯性。
示例:
轮次 1: [用户] "什么是 Spring AI?" → [助手] "Spring AI 是..."
轮次 2: [用户] "它能用工具吗?" → [助手(工具调用)] → [工具结果] → [助手] "是的,可以..."
轮次 3: [用户] "给我举个例子" → [助手] "例如..."
轮次计数规则
轮次数通过 CompactionRequest.currentTurnCount() 计算,仅统计:
- 非合成 (
isSynthetic == false) - 根级 (
branch == null) - 类型为
UserMessage的事件
合成事件和子代理分支上的
UserMessage会被排除,以防止多代理场景下的轮次数虚高,避免TurnCountTrigger过早触发压缩。
5. 合成摘要轮次(Synthetic Summary Turn)
当压缩策略(如 RecursiveSummarizationCompactionStrategy)对会话进行压缩时,它会将归档的事件替换为两条合成事件,这两条事件共同构成一个完整的对话轮次:
[用户 / 合成] "请总结我们到目前为止的对话。"
[助手 / 合成] "用户询问了主题 X。助手解释了......"
[用户 / 真实] "下一个真正的问题"
[助手 / 真实] "下一个真正的回复"
这种模式模仿了 OpenAI Agents SDK 的影子提示(shadow-prompt)模式,确保下游模型始终看到有效的用户↔助手交替,不会出现连续两条用户消息或助手消息。
所有压缩策略的共同行为
- 将合成事件视为不透明数据,在处理前将其与真实事件分离。
- 保留 所有合成事件,并始终将它们放在压缩输出的最前面。
手动构建合成摘要轮次
java
Instant now = Instant.now();
List<SessionEvent> summaryTurn = List.of(
SessionEvent.builder()
.sessionId(sessionId)
.timestamp(now)
.message(new UserMessage("请总结我们到目前为止的对话。"))
.metadata(SessionEvent.METADATA_SYNTHETIC, true)
.metadata(SessionEvent.METADATA_COMPACTION_SOURCE, "recursive-summarization")
.build(),
SessionEvent.builder()
.sessionId(sessionId)
.timestamp(now)
.message(new AssistantMessage("用户询问了 X。助手解释了..."))
.metadata(SessionEvent.METADATA_SYNTHETIC, true)
.metadata(SessionEvent.METADATA_COMPACTION_SOURCE, "recursive-summarization")
.build()
);
两条事件使用相同的时间戳,确保它们被视为一个原子单元。
6. 架构设计要点
6.1 核心组件关系图
#mermaid-svg-oZeNLmDb12SEuq2d{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oZeNLmDb12SEuq2d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oZeNLmDb12SEuq2d .error-icon{fill:#552222;}#mermaid-svg-oZeNLmDb12SEuq2d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oZeNLmDb12SEuq2d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oZeNLmDb12SEuq2d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oZeNLmDb12SEuq2d .marker.cross{stroke:#333333;}#mermaid-svg-oZeNLmDb12SEuq2d svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oZeNLmDb12SEuq2d p{margin:0;}#mermaid-svg-oZeNLmDb12SEuq2d g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-oZeNLmDb12SEuq2d g.classGroup text .title{font-weight:bolder;}#mermaid-svg-oZeNLmDb12SEuq2d .cluster-label text{fill:#333;}#mermaid-svg-oZeNLmDb12SEuq2d .cluster-label span{color:#333;}#mermaid-svg-oZeNLmDb12SEuq2d .cluster-label span p{background-color:transparent;}#mermaid-svg-oZeNLmDb12SEuq2d .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oZeNLmDb12SEuq2d .cluster text{fill:#333;}#mermaid-svg-oZeNLmDb12SEuq2d .cluster span{color:#333;}#mermaid-svg-oZeNLmDb12SEuq2d .nodeLabel,#mermaid-svg-oZeNLmDb12SEuq2d .edgeLabel{color:#131300;}#mermaid-svg-oZeNLmDb12SEuq2d .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-oZeNLmDb12SEuq2d .label text{fill:#131300;}#mermaid-svg-oZeNLmDb12SEuq2d .labelBkg{background:#ECECFF;}#mermaid-svg-oZeNLmDb12SEuq2d .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-oZeNLmDb12SEuq2d .classTitle{font-weight:bolder;}#mermaid-svg-oZeNLmDb12SEuq2d .node rect,#mermaid-svg-oZeNLmDb12SEuq2d .node circle,#mermaid-svg-oZeNLmDb12SEuq2d .node ellipse,#mermaid-svg-oZeNLmDb12SEuq2d .node polygon,#mermaid-svg-oZeNLmDb12SEuq2d .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oZeNLmDb12SEuq2d .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d g.clickable{cursor:pointer;}#mermaid-svg-oZeNLmDb12SEuq2d g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-oZeNLmDb12SEuq2d g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-oZeNLmDb12SEuq2d .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-oZeNLmDb12SEuq2d .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-oZeNLmDb12SEuq2d .dashed-line{stroke-dasharray:3;}#mermaid-svg-oZeNLmDb12SEuq2d .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-oZeNLmDb12SEuq2d #compositionStart,#mermaid-svg-oZeNLmDb12SEuq2d .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #compositionEnd,#mermaid-svg-oZeNLmDb12SEuq2d .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #dependencyStart,#mermaid-svg-oZeNLmDb12SEuq2d .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #dependencyStart,#mermaid-svg-oZeNLmDb12SEuq2d .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #extensionStart,#mermaid-svg-oZeNLmDb12SEuq2d .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #extensionEnd,#mermaid-svg-oZeNLmDb12SEuq2d .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #aggregationStart,#mermaid-svg-oZeNLmDb12SEuq2d .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #aggregationEnd,#mermaid-svg-oZeNLmDb12SEuq2d .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #lollipopStart,#mermaid-svg-oZeNLmDb12SEuq2d .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d #lollipopEnd,#mermaid-svg-oZeNLmDb12SEuq2d .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-oZeNLmDb12SEuq2d .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-oZeNLmDb12SEuq2d .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oZeNLmDb12SEuq2d .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oZeNLmDb12SEuq2d .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oZeNLmDb12SEuq2d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} uses
持有(通过Repository)
1
0..*
Session
+String id
+String userId
+Instant createdAt
+Instant expiresAt
+Map<String,Object> metadata
SessionEvent
+UUID id
+String sessionId
+Instant timestamp
+Message message
+Map<String,Object> metadata
+String branch
+boolean isSynthetic()
+boolean isArchived()
<<interface>>
SessionService
+create(CreateSessionRequest) : Session
+getEvents(sessionId) : List<SessionEvent>
+appendMessage(sessionId, Message) : void
+compactEvents(sessionId, expectedVersion, strategy) : CompactionResult
+delete(sessionId) : void
+deleteExpiredSessions(Instant) : int
DefaultSessionService
-SessionRepository repository
<<interface>>
SessionRepository
+save(session)
+findById(id)
+appendEvent(sessionId, event)
+compactEvents(sessionId, archived, retained, version)
+getEventVersion(sessionId)
+delete(id)
+deleteExpired(instant)
SessionMemoryAdvisor
-SessionService sessionService
-CompactionTrigger trigger
-CompactionStrategy strategy
ChatClient
+prompt()
6.2 为何 Session 不直接携带事件?
- 避免可变引用:若事件列表挂在 Session 内部,任何需要修改列表的操作(如压缩、归档)都必须持有对 Session 内部可变字段的引用,破坏不可变性。
- 职责分离 :Session 仅作为元数据,所有事件变更通过
SessionRepository的专用方法(appendEvent、compactEvents)完成。SessionService将事件列表作为显式参数处理,逻辑更清晰。
6.3 归档而非删除
压缩不会删除 被移出活动上下文的事件,而是通过 compactEvents 将它们标记为 已归档 (archived = true)。完整的历史记录全部保留在日志中。
- 活动上下文 (即
SessionMemoryAdvisor注入到提示中的内容)使用EventFilter.active()视图(excludeArchived = true)。 - 回忆存储(Recall Storage) 搜索(如
EventFilter.keywordSearch(...))则跨越全部日志,包括已归档事件。
这正是 MemGPT 回忆模式 的基础:代理即使在上下文压缩后,仍能检索到任何先前的交流内容。
6.4 乐观并发控制
SessionRepository 提供 getEventVersion(sessionId),返回一个单调递增的版本号,每次 appendEvent 或 compactEvents 都会递增。
调用者流程:
- 读取当前版本号。
- 获取事件列表。
- 执行压缩或追加操作时,将预期版本号作为条件传入
compactEvents(sessionId, archivedEvents, retainedEvents, expectedVersion)。 - 若期间有其他写入者修改了日志,CAS 返回
false,调用者将其视为无操作(不重试)。
持久化实现(JDBC、Redis)应映射为数据库乐观锁列或 Redis
WATCH。
6.5 事件顺序
事件始终按照**插入顺序(即逻辑对话顺序)**返回,而非 墙上时钟时间。
JDBC 实现使用自增的 seq 列来保证顺序,即使合成摘要的时间戳晚于被替换的事件,它们也能被放置在正确的位置(位于被保留的旧事件之前)。
7. 会话生命周期(Session Lifecycle)
SessionRepository SessionService App SessionRepository SessionService App #mermaid-svg-L5XKsngAzYhdo5es{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-L5XKsngAzYhdo5es .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-L5XKsngAzYhdo5es .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-L5XKsngAzYhdo5es .error-icon{fill:#552222;}#mermaid-svg-L5XKsngAzYhdo5es .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-L5XKsngAzYhdo5es .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-L5XKsngAzYhdo5es .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-L5XKsngAzYhdo5es .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-L5XKsngAzYhdo5es .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-L5XKsngAzYhdo5es .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-L5XKsngAzYhdo5es .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-L5XKsngAzYhdo5es .marker{fill:#333333;stroke:#333333;}#mermaid-svg-L5XKsngAzYhdo5es .marker.cross{stroke:#333333;}#mermaid-svg-L5XKsngAzYhdo5es svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-L5XKsngAzYhdo5es p{margin:0;}#mermaid-svg-L5XKsngAzYhdo5es .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L5XKsngAzYhdo5es text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-L5XKsngAzYhdo5es .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-L5XKsngAzYhdo5es .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-L5XKsngAzYhdo5es .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-L5XKsngAzYhdo5es .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-L5XKsngAzYhdo5es #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-L5XKsngAzYhdo5es .sequenceNumber{fill:white;}#mermaid-svg-L5XKsngAzYhdo5es #sequencenumber{fill:#333;}#mermaid-svg-L5XKsngAzYhdo5es #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-L5XKsngAzYhdo5es .messageText{fill:#333;stroke:none;}#mermaid-svg-L5XKsngAzYhdo5es .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L5XKsngAzYhdo5es .labelText,#mermaid-svg-L5XKsngAzYhdo5es .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-L5XKsngAzYhdo5es .loopText,#mermaid-svg-L5XKsngAzYhdo5es .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-L5XKsngAzYhdo5es .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-L5XKsngAzYhdo5es .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-L5XKsngAzYhdo5es .noteText,#mermaid-svg-L5XKsngAzYhdo5es .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-L5XKsngAzYhdo5es .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L5XKsngAzYhdo5es .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L5XKsngAzYhdo5es .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L5XKsngAzYhdo5es .actorPopupMenu{position:absolute;}#mermaid-svg-L5XKsngAzYhdo5es .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-L5XKsngAzYhdo5es .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L5XKsngAzYhdo5es .actor-man circle,#mermaid-svg-L5XKsngAzYhdo5es line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-L5XKsngAzYhdo5es :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} create(request)save(session)sessionsessionappendMessage(sessionId, userMsg)appendEvent(event)okappendMessage(sessionId, asstMsg)appendEvent(event)okgetMessages(sessionId)findById(sessionId)eventsList<Message>delete(sessionId)delete(sessionId)ok
7.1 完整代码示例
java
SessionService service = new DefaultSessionService(InMemorySessionRepository.builder().build());
// 1. 创建
Session session = service.create(
CreateSessionRequest.builder()
.userId("alice")
.build()
);
// 2. 追加事件(便捷方法自动包装 Message 为 SessionEvent)
service.appendMessage(session.id(), new UserMessage("什么是 Spring AI?"));
service.appendMessage(session.id(), new AssistantMessage("Spring AI 是..."));
// 3. 获取消息列表(直接传给 LLM)
List<Message> history = service.getMessages(session.id());
// 4. 获取事件列表(用于过滤、检查、压缩)
List<SessionEvent> events = service.getEvents(session.id());
// 5. 删除单个会话
service.delete(session.id());
// 6. 删除所有过期会话(由调度器调用)
int removed = service.deleteExpiredSessions(Instant.now());
已删除的会话会完全从仓库移除,不存在墓碑状态。
7.2 过期清理
会话不会自动扫描过期------必须显式调用 deleteExpiredSessions(Instant)。建议通过定时任务触发:
java
@Scheduled(fixedRate = 3_600_000) // 每小时执行一次
void sweepExpiredSessions() {
int removed = sessionService.deleteExpiredSessions(Instant.now());
log.info("已清理 {} 个过期会话", removed);
}
deleteExpiredSessions 会查找所有 expiresAt 早于给定时刻的会话,逐个删除,并返回删除数量。
8. 包结构一览
org.springframework.ai.session (Java 包)
├── Session.java -- 不可变元数据对象
├── SessionEvent.java -- 消息包装器
├── SessionService.java -- 生命周期 + 压缩 API
├── SessionRepository.java -- 持久化 SPI
├── CreateSessionRequest.java -- 会话创建参数构建器
├── EventFilter.java -- 事件检索的组合条件
├── DefaultSessionService.java -- 默认服务实现
├── InMemorySessionRepository.java -- 基于 ConcurrentHashMap 的仓库(测试用)
│
├── advisor/
│ └── SessionMemoryAdvisor.java -- ChatClient 通知器(含自动压缩)
│
├── compaction/
│ ├── CompactionRequest.java -- (会话, 事件列表, 事件数, 轮次数)
│ ├── CompactionResult.java -- 压缩后事件 + 归档事件 + 度量信息
│ ├── CompactionStrategy.java -- 策略 SPI
│ ├── CompactionTrigger.java -- 触发器 SPI
│ ├── CompositeCompactionTrigger.java -- OR 组合触发器
│ ├── TurnCountTrigger.java -- 按轮次数触发
│ ├── TokenCountTrigger.java -- 按 token 数触发
│ ├── SlidingWindowCompactionStrategy.java -- 滑动窗口保留最近 N 条
│ ├── TurnWindowCompactionStrategy.java -- 按轮次窗口保留
│ ├── TokenCountCompactionStrategy.java -- 按 token 预算裁剪
│ └── RecursiveSummarizationCompactionStrategy.java -- 递归摘要合成
│
└── tool/
└── SessionEventTools.java -- @Tool 注解的对话搜索(回忆存储)
9. 总结
Spring AI Session 通过元数据会话 + 独立事件日志的设计,实现了高效、可扩展的对话状态管理。核心机制包括:
- 不可变 Session 保证轻量传递。
- SessionEvent 为每条消息补充身份、时间、分支等关键信息。
- 轮次(Turn) 作为原子单元,保证工具调用等复合交互不被割裂。
- 合成摘要轮次 优雅地压缩历史,同时维持用户↔助手的交替模式。
- 归档而非删除 使得回忆搜索成为可能。
- 乐观并发 与事件排序 确保多线程环境下的数据一致性。
理解这些概念后,你可以轻松地将 Spring AI Session 集成到自己的应用中,实现有记忆、可压缩、可回忆的智能对话系统。