When we first started building AI agents in production, we assumed that a large context window was enough. We fed the entire conversation history into the prompt, attached a few documents via RAG, and called it a day. It worked—until it didn’t.
As conversations stretched beyond 50–100 turns, latency spiked. Token costs ballooned. And worse, the agent began to forget critical details from the beginning of the session or, even more concerning, failed to retain user preferences across separate interactions.
The problem wasn’t the model’s intelligence. It was its memory architecture. Just like humans, AI agents need different types of memory to function effectively: short-term working memory for immediate tasks, long-term semantic memory for knowledge, and episodic memory for personal experiences.
In this post, we’ll dive deep into these three memory architectures and show you how to implement them using Java, Spring AI, and modern vector databases.
Why Memory Matters in Agent Design
Before we architect anything, let’s understand why memory is a first-class citizen in agent systems. Consider these scenarios:
Context Window Limits: Even with 128K+ token contexts, there’s a hard limit. Once you hit it, you must truncate or summarize, risking information loss.
Cost Efficiency: Every token in a prompt costs money. Storing and retrieving only relevant memories reduces inference costs significantly.
Personalization: Users expect agents to remember their preferences, past interactions, and learned behaviors across sessions.
Reasoning Quality: Agents that can recall specific past events (episodic memory) make better decisions than those starting from scratch every time.
The solution isn’t to rely solely on the LLM’s context window. It’s to build a structured memory system that mirrors how humans organize information.
The Three-Tier Memory Model
Let’s define the three memory types we’ll implement:
1. Short-Term Memory (Working Memory)
Short-term memory holds the immediate context of the current interaction. It’s analogous to your working memory when solving a problem right now. For an AI agent, this includes:
The current conversation turn
Recent tool outputs
Active goals and sub-goals
Temporary variables and intermediate results
Characteristics:
High volatility: Forgotten after the session ends (or summarized)
Fast access: Available in the prompt context
Limited capacity: Constrained by the LLM’s context window
2. Long-Term Memory (Semantic Memory)
Long-term memory stores general knowledge, facts, and learned concepts. This is the agent’s encyclopedia. It includes:
Domain knowledge (e.g., company policies, technical documentation)
User preferences and profiles
Frequently accessed facts
Procedural knowledge (how to do things)
Characteristics:
Durable: Persists across sessions
Sparse: Only the most relevant information is retrieved
Indexed: Stored in vector databases for semantic search
3. Episodic Memory (Autobiographical Memory)
Episodic memory stores specific experiences and events. This is the agent’s diary. It includes:
Past conversations and interactions
Decisions made and their outcomes
User feedback and corrections
Specific incidents that shaped behavior
Characteristics:
Time-stamped: Ordered chronologically
Rich: Contains full context of events
Retrieval-based: Accessed when relevant to current tasks
Architecture Overview
Here’s how these memory layers interact in a typical agent system:
The Memory Router decides which memory store to query based on the current task. Short-term memory is always in the prompt. Long-term memory is retrieved via semantic search. Episodic memory is fetched when historical context is needed.
Implementing Short-Term Memory
Short-term memory is the simplest layer. In Java, we can use a thread-local or session-scoped storage to maintain the conversation context.
The Conversation Buffer
We’ll create a ConversationMemory class that acts as a sliding window over recent messages:
This class maintains a bounded queue of messages. By limiting the window size, we ensure that the prompt never exceeds token limits. The trimIfNecessary() method removes oldest messages when the buffer is full.
Integration with Spring AI
Spring AI’s ChatClient works seamlessly with this memory class:
The key insight here is that short-term memory is ephemeral. It exists only for the duration of the conversation and is discarded afterward. This is intentional—short-term memory is meant for immediate reasoning, not permanent storage.
Implementing Long-Term Memory
Long-term memory requires persistent storage and semantic search capabilities. We’ll use a vector database to store embeddings of important facts and retrieve them based on relevance.
Choosing a Vector Database
For production Java applications, we recommend:
PostgreSQL with pgvector: Great for relational data with vector search
Redis with RediSearch: Fast in-memory vector search
Pinecone: Managed service, excellent for scaling
We’ll use PostgreSQL with pgvector in our examples, as it’s widely adopted in enterprise Java stacks.
Storing Semantic Knowledge
First, let’s create a schema for our long-term memory:
1 2 3 4 5 6 7 8 9 10 11 12
CREATE EXTENSION vector;
CREATE TABLE semantic_memory ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, embedding vector(1536), -- OpenAI ada-002 dimension metadata JSONB, created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP, updated_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP );
CREATE INDEX ON semantic_memory USING ivfflat (embedding vector_cosine_ops);
Now, let’s create a service to manage this memory:
@Service publicclassMemoryExtractorService { privatefinal ChatClient chatClient; privatefinal LongTermMemoryService longTermMemory; publicvoidextractAndStore(UserMessage userMsg, AssistantMessage assistantMsg) { // Ask LLM to identify memorable facts StringextractionPrompt=""" Extract any important facts, preferences, or knowledge from this conversation that should be remembered long-term. Return as JSON: { "facts": ["string"], "preferences": ["string"], "decisions": ["string"] } """; ChatResponseresponse= chatClient.call( newPrompt(extractionPrompt + "\n\nConversation:\n" + userMsg.getContent() + "\n" + assistantMsg.getContent()) ); // Parse and store MemoryExtractionextraction= JsonUtils.fromJson( response.getResults().get(0).getOutput().getText() ); extraction.getFacts().forEach(fact -> longTermMemory.storeFact(fact, Map.of("type", "fact")) ); extraction.getPreferences().forEach(pref -> longTermMemory.storeFact(pref, Map.of("type", "preference")) ); } }
This approach lets the LLM decide what’s worth remembering, reducing noise in the long-term memory store.
Implementing Episodic Memory
Episodic memory is the most complex layer. It needs to store events with rich context, time stamps, and relationships. Unlike semantic memory (which stores facts), episodic memory stores experiences.
Schema Design
1 2 3 4 5 6 7 8 9 10 11 12 13 14
CREATE TABLE episodic_memory ( id SERIAL PRIMARY KEY, event_type VARCHAR(50) NOT NULL, -- "conversation", "tool_call", "decision" timestampTIMESTAMPDEFAULTCURRENT_TIMESTAMP, user_id VARCHAR(100), session_id VARCHAR(100), context JSONB, -- Full conversation snapshot summary TEXT, -- LLM-generated summary for quick retrieval embedding vector(1536), related_event_ids INTEGER[] );
CREATE INDEX ON episodic_memory USING ivfflat (embedding vector_cosine_ops); CREATE INDEX ON episodic_memory (user_id, timestampDESC);
Storing Episodes
We’ll create an EpisodicMemoryService that captures complete interaction episodes:
@Component publicclassMemoryRouter { privatefinal LongTermMemoryService longTermMemory; privatefinal EpisodicMemoryService episodicMemory; privatefinal ConversationMemory shortTermMemory; public Prompt buildPrompt(String userMessage, String userId) { List<Message> messages = newArrayList<>(); // 1. System prompt with memory instructions messages.add(newMessage(Message.Role.SYSTEM, buildSystemPrompt(userId))); // 2. Retrieve relevant long-term memories List<MemoryItem> semanticMemories = longTermMemory.retrieveRelevant( userMessage, 3 ); if (!semanticMemories.isEmpty()) { messages.add(newMessage(Message.Role.SYSTEM, "Relevant knowledge: " + formatMemories(semanticMemories))); } // 3. Retrieve relevant episodic memories List<EpisodicEvent> episodicMemories = episodicMemory.retrieveRelated( userMessage, userId, 2 ); if (!episodicMemories.isEmpty()) { messages.add(newMessage(Message.Role.SYSTEM, "Past experiences: " + formatEpisodes(episodicMemories))); } // 4. Add short-term conversation history messages.addAll(shortTermMemory.getRecentMessages(10)); // 5. Add current user message messages.add(newMessage(Message.Role.USER, userMessage)); returnnewPrompt(messages); } private String buildSystemPrompt(String userId) { // Get user preferences from long-term memory List<MemoryItem> preferences = longTermMemory.retrieveRelevant( "user preferences", 5 ); StringprefText= preferences.isEmpty() ? "" : "User preferences: " + formatMemories(preferences); return"You are a helpful AI assistant. " + prefText + "Use the provided context to give accurate, personalized responses."; } }
Performance Considerations
When implementing memory systems, keep these performance tips in mind:
1. Caching Layer
Add a caching layer (e.g., Caffeine or Redis) in front of your vector database. Most queries are repetitive, and caching avoids redundant embedding generation and database lookups.
@Service publicclassCustomerSupportAgent { privatefinal MemoryRouter memoryRouter; privatefinal EpisodicMemoryService episodicMemory; privatefinal LongTermMemoryService longTermMemory; privatefinal ConversationMemory shortTermMemory; public String handleSupportRequest(String userId, String message) { // Store the interaction in episodic memory episodicMemory.storeEpisode( UUID.randomUUID().toString(), userId, shortTermMemory.getFullHistory(), List.of() ); // Build enriched prompt Promptprompt= memoryRouter.buildPrompt(message, userId); // Generate response ChatResponseresponse= chatClient.call(prompt); Stringreply= response.getResults().get(0).getOutput().getText(); // Extract and store new knowledge memoryExtractor.extractAndStore( newUserMessage(message), newAssistantMessage(reply) ); return reply; } }
In this example:
Short-term memory tracks the current billing conversation
Long-term memory provides account details and billing policies
Episodic memory recalls previous billing issues and resolutions
The result is an agent that feels personalized and context-aware, rather than starting from scratch every time.
Common Pitfalls and Solutions
Pitfall 1: Memory Overload
Problem: Storing too much information makes retrieval noisy and slow.
Solution: Implement relevance scoring and threshold-based filtering. Only store memories above a confidence threshold. Use summarization to compress low-value episodes.
Pitfall 2: Stale Memories
Problem: Old information becomes inaccurate but persists in the store.
Solution: Add expiration timestamps and update mechanisms. When storing, check for existing similar memories and update them instead of creating duplicates.
1 2 3 4 5 6 7 8 9 10 11 12
publicvoidstoreOrUpdateFact(String content, Map<String, Object> metadata) { // Check for similar existing memories List<MemoryItem> similar = longTermMemory.retrieveRelevant(content, 1); if (!similar.isEmpty() && similar.get(0).getSimilarity() > 0.85) { // Update existing memory longTermMemory.update(similar.get(0).getId(), content, metadata); } else { // Store new memory longTermMemory.storeFact(content, metadata); } }
Pitfall 3: Context Window Bloat
Problem: Retrieving too many memories exceeds the context window.
Solution: Implement a budgeting system. Allocate token budgets to each memory type and truncate as needed.
1 2 3 4 5 6 7 8 9 10
publicclassMemoryBudget { privatestaticfinalintSHORT_TERM_BUDGET=4000; privatestaticfinalintLONG_TERM_BUDGET=2000; privatestaticfinalintEPISODIC_BUDGET=1000; // Truncate memories to fit budget public List<Message> truncateToFitBudget(List<Message> memories, int budget) { // Implementation using token counting } }
Testing Your Memory System
Don’t forget to test your memory implementation. Here’s a simple test strategy:
@SpringBootTest classMemorySystemTest { @Autowired private LongTermMemoryService longTermMemory; @Autowired private EpisodicMemoryService episodicMemory; @Test voidtestLongTermMemoryRetrieval() { // Store a fact longTermMemory.storeFact("The API rate limit is 100 requests per minute", Map.of("type", "fact")); // Retrieve it List<MemoryItem> results = longTermMemory.retrieveRelevant( "What is the API rate limit?", 3 ); // Verify assertThat(results).isNotEmpty(); assertThat(results.get(0).getContent()) .contains("rate limit"); } @Test voidtestEpisodicMemoryChronology() { // Store two episodes episodicMemory.storeEpisode("session1", "user1", List.of(newMessage(Role.USER, "First message")), List.of()); episodicMemory.storeEpisode("session2", "user1", List.of(newMessage(Role.USER, "Second message")), List.of()); // Retrieve in chronological order List<EpisodicEvent> episodes = episodicMemory.retrieveRelated( "messages", "user1", 10 ); // Verify ordering assertThat(episodes).hasSize(2); assertThat(episodes.get(0).getTimestamp()) .isBefore(episodes.get(1).getTimestamp()); } }
Key Takeaways
Three memory types serve different purposes: Short-term for immediate context, long-term for persistent knowledge, episodic for personal experiences.
Vector databases are essential for semantic retrieval: They enable similarity-based search over unstructured memory content.
The Memory Router is critical: It decides what to store, what to retrieve, and how to combine memories into prompts.
Performance matters: Use caching, async storage, and compaction to keep memory operations fast and cost-effective.
Test your memory system: Verify retrieval accuracy, ordering, and relevance scoring with comprehensive tests.
Avoid common pitfalls: Prevent memory overload, stale data, and context bloat with proper filtering and budgeting.
Building effective agent memory is an iterative process. Start simple with short-term and long-term memory, then add episodic capabilities as your use case demands. The goal is to create agents that feel truly personalized and context-aware, rather than stateless conversationalists starting from scratch every time.
Remember: good memory architecture is the difference between an agent that forgets and one that remembers. Choose your memory types wisely, and your agents will thank you with better performance and happier users.