In enterprise AI, the most expensive operation is often redundant computation. When a financial analyst asks the same question about Q3 revenue three times in a meeting, or when multiple agents in a LangGraph system independently request embeddings for identical document chunks, you're burning through API credits, GPU cycles, and latency budgets.
The solution isn't just "add caching." It's adding the right kind of caching. An LRU (Least Recently Used) cache with true O(1) get and put operations ensures that:
Cache lookups never slow down, regardless of cache size
Memory stays bounded (critical for long-running enterprise services)
The most relevant data stays hot while stale data is automatically evicted
In this article, we will build an LRU cache from scratch using a doubly-linked list + hash map (the canonical O(1) implementation), then integrate it into an Enterprise RAG Multi-Agent System built with LangGraph—complete with persistent conversational memory and strict state management.
Part 1: The O(1) LRU Cache from Scratch
Most developers reach for functools.lru_cache or collections.OrderedDict. But in enterprise systems—especially when you need custom eviction policies, metrics tracking, or thread-safe wrappers—you need to understand the underlying data structure.
The Data Structure: Hash Map + Doubly-Linked List
To achieve O(1) for both get and put, we need two structures working in tandem:
| Operation | Hash Map | Doubly-Linked List |
|---|---|---|
| Lookup by key | O(1) | O(n) |
| Insert at front | O(1) | O(1) |
| Remove arbitrary node | O(1) | O(1) if you have the pointer |
| Remove tail (LRU) | O(1) | O(1) |
The hash map gives us instant key lookup, returning a pointer to the linked-list node. The doubly-linked list maintains access order in O(1) time, with sentinel head/tail nodes eliminating null-check edge cases.
class Node:
"""A node in the doubly-linked list."""
__slots__ = ['key', 'value', 'prev', 'next'] # Memory optimization
def __init__(self, key=None, value=None):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
"""
A from-scratch LRU Cache with O(1) get and put operations.
Uses a hash map for O(1) lookups and a doubly-linked list for O(1) ordering.
"""
def __init__(self, capacity: int):
if capacity <= 0:
raise ValueError("Capacity must be positive")
self.capacity = capacity
self.cache = {} # Hash map: key -> Node
# Sentinel nodes eliminate null-check edge cases
self.head = Node() # Dummy head (most recently used side)
self.tail = Node() # Dummy tail (least recently used side)
self.head.next = self.tail
self.tail.prev = self.head
# Metrics for enterprise observability
self.hits = 0
self.misses = 0
self.evictions = 0
# --- Private O(1) linked-list operations ---
def _remove(self, node: Node) -> None:
"""Remove a node from the linked list. O(1)."""
prev_node = node.prev
next_node = node.next
prev_node.next = next_node
next_node.prev = prev_node
def _add_to_front(self, node: Node) -> None:
"""Add a node right after the sentinel head (mark as most recent). O(1)."""
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _move_to_front(self, node: Node) -> None:
"""Move an existing node to the front. O(1)."""
self._remove(node)
self._add_to_front(node)
def _pop_lru(self) -> Node:
"""Remove and return the least recently used node (just before tail). O(1)."""
lru_node = self.tail.prev
self._remove(lru_node)
return lru_node
# --- Public O(1) API ---
def get(self, key: int) -> object:
"""Retrieve value by key. Returns None on miss. O(1)."""
if key not in self.cache:
self.misses += 1
return None
node = self.cache[key]
self._move_to_front(node) # Mark as recently used
self.hits += 1
return node.value
def put(self, key: int, value: object) -> None:
"""Insert or update a key-value pair. O(1)."""
if key in self.cache:
# Update existing: move to front
node = self.cache[key]
node.value = value
self._move_to_front(node)
else:
# Insert new
new_node = Node(key, value)
self.cache[key] = new_node
self._add_to_front(new_node)
# Evict LRU if over capacity
if len(self.cache) > self.capacity:
lru = self._pop_lru()
del self.cache[lru.key]
self.evictions += 1
def stats(self) -> dict:
"""Return cache metrics for observability."""
total = self.hits + self.misses
hit_rate = (self.hits / total * 100) if total > 0 else 0.0
return {
"size": len(self.cache),
"capacity": self.capacity,
"hits": self.hits,
"misses": self.misses,
"evictions": self.evictions,
"hit_rate_percent": round(hit_rate, 2)
}Why this is truly O(1):
get: Hash map lookup O(1) + linked-list move-to-front O(1) = O(1)put: Hash map insert O(1) + linked-list add-to-front O(1) + optional tail removal O(1) = O(1)The sentinel nodes eliminate
if node is Nonechecks, keeping constant factors minimal.
Part 2: The Enterprise Use Case
The Scenario: A Global Investment Bank's Research Assistant.
Analysts constantly ask questions like:
"What was Apple's Q3 revenue?"
"Summarize the Fed's latest meeting minutes."
"Compare Tesla and BYD delivery numbers."
The Problem: Each question triggers a RAG pipeline:
Generate an embedding for the query (~$0.0001 per call, ~50ms latency)
Search the vector database
Retrieve context
Generate answer
When 50 analysts ask similar questions throughout the day, the bank burns through thousands of embedding API calls and adds hundreds of milliseconds of latency to each query.
The Solution: An LRU cache sits between the query and the embedding API. Frequently-asked questions (and semantically-similar rephrasings via normalized keys) return cached embeddings in O(1) time, saving money and latency.

