Agent Design Patterns

The vendor-neutral patterns underneath every agent framework.

Introduction

LangGraph and Google ADK give you the machinery for building agents. This lesson covers the patterns that machinery implements: ReAct, reflection (also called the evaluator-optimizer loop), planning, routing, and the memory tiers that hold it all together. These patterns are framework-agnostic, you can build every one of them with nothing but the raw model API, and understanding them is what lets you choose the right structure for a task instead of reaching for a framework by reflex.

1. What Is an Agent?

LLM + tools + loop + memory

2. ReAct

Reason, act, observe, repeat

3. Reflection

Draft, critique, revise

4. Planner-Executor

Decompose, then execute

5. Routing

Classify, then dispatch

6. Agent Memory

Short, working, long-term

7. Multi-Agent

Supervisor and network topologies

8. When NOT to

Anti-patterns and simpler options

1. What Is an Agent?

An agent is an LLM placed inside a loop, given tools it can call and some memory to track progress, and left to decide its own next step until a goal is met. That last part is the distinction that matters:

Chain

A fixed sequence: A then B then C. You decide the steps in advance. Predictable, no autonomy.

Workflow

Branches and loops you wire explicitly (the LangGraph model). The paths are fixed; which path runs is dynamic.

Agent

The model chooses its own next action at each step. Maximum flexibility, less predictability, harder to test.

The four ingredients: every agent is some combination of an LLM (the reasoning engine), tools (how it affects the world), a control loop (what keeps it going), and memory (what it remembers across steps). The patterns below are different ways of arranging those four ingredients.

2. ReAct: Reason + Act

ReAct is the foundational agent pattern and the one built into every tool-calling API. The model alternates between reasoning ("I need the release docs") and acting (calling a tool), then observes the result and reasons again. The loop continues until the model decides it has enough to answer.

The ReAct Loop

UserAgentTooltaskThought: I need dataAction: search_docs(q)Observation: resultsThought: enough to answerFinal answer

Figure 1: Thought and action interleave until the model stops requesting tools

Here is the entire pattern with the raw Anthropic SDK, no framework. The loop is driven by one signal: stop_reason == "tool_use". While the model keeps asking for tools, you run them and feed results back; when it stops, you have the answer. Two details in this code are what separate a demo from something you would run: the loop counts its steps rather than spinning on while True, and it collects every tool call from a turn before replying, because the model can request several at once. The mechanics of tool schemas and parallel calls are covered properly in Lesson 16.

# react_loop.py - a bounded ReAct loop on the raw Anthropic SDK
import anthropic

client = anthropic.Anthropic()

# --- Tool definition (what the model is allowed to call) ---
tools = [{
    "name": "search_docs",
    "description": "Search internal documentation for a query.",
    "input_schema": {
        "type": "object",
        "properties": {"query": {"type": "string"}},
        "required": ["query"],
    },
}]

def search_docs(query: str) -> str:
    # Real implementation would hit a vector store or search API
    return f"[results for '{query}': the deploy step runs 'make release']"

# --- The ReAct loop: reason -> act -> observe -> repeat ---
messages = [{"role": "user", "content": "What command cuts a release?"}]
MAX_STEPS = 6  # a loop without a budget is an anti-pattern (section 8)

for step in range(1, MAX_STEPS + 1):
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        tools=tools,
        messages=messages,
    )
    messages.append({"role": "assistant", "content": response.content})

    # No tool requested -> the model has its final answer
    if response.stop_reason != "tool_use":
        answer = "".join(b.text for b in response.content if b.type == "text")
        print(f"step {step}: answering")
        print(answer)
        break

    # Execute every tool call the model asked for, feed the results back
    # in a single user message: the model may request several at once.
    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            print(f"step {step}: {block.name}({block.input['query']!r})")
            result = search_docs(**block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result,
            })
    messages.append({"role": "user", "content": tool_results})
else:
    raise RuntimeError(f"agent did not finish within {MAX_STEPS} steps")
Expected Output:
step 1: search_docs('cut a release command')
step 1: search_docs('release process')
step 2: answering
The command that cuts a release is:

```
make release
```

This is the command run during the deploy step to cut a release, according to the internal documentation.
Read the trace, not just the answer: the model asked for two searches in the first step and answered in the second. A stub tool that returns the same string every time still drew multiple queries, which is precisely why the step budget exists. Watching that trace in production is the subject of Lesson 19.

