The 6 AI Agent Design Patterns Every Engineer Should Know (And When Not to Use Them)
Last Updated on September 22, 2026 by Editorial Team
Author(s): Cikal Merdeka
Originally published on Towards AI.
The 6 AI Agent Design Patterns Every Engineer Should Know (And When Not to Use Them)

If you have ever watched an agent burn five dollars of API credits trying to fix its own mistake, you already know the feeling. The demo was flawless. Three agents, smooth handoffs, polished output. Then you deployed it, and within a week your carefully architected multi-agent system was hallucinating facts no single agent would have produced alone, looping on the same failed tool call, and quietly spending at triple the rate you projected.
This is the love-hate relationship developers have with sub-agents and multi-agent systems. They are powerful in theory. In production, they often introduce heavy coordination overhead, unpredictable cascading errors, and token bills that make your finance team nervous. The industry consensus that has emerged is blunt: start with a single agent using great tools, and only scale to sub-agents when you genuinely hit a wall.
But before you can decide when to scale, you need a shared vocabulary. When engineers talk about “agent design,” they are really talking about a small set of named, reusable architectural patterns. Each one defines how an LLM thinks, acts, and collaborates. Each one has a characteristic failure mode. And the difference between a system that survives production and one that becomes archaeology at 3am is almost always pattern selection, not model choice.
What Is an Agent Design Pattern?
An agent design pattern is a named, reusable control structure that defines how an LLM reasons, acts, delegates, and recovers from failure.
The pattern is the skeleton. You can put the same model into six different skeletons and get six wildly different levels of reliability, latency, and cost. Anthropic’s widely cited guidance on building effective agents draws the distinction cleanly: a workflow is a system where LLMs and tools are orchestrated through predefined code paths, while an agent is a system where the LLM dynamically directs its own process and tool usage. Most production systems labeled “agents” are actually workflows, and Anthropic’s advice is blunt: if you can solve it with a deterministic pipeline, do that.
Tool use is the building block beneath all of these patterns. The patterns are the buildings. ReAct decides when to call a tool inside a loop. A planner decides the sequence of tool calls up front. A critic adds a second pass over earlier output. An orchestrator treats entire worker agents as tools. When you compare patterns, you are really comparing how each one orchestrates the same fundamental action: a tool call.
Here is the map of the six core patterns:

Let us walk through each one, and more importantly, each one’s failure mode.
1. Tool Use: The Foundation Everything Sits On

