AI Engineering10 min read·Jul 24, 2026

Giving LLMs a Brain: Architecting Memory for Production AI Applications

LLMs are stateless by default, making multi-turn conversations and knowledge recall a challenge. This post dives into architecting memory solutions for production AI applications, covering both short-term context management and long-term retrieval-augmented generation (RAG).

ShareLinkedInX / Twitter

Most of the time, when you interact with an LLM, it's a fresh slate. Each prompt is processed independently. This works fine for single-shot questions, but it falls apart quickly when you want a coherent conversation or for the model to recall specific information from past interactions or a large knowledge base.

Building production-grade AI applications means giving these models a 'brain'—a robust memory system. It's not just about getting an answer; it's about getting a relevant answer within an ongoing context. I've spent a good chunk of time grappling with this, especially when building systems like Snapfix, where the AI needs to understand the context of a traceback and previous attempts at a fix, or in more complex orchestration layers like the Dronzer AI Gateway where managing context across different LLM providers becomes a critical piece of the puzzle.

The Stateless Nature of LLMs

Think about how an LLM works: you send it a prompt, it processes that prompt, and it returns a response. That's it. It doesn't inherently remember the prompt you sent five minutes ago, or even the response it just gave you. This statelessness is efficient for scaling individual requests, but it makes building stateful, conversational agents tricky. If you want a chatbot to follow up on a previous question, you have to explicitly provide the history with each new turn.

This is where memory comes in. We're essentially building mechanisms to feed the LLM the necessary context, making it appear to have memory.

Short-Term Memory: Managing the Conversation Context

The most immediate form of memory is managing the current conversation. LLMs have a 'context window'—a limit on how many tokens they can process in a single prompt. Exceed this, and you get an error or, worse, the model starts hallucinating because it's truncated important information.

There are a few common strategies for handling this short-term conversational memory:

1. Simple Concatenation

The easiest approach is to just append every new message to the conversation history and send the whole thing with each turn. It looks something like this:

conversation_history = [
    {"role": "system", "content": "You are a helpful AI assistant."}
]

def add_message(role, content):
    conversation_history.append({"role": role, "content": content})

def get_response(user_input, model="gpt-3.5-turbo"):
    add_message("user", user_input)
    # Call LLM with full conversation_history
    # For this example, we'll use LiteLLM to simulate a call
    from litellm import completion

    try:
        response = completion(model=model, messages=conversation_history)
        assistant_response = response.choices[0].message.content
        add_message("assistant", assistant_response)
        return assistant_response
    except Exception as e:
        print(f"Error calling LLM: {e}")
        # In a real app, you'd handle token limits here
        return "I'm sorry, I can't process that much information right now."

# Example usage:
# print(get_response("What's the capital of France?"))
# print(get_response("And what's the population of that city?"))

Pros: Simple to implement, preserves full fidelity. Cons: Hits token limits very quickly, especially with verbose users or models with smaller context windows. Cost scales linearly with conversation length.

2. Sliding Window

To combat token limits, a sliding window approach keeps only the N most recent messages. When the conversation history exceeds a certain token count or message count, the oldest messages are dropped.

from collections import deque
from litellm import completion

class SlidingWindowMemory:
    def __init__(self, max_messages=5, system_prompt="You are a helpful AI assistant."):
        self.max_messages = max_messages
        self.history = deque(
            [{"role": "system", "content": system_prompt}], 
            maxlen=max_messages + 1 # +1 for system prompt
        )

    def add_message(self, role, content):
        self.history.append({"role": role, "content": content})

    def get_messages(self):
        return list(self.history)

def get_response_with_sliding_window(memory: SlidingWindowMemory, user_input, model="gpt-3.5-turbo"):
    memory.add_message("user", user_input)
    current_messages = memory.get_messages()

    try:
        response = completion(model=model, messages=current_messages)
        assistant_response = response.choices[0].message.content
        memory.add_message("assistant", assistant_response)
        return assistant_response
    except Exception as e:
        print(f"Error calling LLM: {e}")
        return "I'm sorry, I seem to have lost track of our longer conversation."

# Example usage:
# memory = SlidingWindowMemory(max_messages=4)
# print(get_response_with_sliding_window(memory, "Hi there!"))
# print(get_response_with_sliding_window(memory, "What's your favorite color?"))
# # ... more messages, older ones will be dropped