3. Reflection: Self-Critique

Models produce better output when asked to critique their own first attempt. The reflection pattern separates generation from evaluation: draft an answer, critique it against the goal, then revise, looping until the critique is satisfied or a round limit is hit. You will also see this called the evaluator-optimizer loop: the critic is the evaluator, the reviser is the optimizer, and the two names describe the same structure.

Reflection Loop

Draftgenerate first attemptCritiquelist concrete flawsRevisefix the flawsAcceptcritique = APPROVEDgoodflaws foundre-check

Figure 2: Generation and evaluation are separate calls, so the critic is not defending its own draft

# reflection.py - draft, critique, revise, with the rounds capped
import anthropic


def text_of(message) -> str:
    """Pull the text out of a response.

    content is a list of typed blocks, and on a thinking model the first one
    is a ThinkingBlock, so select by type rather than by index.
    """
    return next(b.text for b in message.content if b.type == "text")


def reflect_and_improve(client, task: str, max_rounds: int = 2) -> str:
    """Generate a draft, then critique and revise it until approved."""

    draft = text_of(client.messages.create(
        model="claude-sonnet-5", max_tokens=1024,
        messages=[{"role": "user", "content": task}],
    ))

    for round_no in range(1, max_rounds + 1):
        critique = text_of(client.messages.create(
            model="claude-sonnet-5", max_tokens=1024,
            messages=[{"role": "user", "content": (
                "Critique the draft against the task. List concrete, "
                "actionable flaws only. If it is genuinely good, reply "
                f"exactly 'APPROVED'.\n\nTask: {task}\n\nDraft:\n{draft}"
            )}],
        ))

        if "APPROVED" in critique:
            print(f"round {round_no}: approved")
            break
        print(f"round {round_no}: revising")

        draft = text_of(client.messages.create(
            model="claude-sonnet-5", max_tokens=1024,
            messages=[{"role": "user", "content": (
                "Revise the draft to fix every point in the critique. "
                f"Return only the improved draft.\n\nCritique:\n{critique}"
                f"\n\nDraft:\n{draft}"
            )}],
        ))

    return draft


if __name__ == "__main__":
    client = anthropic.Anthropic()
    print(reflect_and_improve(client, "Write a one-sentence release note for a "
                              "--dry-run flag that prints planned changes "
                              "without applying them."))
Expected Output:
round 1: revising
round 2: approved
Added a `--dry-run` flag to preview planned changes without applying them.
Cost note: reflection multiplies your token spend by the number of rounds. Reserve it for high-value output (code, analysis, customer-facing copy), not every response, and always cap the rounds so a stubborn critic cannot loop forever. Give the critique room to finish, too: a max_tokens too small for a thinking model truncates the critique mid-sentence, and the loop cannot tell that from real feedback. Budgeting those tokens is Lesson 18.

4. Planner-Executor

For multi-step goals, asking a model to do everything in one shot invites it to lose the thread. The planner-executor pattern splits the work: a planner decomposes the goal into an explicit list of steps, then an executor carries out each step in turn, often calling tools. Because the plan is written down, you can inspect it, edit it, or ask for human approval before any action is taken.

Planner-Executor

GoalPlannerdecompose into stepsStep 1Step 2Step 3Resultplan

Figure 3: The plan is an explicit, inspectable artifact between goal and execution

What makes the plan inspectable is that it comes back as a list, not as prose you would have to re-parse. Asking for a typed object with structured outputs is what turns "the plan" from a paragraph into steps[2], something you can print, reorder, drop a step from, or hold for approval. Note the two models: planning is the part worth paying for, so it runs on Sonnet, while each step is executed by Haiku.

# planner_executor.py - Sonnet decomposes the goal, Haiku executes each step
import anthropic
from pydantic import BaseModel

client = anthropic.Anthropic()


class Plan(BaseModel):
    steps: list[str]


def make_plan(goal: str) -> list[str]:
    """Planner: decompose the goal into ordered steps, typed rather than prose."""

    response = client.messages.parse(
        model="claude-sonnet-5",
        max_tokens=600,
        system=("Decompose the goal into 3 to 5 ordered steps. Each step is one "
                "short sentence naming a single concrete action."),
        messages=[{"role": "user", "content": goal}],
        output_format=Plan,
    )
    return response.parsed_output.steps


