← Back to DevBytes

Queue-Based Agent Architectures: Handling Async Work

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

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles