Skip to content

Orchestrator-Worker Pattern

Learning Objectives

What You'll Learn Time Difficulty
Understand orchestrator-worker architecture 45 min Intermediate
Implement dynamic task delegation
Handle worker failures gracefully
Build production-ready orchestrators

Prerequisites

Requirement Details
Python 3.10+ With asyncio and openai packages
Module 5: AI Agents Agent fundamentals, tool use
Module 7: Prompt Engineering System prompts for specialized workers
Module 12, Lessons 1-2 Multi-agent concepts and communication
pip install openai pydantic python-dotenv

What Is the Orchestrator-Worker Pattern?

The orchestrator-worker pattern is a multi-agent architecture where a central agent (orchestrator) breaks down complex tasks, delegates to specialized worker agents, and synthesizes results. The orchestrator never does the work itself — it plans, assigns, monitors, and combines.

This is the most common pattern in production multi-agent systems because it provides clear accountability, predictable workflows, and a single point for quality control.

Architecture

                    ┌──────────────────┐
                    │   Orchestrator   │
                    │   (Coordinator)  │
                    │                  │
                    │  - Plans steps   │
                    │  - Assigns work  │
                    │  - Monitors      │
                    │  - Synthesizes   │
                    └────────┬─────────┘
            ┌────────────────┼────────────────┐
            ↓                ↓                ↓
      ┌──────────┐     ┌──────────┐    ┌──────────┐
      │ Worker 1 │     │ Worker 2 │    │ Worker 3 │
      │(Research)│     │(Analysis)│    │(Writing) │
      └──────────┘     └──────────┘    └──────────┘

Key Responsibilities

Orchestrator: - Task decomposition — break complex goals into steps - Work assignment — route each step to the right worker - Result aggregation — combine worker outputs - Error handling — retry, fallback, or abort on failure - Progress tracking — monitor step completion and latency

Workers: - Execute assigned tasks with domain expertise - Report results in a structured format - Request clarifications when input is ambiguous - Provide status updates for long-running tasks


When to Use This Pattern

Perfect For

Use Case Why It Works
Complex workflows Clear task decomposition into known steps
Specialized agents Each worker has distinct expertise and prompts
Predictable process Steps are known in advance (even if content varies)
Quality control needed Central validation point before final output
Resource management Central allocation of work and rate limiting

Not Ideal For

  • Simple, single-step tasks (use a single agent)
  • Highly dynamic, unpredictable workflows (consider peer-to-peer)
  • Systems requiring peer collaboration without hierarchy
  • Ultra-low latency requirements (orchestration adds overhead)

What You'll Build

By the end of this lesson, you will have:

  • [ ] A working orchestrator that plans and executes multi-step workflows
  • [ ] Three specialized worker agents (research, analysis, writing)
  • [ ] Failure handling with retry and fallback strategies
  • [ ] LLM-powered dynamic planning (not just hardcoded workflows)
  • [ ] A complete research report pipeline as a demonstration
  • [ ] Structured logging of each step for debugging

Step 1: Define the Worker Base Class

Workers are specialized agents with focused system prompts and a standard interface.

# src/workers/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from openai import OpenAI
import time

client = OpenAI()

@dataclass
class WorkerResult:
    worker_name: str
    output: str
    success: bool
    latency_ms: float
    error: str = ""

class Worker(ABC):
    def __init__(self, name: str, system_prompt: str, model: str = "gpt-4.1-mini"):
        self.name = name
        self.system_prompt = system_prompt
        self.model = model

    async def execute(self, task_input: str) -> WorkerResult:
        start = time.time()
        try:
            response = client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system", "content": self.system_prompt},
                    {"role": "user", "content": task_input},
                ],
                temperature=0.3,
            )
            output = response.choices[0].message.content
            latency = (time.time() - start) * 1000
            return WorkerResult(
                worker_name=self.name,
                output=output,
                success=True,
                latency_ms=latency,
            )
        except Exception as e:
            latency = (time.time() - start) * 1000
            return WorkerResult(
                worker_name=self.name,
                output="",
                success=False,
                latency_ms=latency,
                error=str(e),
            )

    @abstractmethod
    def get_capabilities(self) -> str:
        pass

Step 2: Implement Specialized Workers

# src/workers/specialists.py
from src.workers.base import Worker