def execute(goal: str, step: str, done: list[str]) -> str:
    """Executor: carry out one step, with the goal and prior results in view."""

    so_far = "\n".join(f"- {d}" for d in done) or "(nothing yet)"
    reply = client.messages.create(
        model="claude-haiku-4-5",  # execution is the cheap half of the pattern
        max_tokens=200,
        system=("Carry out exactly the step you are given. Reply with one sentence "
                "of at most 15 words, no headings and no lists."),
        messages=[{"role": "user", "content": (
            f"Goal: {goal}\n\nDone so far:\n{so_far}\n\nYour step: {step}"
        )}],
    )
    return next(b.text for b in reply.content if b.type == "text")


if __name__ == "__main__":
    goal = "Prepare a release checklist for a small Python CLI tool."
    steps = make_plan(goal)

    # The plan is data, not prose. This is where you would print it for review,
    # edit the list, or require a sign-off before anything actually runs.
    for i, step in enumerate(steps, 1):
        print(f"plan {i}. {step}")

    results: list[str] = []
    for i, step in enumerate(steps, 1):
        results.append(execute(goal, step, results))
        print(f"done {i}. {results[-1]}")
Expected Output:
plan 1. Update version number and changelog in the project files
plan 2. Run the full test suite and fix any failing tests
plan 3. Update documentation and README with new features or changes
plan 4. Build the package and verify it installs correctly in a clean environment
plan 5. Publish the release to PyPI and tag the release in version control
done 1. Update the version number in setup.py and add release notes to CHANGELOG.md.
done 2. Run pytest or your test command to verify all tests pass before proceeding to release.
done 3. Review and update README.md to describe new features and update docs with current usage examples.
done 4. Create a virtual environment, build your package with `python -m build`, and install it to verify functionality.
done 5. Push built package to PyPI using `twine upload dist/*` and create a git tag with `git tag v<version>`.
The executor here only describes its step. It is a plain model call with no tools, so "Run pytest" produces a sentence about running pytest, not a test run. That is fine for showing the control flow, and it is exactly the seam where you attach the tool loop from section 2: give the executor tools and the same structure starts doing real work. Keeping the distinction clear matters, because an executor that reports success it never achieved is one of the harder agent failures to notice.

You already built a version of this in the Google ADK lesson: the Blogger's outline-then-write pipeline is a planner (outline) feeding an executor (writer). The pattern generalizes far beyond content: research assistants, migration tools, and coding agents all plan first, then execute.

5. Routing: Classify, Then Dispatch

Not every request deserves your most powerful, most expensive path. The routing pattern uses a fast classifier (often a small model) to label the request, then dispatches to a handler tuned for that category, each with its own prompt, tools, and even model. It is the single cheapest way to cut cost and latency in a production system: Lesson 18 measures the gap between the cheapest and most expensive model on one task.

Routing / Handoff

User QueryRoutercheap classifierBilling AgentTechnical AgentSales AgentBILLINGTECHNICALSALES

Figure 4: A cheap model routes; specialized handlers do the expensive work only when needed

# router.py - a Haiku classifier in front of three specialized handlers
import anthropic

# Each handler is a mini-agent of its own: different model, prompt, and
# tools. Stubbed here so the example runs end to end.
def handle_billing(query: str) -> str:
    return "-> billing agent"


def handle_technical(query: str) -> str:
    return "-> technical agent"


def handle_sales(query: str) -> str:
    return "-> sales agent"


def handle_general(query: str) -> str:
    return "-> general agent"


def route(client, query: str) -> str:
    """Classify the query once with a cheap model, then dispatch."""

    reply = client.messages.create(
        model="claude-haiku-4-5",  # fast + cheap for classification
        max_tokens=10,
        system=(
            "Classify the user query into exactly one label: "
            "BILLING, TECHNICAL, or SALES. Reply with only the label."
        ),
        messages=[{"role": "user", "content": query}],
    )
    label = next(b.text for b in reply.content if b.type == "text").strip().upper()

    handlers = {
        "BILLING": handle_billing,      # the fallback catches anything the
        "TECHNICAL": handle_technical,  # classifier returns that is not one
        "SALES": handle_sales,          # of the three expected labels
    }
    handler = handlers.get(label, handle_general)
    return f"{label:10} {handler(query)}"