Tool use is the simplest pattern and the one every other pattern is built from. The agent receives a request, decides whether it needs external capability, and calls an API, database, or code interpreter with structured arguments.
The counterintuitive lesson from teams shipping agents at scale is that tool design determines reliability far more than model choice. Anthropic has stated they spent more time on tool descriptions and schemas than on the main system prompt. When researchers instrumented ReAct-style agents with 200 tasks, they found 90.8 percent of retries were wasted on unrecoverable errors like hallucinated tool names, and GPT-4 class models hallucinated tool-call arguments at roughly a 28 percent rate. The tools were not failing. The contract between the model and the tools was.
The fix is rarely a better model. It is deterministic structure:
- Validate arguments with strict schemas. Pydantic validation on tool inputs catches malformed calls immediately and returns structured feedback the model can actually correct from, instead of a raw Python traceback.
- Route tools in code, not in the prompt. When tool routing is a Python dictionary lookup rather than an LLM decision, step variance drops dramatically and tool-name hallucinations disappear entirely.
- Cap the tool count per agent. A single agent empirically degrades above 10 to 12 tools because it spends more compute selecting tools than using them.
If your agent is unreliable, audit your tool schemas before you touch anything else. This one lever moves the needle more than any framework migration you will ever do.
"""
Tool Use: free-form LLM tool calls vs strict schemas + code-driven routing.
The key production lesson: tool design determines reliability far more than
model choice. Instrumented ReAct-style agents wasted 90.8% of retries on
unrecoverable errors like hallucinated tool names, and GPT-4 class models
hallucinate tool-call arguments at roughly a 28% rate.
The BAD version routes tools through free-form LLM strings and accepts
unguarded arguments. The GOOD version:
1. Validates arguments with strict Pydantic schemas -> structured error
feedback the model can correct from.
2. Routes tools in CODE (a dict lookup), not in the prompt -> eliminates
tool-name hallucinations and drops step variance dramatically.
3. Classifies errors as retryable vs non-retryable -> saves retry budget.
Run: python tool_use_schemas.py
"""
from pydantic import BaseModel, Field, ValidationError
# ---------------------------------------------------------------------------
# Tool registry
# ---------------------------------------------------------------------------
class SearchArgs(BaseModel):
query: str = Field(min_length=3)
limit: int = Field(default=5, ge=1, le=20)
class CalcArgs(BaseModel):
expression: str = Field(pattern=r"^[\d\s+\-*/().]+$") # numbers/operators only
def search_tool(args: SearchArgs) -> str:
return f"[results for: {args.query} (top {args.limit})]"
def calculator_tool(args: CalcArgs) -> str:
return str(eval(args.expression, {"__builtins__": {}})) # sandboxed toy
# GOOD: tool registry with BOTH a code router and its schema.
TOOL_REGISTRY = {
"search": (search_tool, SearchArgs),
"calculator": (calculator_tool, CalcArgs),
}
# ---------------------------------------------------------------------------
# BAD: the LLM picks the tool name and raw args as free text.
# ---------------------------------------------------------------------------
def bad_tool_call(llm_output: str):
"""No schema, no routing table, no error taxonomy. Every defect the
model produces flows straight into the tool."""
# llm_output might be "calclator: 2+2" <- hallucinated tool name
name, _, raw_args = llm_output.partition(":")
for known in TOOL_REGISTRY:
if name.strip().lower().startswith(known[:4]): # fuzzy guess
name = known
break
else:
raise RuntimeError(f"unknown tool '{name}' -> naive retry burns budget")
return TOOL_REGISTRY[name][0](raw_args) # raw string into the tool
# ---------------------------------------------------------------------------
# GOOD: strict validation + deterministic routing + error taxonomy.
# ---------------------------------------------------------------------------
class ToolError(Exception):
def __init__(self, message: str, retryable: bool):
super().__init__(message)
self.retryable = retryable # retry timeouts, never retry bad names
def safe_call(name: str, raw_args: dict) -> str:
# 1. Deterministic routing: dict lookup, not LLM string matching.
if name not in TOOL_REGISTRY:
# 2. Non-retryable: the model must fix its output, retrying the same
# hallucinated name wastes budget (90.8% of observed retries).
raise ToolError(f"tool '{name}' does not exist. valid: {list(TOOL_REGISTRY)}", retryable=False)
fn, schema = TOOL_REGISTRY[name]
try:
# 3. Strict schema: catches malformed args BEFORE execution and returns
# field-level feedback the model can actually correct from.
args = schema.model_validate(raw_args)
except ValidationError as e:
errors = "; ".join(f"{'.'.join(map(str, x['loc']))}: {x['msg']}" for x in e.errors())
raise ToolError(f"bad arguments: {errors}", retryable=False)
return fn(args)
def agent_turn(llm_tool_name: str, llm_args: dict, retry_budget: int = 2) -> str:
"""One agent turn with a retry loop that only spends budget on
retryable errors (timeouts, rate limits) -- never on the model's own
malformed output."""
for attempt in range(1, retry_budget + 2):
try:
return safe_call(llm_tool_name, llm_args)
except ToolError as e:
if not e.retryable:
# Feed the error back to the model for self-correction,
# retrying blindly would be throwing money at a typo.
return f"ERROR (sent to model for correction): {e}"
print(f"[attempt {attempt}] retryable failure: {e}")
return "gave up: transient failure after retry budget"
# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("=== BAD: free-form string routing ===")
try:
bad_tool_call("calclator: 2+2") # hallucinated tool name
except Exception as e:
print(f" {type(e).__name__}: {e}")
print("\n=== GOOD: strict schemas + dict routing + error taxonomy ===")
print(f" valid call -> {agent_turn('calculator', {'expression': '(18 * 4) / 3'})}")
print(f" hallucinated name -> {agent_turn('web_search', {'query': 'ai agents'})}")
print(f" malformed args -> {agent_turn('search', {'query': 'ab', 'limit': 99})}")
# Numbers to remember:
# - 90.8% of retries in instrumented ReAct agents were wasted on
# unrecoverable errors (mostly hallucinated tool names).
# A retry/retryable split, like the one above, fixes the waste.
# - Deterministic routing + schema validation achieves ~3x lower step
# variance and near-zero wasted retries vs naive ReAct setups.
The snippet contrasts the two contracts. The bad version routes tools through free-form LLM strings and fuzzy-matches hallucinated tool names before dumping raw, unvalidated arguments straight into the tool. The good version routes through a dictionary lookup, validates every argument against a strict Pydantic schema, splits errors into retryable versus non-retryable, and only spends retry budget on transient failures. Read the demo output with the 90.8 percent wasted-retry statistic in mind: most of that waste is avoidable with these three lines of structure.
2. ReAct: The Default Loop

ReAct, introduced by Yao et al. in 2022, is the pattern most people mean when they say “agent.” The model interleaves reasoning with action: it thinks about what to do, takes one action, observes the result, thinks again, and repeats until the goal is met.
ReAct is the simplest pattern that still earns the word agent, and for tasks under roughly eight steps, it is usually the right call.
Its strength is flexibility. On dynamic, open-ended problems like support triage, multi-hop research, or troubleshooting flows, the next question genuinely depends on the last answer, and no plan written in advance can anticipate every branch. The visible Thought-Action-Observation trail also makes the reasoning grounded and auditable in a way that latent chain-of-thought is not.
Its weakness is drift. Because every step depends on the previous one, a small early mistake compounds. By step twelve, the agent may be confidently solving the wrong problem. The scratchpad grows, earlier sub-goals scroll out of effective context, and latency climbs with every sequential model call.
The math here deserves its own attention, because it explains most agent production incidents.
The Multiplication Trap
Per-step reliability does not add across steps. It multiplies. An agent that is 95 percent reliable per step is only about 60 percent reliable across ten steps, because 0.95 to the tenth power is roughly 0.60. At a 1 percent per-step error rate, a 20-step workflow accumulates into a 63 percent failure probability. Production analyses of multi-agent systems found something even worse: when agents share state, errors amplify rather than average out. A single agent with a 5 percent error rate becomes a 52 percent system failure rate with 10 agents sharing state, and real production measurements where agents also retry and corrupt state push the amplification factor higher still.
This is why the fix for a stalling ReAct agent is almost never a bigger model. It is architecture that bounds steps, truncates observations, and validates intermediate results. Every ReAct loop you ship should have a hard iteration limit, a per-task token budget, and observation truncation. An unconstrained agent loop is a denial-of-service vulnerability against your own cloud bill.

