ChatMemory that decides which messages to keep, a ChatMemoryRepository that stores them (in RAM, in a SQL table, or in Redis), and an advisor that plugs both into ChatClient. It takes a handful of lines to switch on. The trouble is in what those lines leave undecided — how many messages are kept, where they live, who is allowed to read them, and what happens when two requests arrive for the same conversation at once.
This article starts from “why does the model forget”, gets a conversation working, and then goes through every default that surprised me while building the code that goes with it — against a real PostgreSQL 16 and a real Redis Stack, not mocks. There is no live model in any of it: a scripted stand-in records the exact messages it is sent, which is precisely what a memory test has to prove. Everything deeper than the main line of the article sits in expandable sections, so you can read straight through or open only what you need.
Versions this was written and run against. Spring Boot 4.1.1, Spring AI 2.0.1 (2.0.0 reached Maven Central on 12 June 2026, 2.0.1 on 20 August 2026), Java 25, PostgreSQL 16.13, Redis Stack 7.4.7 (RediSearch 2.10.20 and RedisJSON), and Jedis 7.4.1, which the Redis starter brings in. All the code is in thechat-memorymodule of asmhatre/spring-ai; every console block below is quoted from a file under itsoutput/directory, written by a test that also asserts the same facts. Two consecutive full runs produce byte-identical files.
The model forgets. Your code has to remember for it.
Call a chat model twice and the second call cannot see the first. The only thing a model ever receives is the list of messages in the request you send it, so “memory” means one thing: your code keeps the earlier messages somewhere and sends them again, in order, in front of the new question.output/02-advisor-conversations.txt. The model there is a scripted stand-in that answers “Your name is X” only if it can find “My name is X” earlier in the same request, so a correct reply is proof that the earlier message really was in front of it.
The second call, as the model received it (from output/02-advisor-conversations.txt):
conv-A, call 2: "What is my name?"
reply: Your name is Priya.
model was sent: S:You are a terse assistant. | U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name?
The smallest thing that works: an advisor, a memory and a conversation ID
Three pieces, and each one has a job. An advisor is Spring AI’s word for an interceptor around aChatClient call — code that runs before the request goes to the model and after the answer comes back. A ChatMemory is the thing that decides which messages are worth keeping. A conversation ID is a plain string that says which conversation a message belongs to; without it, two users would be writing into the same list.
ChatMemory bean exists only because the window size cannot be set by property in 2.0.1 (more on that below); the ChatClient takes the advisor as a default, so every call gets memory (MemoryConfig.java):
@Bean
ChatMemory chatMemory(ChatMemoryRepository repository,
@Value("${app.memory.max-messages:20}") int maxMessages) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(maxMessages)
.build();
}
@Bean
ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultSystem("You are a terse assistant.")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
Every call needs a conversation ID, and it has to be one you chose
The advisor reads the conversation ID from the request. You pass it as a parameter on each call —.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)) — and everything the memory does is keyed by it. A different ID is a different, empty conversation; the same ID is the same history, no matter which thread, request or user sent it.
Here is the call, in the service the demo application uses (ConversationService.java):
public String send(String user, String conversationId, String text) {
registry.requireOwner(user, conversationId);
return chatClient.prompt()
.user(text)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
A second conversation with its own ID starts blank, even though the question is identical (from output/02-advisor-conversations.txt):
conv-B, call 1: "What is my name?" (different conversation ID)
reply: I do not know your name yet.
model was sent: S:You are a terse assistant. | U:What is my name?
What if you forget the parameter? Spring AI 2.0.1 does not quietly invent a shared default. It throws:
No advisors(...) call at all (from output/03-missing-conversation-id.txt):
chat.prompt().user("hello").call().content() // no advisors(...) param
threw java.lang.IllegalArgumentException
message: conversationId cannot be null
That is the good outcome. The bad outcome is a conversation ID that is valid but wrong: a constant, a username reused across browser tabs, or an ID a client can guess. The memory has no idea who is asking; that is the ownership section near the end of this article. The demo application uses a random UUID per conversation, which is also exactly 36 characters — and that number matters in the JDBC section.
Going deeper: the conversationId(...) builder method and DEFAULT_CONVERSATION_ID that older versions had
Through Spring AI 1.1.5 you could write
MessageChatMemoryAdvisor.builder(memory).conversationId("x") and there was a ChatMemory.DEFAULT_CONVERSATION_ID constant with the value default, which the advisor fell back to, so a forgotten parameter silently landed in one shared conversation. I checked this by running javap on the published jars from Maven Central (this check is not part of the test suite, so there is no transcript for it): both members are present in 1.1.4 and 1.1.5, and absent from 1.1.6 onwards, including 2.0.0 and 2.0.1. The upgrade notes list the removal under 2.0.0, but the jars show a 1.1.x patch release (1.1.6) dropping it, so a 1.1.x upgrade can break this before you ever reach 2.0. The practical consequence is the same either way: code that relied on the default now fails fast, which is safer than a shared conversation, and every call has to supply an ID. The step-by-step 1.x to 2.0 changes are in the Spring AI 1.x to 2.0 migration guide.
The window: the last N messages, and why N is not always what you set
Left alone, a conversation grows by two messages every turn and never shrinks, so the request grows with it.MessageWindowChatMemory is the part that stops this. You give it a number, maxMessages, and it keeps only that many of the most recent messages. Anything older is forgotten, permanently, from the store as well as from the next request.
Out of the box you get a window of 20 messages (ten questions and ten answers) with an in-memory store, and there is no configuration property for it:
With nothing configured (from output/04-autoconfigured-defaults.txt):
ChatMemory bean: MessageWindowChatMemory
ChatMemoryRepository bean: InMemoryChatMemoryRepository
15 turns (30 messages) added -> 20 stored, oldest kept: u6
To change the number you declare your own ChatMemory bean, which is what the MemoryConfig above does. The tempting alternative fails in the worst possible way — Spring Boot ignores property keys it does not recognise, so a guess looks like a working setting:
Three plausible keys, none of which exists (from output/05-no-window-property.txt):
spring.ai.chat.memory.max-messages = 6 -> 30 messages added, 20 stored
spring.ai.chat.memory.window-size = 6 -> 30 messages added, 20 stored
spring.ai.chat.memory.repository.max-messages = 6 -> 30 messages added, 20 stored
Spring Boot ignores unknown keys silently, so none of these did anything.
maxMessages, the window is sometimes one message smaller than you set. The memory would rather send four clean messages than five with an answer that has lost its question. The transcript shows it turn by turn, from output/01-window-semantics.txt:
maxMessages = 5 (odd), no system message:
after turn 1: U:u1 | A:a1
after turn 2: U:u1 | A:a1 | U:u2 | A:a2
after turn 3: U:u2 | A:a2 | U:u3 | A:a3
after turn 4: U:u3 | A:a3 | U:u4 | A:a4
Going deeper: exactly what the window does with system messages and with a second system prompt
System messages are exempt from eviction. A system message counts toward the total but is never the one dropped, so a long conversation always keeps its instructions. If a different system message is added, it replaces the old one — and it lands at the end of the list, not the start. I read both rules from the bytecode of the 2.0.1 jar with
javap, then pinned them with a test (WindowSemanticsTest.java) that writes this transcript with maxMessages = 4 (output/01-window-semantics.txt):
One system message, four turns, then a second system message (from output/01-window-semantics.txt):
maxMessages = 4. One system message, then one user+assistant turn at a time:
after turn 1: S:SYS-1 | U:u1 | A:a1
after turn 2: S:SYS-1 | U:u2 | A:a2
after turn 3: S:SYS-1 | U:u3 | A:a3
after turn 4: S:SYS-1 | U:u4 | A:a4
A second, different system message arrives:
after SYS-2: U:u4 | A:a4 | S:SYS-2
Two things to take from it. The first system message survives all four turns even though the window is only four, because it is exempt. And after SYS-2 arrives, the previous system message is gone and the new one sits after the last answer — if your provider cares about system messages appearing first, that ordering is worth a look. In the demo application the system prompt comes from the ChatClient default, not from memory, so it is added on every call and never stored twice.
Why the window is a cost setting
You pay for a chat model by the token — roughly a word-piece, so a short sentence is a dozen or so. And because the model is stateless, every earlier message you resend is billed again, on every call. A conversation that resends everything therefore gets more expensive with each turn, not just longer: in the run below, turn 30 sends 1563 tokens where turn 1 sent 26. A window turns that steadily rising line into a flat one.output/06-token-growth.txt:
turn unbounded window=10 window=20 budget=400tok
1 26 26 26 26
2 79 79 79 79
5 238 238 238 238
10 503 291 503 397
11 556 291 556 397
15 768 291 556 397
20 1033 291 556 397
21 1086 291 556 397
25 1298 291 556 397
30 1563 291 556 397
sum 23835 7935 13765 10426
sum as % of unbounded: window=10 33%, window=20 58%, budget=400tok 44%
These are estimates from a model of a conversation, not a bill. Each simulated turn is a ~25-token question and a ~40-token answer, counted with the o200k_base tokenizer (JTokkit) that Spring AI ships. Your provider counts tokens its own way, real answers vary in length, and I did not run a live model, so read the chart for its shape — flat versus rising — not for its dollar figures. The saving is also not free: whatever falls out of the window is gone. A window of 10 in a support chat means the customer’s order number, given eleven messages ago, is no longer in the request.
The window counts messages, but cost counts tokens, and the two disagree the moment one message is long. Ten short messages and ten pasted stack traces are both “10”. The next box has a memory that prunes by tokens instead.
Going deeper: a token-budget memory in about forty lines
ChatMemory is a small interface (add, get, clear), so a different pruning rule is a different class that plugs in where MessageWindowChatMemory did. TokenBudgetChatMemory.java drops the oldest non-system messages until the conversation fits a token budget, and keeps dropping until the first message left is a user message, for the same reason the window does.
The pruning loop (TokenBudgetChatMemory.java):
List<Message> prune(List<Message> all) {
List<Message> kept = new ArrayList<>(all);
while (tokens(kept) > maxTokens) {
int oldest = firstIndexOfNonSystem(kept);
if (oldest < 0) {
break; // only system messages left: nothing more can be dropped
}
kept.remove(oldest);
// A reply whose question was just dropped is noise; drop leading non-user messages too.
while (!kept.isEmpty()) {
int first = firstIndexOfNonSystem(kept);
if (first < 0 || kept.get(first).getMessageType() == MessageType.USER) {
break;
}
kept.remove(first);
}
}
return kept;
}
With a 120-token budget, one big pasted trace pushes out the oldest turn but not the system prompt (from output/07-token-budget-memory.txt):
three short turns: S:Be terse. | U:short question 1 | A:short answer 1 | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3
then one pasted trace: 7 messages kept: S:Be terse. | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3 | U:<pasted stack trace, 89 tokens> | A:Check the datasource URL.
The count is the estimator you pass in, not the provider’s billing count, so leave headroom. It also inherits the concurrency problem described below, because it reads and rewrites the whole list.
Keeping conversations across restarts: the JDBC repository
The in-memory store is fine for a demo and wrong for anything else: it empties on every restart, and each instance behind a load balancer has its own separate history, so a user’s next request may land on a server that has never heard of them. A repository is the part that decides where messages live. To move them into a database you add one dependency and change no application code — the advisor and the window do not know which repository is underneath. The dependency for the JDBC repository (the database driver is an ordinary runtime dependency next to it) (pom.xml):
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
Name it exactly: spring-ai-starter-model-chat-memory-repository-jdbc. An older name, spring-ai-starter-model-chat-memory-jdbc, still turns up in blog posts, but Maven Central has it only for two 1.0.0 milestones; it was renamed from 1.0.0-RC1. The profile file that switches the demo over is application-jdbc.yml, and initialize-schema: always makes the starter create its table on startup. The table it creates on PostgreSQL 16, and one conversation stored in it, from output/08-jdbc-round-trip.txt:
Table created by initialize-schema=always:
conversation_id character varying (36)
content text
type character varying (10)
timestamp timestamp without time zone
sequence_id bigint
After one call, rows for the conversation:
seq=0 USER My name is Priya
seq=1 ASSISTANT Nice to meet you, Priya.
The proof that it is a real repository is the last part of that same transcript (output/08-jdbc-round-trip.txt): a brand-new repository and memory object, over the same database, answer “What is my name?” correctly. And one line of it is worth pausing on:
New repository + new memory over the same database, then "What is my name?":
reply: Your name is Priya.
findConversationIds() returns every conversation in the table: [6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10]
findConversationIds() returns every conversation in the table, from every user. It is an administrative call, not a per-user list, and it is the reason the ownership section below exists.
The failure you will hit first: a conversation ID longer than 36 characters
In that table,conversation_id is VARCHAR(36) — the width of a UUID. A human-readable ID such as user:[email protected]:thread:2026-09-24 is 43 characters and does not fit. What matters is when it fails: the user message is saved before the model is called, so the request dies with a database exception and the model never sees it (output/09-jdbc-conversation-id-limit.txt):
conversation ID: "user:[email protected]:thread:2026-09-24" (43 characters)
model calls made for this request: 0 (the user message is saved before the model is called)
thrown: org.springframework.dao.DataIntegrityViolationException
root cause: ERROR: value too long for type character varying(36)
rows stored for it afterwards: 0
Use a UUID, and keep the human-readable key somewhere else. StoreconversationId → userIdin your own table (the demo’sConversationRegistryis the in-memory version of that). Widening the column is possible — you own the table — but then you are maintaining a schema Spring AI expects to manage.
Going deeper: what the JDBC repository does not store
JdbcChatMemoryRepository.saveAll deletes every row for the conversation and inserts the whole list again, inside one transaction. It stores four things per message — content, type, timestamp and a sequence_id — and nothing else: no metadata, no media, and no tool-call messages. Those are filtered out with a warning, so a conversation that used tools comes back shorter than it went in (output/10-jdbc-drops-tool-messages.txt):
saveAll() given 4 messages:
USER Weather in Mumbai?
ASSISTANT (tool call weather)
TOOL (tool call weather)
ASSISTANT 31C and humid.
findByConversationId() returns 2:
USER Weather in Mumbai?
ASSISTANT 31C and humid.
Logged by the repository: WARN JdbcChatMemoryRepository does not support tool call messages. Some messages were filtered out for conversation: tool-conv
That is usually what you want — a tool-call message replayed without its result confuses a model — but it means the stored history is not a complete audit log. If you need one, log the full exchange separately. The Redis repository, by contrast, does persist tool calls and media, so the two repositories are not interchangeable for tool-heavy applications. The test is JdbcPostgresTest.java.
Going deeper: upgrading a Spring AI 1.x table to 2.0
The 2.0 schema adds a
sequence_id BIGINT NOT NULL column, an index over (conversation_id, sequence_id), and reads in that order. Point the 2.0.1 repository at a 1.x table and the first read fails; the fix is four statements, and the transcript shows them working (output/13-jdbc-1x-table-upgrade.txt):
2.0.1 repository, findByConversationId("old-conv"):
ERROR: column "sequence_id" does not exist
Migration: add the column, backfill it from the timestamp order, then make it NOT NULL:
ALTER TABLE SPRING_AI_CHAT_MEMORY ADD COLUMN sequence_id BIGINT
UPDATE SPRING_AI_CHAT_MEMORY m SET sequence_id = s.rn FROM (SELECT ctid AS c, row_number() OVER (PARTITION ...
ALTER TABLE SPRING_AI_CHAT_MEMORY ALTER COLUMN sequence_id SET NOT NULL
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_SEQUENCE_ID_IDX ON SPRING_AI_CHAT_MEMORY(c...
findByConversationId("old-conv") after the migration:
USER My name is Priya
ASSISTANT Nice to meet you, Priya.
The console output truncates the long statements; the full SQL is in JdbcPostgresTest.java. The backfill orders by timestamp, which is only as reliable as your old timestamps — check the result before you trust it. The upgrade notes carry the same migration for other databases; this one I ran on PostgreSQL 16 only. See also the migration guide.
Redis: fast, expiring memory, and one silent trap
Redis suits chat memory for one reason a SQL table does not give you for free: conversations can expire. Set a time-to-live and idle chats vanish by themselves, which is often what a privacy policy wants anyway. Spring AI’s Redis repository stores each message as a JSON document and finds them through the search module, so it needs Redis Stack (or another server with RediSearch and RedisJSON), not plain Redis. The starter isspring-ai-starter-model-chat-memory-repository-redis; the module behind it first appears on Maven Central at 2.0.0-M1, so it is new in the 2.0 line.
All of its settings live under spring.ai.chat.memory.repository.redis (application-redis.yml). What it writes, from output/15-redis-round-trip.txt:
Keys under the default prefix "chat-memory:" (3: one JSON document per message, plus a counter):
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
chat-memory:counter:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10
Fields of one document: [content, conversation_id, metadata, timestamp, type]
TTL on each key (spring.ai.chat.memory.repository.redis.time-to-live=24h): between 86390 and 86400 s -> true
One key per message and one counter per conversation, all under a prefix, each with the time-to-live you set. Expiry is real: with a 2-second TTL the conversation is readable, and three seconds later it is gone. The same test shows the other cap the repository offers, max-messages-per-conversation, and it does not do what its name suggests:
TTL expiry and the per-conversation cap (from output/18-redis-ttl-and-cap.txt):
saved 2 messages; readable now: U:u1 | A:a1
3 seconds later: (gone: Redis expired the keys)
saved 6 messages with max-messages-per-conversation=4, read back 4:
U:u1 | A:a1 | U:u2 | A:a2
keys in Redis for it: 7
With a cap of 4 and six messages saved, a read returns the oldest four, not the newest, and all seven keys (six messages and the counter) are still in Redis. It limits how much is read, not how much is kept, and it keeps the wrong end. For a sliding window, put the window on MessageWindowChatMemory and treat the cap as a safety limit.
The trap: a custom ChatMemory quietly turns Redis off
To change the window size you declare your ownChatMemory bean, as above. On the JDBC starter that is harmless. On the Redis starter it is not: the autoconfigured Redis repository is declared to back off whenever any ChatMemory bean exists, and the in-memory default takes over. No warning is logged. The application runs, the tests pass, and the first restart empties every conversation. This is the transcript of exactly that (output/17-redis-silent-fallback.txt):
application has: spring-ai-starter-model-chat-memory-repository-redis, profile "redis", Redis Stack reachable,
and its own @Bean ChatMemory (needed to set a window other than 20)
ChatMemoryRepository bean in the context: InMemoryChatMemoryRepository
Redis keys under "chat-memory:" before / after one chat call: 0 / 0
Condition report for the autoconfigured Redis repository bean:
@ConditionalOnMissingBean (types: org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository,org.springframework.ai.chat.memory.ChatMemory,org.springframework.ai.chat.memory.ChatMemoryRepository; SearchStrategy: all) found beans of type 'org.springframework.ai.chat.memory.ChatMemory' chatMemory
WARN/ERROR log lines mentioning Redis or ChatMemory, captured over the whole test class: 0
The condition report in the middle is Spring Boot’s own explanation, and it names the culprit: the ChatMemory bean called chatMemory. The JDBC starter only backs off for its own repository type, so the same setup on JDBC works. The fix is to build the Redis repository yourself, from the properties and Jedis client the starter still provides (RedisMemoryConfig.java):
The hand-built repository (RedisMemoryConfig.java):
@Bean
RedisChatMemoryRepository redisChatMemoryRepository(RedisClient jedisClient, RedisChatMemoryRepositoryProperties props) {
RedisChatMemoryRepository.Builder builder = RedisChatMemoryRepository.builder()
.jedisClient(jedisClient)
.indexName(props.getIndexName())
.keyPrefix(props.getKeyPrefix());
Duration ttl = props.getTimeToLive();
if (ttl != null) {
builder.timeToLive(ttl);
}
if (props.getInitializeSchema() != null) {
builder.initializeSchema(props.getInitializeSchema());
}
if (props.getMaxMessagesPerConversation() != null) {
builder.maxMessagesPerConversation(props.getMaxMessagesPerConversation());
}
if (props.getMaxConversationIds() != null) {
builder.maxConversationIds(props.getMaxConversationIds());
}
if (props.getMetadataFields() != null) {
builder.metadataFields(props.getMetadataFields());
}
return builder.build();
}
How to catch this in your own project. Add a startup assertion or a test that ChatMemoryRepository is the type you expect — the “ChatMemoryRepository bean in the context” line of the transcript above is that check. A silently substituted in-memory repository is invisible until production restarts.
Going deeper: plain Redis, and property keys that look right
Point the repository at plain Redis 7.0 and it fails at startup, not at the first call, with the error that gives away the cause (
output/19-redis-plain-fails.txt):
server: redis_version:7.0.15
RedisChatMemoryRepository.builder()...initializeSchema(true).build() threw:
java.lang.IllegalStateException
root cause: JedisDataException: ERR unknown command 'FT._LIST', with args beginning with:
FT._LIST is a RediSearch command. The repository also builds its own Jedis client, which means the Spring Data Redis keys you may already use do not apply to it, and near-miss keys are ignored without complaint (output/22-config-keys.txt):
Three guesses, each of which looks right and binds nothing:
spring.ai.chat.memory.redis.port=6390 (no "repository" segment)
spring.ai.chat.memory.repository.redis.ttl=24h (the key is time-to-live)
spring.data.redis.port=6390 (Spring Data Redis's key; this repository builds its own Jedis client)
-> port = 6379, time-to-live = null
I ran only Redis Stack 7.4.7 here; managed Redis offerings differ in which modules they include, so check for RediSearch and JSON before you commit to this repository. There is more on Redis in Redis with Spring Boot 4.1.
Two requests for the same conversation at once
Both repositories update a conversation the same way: read the whole list, add the new messages, write the whole list back. That is safe when requests arrive one at a time. It is not safe when a user double-clicks Send, a client retries after a timeout, or two browser tabs share one ID, because the second write is built from a list that does not contain the first writer’s message.output/11-jdbc-lost-update.txt), and it repeats the identical result on Redis (output/16-redis-lost-update.txt):
seeded with: u0, a0
two writers each add one message; both read the conversation before either writes it back
first writer to save: adds a 3-message list, saved
second writer to save: adds a different 3-message list, replaces the first
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
the first writer's message is GONE, the second writer's is kept
seeded with u0, a0; two writers add one message each, both read before either writes back
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
On the JDBC repository there is a second, uglier outcome when the two saves genuinely overlap in time. Because a save is delete-then-insert, two overlapping transactions can both insert. The stored history is then either one message short or duplicated, with colliding sequence numbers, and never the four messages that were sent:
Overlapping saves on PostgreSQL 16 (from output/12-jdbc-overlapping-saves.txt):
seeded with u0, a0; two writers add from-A and from-B; both saveAll() calls start together
The outcome differs from run to run, so the exact count is not printed here. Across repeated runs on
PostgreSQL 16 (READ COMMITTED) it is one of two things, and never the 4 messages that were sent:
3 messages -> one writer's message is gone
6 messages -> both transactions inserted, history is duplicated, sequence_id values collide
Inside one application instance, the smallest fix is one lock per conversation around add. The same two writers then keep all four messages:
A decorating ChatMemory that serialises writes per conversation (JdbcPostgresTest.java):
private static final class LockingChatMemory implements ChatMemory {
private final ChatMemory delegate;
private final java.util.concurrent.ConcurrentHashMap<String, Object> locks = new java.util.concurrent.ConcurrentHashMap<>();
LockingChatMemory(ChatMemory delegate) {
this.delegate = delegate;
}
@Override
public void add(String id, List<Message> messages) {
synchronized (locks.computeIfAbsent(id, k -> new Object())) {
delegate.add(id, messages);
}
}
@Override
public List<Message> get(String id) {
return delegate.get(id);
}
@Override
public void clear(String id) {
delegate.clear(id);
}
}
With the lock (from output/14-jdbc-lost-update-locked.txt):
stored afterwards: 4 messages
contains from-A: true, from-B: true
This is a single-instance fix only. A lock inside one JVM does nothing for two instances behind a load balancer. There, either route each conversation to one instance (sticky routing by conversation ID), take a database or Redis lock keyed by the ID, or reject a second concurrent request for the same conversation. I did not run any multi-instance setup, so treat those as options to test, not as verified recipes.
Whose conversation is it? The memory does not know
A conversation ID is a key, not a credential. The memory stores whatever it is given under whatever ID it is given, and it never asks who is calling. So “Alice must not read Bob’s history” is entirely your job, and the failure is quiet: with no check, anyone who learns an ID reads the whole conversation and can continue it, and the model answers them from Alice’s earlier messages. The smallest honest version of that job: remember who created each conversation, and check it on every read, write and delete (ConversationRegistry.java):
public void requireOwner(String user, String conversationId) {
if (!enforce) {
return;
}
if (!user.equals(ownerByConversation.get(conversationId))) {
throw new NotYourConversationException(conversationId);
}
}
With the check switched off (app.ownership.enforce=false), Bob reads and continues Alice’s conversation (from output/21-ownership-unenforced.txt):
alice starts a conversation and says "My name is Priya"
bob: GET /conversations/<alice's id>/messages -> 200
body: [{"role":"USER","text":"My name is Priya"},{"role":"ASSISTANT","text":"Nice to meet you, Priya."}]
bob: POST "What is my name?" -> 200 {"reply":"Your name is Priya."}
With it on, which is the default (from output/20-ownership-enforced.txt):
alice starts a conversation and says "My name is Priya"
bob: GET /conversations/<alice's id>/messages -> 403
bob: POST /conversations/<alice's id>/messages -> 403
alice: GET /conversations/<her id>/messages -> 200, 2 messages
In production the map is a table next to the memory table (conversation_id, user_id), and the user comes from your authentication, not from a header. The demo uses an X-User header only so the tests need no login. A random UUID makes IDs hard to guess, but hard to guess is not a permission check — IDs end up in logs, URLs and browser history.
Going deeper: the same idea for tool calls and secrets
If your application also exposes tools, the caller’s identity has to reach them too, which is a separate problem from memory; see Spring AI 2.0 tool calling and MCP server security for the equivalent problem one layer out. Stored conversations are also personal data: the memory has no encryption, redaction or retention beyond the Redis time-to-live. If users paste passwords, tokens or card numbers into a chat, they are now in your database in plain text.
Memory and tool calling: what actually gets stored
If the model can call a tool (look up the weather, query an API), a single question may cause several round trips to the model: the question goes out, the model asks for the tool, your code runs it and sends the result back, and only then does the model answer. A beginner’s natural worry is whether memory then stores all of that. In Spring AI 2.0.1 it does not, and the reason is an ordering rule.output/24-memory-with-tool-calling.txt):
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + 200
ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + 300
answer: It is 31C in Mumbai.
model calls: 2
What the model was sent on each call:
call 1: U:Weather in Mumbai?
call 2: U:Weather in Mumbai? | A:[tool call currentWeather] | T:[tool result "31C in Mumbai"]
Stored in memory afterwards:
USER Weather in Mumbai?
ASSISTANT It is 31C in Mumbai.
The consequence is a good one for cost and a small one for fidelity. Next turn the model is sent the question and the answer, not the raw tool output, which keeps tool results (often big JSON) out of every later request. But if the follow-up needs a detail that only appeared in the tool result, the model has to call the tool again. The JDBC repository would have dropped those tool messages anyway, so the two behave the same here; the Redis repository is the one that could store them if something above it did.
Going deeper: the advisor order changed between 1.1 and 2.0
In Spring AI 1.1.8 the default chat-memory order was
HIGHEST_PRECEDENCE + 1000; in 2.0.1 it is HIGHEST_PRECEDENCE + 200, and ToolCallingAdvisor sits at +300. I compared the constants with javap on both jars (not part of the test suite; the transcript above prints the 2.0.1 values only). If you write your own advisors, their order relative to these two decides whether they see the tool round trips or only the outer call — the next post in this series, on custom advisors, goes through exactly that. For the tool side, see Spring AI 2.0 tool calling.
What changed from Spring AI 1.x
If you have a 1.x application, these are the changes that bite. The full walkthrough is in the Spring AI 1.x to 2.0 migration guide; this is only the chat-memory part, with where each fact comes from.| What | In 1.x | In 2.0.1 | Source |
|---|---|---|---|
PromptChatMemoryAdvisor | Present in 1.1.8, deprecated for removal | Gone: compile error | compile capture, below |
| JDBC table | No sequence_id | sequence_id BIGINT NOT NULL plus an index; old table fails on read | transcript 13 |
| Default conversation ID | DEFAULT_CONVERSATION_ID and .conversationId() until 1.1.5 | Neither; a missing ID throws | transcript 03, javap |
| Memory advisor order | HIGHEST + 1000 | HIGHEST + 200, outside the tool loop | transcript 24, javap |
| Redis repository | No artifact published | Available from 2.0.0-M1 | Maven metadata |
PromptChatMemoryAdvisor rendered the history into the system prompt as text; MessageChatMemoryAdvisor, used throughout this article, sends real user and assistant messages instead. The capture below compiles the same 1.x source against both versions, and the compiler’s own words are the evidence (output/23-prompt-advisor-removed.txt, produced by capture-1x-compile.sh):
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 2.0.1
PromptAdvisorFrom1x.java:3: error: cannot find symbol
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
^
symbol: class PromptChatMemoryAdvisor
location: package org.springframework.ai.chat.client.advisor
PromptAdvisorFrom1x.java:10: error: cannot find symbol
return PromptChatMemoryAdvisor.builder(chatMemory).build();
^
symbol: variable PromptChatMemoryAdvisor
location: class PromptAdvisorFrom1x
2 errors
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 1.1.8 first
PromptAdvisorFrom1x.java:10: warning: [removal] PromptChatMemoryAdvisor in org.springframework.ai.chat.client.advisor has been deprecated and marked for removal
return PromptChatMemoryAdvisor.builder(chatMemory).build();
^
1 warning
compiled: PromptAdvisorFrom1x.class
Should you even do this?
Probably yes for a chat UI, probably no for everything else. Conversation memory is right when a person talks to the model over several turns and the earlier turns matter. It is wrong for one-shot calls (classification, extraction, a summary of a document), where an empty history is the correct history and memory only adds cost and a place for user data to leak. It is also not long-term memory: a window forgets by design, and “remember that I prefer metric units” belongs in a user profile you load into the system prompt, or in a vector store you search, not in the last N messages. Choose the repository by what you already run: JDBC if you have a relational database (it has no module requirements), Redis Stack only if you already operate it or need expiry. Whatever you pick, decide three things before launch: how long conversations are kept, who can read one, and what happens on a double submit — the three places this article found defaults that will not decide for you.
What I did not verify. No live model was called; a scripted stand-in recorded what it was sent, so nothing here says how a real model uses the history. Token figures are JTokkit estimates, not provider billing. I ran PostgreSQL 16.13 and Redis Stack 7.4.7 only: other databases, Redis Cluster or Sentinel, and managed Redis were not tried. The Cassandra, MongoDB, Neo4j and Cosmos DB repositories andVectorStoreChatMemoryAdvisor(long-term memory through a vector store, inspring-ai-vector-store-advisor) were not run. No multi-instance setup was run. Thejavapcomparisons with 1.x jars are not in the test suite.
Further reading
- The code for this article: the
chat-memorymodule of asmhatre/spring-ai, with all 24 transcripts underoutput/and its README. - Spring AI 1.x to 2.0 migration guide on this site.
- Spring AI 2.0 ChatClient on Spring Boot 4.1 — the client this memory plugs into.
- Spring AI 2.0 tool calling and structured output.
- Redis with Spring Boot 4.1.
- Spring AI reference: Chat Memory, Advisors API and Upgrade notes.
- PostgreSQL 16: Transaction Isolation, for why two overlapping saves behave as they do.
- Redis Query Engine, the search module the Redis repository needs.
No Comments yet!