Understanding Queue-Based Agent Architectures
Queue-based agent architectures represent a design pattern where autonomous software agents communicate and coordinate work through shared message queues rather than direct synchronous calls. Each agent acts as an independent worker that consumes tasks from input queues, processes them asynchronously, and optionally publishes results to output queues for downstream agents to consume.
At its core, this architecture decouples task submission from task execution. A producer agent or service places work items onto a queue, and consumer agents pick them up when they're ready to process. This decoupling is the fundamental insight that makes the entire pattern so powerful for handling asynchronous workloads.
Core Components
- Message Broker — The intermediary that stores and forwards messages. Redis, RabbitMQ, Apache Kafka, or cloud services like AWS SQS serve this role.
- Producer Agents — Components that create work items and push them onto queues.
- Consumer Agents — Worker components that pull tasks from queues, execute the work, and acknowledge completion.
- Dead Letter Queues (DLQ) — Special queues that receive messages that failed processing after multiple attempts.
- Result / Output Queues — Queues where agents publish processed results for other agents to consume.
A Simple Mental Model
Think of a restaurant kitchen during peak hours. Waitstaff (producers) place orders on a rail (the queue). Cooks (consumer agents) grab tickets from the rail when they have capacity. The rail buffers orders so cooks aren't overwhelmed, and waitstaff don't need to find a specific free cook — they just place the ticket and move on. That's the queue-based agent pattern in a nutshell.
Why Queue-Based Architectures Matter
Modern applications increasingly deal with operations that are slow, resource-intensive, or unpredictable in duration — think image processing, LLM inference, email delivery, or data pipeline transformations. Handling these synchronously inside a web request cycle leads to timeouts, poor user experience, and brittle systems. Queue-based architectures solve this class of problems elegantly.
Key Benefits
- Load Smoothing — Queues absorb spikes in workload. Instead of provisioning for peak traffic, you provision for average throughput. The queue buffers the difference, giving your workers time to catch up.
- Fault Isolation — When a consumer agent crashes mid-task, the message remains in the queue (or is requeued automatically). Another agent picks it up. No data is lost, and the failure is contained to that single worker.
- Independent Scaling — You can scale producer and consumer agents independently. If your LLM-based agent is slow, add more consumers without touching the producer's code or infrastructure.
- Priority and Routing — Most brokers support priority queues and routing keys, letting you classify work (e.g., "high-priority inference" vs. "batch cleanup") and route it to dedicated agent pools.
- Observability — Queue depth, processing rates, and age of oldest message become natural metrics. Monitoring these gives you early warning of system health issues before users notice.
- Multi-Agent Pipelines — Complex workflows can be modeled as chains of queues: Agent A processes raw input, publishes to Queue B; Agent B enriches the data, publishes to Queue C; Agent C generates final output. Each step is independently testable and debuggable.
Building a Queue-Based Agent System
Let's walk through a concrete implementation using Python and Redis as the message broker. We'll build a multi-agent pipeline that processes user queries: an Ingestion Agent receives raw requests, a Reasoning Agent performs analysis, and a Response Agent formats final answers.
Project Setup
We'll use redis-py for queue operations and asyncio for concurrent processing. Install the required dependency:
pip install redis
Ensure Redis is running locally on port 6379, or update the connection parameters accordingly.
The Message Queue Abstraction
First, we build a thin abstraction over Redis lists to create reliable queue behavior — push to the tail, blocking pop from the head, with explicit acknowledgment support.
import redis
import json
import time
import uuid
from typing import Optional, Dict, Any
class MessageQueue:
"""A reliable queue backed by Redis with support for retries and dead letters."""
def __init__(self, name: str, broker: redis.Redis, max_retries: int = 3):
self.name = name
self.broker = broker
self.max_retries = max_retries
self.processing_prefix = f"{name}:processing:"
self.retry_counter_key = f"{name}:retries:"
self.dlq_name = f"{name}:dead"
def enqueue(self, message: Dict[str, Any]) -> str:
"""Push a message onto the queue. Returns the message ID."""
msg_id = str(uuid.uuid4())
envelope = {
"id": msg_id,
"timestamp": time.time(),
"payload": message
}
self.broker.lpush(self.name, json.dumps(envelope))
return msg_id
def dequeue(self, timeout: int = 5) -> Optional[Dict[str, Any]]:
"""Blocking pop with atomic move to a processing list (reliability pattern)."""
# BRPOPLPUSH atomically pops from source and pushes to processing list
raw = self.broker.brpoplpush(self.name, self.processing_prefix, timeout=timeout)
if raw is None:
return None
return json.loads(raw)
def acknowledge(self, msg_id: str) -> None:
"""Remove message from processing list after successful handling."""
# We need to find and remove the specific message from the processing list
# In production, use Redis Lua scripts for atomicity; simplified here
cursor = 0
while True:
cursor, items = self.broker.lrange(self.processing_prefix, cursor, cursor + 99, _withscores=False) or (0, [])
for item in items:
envelope = json.loads(item)
if envelope.get("id") == msg_id:
self.broker.lrem(self.processing_prefix, 1, item)
self.broker.delete(f"{self.retry_counter_key}{msg_id}")
return
if cursor == 0:
break
def reject(self, msg_id: str) -> None:
"""Requeue or dead-letter a failed message based on retry count."""
retry_key = f"{self.retry_counter_key}{msg_id}"
current_retries = self.broker.incr(retry_key)
if current_retries > self.max_retries:
# Move to dead letter queue
cursor = 0
while True:
cursor, items = self.broker.lrange(self.processing_prefix, cursor, cursor + 99)
for item in items:
envelope = json.loads(item)
if envelope.get("id") == msg_id:
self.broker.lrem(self.processing_prefix, 1, item)
self.broker.lpush(self.dlq_name, item)
self.broker.delete(retry_key)
return
if cursor == 0:
break
else:
# Requeue for retry — remove from processing, push back to main queue
cursor = 0
while True:
cursor, items = self.broker.lrange(self.processing_prefix, cursor, cursor + 99)
for item in items:
envelope = json.loads(item)
if envelope.get("id") == msg_id:
self.broker.lrem(self.processing_prefix, 1, item)
self.broker.lpush(self.name, item)
return
if cursor == 0:
break
The Agent Base Class
Every agent follows a common lifecycle: connect to input and output queues, enter a processing loop, handle each message, and acknowledge or reject based on outcome. We capture this in a base class.
import asyncio
import signal
from abc import ABC, abstractmethod
class BaseAgent(ABC):
"""Abstract base for all queue-consuming agents."""
def __init__(self, agent_id: str, broker: redis.Redis):
self.agent_id = agent_id
self.broker = broker
self.running = False
self.input_queue: Optional[MessageQueue] = None
self.output_queue: Optional[MessageQueue] = None
def set_queues(self, input_queue: MessageQueue, output_queue: Optional[MessageQueue] = None):
self.input_queue = input_queue
self.output_queue = output_queue
@abstractmethod
async def process(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Override this with agent-specific logic. Returns result dict."""
pass
async def run(self):
"""Main processing loop — dequeues, processes, acknowledges."""
self.running = True
print(f"[{self.agent_id}] Starting processing loop on queue '{self.input_queue.name}'")
while self.running:
envelope = self.input_queue.dequeue(timeout=2)
if envelope is None:
await asyncio.sleep(0.1)
continue
msg_id = envelope["id"]
payload = envelope["payload"]
try:
result = await self.process(payload)
self.input_queue.acknowledge(msg_id)
if self.output_queue:
output_envelope = {
"agent_id": self.agent_id,
"correlation_id": msg_id,
"result": result
}
self.output_queue.enqueue(output_envelope)
except Exception as e:
print(f"[{self.agent_id}] Error processing {msg_id}: {e}")
self.input_queue.reject(msg_id)
def stop(self):
self.running = False
Implementing Concrete Agents
Now we define three agents that form a processing pipeline. Each overrides the process method with domain-specific logic.
class IngestionAgent(BaseAgent):
"""Validates and normalizes incoming user queries, then forwards them downstream."""
async def process(self, payload: Dict[str, Any]) -> Dict[str, Any]:
user_query = payload.get("query", "")
# Simulate validation and normalization
if not user_query or len(user_query.strip()) < 3:
raise ValueError("Query too short or empty")
normalized = user_query.strip().lower()
# Add metadata
return {
"original_query": payload.get("query"),
"normalized_query": normalized,
"received_at": time.time(),
"source": payload.get("source", "unknown")
}
class ReasoningAgent(BaseAgent):
"""Performs analysis on the query — could call an LLM, run classifiers, etc."""
async def process(self, payload: Dict[str, Any]) -> Dict[str, Any]:
query = payload.get("normalized_query", "")
# Simulate reasoning work (in reality, call an LLM API here)
await asyncio.sleep(0.5) # Simulate processing latency
# Example: determine intent and extract entities
intent = "question" if "?" in query else "statement"
entities = [word for word in query.split() if len(word) > 5]
return {
**payload,
"intent": intent,
"entities": entities,
"confidence": 0.92,
"processed_by": self.agent_id
}
class ResponseAgent(BaseAgent):
"""Formats final output and (in production) would deliver to end user."""
async def process(self, payload: Dict[str, Any]) -> Dict[str, Any]:
intent = payload.get("intent", "unknown")
entities = payload.get("entities", [])
query = payload.get("normalized_query", "")
# Simulate response generation
response_text = f"Processed query '{query}' with intent '{intent}' "
response_text += f"and entities: {', '.join(entities) if entities else 'none'}"
return {
**payload,
"final_response": response_text,
"responded_at": time.time(),
"processed_by": self.agent_id
}
Wiring the Pipeline Together
The orchestrator sets up queues, instantiates agents, and starts the processing loop. Each agent runs in its own asyncio task for concurrent execution.
async def main():
broker = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Define queues
raw_queue = MessageQueue("pipeline:raw", broker)
analyzed_queue = MessageQueue("pipeline:analyzed", broker)
response_queue = MessageQueue("pipeline:responses", broker)
# Instantiate agents
ingestion = IngestionAgent("ingestion-01", broker)
ingestion.set_queues(input_queue=raw_queue, output_queue=analyzed_queue)
reasoning = ReasoningAgent("reasoning-01", broker)
reasoning.set_queues(input_queue=analyzed_queue, output_queue=response_queue)
responder = ResponseAgent("response-01", broker)
responder.set_queues(input_queue=response_queue) # terminal agent, no output queue
# Launch agent coroutines
tasks = [
asyncio.create_task(ingestion.run()),
asyncio.create_task(reasoning.run()),
asyncio.create_task(responder.run()),
]
# Seed the pipeline with some test messages
test_queries = [
{"query": "What is the capital of France?", "source": "web"},
{"query": "How do I reset my password?", "source": "mobile"},
{"query": "Tell me about machine learning trends", "source": "api"},
]
for q in test_queries:
raw_queue.enqueue(q)
print("Pipeline running. Press Ctrl+C to stop.")
# Handle graceful shutdown
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda: [t.cancel() for t in tasks])
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
print("Shutting down agents...")
ingestion.stop()
reasoning.stop()
responder.stop()
if __name__ == "__main__":
asyncio.run(main())
Advanced Patterns for Production
The pipeline above works, but production systems demand additional robustness. Let's explore patterns that harden queue-based agent architectures for real-world use.
Pattern 1: Priority Queues with Weighted Fair Queuing
Not all messages are equal. A customer-facing inference request deserves faster processing than a batch analytics job. Many brokers support multiple priority levels. With Redis, you can implement this using multiple list keys and a polling strategy.
class PriorityMessageQueue:
"""Multi-priority queue that checks higher-priority lists first."""
def __init__(self, base_name: str, broker: redis.Redis, levels: int = 3):
self.base_name = base_name
self.broker = broker
self.levels = levels
self.queue_names = [f"{base_name}:p{i}" for i in range(levels)]
# Level 0 is highest priority, level (levels-1) is lowest
def enqueue(self, message: Dict[str, Any], priority: int = 2) -> str:
"""Enqueue with priority 0 (highest) to 2 (lowest default)."""
msg_id = str(uuid.uuid4())
envelope = {"id": msg_id, "timestamp": time.time(), "payload": message}
target = self.queue_names[min(priority, self.levels - 1)]
self.broker.lpush(target, json.dumps(envelope))
return msg_id
def dequeue(self, timeout: int = 2) -> Optional[Dict[str, Any]]:
"""Poll queues from highest to lowest priority with weighted fairness."""
end_time = time.time() + timeout
while time.time() < end_time:
for i, qname in enumerate(self.queue_names):
# Higher priority queues get checked more aggressively
# Level 0: checked every iteration, Level 2: every 3rd iteration
raw = self.broker.lpop(qname)
if raw:
return json.loads(raw)
time.sleep(0.05)
return None
This weighted polling ensures high-priority messages experience lower latency while still guaranteeing that low-priority messages eventually get processed.
Pattern 2: Delayed Retry with Exponential Backoff
When an agent fails due to a transient error (e.g., an API rate limit), immediately requeuing may cause a tight retry loop. Instead, schedule retries with increasing delays.
class BackoffMessageQueue(MessageQueue):
"""Extends MessageQueue with exponential backoff on retries."""
def __init__(self, name: str, broker: redis.Redis, max_retries: int = 5, base_delay: float = 1.0):
super().__init__(name, broker, max_retries)
self.base_delay = base_delay
self.delayed_key = f"{name}:delayed"
def reject_with_backoff(self, msg_id: str) -> None:
retry_key = f"{self.retry_counter_key}{msg_id}"
current_retries = self.broker.get(retry_key)
if current_retries is None:
current_retries = self.broker.incr(retry_key)
else:
current_retries = int(current_retries) + 1
self.broker.set(retry_key, current_retries)
if current_retries > self.max_retries:
self._move_to_dlq(msg_id)
return
# Calculate delay: base_delay * 2^(retry_count)
delay = self.base_delay * (2 ** (current_retries - 1))
delayed_until = time.time() + delay
# Store in a sorted set keyed by scheduled time
self.broker.zadd(self.delayed_key, {msg_id: delayed_until})
def requeue_due_messages(self):
"""Call periodically to move delayed messages whose time has come back to the main queue."""
now = time.time()
due_ids = self.broker.zrangebyscore(self.delayed_key, 0, now)
for msg_id in due_ids:
# Retrieve the original envelope from processing list
envelope_raw = self._find_in_processing(msg_id)
if envelope_raw:
self.broker.lrem(self.processing_prefix, 1, envelope_raw)
self.broker.lpush(self.name, envelope_raw)
self.broker.zrem(self.delayed_key, msg_id)
Pattern 3: Agent Heartbeats and Health Monitoring
In production, agents can hang or become zombies without crashing outright. Heartbeat mechanisms let the system detect stalled workers.
class HeartbeatAgent(BaseAgent):
"""Mixin-like approach: agents periodically report health to Redis."""
def __init__(self, agent_id: str, broker: redis.Redis):
super().__init__(agent_id, broker)
self.heartbeat_key = f"heartbeats:{agent_id}"
self._heartbeat_task: Optional[asyncio.Task] = None
async def _send_heartbeats(self):
"""Send a heartbeat every 10 seconds while the agent is running."""
while self.running:
self.broker.hset(self.heartbeat_key, mapping={
"last_seen": time.time(),
"status": "alive",
"messages_processed": getattr(self, 'processed_count', 0)
})
self.broker.expire(self.heartbeat_key, 30) # TTL so dead agents clean up
await asyncio.sleep(10)
async def run(self):
self._heartbeat_task = asyncio.create_task(self._send_heartbeats())
try:
await super().run()
finally:
if self._heartbeat_task:
self._heartbeat_task.cancel()
self.broker.delete(self.heartbeat_key)
A separate monitoring service can scan heartbeat keys and alert on agents that haven't reported in over 30 seconds, triggering automated restarts or scaling actions.
Pattern 4: Graceful Draining on Deployments
When deploying new agent code, you want in-flight messages to finish before killing old instances. Implement a drain pattern:
class DrainableAgent(BaseAgent):
"""Agent that supports graceful drain — stop consuming, finish in-flight work."""
def __init__(self, agent_id: str, broker: redis.Redis):
super().__init__(agent_id, broker)
self.drain_signal = asyncio.Event()
self.in_flight: Dict[str, asyncio.Task] = {}
async def run(self):
self.running = True
while self.running:
# Check drain signal — if set, don't dequeue new messages
if self.drain_signal.is_set():
if not self.in_flight:
self.running = False
break
await asyncio.sleep(0.2)
continue
envelope = self.input_queue.dequeue(timeout=1)
if envelope is None:
continue
msg_id = envelope["id"]
task = asyncio.create_task(self._handle_with_tracking(msg_id, envelope["payload"]))
self.in_flight[msg_id] = task
# Clean up completed tasks
task.add_done_callback(lambda t, mid=msg_id: self.in_flight.pop(mid, None))
async def _handle_with_tracking(self, msg_id: str, payload: Dict[str, Any]):
try:
result = await self.process(payload)
self.input_queue.acknowledge(msg_id)
if self.output_queue:
self.output_queue.enqueue({"agent_id": self.agent_id, "correlation_id": msg_id, "result": result})
except Exception:
self.input_queue.reject(msg_id)
def initiate_drain(self):
"""Signal the agent to stop accepting new work."""
self.drain_signal.set()
print(f"[{self.agent_id}] Drain initiated — will finish {len(self.in_flight)} in-flight tasks")
During deployment, your orchestrator calls initiate_drain() on old instances, waits for them to exit, then starts new instances — all without losing a single message.
Handling Async Work with External Services
Many agent architectures involve calling external APIs — LLM inference endpoints, image processing services, or third-party data providers. These calls are inherently asynchronous and often have rate limits, long latencies, or unpredictable failures. Queue-based agents are perfectly suited for wrapping these external dependencies.
LLM Inference Agent Example
import aiohttp
class LLMInferenceAgent(BaseAgent):
"""Agent that calls an external LLM API with retry and rate-limit awareness."""
def __init__(self, agent_id: str, broker: redis.Redis, api_key: str, model: str = "gpt-4"):
super().__init__(agent_id, broker)
self.api_key = api_key
self.model = model
self.session: Optional[aiohttp.ClientSession] = None
self.rate_limit_semaphore = asyncio.Semaphore(5) # Max 5 concurrent calls
async def process(self, payload: Dict[str, Any]) -> Dict[str, Any]:
prompt = payload.get("prompt", "")
max_tokens = payload.get("max_tokens", 256)
async with self.rate_limit_semaphore:
result = await self._call_llm_with_retry(prompt, max_tokens)
return {
"prompt": prompt,
"completion": result.get("choices", [{}])[0].get("text", ""),
"model": self.model,
"usage": result.get("usage", {})
}
async def _call_llm_with_retry(self, prompt: str, max_tokens: int, retries: int = 3) -> Dict:
if self.session is None:
self.session = aiohttp.ClientSession(headers={"Authorization": f"Bearer {self.api_key}"})
for attempt in range(retries):
try:
async with self.session.post(
"https://api.openai.com/v1/completions",
json={
"model": self.model,
"prompt": prompt,
"max_tokens": max_tokens
},
timeout=aiohttp.ClientTimeout(total=60)
) as response:
if response.status == 429:
retry_after = int(response.headers.get("Retry-After", 5))
await asyncio.sleep(retry_after * (attempt + 1))
continue
response.raise_for_status()
return await response.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == retries - 1:
raise
await asyncio.sleep(2 ** attempt)
raise RuntimeError("Max retries exhausted for LLM call")
The semaphore prevents overwhelming the API with concurrent requests, while the retry logic handles transient failures and rate limits gracefully. The queue buffers requests during traffic spikes, so the agent processes at a steady, manageable rate.
Best Practices for Queue-Based Agent Architectures
1. Design for Idempotency
Messages may be delivered more than once (at-least-once semantics). Every agent's process method should be idempotent — processing the same message twice should produce the same side effects. Use unique message IDs to deduplicate, or structure your logic so repeated execution is safe.
2. Keep Message Payloads Small
Messages should carry references (IDs, S3 URLs, database keys) rather than large binary blobs. A good rule: keep payloads under 64KB. Store heavy data in object storage and pass pointers through the queue. This keeps queue operations fast and memory usage predictable.
3. Use Correlation IDs for End-to-End Tracing
When a request enters the system, generate a correlation ID that propagates through every queue hop. Include it in every message envelope. This lets you trace a single user request across multiple agents and queues, invaluable for debugging and latency analysis.
4. Implement Dead Letter Queue Monitoring
A growing DLQ is a leading indicator of system problems. Set up alerts on DLQ depth, and build a dashboard or periodic job that inspects dead-lettered messages. Some teams build a "replay" tool that lets them resubmit DLQ messages after fixing the underlying bug.
5. Tune Prefetch and Concurrency
Most brokers allow configuring how many messages a consumer fetches at once (prefetch count). Setting this too high can cause uneven load distribution; too low increases round-trip overhead. A prefetch of 1 ensures strict ordering but limits throughput. Experiment to find the right balance for your workload.
6. Handle Backpressure Explicitly
If downstream agents are slower than upstream agents, queues grow unbounded. Implement circuit breakers or backpressure signals: when queue depth exceeds a threshold, upstream agents slow down or pause publishing. Redis makes this easy with LLEN checks before enqueue operations.
def enqueue_with_backpressure(self, message: Dict[str, Any], max_depth: int = 10000) -> Optional[str]:
current_depth = self.broker.llen(self.name)
if current_depth >= max_depth:
# Instead of dropping, you might also: block, return an error, or publish to overflow storage
return None # Caller must handle backpressure
return self.enqueue(message)
7. Version Your Message Schemas
Include a schema_version field in every message envelope. When you evolve the data structure, agents can inspect the version and handle both old and new formats during migration periods. This avoids requiring synchronized deployments across your entire agent fleet.
8. Test with Chaos Engineering
Queue-based systems handle failure well by design — but you should verify this. Kill agent processes mid-message, partition the Redis network, flood the queue with malformed messages. Observe how the system recovers. The DLQ should catch poison messages, surviving agents should pick up abandoned work, and monitoring should alert you.
Common Pitfalls to Avoid
- Swallowing exceptions silently — Always either acknowledge (success) or reject (failure). Swallowing exceptions and acknowledging anyway loses messages permanently.
- Infinite retry loops — Without a max retry limit and DLQ, a poison message can loop forever, consuming resources and blocking other messages.
- Tight coupling via queue names — Agents hardcoding queue names makes pipelines rigid. Use configuration or a registry so you can reconfigure routing without code changes.
- Assuming ordered delivery — Most queue systems do not guarantee strict ordering (or do so only within partitions). Design agents to handle out-of-order messages.
- Ignoring queue TTLs — Messages sitting in a queue for days may represent stale requests. Set per-message or per-queue TTLs so outdated work is automatically discarded.
Conclusion
Queue-based agent architectures transform brittle, synchronous processing pipelines into resilient, scalable systems that handle asynchronous work with grace. By decoupling task submission from execution, you gain load smoothing, fault isolation, independent scaling, and natural observability points. The patterns we explored — from basic dequeuing loops to priority queuing, exponential backoff, heartbeats, and graceful draining — form a toolkit that adapts to workloads ranging from simple batch jobs to complex multi-agent LLM pipelines. The key is to treat the queue as the backbone of agent communication: reliable, observable, and forgiving. Start with a simple Redis-backed queue and a well-structured agent base class, then layer on production patterns as your system grows. The investment in queue infrastructure pays dividends in system reliability and developer confidence when handling the unpredictable nature of async work.