"""
ReAct Agent Loop: naive vs production-grade.
The BAD version has no step limit, feeds raw unbounded observations back into
context, and trusts the model to stop on its own. The GOOD version caps
iterations, truncates observations, logs every step, and exits with a
structured result.
Why the guardrails matter: per-step reliability MULTIPLIES across steps.
A 95% reliable step is only ~60% reliable across 10 steps (0.95**10 = 0.60).
Guardrails do not make the model smarter; they stop small errors from
compounding into a runaway loop.
Run: python react_agent_loop.py
"""
from dataclasses import dataclass, field
from typing import Callable
# ---------------------------------------------------------------------------
# Fake tool environment so the example is fully runnable without API keys.
# In a real system, `execute_tool` would call an LLM + actual tools.
# ---------------------------------------------------------------------------
def fake_tool_call(name: str, args: dict) -> str:
"""Pretend tool executor. Returns a large JSON-like payload for search."""
if name == "search":
# Simulates a tool that returns a 40KB JSON blob.
return '{"results": [' + '{"title": "item", "snippet": "' + "x" * 900 + '"},' * 40 + ']}'
if name == "calculator":
return str(eval(args["expression"])) # toy only, never do this in prod
return f"Unknown tool: {name}"
@dataclass
class AgentResult:
answer: str
steps_used: int
hit_step_limit: bool
trace: list = field(default_factory=list)
# ---------------------------------------------------------------------------
# BAD: the naive loop most tutorials show you.
# ---------------------------------------------------------------------------
def naive_react_loop(goal: str, max_llm_calls: int = 10_000) -> str:
"""
Problems:
1. No real step limit (max_llm_calls=10000 is not a limit, it is a wish).
2. Raw observation (40KB of JSON) is appended to context every turn.
By step 10 the context is 400KB and every subsequent call costs 5-15x
more than expected (long-context bloating).
3. No logging: when it fails, you are doing archaeology.
"""
history = []
for _ in range(max_llm_calls):
response = f"llm(history={history}, goal={goal})" # placeholder LLM call
observation = fake_tool_call("search", {}) # unbounded payload
history.append(observation) # <-- context bloat lives here
return "hopefully done" # no structured exit
# ---------------------------------------------------------------------------
# GOOD: the production-grade loop.
# ---------------------------------------------------------------------------
MAX_STEPS = 8 # hard iteration cap
MAX_OBS_CHARS = 500 # truncate observations before they hit context
def truncate_observation(obs: str, max_chars: int = MAX_OBS_CHARS) -> str:
"""Keep the head and tail of an observation, drop the middle bloat."""
if len(obs) <= max_chars:
return obs
return f"{obs[:max_chars // 2]} ...[truncated {len(obs) - max_chars} chars]... {obs[-max_chars // 2:]}"
def production_react_loop(goal: str, execute_tool: Callable = fake_tool_call) -> AgentResult:
trace = []
for step in range(1, MAX_STEPS + 1):
# 1. THINK: in a real system, call the LLM with (goal, truncated trace).
thought = f"step {step}: decide next action for: {goal}"
# 2. ACT: call a tool. Route tools in code when you can, not via
# free-form LLM strings (this removes tool-name hallucinations).
raw_obs = execute_tool("search", {})
# 3. OBSERVE: truncate BEFORE it enters context. This single line
# prevents the 5-15x long-context cost blowup.
observation = truncate_observation(raw_obs)
trace.append({"step": step, "thought": thought, "observation": observation})
print(f"[step {step:02d}] obs_len={len(observation)} (truncated)")
# 4. CHECK EXIT: a real loop asks the model "is the goal met?" with
# structured output. Here we simulate a done-condition at step 4.
if step >= 4:
return AgentResult(
answer="structured answer with citations",
steps_used=step,
hit_step_limit=False,
trace=trace,
)
# 5. HARD EXIT: if we get here the agent is stuck. Fail LOUDLY with a
# structured error instead of silently looping forever.
print("WARNING: hit MAX_STEPS, escalating to human/fallback")
return AgentResult(answer="escalated", steps_used=MAX_STEPS, hit_step_limit=True, trace=trace)
if __name__ == "__main__":
# The compounding math that justifies every guardrail above:
for p in (0.99, 0.95, 0.90):
for n in (5, 10, 20):
print(f"step_success={p:.2f}, steps={n:2d} -> task_success={p ** n:.1%}")
print()
result = production_react_loop("Research the top 3 competitors")
print(f"\nDone in {result.steps_used} steps. Hit limit: {result.hit_step_limit}")
The snippet above shows the contrast between a naive ReAct loop and a production-grade one. The bad version has no step limit, feeds raw 40KB JSON observations back into context, and trusts the model to stop on its own. The good version caps iterations, truncates observations, logs every step, and exits with a structured result. Read it with the multiplication math in mind: the guardrails exist because 0.95 is not good enough at step ten.
3. Reflection: The Quality Gate

