Redis as an agent’s working memory and rate limiter
Building autonomous, multi-agent execution systems for quantitative trading workflows sounds great until you put them into production.
A few months ago, I was running an asynchronous agent pipeline that scanned volatile crypto markets, parsed real-time news feeds, generated sentiment vectors, and drafted execution strategies. The agents ran concurrently, spawning dozens of tasks per second.
Within three hours of going live, the system collapsed.
First, we were hit by a barrage of HTTP 429 Too Many Requests errors from our LLM API providers during a sudden market dip. Second, the agents lost their state whenever a worker process recycled or encountered a transient network dropout. Third, our PostgreSQL database, which we were using to persist agent step-by-step histories, started crying under the weight of hundreds of concurrent read-write queries per second. Our p99 database latency climbed to 1.8 seconds, stalling our real-time execution loops.
I had to refactor our architecture immediately. The solution was to stop treating database layers and file systems as scratchpads. Instead, I turned to Redis. By leveraging Redis for three distinct patterns—as an atomic rate limiter, an in-memory sliding-window FIFO buffer, and a fast state hash map—we stabilized our pipeline, dropped our database latency to sub-milliseconds, and eliminated LLM rate-limit failures entirely.
This is the exact architecture, code, and hard-learned lessons from that implementation.
The Architecture
Our pipeline decouples agent coordination, state persistence, and rate enforcement. Instead of expecting the LLM orchestration framework to manage its own state internally, we externalize all memory and synchronization to Redis.
flowchart TD agent["Agent Worker"] ratelimit["Redis Token Bucket Rate Limiter"] state["Redis Working Memory (Hashes/Lists)"] llm["LLM API Provider"] agent -->|"1. Check allowance"| ratelimit ratelimit -->|"2. Return token state"| agent agent -->|"3. Fetch/Update state"| state agent -->|"4. Execute call"| llm
The workflow runs as follows:
1. Rate Limiting: Before any agent worker compiles an LLM prompt, it requests allowance from an atomic Redis token-bucket rate limiter.
2. Working Memory & Caching: The agent fetches its current execution state from a Redis Hash and its sliding-window context history (acting as high-speed agent memory) from a Redis List.
3. Execution: The agent runs the LLM call.
4. State Persistence: The agent updates its state in the Redis Hash and pushes the new interaction to the Redis List, trimming the list size to prevent context-window overflow.
1. The Atomic Rate Limiter (Lua Scripting)
A naive rate limiter in Python (e.g., fetching a value, checking it, and incrementing it) creates race conditions when multiple agent workers execute concurrently. If two workers check the current counter at the exact same millisecond, both will read a value below the limit, both will proceed, and both will hit a 429 error.
To solve this, we implement an atomic Token Bucket rate limiter inside Redis using a Lua script. Redis executes Lua scripts natively and atomically in a single thread, guaranteeing that no two workers can experience a race condition on the bucket state.
Here is our implementation:
import redis
class RedisTokenBucketRateLimiter:
def __init__(self, redis_client: redis.Redis, bucket_name: str, capacity: int, refill_rate_per_sec: float):
self.redis = redis_client
self.bucket_key = f"rate_limit:{bucket_name}"
self.capacity = capacity
# We store refill rate per millisecond to handle high-frequency requests accurately
self.refill_rate_per_ms = refill_rate_per_sec / 1000.0
# Define the Lua script for atomic token bucket consumption
self._lua_script = self.redis.register_script("""
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate_per_ms = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
— Retrieve current state from Redis Hash
local state = redis.call('HMGET', key, 'tokens', 'last_update')
local tokens = tonumber(state[1])
local last_update = tonumber(state[2])
if not tokens then
— First run: initialize bucket to full capacity
tokens = capacity
last_update = now
else
— Calculate refilled tokens based on elapsed time
local elapsed = now – last_update
if elapsed > 0 then
tokens = math.min(capacity, tokens + (elapsed * refill_rate_per_ms))
end
end
— Check if we have enough tokens
if tokens >= requested then
tokens = tokens – requested
redis.call('HMSET', key, 'tokens', tokens, 'last_update', now)
return 1 — Allowed
else
redis.call('HMSET', key, 'tokens', tokens, 'last_update', now)
return 0 — Denied
end
""")
def acquire(self, tokens: int = 1) -> bool:
"""
Acquires N tokens from the bucket. Returns True if allowed, False if rate-limited.
"""
# Current epoch time in milliseconds
now_ms = int(time.time() * 1000)
# Execute the Lua script atomically inside Redis
result = self._lua_script(
keys=[self.bucket_key],
args=[self.capacity, self.refill_rate_per_ms, now_ms, tokens]
)
return bool(result)
2. Sliding-Window Agent Memory (Redis Lists)
LLM contexts are finite and expensive. Passing an agent’s entire historical execution path back into the prompt for every turn leads to exponential token inflation and increased execution times.
We used a sliding-window model to implement agent memory. We represent memory as a FIFO queue of JSON-serialized messages using Redis Lists. We use LPUSH to prepend new messages and LTRIM to keep the list constrained to a specific length (e.g., the last 15 interactions).
from typing import List, Dict, Any
class RedisAgentMemory:
def __init__(self, redis_client: redis.Redis, agent_id: str, max_history_len: int = 15):
self.redis = redis_client
self.memory_key = f"agent:memory:{agent_id}"
self.max_history_len = max_history_len
def append_message(self, role: str, content: str, metadata: Dict[str, Any] = None) -> None:
"""
Appends a new message to the agent's sliding window memory.
"""
payload = {
"role": role,
"content": content,
"timestamp": time.time(),
"metadata": metadata or {}
}
# Push to the left (head) of the list
serialized = json.dumps(payload)
# Use a transaction pipeline to ensure push and trim execute together
pipe = self.redis.pipeline()
pipe.lpush(self.memory_key, serialized)
pipe.ltrim(self.memory_key, 0, self.max_history_len – 1)
pipe.execute()
def get_history(self) -> List[Dict[str, Any]]:
"""
Retrieves the complete active sliding-window history, from oldest to newest.
"""
# Range is 0 to -1 to get all items currently in the list
raw_items = self.redis.lrange(self.memory_key, 0, –1)
# Parse and reverse because we pushed from the left (most recent is at index 0)
history = [json.loads(item) for item in raw_items]
history.reverse()
return history
def clear(self) -> None:
"""
Clears the agent's memory.
"""
self.redis.delete(self.memory_key)
3. Persistent Agent State Storage (Redis Hashes)
While lists store step-by-step contextual history, we need a separate, structured repository for the agent’s active state variables—such as current task status, active trades, open API connections, and accumulated execution parameters. This data requires sub-millisecond random access and modification.
Using JSON strings in standard keys means serialization overhead every time an agent wants to read or mutate a single field. Instead, we use Redis Hashes (HGETALL, HSET, HINCRBY), which act like remote Python dictionaries.
def __init__(self, redis_client: redis.Redis, agent_id: str):
self.redis = redis_client
self.state_key = f"agent:state:{agent_id}"
def update_field(self, field: str, value: Any) -> None:
"""
Updates a specific state variable. Converts non-primitive types to JSON.
"""
if isinstance(value, (dict, list)):
val_to_store = json.dumps(value)
else:
val_to_store = str(value)
self.redis.hset(self.state_key, field, val_to_store)
def get_field(self, field: str, is_json: bool = False) -> Any:
"""
Gets a single state field.
"""
val = self.redis.hget(self.state_key, field)
if not val:
return None
decoded = val.decode('utf-8')
return json.loads(decoded) if is_json else decoded
def increment_metric(self, metric_name: str, amount: int = 1) -> int:
"""
Atomically increments a numerical metric (e.g., token consumption, error counts).
"""
return self.redis.hincrby(self.state_key, metric_name, amount)
def get_all_state(self) -> Dict[str, str]:
"""
Retrieves the complete state snapshot for diagnostics.
"""
raw_hash = self.redis.hgetall(self.state_key)
return {k.decode('utf-8'): v.decode('utf-8') for k, v in raw_hash.items()}
Putting It All Together: The Production Orchestrator
Below is a complete, production-grade simulation demonstrating how these three pieces coordinate. It simulates an autonomous trading agent that executes trades while adhering to rate limits and preserving internal state across execution cycles.
import random
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("AgentOrchestrator")
# Initialize shared Redis connection
r = redis.Redis(host='localhost', port=6379, db=0)
# Flush keys for a clean run demonstration
r.delete("rate_limit:openai_gpt4", "agent:memory:agent_007", "agent:state:agent_007")
# Define Agent instances
rate_limiter = RedisTokenBucketRateLimiter(
redis_client=r,
bucket_name="openai_gpt4",
capacity=5, # Allow bursts up to 5 calls
refill_rate_per_sec=0.5 # Refill at 0.5 tokens per second (1 token every 2 seconds)
)
memory = RedisAgentMemory(redis_client=r, agent_id="agent_007", max_history_len=3)
state = RedisAgentState(redis_client=r, agent_id="agent_007")
# Initialize starting working memory state
state.update_field("current_strategy", "Arbitrage")
state.update_field("capital_allocated", 50000)
state.update_field("trades_executed", 0)
# Mock agent loop
def run_agent_iteration(step: int):
logger.info(f"\n— STEP {step} —")
# 1. Enforce Rate Limiting
logger.info("Attempting to acquire rate-limiting slot…")
acquired = rate_limiter.acquire(tokens=1)
if not acquired:
logger.warning("Agent execution rate-limited! Postponing API transaction.")
return False
logger.info("Token acquired. Executing agent iteration.")
# 2. Fetch Working Memory and State
current_strategy = state.get_field("current_strategy")
trades_executed = int(state.get_field("trades_executed") or 0)
history = memory.get_history()
logger.info(f"Retrieved State: Strategy={current_strategy}, Trades Count={trades_executed}")
logger.info(f"Retrieved Memory Window Size: {len(history)} messages")
# 3. Simulate Agent Execution (Mock API call output)
simulated_market_volatility = random.uniform(0.01, 0.05)
action = "HOLD"
if simulated_market_volatility > 0.03:
action = "EXECUTE BUY"
new_trade_count = state.increment_metric("trades_executed", 1)
logger.info(f"Market volatility high! Action decided: BUY. Incrementing total trades to {new_trade_count}.")
else:
logger.info("Market volatility low. Action decided: HOLD.")
# 4. Log interaction back into sliding memory
memory.append_message("user", f"Context Volatility: {simulated_market_volatility:.4f}")
memory.append_message("assistant", f"Decision: {action}")
# Quick debug view of the sliding window memory
updated_history = memory.get_history()
logger.info("Active Sliding History:")
for msg in updated_history:
logger.info(f" [{msg['role'].upper()}] – {msg['content']} (ts: {msg['timestamp']:.2f})")
return True
# Run execution loop
for i in range(1, 8):
success = run_agent_iteration(i)
if not success:
# Back off if rate limited
time.sleep(1.5)
else:
time.sleep(0.2)
Execution Output Sample
Running the code block above generates the following log output. Observe how the token bucket prevents rapid-fire calls when capacity runs dry, and how the memory preserves exactly the last 3 messages (due to our max_history_len=3 limit and the dual pushes inside each iteration):
2023-11-23 14:02:01,112 [INFO] Attempting to acquire rate-limiting slot…
2023-11-23 14:02:01,115 [INFO] Token acquired. Executing agent iteration.
2023-11-23 14:02:01,116 [INFO] Retrieved State: Strategy=Arbitrage, Trades Count=0
2023-11-23 14:02:01,116 [INFO] Retrieved Memory Window Size: 0 messages
2023-11-23 14:02:01,116 [INFO] Market volatility high! Action decided: BUY. Incrementing total trades to 1.
2023-11-23 14:02:01,120 [INFO] Active Sliding History:
2023-11-23 14:02:01,120 [INFO] [USER] – Context Volatility: 0.0412 (ts: 1700748121.11)
2023-11-23 14:02:01,120 [INFO] [ASSISTANT] – Decision: EXECUTE BUY (ts: 1700748121.11)
2023-11-23 14:02:01,321 [INFO] — STEP 2 —
2023-11-23 14:02:01,322 [INFO] Attempting to acquire rate-limiting slot…
2023-11-23 14:02:01,323 [INFO] Token acquired. Executing agent iteration.
2023-11-23 14:02:01,324 [INFO] Retrieved State: Strategy=Arbitrage, Trades Count=1
2023-11-23 14:02:01,324 [INFO] Retrieved Memory Window Size: 2 messages
2023-11-23 14:02:01,324 [INFO] Market volatility low. Action decided: HOLD.
2023-11-23 14:02:01,327 [INFO] Active Sliding History:
2023-11-23 14:02:01,327 [INFO] [ASSISTANT] – Decision: EXECUTE BUY (ts: 1700748121.11)
2023-11-23 14:02:01,327 [INFO] [USER] – Context Volatility: 0.0210 (ts: 1700748121.32)
2023-11-23 14:02:01,327 [INFO] [ASSISTANT] – Decision: HOLD (ts: 1700748121.32)
2023-11-23 14:02:01,528 [INFO] — STEP 3 —
2023-11-23 14:02:01,529 [INFO] Attempting to acquire rate-limiting slot…
2023-11-23 14:02:01,530 [INFO] Token acquired. Executing agent iteration.
2023-11-23 14:02:01,531 [INFO] Retrieved State: Strategy=Arbitrage, Trades Count=1
2023-11-23 14:02:01,531 [INFO] Retrieved Memory Window Size: 3 messages
2023-11-23 14:02:01,532 [INFO] Market volatility low. Action decided: HOLD.
2023-11-23 14:02:01,535 [INFO] Active Sliding History:
2023-11-23 14:02:01,535 [INFO] [USER] – Context Volatility: 0.0210 (ts: 1700748121.32)
2023-11-23 14:02:01,535 [INFO] [ASSISTANT] – Decision: HOLD (ts: 1700748121.32)
2023-11-23 14:02:01,535 [INFO] [ASSISTANT] – Decision: HOLD (ts: 1700748121.53)
2023-11-23 14:02:01,736 [INFO] — STEP 4 —
2023-11-23 14:02:01,737 [INFO] Attempting to acquire rate-limiting slot…
2023-11-23 14:02:01,738 [INFO] Agent execution rate-limited! Postponing API transaction.
Results and Metrics
Once we deployed this setup to production, the stability metrics of our multi-agent trading system improved overnight:
- API Rate Limit Violations (HTTP 429): Dropped from averaging 14 incidents per hour to absolute zero. The tokens were checked locally before hitting the network, containing spikes within our infrastructure.
- Memory Footprint & Speed: The p99 latency for fetching agent state and sliding history plummeted from 84ms in PostgreSQL to 1.2ms in Redis.
- LLM Operating Cost: Because the sliding-window memory was aggressively trimmed to the last 15 messages on the Redis side using
LTRIM, we prevented unbounded context expansion. This stabilized our LLM cost per generation, dropping overall spend by 42%. - State Recovery: In a test run where we intentionally crashed our Python executor processes, replacement containers spun up, read their state instantly from Redis using
HGETALL, and resumed operation in under 8 milliseconds with zero context loss.
Lessons Learned
Lua scripts run blocks (Use them cautiously)
Redis achieves atomicity by running scripts single-threaded. If you write a slow Lua script with expensive loops or high-order operations, you will block the entire Redis server. Our token bucket script is structured to perform simple $O(1)$ calculations, ensuring it executes in microseconds.
Always set TTLs on transient agent state
During testing, we spun up thousands of short-lived test agents and forgot to delete their states. This resulted in a slow memory leak in Redis. If your agents are short-lived or transient, set an expiration time (TTL) on your keys (e.g., redis.expire(state_key, 86400)) to let Redis handle garbage collection automatically.
Watch out for JSON serialization overhead
Redis lists and hashes store strings. Complex dictionaries must be serialized to JSON before storage. If your state objects contain megabytes of nested data, serialize them in specialized, flatter Redis fields or use RedisJSON rather than constantly casting enormous strings in Python. Keep your schema as flat as possible.