Module 4: Parallel fan-out and aggregation
Measuring the latency savings in rounds
Description
Lessons 03 through 06 left two complete paths for the same compound request:
run_fanout_sequential, which dispatches one sub-task after another, and run_fanout_parallel,
which dispatches them all at once with ThreadPoolExecutor. This lesson answers the question
that justifies lesson 06's existence: how much does running in parallel actually save?
The answer has a part that's surprising if you're coming from Module 3: the fan-out doesn't save
a single model call. The same calls — the split, each specialist's internal calls, the
synthesis — happen on both paths, exactly the same ones. What changes is how many rounds it
takes to complete them: in sequential, the rounds get added up; in parallel, they end up
bounded by the sub-task that needs the most, because the rest advance at the same time. This
lesson measures that difference with a round count — never with time.time() or any real
clock — and builds a formula that generalizes the result to any number of sub-tasks.
Connection to the module
This lesson reuses the numbers already produced in lesson 04 (each specialist's internal calls)
and lesson 06's mechanism (run_fanout_parallel), without modifying either one. What's new is the
cost model — fanout_rounds — that turns those numbers into a rounds comparison. Lesson 08
(mini-project) applies this same formula to new scenarios, including one with three agents.
Analogy: three kitchen tasks, one kitchen or three kitchens
Picture preparing three dishes that don't depend on each other — a salad, a soup, a dessert — each with its own steps. With a single kitchen (sequential), you prepare the complete salad, then the complete soup, then the complete dessert: the total time is the sum of the three. With three kitchens (parallel), you put someone in each one at the same time: the total time is no longer the sum — it's the time of the slowest dish of the three, because the other two would already be done by the time that one finishes. If the salad takes 2 steps, the soup 2 steps, and the dessert 2 steps, with one kitchen that's 6 steps total; with three kitchens, it's 2 steps — bounded by the slowest one, which in this case is all three equally.
Worked example: sequential vs. parallel rounds, with real numbers
import concurrent.futures
from dataclasses import dataclass
import reservo_tools as rt
def dispatch_parallel(tool_use_blocks, tools):
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
results = [f.result() for f in futures]
return [
{"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
for b, r in zip(tool_use_blocks, results)
]
def run_agent_parallel(question, model_script, tools, max_iterations=10):
messages = [{"role": "user", "content": question}]
for step in range(max_iterations):
turn = model_script[step]
messages.append({"role": "assistant", "content": turn["content"]})
if turn["stop_reason"] != "tool_use":
return turn, messages
tool_result_blocks = dispatch_parallel(turn["content"], tools)
messages.append({"role": "user", "content": tool_result_blocks})
raise RuntimeError(f"max_iterations reached ({max_iterations})")
POLICY_DOCS = {
"cancellation-policy": (
"Bookings can be cancelled at no charge up to 2 hours before the "
"booked time slot. Cancellations within those 2 hours incur the "
"no-show fee."
),
}
def search_docs(query):
q = query.lower()
if "cancel" in q:
return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
return "No relevant policy was found for that question."
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
},
"policy_agent": {"tools": {"search_docs": search_docs}},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
def count_model_calls(history):
return sum(1 for m in history if m["role"] == "assistant")
@dataclass
class SubTask:
agent: str
task: str
def run_fanout_sequential(subtasks, model_scripts):
ordered = sorted(subtasks, key=lambda s: s.agent)
results = {}
for sub in ordered:
final, history = run_specialist(sub.agent, sub.task, model_scripts[sub.agent])
results[sub.agent] = {"task": sub.task, "output": final["content"][0]["text"], "history": history}
return results
subtasks = [
SubTask(agent="booking_agent", task="Quote Focus pro 3h."),
SubTask(agent="policy_agent", task="What's the cancellation policy?"),
]
model_scripts = {
"booking_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Focus pro 3h costs 6000 cents."}]},
],
"policy_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "cancellation policy"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"You can cancel at no charge up to 2 hours before the "
"booked time. After that, the no-show fee applies."
)}]},
],
}
results = run_fanout_sequential(subtasks, model_scripts)
call_counts = {a: count_model_calls(results[a]["history"]) for a in results}
print("--- internal calls per specialist (identical on both paths) ---")
for a in sorted(call_counts):
print(f" {a:15} -> {call_counts[a]} model calls")
FANOUT_SPLIT_CALLS = 1 # concept: the supervisor recognizes the sub-tasks -- happens ONCE, before dispatching
COMPOSE_CALLS = 1 # concept: the final synthesis -- happens ONCE, AFTER both finish
HOPS_PER_SPECIALIST = 2 # M2 L06 / M3 L05 convention: a round trip to each specialist
def fanout_rounds(call_counts_list, surrounding_calls):
"""A 'round' is one round-trip step with the model -- NEVER real
wall-clock time (there's no time.time() in this guide). Sequential:
each specialist waits for the previous one to finish, so the rounds
get ADDED UP. Parallel: the specialists run at the same time, so the
rounds end up bounded by the one that needs the MOST -- max(), not
sum()."""
sequential = sum(call_counts_list) + surrounding_calls
parallel = max(call_counts_list) + surrounding_calls
return sequential, parallel
counts_list = list(call_counts.values())
seq_rounds, par_rounds = fanout_rounds(counts_list, FANOUT_SPLIT_CALLS + COMPOSE_CALLS)
total_calls = sum(counts_list) + FANOUT_SPLIT_CALLS + COMPOSE_CALLS # the TOTAL number of calls is the same on both paths
total_hops = HOPS_PER_SPECIALIST * len(counts_list) # the hops don't change either
print()
print(f"{'':32}{'sequential fan-out':>20}{'parallel fan-out':>20}")
print(f"{'model calls TOTAL':32}{total_calls:>20}{total_calls:>20}")
print(f"{'hops between agents':32}{total_hops:>20}{total_hops:>20}")
print(f"{'coordination rounds':32}{seq_rounds:>20}{par_rounds:>20}")
rounds_saved = seq_rounds - par_rounds
pct_saved = round(100 * rounds_saved / seq_rounds)
print()
print(f"difference: SAME {total_calls} model calls and SAME {total_hops} hops on both "
f"paths -- the only thing that changes is ROUNDS: {rounds_saved} fewer with real concurrency "
f"({pct_saved}% fewer), because booking_agent and policy_agent advance their 2 internal "
f"rounds at the same time, instead of one behind the other.")
What to expect:
--- internal calls per specialist (identical on both paths) ---
booking_agent -> 2 model calls
policy_agent -> 2 model calls
sequential fan-out parallel fan-out
model calls TOTAL 6 6
hops between agents 4 4
coordination rounds 6 4
difference: SAME 6 model calls and SAME 4 hops on both paths -- the only thing that changes is ROUNDS: 2 fewer with real concurrency (33% fewer), because booking_agent and policy_agent advance their 2 internal rounds at the same time, instead of one behind the other.
There's this lesson's central result: 6 = 6 model calls, 4 = 4 hops — neither path saves
even a single call or a single hop, because the same decisions have to be made either way. The
only column that differs is rounds: 6 in sequential (the split, plus the 2+2 internal calls
added up, plus the synthesis), 4 in parallel (the split, plus max(2, 2) = 2 because the two run
at the same time, plus the synthesis).
Where each number comes from
Sequential:
1 split -- happens once, before anything gets dispatched
2 + 2 = 4 -- booking_agent and policy_agent, ONE AFTER THE OTHER (added up)
1 synthesis -- happens once, after the last one finishes
------------------------------------------------------------
6 ROUNDS
Parallel:
1 split -- IDENTICAL -- still happens once, before dispatch
max(2, 2) = 2 -- booking_agent and policy_agent advance THEIR 2 rounds AT THE SAME TIME
1 synthesis -- IDENTICAL -- still waits for BOTH to finish
------------------------------------------------------------
4 ROUNDS
The split and the synthesis don't parallelize with anything — the split has to happen before
any sub-task exists to dispatch, and the synthesis has to wait for all the sub-tasks to have
finished (lesson 04 already confirmed this with compose_fanout_response's KeyError over
incomplete results). That's why surrounding_calls gets added the same way on both paths — the
only thing max() instead of sum() changes is the part that does run at the same time: the
specialists' internal calls.
Why the fan-out doesn't save calls (unlike the pipeline)
It's worth comparing this to Module 3, lesson 05, which did measure a call saving: there, a pipeline saved exactly one routing call per stage, because the fixed order eliminated a decision a repeated supervisor would otherwise have to pay for. Here there's no decision to eliminate — the split (recognizing there are two independent sub-tasks) has to happen either way, whether you dispatch them sequentially or in parallel. The fan-out doesn't reduce how much decision work is needed; it reduces how much conceptual-clock time (rounds) it takes to complete that same work, because part of it stops waiting in line.
Common mistakes
-
Looking for a model-call saving where there isn't one. This lesson's result isn't "fan-out is cheaper" — it's "fan-out is faster, at the same cost." Confusing the two leads to expecting a number this pattern, by design, doesn't produce.
-
Using
sum()for the parallel path, ormax()for the sequential one. This is the most direct formula mistake — swapping them produces nonsensical numbers (a parallel path "slower" than sequential, for example). The rule is fixed: sequential adds up, parallel gets bounded by the maximum. -
Forgetting to add
surrounding_callson both sides equally. The split and the synthesis don't disappear on either path — omitting them from one of the two sides of the comparison overstates the real saving. -
Generalizing this example's 33% to any fan-out. The percentage depends on how many internal calls each sub-task has and how many sub-tasks there are. What does generalize is the formula (
sum()vs.max()) — Exercise 1 applies it to a three-sub-task case with a different result. -
Thinking the rounds saving is the same as a real wall-clock time saving. It isn't — this guide never measures
time.time(). "Rounds" is a discrete count of coordination steps, useful for comparing patterns against each other, not a measurement of a real API's latency.
Exercises
Exercise 1: Fan-out of three agents, each with 2 internal calls (Easy)
Using fanout_rounds, calculate the sequential and parallel rounds for a fan-out of three
sub-tasks, each with 2 internal calls (like booking_agent, policy_agent, and pricing_agent in
lesson 08's mini-project), with the same worked example's surrounding_calls = 2.
See solution
seq_3, par_3 = fanout_rounds([2, 2, 2], surrounding_calls=2)
print(f"sequential rounds: {seq_3} | parallel rounds: {par_3} | saved: {seq_3 - par_3}")
Expected output:
sequential rounds: 8 | parallel rounds: 4 | saved: 4
Explanation: sequential adds up the three (2+2+2=6) plus the 2 surrounding_calls = 8.
Parallel takes the maximum (max(2,2,2)=2) plus the same 2 surrounding_calls = 4. The saving
grew from 2 (with two sub-tasks) to 4 (with three) — the more sub-tasks run at the same time, the
bigger the saving in rounds, as long as they all have a similar internal cost.
Exercise 2: Two agents with DIFFERENT internal costs (Medium)
Calculate the sequential and parallel rounds for two sub-tasks with internal costs of 2 and 3 calls
respectively (imagine policy_agent needed an extra refinement step in its search), with
surrounding_calls = 2.
See solution
seq_2, par_2 = fanout_rounds([2, 3], surrounding_calls=2)
print(f"sequential rounds: {seq_2} | parallel rounds: {par_2} | saved: {seq_2 - par_2}")
print("note: parallel ends up BOUNDED by the slowest specialist (3), not by the average")
Expected output:
sequential rounds: 7 | parallel rounds: 5 | saved: 2
Explanation: parallel (5 = max(2,3) + 2) is determined by the slowest sub-task (3
calls), not by an average of the two (which would be 2.5). This matters for real design: adding
fast sub-tasks to a fan-out that already has a slow one doesn't reduce total time — only adding
work that runs within the time the slowest one was already using is "free" in rounds.
Exercise 3: Daily savings over 500 compound requests (Hard)
Reservo receives 500 compound requests per day, each with the same shape as the worked example (two
sub-tasks with 2 calls each). Calculate the rounds saved per day if ALL of them were dispatched
with run_fanout_parallel instead of run_fanout_sequential.
See solution
DAILY_REQUESTS = 500
seq_daily, par_daily = fanout_rounds([2, 2], surrounding_calls=2)
savings_per_request = seq_daily - par_daily
print(f"saving per request: {savings_per_request} rounds")
print(f"daily saving: {DAILY_REQUESTS * savings_per_request} rounds")
Expected output:
saving per request: 2 rounds
daily saving: 1000 rounds
Explanation: 1000 rounds per day is a real, measurable saving in aggregate latency — the same kind of calculation Module 3, lesson 05, already used for a pipeline's call saving. The underlying difference: here no call gets eliminated — Reservo still pays exactly the same coordination cost per day — what gets recovered is time: 1000 fewer rounds of accumulated waiting for the members sending compound requests, without the system doing a single bit less work.
Summary and next step
- The fan-out doesn't save model calls or hops — the same decisions happen on both paths, sequential and parallel, in the exact same quantity.
- What it does save is coordination rounds: sequential adds them up (each sub-task waits for the previous one); parallel bounds them by the maximum (all advance at the same time, limited only by the slowest one).
- For the worked example's request: 6 sequential rounds, 4 parallel rounds — a saving of 2 rounds (33%), with the same 6 model calls and the same 4 hops on both paths.
- The
fanout_roundsformula generalizes to any number of sub-tasks and to different internal costs between them — the parallel path always ends up bounded by the slowest sub-task, never by an average.
Next lesson: 08 — Mini-project: fan-out across Reservo. We apply the complete pattern — identifying independence, dispatching (sequential and parallel), aggregating, measuring the saving — to three new scenarios, including one that is NOT fan-out between agents.
Additional resources
- Anthropic — Building effective agents — The principle that parallelizing independent sub-tasks reduces perceived latency without changing the total work — the exact result this lesson measures.
- Anthropic — Multi-agent research system — Anthropic's report explicitly measures that a multi-agent system's latency is bounded by the slowest sub-agent when work is split in parallel — the same
max()property this lesson confirms. - Python — Built-in functions
sum()andmax()— The two functions that completely distinguishfanout_rounds's sequential cost model from the parallel one. - Python 3.14 — What's New — The version every line of this measurement ran on.