Reflection adds a critic step. After the agent produces a draft, a second pass, either the same model with a different prompt or a separate evaluator, scores the draft against the goal and either approves it or sends it back with corrections. The Reflexion work by Shinn et al. formalized this and reported strong gains on reasoning and coding benchmarks, lifting GPT-4’s HumanEval pass@1 from 80 percent to 91 percent.
The production reality is sharper than the benchmark numbers suggest: reflection only works when the critique is grounded in something external. A code agent should reflect against a real test run. An extraction agent should reflect against a JSON schema validator. A research agent should reflect against retrieved sources. When the critic is just the model judging its own output, it can confidently approve a wrong answer. Practitioners have a name for this: reflection theater. It adds cost without adding accuracy.
There is also an economic filter. Reflection reliably earns its cost on high-stakes, low-volume outputs: a legal summary, a financial reconciliation, code that must pass a test suite. It rarely pays off on cheap, high-volume calls where a wrong answer is easy to retry. And every reflection pass multiplies latency and tokens, so a task that took one call now takes three or four.
Two hard rules prevent reflection from becoming a cost center. First, always wire the critic to a real validator when one exists, because a verifiable signal beats model self-judgment every time. Second, cap the loop at two to three iterations with a token budget per task. Teams have found agents stuck revising the same broken output, burning dollars of spend on a problem a human would have fixed in one pass.
"""
Reflection Loop: grounded in a real validator, with a hard iteration cap.
The BAD version asks the model to judge its own output ("does this look
right?"). That is reflection theater: it adds cost without adding accuracy,
because a model that confidently produced wrong output will confidently
approve it.
The GOOD version:
1. Grounds the critique in an EXTERNAL validator (Pydantic schema here; in
production it could be a test suite, a SQL query, or a compiler).
2. Passes the specific validation errors back to the generator, so each
revision fixes a known defect instead of vibes.
3. Caps the loop at 2-3 iterations with a token budget. Uncapped reflection
loops have been observed burning dollars revising the same broken output.
Run: python reflection_loop.py
"""
from pydantic import BaseModel, Field, ValidationError
# ---------------------------------------------------------------------------
# The structured output we need from the agent.
# ---------------------------------------------------------------------------
class CompetitorReport(BaseModel):
name: str = Field(min_length=2)
employees: int = Field(gt=0)
strengths: list[str] = Field(min_length=1)
funding_musd: float = Field(ge=0)
# ---------------------------------------------------------------------------
# BAD: self-judged reflection. The critic is the same model with no oracle.
# ---------------------------------------------------------------------------
def bad_reflection(draft: str) -> str:
"""
The critique has no ground truth to check against, so it can only
produce an opinion. If the draft hallucinated an employee count of
"twelve thousand", the critic has no way to catch it.
"""
critique = f"llm_critique(draft={draft})" # opinion, not verification
return critique # revision follows an opinion -> reflection theater
# ---------------------------------------------------------------------------
# GOOD: validator-grounded reflection with a hard cap.
# ---------------------------------------------------------------------------
MAX_REFLECTIONS = 3 # production rule: cap the loop, budget the tokens
def generate_draft(prompt: str, feedback: str | None) -> str:
"""
Placeholder for a real LLM call. First pass gets the task; revision
passes get the SPECIFIC validation errors, not a vague "try better".
"""
if feedback is None:
# Simulate a first draft that fails validation (missing field,
# negative number) -- a very common LLM failure mode.
return '{"name": "Acme", "employees": -50, "strengths": []}'
# Simulate a corrected second pass after seeing real error messages.
return ('{"name": "Acme Corp", "employees": 1200, '
'"strengths": ["distribution"], "funding_musd": 85.0}')
def reflect_with_validator(max_iterations: int = MAX_REFLECTIONS):
feedback = None
for attempt in range(1, max_iterations + 1):
draft = generate_draft("Write a competitor report", feedback)
try:
# THE CRITIC IS THE SCHEMA, not another model opinion.
report = CompetitorReport.model_validate_json(draft)
print(f"[attempt {attempt}] PASSED validation")
return report
except ValidationError as e:
# Grounded feedback: the exact defects, machine-readable.
errors = [f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}"
for err in e.errors()]
feedback = "; ".join(errors)
print(f"[attempt {attempt}] FAILED -> {feedback}")
# Hard exit: escalate rather than loop forever.
print(f"FAILED after {max_iterations} attempts, escalating to human review")
return None
if __name__ == "__main__":
print("=== BAD: self-judged critique (no oracle) ===")
bad_reflection('{"name": "Acme", "employees": "twelve thousand"}')
print(" -> critic approved based on vibes. Nothing was verified.\n")
print("=== GOOD: validator-grounded critique ===")
result = reflect_with_validator()
print(f"\nFinal report: {result.model_dump_json(indent=2)}")
# Key numbers to remember:
# - Reflexion (Shinn et al., 2023) lifted GPT-4 HumanEval pass@1
# from 80% to 91% -- but ONLY because code has a real oracle (tests).
# - 0.95 reliable self-critique on top of a 0.80 generator does not give
# you 0.76 reliability with a boost; without a validator it gives you
# 2-4x the token cost and the same wrong answer, approved confidently.
This snippet implements reflection the way production systems should for this type pf agent: the critic evaluates against a Pydantic schema validator rather than its own opinion, the loop has a hard maximum iteration count, and each revision carries the specific validation errors forward. Notice how much of the code is guardrails rather than generation. That ratio is typical of reliable agent systems.
4. Planning: The Commitment Device

