- Book: AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Most "should I use sub-agents" debates skip the only four signals that matter. Cost, traceability, recovery semantics, and parallelism. The answer is rarely "it depends". Once you measure those, you usually score a 0, a 1, or a 4. A 2 means you're undecided because you haven't looked hard enough.
This post is a scorecard. Four signals, 0-1 point each, four concrete tests. You finish reading with a number between 0 and 4 and a clear next step.
The framing most posts get wrong
Two claims keep showing up in agent-architecture writeups and both are wrong.
The first is "sub-agents are more capable." They're not. A sub-agent is the same model with a smaller context window and a constrained tool set. If anything, it's less capable than the parent. Capability isn't why you split.
The second is "monoliths are simpler." Simpler to draw on a whiteboard. Not simpler to debug at 3am when a single 47-step trace is the whole incident. No isolation between the search step that failed and the writing step that hallucinated around it.
Topology isn't the question. The question is whether your workload has properties that require fan-out. Score them. Then build.
Signal 1: Cost variance per task
The test: Sample 200 real tasks from your traces. For each one, ask: "what's the cheapest model that handles this correctly?" If the answer ranges from Haiku-level to Opus-level across the sample, you have cost variance. If 95% of tasks need Haiku and 5% need Opus, a monolith pays Opus prices on every task.
Here's the rough check you can run on a captured eval set:
from collections import Counter
def score_cost_variance(tasks, judge):
"""tasks: list of (input, expected_output)
judge: callable(model_name, input) -> output
returns 1 if cost variance signal is present, else 0
"""
needed = []
for task_input, expected in tasks:
# try cheapest first, escalate until correct
for model in ["haiku", "sonnet", "opus"]:
out = judge(model, task_input)
if matches(out, expected):
needed.append(model)
break
else:
needed.append("opus")
dist = Counter(needed)
total = sum(dist.values())
cheap_share = dist["haiku"] / total
# signal fires when cheap models cover most tasks
# but a non-trivial minority needs the big one
return 1 if cheap_share >= 0.6 and dist["opus"] >= 0.05 else 0
You're looking for a bimodal distribution. Most tasks are easy, a small fraction is hard. In a monolithic agent you pick one model for the whole thing. That "one model" has to be the one that handles the hardest 5%, which means you overpay on the 95%.
The pattern that wins: a cheap router or first-pass classifier sends each task to the right specialist. The router runs Haiku. The "extract structured data from a PDF" specialist runs Haiku too. The "reconcile contradictions across three sources and propose a resolution" specialist runs Opus. Total cost drops 3-6x against the monolith depending on how skewed the distribution is.
Score 1 if your sample shows: cheap-model coverage above 60%, expensive-model coverage above 5%. Otherwise score 0.
Signal 2: Traceability and isolation
The test: Pick three real failure incidents from the last quarter. For each one, ask: "could I have debugged this by reading a single trace, or would I have needed isolation between sub-workloads to even ask the right question?"
The cases where isolation matters aren't theoretical. They show up in three concrete shapes:
- Tenant boundaries. Agent processes tasks from Customer A and Customer B in the same Python process. Customer A's data ends up in a tool call meant for Customer B. The trace shows both. Auditing this on a monolith means parsing prompts to figure out which tenant a given tool call belonged to. Painful, ad-hoc, sometimes impossible after the fact.
- Compliance / data retention. Customer A is on a 7-day retention plan. Customer B is on 90 days. If you store the agent's full trace in one log stream, you either delete everyone's data at 7 days or violate Customer B's contract. Sub-agent-per-tenant gives you natural trace partitioning.
- PII vs non-PII subtasks. The "summarize this support email" step touches PII. The "look up an internal SKU" step doesn't. Splitting them lets you route the PII step to an EU-region model and the SKU lookup to a US-region cheap model. On a monolith, the whole trace is PII because part of it is.
# trace-level question you should be able to answer
def can_isolate_subtrace(trace_id, predicate):
"""
predicate: callable(span) -> bool
e.g. "is this span on behalf of tenant=acme?"
On a monolith, this returns False for most useful predicates
because tenancy/PII/region is implicit in the prompt content,
not the span structure.
"""
spans = load_trace(trace_id)
matching = [s for s in spans if predicate(s)]
return all(s.parent_id in {x.span_id for x in matching} or s == matching[0]
for s in matching)
If can_isolate_subtrace returns False on questions your compliance team actually asks, you have a traceability problem that sub-agents solve and a monolith doesn't.
Score 1 if any of: multi-tenant with regulated data, mixed retention requirements, mixed-region routing, or compliance team has ever asked you "show me only the part of this run that touched X." Otherwise score 0.
Signal 3: Recovery semantics on partial failure
This is the underrated one. Cost gets the headlines. Recovery is what bites you in production at 2am.
The test: Take your workload's worst-case input. Imagine it's a five-document data-extraction task and one of the five documents is malformed. What does your agent do?
A monolith has two options, both bad:
- Fail the whole task. User submitted 5 docs, got 0 results back. The 4 good docs are wasted compute. The user retries the whole batch. Same monolithic cost, probably same failure.
- Continue past the failure. Inject "couldn't parse doc 3" into the conversation and hope the agent does something sensible. In practice it either silently drops doc 3 with no audit trail, or it starts to hallucinate around the gap because the prompt context is now confusing.
Sub-agents give you a third option: per-task isolation with per-task retries.
async def extract_all(documents):
# each doc gets its own sub-agent invocation
tasks = [extract_one_with_retries(doc) for doc in documents]
results = await asyncio.gather(*tasks, return_exceptions=True)
extracted = []
failed = []
for doc, result in zip(documents, results):
if isinstance(result, Exception):
failed.append({"doc_id": doc.id, "error": str(result)})
else:
extracted.append(result)
# caller gets a structured "3 of 5 succeeded" response
return {
"extracted": extracted,
"failed": failed,
"success_rate": len(extracted) / len(documents),
}
async def extract_one_with_retries(doc, max_retries=2):
for attempt in range(max_retries + 1):
try:
return await sub_agent_extract(doc)
except RetryableError:
if attempt == max_retries:
raise
await asyncio.sleep(2 ** attempt)
Now the user gets back "3 of 5 documents parsed, here's what we got, here's what failed." That's a recoverable state. The 2 failures get re-queued, the 3 successes are already in the database. Each sub-agent invocation is the unit of retry. No prompt-stuffing the parent agent with failure context it doesn't know how to handle.
The signal is strongest when your tasks are naturally enumerable: a list of files, a batch of API calls, a set of records to update. If your workload is one indivisible reasoning chain ("plan a trip across these constraints"), this signal won't fire and that's fine.
Score 1 if your workload involves N independent sub-tasks where partial success is a useful outcome. Otherwise score 0.
Signal 4: Parallelism opportunity
The test: Draw the data dependency graph of your workload. If subtask B needs subtask A's output to even start, they're sequential. If B and C can both run as soon as A finishes, you have a fan-out point.
This is the signal people fake the hardest. They wrap a sequential chain in asyncio.gather and call it parallel. It isn't. Each step is waiting on the previous step's output, the await just hides that.
Here's the actual test:
def parallelism_signal(workload_graph):
"""
workload_graph: list of (subtask_id, depends_on=[ids])
returns 1 if there's a node where 3+ children can run concurrently
"""
from collections import defaultdict
children = defaultdict(list)
for task, deps in workload_graph:
for dep in deps:
children[dep].append(task)
max_fanout = max((len(c) for c in children.values()), default=0)
# also check: are leaves cheap enough that parallelism matters?
# if every leaf is 200ms, parallelizing 4 of them saves 600ms total
# if every leaf is 8s, parallelizing 4 saves 24s, worth it
return 1 if max_fanout >= 3 else 0
A real fan-out example: an agent that researches a company. Subtasks include "scrape the website," "look up funding history," "summarize recent news," and "find competitor list." None of those depend on each other. All four can run in parallel. Wall-clock time drops from ~24s (sequential) to ~7s (the slowest of the four).
A fake fan-out: an agent that writes a blog post. "Generate outline" → "write section 1" → "write section 2" → "edit." You cannot start section 2 without section 1 because section 2 references it. Wrapping the section-writing in gather produces wrong output. Don't do this.
Score 1 if your graph has a node with 3+ independent children whose individual cost is above 1 second. Otherwise score 0.
The 4-signal score
Add the four signals. You get a number between 0 and 4. Here's the decision rule:
- Score 0 or 1: Monolith. A single agent with the right model. Tool use, looping, the works. Don't over-engineer. The complexity of orchestrating sub-agents (state machines, partial-failure handling, fan-out coordination) costs more than the marginal benefit at this score.
- Score 2: Lean monolith. Stay monolithic but bake in the cheap optimization the two signals you scored on imply. If cost variance fired, add a cheap-model router as a function call, not a sub-agent. If parallelism fired, run your tool calls in parallel where the graph permits. You're not splitting the agent. You're adding two utilities around it.
- Score 3: Design fan-out. Now you're committing to a multi-agent topology. Pick the one that maps cleanest to which three signals fired. Cost plus parallelism plus recovery typically means coordinator-plus-workers. Cost plus traceability plus parallelism means tenant-sharded agents. Don't go to score 4 first.
- Score 4: Fan-out is the only sane choice. You have cost variance, you have isolation needs, you have partial-failure semantics, and you have parallel subtasks. A monolith costs more, debugs worse, fails harder, and runs slower. Build the fan-out version.
The honest middle ground is score 2. Most teams sit there and the right move is not to jump straight to a coordinator topology. Add the two specific affordances the two signals point at instead.
What the score doesn't tell you
The score measures workload signals. It doesn't measure operational complexity, and operational complexity is real.
Sub-agents bring state machines you have to maintain. Retry coordination across N workers means idempotency keys, dead-letter queues, and stuck-task detection. Trace stitching across processes needs structured span propagation. Cost tracking per sub-agent means a new dimension in your billing pipeline.
A team of three engineers shipping at v1 with score 3 will probably ship faster with a monolith, hit the limits in three months, then split when the pain is concrete. A team of fifteen at v3 with score 3 should split now.
The score is a workload property. The decision is a workload-plus-team property.
Migration path when you score 3 or 4
You don't rewrite a monolith into a fan-out in one PR. The migration path that works:
- Extract the highest cost-variance subtask first. This is the one where the savings are obvious and measurable. Run it as a separate agent invocation, called from the monolith. Same model selection. Same tools. Different process boundary.
- Run alongside, measure. Compare cost per task, latency, success rate against the monolithic baseline for two weeks. If the new sub-agent is worse, you have a bug, not a bad architecture. Fix it before extracting another.
- Extract the next subtask the score points at. If recovery fired, that's the per-document-extraction step. If parallelism fired, that's the fan-out point.
- Stop when the score drops below 3. Each extraction lowers the residual score because the remaining monolith is doing less. At some point the rest doesn't justify another split.
The trap is extracting the wrong subtask first. Usually the one that's easiest to extract, not the one with the highest signal. Easy extractions don't move metrics, the team loses faith in the migration, and you end up with a half-split system that has all the operational cost of fan-out and none of the wins.
What's your team's score, and which signal was the surprise? Drop it in the comments.
If this was useful
The AI Agents Pocket Guide walks through the topology decision in more depth: coordinator-worker patterns, tenant-sharded layouts, partial-failure recovery state machines, and the operational pieces (idempotency, trace propagation, retry coordination) that this post deliberately skipped. If you scored 3 or 4 on the scorecard, the chapter on migration paths from monolithic agents is where to go next.

Top comments (0)