Pros: Keeps conversations within token limits, maintains recent context. Cons: Loses older, potentially important context. Can lead to jarring jumps if key information is dropped.

3. Summarization

Instead of dropping old messages, you can summarize them. Periodically, the older parts of the conversation are condensed into a single summary message, which replaces the original messages in the history. This keeps the overall token count down while retaining more of the original information than a sliding window.

This usually involves calling the LLM itself to summarize the conversation history, which adds latency and cost.

# Conceptually:
# if total_tokens(conversation_history) > threshold:
#     summary_prompt = "Summarize the following conversation for context: " + \ 
#                      str(conversation_history[:-N]) # summarize oldest N messages
#     summary = llm.generate(summary_prompt)
#     conversation_history = [system_prompt, summary_message] + conversation_history[-N:]

Pros: Retains more information than a sliding window, better context for longer conversations. Cons: Introduces latency and cost for summarization calls. Summaries are lossy; details can be lost. Requires careful prompt engineering for effective summarization.

Long-Term Memory: Retrieval Augmented Generation (RAG)

Short-term memory handles the current chat. But what if your LLM needs to answer questions about proprietary documents, recent news that wasn't in its training data, or highly specific domain knowledge? That's where long-term memory, primarily through Retrieval Augmented Generation (RAG), comes into play.

RAG involves three main steps:

  1. Indexing: You take your external knowledge base (documents, articles, database records) and break it into smaller chunks. Each chunk is then converted into a numerical representation called an 'embedding' using an embedding model. These embeddings are stored in a vector database (e.g., pgvector, Milvus, Pinecone, Chroma).
  2. Retrieval: When a user asks a question, their query is also embedded. This query embedding is then used to perform a similarity search in your vector database, finding the most relevant chunks of information from your indexed knowledge base.
  3. Augmentation: The retrieved chunks are then inserted directly into the LLM's prompt, along with the user's original question. The LLM then generates a response based on this augmented context.

This process allows the LLM to 'look up' information in real-time, effectively extending its knowledge beyond its training data.

Here's a simplified conceptual example of RAG:

from litellm import embedding, completion
import numpy as np

# 1. Simulate a knowledge base and embeddings
knowledge_base = {
    "doc1": "The capital of France is Paris. It is known for its Eiffel Tower.",
    "doc2": "Paris is a major European city with a population of over 2 million people.",
    "doc3": "The Louvre Museum in Paris houses thousands of works of art."
}

def get_text_embedding(text, model="text-embedding-ada-002"):
    try:
        # Using LiteLLM for embedding calls too
        response = embedding(model=model, input=[text])
        return response.data[0].embedding
    except Exception as e:
        print(f"Error getting embedding: {e}")
        return None

# In a real system, these would be stored in a vector DB
# For simplicity, we'll compute on the fly or pre-store for this example
embedded_knowledge = {
    k: get_text_embedding(v) for k, v in knowledge_base.items()
}

# 2. Simulate retrieval
def retrieve_relevant_chunks(query, embedded_kb, top_k=1):
    query_embedding = get_text_embedding(query)
    if query_embedding is None:
        return []
    
    similarities = []
    for doc_id, doc_embed in embedded_kb.items():
        if doc_embed is not None:
            # Cosine similarity (dot product for normalized vectors)
            similarity = np.dot(query_embedding, doc_embed)
            similarities.append((similarity, knowledge_base[doc_id]))
    
    similarities.sort(key=lambda x: x[0], reverse=True)
    return [text for _, text in similarities[:top_k]]

# 3. Augment prompt and generate response
def get_rag_response(user_query, embedded_kb, model="gpt-3.5-turbo"):
    retrieved_info = retrieve_relevant_chunks(user_query, embedded_kb, top_k=2)
    
    if not retrieved_info:
        context_str = "No specific information found."
    else:
        context_str = "\n\nRelevant Information:\n" + "\n".join(retrieved_info)
    
    prompt = f"Based on the following information, answer the user's question. If the information doesn't contain the answer, state that you don't know.\n\nUser question: {user_query}{context_str}"
    
    try:
        response = completion(model=model, messages=[
            {"role": "system", "content": "You are a helpful assistant that answers questions based on provided context."},
            {"role": "user", "content": prompt}
        ])
        return response.choices[0].message.content
    except Exception as e:
        print(f"Error calling LLM: {e}")
        return "I'm having trouble retrieving information right now."