Planning splits the agent into two roles. A planner reads the goal once and produces an ordered, complete plan. An executor then works through the plan, ideally with a smaller, cheaper model. The plan is a commitment device: it survives context scrolling because it lives outside the per-step reasoning, and a human can review it before any irreversible action runs.
This pattern shines when the task shape is predictable: a monthly financial close, a data migration, a multi-section report, a 20-step code change. Writing the plan once and executing it is cheaper than reasoning step by step, because the expensive reasoning tokens are spent only at the planning stage. On long-horizon tasks, keeping the executor’s context bounded to the current step plus plan context rather than the full accumulated scratchpad cuts context consumption by 40 to 60 percent compared to flat ReAct.
The failure mode is plan staleness. The plan is generated at the start based on the planner’s world model at that moment. If a step returns something unexpected, an export fails, a record does not exist, an external data source updates, a naive executor barrels ahead executing a dead plan. The fix is a hybrid that most reliable production agents converge on: plan the skeleton, react within each step, and add a cheap replan trigger that fires when an observation contradicts the plan’s assumptions.
The honest framing is that planning and ReAct are not rivals. Above roughly ten steps, the planning overhead almost always pays for itself. Below that, it is wasted tokens on a three-step task.
"""
Plan-and-Execute: dead plans vs plans with a replan trigger.
The BAD version generates a plan and executes it no matter what happens.
This is the pattern's signature failure mode, plan staleness: the plan is
built on the planner's world model at T=0, and if reality diverges (a step
fails, data updates, an assumption breaks), a naive executor barrels ahead
executing a dead plan.
The GOOD version:
1. Generates the plan ONCE (cheap, compact, survives context scrolling).
2. Uses a cheap executor (plan tokens are spent once, not per step).
3. Compares each step's EXPECTED output against the ACTUAL observation.
On a mismatch, it fires a REPLAN signal and regenerates the remaining
plan from current state, not from scratch.
Run: python plan_and_execute.py
"""
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Placeholder LLM functions (the logic, not the model, is the point here).
# ---------------------------------------------------------------------------
def fake_planner(goal: str, state: dict | None = None) -> list[dict]:
"""
In production this is one LLM call returning a compact structured plan
(50-150 tokens). Keeping the plan OUT of per-step context cuts context
consumption 40-60% vs flat ReAct on long-horizon tasks.
"""
if state is None: # initial plan for: quarterly report with live data
return [
{"id": 1, "description": "fetch Q3 revenue data", "expect": "numeric dict"},
{"id": 2, "description": "compute growth vs Q2", "expect": "percentage"},
{"id": 3, "description": "draft report section", "expect": "text"},
]
# REPLAN: regenerate only the remaining steps from current state.
return [
{"id": 4, "description": "re-fetch revenue data (source changed)",
"expect": "numeric dict"},
{"id": 5, "description": "compute growth revised", "expect": "percentage"},
{"id": 6, "description": "draft report section", "expect": "text"},
]
def fake_execution_env(step: dict) -> str:
"""Simulated environment: the data source CHANGES mid-run."""
if step["id"] == 2 and not REPLANNED[0]:
return "ERROR: revenue API schema changed, numeric dict expected" # reality diverges
return f"ok -> {step['expect']}"
REPLANNED = [False] # tracks whether the demo already replanned
# ---------------------------------------------------------------------------
# BAD: fires and forgets. Executes a dead plan to the end.
# ---------------------------------------------------------------------------
def bad_execute(plan: list[dict]) -> list[str]:
results = []
for step in plan: # no expectation check at all
results.append(fake_execution_env(step)) # ERROR result is just
# appended like any other output -> downstream steps build on a
# broken precondition and the compound error rolls on to step 20.
return results
# ---------------------------------------------------------------------------
# GOOD: executor + expectation gate + replan trigger.
# ---------------------------------------------------------------------------
def expected_met(observation: str, step: dict) -> bool:
"""Cheap check: does the observation match the step's expectation?
In production this is a schema/type assert or a tiny classifier call,
NOT a full frontier-model reflection."""
return not observation.startswith("ERROR")
def plan_and_execute(goal: str) -> dict:
trace = []
state = None
plan = fake_planner(goal) # PLAN ONCE
while plan: # work through the plan
step = plan.pop(0)
obs = fake_execution_env(step) # EXECUTE current step
trace.append(f"step {step['id']}: {obs[:60]}")
if expected_met(obs, step):
continue # healthy, carry on
# MISMATCH -> REPLAN from current state, not from scratch.
print(f"[replan] step {step['id']} expectation '{step['expect']}' unmet")
REPLANNED[0] = True
plan = fake_planner(goal, state={**step, "observation": obs, "trace": trace})
return {"result": "quarterly report drafted", "trace": trace}
# ---------------------------------------------------------------------------
# When to use this pattern vs plain ReAct:
# steps <= 5 and reversible actions -> ReAct (planning is waste)
# 6-20 steps, structure known in advance -> Plan-and-Execute (this)
# plan likely to face divergent reality -> add the replan trigger
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("=== BAD: executes a dead plan straight off a cliff ===")
for line in bad_execute(fake_planner("quarterly report")):
print(f" {line}")
print(" -> rest of the plan ran on broken assumptions.\n")
print("=== GOOD: replan trigger rescues the run ===")
result = plan_and_execute("quarterly business report")
for line in result["trace"]:
print(f" {line}")
print(f"\n final: {result['result']}")
The snippet shows both worlds. The bad version executes a dead plan straight to the end: an error observation is appended like any other output, and downstream steps build on a broken precondition. The good version checks every step’s expected output against the actual observation, fires a REPLAN signal on mismatch, and regenerates the remaining plan from current state rather than from scratch. Notice that the replan check is a cheap, deterministic comparison, not another expensive model call.
5. Sequential Workflows: The Boring Pattern That Wins