class ResearchWorker(Worker):
    def __init__(self):
        super().__init__(
            name="research_agent",
            system_prompt=(
                "You are a research specialist. Given a topic or question, "
                "provide comprehensive background information, key facts, "
                "relevant statistics, and important context. "
                "Structure your response with clear sections. "
                "Cite sources when possible. Be thorough but concise."
            ),
        )

    def get_capabilities(self) -> str:
        return "Background research, fact gathering, context building"

class AnalysisWorker(Worker):
    def __init__(self):
        super().__init__(
            name="analysis_agent",
            system_prompt=(
                "You are an analysis specialist. Given research data, "
                "identify patterns, trends, key insights, and implications. "
                "Compare different perspectives. Highlight strengths and weaknesses. "
                "Provide data-driven conclusions."
            ),
        )

    def get_capabilities(self) -> str:
        return "Pattern analysis, trend identification, insight extraction"

class WritingWorker(Worker):
    def __init__(self):
        super().__init__(
            name="writing_agent",
            system_prompt=(
                "You are a writing specialist. Given research and analysis, "
                "produce a well-structured, clear, and engaging written report. "
                "Use professional tone. Include an executive summary, "
                "main sections, and conclusions. Format with headers."
            ),
        )

    def get_capabilities(self) -> str:
        return "Report writing, summarization, content structuring"

Step 3: Build the Orchestrator

The orchestrator plans workflows, delegates to workers, handles failures, and synthesizes results.

# src/orchestrator.py
import asyncio
import json
from typing import Any
from openai import OpenAI
from src.workers.base import Worker, WorkerResult

client = OpenAI()

class Orchestrator:
    def __init__(self):
        self.workers: dict[str, Worker] = {}
        self.step_log: list[dict] = []

    def register_worker(self, worker: Worker):
        self.workers[worker.name] = worker
        print(f"Registered worker: {worker.name} ({worker.get_capabilities()})")

    async def execute_workflow(self, task: dict) -> dict:
        workflow_id = task.get("id", "workflow-1")
        print(f"Starting workflow: {workflow_id}")

        plan = await self.plan_workflow(task)
        print(f"Plan: {len(plan)} steps")

        results: dict[str, WorkerResult] = {}
        for step in plan:
            resolved_input = self._resolve_input(step["input"], results)
            step_result = await self.execute_step(step, resolved_input)
            results[step["name"]] = step_result
            self.step_log.append({
                "step": step["name"],
                "worker": step["worker"],
                "success": step_result.success,
                "latency_ms": step_result.latency_ms,
            })

        final = await self.synthesize_results(task, results)
        print(f"Workflow complete: {workflow_id}")
        return final

    async def plan_workflow(self, task: dict) -> list[dict]:
        """Use LLM to dynamically plan workflow steps."""
        worker_descriptions = "\n".join(
            f"- {name}: {w.get_capabilities()}"
            for name, w in self.workers.items()
        )

        response = client.chat.completions.create(
            model="gpt-4.1",
            messages=[{
                "role": "user",
                "content": f"""Plan a workflow to complete this task.

Task: {task.get('description', task.get('query', ''))}
Task type: {task.get('type', 'general')}

Available workers:
{worker_descriptions}

Return a JSON array of steps:
[
  {{"name": "step_name", "worker": "worker_name", "input": "what to pass to the worker"}},
  ...
]

Use {{{{step_name}}}} in input to reference a previous step's output.
Keep plans to 2-5 steps. Order steps logically.""",
            }],
            response_format={"type": "json_object"},
        )

        plan_data = json.loads(response.choices[0].message.content)
        steps = plan_data.get("steps", plan_data.get("plan", []))
        return steps

    def _resolve_input(self, input_template: str, results: dict[str, WorkerResult]) -> str:
        """Replace {{step_name}} placeholders with actual step outputs."""
        resolved = input_template
        for step_name, result in results.items():
            placeholder = "{{" + step_name + "}}"
            if placeholder in resolved:
                resolved = resolved.replace(placeholder, result.output)
        return resolved

    async def execute_step(self, step: dict, resolved_input: str) -> WorkerResult:
        worker_name = step["worker"]
        worker = self.workers.get(worker_name)

        if not worker:
            return WorkerResult(
                worker_name=worker_name,
                output="",
                success=False,
                error=f"Worker not found: {worker_name}",
                latency_ms=0,
            )

        print(f"  Executing: {step['name']} -> {worker_name}")
        result = await worker.execute(resolved_input)

        if not result.success:
            return await self.handle_step_failure(step, resolved_input, result)

        print(f"  Completed: {step['name']} ({result.latency_ms:.0f}ms)")
        return result

    async def handle_step_failure(
        self, step: dict, resolved_input: str, failed_result: WorkerResult,
    ) -> WorkerResult:
        retries = step.get("retries", 0)
        max_retries = 3

        if retries < max_retries:
            print(f"  Retrying: {step['name']} (attempt {retries + 1}/{max_retries})")
            step["retries"] = retries + 1
            await asyncio.sleep(2 ** retries)
            return await self.execute_step(step, resolved_input)

        fallback = step.get("fallback_worker")
        if fallback and fallback in self.workers:
            print(f"  Fallback: {step['name']} -> {fallback}")
            step["worker"] = fallback
            step["retries"] = 0
            return await self.execute_step(step, resolved_input)

        print(f"  Failed: {step['name']} after {max_retries} retries")
        return failed_result

    async def synthesize_results(self, task: dict, results: dict[str, WorkerResult]) -> dict:
        """Combine worker results into a final output."""
        successful = {k: v for k, v in results.items() if v.success}
        failed = {k: v for k, v in results.items() if not v.success}

        if failed and not successful:
            return {
                "status": "failed",
                "error": f"All steps failed: {list(failed.keys())}",
                "step_log": self.step_log,
            }

        combined = "\n\n".join(
            f"## {name}\n{result.output}"
            for name, result in successful.items()
        )

        response = client.chat.completions.create(
            model="gpt-4.1",
            messages=[{
                "role": "user",
                "content": f"""Synthesize these worker outputs into a final deliverable.

Original task: {task.get('description', '')}

Worker outputs:
{combined}

Produce a polished final output that integrates all worker contributions.""",
            }],
            temperature=0.3,
        )

        return {
            "status": "success" if not failed else "partial",
            "final_output": response.choices[0].message.content,
            "steps_completed": len(successful),
            "steps_failed": len(failed),
            "step_log": self.step_log,
            "total_latency_ms": sum(s["latency_ms"] for s in self.step_log),
        }

