Send two messages to a basic agent:
"My budget for the trip is ₹5,000."
"What can I do within that budget?"
The second message makes no sense without the first. If the agent has no memory, it treats every message as a fresh conversation with no history. It doesn't know what trip. It doesn't know what budget. It has to ask again — or worse, it guesses.
Memory is what turns a stateless request-response system into an agent that can hold a real conversation.
What memory means in this context
Memory in an AI agent means one thing: the conversation history that gets sent to the model on each request.
The model itself has no persistent state. Between requests, it remembers nothing. What creates the illusion of memory is appending previous messages to each new request, so the model always sees the full conversation up to that point.
Request 1: [user: "My budget is ₹5,000"]
↓ model responds
[assistant: "Got it, ₹5,000 budget."]
Request 2: [user: "My budget is ₹5,000"] ← history replayed
[assistant: "Got it, ₹5,000 budget."] ← history replayed
[user: "What can I do within that budget?"] ← new message
↓ model responds with full context
Your application stores the conversation history. On every new request, it prepends the stored history before sending to the model. That's the entire mechanism.
MessageChatMemoryAdvisor in Spring AI
Spring AI handles conversation history automatically through an advisor called MessageChatMemoryAdvisor. You saw it briefly in the RAG article — here's what it actually does.
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
new MessageChatMemoryAdvisor(new InMemoryChatMemory())
)
.build();
}
InMemoryChatMemory is a simple in-process store — a Map from conversation ID to message list. Before every request, the advisor loads the history for the current conversation ID and prepends it to the messages. After every request, it appends the new user message and the model's response to the history.
You don't write any of this yourself. The advisor handles it.
Conversation IDs — one per user session
Memory is per-conversation, not global. Each user gets their own conversation history. You identify conversations with a string ID — typically a UUID generated when the session starts.
@PostMapping("/chat")
public String chat(@RequestBody ChatRequest request) {
return chatClient
.prompt()
.user(request.message())
.advisors(a -> a.param(
AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
request.conversationId()
))
.call()
.content();
}
public record ChatRequest(String message, String conversationId) {}
The client generates a conversationId once (e.g. UUID.randomUUID().toString()) and sends it with every message in a session. The advisor uses it to look up and update the right history.
Two users with different conversation IDs get completely separate histories. Same agent, isolated memory.
What gets stored in history
The history isn't just user messages and assistant responses. In an agent with tools, the conversation history also includes tool calls and their results.
A typical history after one tool-calling turn looks like this:
[user] "What's the weather in Chennai?"
[assistant] → tool_call: getWeather("Chennai")
[tool] "Chennai: 34°C, humid, partly cloudy."
[assistant] "It's 34°C and humid in Chennai right now with some cloud cover."
All four messages are stored and replayed on the next turn. This is what allows the agent to remember not just what it said, but what it did — which tools it called, what they returned, and what conclusion it drew.
If a user follows up: "Is that hotter than usual for this time of year?" — the model has the tool result in context and can reason about it without calling getWeather again.
The context window limit
Conversation history grows with every turn. The model has a context window — a maximum number of tokens it can process in a single request. Pack enough history into a conversation and you hit that limit.
When history overflows the context window, one of a few things happens depending on how your application handles it:
- The request fails with a token limit error
- The oldest messages get silently truncated (you lose early context)
- The model gets confused because the history appears to start mid-conversation
Spring AI's MessageChatMemoryAdvisor accepts a chatHistoryWindowSize parameter that limits how many previous messages to include:
new MessageChatMemoryAdvisor(
new InMemoryChatMemory(),
AbstractChatMemoryAdvisor.DEFAULT_CHAT_MEMORY_CONVERSATION_ID,
20 // keep last 20 messages
)
With a window of 20, the advisor includes only the 20 most recent messages in each request. Older messages are still stored — the window just controls what gets sent to the model.
What window size to use? Start with 20–30 messages. A message is typically 50–200 tokens; 20 messages is roughly 1,000–4,000 tokens of history, leaving plenty of room for the system prompt, tool definitions, and the current response.
In-memory vs persistent memory
InMemoryChatMemory stores history in a Map inside your running application. It's fast and requires no infrastructure. The trade-off: it disappears when the application restarts.
For a development server or a demo, in-memory is fine. For production, you need persistent storage.
Spring AI provides a CassandraChatMemory and a Neo4jChatMemory, and the ChatMemory interface is straightforward to implement against any database:
public class PostgresChatMemory implements ChatMemory {
private final JdbcTemplate jdbc;
@Override
public void add(String conversationId, List<Message> messages) {
messages.forEach(msg ->
jdbc.update(
"INSERT INTO chat_history (conversation_id, role, content, created_at) VALUES (?,?,?,?)",
conversationId, msg.getMessageType().name(),
msg.getContent(), Instant.now()
)
);
}
@Override
public List<Message> get(String conversationId, int lastN) {
return jdbc.query(
"SELECT role, content FROM chat_history " +
"WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?",
(rs, i) -> toMessage(rs.getString("role"), rs.getString("content")),
conversationId, lastN
);
}
@Override
public void clear(String conversationId) {
jdbc.update("DELETE FROM chat_history WHERE conversation_id = ?", conversationId);
}
}
Swap new InMemoryChatMemory() for new PostgresChatMemory(jdbc) and history survives restarts, scales across multiple instances, and can be queried or audited.
Clearing history
When a user starts a new topic or wants a clean slate, clear the history:
chatMemory.clear(conversationId);
Without explicit clearing, history accumulates indefinitely (or until the window drops the oldest messages). For session-based use cases, clear on logout or on session expiry.
Memory and RAG together
When both MessageChatMemoryAdvisor and QuestionAnswerAdvisor are registered, the order matters:
ChatClient.builder(chatModel)
.defaultAdvisors(
new MessageChatMemoryAdvisor(new InMemoryChatMemory()), // runs first
new QuestionAnswerAdvisor(vectorStore,
SearchRequest.defaults().withTopK(5)) // runs second
)
.build();
Memory runs first so that follow-up questions like "tell me more about that" contain the previous turn's context before RAG tries to retrieve relevant chunks. With the order reversed, RAG searches based on an incomplete message that has no conversational context.
What memory doesn't solve
Memory gives the model access to recent conversation history. It doesn't give the model access to:
Facts from much earlier in a long conversation. Once messages scroll past the history window, they're gone from the model's view. If a user mentioned their budget 40 messages ago and the window is 20, the model won't see it.
Knowledge from other conversations. Each conversation ID has its own isolated history. An insight the model developed in one session isn't available in another.
Structured data. History is a list of text messages. If you need to remember structured facts — the user's name, preferences, profile — store them in your database and inject them via the system prompt rather than relying on them being in conversation history.
What's next
Memory keeps context across turns. But there's another dimension to agent design: what happens when a task is too complex to complete in one shot — when the agent needs to break a goal into sub-tasks, work through them over multiple steps, and adapt its plan when something doesn't work?
The next article covers structured agent patterns — how to design agents that plan, delegate, and recover from failures.
Sham Prakash K — Backend Engineer, 4+ years in Java, Spring Boot, and distributed systems. Building AI backend infrastructure. Writing about what I actually learned, mistakes included.
Top comments (0)