Parallel & Concurrent Execution

9 min read Module 5 of 10 Topic 15 of 30

What you'll learn

  • Execute independent tool calls concurrently with asyncio.gather
  • Fan out LangGraph graph execution to parallel branches using Send()
  • Implement bounded concurrency to stay within API rate limits
  • Measure and optimize the latency of parallel agent workflows
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Why Parallel Execution Matters

Most agent tasks involve significant I/O: web searches, API calls, database queries, LLM calls. These are all async-friendly and can run in parallel. The serial vs parallel difference is stark:

flowchart LR
    subgraph Serial["Serial, 1800ms total"]
        direction TB
        SA["search A · 800ms"] --> SB["search B · 600ms"] --> SC["fetch C · 400ms"]
    end
    subgraph Parallel["Parallel, ~800ms total"]
        direction TB
        PA["search A · 800ms"] & PB["search B · 600ms"] & PC["fetch C · 400ms"] --> DONE([Done])
    end
    style Serial fill:#fff0f0,stroke:#f87171
    style Parallel fill:#f0fdf9,stroke:#0D9488

For an agent making 10 independent tool calls, parallelism is a 5-10x latency improvement.


Concurrent Tool Calls with asyncio

Basic Pattern

asyncio.gather is the standard way to run independent coroutines simultaneously. Each tool call runs in its own coroutine, and all start at the same time, total latency is the slowest single call, not the sum.

import asyncio
from typing import Any

async def parallel_tool_calls(tool_calls: list[dict]) -> list[dict]:
    """Execute all tool calls concurrently and return results in order."""
    
    async def execute_one(tc: dict) -> dict:
        func_name = tc["function"]["name"]
        func_args = json.loads(tc["function"]["arguments"])
        
        try:
            result = await TOOL_FUNCTIONS[func_name](**func_args)
            return {
                "tool_call_id": tc["id"],
                "role": "tool",
                "content": json.dumps(result),
            }
        except Exception as e:
            # return errors as tool results rather than raising: lets the LLM handle failures gracefully
            return {
                "tool_call_id": tc["id"],
                "role": "tool",
                "content": json.dumps({"error": str(e)}),
            }
    
    # asyncio.gather() runs all tool calls concurrently: if each takes 1s, total is ~1s not N×1s
    results = await asyncio.gather(*[execute_one(tc) for tc in tool_calls])
    return list(results)  # gather preserves input order even though tasks complete in arbitrary order

Bounded Concurrency with Semaphore

Unbounded parallelism can trigger rate limits. This class wraps a semaphore to cap how many coroutines run at once, letting you maximize throughput without overwhelming the API.

import asyncio

class BoundedParallelExecutor:
    """Execute tasks in parallel with a concurrency limit."""
    
    def __init__(self, max_concurrent: int = 10):
        # Semaphore(10) caps concurrency: prevents overwhelming the API with too many simultaneous requests
        self.semaphore = asyncio.Semaphore(max_concurrent)
    
    async def execute_with_limit(self, coro):
        async with self.semaphore:  # blocks here if max_concurrent tasks are already running
            return await coro
    
    async def gather(self, tasks: list) -> list:
        bounded_tasks = [self.execute_with_limit(task) for task in tasks]
        # return_exceptions=True means a single failure doesn't cancel all other tasks
        return await asyncio.gather(*bounded_tasks, return_exceptions=True)

# Usage: limit to 5 concurrent API calls
executor = BoundedParallelExecutor(max_concurrent=5)

async def search_multiple_queries(queries: list[str]) -> list[dict]:
    tasks = [search_web(q) for q in queries]
    results = await executor.gather(tasks)
    
    # Handle exceptions from individual tasks without dropping successful results
    return [
        r if not isinstance(r, Exception) else {"error": str(r)}
        for r in results
    ]

Parallel Branches in LangGraph

Send() is LangGraph’s mechanism for fan-out parallelism. It creates one copy of a node per item and runs all copies concurrently, the results are then merged by a reducer function before the next node runs.

# Parallel fact-checking: verify multiple claims simultaneously
class FactCheckState(TypedDict):
    claims: list[str]               # input: list of claims to verify
    # operator.add as state reducer means each worker's output appends to the shared list
    verified: Annotated[list[dict], operator.add]
    final_verdict: str | None

def fan_out_claims(state: FactCheckState) -> list[Send]:
    """Start one verification branch per claim."""
    # Send() creates one copy of the worker node per claim: LangGraph fans out in parallel automatically
    return [
        Send("verify_one_claim", {"claim": claim, "result": None})
        for claim in state["claims"]
    ]

class SingleClaimState(TypedDict):
    claim: str
    result: dict | None

async def verify_one_claim(state: SingleClaimState) -> dict:
    """Verify a single claim by searching for evidence."""
    # This runs in parallel for each claim: all claims are searched simultaneously
    search_results = await search_web(f"fact check: {state['claim']}")
    
    verification_prompt = f"""
Claim: {state['claim']}
Evidence: {json.dumps(search_results[:3], indent=2)}

Verdict (true/false/uncertain) and brief explanation.
Return JSON: {{"verdict": "true|false|uncertain", "explanation": "...", "sources": []}}"""
    
    result = llm.invoke([HumanMessage(content=verification_prompt)])
    verdict = json.loads(result.content)
    
    # return a single-item list so operator.add can accumulate results across all parallel branches
    return {"result": {"claim": state["claim"], **verdict}}