if __name__ == "__main__":
    client = anthropic.Anthropic()
    for q in ["My card was charged twice this month.",
              "The webhook returns 502 after I rotate the key.",
              "Do you offer volume discounts for 50 seats?"]:
        print(route(client, q))
Expected Output:
BILLING    -> billing agent
TECHNICAL  -> technical agent
SALES      -> sales agent

6. Agent Memory

An agent's intelligence is bounded by what it can remember. Three tiers of memory work together, and knowing which is which prevents the two classic failures: overflowing the context window, and forgetting things that mattered.

The Three Memory Tiers

Agentcurrent reasoning stepShort-Termcontext windowWorkingscratchpad / stateLong-Termvector DB / SQLretrieve relevant slice

Figure 5: Short-term is finite and fast, working memory tracks progress, long-term is queried on demand

Short-Term

The context window: this request's messages. Fast, but finite and expensive, everything competes for the same space.

messages = [system, *recent_turns, user_msg]

Working

An explicit scratchpad the agent reads and writes across steps, the plan, intermediate results, and progress flags.

state = {"plan": [...], "step": 3}

Long-Term

An external store (vector DB, SQL) queried on demand. Survives across sessions; only the relevant slice is pulled into context.

vector_store.search(query, k=4)

Compaction: when short-term memory fills up, do not let the oldest messages silently fall out of the window. Instead, keep the system prompt and the most recent few turns, and replace the middle with a summary, so the agent retains the gist of what happened without paying for every token of history. Lesson 17 treats the window as a budget and covers compaction, context editing, and what earns a slot.

7. Multi-Agent Topologies

When one agent's job gets too broad, split it. Multiple focused agents, each with a narrow tool set and prompt, are easier to reason about and debug than one agent trying to do everything. The most common arrangement is the supervisor: an orchestrator that delegates to workers and aggregates their results. The capstone project builds the same fan-out/fan-in shape, though there the branch is decided in code rather than by a model, which makes it a workflow by the definitions in section 1. Both are worth knowing: a hardcoded fan-out is cheaper and far easier to test, so promote the decision to a model only when the set of workers genuinely varies.

Supervisor Topology

Supervisordelegates + aggregatesResearcherWriterFact-Checkerdelegate

Figure 6: A supervisor delegates to specialized workers and merges their output (solid = delegate, dashed = report back)

Supervisor

One orchestrator, many workers. Easiest to control and observe. Default choice.

Network

Agents hand off to each other peer-to-peer. Flexible but harder to trace and bound.

Hierarchical

Supervisors of supervisors. For large systems; adds coordination overhead.

8. When NOT to Build an Agent

Agents are powerful and frequently the wrong tool. Every loop is a chance to burn tokens, take a wrong action, or run forever. Reach for the simplest structure that solves the problem:

Anti-patterns
  • Using an agent for a task with a fixed, known sequence (that is a chain)
  • No stopping condition or step budget, so a confused agent loops indefinitely
  • Giving one agent dozens of tools instead of splitting or routing
  • No observability, you cannot see the thought/action trace when it misbehaves
Prefer the simpler option when...
  • A single prompt answers it, use a plain call
  • The steps never change, use a chain or workflow
  • You only need branching, use conditional edges (LangGraph)
  • Autonomy is genuinely required, then, and only then, use an agent
This mirrors the lesson from LangChain & LangGraph: always add exactly as much structure as the problem demands, and no more. The patterns here are tools to reach for when the problem earns them.

Key Takeaways

  • An agent is an LLM in a loop - with tools and memory, deciding its own next step. Chains and workflows are the less-autonomous alternatives.
  • ReAct is the base pattern - reason, act, observe, repeat; it is exactly what a tool-calling API does when you loop on stop_reason.
  • Every loop gets a budget - count the steps and fail loudly at the cap. A stub tool returning one constant string still drew two searches before the model would answer.
  • Reflection separates generating from judging - a critic pass catches flaws the drafter missed. Cap the rounds and reserve it for high-value output.
  • Planner-executor makes the plan explicit - so you can inspect, edit, or gate it before any action runs.
  • Routing is the cheapest optimization - a small classifier sends each request to the right-sized handler.
  • Memory has three tiers - short-term (context), working (scratchpad), long-term (external store). Compact before you overflow.
  • Default to a supervisor - for multi-agent work; it is the easiest topology to observe and control.
  • Most tasks are not agents - use the simplest structure that works; earn the loop before you add it.