A sequential workflow is an assembly line: the output of one agent or stage becomes the validated input of the next. Content generation flows into an SEO check, which flows into social formatting. Retrieval flows into analysis, which flows into report writing.
This pattern barely gets conference talks, but production engineers keep landing on it, and for good reason. Sequential chaining with validation eliminates, by design, the three failure modes that plague every multi-agent system. There are no infinite loops because the chain is strictly sequential. There is no collaborative hallucination because each stage operates on validated data rather than agent-to-agent conversation. There is minimal coordination overhead because the routing is a function call, not an LLM classification.
The critical word is validated. The pattern works because an explicit gate sits between stages: a schema check, a test suite, a deterministic assertion. Unvalidated chaining is just a pipeline that confidently propagates garbage. Validated chaining is how most real business logic gets built on LLMs: boring, predictable, testable, and cheap to debug, because when stage three fails you know exactly which stage to inspect.
When you compare framework benchmarks, this is also where the cost differences get dramatic. Token efficiency varies by two to three times across popular frameworks for the same workflow, because some pass full conversation context between stages while others pass compact state deltas. At a million requests per month, that difference is real money. A deterministic chain where you control exactly what context each stage receives is the cheapest architecture you can build.
"""
Sequential Workflow: shared mutable state vs validated immutable payloads.
A sequential workflow is an assembly line: the output of one stage becomes
the VALIDATED input of the next. It is the pattern that quietly wins in
production because it eliminates, by design, the three failure modes that
plague multi-agent systems:
1. No infinite loops -- the chain is strictly sequential.
2. No collaborative hallucination -- each stage operates on validated data,
not agent-to-agent conversation.
3. Minimal coordination overhead -- routing is a function call, not an
LLM classification.
The critical word is VALIDATED. Unvalidated chaining is a pipeline that
confidently propagates garbage. Every handoff needs a gate.
Run: python sequential_workflow.py
"""
from dataclasses import dataclass
from typing import Callable
# ---------------------------------------------------------------------------
# BAD: one shared mutable dict that every stage reads and writes.
# ---------------------------------------------------------------------------
SHARED_STATE = {"topic": "agent patterns", "tone": "professional", "draft": None, "seo": None}
def bad_stage_generate(state: dict) -> dict:
state["draft"] = f"Draft about {state['topic']}" # writes shared state
return state
def bad_stage_seo(state: dict) -> dict:
# Reads 'draft'... which might not exist yet if stages run out of order,
# or might contain another stage's half-written value. No validation.
state["seo"] = f"SEO ok for: {state.get('draft', 'MISSING')}"
return state
# Failure modes: hidden coupling, out-of-order execution, no gate between
# stages, and one bad write silently corrupts everything downstream.
# ---------------------------------------------------------------------------
# GOOD: immutable validated payloads passed explicitly between stages.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class GeneratedDraft:
topic: str
body: str
@dataclass(frozen=True)
class SeoResult:
draft: GeneratedDraft
keywords: list[str]
def validate(cond: bool, msg: str) -> None:
"""The gate. Fail LOUDLY at the boundary, never propagate garbage."""
if not cond:
raise ValueError(f"Validation gate failed: {msg}")
def stage_generate(topic: str) -> GeneratedDraft:
body = f"A structured article about {topic}" # placeholder LLM call
# GATE 1: assert the output contract before anything downstream sees it.
validate(len(body) > 10, "draft too short")
return GeneratedDraft(topic=topic, body=body)
def stage_seo(draft: GeneratedDraft) -> SeoResult:
# The type system IS part of the gate: this function cannot be called
# with an unvalidated string. It only accepts a GeneratedDraft.
keywords = [draft.topic, "ai agents"] # placeholder LLM call
validate(len(keywords) > 0, "no keywords extracted")
return SeoResult(draft=draft, keywords=keywords)
def stage_format(seo: SeoResult) -> str:
# GATE 3: final output check (placeholder: assert publishable shape).
validate(seo.draft.topic in str(seo.keywords), "topic lost in formatting")
return f"PUBLISHED: {seo.draft.body} [keywords: {', '.join(seo.keywords)}]"
def validated_pipeline(topic: str) -> str:
"""
The routing between stages is plain Python. No LLM decisions, no
negotiation, no shared state. When stage 2 fails you know EXACTLY
which stage to inspect -- debugging is a stack trace, not archaeology.
"""
draft = stage_generate(topic) # stage 1 -> gate 1
seo = stage_seo(draft) # stage 2 -> gate 2
return stage_format(seo) # stage 3 -> gate 3
# ---------------------------------------------------------------------------
# Why not multi-agent here? Decision table:
# ---------------------------------------------------------------------------
# | Question | This task |
# |---------------------------------------------|----------------|
# | Steps listable in advance? | Yes |
# | Each stage's input checkable with code? | Yes |
# | Domains need different tools per agent? | No |
# | Would one prompt be overwhelmed? | No |
# => Sequential workflow with gates. Not a supervisor.
if __name__ == "__main__":
print("=== BAD: shared mutable state, no gates ===")
# Imagine these run out of order (a retry, a parallel scheduler):
out_of_order = dict(SHARED_STATE)
bad_stage_seo(out_of_order) # runs BEFORE generate!
print(f" seo result: {out_of_order['seo']}")
print(" -> silently accepted a missing draft as None. No error raised.\n")
print("=== GOOD: sequential stages with validation gates ===")
final = validated_pipeline("ai agent design patterns")
print(f" {final}")
# When a gate fails, it fails LOUDLY with a named stage, e.g.:
try:
stage_generate("x") # too short -> gate 1 rejects it
except ValueError as e:
print(f"\n (gate demo) {e}")
The snippet builds a three-stage content pipeline where each stage is a function call and each handoff passes through an explicit validation gate. The bad version shares a mutable state dictionary across stages and lets any stage read anything. The good version passes immutable, validated payloads and fails loudly at the first gate. This is deliberately unglamorous code. That is the point.
6. Multi-Agent: Powerful, Expensive, and Usually Premature