Part 3: The Multi-Agent LangGraph Implementation
We'll build a LangGraph system with three agents:
Query Normalizer: Normalizes queries for cache-key consistency
Embedding Agent: Uses the LRU cache before calling the embedding API
RAG Synthesizer: Generates the final answer
1. Defining the State and Memory
import os
import getpass
import hashlib
import asyncio
from typing import TypedDict, Annotated, Sequence, List, Dict, Any
from pydantic import BaseModel
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
# --- Global LRU Cache Instance ---
# Capacity: 1000 embeddings (~12MB for 1536-dim vectors)
EMBEDDING_CACHE = LRUCache(capacity=1000)
# --- 1. Strict State Definitions ---
class CacheMetrics(BaseModel):
hits: int
misses: int
evictions: int
hit_rate: float
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], "add_messages"]
normalized_query: str
embedding_source: str # "cache" or "api"
cache_metrics: CacheMetrics
retrieved_context: str2. Defining the Tools
# --- 2. Define the Tools ---
def _normalize_query_key(query: str) -> int:
"""Generate a deterministic integer key from a query string."""
# Normalize: lowercase, strip, collapse whitespace
normalized = " ".join(query.lower().split())
# Hash to integer (using first 16 hex chars to fit in int range)
hash_hex = hashlib.sha256(normalized.encode()).hexdigest()[:16]
return int(hash_hex, 16)
async def _call_embedding_api(query: str) -> List[float]:
"""Simulates an expensive embedding API call."""
print(f" [EMBEDDING API] Generating embedding for: '{query[:50]}...'")
await asyncio.sleep(0.5) # Simulate 500ms API latency
# Return a deterministic mock vector
hash_val = int(hashlib.md5(query.encode()).hexdigest(), 16)
return [(hash_val % 1000) / 1000.0] * 1536
@tool
async def get_embedding_with_cache(query: str) -> Dict[str, Any]:
"""
Retrieves an embedding for a query, using the LRU cache to avoid
redundant API calls. Returns the embedding and cache source.
"""
cache_key = _normalize_query_key(query)
# O(1) cache lookup
cached_embedding = EMBEDDING_CACHE.get(cache_key)
if cached_embedding is not None:
print(f" [CACHE] HIT for query: '{query[:50]}...'")
return {
"embedding": cached_embedding[:5], # Truncate for display
"source": "cache",
"full_vector_size": len(cached_embedding)
}
# Cache miss: call the API
print(f" [CACHE] MISS for query: '{query[:50]}...'")
embedding = await _call_embedding_api(query)
# O(1) cache insertion
EMBEDDING_CACHE.put(cache_key, embedding)
return {
"embedding": embedding[:5],
"source": "api",
"full_vector_size": len(embedding)
}
@tool
async def search_vector_db(embedding: List[float], query: str) -> str:
"""Simulates a vector database similarity search."""
print(f" [VECTOR DB] Searching with {len(embedding)}-dim embedding...")
await asyncio.sleep(0.1)
# Mock retrieval based on query content
if "apple" in query.lower() or "aapl" in query.lower():
return "Apple Q3 2025 Revenue: $94.9B (+5% YoY). Services revenue hit $25.0B, a new record."
elif "tesla" in query.lower() or "byd" in query.lower():
return "Tesla Q3 deliveries: 462,890 vehicles. BYD Q3 deliveries: 1.13M vehicles (including PHEVs)."
elif "fed" in query.lower():
return "Fed Sept 2025 Meeting: Rates held at 4.25-4.50%. Dot plot signals one more cut in 2025."
else:
return "No specific data found for this query in the knowledge base."
tools = [get_embedding_with_cache, search_vector_db]
Join the conversation! Your thoughts help the community grow.