# The 6 AI Agent Design Patterns Every Engineer Should Know (And When Not to Use Them)

> Source: <https://pub.towardsai.net/the-6-ai-agent-design-patterns-every-engineer-should-know-and-when-not-to-use-them-3720ac3b5b90?source=rss----98111c9905da---4>
> Published: 2026-09-21 05:02:49+00:00

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.

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.

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:

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 thanmodel choice. Instrumented ReAct-style agents wasted 90.8% of retries onunrecoverable errors like hallucinated tool names, and GPT-4 class modelshallucinate tool-call arguments at roughly a 28% rate.The BAD version routes tools through free-form LLM strings and acceptsunguarded 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 onlydef 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 namesdef 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.

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.

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 intocontext, and trusts the model to stop on its own. The GOOD version capsiterations, truncates observations, logs every step, and exits with astructured 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 fromcompounding into a runaway loop.Run: python react_agent_loop.py"""from dataclasses import dataclass, fieldfrom 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}"@dataclassclass 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 capMAX_OBS_CHARS = 500                # truncate observations before they hit contextdef 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.

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 lookright?"). That is reflection theater: it adds cost without adding accuracy,because a model that confidently produced wrong output will confidentlyapprove 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 tokensdef 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 Noneif __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.

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 isbuilt on the planner's world model at T=0, and if reality diverges (a stepfails, data updates, an assumption breaks), a naive executor barrels aheadexecuting 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.

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 becomesthe VALIDATED input of the next. It is the pattern that quietly wins inproduction because it eliminates, by design, the three failure modes thatplague 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 thatconfidently propagates garbage. Every handoff needs a gate.Run: python sequential_workflow.py"""from dataclasses import dataclassfrom 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 statedef 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.

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:

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 builtnaively: 10 agents each 95% accurate, sharing state, produce a system thatfails ~40% of requests (0.95**10 = 0.60, and state contamination makes itworse). Documented runaway scenarios burn $50-500 per incident.The BAD version lets agents delegate to each other freely -> infinitehandoff loops (A asks B, B asks A back, no exit criteria). The GOOD versionenforces 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.# ---------------------------------------------------------------------------@dataclassclass Worker:    name: str    tools: list@dataclassclass SupervisorRun:    answer: str | None = None    handoffs: list = field(default_factory=list)    cost_units: int = 0                          # token budget accounting    hit_cap: bool = FalseMAX_HANDOFFS = 6       # hard cap 1: bounded handoffsMAX_COST_UNITS = 100   # hard cap 2: bounded spendWORKERS = {    "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 sourcedef 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 runif __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.

Everything above compresses into a single progressive ladder that the industry has converged on:

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.

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:

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

[The 6 AI Agent Design Patterns Every Engineer Should Know (And When Not to Use Them)](https://pub.towardsai.net/the-6-ai-agent-design-patterns-every-engineer-should-know-and-when-not-to-use-them-3720ac3b5b90) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
