Building agents that remember context across sessions is where most LlamaIndex prototypes break down. The framework gives you several memory primitives, but the documentation treats them as interchangeable — they’re not. This llamaindex agent memory state tutorial walks through the patterns that actually hold up in production, with code you can drop into a real system.
Understanding the memory hierarchy
LlamaIndex exposes three distinct memory layers, each solving a different problem. Choosing the wrong one is the most common source of subtle bugs.
ChatMemoryBuffer keeps the last N token-equivalents of conversation history in a simple list. It’s fast, deterministic, and works well for short-lived interactions where you only need recent context. The tradeoff: it forgets everything outside the window.
VectorStoreIndex-backed memory (often called “long-term memory”) embeds conversation turns and retrieves semantically relevant ones at query time. This scales to months of history but introduces latency, non-determinism, and embedding costs.
Custom state objects let you persist structured data — user preferences, extracted entities, workflow checkpoints — that doesn’t fit naturally in a message list. This is where most production systems end up.
You’ll typically compose all three. A reasonable default: ChatMemoryBuffer for the active window, vector memory for “have we discussed this before?” lookups, and a JSON-serializable state dict for everything else.
Setting up the baseline agent
Start with a minimal agent that uses only ChatMemoryBuffer. This establishes the contract your memory layer must satisfy.
from llama_index.core.agent import FunctionCallingAgentWorker
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.tools import FunctionTool
from llama_index.llms.openai import OpenAI
def get_weather(location: str) -> str:
"""Mock weather tool for demonstration."""
return f"The weather in {location} is sunny, 72°F."
tools = [FunctionTool.from_defaults(fn=get_weather)]
llm = OpenAI(model="gpt-4o-mini", temperature=0)
memory = ChatMemoryBuffer.from_defaults(token_limit=3000)
agent_worker = FunctionCallingAgentWorker.from_tools(
tools=tools,
llm=llm,
memory=memory,
verbose=True,
)
agent = agent_worker.as_agent()
Run a few turns and inspect memory.get() to see the raw message list. This is your debugging baseline — if something breaks later, compare against this.
Extending the token window with summarization
The default ChatMemoryBuffer truncates aggressively. For longer conversations, wrap it with a summarizer that compresses older turns while preserving key facts.
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.llms import ChatMessage
from llama_index.llms.openai import OpenAI
class SummarizingMemory(ChatMemoryBuffer):
def __init__(self, *args, summarizer_llm=None, summarize_every_n=10, **kwargs):
super().__init__(*args, **kwargs)
self.summarizer_llm = summarizer_llm or OpenAI(model="gpt-4o-mini")
self.summarize_every_n = summarize_every_n
self._turn_count = 0
def put(self, message: ChatMessage) -> None:
super().put(message)
self._turn_count += 1
if self._turn_count >= self.summarize_every_n:
self._summarize_oldest()
self._turn_count = 0
def _summarize_oldest(self) -> None:
if len(self.chat_history) <= 2:
return
# Keep system prompt + first user message, summarize the rest
to_summarize = self.chat_history[1:-1]
if not to_summarize:
return
prompt = (
"Summarize the following conversation segment, preserving "
"specific facts, decisions, and user preferences:\n\n"
+ "\n".join(f"{m.role}: {m.content}" for m in to_summarize)
)
summary = self.summarizer_llm.complete(prompt)
# Replace summarized messages with a single summary message
self.chat_history = (
[self.chat_history[0]] +
[ChatMessage(role="system", content=f"Previous context: {summary.text}")] +
[self.chat_history[-1]]
)
This keeps the token count bounded while retaining signal. The summarization frequency (summarize_every_n) is a tuning knob — too aggressive loses nuance, too loose defeats the purpose.
Adding vector-backed long-term memory
When a user asks “what did I decide about the vendor last month?”, ChatMemoryBuffer can’t help. You need semantic retrieval over the full history.
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.core.vector_stores import SimpleVectorStore
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core.schema import TextNode
import json
class VectorMemory:
def __init__(self, persist_path: str = "./vector_memory"):
self.persist_path = persist_path
self.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
self.vector_store = SimpleVectorStore()
self.index = VectorStoreIndex(
[],
embed_model=self.embed_model,
vector_store=self.vector_store,
)
self._load()
def _load(self):
try:
self.vector_store.persist(self.persist_path)
except FileNotFoundError:
pass
def add_turn(self, user_msg: str, assistant_msg: str, metadata: dict = None):
"""Store a conversation turn as a retrievable node."""
content = f"User: {user_msg}\nAssistant: {assistant_msg}"
node = TextNode(
text=content,
metadata=metadata or {},
embedding=self.embed_model.get_text_embedding(content),
)
self.index.insert_nodes([node])
self.vector_store.persist(self.persist_path)
def retrieve_relevant(self, query: str, top_k: int = 3) -> list[str]:
retriever = self.index.as_retriever(similarity_top_k=top_k)
nodes = retriever.retrieve(query)
return [n.text for n in nodes]
Wire this into your agent loop:
vector_memory = VectorMemory()
def chat_with_memory(user_input: str) -> str:
# Retrieve relevant history
relevant = vector_memory.retrieve_relevant(user_input)
context = "\n---\n".join(relevant) if relevant else "No relevant history."
# Inject as system context
augmented_input = f"Relevant past context:\n{context}\n\nCurrent query: {user_input}"
response = agent.chat(augmented_input)
# Persist this turn
vector_memory.add_turn(user_input, str(response))
return str(response)
Pitfall: Embedding every turn gets expensive fast. Batch inserts, use a cheaper embedding model (text-embedding-3-small is ~5x cheaper than ada-002), and consider filtering — you don’t need to embed “hello” and “thank you” exchanges.
Structured state for workflows
Agents that execute multi-step workflows need checkpoints. Don’t stuff this into chat history. Use a dedicated state object that serializes cleanly.
from dataclasses import dataclass, asdict
from typing import Optional, List
import json
from pathlib import Path
@dataclass
class AgentState:
user_id: str
session_id: str
current_workflow: Optional[str] = None
workflow_step: int = 0
collected_slots: dict = None
preferences: dict = None
last_updated: str = ""
def __post_init__(self):
if self.collected_slots is None:
self.collected_slots = {}
if self.preferences is None:
self.preferences = {}
from datetime import datetime
self.last_updated = datetime.utcnow().isoformat()
def to_json(self) -> str:
return json.dumps(asdict(self), indent=2)
@classmethod
def from_json(cls, data: str) -> "AgentState":
return cls(**json.loads(data))
class StateStore:
def __init__(self, base_path: str = "./agent_state"):
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
def _path(self, user_id: str, session_id: str) -> Path:
return self.base_path / f"{user_id}_{session_id}.json"
def save(self, state: AgentState) -> None:
self._path(state.user_id, state.session_id).write_text(state.to_json())
def load(self, user_id: str, session_id: str) -> Optional[AgentState]:
path = self._path(user_id, session_id)
if not path.exists():
return None
return AgentState.from_json(path.read_text())
def delete(self, user_id: str, session_id: str) -> None:
self._path(user_id, session_id).unlink(missing_ok=True)
Now your tools can read and write structured state:
from llama_index.core.tools import FunctionTool
state_store = StateStore()
def update_workflow_state(
user_id: str,
session_id: str,
workflow: str,
step: int,
slots: dict = None,
) -> str:
state = state_store.load(user_id, session_id) or AgentState(
user_id=user_id, session_id=session_id
)
state.current_workflow = workflow
state.workflow_step = step
if slots:
state.collected_slots.update(slots)
state_store.save(state)
return f"State updated: {workflow} step {step}"
def get_workflow_state(user_id: str, session_id: str) -> str:
state = state_store.load(user_id, session_id)
if not state:
return "No state found."
return f"Workflow: {state.current_workflow}, Step: {state.workflow_step}, Slots: {state.collected_slots}"
workflow_tools = [
FunctionTool.from_defaults(fn=update_workflow_state),
FunctionTool.from_defaults(fn=get_workflow_state),
]
This pattern — explicit state machines with durable storage — beats “let the LLM figure it out from context” every time for anything involving money, compliance, or user-facing commitments.
Composing the full memory stack
Here’s how the pieces fit together in a production agent loop:
class ProductionAgent:
def __init__(
self,
user_id: str,
session_id: str,
llm=None,
tools=None,
):
self.user_id = user_id
self.session_id = session_id
self.llm = llm or OpenAI(model="gpt-4o-mini")
self.tools = tools or []
# Layer 1: Short-term chat buffer
self.chat_memory = ChatMemoryBuffer.from_defaults(token_limit=4000)
# Layer 2: Vector long-term memory
self.vector_memory = VectorMemory()
# Layer 3: Structured state
self.state_store = StateStore()
self.state = self.state_store.load(user_id, session_id) or AgentState(
user_id=user_id, session_id=session_id
)
self.agent_worker = FunctionCallingAgentWorker.from_tools(
tools=self.tools,
llm=self.llm,
memory=self.chat_memory,
verbose=True,
)
self.agent = self.agent_worker.as_agent()
def chat(self, user_input: str) -> str:
# 1. Retrieve semantic context
relevant = self.vector_memory.retrieve_relevant(user_input, top_k=3)
context_block = "\n---\n".join(relevant) if relevant else ""
# 2. Inject structured state as context
state_context = (
f"Current workflow: {self.state.current_workflow or 'none'}\n"
f"Step: {self.state.workflow_step}\n"
f"Collected: {self.state.collected_slots}\n"
f"Preferences: {self.state.preferences}"
)
# 3. Build augmented prompt
system_prompt = (
"You are a helpful assistant with access to tools. "
"Relevant conversation history:\n"
f"{context_block}\n\n"
f"Current state:\n{state_context}"
)
# 4. Execute
full_input = f"{system_prompt}\n\nUser: {user_input}"
response = self.agent.chat(full_input)
# 5. Persist everything
self.vector_memory.add_turn(user_input, str(response))
self.state_store.save(self.state)
return str(response)
def update_state(self, **kwargs) -> None:
for k, v in kwargs.items():
if hasattr(self.state, k):
setattr(self.state, k, v)
self.state_store.save(self.state)
Common pitfalls and tradeoffs
Token budget collisions: ChatMemoryBuffer, vector retrieval results, and state context all compete for the same context window. Monitor llm.metadata.context_window and allocate budgets explicitly — e.g., 60% chat buffer, 20% vector context, 20% state. If you’re using n4n.ai’s gateway, the per-token metering makes this visible in real time.
Stale vector embeddings: User preferences change. If someone updates their dietary restriction from “vegetarian” to “vegan”, the old “vegetarian” embedding still matches queries about food. Implement a TTL or explicit invalidation: when structured state updates, re-embed affected turns or tag them with a version field you filter on at retrieval time.
Race conditions in state writes: If your agent handles concurrent requests for the same session (common in web deployments), the simple file-based StateStore will lose updates. Use a real database with optimistic locking (PostgreSQL FOR UPDATE SKIP LOCKED, Redis WATCH/MULTI/EXEC, or DynamoDB conditional writes). The dataclass pattern stays the same; only the persistence layer changes.
Over-retrieval noise: Vector memory returns semantically similar but contextually irrelevant turns. A user asking “cancel my subscription” matches “how do I subscribe?” — opposite intent. Add a lightweight classifier or keyword filter before injecting retrieved context. Even a simple “does this contain negation words?” heuristic cuts false positives significantly.
Summarization drift: The SummarizingMemory class above loses nuance over many cycles. After ~5 summarization rounds, specific numbers and names degrade. Mitigation: keep a separate “key facts” list in structured state that the summarizer is prompted to preserve verbatim, or use a hierarchical approach where only the oldest 50% of the buffer gets summarized each round.
Persistence strategy for deployment
Local JSON files work for development. In production, you need:
| Layer | Storage | Reason |
|---|---|---|
| Chat buffer | Redis (TTL 24-72h) | Fast append/read, auto-expiry |
| Vector memory | Pinecone / Weaviate / pgvector | Scales, supports metadata filtering |
| Structured state | PostgreSQL (JSONB) | ACID, queryable, migrations |
The agent code doesn’t change — swap the VectorMemory and StateStore implementations. Keep the interface identical.
# Production vector_memory_protocol.py
from abc import ABC, abstractmethod
from typing import List, Optional
class VectorMemoryProtocol(ABC):
@abstractmethod
def add_turn(self, user_msg: str, assistant_msg: str, metadata: dict = None): ...
@abstractmethod
def retrieve_relevant(self, query: str, top_k: int = 3) -> List[str]: ...
class StateStoreProtocol(ABC):
@abstractmethod
def save(self, state: AgentState) -> None: ...
@abstractmethod
def load(self, user_id: str, session_id: str) -> Optional[AgentState]: ...
Implement these protocols for each backend. Your ProductionAgent depends only on the protocols.
Testing memory behavior
Write integration tests that verify memory semantics, not just “does it run.”
def test_vector_memory_retrieves_correct_turn():
vm = VectorMemory(persist_path="/tmp/test_vec")
vm.add_turn("I love pizza", "Great choice!", {"topic": "food"})
vm.add_turn("I hate broccoli", "Noted.", {"topic": "food"})
results = vm.retrieve_relevant("what do I like?", top_k=1)
assert "pizza" in results[0].lower()
assert "broccoli" not in results[0].lower()
def test_state_persistence_survives_restart():
store = StateStore("/tmp/test_state")
state = AgentState(user_id="u1", session_id="s1", current_workflow="onboarding", workflow_step=2)
store.save(state)
# Simulate new process
new_store = StateStore("/tmp/test_state")
loaded = new_store.load("u1", "s1")
assert loaded.current_workflow == "onboarding"
assert loaded.workflow_step == 2
Run these in CI. Memory bugs are the hardest to catch manually because they manifest over time and across sessions.
When to use each layer
| Scenario | Primary layer | Why |
|---|---|---|
| “What did I just ask?” | ChatMemoryBuffer | Deterministic, zero latency |
| “What was that vendor name from last week?” | Vector memory | Semantic match across time |
| “Am I on step 3 of onboarding?” | Structured state | Exact, queryable, auditable |
| “Summarize our last 5 conversations” | Vector + summarization | Retrieval + compression |
Don’t try to make one layer do another’s job. Stuffing workflow state into chat history creates fragile prompts. Using vector search for “what’s my current step?” returns noise.
The pattern that scales: short-term buffer for continuity, vector index for recall, structured state for truth. Build the adapters once, test the boundaries, and you’ll stop debugging memory leaks in production.