Step 4: Run a Complete Workflow

# demo.py
import asyncio
from src.orchestrator import Orchestrator
from src.workers.specialists import ResearchWorker, AnalysisWorker, WritingWorker

async def main():
    orchestrator = Orchestrator()
    orchestrator.register_worker(ResearchWorker())
    orchestrator.register_worker(AnalysisWorker())
    orchestrator.register_worker(WritingWorker())

    task = {
        "id": "report-001",
        "type": "research_report",
        "description": "Write a report on the impact of AI agents on software engineering productivity",
        "query": "How are AI coding agents changing software development?",
    }

    result = await orchestrator.execute_workflow(task)

    print(f"\nStatus: {result['status']}")
    print(f"Steps: {result['steps_completed']} completed, {result.get('steps_failed', 0)} failed")
    print(f"Total latency: {result['total_latency_ms']:.0f}ms")
    print(f"\n--- Final Output ---\n{result['final_output'][:500]}...")

if __name__ == "__main__":
    asyncio.run(main())

Step 5: Hardcoded Workflows (Alternative)

For predictable pipelines, skip LLM planning and use predefined workflows:

HARDCODED_PLANS = {
    "research_report": [
        {"name": "research", "worker": "research_agent", "input": "{query}"},
        {"name": "analyze", "worker": "analysis_agent", "input": "{{research}}"},
        {"name": "write", "worker": "writing_agent", "input": "Research:\n{{research}}\n\nAnalysis:\n{{analyze}}"},
    ],
    "code_review": [
        {"name": "security", "worker": "security_agent", "input": "{code}"},
        {"name": "quality", "worker": "quality_agent", "input": "{code}"},
        {"name": "synthesize", "worker": "writing_agent",
         "input": "Security review:\n{{security}}\n\nQuality review:\n{{quality}}"},
    ],
}

async def plan_hardcoded(self, task: dict) -> list[dict]:
    plan_template = HARDCODED_PLANS.get(task["type"], [])
    query = task.get("query", task.get("description", ""))
    return [
        {**step, "input": step["input"].replace("{query}", query).replace("{code}", task.get("code", ""))}
        for step in plan_template
    ]

