LangGraph Map-Reduce and Parallel Execution
Source: mortalapps.com- LangGraph's map-reduce pattern enables parallel execution of agentic tasks, fanning out work to multiple nodes and aggregating results.
- This pattern solves the problem of sequential bottlenecks in complex agent workflows, improving throughput and reducing overall latency for divisible tasks.
- In production, parallel execution is critical for scaling agent systems that process large batches of data or require concurrent sub-task completion.
- Utilize the `Send` API for dynamic fan-out to parallel nodes and design a dedicated aggregator node for result collection and consolidation.
- Robust error handling in the reduce step is essential to manage partial failures and ensure system resilience in distributed agent graphs.
Why This Matters
Complex AI agent systems often encounter performance bottlenecks when processing multiple independent sub-tasks sequentially. The LangGraph map-reduce and parallel execution pattern addresses this by allowing agent workflows to fan out operations across multiple parallel nodes, significantly improving efficiency. This approach was invented to leverage distributed computing principles within the state-machine paradigm of LangGraph, enabling agents to tackle larger, more intricate problems that are naturally decomposable. Ignoring parallelization leads to underutilized compute resources and extended processing times, making real-time or high-throughput agent applications impractical. In production, this translates to higher operational costs, degraded user experience due to latency, and inability to meet strict SLAs. For developers, understanding this pattern means building more scalable and responsive agent systems. Enterprises benefit from faster task completion, higher operational throughput, and the ability to process larger datasets or user requests concurrently. This pattern is preferred over purely sequential processing when tasks can be independently executed and their results later merged, offering a clear advantage in scenarios like document analysis, multi-source data retrieval, or parallel tool execution, where alternatives like simple sequential chains would be prohibitively slow.
Core Concepts
The LangGraph framework provides primitives for constructing stateful, multi-actor applications as graphs. Implementing map-reduce and parallel execution patterns within this framework requires understanding several core concepts:
- StateGraph: The fundamental building block in LangGraph, defining the state schema and the nodes/edges that operate on it. For parallel execution, the state must accommodate multiple concurrent results.
- Map-Reduce Pattern: A distributed computing paradigm where a 'map' function processes individual elements in parallel, and a 'reduce' function aggregates their results. In LangGraph, 'map' corresponds to parallel agent nodes, and 'reduce' to an aggregator node.
- Parallel Execution: The concurrent running of multiple agent nodes or sub-graphs. This is achieved by dynamically branching the graph's execution path, allowing independent tasks to progress simultaneously.
- Fan-out: The process of taking a single input and distributing it to multiple parallel processing units. In LangGraph, this is typically orchestrated by a dispatcher node that uses the
SendAPI. SendAPI: A LangGraph primitive that allows a node to emit multiple messages, each targeting a specific next node or a set of nodes. This is the primary mechanism for dynamic fan-out, enabling a single graph execution to branch into multiple concurrent paths.- Aggregator Node: A dedicated node responsible for collecting results from all parallel execution branches. It waits for all expected inputs, handles potential partial failures, and consolidates the data into a single, coherent output for subsequent graph steps.
- Partial Failure Handling: In parallel systems, individual branches can fail without halting the entire process. The aggregator node must be designed to detect these failures, log them, and potentially proceed with available successful results or trigger specific recovery actions.
How It Works
Implementing map-reduce and parallel execution in LangGraph involves a sequence of distinct phases, from task decomposition to result aggregation.
1. Initialization and Task Decomposition
The process begins when an initial input enters the LangGraph StateGraph. A dedicated 'dispatcher' or 'splitter' node receives this input. Its primary responsibility is to analyze the input and decompose it into a list of independent sub-tasks. For instance, if the input is a large document, the splitter might divide it into paragraphs or sections. This node then updates the graph's state to include this list of sub-tasks, often storing them in a list-like structure within the state.
2. Dynamic Fan-out with Send
After decomposition, the dispatcher node uses LangGraph's Send API to initiate parallel execution. For each sub-task generated, the dispatcher emits a message, directing it to a 'worker' node. The Send API allows a node to return a list of StateGraph updates, each potentially targeting a different next node or carrying specific data. This effectively creates multiple concurrent execution paths, each processing one sub-task. The graph's state is updated to reflect these new, independent execution branches, typically by appending individual task results to a list or dictionary in the state, keyed by task ID.
3. Parallel Worker Execution
Each 'worker' node receives a single sub-task and executes its specific logic. This might involve calling an LLM, using a tool, performing data processing, or even invoking another subgraph. Worker nodes operate independently, without direct knowledge of other parallel workers. Upon completion, each worker updates the shared graph state with its result. It's crucial that worker nodes are idempotent and handle their own local errors gracefully, potentially marking their specific task as 'failed' in the shared state rather than raising an unhandled exception that could halt the entire graph.
4. Result Aggregation and Reduction
Once all parallel worker nodes have completed (or timed out/failed), the execution flow converges to an 'aggregator' or 'reducer' node. This node is responsible for collecting all individual results from the shared graph state. It iterates through the results, consolidates them, and applies any necessary reduction logic (e.g., summing numerical results, concatenating text, or synthesizing insights from multiple analyses). The aggregator must also handle partial failures: if some worker nodes failed, it can decide whether to proceed with the successful results, attempt retries for failed tasks, or flag the overall operation as partially failed. This decision logic is critical for robust production systems. Finally, the aggregator updates the graph state with the consolidated result, and the graph can then proceed to subsequent sequential steps or terminate.
Architecture
The conceptual architecture for LangGraph map-reduce and parallel execution involves a central StateGraph orchestrating interactions between specialized nodes. The system initiates with an Input Source delivering a request to the Graph Executor. This executor, an instance of LangGraph's StateGraph, manages the overall state and transitions.
Within the StateGraph, the first key component is the Dispatcher Node. This node receives the initial input, performs task decomposition, and uses the Send API to fan out individual sub-tasks. Each sub-task is encapsulated as a message directed towards a Worker Node Pool. These Worker Nodes are independent computational units, potentially running concurrently, each designed to process a single sub-task. They interact with External Tools/LLMs (e.g., an OpenAI API, a custom database query tool) to perform their specific work.
Results from the Worker Nodes flow back into the shared Graph State, which acts as a central repository for all ongoing and completed task data. Once all expected worker results are available (or after a timeout period), the execution transitions to the Aggregator Node. This node retrieves all sub-task results from the Graph State, performs the reduction logic (e.g., merging, summarizing, error handling), and updates the Graph State with the final consolidated output. The final result is then passed from the Aggregator Node to the Output Sink, completing the request lifecycle. Arrows represent data flow and control transitions, with the Graph State mediating all inter-node communication.
Implementing Dynamic Fan-out with LangGraph's Send API
LangGraph's Send API is the cornerstone for dynamic fan-out in parallel execution patterns. Unlike static graph definitions where transitions are fixed, Send allows a node to dynamically return a list of StateGraph updates, each representing an independent path of execution. This is crucial for map-reduce, where the number of parallel tasks is determined at runtime based on the input.
Consider a scenario where an agent needs to process a list of URLs. A 'URL Splitter' node would receive the initial list. Instead of returning a single next state, it would return a list of StateGraph.update calls. Each update targets a 'URL Processor' node, passing a single URL. The Send mechanism ensures that LangGraph's executor creates and manages these parallel branches, allowing them to run concurrently. The state update for each parallel branch must be carefully designed to avoid race conditions or overwriting data. Typically, this involves appending to a list or updating a dictionary keyed by a unique task identifier within the shared graph state.
# Example of a dispatcher node using Send
def dispatcher_node(state):
urls = state["urls_to_process"]
updates = []
for i, url in enumerate(urls):
# Each update targets the 'process_url' node with a specific URL
# and stores its result in a unique key in the 'results' dictionary
updates.append(Send("process_url", {"url": url, "task_id": f"task_{i}"}))
return updates # List[Send] returned directly for LangGraph fan-out
This conceptual dispatcher_node would return a structure that LangGraph interprets as multiple concurrent calls. The actual Send API is typically used implicitly by returning a list of (node_name, kwargs) tuples or by directly manipulating the state in a way that triggers multiple transitions.
Designing Robust Worker Nodes for Parallelism
Worker nodes are the 'map' part of the map-reduce. Each worker should be designed to be stateless with respect to other workers and idempotent. If a worker fails and is retried, it should produce the same outcome. Key considerations for worker node design include:
- Isolation: Each worker operates on its assigned sub-task independently. Avoid shared mutable state between workers directly; all shared state should be managed through the
StateGraphand accessed via thestateobject passed to the node. - Error Handling: Workers must implement robust
try-exceptblocks. Instead of crashing, a worker should catch exceptions, log the error, and update its specific task's status in the shared graph state to 'failed' along with an error message. This allows the aggregator to make informed decisions. - Timeouts: Implement internal timeouts for external calls (LLMs, APIs) within worker nodes to prevent a single slow worker from holding up the entire parallel execution.
- Resource Management: Be mindful of resource consumption (CPU, memory, API rate limits). If many workers run concurrently, they can exhaust shared resources. Consider using a semaphore or rate limiter if external API calls are involved.
# Example of a worker node with error handling
def process_url_node(state):
url = state["url"]
task_id = state["task_id"]
try:
# Simulate an external API call or LLM interaction
import time, random
time.sleep(random.uniform(0.5, 2.0))
if random.random() < 0.1: # Simulate 10% failure rate
raise ValueError(f"Failed to process {url}")
result = f"Processed data from {url}"
return {"results": {task_id: {"status": "success", "data": result}}}
except Exception as e:
return {"results": {task_id: {"status": "failed", "error": str(e)}}}}
Building the Aggregator Node and Handling Partial Failures
The aggregator node is the 'reduce' step, responsible for collecting and consolidating results from all parallel workers. Its design is critical for the resilience of the overall system.
- Completion Detection: The aggregator needs a mechanism to know when all expected parallel tasks have completed. This can be implicit if the graph structure ensures all paths lead to the aggregator, or explicit if the state tracks the number of pending tasks.
- Result Collection: It iterates through the
resultsdictionary or list in the shared state, collecting all successful and failed outcomes. - Reduction Logic: This is where the core 'reduce' operation happens. It could be concatenation, summarization, statistical analysis, or a more complex synthesis of information.
- Partial Failure Strategy: This is paramount. The aggregator must decide how to proceed if some tasks failed. Options include:
- Strict Failure: If any task fails, the entire map-reduce operation fails.
- Graceful Degradation: Proceed with successful results, ignoring or logging failures. This is common when processing large datasets where a few failures are acceptable.
- Retry Mechanism: Identify failed tasks and re-queue them for processing (potentially with a different worker or strategy). This requires careful state management to avoid infinite loops.
- Human-in-the-Loop (HITL): If critical tasks fail, escalate to a human for review and intervention.
# Example of an aggregator node with partial failure handling
def aggregator_node(state):
all_results = state.get("results", {})
successful_data = []
failed_tasks = {}
for task_id, res in all_results.items():
if res["status"] == "success":
successful_data.append(res["data"])
else:
failed_tasks[task_id] = res["error"]
final_summary = ""
if successful_data:
final_summary = f"Successfully processed {len(successful_data)} items. Data: {'; '.join(successful_data)}"
if failed_tasks:
final_summary += f"
Warnings: {len(failed_tasks)} tasks failed: {failed_tasks}"
# Depending on criticality, could raise an error or return partial success
# For this example, we'll log and continue with partial success
print(f"Aggregator detected failures: {failed_tasks}")
return {"final_output": final_summary, "has_failures": bool(failed_tasks)}
This structured approach to dynamic fan-out, robust worker design, and intelligent aggregation allows LangGraph to implement highly scalable and resilient map-reduce patterns for complex agentic workflows.
Code Example
import os
import operator
import random
import time
import logging
from typing import List, Dict, TypedDict, Annotated
from langchain_core.messages import BaseMessage
from langgraph.graph import StateGraph, END
from langgraph.constants import Send
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Define the graph state
class AgentState(TypedDict):
input_items: List[str]
task_results: Annotated[Dict[str, Dict], lambda x, y: {**x, **y}]
final_output: str
has_failures: bool
# --- Nodes ---
def dispatcher_node(state: AgentState) -> Dict:
"""Splits input_items into individual tasks and prepares for parallel execution."""
items = state["input_items"]
logger.info(f"Dispatcher: Received {len(items)} items for processing.")
# Prepare updates for parallel worker nodes
# Each update targets the 'worker_node' with a single item and a unique task_id.
# The 'task_results' dictionary in the state will store results keyed by task_id.
return [
Send("worker_node", {"item": item, "task_id": f"task_{i}"})
for i, item in enumerate(items)
]
def worker_node(state: dict) -> Dict:
"""Simulates processing a single item. Introduces a random failure."""
item = state["item"]
task_id = state["task_id"]
logger.info(f"Worker {task_id}: Processing item '{item}'...")
try:
# Simulate work with a delay
time.sleep(random.uniform(0.5, 1.5))
# Simulate a 20% chance of failure
if random.random() < 0.2:
raise ValueError(f"Simulated failure for item: {item}")
result_data = f"Processed: {item.upper()}"
logger.info(f"Worker {task_id}: Successfully processed '{item}'.")
return {"task_results": {task_id: {"status": "success", "data": result_data}}}
except Exception as e:
logger.error(f"Worker {task_id}: Failed to process '{item}'. Error: {e}")
return {"task_results": {task_id: {"status": "failed", "error": str(e)}}}}
def aggregator_node(state: AgentState) -> Dict:
"""Collects results from all workers and consolidates them."""
all_results = state.get("task_results", {})
successful_data = []
failed_tasks_info = {}
logger.info(f"Aggregator: Collecting results from {len(all_results)} tasks.")
for task_id, res in all_results.items():
if res["status"] == "success":
successful_data.append(res["data"])
else:
failed_tasks_info[task_id] = res["error"]
final_summary = ""
if successful_data:
final_summary += f"Successfully processed {len(successful_data)} items: {', '.join(successful_data)}.
"
if failed_tasks_info:
final_summary += f"Detected {len(failed_tasks_info)} failures: {failed_tasks_info}.
"
logger.info(f"Aggregator: Final summary generated.
{final_summary}")
return {"final_output": final_summary, "has_failures": bool(failed_tasks_info)}
# --- Build the Graph ---
builder = StateGraph(AgentState)
# Add nodes
builder.add_node("dispatcher_node", dispatcher_node)
builder.add_node("worker_node", worker_node)
builder.add_node("aggregator_node", aggregator_node)
# Define entry point
builder.set_entry_point("dispatcher_node")
# Define transitions
# Dispatcher sends to worker_node (implicitly handled by LangGraph's Send API)
# Worker node always transitions to aggregator_node (after its specific task is done)
builder.add_edge("worker_node", "aggregator_node")
# Aggregator node transitions to END
builder.add_edge("aggregator_node", END)
# Compile the graph
app = builder.compile()
# --- Run the Graph ---
if __name__ == "__main__":
# Example input
input_data = {"input_items": ["apple", "banana", "cherry", "date", "elderberry", "fig", "grape", "honeydew", "kiwi", "lemon"]}
# Run the graph
print("
--- Running LangGraph Map-Reduce Example ---")
final_state = app.invoke(input_data)
print("
--- Final State Output ---")
print(f"Final Output: {final_state['final_output']}")
print(f"Any Failures: {final_state['has_failures']}")
# Example with a single item to show it still works
print("
--- Running with a single item ---")
single_item_input = {"input_items": ["orange"]}
single_item_state = app.invoke(single_item_input)
print(f"Final Output (single item): {single_item_state['final_output']}")
print(f"Any Failures (single item): {single_item_state['has_failures']}")
--- Running LangGraph Map-Reduce Example ---
[INFO] Dispatcher: Received 10 items for processing.
[INFO] Worker task_0: Processing item 'apple'...
[INFO] Worker task_1: Processing item 'banana'...
[INFO] Worker task_2: Processing item 'cherry'...
[INFO] Worker task_3: Processing item 'date'...
[INFO] Worker task_4: Processing item 'elderberry'...
[INFO] Worker task_5: Processing item 'fig'...
[INFO] Worker task_6: Processing item 'grape'...
[INFO] Worker task_7: Processing item 'honeydew'...
[INFO] Worker task_8: Processing item 'kiwi'...
[INFO] Worker task_9: Processing item 'lemon'...
[INFO] Worker task_X: Successfully processed 'Y'. (multiple lines, some may show 'Failed to process')
[INFO] Aggregator: Collecting results from 10 tasks.
[INFO] Aggregator: Final summary generated.
Successfully processed X items: Processed: Y, Processed: Z, ...
Detected W failures: {'task_A': 'Simulated failure for item: item_A', ...}.
--- Final State Output ---
Final Output: Successfully processed X items: Processed: Y, Processed: Z, ...
Detected W failures: {'task_A': 'Simulated failure for item: item_A', ...}.
Any Failures: True/False (depending on random failures)
--- Running with a single item ---
[INFO] Dispatcher: Received 1 items for processing.
[INFO] Worker task_0: Processing item 'orange'...
[INFO] Worker task_0: Successfully processed 'orange'. (or 'Failed to process')
[INFO] Aggregator: Collecting results from 1 tasks.
[INFO] Aggregator: Final summary generated.
Successfully processed 1 items: Processed: ORANGE.
(or Detected 1 failures: {'task_0': 'Simulated failure for item: orange'}.
)
Final Output (single item): Successfully processed 1 items: Processed: ORANGE.
(or Detected 1 failures: {'task_0': 'Simulated failure for item: orange'}.
)
Any Failures (single item): True/False