Skip to content
Back to Blog
pythonorchestrationvoicelatencyreliability

My Three‑Phase Parallel Orchestrator: Typed Results, Exception‑Proof Phases, and a Rollout That Never Flaps

Daniel Anthony Romitelli Jr. · March 11, 2026

A voice session stalled on me. The user had finished speaking, and the system was still "thinking" like someone reading a novel one line at a time.

That stall is why I stopped treating orchestration as a chain of calls and started treating it as a compiler.

A voice-first intelligence assistant doesn't get to be precious. It can't crash because one sub-agent threw. It can't block because one call got slow. And it definitely can't depend on loosely-typed blobs passed around like notes on a sticky pad.

So I built a three-phase parallel orchestrator with typed intermediate results, strict latency budgets, and a fallback chain. It replaced a ~3,500ms linear pipeline with a 600ms parallel architecture. The move that made it work was splitting execution into phases that behave like compilation stages: each phase produces a well-defined intermediate representation, and the next one consumes it without re-doing the work.

The key insight: orchestration is a compilation pipeline

The non-obvious part of "multi-agent" work isn't agents talking to each other.

It's that you need typed intermediate results so downstream stages can be fast, deterministic, and resilient. If Phase 2 has to re-interpret raw text because Phase 1 returned an unstructured blob, or nothing at all, the latency budget is already gone.

In my design, Phase 1 runs three independent analyses in parallel:

  • Query parsing → intent + entities + filters
  • Embedding generation → vector for semantic search
  • Location extraction → geo filter expression

Each one produces a typed dataclass. That typed shape is the "IR" (intermediate representation) that Phase 2 consumes.

Per-phase budgets are explicit:

  • Phase 1 total: <150ms (parallel execution)
  • Phase 2: <300ms (search with pre-computed inputs)
  • Phase 3: <100ms (response formatting)
  • Total P95 target: <600ms (down from ~3,500ms in the linear pipeline)

Those numbers aren't motivational quotes. They're the shape of the system.

Phase 1 contracts: the typed intermediate results

When I say "typed contracts," I mean I literally want Phase 1 to hand Phase 2 a stable object, with defaults that still make sense when something fails.

These are the three results I pass forward.

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

@dataclass
class QueryParserResult:
    intent: str
    entities: Dict[str, Any] = field(default_factory=dict)
    filters: Dict[str, Any] = field(default_factory=dict)
    confidence: float = 0.0
    raw_query: str = ""
    processing_time_ms: float = 0.0

@dataclass
class EmbeddingResult:
    embedding: List[float] = field(default_factory=list)
    cache_hit: bool = False
    model: str = "text-embedding-ada-002"
    dimensions: int = 1536
    processing_time_ms: float = 0.0

@dataclass
class LocationResult:
    state_code: Optional[str] = None
    state_name: Optional[str] = None
    city: Optional[str] = None
    filter_expression: Optional[str] = None
    cache_hit: bool = False
    processing_time_ms: float = 0.0

There's nothing clever in there, and that's deliberate. When a voice pipeline goes sideways, plain defaults beat clever exceptions every time.

Phase 1 execution: parallel, exception-tolerant, and still typed

The naive approach runs the three Phase 1 steps one after another. That's how you end up with "it works on my laptop" latency.

The second naive approach parallelizes them but lets a single exception tear down the whole gather. Now you have a fast system that's down.

What I actually do is run them concurrently and treat exceptions as data. If an agent fails, I return a default instance of the expected dataclass and keep moving.

import asyncio
import logging

logger = logging.getLogger(__name__)

async def _execute_phase1(self, query, context, metrics):
    parser_task = self._run_query_parser(query, context)
    embedding_task = self._run_embedding_agent(query, context)
    location_task = self._run_location_agent(query, context)

    results = await asyncio.gather(
        parser_task, embedding_task, location_task,
        return_exceptions=True,
    )

    parser_result = self._handle_agent_result(results[0], QueryParserResult)
    embedding_result = self._handle_agent_result(results[1], EmbeddingResult)
    location_result = self._handle_agent_result(results[2], LocationResult)
    return parser_result, embedding_result, location_result

def _handle_agent_result(self, result, expected_type):
    if isinstance(result, Exception):
        logger.warning(f"Agent returned exception: {result}")
        return expected_type()  # Return default dataclass
    if isinstance(result, expected_type):
        return result
    return expected_type()

The first run surprised me, and not because of the latency win. Everything downstream got calmer once failures stopped being special. A failure turns into "empty embedding" or "no location filter," and Phase 2 already knows what to do with both.

Phase 2: search consumes precomputed inputs (no re-derivation)