Now the pattern everyone wants to talk about. An orchestrator or supervisor agent receives a goal, decomposes it, and delegates subtasks to specialized workers, each with its own role, tools, and context. LangGraph’s supervisor pattern, CrewAI’s hierarchical process, and OpenAI’s triage-and-handoff model all implement this shape.
The genuine advantages are real: focused workers with tight context windows are easier to make correct than one agent juggling everything, subtasks with different toolsets can run in parallel, and per-role audit attribution satisfies compliance requirements.
But the production evidence is overwhelmingly one-sided. The failures are predictable and well documented:
- Cascading errors. When agents share state, one agent’s hallucinated customer ID becomes the next agent’s ground truth. Three agents collaborate to produce a confidently wrong answer that none would have generated alone.
- Coordination overhead. In real production measurements, 40 to 60 percent of tokens in multi-agent systems go to agents describing their work to other agents: routing, summarizing, synchronizing. The meta-work exceeds the actual task work.
- Infinite handoff loops. Two agents with overlapping responsibilities and no exit criteria delegate back and forth until a rate limit stops them. Documented runaway scenarios range from $50 to $500 per incident before anyone notices.
- Latency stacking. Every handoff is a fresh inference cycle. A task a single agent completes in five sequential tool calls may take three full supervisor round-trips, pushing simple responses past 45 seconds.
Here is the decision rule, distilled from what teams actually ship:

One team’s experience captures the pattern of failure and correction: they built a multi-agent research pipeline, watched it fail under production traffic, then collapsed it back to a single agent with a longer context window and a plan-and-execute loop. Same task, half the cost, better output. The lesson they kept relearning, and that the wider community keeps confirming, is that most applications do not need agents that collaborate. They need agents that each do one thing well, connected by deterministic code.
If you do reach multi-agent, three rules are non-negotiable: no two agents may have a mutual dependency where each can delegate to the other, at least one agent must have access to real data rather than other agents’ outputs, and every agent carries an explicit iteration cap.
"""
Supervisor / Multi-Agent: mutual delegation chaos vs a disciplined hub.
Multi-agent systems amplify errors instead of reducing them when built
naively: 10 agents each 95% accurate, sharing state, produce a system that
fails ~40% of requests (0.95**10 = 0.60, and state contamination makes it
worse). Documented runaway scenarios burn $50-500 per incident.
The BAD version lets agents delegate to each other freely -> infinite
handoff loops (A asks B, B asks A back, no exit criteria). The GOOD version
enforces the three non-negotiable rules:
1. NO MUTUAL DELEGATION: if A can delegate to B, B can never delegate
back to A. All routing flows through one hub (supervisor).
2. GROUND TRUTH CHECKPOINT: at least one worker reads real data, so the
system cannot do "collaborative hallucination".
3. HARD CAPS: every loop has max_iterations and a token budget. An
unconstrained agent is a denial-of-service attack on your own bill.
Run: python supervisor_pattern.py
"""
from dataclasses import dataclass, field
# ---------------------------------------------------------------------------
# BAD: peer-to-peer mesh where agents can call each other.
# ---------------------------------------------------------------------------
def bad_multi_agent():
"""Agent A and Agent B have overlapping scope and no exit criteria."""
log = []
turn, speaker, question = 0, "A", "who validates the data?"
while turn < 5 and speaker != "DONE": # NO hard cap (range 5 = demo mercy)
if speaker == "A":
log.append(f"A asks B: {turn}")
speaker = "B" # A can talk to B...
else:
log.append(f"B asks A back: {turn}")
speaker = "A" # ...and B can delegate back -> LOOP
turn += 1
return log
# ---------------------------------------------------------------------------
# GOOD: supervisor hub-and-spoke.
# ---------------------------------------------------------------------------
@dataclass
class Worker:
name: str
tools: list
@dataclass
class SupervisorRun:
answer: str | None = None
handoffs: list = field(default_factory=list)
cost_units: int = 0 # token budget accounting
hit_cap: bool = False
MAX_HANDOFFS = 6 # hard cap 1: bounded handoffs
MAX_COST_UNITS = 100 # hard cap 2: bounded spend
WORKERS = {
"research": Worker("research", tools=["web_search", "docs"]),
"database": Worker("database", tools=["sql"]), # GROUND TRUTH worker
"writer": Worker("writer", tools=["none"]), # pure text synthesis
}
def supervisor_route(task: str) -> str:
"""
Routing decision lives in CODE (a tiny classifier/ruleset here), not in
a free-form LLM conversation. Deterministic routing is debuggable:
when something goes wrong there is exactly one node to inspect.
A production supervisor may classify with an LLM, but the TOPOLOGY
guarantees every handoff passes through this single point.
"""
if "lookup" in task:
return "database" # facts come from real data, never from peers
if "summarize" in task:
return "writer"
return "research"
def ground_truth_lookup(_query: str) -> str:
return "acme corp: 1,200 employees, $85M raised" # real data source
def worker_execute(worker_name: str, task: str) -> str:
w = WORKERS[worker_name]
if "sql" in w.tools:
return ground_truth_lookup(task)
if worker_name == "writer":
return f"summary grounded in: {task}"
return f"research notes on: {task}"
def supervisor_run(tasks: list[str]) -> SupervisorRun:
run = SupervisorRun()
for task in tasks:
# THE RULE: workers never talk to each other. The supervisor is the
# only node that can initiate a handoff ( decisively breaking loops).
for handoff in range(MAX_HANDOFFS):
route = supervisor_route(task)
run.cost_units += 10 # every hop costs tokens
if run.cost_units > MAX_COST_UNITS: # budget enforcement
run.hit_cap = True
run.answer = "aborted: token budget exceeded"
return run
result = worker_execute(route, task)
run.handoffs.append(f"{task} -> {route}: {result[:50]}")
# Supervisor validates the worker output against the plan gate.
if result and "ERROR" not in result:
run.answer = result if "summary" in result or "acme" in result else run.answer
break # progress -> next task
else:
# Exited by handoff cap: escalate rather than loop forever.
run.hit_cap = True
run.answer = "escalated to human (handoff cap)"
return run
if __name__ == "__main__":
print("=== BAD: mutual delegation (mesh topology) ===")
for line in bad_multi_agent():
print(f" {line}")
print(" -> A and B ping-pong forever. Only a rate limit ends it.\n")
print("=== GOOD: supervisor hub with caps and ground truth ===")
run = supervisor_run(["lookup new competitor", "summarize findings"])
for line in run.handoffs:
print(f" {line}")
print(f" cost: {run.cost_units}/{MAX_COST_UNITS} units, cap hit: {run.hit_cap}")
# Measure coordination overhead in YOUR system:
# coordination_tokens (routing + summarizing between agents)
# -----------------------------------------------------------
# work_tokens (actual task execution)
# If coordination > 40% of spend, a single agent with better tools
# would be cheaper AND more reliable. That threshold is the exit sign.
The snippet contrasts both topologies. The bad version is a peer-to-peer mesh where Agent A and Agent B have overlapping scope and can delegate to each other, so they ping-pong forever until a rate limit intervenes. The good version is a hub-and-spoke supervisor: routing flows only through one hub in code, a dedicated ground-truth worker reads real data so no chain of agents can hallucinate consensus, and the run carries hard caps on both handoffs and token budget. The closing code returns a coordination-overhead ratio you should measure in your own system, because if coordination exceeds 40 percent of your spend, a single agent with better tools would be cheaper and more reliable.
The Golden Rule: Climb the Ladder One Rung at a Time