def aggregate_verdicts(state: FactCheckState) -> dict:
    """Combine all verification results."""
    # state["verified"] contains results from all parallel branches, merged by operator.add
    true_count = sum(1 for r in state["verified"] if r.get("verdict") == "true")
    false_count = sum(1 for r in state["verified"] if r.get("verdict") == "false")
    uncertain_count = len(state["verified"]) - true_count - false_count
    
    summary = f"{true_count} claims verified, {false_count} false, {uncertain_count} uncertain"
    return {"final_verdict": summary}

# Build the parallel fact-checking graph
fc_builder = StateGraph(FactCheckState)
fc_builder.add_node("fan_out", lambda s: s)      # passthrough node, triggers the fan-out edge
fc_builder.add_node("verify_one_claim", verify_one_claim)
fc_builder.add_node("aggregate", aggregate_verdicts)

fc_builder.set_entry_point("fan_out")
fc_builder.add_conditional_edges("fan_out", fan_out_claims)  # fan_out_claims returns list[Send]
fc_builder.add_edge("verify_one_claim", "aggregate")         # all branches converge here
fc_builder.add_edge("aggregate", END)

fact_checker = fc_builder.compile()

Batched Parallel Processing

For large-scale processing (hundreds of items), batch them to avoid overwhelming APIs:

async def process_documents_in_batches(
    documents: list[str],
    batch_size: int = 20,          # process 20 documents at once within each batch
    max_concurrent_batches: int = 3,  # run up to 3 batches simultaneously
) -> list[dict]:
    """Process documents in parallel batches."""
    
    async def process_batch(batch: list[str]) -> list[dict]:
        # all documents in the batch run concurrently
        tasks = [process_single_document(doc) for doc in batch]
        return await asyncio.gather(*tasks, return_exceptions=True)
    
    # Split into batches of batch_size
    batches = [documents[i:i+batch_size] for i in range(0, len(documents), batch_size)]
    
    all_results = []
    # Semaphore limits how many batches run simultaneously: outer rate control
    batch_semaphore = asyncio.Semaphore(max_concurrent_batches)
    
    async def run_batch_with_limit(batch):
        async with batch_semaphore:
            return await process_batch(batch)
    
    # Run all batches with concurrency capped at max_concurrent_batches
    batch_results = await asyncio.gather(*[run_batch_with_limit(b) for b in batches])
    
    for batch_result in batch_results:
        for item in batch_result:
            if isinstance(item, Exception):
                all_results.append({"error": str(item)})  # preserve errors without stopping the rest
            else:
                all_results.append(item)
    
    return all_results

Async LLM Calls in Parallel

When you need multiple model opinions on the same prompt, run all the LLM calls simultaneously rather than waiting for each to finish before starting the next.

async def evaluate_with_multiple_models(prompt: str) -> dict:
    """Get opinions from multiple LLMs simultaneously."""
    
    async def call_openai():
        client = AsyncOpenAI()
        response = await client.chat.completions.create(
            model="gpt-5.6-sol",
            messages=[{"role": "user", "content": prompt}],
            max_tokens=500,
        )
        return {"model": "gpt-5.6-sol", "response": response.choices[0].message.content}
    
    async def call_anthropic():
        client = AsyncAnthropic()
        response = await client.messages.create(
            model="claude-sonnet-5",
            max_tokens=500,
            messages=[{"role": "user", "content": prompt}],
        )
        return {"model": "claude-sonnet-5", "response": response.content[0].text}
    
    # asyncio.gather() runs all three model calls concurrently: if each takes 1s, total is ~1s not 3s
    results = await asyncio.gather(
        call_openai(),
        call_anthropic(),
        return_exceptions=True,  # one model failing doesn't cancel the others
    )
    
    successful = [r for r in results if not isinstance(r, Exception)]
    return {"responses": successful, "failures": len(results) - len(successful)}

Measuring Parallel vs Sequential Performance

Benchmarking before and after parallelization confirms the speedup is real, always measure with realistic workloads before committing to architectural changes.

import time

async def benchmark_execution():
    queries = ["AI agent frameworks", "vector databases", "LLM pricing 2025",
               "Python async patterns", "Docker best practices"]
    
    # Sequential: each search waits for the previous one to finish
    start = time.perf_counter()
    sequential_results = []
    for q in queries:
        result = await search_web(q)
        sequential_results.append(result)
    sequential_time = time.perf_counter() - start
    
    # Parallel: all searches start simultaneously with asyncio.gather
    start = time.perf_counter()
    parallel_results = await asyncio.gather(*[search_web(q) for q in queries])
    parallel_time = time.perf_counter() - start
    
    speedup = sequential_time / parallel_time
    print(f"Sequential: {sequential_time:.2f}s")
    print(f"Parallel:   {parallel_time:.2f}s")
    print(f"Speedup:    {speedup:.1f}x")  # expect ~N× for N independent I/O-bound calls

The key insight: for any agent task where tool calls are independent of each other (not using each other’s results), parallelism is almost always worth the slightly more complex code.

Knowledge Check

3 questions to test your understanding

1 An agent needs to fetch weather data for 10 cities and each API call takes 200ms. How much time does sequential execution take vs parallel execution?

2 What is a semaphore and why do you need it for parallel agent tool calls?

3 In LangGraph, what determines whether two nodes run in parallel vs sequentially?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan