Let's get started with a Microservice Architecture with Spring Cloud:
A Guide to Short-Term Memory Sessions in Spring AI
Last updated: July 30, 2026
1. Overview
Large language models are stateless, so every request we send is independent unless we replay the earlier conversation ourselves. Spring AI has traditionally solved this with ChatMemory, but as conversations grow, naively replaying every message quickly overflows the model’s context window.
In this tutorial, we’ll explore Spring AI Session, an event-sourced short-term memory layer that stores conversation history and shrinks it intelligently when it gets too large. We’ll create and inspect sessions, plug memory into a ChatClient through an advisor, and review the available compaction strategies.
2. What Are Memory Sessions?
A session is a container for a single conversation, identified by an ID and optionally tied to a user. Instead of storing a flat list of messages, it records an ordered stream of SessionEvent objects, each wrapping a Message with a timestamp, a unique ID, and optional branch information.
The library groups these events into turns. A turn is one user message plus every assistant reply, tool call, and tool result that follows it, until the next user message. Turns are the atomic unit the library never breaks apart.
That last point is the key improvement over the older ChatMemory API. When history grows too large, Spring AI Session compacts it along turn boundaries rather than evicting the oldest individual messages. This way, we never end up with a dangling tool call whose result was dropped. For reference, a MessageWindowChatMemory capped at 20 messages becomes a TurnCountTrigger(20) paired with a sliding-window strategy.
Every event is immutable and timestamped. This lets the same session support more advanced scenarios, such as isolating the histories of cooperating agents through branch labels.
Sessions are currently incubating in the spring-ai-community project and are slated to replace ChatMemory in a future Spring AI release.
3. Setting Up the Project
The Session API requires Spring AI 2.x and Spring Boot 4.x. We add the core module, which ships the in-memory repository and all the compaction building blocks:
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>spring-ai-session-management</artifactId>
<version>0.5.0</version>
</dependency>
The latest version of spring-ai-session-management is available in the Maven Repository.
To talk to a model, we add a Spring AI chat starter. The session layer is provider-agnostic, so OpenAI, Anthropic, or a local model works just as well. Here, we use spring-ai-starter-model-google-genai with our Gemini API key:
spring.ai.google.genai.api-key=${GEMINI_API_KEY}
spring.ai.google.genai.chat.options.model=gemini-3.5-flash
For production, we’d typically swap the in-memory store for a relational one. Adding the spring-ai-starter-session-jdbc starter auto-configures a JDBC repository for PostgreSQL, MySQL, MariaDB, or H2. In this tutorial, we’ll stick with the in-memory repository to keep the examples self-contained.
4. Creating and Managing Sessions
A SessionService is the entry point for the whole API. We start by exposing it as a bean backed by an InMemorySessionRepository:
@Bean
public SessionService sessionService() {
return DefaultSessionService.builder()
.sessionRepository(InMemorySessionRepository.builder().build())
.build();
}
Let’s create a session, append a couple of messages, and read them back as plain Spring AI messages:
@Test
void givenSession_whenAppendingMessages_thenStoredInOrder() {
Session session = sessionService.create(CreateSessionRequest.builder()
.userId("alice")
.build());
sessionService.appendMessage(session.id(), new UserMessage("What is Spring AI?"));
sessionService.appendMessage(session.id(),
new AssistantMessage("It's an application framework for AI engineering."));
List<Message> messages = sessionService.getMessages(session.id());
assertThat(messages).hasSize(2);
assertThat(messages.get(0).getMessageType()).isEqualTo(MessageType.USER);
assertThat(messages.get(1).getMessageType()).isEqualTo(MessageType.ASSISTANT);
}
For lower-level access, getEvents() returns the richer SessionEvent stream, including timestamps and metadata. The service also lets us look up a conversation later with findById() or list everything for a user through findByUserId(). Finally, we can set a time-to-live on CreateSessionRequest so stale sessions expire automatically.
5. Using the SessionMemoryAdvisor
Managing the service by hand is useful, but most applications want memory to work transparently, much like the advisors we lean on when building an AI assistant. The SessionMemoryAdvisor hooks the session into the ChatClient pipeline, loading history before each call and appending the new exchange afterward. It’s a standard Spring AI advisor.
5.1. Configuring the Advisor Bean
Now, let’s expose the advisor as a bean, attaching a compaction trigger and strategy:
@Bean
public SessionMemoryAdvisor sessionMemoryAdvisor(SessionService sessionService) {
return SessionMemoryAdvisor.builder(sessionService)
.defaultUserId("alice")
.compactionTrigger(new TurnCountTrigger(20))
.compactionStrategy(SlidingWindowCompactionStrategy.builder()
.maxEvents(10)
.build())
.build();
}
This configuration keeps the ten most recent events and compacts once a session passes twenty turns.
5.2. Wiring It Into ChatClient
Next, we register the advisor as a default on a ChatClient, then identify the conversation with the session-ID parameter on each call:
@Component
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatModel chatModel, SessionMemoryAdvisor sessionMemoryAdvisor) {
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(sessionMemoryAdvisor)
.build();
}
public String chat(String sessionId, String prompt) {
return chatClient.prompt()
.user(prompt)
.advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId))
.call()
.content();
}
}
The advisor stores every exchange under that session ID, so a follow-up question resolves against the earlier context. In the test below, we share a name in the first message, then ask for it back in a second one. The assertion confirms the reply still recalls the name, proving the session preserved context across the two turns:
@Test
void givenSessionId_whenChattingAcrossTurns_thenContextIsRemembered() {
chatService.chat("session-abc", "My name is Yadier, remember it.");
String response = chatService.chat("session-abc", "What is my name?");
assertThat(response).containsIgnoringCase("Yadier");
assertThat(sessionService.getMessages("session-abc")).hasSize(4);
}
The second call answers with the remembered name, which confirms the advisor replayed the first turn from the session before calling the model.
6. Compaction Strategies
A compaction trigger decides when to shrink history, while a strategy decides how. Besides TurnCountTrigger, we can trigger on estimated tokens with TokenCountTrigger, or combine conditions with CompositeCompactionTrigger.
6.1. Comparing the Strategies
The library ships four strategies, all of which respect turn boundaries:
| Strategy | LLM required | Best for |
|---|---|---|
| SlidingWindowCompactionStrategy | No | Cost-sensitive, recent-context chats |
| TurnWindowCompactionStrategy | No | Keeping the last N complete turns |
| TokenCountCompactionStrategy | No | Hard context-window limits |
| RecursiveSummarizationCompactionStrategy | Yes | Long-running sessions needing recall |
The first three strategies drop older events outright, so they’re fast and free — a good default when we just need to cap history cheaply. Recursive summarization instead preserves the gist of what it removes, at the cost of an extra model call. In short, we reach for recursive summarization only when we need to recall older context, otherwise, the cheaper windowing strategies are enough.
6.2. Running Compaction Manually
The SessionMemoryAdvisor runs compaction automatically as a session grows, but we can also trigger it ourselves by calling compact() directly. That’s handy in tests or batch jobs where we decide exactly when history shrinks. Let’s use the SlidingWindowCompactionStrategy to keep only the most recent events, passing it and a trigger to compact(), which returns a CompactionResult describing what it archived:
@Test
void givenMultiTurnConversation_whenCompacting_thenOlderEventsAreArchived() {
Session session = sessionService.create(CreateSessionRequest.builder()
.userId("alice")
.build());
for (int turn = 1; turn <= 4; turn++) {
sessionService.appendMessage(session.id(), new UserMessage("Question " + turn));
sessionService.appendMessage(session.id(), new AssistantMessage("Answer " + turn));
}
CompactionResult result = sessionService.compact(session.id(),
new TurnCountTrigger(2),
SlidingWindowCompactionStrategy.builder()
.maxEvents(4)
.build());
assertThat(result.eventsRemoved()).isPositive();
assertThat(sessionService.getEvents(session.id())).hasSameSizeAs(result.compactedEvents());
}
First we build up four turns, then we compact the session. Finally, we verify that some events were removed and the stored history now matches the compacted result. The assertions confirm that older events were archived and that the session now holds only the compacted set.
6.3. Summarizing With an LLM
Recursive summarization is the one strategy that needs a ChatClient. The other three simply drop or window events, but this one asks a model to write a summary of the events it removes, then replaces them with a single synthetic summary event. That extra model call is the price for keeping older context available in condensed form:
@Test
void givenLongConversation_whenSummarizing_thenOlderEventsAreReplacedBySummary() {
Session session = sessionService.create(CreateSessionRequest.builder()
.userId("alice")
.build());
for (int turn = 1; turn <= 4; turn++) {
sessionService.appendMessage(session.id(), new UserMessage("Question " + turn));
sessionService.appendMessage(session.id(), new AssistantMessage("Answer " + turn));
}
ChatClient chatClient = ChatClient.builder(chatModel).build();
CompactionResult result = sessionService.compact(session.id(),
new TurnCountTrigger(2),
RecursiveSummarizationCompactionStrategy.builder(chatClient)
.maxEventsToKeep(4)
.build());
SessionEvent summary = result.compactedEvents().stream()
.filter(SessionEvent::isSynthetic)
.findFirst()
.orElseThrow();
assertThat(result.eventsRemoved()).isPositive();
assertThat(summary.getMessage().getText()).isNotBlank();
}
The synthetic event in the compacted set is the model-generated summary, so the session keeps the gist of the dropped turns in condensed form.
7. Conclusion
In this article, we explored Spring AI’s short-term memory sessions. We saw how a SessionService stores conversations as turn-aware events. The SessionMemoryAdvisor makes that memory transparent to the ChatClient. Finally, pluggable triggers and strategies keep history within the model’s context window.
As the API graduates from incubation, it’s positioned to become the default replacement for ChatMemory, so investing in it now sets us up for the long term.
As always, the full source code is available over on GitHub.
