Use hardcoded plans when the workflow is stable. Use LLM planning when task types vary or steps depend on task content.


Testing Your Build

Unit Tests

# tests/test_orchestrator.py
import pytest
import asyncio
from src.orchestrator import Orchestrator
from src.workers.base import Worker, WorkerResult

class MockWorker(Worker):
    def __init__(self, name: str, response: str = "mock output", should_fail: bool = False):
        super().__init__(name=name, system_prompt="test")
        self._response = response
        self._should_fail = should_fail

    async def execute(self, task_input: str) -> WorkerResult:
        if self._should_fail:
            return WorkerResult(self.name, "", False, 10, error="mock failure")
        return WorkerResult(self.name, self._response, True, 10)

    def get_capabilities(self) -> str:
        return f"Mock {self.name}"

@pytest.fixture
def orchestrator():
    o = Orchestrator()
    o.register_worker(MockWorker("research_agent", "Research findings here"))
    o.register_worker(MockWorker("analysis_agent", "Analysis results here"))
    o.register_worker(MockWorker("writing_agent", "Final report here"))
    return o

@pytest.mark.asyncio
async def test_workflow_completes(orchestrator):
    result = await orchestrator.execute_workflow({
        "type": "research_report",
        "query": "Test topic",
        "description": "Test report",
    })
    assert result["status"] in ("success", "partial")

@pytest.mark.asyncio
async def test_worker_failure_handled(orchestrator):
    orchestrator.register_worker(MockWorker("failing_agent", should_fail=True))
    result = await orchestrator.execute_workflow({
        "type": "general",
        "description": "Test with failing worker",
    })
    assert "step_log" in result

def test_input_resolution():
    o = Orchestrator()
    results = {
        "research": WorkerResult("research_agent", "Research data", True, 10),
    }
    resolved = o._resolve_input("Based on: {{research}}", results)
    assert "Research data" in resolved

Manual Testing Checklist

  • [ ] Orchestrator registers workers and lists capabilities
  • [ ] LLM planning produces valid step sequences
  • [ ] Steps execute in order with correct input passing
  • [ ] {{step_name}} placeholders resolve to previous outputs
  • [ ] Failed steps retry up to 3 times
  • [ ] Final synthesis produces coherent combined output
  • [ ] Step log captures latency and success for each step

Deployment Notes

Production Considerations

Concern Development Production
Planning LLM-generated (flexible) Hardcoded for known workflows, LLM for novel tasks
Worker calls Sequential Parallel where steps are independent
Failure handling Retry 3x Retry + circuit breaker + dead letter queue
Observability Print statements Structured logging + tracing (Langfuse/LangSmith)
Cost control No limits Per-workflow token budget, worker timeouts

Parallel Execution

When steps are independent, run them concurrently:

async def execute_parallel_steps(self, steps: list[dict]) -> dict[str, WorkerResult]:
    tasks = [self.execute_step(step, step["input"]) for step in steps]
    results = await asyncio.gather(*tasks)
    return {steps[i]["name"]: results[i] for i in range(len(steps))}

Framework Alternatives

Framework Orchestrator Pattern Best For
LangGraph State machine orchestrator Complex branching workflows
CrewAI Manager + crew members Role-based agent teams
AutoGen Group chat manager Conversational multi-agent
Custom (this lesson) Full control Learning and simple pipelines

Extensions and Challenges

  • Dynamic worker creation: Orchestrator spawns new workers based on task needs
  • Human-in-the-loop: Pause workflow for human approval before synthesis
  • Worker specialization via fine-tuning: Fine-tune each worker on domain-specific data
  • Cost tracking: Log token usage per worker per workflow for budgeting
  • Workflow templates: Save and reuse successful plans for similar tasks
  • LangGraph migration: Reimplement the orchestrator as a LangGraph state machine

Key Takeaways

  • The orchestrator-worker pattern provides centralized control with specialized execution
  • The orchestrator plans, delegates, monitors, and synthesizes — workers execute
  • Use LLM planning for flexible workflows, hardcoded plans for predictable pipelines
  • Always implement retry, fallback, and structured logging for production reliability
  • Input resolution between steps ({{step_name}}) is the key mechanism for chaining workers
  • Frameworks like LangGraph and CrewAI implement this pattern with additional features

Next Lesson

Lesson 4: Peer-to-Peer Agent Collaboration — Learn patterns where agents communicate directly without a central orchestrator.