Everything above compresses into a single progressive ladder that the industry has converged on:
- Prompt engineering and structured outputs first. If a single well-crafted call with a strict schema solves it, you are done.
- A single agent with strict tool access when the task needs data or actions. Design the tool schemas with more care than your system prompt.
- A reflection loop when the task has an automated success signal and the stakes justify extra latency.
- Planning when the task exceeds roughly ten steps and the structure is knowable in advance.
- Sequential workflows with validation gates for predictable business logic.
- Multi-agent only when the tasks are so wildly distinct that a single prompt context would be completely overwhelmed, the domains genuinely differ, and the subtasks parallelize.
The discipline is to add complexity only when the simpler pattern’s failure mode has been observed, not because the sophisticated architecture looks impressive in a demo. Every layer you add buys capability and spends latency, cost, and debuggability. Production systems optimize for predictability, and a 95 percent per-step success rate is a promise architecture makes, not a model.
Ask yourself the question that separates systems that survive from systems that get rebuilt: does this design decision make the system easier to reason about six months from now? If the answer is no, you are adding complexity faster than you are adding capability. In production, complexity always collects its debt.
Final Thoughts
The agent pattern landscape in 2026 is not a menu of equally valid choices. It is a ladder, and the evidence from teams running these systems at scale points in one direction: the simplest pattern that handles the task is almost always the correct one. The engineers getting the best results are not the ones with the most agents. They are the ones with the best tool schemas, the tightest validation gates, and the strongest restraint about when to add a second agent.
Here are several key takeaways from this article:
- Patterns are control structures, not features. The same model in six different skeletons produces six different reliability, cost, and latency profiles. Choose the skeleton deliberately.
- Tool design beats model upgrades. Strict schemas, deterministic routing, and fewer than 12 tools per agent fix more reliability problems than any framework migration.
- Reliability multiplies, it does not add. A 95 percent per-step success rate becomes roughly 60 percent across ten steps, which is why step budgets and validation gates are non-negotiable.
- Reflection without a validator is theater. Ground every critique in tests, schemas, or real data, and cap the loop at two to three iterations.
- Sequential workflows with validation gates beat multi-agent for most business logic. Boring, predictable, testable, and dramatically cheaper.
- Go multi-agent only when a single context window genuinely cannot hold the task. If you cannot articulate why one agent is insufficient, you do not have a multi-agent problem.
Thank you for reading this article! I hope you found it helpful. If you have any questions or feedback, please feel free to reach out to me.
#ArtificialIntelligence #AIEngineering #AIAgents #LLM #SoftwareArchitecture #MachineLearning #TechTrends
Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.
Published via Towards AI
Towards AI Academy
We Build Enterprise-Grade AI. We'll Teach You to Master It Too.
15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.
Start free — no commitment:
→ 6-Day Agentic AI Engineering Email Guide — one practical lesson per day
→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages
Our courses:
→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.
→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.
→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.
Note: Article content contains the views of the contributing authors and not Towards AI.