Phase 2 is where most systems accidentally light money and time on fire.

If you don't treat Phase 1 outputs as real inputs, you'll end up recomputing or re-parsing inside search. That gets you a pipeline that looks modular but behaves like spaghetti.

My Phase 2 takes all three Phase 1 results and builds a single dictionary of optimized search parameters.

async def _execute_phase2(self, query, parser_result, embedding_result, location_result, user_id, context):
    search_params = {
        "query": query,
        "embedding": embedding_result.embedding,
        "location_filter": location_result.filter_expression,
        "state_code": location_result.state_code,
        "filters": parser_result.filters,
        "entities": parser_result.entities,
        "user_id": user_id,
        "limit": context.get("limit", 10),
    }
    result = await self._run_search_orchestrator(search_params, context)
    return result

I'm opinionated about this one. Phase 2 should feel like calling a compiler backend: a structured input bundle goes in, results come out. It should not be doing "just one more quick parse."

Phase 3: response formatting (and why it's a phase, not a helper)

Phase 3 exists because formatting is not a footnote in voice.

Voice output has different constraints than web output. Let formatting creep into Phase 2 and you destroy your latency accounting along with your ability to evolve the system safely.

Here's the actual implementation:

async def _execute_phase3(
    self,
    candidates: List[Dict[str, Any]],
    parser_result: QueryParserResult,
    user_id: str,
    context: Dict[str, Any],
) -> List[Dict[str, Any]]:
    start = time.time()

    try:
        formatted = await self._run_response_agent(
            candidates=candidates,
            intent=parser_result.intent,
            user_id=user_id,
            context=context,
        )

        elapsed = (time.time() - start) * 1000
        logger.debug(f"Response formatting took {elapsed:.0f}ms")

        return formatted

    except Exception as e:
        logger.exception("Phase 3 formatting failed")
        return candidates  # Return unformatted as fallback

The ResponseAgent doing the real work has four steps: apply a 30-day exclusion window (candidates shown recently get filtered), format each candidate into a typed card with action buttons, track the delivery for future exclusion, and generate a voice-friendly summary.

async def format_response(self, results, conversation_id=None, user_id=None,
                          preview_mode=False, apply_exclusion=True, intent=None):
    candidates = results.candidates
    excluded_count = 0

    # Step 1: 30-day exclusion window
    if apply_exclusion and user_id and self._db_manager:
        candidates, excluded_count = await self._apply_exclusion_window(
            candidates=candidates, user_email=user_id, conversation_id=conversation_id)

    # Step 2: Format candidate cards with action buttons
    formatted_cards = []
    for candidate in candidates:
        card = await self._format_single_card(candidate, include_actions=True)
        formatted_cards.append(card)

    # Step 3: Track delivery (non-blocking — failure doesn't break response)
    delivery_id = None
    if not preview_mode and conversation_id and self._delivery_tracker:
        try:
            delivery_id = await self._track_delivery(
                conversation_id=conversation_id, candidates=candidates, user_id=user_id)
        except Exception as e:
            logger.warning(f"Delivery tracking failed (non-blocking): {e}")

    # Step 4: Generate voice summary
    voice_summary = await self._generate_voice_summary(
        total_count=len(formatted_cards),
        filters=results.filters_applied,
        top_candidates=[c.full_name for c in formatted_cards[:3] if c.full_name])

    return AtlasChatResponse(
        candidates=formatted_cards, total_count=len(formatted_cards),
        excluded_count=excluded_count, conversation_id=conversation_id,
        delivery_id=delivery_id, voice_summary=voice_summary, intent=intent or {})

The important part is the boundary. Phase 2 returns "what we found." Phase 3 returns "what we say." And the fallback in _execute_phase3 means that if formatting explodes, the pipeline still returns raw candidates instead of nothing.

The fallback chain: every runner degrades instead of crashing

Most orchestration failures I've seen in the wild aren't "the model was wrong." They're operational: timeouts, transient API issues, unexpected response shapes.

So every agent runner follows the same pattern:

  1. Try the real agent
  2. If it errors or returns nothing usable, fall back to a simpler implementation

Here's the real pattern for query parsing.

import time
import re

async def _run_query_parser(self, query, context):
    start = time.time()
    try:
        if self._query_parser_agent:
            response = await self._query_parser_agent.process(query, context)
            if hasattr(response, 'result') and response.result:
                return QueryParserResult(
                    intent=response.result.get("intent", "search"),
                    entities=response.result.get("entities", {}),
                    filters=response.result.get("filters", {}),
                    confidence=response.result.get("confidence", 0.7),
                    raw_query=query,
                    processing_time_ms=(time.time() - start) * 1000,
                )
        return await self._fallback_query_parser(query, start)
    except Exception as e:
        logger.warning(f"QueryParser error, using fallback: {e}")
        return await self._fallback_query_parser(query, start)

I like this structure because it's brutally readable under pressure. When I'm debugging a production incident, I don't want a clever abstraction. I want to see the exact decision points.

The query parser fallback: regex, deterministic, no dependency on "smart"

The fallback parser is intentionally simple. Regex-based pattern matching, no LLM call.

So when the "smart" path fails, the system still produces an intent, and the rest of the pipeline keeps moving.

import re

async def _fallback_query_parser(self, query, start):
    query_lower = query.lower()
    if re.search(r'\bhow\s+many\b', query_lower): intent = "count"
    elif re.search(r'\b(find|search|show|get|list)\b', query_lower): intent = "search"
    elif re.search(r'\b(filter|with|having)\b', query_lower): intent = "filter"
    else: intent = "search"
    # ... extract designations, experience, remote from regex
    return QueryParserResult(intent=intent, confidence=0.7, ...)

Here's the part I had to learn the hard way. Fallbacks belong in the primary design, not in a backup plan you bolt on later. A voice system that only works when everything is healthy is a demo, not a product.

Metrics: timing every phase like it owes you money

If you want to hit a <600ms P95 target, measuring "total time" and calling it observability won't get you there.

I track per-phase timings, plus a few key booleans, in a dedicated metrics dataclass.

from dataclasses import dataclass

@dataclass
class OrchestratorMetrics:
    phase1_query_parser_ms: float = 0.0
    phase1_embedding_ms: float = 0.0
    phase1_location_ms: float = 0.0
    phase1_total_ms: float = 0.0
    phase2_search_ms: float = 0.0
    phase3_response_ms: float = 0.0
    total_ms: float = 0.0
    embedding_cache_hit: bool = False
    location_cache_hit: bool = False
    candidates_found: int = 0
    search_tier: str = "primary"

What I like about this shape is that it matches the architecture. When a phase runs long, it's obvious which one. When we're fast but dumb, the cache hit flags and search tier tell me why.

Rollout: consistent user assignment via hash (no flapping)

A parallel orchestrator is an architectural change. Refactor is too small a word for it, and shipping it behind a gate is how you avoid learning about edge cases from your entire user base at once.

The rollout logic uses a feature flag and a percentage. The part that matters is consistent assignment: if a user is "in," they stay in.

import hashlib
import random

def should_use_multi_agent_orchestrator(user_id=None):
    if not USE_MULTI_AGENT_ORCHESTRATOR: return False
    if MULTI_AGENT_ROLLOUT_PERCENTAGE >= 100: return True
    if MULTI_AGENT_ROLLOUT_PERCENTAGE <= 0: return False
    if user_id:
        user_hash = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
        return (user_hash % 100) < MULTI_AGENT_ROLLOUT_PERCENTAGE
    return random.randint(0, 99) < MULTI_AGENT_ROLLOUT_PERCENTAGE

I've shipped enough systems to be allergic to rollout dice rolls. The md5-based bucketing is simple, it's stable, and it keeps experiments trustworthy.

Architecture, end-to-end

Here's the whole dataflow, including the error handling paths that make this interesting:

Every failure degrades capability, never availability.

Nuances: why this works when "agent soup" doesn't

A lot of multi-agent systems fail for one of two reasons.

1) Untyped messages create hidden coupling

If Phase 1 returns an unstructured dict and Phase 2 expects a certain key, you've built a runtime landmine. That doesn't show up as worse results. It shows up as a crash in production when an upstream shape shifts.

Typed dataclasses with defaults force a discipline: every phase can assume the object exists, and the missingness is explicit (empty embedding, no filter expression, low confidence).

2) Exceptions are treated as exceptional

In voice, exceptions are normal. Networks wobble. Dependencies degrade. That's why I run Phase 1 with return_exceptions=True and normalize the results straight back into typed objects.

The orchestrator never blocks on a failed agent. It degrades to a fallback and keeps moving.

The tradeoff: graceful degradation means occasionally less-smart answers

This design chooses availability over perfection.

If the embedding path fails and returns an empty vector, Phase 2 still runs. If location extraction fails, we lose geo filtering. If parsing falls back to regex, intent may be less nuanced.

That's not a bug. That's the contract: the system is always available, just sometimes less smart. And in a voice-first experience, "always answers" beats "answers perfectly, sometimes."

I don't think of this orchestrator as agents cooperating. I think of it as a compilation pipeline that never panics, because every stage produces a typed artifact and every failure has a deterministic shape.