# Example usage:
# print(get_rag_response("What is Paris known for?", embedded_knowledge))
# print(get_rag_response("Where is the Louvre Museum located?", embedded_knowledge))

Orchestration and Multi-Provider Considerations

When you're building systems like Dronzer AI Gateway, which routes requests across 45+ LLM providers, memory management takes on another layer of complexity. The gateway itself doesn't store the conversation history or the vector database. Instead, its role is to ensure that the memory-augmented prompt gets to the right model efficiently and reliably.

Here's how an intelligent gateway integrates with memory strategies:

  1. Context-Aware Routing: The gateway can be configured to route requests based on prompt length. If a prompt (including its memory context) is very long, it might be routed to a model with a larger context window or a provider known for handling longer inputs more economically.
  2. Failover with Context: If one LLM provider fails while processing a large, memory-rich prompt, the gateway needs to ensure that the entire context is passed to the next available provider during failover. This means the application layer needs to provide the full prompt to the gateway, which then handles the retry gracefully.
  3. Provider-Specific Optimizations: Different LLMs might have slightly different ideal prompt formats or tokenization. A gateway can abstract this, transforming your application's memory-augmented prompt into the optimal format for the chosen provider.
  4. Cost Management: By intelligently routing based on context size and provider pricing, the gateway can help optimize costs associated with sending long contexts, especially if summarization isn't enough.

For Snapfix, where we support GPT-4, Claude, and local Ollama models via LiteLLM, the framework itself helps in abstracting the provider-specific calls. But for a more robust enterprise solution, a gateway like Dronzer would handle the routing logic, ensuring the memory-augmented prompt is delivered to the best-suited backend, even if it means transparently switching providers.

Challenges and Best Practices

Implementing LLM memory isn't without its hurdles:

  • Token Limits and Cost: Even with RAG, the retrieved context still consumes tokens. Long contexts mean higher costs and potentially slower inference. Striking the right balance between detail and brevity is crucial.
  • Latency: Retrieval from a vector database adds latency to each LLM call. Optimizing your indexing and retrieval pipeline is key for real-time applications.
  • Relevance: The quality of your RAG system heavily depends on how well your chunks are embedded and how accurately your retrieval system finds relevant information. Poor retrieval leads to 'garbage in, garbage out.'
  • State Management: For complex agents, maintaining state beyond simple conversation history (e.g., user preferences, ongoing tasks) requires more sophisticated data structures and serialization.

To build effective memory systems, you need to be deliberate. Design your short-term memory strategy based on the nature of your conversations. For long-term knowledge, invest time in chunking strategies, embedding model selection, and robust vector database integration. And for production systems, consider how an orchestration layer can manage the complexities of routing these context-rich requests across multiple providers.

Wrapping Up

Giving LLMs memory transforms them from sophisticated autocomplete engines into truly intelligent, stateful agents. Whether it's managing a simple chatbot conversation or allowing an AI to query vast internal knowledge bases, a well-architected memory system is fundamental to building useful and reliable AI applications. It's about empowering the LLM with the context it needs to perform, making your AI solutions smarter and more capable in the real world.

Frequently Asked Questions

Why do LLMs need memory?

LLMs are inherently stateless, meaning they don't remember past interactions. For multi-turn conversations or accessing specific knowledge, they need a 'memory' system to provide relevant context with each new prompt.

What's the difference between short-term and long-term LLM memory?

Short-term memory manages the immediate conversational context within the model's token limit, often using techniques like sliding windows or summarization. Long-term memory, typically implemented via Retrieval Augmented Generation (RAG), allows LLMs to access vast external knowledge bases beyond their training data or context window.

How does an LLM gateway like Dronzer fit into memory management?

An LLM gateway doesn't typically store the memory itself, but it's crucial for orchestrating how memory-augmented prompts are handled. It can route requests containing extensive context, manage failover if a provider struggles with large inputs, and even apply provider-specific optimizations for prompt formatting or context window management.

What are common challenges when implementing LLM memory?

Key challenges include managing token limits and associated costs, minimizing latency introduced by retrieval processes, ensuring the relevance and accuracy of retrieved information, and balancing aggressive summarization with the need to retain critical details.

AG

Written by Ansh Gautam

Full-stack engineer building production systems with FastAPI, React, and AI/LLM integrations. Currently looking for backend engineering & AI integration roles.