agentsope/agentsop-map-reduce-fanout
| Decision protocol for the map-reduce / dynamic fan-out pattern in LM pipelines — "given list L, run f(item) for each item in parallel, then combine". Activates when the coder agent is about to process N items with N LM calls (per-doc summarize, per-query retrieve, per-candidate rank, parallel tool fan-out). Encodes the *when*, *how many at once*, *what to do when one fails*, and *how to reduce* — not the API of any single Flow, `asyncio.gather`, `ThreadPoolExecutor`, LlamaIndex batch retrieval.
npx skills add https://github.com/agentsope/SkillAlchemy --skill agentsop-map-reduce-fanout
> Pattern: results = reduce(combine, parallel_map(f, L)) where f is one
> or more LM calls. The *only* reason to fan out is that latency or
> throughput matters more than the cost of doing it. The *only* reason to
> fan in is that the consumer wants one answer, not N.
> Source posture: claims grounded in primary docs and 2026 production
> write-ups, cited inline with short tags resolved in the citation index.
Activate this skill when any of these is true:
call, a retriever hit, or any I/O-bound step costing >100ms.
for item in items: result = llm(item) loopand the items are independent (no item depends on the previous result).
asyncio.gather(...), ThreadPoolExecutor(...),Send(...), Process.hierarchical parallel branches, or
crew.kickoff_for_each(...) and the question is *how* to use them safely.
"vote across M models", "ensemble", "parallel agents", "multi-query
retrieval", "scatter-gather", "fan out".
InvalidUpdateError on a key two parallelbranches write to (see OP-3 cross-link in skill O5 state-reducer).
Send-based fan-out is hitting GRAPH_RECURSION_LIMIT orrate-limit 429s because all N workers fired at once
[aipractitioner/scaling].
Do not activate when:
across docs) — fan-out destroys the dependency.
asyncio.wait(..., return_when=FIRST_COMPLETED), not gather.
strings in one OpenAI request). That's a batched single call, not
map-reduce. Use it; it's cheaper.
Fan out for latency, fan in for coherence.
The whole protocol is two questions: *what runs concurrently?* and *how do
the answers merge?* Everything else — Send, gather, Semaphore,
reducers — is mechanics.
Three load-bearing concepts:
f(item) may itselfbe a multi-step LM workflow (retrieve → rerank → synthesize) — that is
fine. What you parallelise is the *per-item function*. Don't confuse
"parallel LM calls" with "parallel workflow instances". The latter is
what Send and gather(f(x) for x in L) actually do
[deepwiki/mapreduce].
(OpenAI/Anthropic API, your vector DB, your KV cache) has at least one
of: RPM limit, TPM limit, connection-pool limit, GPU KV-cache budget.
asyncio.gather(*[call(x) for x in 100_items]) will *not* call 100
things — it will fire 100, the API will 429 most, and you'll spend the
next 20 minutes in retry-storm hell [newline/asyncio-llm]
[tianpan/structured-concurrency]. The first thing you choose, before
any code, is N_concurrent.
concatenate keepsall evidence (large output, no judgment); summarize collapses
(information loss, smaller output); vote / majority picks one (lossy
but decisive); rank-top-K selects the best few. Pick the reducer
*before* you write the map — because the map's expected_output shape
is dictated by the reduce.
The Pregel-lineage version of the same point: **a fan-out / fan-in is one
superstep**. Either it all succeeds and the reduce node runs, or one branch
fails and (in LangGraph) the whole superstep is discarded
[aipractitioner/scaling]. Your code must decide *before* fan-out which
semantics you want: atomic-or-nothing, or best-effort-with-holes.
Walk this top-down. Each step has a decision gate.
Gate: can f(item_i) run without seeing f(item_j) for any j ≠ i?
If no, stop. You have a sequential or recursive problem masquerading
as map-reduce. Use a chain, or model the dependency explicitly (DAG,
beam search, etc.). Fan-out will silently drop the cross-talk.
Before any code, multiply:
cost = N · cost_per_item (in $, tokens, AND seconds_wall)
peak_rps = N_concurrent / mean_latency_per_item
Three numbers must fit inside three budgets:
cost_$ ≤ task budgetpeak_rps ≤ min(API RPM/60, vector-DB QPS, GPU concurrency)peak_tps ≤ API TPM / 60 — TPM is the silent killer: 50 parallel callseach with a 4k-token prompt instantly exceeds most providers' TPM even
if RPM is fine [newline/asyncio-llm].
If any budget is tight, fan-out is the wrong tool. Options: batch
calls into single API request (embeddings, reranking), reduce N (pre-filter
items), or accept sequential.
Default rubric:
| Constraint | Pick |
|---|---|
| API-bound (OpenAI/Anthropic) | min(10, RPM/60 · target_latency_s) — keep at most one "request-second" of headroom |
| Self-hosted vLLM/SGLang | Start at num_kv_blocks / mean_prompt_blocks; for most setups 8–32 |
| Vector DB retrieval | Provider QPS limit / 2 (leave headroom for other paths) |
| Mixed (LM + tool calls) | Constrain by the slowest dependency |
| No instrumentation yet | Start at 5. Always. Then measure. |
N_concurrent is enforced with asyncio.Semaphore(N) for asyncio,
ThreadPoolExecutor(max_workers=N) for sync, max_concurrency config in
LangGraph, max_rpm in CrewAI. Never rely on the framework's defaults.
Four canonical policies — pick one explicitly:
| Policy | When to use | How |
|---|---|---|
| Abort-all | One missing item invalidates the answer (legal review, regulated workflows) | asyncio.gather(*, return_exceptions=False) — first exception cancels siblings. In LangGraph this is the *default* superstep semantic [aipractitioner/scaling]. |
| Best-effort | Concatenate / summarize use cases — partial is OK | asyncio.gather(*, return_exceptions=True) then filter; or per-task try/except returning a sentinel |
| Retry-then-skip | API flakiness is the main failure | Wrap f with tenacity / backoff: 3 attempts × exponential, then sentinel |
| Quorum | Vote / ensemble — need at least K of N | asyncio.as_completed, collect K, cancel the rest |
Anti-pattern: making this decision implicitly. The single most common
production bug in this pattern is "I assumed gather would skip failures"
or "I assumed one failed branch wouldn't kill the rest" — both are wrong
defaults [aipractitioner/scaling] [newline/asyncio-llm].
| Reducer | Shape change | Cost | When to use |
|---|---|---|---|
| concatenate | [A, B, C] → "A\nB\nC" | None | Downstream LM can handle big context; you want full evidence |
| summarize (LM call) | [A, B, C] → "abc" | +1 LM call | Output must fit in a final prompt; lossy by design |
| vote / majority | [A, B, A] → A | None | Self-consistency / ensemble agreement |
| rank-top-K | [(a,0.9), (b,0.4), (c,0.7)] → [a, c] | None | Multi-query retrieval, reranking |
| merge-dedupe | overlapping lists → set | Cheap | Multi-query retrieval, multi-source enrichment |
| tree-reduce | binary combine(x, y) recursively | log₂(N) LM calls | When pairwise merging is meaningful (summary-of-summaries) |
Choose by asking: *what does the next node consume?* The reducer is just
"adapt the fan-out shape to the consumer's input shape."
Two independent timeouts:
asyncio.wait_for(call, timeout=30) — protects againstone stuck call holding a semaphore slot forever (the canonical "100 items,
99 done in 5s, the whole job blocked 5 min on item 73"). Default 30s.
asyncio.wait_for(gather(...), timeout=N · mean + 3σ)— protects against pathological N. Default 2× the optimistic wall
estimate.
In LangGraph, recursion_limit is *not* a timeout — set both, plus
node-level RetryPolicy [lc-docs/errors]. In CrewAI, max_rpm rate-
limits but does not timeout — wrap kickoff() in asyncio.wait_for.
After the reducer runs, do one cheap LLM-free check:
concatenate → assert len(merged) is within expected bounds; raise ifone branch returned a 50KB blob.
vote → log the vote distribution; if it's near-uniform, the itemsweren't actually deciding the same question — go back to Step 1.
rank-top-K → assert scores are not all identical (failure mode of abroken reranker).
These cheap checks catch the "fan-out succeeded but the answer is garbage"
bug class that no exception will surface.
Format: Trigger → Action → Output → Evidence.
asyncio.gather with bounded concurrency (Python baseline)/ OpenAI), N items, you want a list back.
sem = asyncio.Semaphore(N_CONCURRENT)
async def bounded(item):
async with sem:
return await asyncio.wait_for(f(item), timeout=PER_CALL_S)
results = await asyncio.gather(
*[bounded(i) for i in items],
return_exceptions=True, # explicit best-effort
)
ok = [r for r in results if not isinstance(r, Exception)]
the rate limiter; return_exceptions=True is the failure policy;
wait_for is the per-call timeout.
[newline/asyncio-llm], [soumendrak/semaphore],[superfastpython/gather].
asyncio.as_completed for quorum / first-Kearly-exit, "first 3 of 10 retrieve hits").
tasks = [asyncio.create_task(f(i)) for i in items]
done = []
for fut in asyncio.as_completed(tasks):
try: done.append(await fut)
except Exception: pass
if len(done) >= K:
for t in tasks: t.cancel()
break
[instructor/learn-async].Send for dynamic fan-outbranches is decided at runtime from state.
from langgraph.types import Send
def route_fanout(state):
return [Send("worker", {"item": x}) for x in state["items"]]
graph.add_conditional_edges("planner", route_fanout, ["worker"])
The worker writes to a list-typed state key with a reducer
(see OP-4). Cap concurrency at .compile() time via
compile(...).with_config({"max_concurrency": N}) or
graph.invoke(..., {"max_concurrency": N}).
Send is its own tracesegment in LangSmith.
[deepwiki/mapreduce], [mlplus/langgraph-mr],[medium/send-api]. Skill langgraph-sop OP-4 is the canonical entry.
O5)Send-spawned workers (or any parallelbranches) write to the same state key. Without a reducer, LangGraph
raises InvalidUpdateError.
summaries: Annotated[list[str], operator.add] forconcatenate, add_messages for chat, or a custom reducer for dedupe/top-K.
[cheatsheet/gotchas] "Reducers are mandatory, notoptional, for parallel execution"; [lc-docs/persistence].
kickoff_for_each (parallel crew invocations)topic).
results = crew.kickoff_for_each(inputs=[{"topic": t} for t in topics])
# async variant: kickoff_for_each_async
Rate-limit at crew creation: Crew(..., max_rpm=30). Wrap calls in
asyncio.wait_for for total wall budget.
[crewai-docs/kickoff]. Note: CrewAI's hierarchicalprocess is *not* a fan-out primitive — see DC-3.
ThreadPoolExecutor for sync LM clientsvariant), but the workload is I/O-bound.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=N_CONCURRENT) as ex:
results = list(ex.map(f, items, timeout=PER_CALL_S))
as_completed(futures, timeout=...) for failure isolation. Avoid for
CPU-bound f — use ProcessPoolExecutor or a job queue.
concurrent.futures docs.each → reduce.
MultiQueryRetriever or QueryFusionRetriever withnum_queries=N; let LlamaIndex parallelise internally. Combine with
RRF (reciprocal rank fusion) as the reducer, not naive concat.
llamaindex-sop.pairwise summarize(a, b) → c is meaningful.
layer 2 N/2 → N/4, until 1. Each layer is its own bounded fan-out.
In LangGraph, model as repeated Send rounds; in plain Python,
recursive gather.
O(N), latency O(log N).predecessor used the same shape.
gather)f *not* the gather: @tenacity.retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, max=10),
retry=retry_if_exception_type((RateLimitError, APITimeoutError)),
)
async def f_retry(item): return await f(item)
Retry inside the semaphore (slot held during backoff is intentional —
it spaces out load).
bubble up to the failure-policy layer.
produces a wrong answer.
item_id in the LangSmith /OTel / Logfire trace. Log len(items), len(ok), len(failed),
wall_time_s on the reduce step. For LangGraph, each Send is
already a distinct trace segment — name the worker node descriptively
(summarize_doc not worker).
[swarnendu/best]; LangSmith Send-tracing docs.100_docs])` against the Anthropic API. First 50 fire instantly, the rest
queue inside the client; provider returns 429 for half; tenacity retries
pile on; nothing finishes. Wall time worse than sequential.
if all fire) → TPM is the binding constraint, not RPM.
= 200_000 / (60 · 3000) ≈ 1.1` → round to 2 in-flight, *not* 10.
RPM said 50 — TPM said 2. Bind to the tighter one.
100 · 5s / 2 = 250s— exceeds SLA.
similarity to the query → drop to 20 relevant docs.
20 · 5s / 2 = 50s ✓ under SLA.return_exceptions=True and a 30s per-call wait_for. Ifone doc hangs, the others still finish.
*actual* completed-per-second goes up because no retry-storm. SLA met.
and TPM, not the looser. When the math says SLA can't be met at the
safe concurrency, reduce N before fan-out — never raise concurrency
past the real ceiling.** [newline/asyncio-llm]
Send fans out 50 workers, all write state['summaries'], get InvalidUpdateError"Send to spawn one summarizer perretrieved doc. State has summaries: list[str]. LangGraph crashes on
the first run with `InvalidUpdateError: At key 'summaries': Can receive
only one value per step. Use an Annotated key to handle multiple
values. Skill langgraph-sop flags this [cheatsheet/gotchas]`.
O5 state-reducer: parallel writes requirean explicit reducer.
summaries: Annotated[list[dict], operator.add]where each worker returns [{"doc_id": ..., "summary": ...}].
operator.add order (Python listconcat is in completion order); sort by doc_id in the reduce node.
doc_id.
merged list length and order.
regardless of completion order.
Sendworker must have a reducer. Pick operator.add for concat,
add_messages for chat, a custom function for dedupe / top-K. Order
is completion order, not Send order — sort in the reduce node if you
need stability.** [cheatsheet/gotchas], [deepwiki/mapreduce]
record). One record has malformed input → call returns 4xx. Coder
doesn't know whether to abort the whole batch, drop the bad record,
or retry it.
timeout) from *permanent* (400 bad input, 401, 422).
RateLimitError, APITimeoutError, APIConnectionError))`. Permanent
errors fall through immediately.
return_exceptions=True): permanentfailures land as exceptions in the result list, tagged with the
input id.
failed_records list alongside ok_records— never lose the failure metadata.
len(failed) / N > threshold,surface a louder failure (e.g., refuse to emit the CSV). Otherwise
emit partial + a sidecar errors file.
structured failures, not a 30-call atomic abort.
(a) is the consumer OK with holes? (b) can you classify transient
vs. permanent? (c) what % failure makes the result useless? Encode
those answers as code — not as hope.** [aipractitioner/scaling]
temperature=0.7, vote on the answer. Three say "A", two say "B".
Coder is about to return mode(answers).
Return both the mode and the agreement ratio.
confident_if agreement_ratio ≥ 0.8. Belowthat, return ("unknown", {"votes": Counter(...)}).
more) or escalate to a stronger model — never random tiebreak.
a near-tie is your early-warning signal for prompt drift.
false certainty; can choose to escalate.
Preserve the distribution as metadata; let the caller decide what
"confident enough" means. Default threshold: 0.8 agreement for high
stakes, 0.6 for low.**
summarize_doc returns ~400 tokens. Flatconcat = 80k tokens → exceeds context for the final synthesis step.
budget allows extra LM calls.
LM call summarising 80k tokens loses too much detail.
layer 2 groups of 5 → 4 quarter-summaries; layer 3 → 1 final.
Each layer is its own bounded fan-out (concurrency cap re-applies).
chain on suspicious shrinkage (one layer dropping >70% of content
is usually a prompt bug).
200 + 20 + 4 + 1 = 225 calls (vs. flat-concat'simpossible 1 call); latency O(log_10 200) ≈ 3 layers.
overflows; latency stays roughly constant in N.
answer — but each layer is itself a bounded fan-out, with its own
concurrency cap, failure policy, and verification. Treat each layer
as a separate superstep.** [langchain/map-reduce-chain]
Concrete don'ts:
asyncio.gather. "100 tasks at once" is notparallelism — it's a denial-of-service against your own dependencies.
Always wrap in a semaphore, even if you think N is small
[newline/asyncio-llm].
wait_for, a single stuckcall holds a semaphore slot forever and silently lowers your effective
concurrency to N−1, then N−2, etc.
best-effort, retry-then-skip, or quorum. Implicit defaults are wrong
half the time: gather(...) aborts on first failure (surprise to
most), but LangGraph parallel branches *also* abort the entire
superstep — not the same level but the same shape of surprise
[aipractitioner/scaling].
Send for fixed-cardinality work. If N is always 3,static parallel edges are simpler, more readable, and easier to
trace [aipractitioner/scaling]. Send is for runtime-variable N.
reducer.** This is the canonical InvalidUpdateError
(cross-link skill O5 state-reducer) [cheatsheet/gotchas].
the goal.** Self-consistency requires *diversity* — sample with
temperature > 0, vary the prompt, or vary the model. Same prompt
+ temperature 0 + N parallel calls = N identical answers and a
meaningless vote.
f. asyncio.gather andThreadPoolExecutor parallelise I/O, not CPU. CPU-bound f
(heavy local tokenisation, image preprocessing) needs
ProcessPoolExecutor or a job queue.
flat-concat dressed up; the context-overflow failure mode is
identical. See DC-5: tree-reduce when N is large.
max_concurrency. LangGraph,CrewAI, and LlamaIndex all default to "as many as you ask for". Set
the cap explicitly, in code, near the fan-out.
A 50ms function fanned 100-wide over a 200ms-RTT network finishes in
~250ms regardless of N — the win evaporates. Profile first.
Hard boundaries — when this skill is the wrong tool:
search). Use a chain or a DAG.
map-reduce. Use it.
the previous call's writes). That's a state machine, not map-reduce.
| Framework | Primitive | Concurrency cap | Failure default | Reduce mechanism |
|---|---|---|---|---|
| asyncio | gather(*coros) / as_completed | Semaphore(N) | return_exceptions=False aborts on first | Post-gather list comprehension; manual |
| threads | ThreadPoolExecutor(max_workers=N) | max_workers arg | Per-future result() raises | as_completed; manual aggregation |
| LangGraph | Send("worker", state) from conditional edge | config={"max_concurrency": N} | Atomic superstep — one fails, all discarded [aipractitioner/scaling] | State reducer (operator.add, add_messages, custom) — mandatory for parallel writes [cheatsheet/gotchas] |
| CrewAI | crew.kickoff_for_each(inputs=[...]), Flow @listen | Crew(max_rpm=N) | Per-kickoff; not atomic | Caller-side merge; or downstream task with context=[...] |
| LlamaIndex | MultiQueryRetriever, QueryFusionRetriever, async_aggregate=True on QueryEngine | Internal; configurable in retriever | Per-retriever | RRF / dedupe / score-merge built into the retriever |
| OpenAI/Anthropic batch APIs | Native batch endpoint | Provider-managed | Per-item | One sync API call returns the list |
Quick chooser:
Send + reducer (OP-3 + OP-4).kickoff_for_each (OP-5); for conditionalrouting → CrewAI Flow with parallel @listen branches.
(1 call), not fan-out.
The general rule: prefer the lowest abstraction that lets you set
concurrency cap, per-call timeout, and failure policy
explicitly. If a framework hides any of the three, wrap it.
import asyncio
from typing import Awaitable, Callable, TypeVar
T = TypeVar("T"); R = TypeVar("R")
async def map_reduce(
items: list[T],
f: Callable[[T], Awaitable[R]],
reduce: Callable[[list[R]], R],
*,
n_concurrent: int = 5,
per_call_timeout_s: float = 30.0,
fail_policy: str = "best_effort", # "abort" | "best_effort"
) -> R:
sem = asyncio.Semaphore(n_concurrent)
async def bounded(x: T):
async with sem:
return await asyncio.wait_for(f(x), timeout=per_call_timeout_s)
raw = await asyncio.gather(
*(bounded(x) for x in items),
return_exceptions=(fail_policy == "best_effort"),
)
if fail_policy == "best_effort":
ok = [r for r in raw if not isinstance(r, BaseException)]
failed = [r for r in raw if isinstance(r, BaseException)]
if not ok:
raise RuntimeError(f"all {len(items)} branches failed: {failed[:3]}")
return reduce(ok)
return reduce(raw)
Use this when there's no graph framework already in the codebase. Replace
reduce with lambda xs: "\n".join(xs) (concat), Counter(xs).most_common(1)[0][0]
(vote), or a follow-up LM call (summarize). The same three knobs —
n_concurrent, per_call_timeout_s, fail_policy — show up in every
framework variant; this snippet just makes them explicit.
Send referenceimport operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
class State(TypedDict):
items: list[str]
summaries: Annotated[list[dict], operator.add] # reducer is mandatory
class WorkerState(TypedDict):
item: str
def fanout(state: State):
return [Send("worker", {"item": x}) for x in state["items"]]
async def worker(s: WorkerState):
# one LM call per item; state writes are merged via the reducer above
text = await llm.ainvoke(f"Summarize: {s['item']}")
return {"summaries": [{"item": s["item"], "summary": text.content}]}
def reduce_node(state: State):
ordered = sorted(state["summaries"], key=lambda d: state["items"].index(d["item"]))
return {"final": "\n".join(d["summary"] for d in ordered)}
g = StateGraph(State)
g.add_node("worker", worker)
g.add_node("reduce", reduce_node)
g.add_conditional_edges(START, fanout, ["worker"])
g.add_edge("worker", "reduce")
g.add_edge("reduce", END)
app = g.compile()
result = await app.ainvoke({"items": docs}, {"max_concurrency": 5})
Cross-references: skill langgraph-sop (OP-4 Send, OP-3 reducers),
skill O5 state-reducer (deeper on parallel-write semantics).
[deepwiki/mapreduce] = deepwiki.com/langchain-ai/langchain-academy/7.1-map-reduce-pattern[medium/send-api] = medium.com/@vishy2k5/langgraph-send-api-7aaab56bc6b8[mlplus/langgraph-mr] = machinelearningplus.com/gen-ai/langgraph-map-reduce-parallel-execution/[aipractitioner/scaling] = aipractitioner.substack.com/p/scaling-langgraph-agents-parallelization[cheatsheet/gotchas] = sumanmichael.github.io/langgraph-cheatsheet/cheatsheet/faqs-gotchas/[lc-docs/persistence] = docs.langchain.com/oss/python/langgraph/persistence[lc-docs/errors] = docs.langchain.com/oss/python/langgraph/errors[newline/asyncio-llm] = newline.co/@zaoyang/python-asyncio-for-llm-concurrency-best-practices[soumendrak/semaphore] = soumendrak.com/blog/semaphores-python-async-programming/[superfastpython/gather] = superfastpython.com/asyncio-gather-limit-concurrency/[instructor/learn-async] = python.useinstructor.com/blog/2023/11/13/learn-async/[tianpan/structured-concurrency] = tianpan.co/blog/2026-04-09-structured-concurrency-ai-pipelines-parallel-tool-calls[crewai-docs/kickoff] = docs.crewai.com/en/concepts/crews (kickoff_for_each)[swarnendu/best] = swarnendu.de/blog/langgraph-best-practices/[langchain/map-reduce-chain] = python.langchain.com/docs/versions/migrating_chains/map_reduce_chain/Take agentsope/agentsop-map-reduce-fanout from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.