The Graph Is a Contract. Braided state trajectories cross checkpoints, split at an authority cut and reunite after verified readback on a white field.

TLDR

  • A graph is a control contract, not a picture of cleverness. It defines which state exists, which routes may run and where authority can interrupt action.
  • State should behave like a typed case file: versioned, minimal, provenance-aware and explicit about what merges when concurrent work returns.
  • Model proposals belong inside deterministic boundaries. Conditional edges, tool contracts, policy checks and human authorisation decide what may happen next.
  • Checkpoints preserve workflow continuity; user memory, enterprise knowledge, world state and effect evidence are different stores with different retention rules.
  • A controlled graph proves its route. Release evidence joins state transitions, tool receipts, policy decisions, readback and recovery rather than celebrating a plausible final answer.

Reader and route

This book is for engineers, architects, model-risk practitioners and technical leaders designing long-running AI workflows. Read Parts I and II for the mechanism, Part III for consequential authority, and Parts IV and V for retrieval, coordination and operation. The Merehaven lab and glossary can be used as a field manual.

Chapter map for TLDR: Reader and route; Evidence boundary; The paused payment; The graph contract.
Mermaid chapter map. TLDR connects Reader and route, Evidence boundary, The paused payment, The graph contract.

Evidence boundary

LangGraph APIs, provider interfaces, cloud features and code fragments are version-pinned learning specimens. Revalidate them against the selected package versions and runtime before use. Merehaven Bank is wholly fictional; every customer, case, metric, incident and route is synthetic.

The paused payment

A payment-support assistant gathers a customer’s request, checks account context and proposes a route. Halfway through, the customer changes the destination account. A linear script has two bad choices: restart and lose the approval trail, or continue with stale context. A graph can resume, but only if its state distinguishes the old proposal from the new world state.

Now add a timeout. The transfer tool returns no receipt. Retrying may duplicate an effect; assuming success may mislead the customer. The difficult part is not model reasoning. It is preserving identity, state, authority and evidence while the route changes underneath the conversation.

The graph contract

Every transition should answer five questions: what state was read, what new facts were proposed, which policy authorised the transition, which effect receipt returned and how an unknown outcome will be reconciled. A graph that cannot answer them is an orchestration sketch, not an operating control.

Part I: State before action

The first design decision is not the model. It is the state boundary: what the workflow knows, what it may infer and which facts remain independently authoritative.

Chapter map for Part I: State before action: Why a graph changes the problem; Why the chain paradigm finally broke; Worked example: the state as the shared case file; The failure mode: when “agentic” goes wrong; A thought experiment.
Mermaid chapter map. Part I: State before action connects Why a graph changes the problem, Why the chain paradigm finally broke, Worked example: the state as the shared case file, The failure mode: when “agentic” goes wrong, A thought experiment.

Why a graph changes the problem

Why the chain paradigm finally broke

Then something peculiar started happening. Product owners at banks began asking harder questions. “Can the assistant more than answer the customer’s question about their mortgage, but also check whether they are in financial difficulty, and if so, route them to the Consumer Duty workflow?” “Can it more than summarise a loan contract, but compare it against ten peer contracts and flag non-standard clauses?” “Can it call the transaction system, check the actual overdraft balance, and then explain it in plain English?” Every one of these requests had the same shape. They all required the system to decide, at runtime, what to do next, based on what it had just discovered.

A state-topology view of why the chain paradigm finally broke, using position, line and pattern so the decision remains legible without colour.

That is the chain. Beautiful, predictable, reproducible, and profoundly limited. Now look at what an agent does when the same question arrives, but the customer turns out to be in arrears:

A state-topology view of why the chain paradigm finally broke, using position, line and pattern so the decision remains legible without colour.

That is an agent. The difference is more than the diamond shapes. The difference is that the diamond shapes are real decisions, made by a language model at runtime, based on the actual content of the state. The chain’s author has to predict every path. The agent discovers its own path.

Worked example: the state as the shared case file

Start with a concrete case with the smallest possible banking example. Suppose the Merehaven worked scenario has a prototype relationship manager copilot. A question arrives: “What is Meridian Logistics’s current exposure across all products?” Trace what happens with a chain and with an agent.

In the chain version, the system has four steps. Step one parses the question. Step two calls the core banking API for exposures. Step three formats the numbers. Step four returns. Total tokens used: about 800. Total time: 2.3 seconds. Perfect, until the RM asks a follow-up: “And show me the covenant status on each facility.” Now the chain is stuck. It has no step for covenants. A human would know to call a different API. The chain does not.

In the agent version, the same question arrives and flows into a state object. The state is, roughly, a Python dictionary that travels through the graph and collects evidence as it goes. It looks something like this, expressed as a small Python type:

from typing import TypedDict, List, Optional
from typing_extensions import Annotated

class RMCopilotState(TypedDict):
    # What the RM actually asked
    query: str
    # What the agent has decided to do next
    next_action: Optional[str]
    # Structured results that have been gathered so far
    exposures: Optional[List[dict]]
    covenants: Optional[List[dict]]
    peer_comparison: Optional[dict]
    # The final answer that will be returned
    draft_answer: Optional[str]
    # Any flags that need human attention
    escalations: List[str]

The state is the shared case file. Every node in the graph reads from it and writes to it. A router node at the top inspects the query and decides which tools to invoke. If the query mentions “exposure”, it routes to the exposure tool. If it mentions “covenant” or “breach”, it routes to the covenant tool. If it mentions both, it runs both in parallel. If the RM’s follow-up arrives with the same state already populated, the agent does not re-fetch the exposures. It sees them already in the case file and moves on to covenants.

This is the quiet revolution. The state lets the agent accumulate evidence. The graph lets the agent choose which evidence to gather next. The combination is what produces behaviour that feels like reasoning.

A state-topology view of worked example: the state as the shared case file, using position, line and pattern so the decision remains legible without colour.

Look carefully at the loop from H back to C. A chain cannot do that. A chain is acyclic by definition. The moment your workflow needs to revisit an earlier step, the chain breaks down. LangGraph, as its name suggests, embraces cycles. Cycles are where agency lives.

The failure mode: when “agentic” goes wrong

The lesson, which LangGraph does not eliminate but rather makes more tractable, is that agent capability is exactly as valuable as the guardrails that constrain it. A chain fails by being unhelpful. An agent can fail by being too helpful in the wrong direction. Three patterns in particular recur in post-mortems.

The first is unbounded action space. If an agent can call any tool, it will sometimes call the wrong tool for the wrong reason. The discipline is to scope the tool set tightly at each node, not globally.

The second is state contamination. If the same graph instance serves many users and state leaks between them, the failure mode is data breach. Session isolation, enforced at the state object level, is required by the design in a banking context. In the Merehaven worked scenario, this means every graph run has a uniquely namespaced state bucket in Firestore, tied to the authenticated user’s subject identifier.

The third is loop divergence. If an agent can loop back to an earlier node, it can, in principle, loop forever. LangGraph has a native recursion limit (default 25 steps) to prevent this, but the deeper discipline is to design your graphs so that each node either makes measurable progress on the state or terminates.

Each of these failure modes has a fix. Each fix is easier to implement in a graph than in a chain. That is the quiet virtue of the graph abstraction: it makes the failure modes explicit and therefore addressable.

A thought experiment

Imagine you are architecting a new mortgage pre-application agent for Merehaven Bank. The customer lands on the mortgage pages, starts chatting with the copilot, and within five minutes the copilot needs to have: collected enough information to produce an Agreement in Principle, verified that the customer is not in financial difficulty (Consumer Duty), flagged any vulnerability indicators, checked sanctions and PEP status, and either approved provisionally or politely deferred.

Now ask yourself: what is the smallest possible state object that could carry all of this? A minimal answer looks like this.

from typing import TypedDict, List, Optional, Literal
from datetime import datetime

class MortgageIntakeState(TypedDict):
    # Conversation tracking
    session_id: str
    subject_identifier: str  # authenticated customer ID
    conversation: List[dict]  # history of turns
    current_turn: int
    # Structured data captured so far
    property_value: Optional[float]
    requested_loan: Optional[float]
    household_income: Optional[float]
    household_dependants: Optional[int]
    existing_commitments: Optional[List[dict]]
    source_of_deposit: Optional[str]
    # Checks and assessments
    affordability_assessment: Optional[dict]
    consumer_duty_flags: List[str]
    vulnerability_indicators: List[str]
    sanctions_check_status: Literal["pending", "clear", "hit"]
    pep_check_status: Literal["pending", "clear", "hit"]
    # Decision
    decision: Optional[Literal["provisional_approve", "defer", "decline"]]
    decision_reasons: List[str]
    # Audit
    model_versions: dict
    timestamps: List[datetime]
    next_best_action: Optional[str]

The design shows how the state carries more than the customer’s answers but also the agent’s in-progress assessments, the audit trail (model versions, timestamps), and the regulatory flags. This is what a state object looks like when it has to survive scrutiny. The graph itself is simpler than you might expect: an intake node, a verification node, a parallel block for sanctions / PEP / affordability, a Consumer Duty node, a vulnerability node, and a decision node. The simplicity of the graph is bought by the richness of the state.

Deeper mechanism: the ontology of agency

The word “agent” is used so loosely that the underlying system boundary can disappear. The word is used so loosely in industry that it has become almost meaningless. A “fraud agent” in one bank is a rule engine. In another it is a multi-step LLM workflow. In a third it is a team of specialised LLM-backed roles coordinating under a supervisor. All three get marketed as “agents” and all three behave very differently.

Stuart Russell and Peter Norvig, in Artificial Intelligence: A Modern Approach, defined an agent as anything that perceives its environment through sensors and acts upon that environment through actuators. By that definition, a thermostat is an agent. A Roomba is an agent. Google Search is an agent. This is too broad to be useful in the context of LangGraph.

The useful definition, specific to the LLM era, is narrower. An LLM-based agent is a system in which a language model, given a specification of a task and a set of tools, can autonomously decide the sequence of reasoning steps and tool invocations needed to accomplish the task. The key words are autonomously and decide. If a human encodes the sequence (even cleverly, with many branches), it is not an agent. If the language model chooses the sequence at runtime based on intermediate observations, it is.

This narrower definition has three consequences. Agency belongs to the architecture, not to a model name. Agency varies by degree, from choosing between fixed routes to constructing long tool sequences. Every additional choice adds latency, cost and failure surface, so grant only the freedom the task can justify.

A useful distinction separates workflows, where code fixes the route and a model performs bounded sub-tasks, from agents, where a model may choose the next operation. Regulated systems should prefer the narrowest freedom that the task actually needs.

A state-topology view of deeper mechanism: the ontology of agency, using position, line and pattern so the decision remains legible without colour.

This spectrum, from left to right, is also roughly a cost and complexity gradient. Left-hand systems are cheap and simple. Right-hand systems are expensive and capable. Most banks run a portfolio. You will find single-LLM-call services in copywriting tools, prompt chains in FAQ responders, workflows in document summarisation, tool-using workflows in the majority of RM copilots, true agents in the most ambitious credit memo drafters, and multi-agent systems in the rare strategic initiatives that have had enough capital and patience to mature.

The consumer duty dimension

In a LangGraph-based mortgage agent, this translates into concrete design patterns. Every draft response is passed through a Consumer Duty node that checks for clarity (consumer understanding), flags vulnerability indicators, ensures no pressure selling language, and verifies that the product being offered is genuinely appropriate for the customer’s needs and circumstances. This node is not a rubber stamp. It is a separately-prompted call to the language model, given the draft response and the full context, asked to critique from a Consumer Duty perspective. If it raises concerns, the graph routes to a revision node or to human review.

class ConsumerDutyAssessmentState(TypedDict):
    draft_response: str
    customer_profile: dict
    product_being_discussed: Optional[str]
    # Four Consumer Duty outcomes
    products_and_services_ok: Optional[bool]
    price_and_value_ok: Optional[bool]
    consumer_understanding_ok: Optional[bool]
    consumer_support_ok: Optional[bool]
    # Specific flags
    pressure_selling_detected: bool
    vulnerability_indicators: List[str]
    accessibility_concerns: List[str]
    clarity_issues: List[str]
    # Decision
    passes_consumer_duty: Optional[bool]
    revision_required: bool
    human_review_required: bool

That is a state object carrying regulatory intent. It makes the Consumer Duty explicit rather than implicit, and that explicitness is, in practice, the single biggest architectural concession that banking teams need to make to get their agent systems past the conduct risk team.

A decision view of why a graph changes the problem, showing how state, route, authority and evidence change together.

Build a reproducible workshop

Why environment discipline matters more in banking

Environment discipline is part of governance. A reproducible build lets a team connect observed behaviour to an exact dependency set, investigate a defect and restore a known route.

The third is change management. When your agent system is behind a change advisory board, you need to be able to describe what has changed between releases. “We upgraded LangGraph from 0.3.15 to 0.3.16” is an auditable change. “We ran pip install without pinning versions” is not.

The fourth is regulatory portability. UK banks operate across multiple jurisdictions. A mortgage agent deployed in the UK may share code with a wealth management assistant deployed in Singapore. The same codebase needs to behave identically in both. Environment discipline is what makes this practical.

With those reasons in mind, Build.

Worked example: install everything, step by step

Step 1: confirm your Python version.

Open a terminal and type:

python3 --version

Use a Python version supported by the selected LangGraph release, then pin the interpreter and resolved dependencies in the build record. The command below shows one local installation path; verify the current package requirements before running it.

brew install python@3.11

On Windows, use the official installer from python.org, and importantly, tick the box that says “Add Python to PATH.” On Ubuntu:

sudo apt update && sudo apt install python3.11 python3.11-venv

Step 2: pick a home for your LangGraph work.

mkdir -p ~/LangGraphProjects
cd ~/LangGraphProjects

This directory will hold all your agent code for the rest of this edition. Inside it, each chapter’s code will go in its own subdirectory. This keeps the topology flat and inspectable.

Step 3: create a virtual environment.

A virtual environment is a directory that contains its own Python interpreter and its own set of installed packages, isolated from the system Python. Create one with:

python3 -m venv langgraph_env

This creates a langgraph_env directory containing bin/, lib/, and a few configuration files. That directory now holds everything Python-specific for this project.

Step 4: activate the virtual environment.

On macOS and Linux:

source langgraph_env/bin/activate

On Windows PowerShell:

.\langgraph_env\Scripts\Activate.ps1

(You may need to set the execution policy first with Set-ExecutionPolicy RemoteSigned run as an administrator.)

Your shell prompt should now show (langgraph_env) at the start. This means that when you type python or pip in this shell, you get the virtual environment’s versions, not the system’s. Activation is per-shell; if you open a new terminal, you have to activate again.

Step 5: install LangGraph and core packages.

pip install langgraph langchain langchain-openai langchain-google-vertexai langchain-community python-dotenv pydantic

You will also want development tools:

pip install jupyter ipykernel pytest pytest-asyncio black ruff mypy

These add: - jupyter and ipykernel: for interactive notebooks, useful when exploring agent behaviour - pytest and pytest-asyncio: for unit testing (we will use these from Chapter 5 onwards) - black: an opinionated code formatter - ruff: a fast linter that catches common mistakes - mypy: a static type checker that is invaluable when working with TypedDict state schemas

Step 6: pin your versions.

The single most important step for reproducibility:

pip freeze > requirements.txt

This writes the exact versions of every installed package (direct and transitive) to a file. Check this file into your source control. From now on, anyone else who wants to reproduce your environment can run:

pip install -r requirements.txt

and get the identical setup. This is the primitive of banking-grade reproducibility. Without pinned versions, your “it worked yesterday” can become “it does not work today” because some transitive dependency released a minor version overnight.

Step 7: configure your environment variables.

Create a file called .env in your project directory:

touch .env

Open it in your editor and add (for development, using an OpenAI key):

OPENAI_API_KEY=sk-your-key-here

for an operating deployment, Merehaven Bank engineers would instead configure Application Default Credentials for Vertex AI and reference Secret Manager-held secrets at runtime. But for the exercises in this edition, an OpenAI development key is simplest. Critically, add .env to your .gitignore file immediately:

echo ".env" >> .gitignore

Checking API keys into source control is the single most common security incident in AI engineering. Do not be the person who has to file an incident report because a customer committed their key to a public repository.

Step 8: verify the installation.

python -c "import langgraph; print(langgraph.__version__)"

You should see a version string like 0.3.x or higher. If you see an import error, back up and check that your virtual environment is activated (your prompt should show (langgraph_env)). If activation is fine but the import fails, check your requirements.txt against the versions that actually installed.

That is your workshop. A dedicated Python, a dedicated virtual environment, pinned versions, secrets kept out of source control, development tools for formatting and linting. It is not glamorous. It is the foundation everything else sits on.

Deeper: what a container looks like for an operating deployment

The development setup we just built is fine for your laptop. for an operating deployment, we containerise. A container is a self-contained package that bundles your code, your Python interpreter, your dependencies, and just enough operating system to run them. The advantage is that the container runs identically on your laptop, on a CI server, on a staging GKE cluster, and on production. Here is a minimal release-tested Dockerfile for a LangGraph service:

# Use a specific, pinned base image
FROM python:3.11.9-slim-bookworm

# Create a non-root user (banking security standard)
RUN useradd -m -u 1000 appuser

# Set up the application directory
WORKDIR /app
RUN chown appuser:appuser /app

# Copy dependency specification first for layer caching
COPY --chown=appuser:appuser requirements.txt .

# Install dependencies as root, then drop privileges
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY --chown=appuser:appuser . .

# Switch to non-root user
USER appuser

# Expose the service port
EXPOSE 8080

# Run the service
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Three build properties matter. The base image is pinned to a specific version of Python on a specific Debian release; not python:3.11 but python:3.11.9-slim-bookworm. This matters because “3.11” will drift as Python releases patch versions, and “slim” bases change frequently. Pinning to the specific patch version gives you bit-level reproducibility. The second is that the scenario runs as a non-root user, which is standard banking security practice (container escapes are easier from root processes). The third is that we install dependencies before copying the application code, which lets Docker cache the dependency layer separately from the fast-changing application layer.

The first graph: hello, LangGraph

Now that the workshop is ready, Build the smallest possible graph. Create a file called lesson1.py in your project directory:

# lesson1.py
# The simplest possible LangGraph: one node that greets.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

# Define the state structure
class HelloWorldState(TypedDict):
    greeting: str

# Define the node function
def greet_node(state: HelloWorldState) -> HelloWorldState:
    """Prepend 'Hello World, ' to whatever greeting is in the state."""
    state["greeting"] = "Hello World, " + state["greeting"]
    return state

# Initialise the graph and add the node
graph = StateGraph(HelloWorldState)
graph.add_node("greet", greet_node)

# Define the flow of execution using edges
graph.add_edge(START, "greet")
graph.add_edge("greet", END)

# Compile and run the graph
runnable = graph.compile()
result = runnable.invoke({"greeting": "from LangGraph!"})

# Output the result
print(result)
# Expected: {'greeting': 'Hello World, from LangGraph!'}

Run it:

python lesson1.py

You should see {'greeting': 'Hello World, from LangGraph!'} on your screen.

Walk through every line, because every line demonstrates something that will matter later.

The TypedDict definition of HelloWorldState declares the shape of data that flows through the graph. Python will not enforce this at runtime, but your type checker will, and LangGraph will use the schema for its own internal validation. When you start writing state objects with twenty fields, this discipline becomes priceless. Every field you declare here becomes a field the graph will track.

The node function greet_node takes a state and returns a state. In this case, it mutates the state in place and returns the same object, which is one valid pattern. Another valid pattern is to return a dictionary containing only the fields you want to update; LangGraph will merge this with the existing state. We will see both in later chapters.

The graph construction starts with StateGraph(HelloWorldState). The state schema is passed to the graph constructor, which uses it both for validation and for documentation. Every node you add will be expected to take a HelloWorldState and return a HelloWorldState (or a subset of its fields).

add_node("greet", greet_node) registers the function under a name. The name matters: it is how you reference the node in edges, in visualisations, and importantly, in audit traces. Pick names that would make sense to a compliance reviewer. “greet” is fine for this toy; “cross_reference_covenants” is better than “n17” for a production node.

add_edge(START, "greet") and add_edge("greet", END) define the flow. START and END are special sentinel nodes built into LangGraph. START is where execution begins; END is where it terminates. Every graph must have at least one edge from START to a real node, and at least one path from real nodes to END.

compile() validates the graph structure and returns an executable object. If your graph has errors (a node with no incoming edge, a dangling reference to a node that does not exist), compile will raise an exception with a useful error message. This is a point of safety: you cannot deploy a structurally broken graph to production.

invoke() runs the graph synchronously, from START to END, passing the initial state through every node. It returns the final state. There are other execution modes (stream, astream, abatch) which we will meet in later chapters, but invoke is the one you will use most when developing and testing.

Extending the toy: two nodes, one cycle-free graph

Extend the example to show composition. Add a second node:

# lesson1b.py
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class HelloWorldState(TypedDict):
    greeting: str

def greet_node(state: HelloWorldState) -> HelloWorldState:
    state["greeting"] = "Hello World, " + state["greeting"]
    return state

def exclaim_node(state: HelloWorldState) -> HelloWorldState:
    """Append an exclamation mark to the greeting."""
    state["greeting"] = state["greeting"] + "!"
    return state

graph = StateGraph(HelloWorldState)
graph.add_node("greet", greet_node)
graph.add_node("exclaim", exclaim_node)

graph.add_edge(START, "greet")
graph.add_edge("greet", "exclaim")
graph.add_edge("exclaim", END)

runnable = graph.compile()
result = runnable.invoke({"greeting": "from LangGraph!"})
print(result)
# Expected: {'greeting': 'Hello World, from LangGraph!!'}

The design shows how each node sees the result of the previous one. The greet node produces "Hello World, from LangGraph!". The exclaim node takes that and produces "Hello World, from LangGraph!!". This composition of transformations is the essence of the graph abstraction. Each node is a pure transformation. The state carries state. The graph coordinates.

This is also the first moment where the graph abstraction starts to pay for itself. In a chain-based world, you would have had to decide at the outset whether “greet then exclaim” was the right sequence. In the graph world, you can add, remove, or reorder nodes by changing edges. If next week you decide that the exclaim node should come before the greet node, you change two lines. If you decide that the exclaim node is only needed on certain inputs, you add a conditional edge. The architecture is mutable in a way that chains are not.

A state-topology view of extending the toy: two nodes, one cycle-free graph, using position, line and pattern so the decision remains legible without colour.

Visualising graphs: your future self will thank you

LangGraph has built-in support for rendering graphs to images, which is invaluable for debugging, documentation, and the audit trail. Add this utility module as display_graph.py:

# display_graph.py
import os
import platform
import random
import subprocess

def display_graph(runnable, output_folder: str = "output") -> str:
    """Render a compiled LangGraph to a PNG and open it."""
    from langchain_core.runnables.graph import MermaidDrawMethod

    os.makedirs(output_folder, exist_ok=True)
    # Render the graph through the Mermaid API
    png_bytes = runnable.get_graph(xray=1).draw_mermaid_png(
        draw_method=MermaidDrawMethod.API
    )
    # Write the bytes to a file with a semi-random name
    file_path = os.path.join(
        output_folder, f"graph_{random.randint(1, 999999)}.png"
    )
    with open(file_path, "wb") as f:
        f.write(png_bytes)

    # Open the PNG with the OS's default viewer
    if platform.system() == "Darwin":
        subprocess.run(["open", file_path])
    elif platform.system() == "Windows":
        os.startfile(file_path)
    else:  # Linux
        subprocess.run(["xdg-open", file_path])

    return file_path

Now from any of your lesson files:

from display_graph import display_graph
display_graph(runnable)

and you get a visual rendering of the graph in your default image viewer. for an operating deployment graphs, this visualisation becomes part of the architectural documentation. The compliance team will ask for diagrams. You want to be able to generate them automatically from the source of truth (the code), not maintain them separately (where they will drift out of date).

The failure mode: the dependency hell that eats your saturday

The failure mode in this section is not a bug in LangGraph. It is a bug in the Python packaging ecosystem, and it will bite you. Here is the shape of it.

One Saturday morning, you install a new version of LangGraph to try a new feature. Your virtual environment happily resolves dependencies, upgrades a few packages, and reports success. You run your existing code. It fails. The error is something obscure about Pydantic v1 versus v2 incompatibility, or about a method that moved from one module to another.

What has happened: a new version of LangGraph had updated constraints on one of its dependencies (typically Pydantic or LangChain core), and pip’s resolver upgraded that dependency to a version that is incompatible with code you wrote earlier. The fix, once you know what to do, is to pin your versions carefully and upgrade in deliberate steps, not in an eager blanket.

A second worked example: parameterising the greet node

Extend the Hello World example with explicit configuration. Suppose we want the greet prefix to be configurable rather than hard-coded. Here is a version with a config:

# lesson1c.py
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class HelloWorldState(TypedDict):
    greeting: str
    prefix: str  # now in the state

def greet_node(state: HelloWorldState) -> HelloWorldState:
    prefix = state.get("prefix", "Hello World, ")
    state["greeting"] = prefix + state["greeting"]
    return state

def exclaim_node(state: HelloWorldState) -> HelloWorldState:
    state["greeting"] = state["greeting"] + "!"
    return state

graph = StateGraph(HelloWorldState)
graph.add_node("greet", greet_node)
graph.add_node("exclaim", exclaim_node)
graph.add_edge(START, "greet")
graph.add_edge("greet", "exclaim")
graph.add_edge("exclaim", END)
runnable = graph.compile()

# Two invocations with different prefixes
result_en = runnable.invoke(
    {"greeting": "from LangGraph!", "prefix": "Hello World, "}
)
result_fr = runnable.invoke(
    {"greeting": "de LangGraph!", "prefix": "Bonjour le monde, "}
)

print(result_en)  # {'greeting': 'Hello World, from LangGraph!!', ...}
print(result_fr)  # {'greeting': 'Bonjour le monde, de LangGraph!!', ...}

Here, we made the prefix a field on the state. This is the idiomatic way to parameterise a graph at invocation time: pass the parameters in the initial state. It keeps the graph pure, makes testing trivial (just invoke with different states), and means that the parameters are captured in the audit trail alongside everything else.

There is a separate mechanism, configurable fields, for cross-cutting concerns like the model to use, the API endpoint, the temperature setting. We will meet these in later chapters. For now, put parameters on the state.

A second thought experiment: what the state really is

The discussion has used “state” repeatedly, but the next example makes it concrete. When LangGraph passes state through a graph, what is actually happening under the hood? Inspect the mechanism.

When you call graph.compile(), LangGraph builds an internal representation of your nodes and edges as a pregel-style graph, named after Google’s classic paper on graph computation. At runtime, when you call invoke, LangGraph initialises a state object from your input, then for each step, it identifies which nodes should execute based on the current state and the graph’s structure. It runs those nodes (potentially in parallel), collects their outputs, and merges them into the state using the schema’s merging rules.

The merging rules are important. By default, when a node returns a dictionary with a field, that field overwrites the state’s previous value. But for certain field types, you want to accumulate rather than overwrite. Consider a conversation history: each turn should append to the list, not replace it. LangGraph supports this through reducers, which are functions specified on fields via Annotated types:

from typing import TypedDict, List, Annotated
from operator import add

class ConversationState(TypedDict):
    messages: Annotated[List[str], add]  # append, not overwrite
    final_answer: str  # overwrite

Any node that returns {"messages": ["new turn"]} will cause LangGraph to concatenate that list with the existing state’s messages. Any node that returns {"final_answer": "foo"} will overwrite the previous value. This subtlety will matter enormously when we build memory-augmented agents in Chapter 6.

A state-topology view of a second thought experiment: what the state really is, using position, line and pattern so the decision remains legible without colour.

The reducer pattern is worth internalising because it is how you distinguish between values that replace and values that accumulate. In banking terms, a customer’s current account balance replaces (you do not “add” today’s balance to yesterday’s; you replace with the new value). A customer’s transaction history accumulates (you append each new transaction, you do not replace). Your state schema should declare this explicitly.

Another walk: a three-node example you can hold in your head

Consider one more complete worked example, this time with three nodes, because it captures a pattern you will see repeatedly: gather, transform, respond.

# lesson1d.py
# Three-node example: gather customer context, transform, respond.

from typing import TypedDict, Optional
from langgraph.graph import StateGraph, START, END

class CustomerQueryState(TypedDict):
    customer_id: str
    query: str
    # Filled in by nodes:
    account_balance: Optional[float]
    available_overdraft: Optional[float]
    response: Optional[str]

def gather_node(state: CustomerQueryState) -> CustomerQueryState:
    """Simulate fetching account details from the core banking system."""
    # in an operating environment this would be a real API call
    state["account_balance"] = 2345.67
    state["available_overdraft"] = 500.00
    return state

def transform_node(state: CustomerQueryState) -> CustomerQueryState:
    """Compute the effective available funds."""
    balance = state.get("account_balance", 0.0)
    overdraft = state.get("available_overdraft", 0.0)
    state["available_overdraft"] = balance + overdraft  # total spendable
    return state

def respond_node(state: CustomerQueryState) -> CustomerQueryState:
    """Generate a human-readable response."""
    total = state.get("available_overdraft", 0.0)
    state["response"] = (
        f"Your available funds, including agreed overdraft, "
        f"are £{total:,.2f}."
    )
    return state

graph = StateGraph(CustomerQueryState)
graph.add_node("gather", gather_node)
graph.add_node("transform", transform_node)
graph.add_node("respond", respond_node)

graph.add_edge(START, "gather")
graph.add_edge("gather", "transform")
graph.add_edge("transform", "respond")
graph.add_edge("respond", END)

runnable = graph.compile()
result = runnable.invoke({
    "customer_id": "C-00123456",
    "query": "How much can I spend today?"
})
print(result["response"])

This is a toy but it shows the gather, transform, respond pattern that underlies most banking agents. The gather node fetches evidence. The transform node applies business logic. The respond node produces the customer-facing output. In a operating system, each of these would be much richer: the gather node might call three APIs in parallel; the transform node might apply regulatory business logic and Consumer Duty checks; the respond node might call an LLM to produce natural language. But the shape is the same.

Deeper mechanism: configuring structured logging from day one

One thing I wish every engineer at a UK bank would internalise on day one is structured logging. Unstructured log lines (print("something happened")) are useless for an operating deployment debugging. Structured logs, where every log line is a JSON object with consistent field names, let you run queries, build dashboards, and trigger alerts.

Here is a minimal structured logging setup for a LangGraph project:

# logging_config.py
import logging
import sys
import json
from datetime import datetime, timezone
from typing import Any

class JSONFormatter(logging.Formatter):
    """Emit logs as JSON objects for Cloud Logging / CloudWatch ingestion."""
    def format(self, record: logging.LogRecord) -> str:
        log_obj: dict[str, Any] = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "severity": record.levelname,
            "message": record.getMessage(),
            "logger": record.name,
            "module": record.module,
            "function": record.funcName,
            "line": record.lineno,
        }
        # Attach any extra fields passed via extra=
        if hasattr(record, "state_snapshot"):
            log_obj["state_snapshot"] = record.state_snapshot
        if hasattr(record, "graph_node"):
            log_obj["graph_node"] = record.graph_node
        if hasattr(record, "session_id"):
            log_obj["session_id"] = record.session_id
        if record.exc_info:
            log_obj["exception"] = self.formatException(record.exc_info)
        return json.dumps(log_obj)

def configure_logging(level: int = logging.INFO) -> None:
    root = logging.getLogger()
    root.setLevel(level)
    # Remove any existing handlers
    for h in list(root.handlers):
        root.removeHandler(h)
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JSONFormatter())
    root.addHandler(handler)

And at the entry point of your application:

# main.py
from logging_config import configure_logging
import logging

configure_logging()
logger = logging.getLogger(__name__)

logger.info("service starting", extra={"version": "1.0.3"})

Now every log line is a JSON object. Cloud Logging on GCP (and CloudWatch on AWS) will ingest these natively and let you query by field. You can search for all logs from a specific session_id, all errors at a specific graph_node, all events in a time window. For compliance and debugging in a regulated environment, this is priceless.

The extra= parameter in the log call. Any keyword arguments you pass via extra become fields in the JSON output. In a LangGraph application, you will want to log the current graph node name, the session ID, a snapshot of the state (or at least a digest), and any tool calls or LLM responses. Structured logging makes all of this queryable.

A decision view of build a reproducible workshop, showing how state, route, authority and evidence change together.

Type the case file

Why Python’s typing matters now more than ever

In a banking context, typing does more than catch bugs. It serves three distinct governance purposes. First, it is a form of documentation that cannot rot. A docstring can become stale as code evolves; a type annotation cannot, because if it becomes wrong, mypy or the test suite will flag it. Second, it is a form of schema contract. When a node declares that it takes a CreditMemoState and returns a CreditMemoState, this is a contract that both parties (the graph and the node) can rely upon. Third, it is a form of audit artefact. During model risk validation, a reviewer can read the type signatures and understand, in minutes, what each component does without having to trace execution.

Good Python, in a bank, is typed Python. LangGraph makes this easy by using typing heavily in its own API.

TypedDict: the primary state mechanism

TypedDict, from PEP 589, is Python’s way of saying “I want a dictionary, but with declared keys and types.” It is Python’s preferred mechanism for LangGraph state. Here is the canonical form:

from typing import TypedDict, List, Optional
from datetime import datetime

class CreditMemoState(TypedDict):
    # Always-present fields
    customer_id: str
    request_type: str
    submitted_at: datetime
    
    # Filled as the graph progresses (hence Optional)
    financial_statements: Optional[dict]
    sector_analysis: Optional[str]
    covenant_check: Optional[dict]
    consumer_duty_flags: Optional[List[str]]
    
    # Always present, accumulates through the graph
    narrative_paragraphs: List[str]
    audit_entries: List[dict]

Why TypedDict rather than a regular class? Three reasons.

First, LangGraph expects dict-shaped state because it uses dictionary merging to apply node outputs to state. A TypedDict is a dict at runtime; you can subscript it with state["customer_id"]. A regular class would require attribute access, and LangGraph’s internals would need a different code path.

Second, TypedDict is lightweight. It is essentially a type hint on top of a regular dictionary; there is no runtime overhead beyond what you would pay for a dict anyway. Pydantic, by contrast, adds validation overhead on every assignment (which is often worth the cost, but not free).

Third, TypedDict plays well with JSON serialisation. LangGraph needs to serialise state for checkpointing, for cross-process passing, and for audit. A TypedDict maps cleanly to JSON. A Pydantic model does too, but TypedDict is one fewer conversion step.

Some notes on TypedDict that catch engineers out:

  • Optional does not mean “may be absent”; it means “may be None.” Optional[str] is equivalent to Union[str, None]. To say “this key may or may not be in the dict,” you use NotRequired[str] (from typing_extensions or Python 3.11+’s typing). In the Merehaven worked scenario, the scenario uses NotRequired for fields that might not appear in the initial state.
  • TypedDict inheritance works but is shallow. If you inherit from a TypedDict and add fields, the child has all the parent’s fields plus the new ones. But there is no runtime class hierarchy to query; at runtime these are all just dicts.
  • Total vs partial. By default a TypedDict is total=True, meaning every declared field is required. You can say total=False to make all fields optional. In practice, you will usually use NotRequired on specific fields rather than making the whole class partial.

Pydantic: the stricter alternative

Pydantic is a separate library that provides validated data classes. You declare your schema, Pydantic enforces it at runtime. When a state object violates the schema (missing required field, wrong type), Pydantic raises a clear error immediately rather than letting the error propagate silently.

from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime

class CreditMemoModel(BaseModel):
    customer_id: str = Field(..., regex=r"^C-\d{8}$")
    request_type: str = Field(..., pattern=r"^(extension|new|review)$")
    submitted_at: datetime
    
    financial_statements: Optional[dict] = None
    sector_analysis: Optional[str] = None
    
    narrative_paragraphs: List[str] = Field(default_factory=list)
    audit_entries: List[dict] = Field(default_factory=list)
    
    @validator("customer_id")
    def customer_id_must_be_active(cls, v):
        # Could call out to a database here
        return v

LangGraph supports Pydantic models as state schemas. The tradeoff is: Pydantic gives you runtime validation (great for catching bugs early and producing clear error messages at the boundary), at a small performance cost (maybe 5-10% overhead on state mutations) and some complexity overhead (field_validators, pre-validators, custom serialisation).

A useful boundary rule is: use Pydantic for the outer-most state schema (the one that crosses service boundaries), use TypedDict for internal, tightly-scoped state schemas used within a single graph or tool. This gives you validation where it matters (at trust boundaries) and avoids overhead where it doesn’t.

Annotated and reducers: the state merging primitive

We saw this in Chapter 2, but it deserves deeper treatment. When a LangGraph node returns a dict, LangGraph needs to know how to merge that dict into the existing state. For most fields, the default behaviour is “overwrite.” For some fields, you want to “append” or “sum” or “union.” This is what the Annotated type and reducers are for.

from typing import TypedDict, List, Annotated
from operator import add
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage

class ChatbotState(TypedDict):
    # Conversation history: append, never overwrite.
    messages: Annotated[List[BaseMessage], add_messages]
    # Working notes: append strings.
    notes: Annotated[List[str], add]
    # Retry count: replace (standard behaviour).
    retry_count: int
    # Cumulative cost: sum.
    total_cost: Annotated[float, lambda a, b: a + b]
    # Final answer: replace.
    answer: str

add_messages is a special LangGraph reducer that intelligently merges lists of BaseMessage objects. It handles a few tricky cases: if you return a message with an ID that already exists in the state, it replaces rather than appends (so you can update a message in place); it preserves ordering; and it handles both single messages and lists.

For custom reducers, any two-argument function that can combine the current value and the new value will work. operator.add works for lists and numbers. Lambda functions like lambda a, b: {**a, **b} work for dicts (shallow merge). You can write richer reducers for domain-specific needs (such as “merge two IFRS 9 provision schedules by date, keeping the latest”).

The reducer is the seam between nodes. Every piece of state has either a default merger (replace) or a custom one. When you design a graph, thinking about the reducer for each field is part of the design. Get it wrong and your state will grow unboundedly, or lose information, or produce mysterious bugs.

A state-topology view of annotated and reducers: the state merging primitive, using position, line and pattern so the decision remains legible without colour.

Async and await: the concurrency model

LangGraph nodes can be either synchronous or asynchronous. The choice matters because it determines how the graph schedules work. A graph with all-sync nodes runs one at a time. A graph with async nodes can do I/O concurrently, which is critical for performance.

A synchronous node function looks like this:

def fetch_exposures_sync(state: MyState) -> MyState:
    import requests
    resp = requests.get(f"https://api.bank/exposures/{state['customer_id']}")
    state["exposures"] = resp.json()
    return state

An asynchronous version looks like this:

import httpx

async def fetch_exposures_async(state: MyState) -> MyState:
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"https://api.bank/exposures/{state['customer_id']}"
        )
    state["exposures"] = resp.json()
    return state

The async version allows the graph to process other nodes while this one is waiting on network I/O. In a graph that needs to call three APIs in parallel, the async version completes in the time of the slowest call; the sync version takes the sum of the calls.

In LangGraph, you register async nodes the same way as sync ones:

graph.add_node("fetch_exposures", fetch_exposures_async)

LangGraph’s executor will detect the node’s async-ness and schedule it appropriately. You run an async graph with runnable.ainvoke(...) or await runnable.ainvoke(...). Mixing sync and async nodes in the same graph is allowed; the executor handles this transparently.

Useful project rules:

  • I/O-bound nodes (API calls, database queries, LLM invocations) are async. The exception is if the underlying client does not support async, in which case run the sync client inside asyncio.to_thread to avoid blocking the event loop.
  • CPU-bound nodes (embedding computation, local inference, heavy data processing) are sync, and ideally run in a worker pool or a dedicated service. Async Python does not help with CPU-bound work because of the GIL.
  • Graph invocation is async in an operating environment. Even if all nodes are sync, using ainvoke keeps your service’s event loop free to handle other requests.

The failure mode: reducers that lose data

The bug: after the graph ran, only the last node’s audit entry was in the state. All the earlier entries had been overwritten. The engineer spent two days assuming it was a threading issue.

The fix: audit_entries: Annotated[List[dict], add]. One line. Now every node’s contribution is appended rather than overwriting.

The lesson: whenever a field should accumulate, declare the reducer explicitly. If you are not sure, err on the side of declaring. The cost of a reducer is negligible; the cost of silent data loss can be weeks of investigation.

A thought experiment: designing the state for an autonomous mortgage agent

Consider the state design for something real. Imagine you are designing the state schema For the Merehaven worked scenario, an autonomous mortgage agent. The goal: from initial enquiry to agreement-in-principle within a single session, with Consumer Duty, vulnerability, affordability, sanctions, and PEP checks all performed.

What fields should be in the state? Build it up.

from typing import TypedDict, List, Optional, Annotated, Literal
from datetime import datetime
from operator import add
from pydantic import BaseModel

# --- Component models ---

class ApplicantDetails(BaseModel):
    customer_id: Optional[str] = None
    full_name: Optional[str] = None
    date_of_birth: Optional[str] = None
    current_address: Optional[str] = None
    employment_status: Optional[str] = None
    annual_income: Optional[float] = None
    # Vulnerability indicators
    declared_disability: Optional[bool] = None
    declared_health_issues: Optional[bool] = None

class PropertyDetails(BaseModel):
    property_address: Optional[str] = None
    property_value: Optional[float] = None
    property_type: Optional[str] = None
    deposit_amount: Optional[float] = None
    deposit_source: Optional[str] = None

class AffordabilityAssessment(BaseModel):
    monthly_household_income: Optional[float] = None
    monthly_household_commitments: Optional[float] = None
    proposed_monthly_repayment: Optional[float] = None
    stress_test_rate: Optional[float] = None
    stressed_repayment: Optional[float] = None
    passes_affordability: Optional[bool] = None
    affordability_rationale: Optional[str] = None

# --- Main state ---

class MortgageAgentState(TypedDict):
    # Session
    session_id: str
    subject_identifier: str
    channel: Literal["web", "mobile", "branch", "phone"]
    session_started_at: datetime
    
    # Conversation
    messages: Annotated[List[dict], add]  # Each message is {role, content, ts}
    current_turn: int
    
    # Application data
    applicant: ApplicantDetails
    property: PropertyDetails
    affordability: AffordabilityAssessment
    
    # Compliance checks
    consumer_duty_flags: Annotated[List[str], add]
    vulnerability_indicators: Annotated[List[str], add]
    sanctions_status: Literal["pending", "clear", "hit"]
    pep_status: Literal["pending", "clear", "hit"]
    kyc_status: Literal["pending", "clear", "incomplete", "failed"]
    
    # Decision
    decision: Optional[Literal["provisional_approve", "defer", "decline", "escalate"]]
    decision_rationale: Optional[str]
    
    # Audit
    audit_trail: Annotated[List[dict], add]
    model_versions: dict  # replaced (overwritten with current snapshot)

The design choices:

  • The state nests Pydantic models for structured sub-domains (applicant, property, affordability). These are validated at the boundary.
  • Scalar fields use TypedDict directly, with Literal types for enumerated values.
  • Accumulating fields (messages, flags, indicators, audit_trail) use Annotated[..., add] so nodes can append without overwriting.
  • Singleton fields that are gradually filled in (decision, rationale) are Optional and overwritten when the node produces the final value.
  • model_versions is a dict that is replaced each time, capturing a snapshot of which versions were in use.

A closer look at generics: list[str] versus list[str]

One detail that confuses many engineers moving to modern Python: the difference between List[str] (from typing) and list[str] (built-in generics). Both are valid; they mean the same thing to the type checker. The built-in form is available from Python 3.9 onwards. The typing form is available from Python 3.5 onwards.

For LangGraph work on Python 3.11+, both are fine. The built-in form is slightly preferred because it avoids an import and reads more naturally. The typing form is still necessary for Annotated, Union, Optional, and other constructs that have no built-in equivalent until Python 3.10+.

A useful rule: use list[str], dict[str, X], tuple[X, Y] for simple containers; keep from typing import ... for Annotated, TypedDict, Protocol, Literal, and friends. This is the style the LangGraph source itself tends toward, and it makes migration between projects smoother.

worked typing: a commercial banking state schema

from typing import Annotated, List, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph import add_messages
from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field
from datetime import datetime
from decimal import Decimal
import operator

class CustomerSnapshot(BaseModel):
    """Validated customer identity block. Crosses a trust boundary (ingress)."""
    customer_id: str = Field(pattern=r"^CUS-\d{10}$")
    segment: Literal["retail", "commercial", "corporate", "private"]
    risk_band: Literal["low", "medium", "high"]
    sanctions_cleared_at: datetime
    consumer_duty_vulnerable: bool = False

class CreditExposure(BaseModel):
    """A single exposure record. Decimal for money, not float."""
    product: Literal["mortgage", "card", "loan", "overdraft", "revolving_credit"]
    balance: Decimal
    limit: Optional[Decimal] = None
    originated_at: datetime
    ifrs9_stage: Literal[1, 2, 3]

class CreditMemoState(TypedDict, total=False):
    # Identity, validated at boundary
    customer: CustomerSnapshot

    # Accumulating fields with explicit reducers
    exposures: Annotated[List[CreditExposure], operator.add]
    audit_log: Annotated[List[str], operator.add]
    messages: Annotated[List[BaseMessage], add_messages]

    # Single-writer fields (replace semantics are fine)
    draft_memo: Optional[str]
    risk_rating: Optional[Literal["A", "B", "C", "D", "E"]]
    requires_human_review: bool
    reviewer_notes: Optional[str]

    # Telemetry, set once by the observability node
    trace_id: str
    started_at: datetime
    model_versions: dict[str, str]

Look at this schema carefully. Every type choice is deliberate. CustomerSnapshot is a Pydantic model because it enters the graph from an external API and must be validated. If a malformed customer object arrived, the validator would reject it immediately with a clear error message, exactly what you want at a trust boundary.

CreditExposure uses Decimal not float for monetary balances. Floats cannot represent 0.1 exactly. In a banking system, rounding errors compound across millions of calculations and eventually cause regulatory reporting discrepancies. Use Decimal for money. Always.

exposures and audit_log use operator.add as reducers because multiple nodes append to them. messages uses add_messages because it has special semantics (ID-based replacement for tool call updates). draft_memo, risk_rating, and reviewer_notes are single-writer fields; the default replace semantics are correct. trace_id, started_at, and model_versions are telemetry fields written once by the observability node.

The total schema is about thirty lines and fully captures the contract of a credit memo graph. A new engineer joining the team can read this schema and know, without reading any node code, what the graph operates on and how fields combine.

Context managers: the unsung heroes of resource safety

Every production LangGraph application touches resources that must be cleaned up: database connections, HTTP sessions, spans in a tracing system, locks, file handles. Python’s context manager protocol, the with statement, is the cleanest way to handle these. LangGraph nodes almost always use context managers internally.

from contextlib import asynccontextmanager
from opentelemetry import trace
import httpx

tracer = trace.get_tracer(__name__)

@asynccontextmanager
async def traced_call(node_name: str):
    """Create a span and ensure it closes even on exception."""
    with tracer.start_as_current_span(node_name) as span:
        try:
            yield span
        except Exception as e:
            span.record_exception(e)
            span.set_status(trace.StatusCode.ERROR, str(e))
            raise

async def fetch_exposures(state: CreditMemoState) -> dict:
    async with traced_call("fetch_exposures") as span:
        async with httpx.AsyncClient(timeout=5.0) as client:
            resp = await client.get(
                f"https://api.internal.merehaven.test/exposures/{state['customer'].customer_id}"
            )
            resp.raise_for_status()
            span.set_attribute("exposures.count", len(resp.json()))
            return {"exposures": resp.json()}

Three nested context managers, the tracer span, the HTTP client, and (implicitly) the resp object, are all guaranteed to clean up properly even if the node raises. This is how production nodes avoid leaking sockets, orphaned spans, and half-open database connections under load. It’s the kind of detail that makes the difference between a graph that runs fine in a lab and one that survives ninety-ninth percentile spikes in an operating environment.

Generators, iterators, and streaming

Production LLM applications need to stream. A chat UI that displays tokens as they arrive feels an order of magnitude more responsive than one that waits for the full response. LangGraph supports streaming through Python’s async iterator protocol, which builds on generators.

from typing import AsyncIterator

async def stream_tokens(state: ChatState) -> AsyncIterator[str]:
    async for chunk in llm.astream(state["messages"]):
        if chunk.content:
            yield chunk.content

# In the FastAPI handler:
@app.post("/chat/stream")
async def chat_stream(request: ChatRequest):
    async def event_stream():
        async for token in graph.astream({"messages": request.messages}):
            yield f"data: {json.dumps(token)}\n\n"
    return StreamingResponse(event_stream(), media_type="text/event-stream")

The async for and yield combination is the Python idiom for async generation. It’s simple to write and produces a resource-efficient stream that releases backpressure naturally. For a conversational AI in a banking app, Merehaven Bank RM copilot, for example, this is required by the design. Relationship managers do not want to wait three seconds for a full response; they want the first tokens immediately.

Dataclasses versus Pydantic versus TypedDict: a final summary

Python now has three common ways to define structured records. The distinctions matter for LangGraph:

  • @dataclass: lightweight, has __init__, __repr__, __eq__ generated. No validation. Good for internal objects that don’t cross boundaries and don’t need dict-style access.
  • TypedDict: typed dict-style access. No validation, no methods. The natural fit for LangGraph state because state merging is dict-based.
  • pydantic.BaseModel: full runtime validation, serialisation to/from JSON, custom validators. The right choice for ingress/egress boundaries.

For state, use TypedDict. For request/response models at API boundaries, use Pydantic. For small internal value objects (for example, a ScoringWeights struct used inside a node), a dataclass is fine and has the smallest overhead.

Putting it all together: a typed async RAG node

This final example synthesises everything in the chapter: TypedDict state, Pydantic validation at the boundary, Annotated with add_messages, async/await, context managers, and Protocol-based substitution.

from typing import Annotated, List, Protocol, runtime_checkable
from typing_extensions import TypedDict
from langgraph.graph import add_messages
from langchain_core.messages import BaseMessage, AIMessage, HumanMessage
from pydantic import BaseModel, Field
from contextlib import asynccontextmanager
import operator

class RAGQuery(BaseModel):
    """Validated at graph ingress."""
    question: str = Field(min_length=1, max_length=2000)
    customer_segment: str
    locale: str = "en-GB"

@runtime_checkable
class VectorRetriever(Protocol):
    async def aretrieve(self, query: str, k: int = 5) -> list[dict]: ...

class RAGState(TypedDict, total=False):
    query: RAGQuery
    retrieved: Annotated[List[dict], operator.add]
    citations: Annotated[List[str], operator.add]
    messages: Annotated[List[BaseMessage], add_messages]
    answer: str

async def retrieve_node(state: RAGState, retriever: VectorRetriever) -> dict:
    async with traced_call("retrieve"):
        docs = await retriever.aretrieve(
            state["query"].question,
            k=5,
        )
        return {
            "retrieved": docs,
            "citations": [d["source"] for d in docs],
        }

async def generate_node(state: RAGState) -> dict:
    async with traced_call("generate"):
        context = "\n\n".join(d["text"] for d in state["retrieved"])
        prompt = f"Context:\n{context}\n\nQuestion: {state['query'].question}"
        response = await llm.ainvoke(prompt)
        return {
            "messages": [AIMessage(content=response.content)],
            "answer": response.content,
        }

If you can read this block and understand every choice, you are ready for Chapter 4, and for an operating deployment.

Protocols and structural typing: the escape hatch

Occasionally you need typing that goes beyond TypedDict and Pydantic. LangGraph nodes sometimes need to accept “anything that behaves like a retriever” or “anything that has an ainvoke method.” Python’s answer is typing.Protocol, which provides structural typing (duck typing with static checking).

from typing import Protocol, runtime_checkable

@runtime_checkable
class Retriever(Protocol):
    async def aretrieve(self, query: str, k: int = 5) -> list[str]: ...

async def rag_node(state: RAGState, retriever: Retriever) -> dict:
    docs = await retriever.aretrieve(state["query"])
    return {"docs": docs}

A deeper walkthrough: evolving a state schema

Version 1: the naïve approach

from typing import TypedDict

class CreditMemoStateV1(TypedDict):
    customer_id: str
    memo_text: str
    approved: bool

This works for a trivial prototype but fails every real requirement. It has no audit trail (you cannot tell which LLM generated the memo, or when). It has no intermediate data (no exposures, no financials, no ratings). It has no error handling (if the credit search fails, where does the failure go?). A demo built on this state is the kind of demo that wins an internal hackathon and dies the first time a production incident requires root-cause analysis.

Version 2: adding messages and tools

from typing import TypedDict, Annotated, List
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages

class CreditMemoStateV2(TypedDict):
    customer_id: str
    messages: Annotated[List[BaseMessage], add_messages]
    exposures: List[dict]
    financials: dict
    proposed_rating: str
    memo_text: str
    approved: bool

Now the scenario has a conversation history and intermediate data. This is enough to build a working agent. But it is still missing the audit fields that a bank will demand before production. Who ran this? When? Against which model version? Which policy version? What was the retrieval corpus at the time the memo was drafted? The moment model risk validation gets their hands on this, version 2 will come back with a list of required additions.

Version 3: release-tested with audit

from typing import TypedDict, Annotated, List, Optional
from datetime import datetime
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
from pydantic import BaseModel

class Exposure(BaseModel):
    product: str
    balance: float
    arrears_months: int
    opened_on: datetime

class Financials(BaseModel):
    turnover: float
    ebitda: float
    net_debt: float
    interest_cover: float

class AuditTrail(BaseModel):
    run_id: str
    started_at: datetime
    model_name: str
    model_version: str
    policy_pack_version: str
    user_id: str
    tool_calls: List[dict] = []

class CreditMemoStateV3(TypedDict):
    # Inputs
    customer_id: str
    requested_facility: float
    # Agent working memory
    messages: Annotated[List[BaseMessage], add_messages]
    exposures: List[Exposure]
    financials: Optional[Financials]
    # Agent outputs
    proposed_rating: Optional[str]
    proposed_rating_justification: Optional[str]
    memo_text: Optional[str]
    # Governance
    audit: AuditTrail
    requires_human_review: bool
    human_decision: Optional[str]
    # Error surface
    errors: List[str]

This is the shape of real production state in a banking agent. The properties it now has. Separation of concerns: inputs, working memory, outputs, governance, errors are grouped logically. Typed sub-schemas: Exposure, Financials, and AuditTrail are Pydantic models, so they get full runtime validation. Explicit optionality: every field that is populated by the agent is Optional, clarifying what is expected at input versus what is produced. Audit first-class: AuditTrail is a required field, not an afterthought; every node that changes meaningful state appends a tool call entry.

The cost of this design is about thirty lines of code more than version 1. The payoff is that when an auditor or regulator asks “how did this memo get produced?” you can answer that question exhaustively.


Deeper mechanism: reducer semantics and concurrency

LangGraph’s reducers are what make parallel execution safe. Most state schemas can get away with the default “last write wins” semantics, but the moment you have any parallel branch (say, one node fetches exposures while another fetches market data in parallel) you need reducers that can merge those branches without losing data.

The built-in add_messages reducer is the canonical example. It takes a list of messages from the incoming state and appends the new messages, deduplicating by id. But you can write your own.

Consider a credit memo that aggregates findings from three parallel investigators: a financial analyst, a compliance reviewer, and a relationship manager. Each produces a dict of findings keyed by topic. You want to merge these into a single findings dict in state.

from typing import TypedDict, Annotated, Dict, List
from operator import add

def merge_findings(left: Dict[str, List[str]], right: Dict[str, List[str]]) -> Dict[str, List[str]]:
    """Merge two finding dicts: concatenate lists for shared keys, union the rest."""
    result = {**left}
    for key, value in right.items():
        if key in result:
            result[key] = result[key] + value
        else:
            result[key] = value
    return result

class InvestigationState(TypedDict):
    customer_id: str
    findings: Annotated[Dict[str, List[str]], merge_findings]
    notes: Annotated[List[str], add]

Now if three parallel nodes each emit {"findings": {"financials": ["concern A"]}} and {"findings": {"compliance": ["concern B"]}} and {"findings": {"financials": ["concern C"]}}, the merged result will be {"financials": ["concern A", "concern C"], "compliance": ["concern B"]}. Without the reducer, the last writer would clobber the others and you would silently lose information.

Three production rules for reducers.

Rule 1: reducers must be associative and commutative. LangGraph does not guarantee the order in which parallel branches complete. Your reducer must produce the same result regardless of order. add for lists is commutative; list concatenation with dedup is commutative; “last write wins” is not commutative and is exactly why it is dangerous in parallel contexts.

Rule 2: reducers must be pure. Do not mutate the input arguments. Return a new object. Mutation of a shared state reference across parallel threads is a recipe for Heisenbugs that disappear when you add logging.

Rule 3: reducers should be fast. They run on every state update. A reducer that does an expensive database call or a remote lookup will become a bottleneck. Keep the work in the node; keep the reducer to in-memory merging.

The failure mode: state bloat and its consequences

State bloat is the silent killer of long-running agents. Every time a node appends to state and nothing trims it, state grows. After twenty turns of a conversation, you might have 200KB of messages. After fifty, 500KB. At 1MB, serialisation to your checkpointer starts to take seconds. At 10MB, your graph grinds to a halt and you start getting timeouts from downstream services.

Three defensive patterns.

Pattern 1: message summarisation. When the message history exceeds a threshold (say, twenty messages or 4000 tokens), summarise the older portion into a single system message and retain only the last N raw messages. This is the pattern most production chat agents use by default.

from langchain_core.messages import SystemMessage, RemoveMessage

def summarise_if_needed(state: AgentState):
    messages = state["messages"]
    if len(messages) < 20:
        return {}  # nothing to do
    old = messages[:-8]
    summary = llm.invoke([
        SystemMessage("Summarise this conversation concisely."),
        *old,
    ])
    # Use RemoveMessage to drop the old messages from state
    remove_ops = [RemoveMessage(id=m.id) for m in old]
    summary_msg = SystemMessage(content=f"Summary of earlier conversation: {summary.content}")
    return {"messages": remove_ops + [summary_msg]}

Pattern 2: selective field pruning. Mark some state fields as ephemeral (working memory that need not be persisted) and drop them before checkpointing. LangGraph supports this via explicit state key reset in nodes that finish a phase.

Pattern 3: external storage for large blobs. If you need to include a 2MB document in the reasoning flow, do not put it in state. Store it in object storage (GCS or S3) and put a reference in state (a URI plus a hash). Nodes that need the content fetch it on demand. This keeps state small and the checkpoint fast.

In BFSI contexts, bloat is especially pernicious because your checkpointer is typically a regulated database like Cloud SQL or DynamoDB, with its own row-size limits and its own performance profile. A 5MB row is not only slow; it may trip alerts, get flagged by the DBA team, and cause your agent to be escalated as an infrastructure abuser.

A thought experiment: typing a multi-step mortgage application

Sketch the state schema on paper before reading further. What fields do you need? What types? Which are Optional? Which need reducers? Which need audit fields?

Here is a candidate design.

from typing import TypedDict, Annotated, List, Optional, Literal
from datetime import date
from pydantic import BaseModel, Field

class Applicant(BaseModel):
    customer_id: str
    date_of_birth: date
    employment_status: Literal["employed", "self_employed", "retired", "other"]
    gross_annual_income: float
    other_income: float = 0

class Property(BaseModel):
    postcode: str
    property_type: Literal["freehold", "leasehold"]
    estimated_value: float
    years_remaining_on_lease: Optional[int] = None

class AffordabilityAssessment(BaseModel):
    monthly_income_net: float
    committed_expenditure: float
    stress_tested_rate: float
    max_supportable_ltv: float
    passed: bool
    failure_reasons: List[str] = []

class MortgageApplicationState(TypedDict):
    # Inputs
    applicants: List[Applicant]
    subject_property: Property
    requested_loan: float
    requested_term_years: int
    # Agent working state
    messages: Annotated[List, add_messages]
    affordability: Optional[AffordabilityAssessment]
    credit_data: Optional[dict]
    outstanding_queries: List[str]
    # Decision outputs
    proposed_rate: Optional[float]
    proposed_term: Optional[int]
    decision: Optional[Literal["offer", "refer_to_human", "decline"]]
    decision_reasons: List[str]
    # Governance
    audit: AuditTrail
    fca_consumer_duty_checks: dict
    mcob_compliance_checks: dict

The omissions matter. It does not store the full conversation transcript in place of structured fields. It does not conflate inputs with outputs. It does not let the agent’s free-text reasoning become the source of truth. Those are common anti-patterns that feel natural early in a project and become painful to refactor later.

Operating boundary: typing, linting, and CI gates

On the engineering side, how do you make sure your team actually uses these types rigorously? Four things, in escalating severity.

First, mypy strict mode as a CI gate. Any PR that introduces a type error fails the build. Start with --strict on new modules and gradually extend to older ones. In the Merehaven worked scenario, the Python code for AI services runs in strict mode, with Any explicitly disallowed except in explicitly labelled interop boundaries.

Second, Pydantic for all external boundaries. Anything that crosses a network boundary (API input, Pub/Sub message, database row) uses a Pydantic model with runtime validation. TypedDict is fine for internal graph state but not for crossing trust boundaries.

Third, schema evolution reviews. Any change to a state schema that is persisted (such as a checkpointer schema) must be reviewed and migrated intentionally. Adding a required field is a breaking change; adding an optional field with a default is backwards compatible. Removing a field requires a deprecation cycle.

Fourth, runtime assertions at critical points. Even with mypy and Pydantic, runtime assertions at the start of each node (“I expect this state shape”) catch the cases where bad data has crept in from a path not covered by static typing. These should be cheap to evaluate and loud when they fail.

A decision view of type the case file, showing how state, route, authority and evidence change together.

Give the graph a grammar

The four primitives

A LangGraph has exactly four primitive elements. Everything else, subgraphs, tool calls, ReAct loops, hierarchical teams, plan-and-execute architectures, is built from these four things. Learn them cold.

The first primitive is state. State is the ledger of the computation. It is a Python object (a TypedDict or a Pydantic model) that carries all the information the graph cares about. At every step, state flows from one node to the next, being updated according to each field’s reducer.

The second primitive is the node. A node is a function (usually async) that takes state, does some work (calls an LLM, queries a database, runs a tool), and returns a partial update to the state. Nodes are pure with respect to their declared state updates: whatever they return is merged into state via reducers, and nothing else. A node that sneakily writes to a global variable is a node that will bite you in an operating environment.

The third primitive is the edge. An edge is a fixed arrow: “when this node finishes, go to that node.” Edges are deterministic. The simplest graph, input → process → output, is three nodes connected by two edges.

The fourth primitive is the conditional edge. A conditional edge is a branch: “after this node, look at state, and pick one of several next nodes.” Conditional edges are what make a LangGraph more than a linear chain. They let you route based on classification, retry on failure, escalate to a human, or loop back for another iteration.

These four primitives, state, node, edge, conditional edge, are sufficient to express every agent architecture in this edition. They are also sufficient to confuse engineers who’ve never seen them framed this way. The rest of the chapter walks through each in depth.

Nodes: the functional unit

A node in LangGraph is, The mechanism is direct: a callable. Here is the simplest node possible:

def add_one(state: dict) -> dict:
    return {"count": state.get("count", 0) + 1}

That’s it. A function that takes state, returns a partial update. No registration ceremony, no base class to inherit from, no decorator required. LangGraph embraces Python functions as first-class agents.

in an operating environment, nodes become more interesting:

from langchain_core.messages import AIMessage
from langchain_google_vertexai import ChatVertexAI

llm = ChatVertexAI(model_name="gemini-1.5-pro", temperature=0.2)

async def draft_memo_node(state: CreditMemoState) -> dict:
    """Draft a credit memo from customer and exposure data."""
    prompt = build_memo_prompt(state["customer"], state["exposures"])
    response = await llm.ainvoke(prompt)
    return {
        "draft_memo": response.content,
        "messages": [AIMessage(content=response.content)],
        "audit_log": [f"memo drafted by {llm.model_name} at {now_iso()}"],
    }

This node reads customer data and exposures from state, asks Gemini to draft a memo, and returns three updates: the draft text, an AI message (which will be appended to messages via add_messages), and an audit log entry (which will be appended via operator.add). It never mutates the input state; it returns a partial update and lets LangGraph merge it.

Nodes can be synchronous or asynchronous. For banking work, they should almost always be asynchronous because almost every node waits on I/O: an LLM call, a database query, a REST API. Async nodes release the event loop during waits and let the service process other requests concurrently.

Nodes can also be objects with an __call__ method, which is useful when a node needs configuration. For example, a “score_credit” node might need a scoring model to be injected:

class CreditScorer:
    def __init__(self, model, threshold: float = 0.6):
        self.model = model
        self.threshold = threshold

    async def __call__(self, state: CreditMemoState) -> dict:
        features = extract_features(state["customer"], state["exposures"])
        score = await self.model.apredict(features)
        return {
            "credit_score": score,
            "risk_rating": self.bucket(score),
            "audit_log": [f"score {score:.3f} bucketed to {self.bucket(score)}"],
        }

    def bucket(self, score: float) -> str:
        if score > 0.85: return "A"
        if score > 0.70: return "B"
        if score > 0.55: return "C"
        if score > 0.40: return "D"
        return "E"

This pattern, a class with __call__ that is registered as a node, is how you inject dependencies cleanly. It’s also how you make nodes testable: you construct a CreditScorer with a mock model and call it with synthetic state.

Edges: the simple connectors

Edges are the simplest primitive. An edge says: after node X, go to node Y. Nothing more. Here’s the syntax:

from langgraph.graph import StateGraph, END

builder = StateGraph(CreditMemoState)
builder.add_node("fetch", fetch_node)
builder.add_node("draft", draft_node)
builder.add_node("score", score_node)

builder.set_entry_point("fetch")
builder.add_edge("fetch", "draft")
builder.add_edge("draft", "score")
builder.add_edge("score", END)

graph = builder.compile()

This graph is fully linear: fetch → draft → score → END. It is, in effect, a chain. And that’s fine, sometimes a chain is what you need, and LangGraph makes it trivial to express. The power of LangGraph isn’t that you can’t build chains with it; it’s that you can start with a chain and add conditional branches later without rewriting.

The END sentinel is special. It’s imported from langgraph.graph and marks the graph’s terminal node. When execution reaches END, the graph returns the final state to the caller. You can have multiple edges leading to END, a graph can have many exit points, but you cannot exit without hitting END somewhere. Some graphs have a single END; others (for example, a customer service triage) have several, one per resolved path.

Conditional edges: the branching primitive

Conditional edges are where LangGraph earns its name. A conditional edge is a function that, given state, returns the name of the next node. Here’s the classic shape:

def route_after_score(state: CreditMemoState) -> str:
    if state["risk_rating"] in ("D", "E"):
        return "human_review"
    if state["requires_second_opinion"]:
        return "committee_review"
    return "format_output"

builder.add_conditional_edges(
    "score",
    route_after_score,
    {
        "human_review": "human_review",
        "committee_review": "committee_review",
        "format_output": "format_output",
    }
)

Three properties matter. First, the router is a pure function of state: no side effects, no I/O. It reads state, returns a string. Second, the third argument to add_conditional_edges is a mapping from router-returned strings to node names. This dual-layer addressing, the router can return any string; the map binds it to a concrete node, makes refactors safer. If you rename a node, you change the map, not every router. Third, conditional edges support multiple branches natively; there’s no nested if/else pattern to manage.

Conditional edges can do more than pick one next node. They can also return a list of next nodes, which causes fan-out: execution proceeds in parallel along multiple branches, and state merges when they rejoin. This is how you implement map-reduce patterns inside a graph.

def fan_out_to_scorers(state: CreditState) -> list[str]:
    """Run multiple scoring models in parallel, then merge."""
    return ["score_lgd", "score_pd", "score_ead"]

Each branch runs independently. When all three complete, LangGraph merges their updates into state using the reducers defined on each field, and the next edge fires. This parallelism is sometimes called a “barrier join” in concurrency literature; LangGraph implements it for you.

Building the graph: the stategraph API

The StateGraph class is the builder you use to assemble a graph from nodes and edges. The pattern is always the same:

  1. Instantiate StateGraph(StateType), passing the state schema so LangGraph knows the reducers.
  2. Add nodes with .add_node(name, function).
  3. Set the entry point with .set_entry_point(name).
  4. Add edges with .add_edge(from_name, to_name).
  5. Add conditional edges with .add_conditional_edges(from_name, router_fn, mapping).
  6. Compile with .compile(). Optionally pass a checkpointer to enable persistence.

Here is a full, production-shaped example for a commercial banking credit memo graph:

from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver

def build_credit_memo_graph(llm, scorer, retriever, saver):
    builder = StateGraph(CreditMemoState)

    # Ingestion and enrichment
    builder.add_node("validate", validate_node)
    builder.add_node("fetch_exposures", fetch_exposures_node)
    builder.add_node("retrieve_policies", make_retrieve_node(retriever))

    # Analysis
    builder.add_node("compute_ratios", compute_ratios_node)
    builder.add_node("score", CreditScorer(scorer))
    builder.add_node("draft_memo", make_draft_node(llm))

    # Review paths
    builder.add_node("committee_review", committee_review_node)
    builder.add_node("human_review", human_review_node)
    builder.add_node("finalise", finalise_node)

    # Flow
    builder.set_entry_point("validate")
    builder.add_edge("validate", "fetch_exposures")
    builder.add_edge("fetch_exposures", "retrieve_policies")
    builder.add_edge("retrieve_policies", "compute_ratios")
    builder.add_edge("compute_ratios", "score")
    builder.add_edge("score", "draft_memo")

    builder.add_conditional_edges(
        "draft_memo",
        route_after_draft,
        {
            "human_review": "human_review",
            "committee_review": "committee_review",
            "finalise": "finalise",
        }
    )
    builder.add_edge("human_review", "finalise")
    builder.add_edge("committee_review", "finalise")
    builder.add_edge("finalise", END)

    return builder.compile(checkpointer=saver)

That is not a demo graph. That is roughly the shape of a real credit memo pipeline at a UK high-street bank. The nodes are named for what they do; the edges follow a clear narrative (ingest, enrich, analyse, route to review, finalise); the router makes a single branching decision based on risk and complexity. A new engineer can read the builder section and understand the control flow in under a minute.

Visualising the graph

Here is the graph we just built, rendered as Mermaid:

A state-topology view of visualising the graph, using position, line and pattern so the decision remains legible without colour.

Figure 4.1: A production credit memo graph. Note the single conditional edge (route_after_draft) doing all the routing work. A graph with one well-placed decision is usually better than one with five scattered ones.

Execution semantics: how LangGraph actually runs

When you call graph.invoke(initial_state), what happens? Understanding this is the difference between debugging in an hour and debugging in a week.

LangGraph executes the graph as a series of supersteps. A superstep is a single discrete advance of the graph. In each superstep:

  1. LangGraph looks at the current state and determines which nodes are active (ready to run).
  2. All active nodes run concurrently (in parallel, if async).
  3. When all active nodes return, their updates are merged into state via the reducers defined on each field.
  4. LangGraph examines the edges leaving the nodes that just ran and determines the next set of active nodes.
  5. If the next set is empty or reaches END, execution halts.

This design has several consequences that trip up newcomers. First, nodes in the same superstep see the same input state; they do not see each other’s updates until the next superstep. This is by design, it makes parallel execution safe. Second, if two nodes both write to the same field in the same superstep, their updates are merged according to the field’s reducer. If the reducer is the default (replace), only one of the updates survives (which one is deterministic but depends on internal order); if the reducer is operator.add, both updates are concatenated. This is why reducers are so important for parallel execution. Third, a node cannot peek ahead at what the next node will do. A node only has access to current state plus its own logic.

A state-topology view of execution semantics: how langgraph actually runs, using position, line and pattern so the decision remains legible without colour.

Figure 4.2: Supersteps are barriers. All updates in a superstep are merged before the next one begins.

Thinking about execution: the pregel heritage

LangGraph’s execution model is inspired by Pregel, Google’s bulk-synchronous parallel graph processing framework. In Pregel, compute happens in supersteps, and messages passed during one superstep become visible in the next. LangGraph inherits this cleanliness: the superstep discipline makes parallelism safe and debugging tractable. When you inspect a trace of a LangGraph run, you see a sequence of well-defined superstep transitions, not a tangled thread-of-execution chart.

Failure modes in graph design

Three patterns reliably break graphs in an operating environment, and all three are about graph topology rather than node correctness.

The first is unreachable nodes. You add a node and forget to add an edge into it. The node is in the graph; it will never run. This can silently break functionality for months. Mitigation: a test that verifies every node in the graph is reachable from the entry point. LangGraph exposes this via graph introspection; write a test that asserts graph.nodes.keys() == reachable(graph, entry_point).

The second is infinite loops. A conditional edge routes back to a node that, given the updated state, routes back again. Without a cycle-breaker, the graph runs forever (until it hits a recursion depth limit and crashes). Mitigation: (a) include a loop counter in state and test it in the router; (b) set recursion_limit on the graph to a sane upper bound; (c) for ReAct agents specifically, use the max_iterations convention and have the router force a terminal path after the limit.

The third is unbounded fan-out. A fan-out router returns a list of nodes whose length is driven by data, for example, “one branch per account.” If an edge case causes the list to be enormous (ten thousand accounts), the graph may spawn more concurrent work than the event loop can handle. Mitigation: cap fan-out width in the router, or chunk the work into batches of reasonable size.

[!warning] failure specimen Early in the credit memo rollout, we had a graph that fanned out one branch per exposure. For a normal retail customer, this meant three or four branches. For a large commercial customer with over four hundred exposures, the graph spawned four hundred parallel branches, saturated the event loop, and timed out the Cloud Run instance. The fix was a fan-out cap of twenty with pagination over the remainder. No production incidents since.

Regulated banking walkthrough: the rm copilot graph

Consider another full graph, the Relationship Manager copilot, to reinforce the patterns.

def build_rm_copilot_graph(llm, crm_tool, docs_retriever, saver):
    builder = StateGraph(RMState)

    builder.add_node("classify_intent", make_classify_node(llm))
    builder.add_node("fetch_customer", make_crm_node(crm_tool))
    builder.add_node("retrieve_policies", make_retrieve_node(docs_retriever))
    builder.add_node("answer_simple", make_answer_node(llm, style="concise"))
    builder.add_node("answer_complex", make_answer_node(llm, style="detailed"))
    builder.add_node("escalate", escalate_node)

    builder.set_entry_point("classify_intent")
    builder.add_conditional_edges(
        "classify_intent",
        route_by_intent,
        {
            "simple_question": "answer_simple",
            "customer_specific": "fetch_customer",
            "policy_question": "retrieve_policies",
            "escalate": "escalate",
        }
    )
    builder.add_edge("fetch_customer", "answer_complex")
    builder.add_edge("retrieve_policies", "answer_complex")
    builder.add_edge("answer_simple", END)
    builder.add_edge("answer_complex", END)
    builder.add_edge("escalate", END)

    return builder.compile(checkpointer=saver)

The shape. Entry point is a classifier node (runs an LLM with a simple taxonomy prompt). The router branches on the classification: simple chat gets a fast path; customer-specific queries fetch CRM data first; policy questions run a retrieval step first; anything flagged as escalation goes to a human. All paths converge to END (except the fan-in through answer_complex).

A state-topology view of regulated banking walkthrough: the rm copilot graph, using position, line and pattern so the decision remains legible without colour.

Figure 4.3: The RM copilot graph. One classifier, one router, three worker paths, plus an escalation. Most useful graphs are not much more complex.

Testing graphs: unit, integration, and graph-shape

Graphs deserve three levels of testing. Unit tests verify individual node behaviour with mocked dependencies. Integration tests run the compiled graph end-to-end with real or fake LLMs against a golden set of inputs. Graph-shape tests verify structural properties: every node is reachable; every router returns a valid target; no cycles exist without a bound.

Here is a graph-shape test in pytest style:

def test_credit_memo_graph_shape():
    graph = build_credit_memo_graph(mock_llm, mock_scorer, mock_retriever, None)
    schema = graph.get_graph()

    assert "validate" in schema.nodes
    assert "finalise" in schema.nodes
    assert any(e.target == "human_review" for e in schema.edges)
    assert any(e.target == END for e in schema.edges)

def test_router_exhaustiveness():
    """Every value in the router mapping must be an actual node."""
    graph = build_credit_memo_graph(mock_llm, mock_scorer, mock_retriever, None)
    schema = graph.get_graph()
    node_names = set(schema.nodes.keys()) | {END}
    for edge in schema.edges:
        assert edge.target in node_names

These tests run in milliseconds because they don’t invoke any nodes. They catch the most common class of graph bug: a misspelled node name in a conditional edge map.

🗜 subgraphs: composition for the large

As a graph grows beyond a dozen nodes, it becomes hard to reason about. LangGraph supports subgraphs, a compiled graph used as a single node in a parent graph. Subgraphs are how you manage complexity.

retrieval_graph = build_retrieval_subgraph(retriever, reranker)
scoring_graph = build_scoring_subgraph(scorer_models)

parent_builder = StateGraph(CreditMemoState)
parent_builder.add_node("retrieve", retrieval_graph)  # subgraph as node
parent_builder.add_node("score", scoring_graph)       # subgraph as node

State flows into the subgraph, through its internal nodes, back out to the parent. The subgraph is a black box from the parent’s perspective, which means you can develop it, test it, and reason about it independently.

Subgraphs are also the unit of reuse across projects. In the Merehaven worked scenario, the scenario has a library of shared subgraphs: a “sanctions check” subgraph used by every money-movement graph; a “consumer duty gate” subgraph used by every customer-facing graph; a “PII redaction” subgraph used by every logging path. A new agent In the Merehaven worked scenario, snaps these in as nodes, and inherits the compliance posture for free.

Node state, edge state, and the visibility rule

One subtle property of LangGraph: nodes have full access to state, edges (routers) have full access to state, but nodes cannot “see” edges directly. This asymmetry matters because it keeps nodes portable. A node written for one graph can be dropped into another graph with a different topology, because the node doesn’t know anything about the graph around it. It only knows about state.

Treat attempts to bypass the visibility rule as a design smell. Promote the required information into typed state so the dependency is explicit and testable.

Thought experiment: redesigning the scoring graph

[!tip] Exercise You have a scoring graph with three scoring nodes (PD, LGD, EAD) running in parallel, then a combining node that produces the final rating. The compliance team asks: “Can we guarantee that if PD fails, we don’t produce a rating at all, we escalate?” How would you change the graph?

The answer: use a conditional edge after the fan-in. After the combining node, branch based on whether any input was missing. If PD (or any critical input) is None, route to escalate_missing_input rather than produce_rating. The beauty of LangGraph is this kind of compliance invariant is expressible declaratively in the graph, not hidden in imperative code.

def route_after_combine(state: ScoringState) -> str:
    if state["pd"] is None or state["lgd"] is None or state["ead"] is None:
        return "escalate_missing"
    if state["combined_score"] is None:
        return "escalate_missing"
    return "produce_rating"

Simple, auditable, and easy to evolve: tomorrow the compliance team may add “escalate if PD > 0.8,” and it’s a one-line change.

A decision view of give the graph a grammar, showing how state, route, authority and evidence change together.

Part II: Bound the agent

An agent is a route that may choose its next operation. That freedom is useful only when tools, state and stopping rules make the choice inspectable.

Chapter map for Part II: Bound the agent: Assemble a bounded tool-using agent; What we mean by “agent” in this chapter; The materials: what we are assembling; Piece 1: tools and the @tool decorator; Piece 2: state with add_messages.
Mermaid chapter map. Part II: Bound the agent connects Assemble a bounded tool-using agent, What we mean by “agent” in this chapter, The materials: what we are assembling, Piece 1: tools and the @tool decorator, Piece 2: state with add_messages.

Assemble a bounded tool-using agent

What we mean by “agent” in this chapter

The word “agent” is overloaded. In academic literature, it can mean almost anything autonomous. In the LangGraph world, we’ll use a precise definition: an agent is a graph whose behaviour includes an LLM making choices about what to do next, typically by calling tools. Agents observe, think, act, and observe again. The thinking is done by the LLM; the acting is done by tools; the observing is done by reading the results back into state.

Our first agent is going to be small. It will accept a natural-language question about a banking customer, decide whether it needs to call a tool (such as a customer-lookup or a policy-search), call it if needed, interpret the result, and reply. This is the simplest useful agent pattern in banking, and once you understand it, everything else in the remaining patterns are variations.

The materials: what we are assembling

A minimum viable agent needs four components in addition to the LangGraph primitives from Chapter 4:

Here is the entire code, and then we’ll take it apart piece by piece.

from typing import Annotated, List, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, END, add_messages
from langgraph.prebuilt import ToolNode
from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage
from langchain_core.tools import tool
from langchain_google_vertexai import ChatVertexAI

# 1. Define tools
@tool
def get_customer(customer_id: str) -> dict:
    """Return basic details for a customer by ID."""
    # In real code, this calls the CRM.
    return {"id": customer_id, "name": "Alice Smith", "segment": "retail"}

@tool
def search_policies(query: str, k: int = 3) -> list[dict]:
    """Search the internal policy library."""
    return [{"title": "Chargeback policy", "snippet": "..."}]

tools = [get_customer, search_policies]

# 2. Define state
class AgentState(TypedDict):
    messages: Annotated[List[BaseMessage], add_messages]

# 3. Build LLM with tools bound
llm = ChatVertexAI(model_name="gemini-1.5-pro", temperature=0.1)
llm_with_tools = llm.bind_tools(tools)

# 4. Define nodes
SYSTEM_PROMPT = """You are an assistant for Merehaven Bank peers.
Use the tools to look up customer information and policies.
Always cite policy titles when you reference policies."""

def agent_node(state: AgentState) -> dict:
    system = SystemMessage(content=SYSTEM_PROMPT)
    response = llm_with_tools.invoke([system] + state["messages"])
    return {"messages": [response]}

tool_node = ToolNode(tools)

def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
    last = state["messages"][-1]
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "__end__"

# 5. Build graph
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("tools", tool_node)
builder.set_entry_point("agent")
builder.add_conditional_edges("agent", should_continue)
builder.add_edge("tools", "agent")
agent = builder.compile()

That is a complete, working, production-shaped agent in fifty lines. It has a system prompt, two tools, a state schema, a graph with one conditional edge, and a loop between the agent node and the tools node. Everything else in this section is making sure you understand each line.

Piece 1: tools and the @tool decorator

The @tool decorator is how LangChain turns a Python function into something the LLM can call. It does three things. It captures the function’s signature, its argument names, types, and defaults. It captures the docstring as the description the LLM sees. And it wraps the function in a class that knows how to be invoked from a model-generated tool-call object.

Two patterns I enforce on every team. First, the docstring must start with a verb phrase describing what the tool does (“Return…” or “Compute…” or “Send…”). Second, every argument must have a type hint, because LangChain uses those hints to generate the JSON Schema the LLM sees. A tool with untyped arguments is a tool the LLM cannot call reliably.

@tool
def compute_ltv(
    loan_amount: float,
    property_value: float,
) -> float:
    """Return the loan-to-value ratio as a percentage.

    loan_amount and property_value are in GBP.
    """
    return 100.0 * loan_amount / property_value

Gemini and Claude both parse this as: a function named compute_ltv that takes two floats (loan_amount and property_value) and returns a float. When the user asks “what’s the LTV for a £300k loan on a £400k property?”, the model generates a tool-call with loan_amount=300000 and property_value=400000, invokes the tool, receives 75.0, and reports that back to the user. All of that happens via the graph loop.

Piece 2: state with add_messages

The agent’s state is deliberately minimal: just a single field, messages, annotated with add_messages as the reducer. Why? Because messages are the medium of the agent’s thinking. Every LLM call appends new messages. Every tool invocation appends a ToolMessage with the result. The agent’s memory of what has happened is the messages list.

add_messages is a clever reducer. It doesn’t just concatenate lists; it dedupes by message ID and allows updates to existing messages (which is important for streaming and for tool-call updates). Always use add_messages for message fields in LangGraph state. The default reducer (replace) would destroy the conversation history.

In richer agents, you will add more state fields: an answer field that the final node populates, an escalate boolean, a tool_call_count integer for limiting loops, a trace_id for observability. But start with just messages and add fields as you find concrete needs.

Piece 3: the agent node

The agent node is simple: it builds a list of messages starting with a system prompt, adds the conversation so far, and invokes the LLM. The LLM returns either a regular AIMessage (if it’s answering directly) or an AIMessage with tool_calls populated (if it wants to call a tool).

def agent_node(state: AgentState) -> dict:
    system = SystemMessage(content=SYSTEM_PROMPT)
    response = llm_with_tools.invoke([system] + state["messages"])
    return {"messages": [response]}

Three subtleties to note. First, the system prompt is prepended at every invocation, not stored in state. This keeps the system prompt as a constant owned by the code, not subject to manipulation by the conversation. Second, the node returns a partial update, a single message, rather than replacing the whole messages field. add_messages handles the merge. Third, the LLM is llm_with_tools, not raw llm. Binding the tools makes them available via the model’s function-calling interface; the LLM literally sees the tool schemas in its prompt template.

Piece 4: the tools node

ToolNode is a prebuilt node from langgraph.prebuilt. It takes the list of tools at construction time and knows how to execute any tool the LLM has requested via a tool-call. When the tools node runs, it looks at the last message in state, finds the tool calls, executes them (in parallel if there are multiple), and returns ToolMessage objects with the results.

You could write this yourself, it’s maybe twenty lines, but ToolNode handles the error cases (invalid arguments, exceptions during tool execution, missing tool names) more thoroughly than most first-draft implementations. Use it.

If you need custom behaviour (for example, logging every tool call, enforcing authorization before a tool runs, injecting rate limits), subclass ToolNode or wrap it in a higher-level node.

Piece 5: the conditional edge

The single conditional edge is the brain of the loop. After the agent node runs, we check the last message: does it have tool calls? If yes, go to the tools node; if no, end.

def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
    last = state["messages"][-1]
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "__end__"

That __end__ sentinel is how you return END from a conditional edge. After the tools node runs, the fixed edge builder.add_edge("tools", "agent") sends control back to the agent for the next iteration. The loop continues, agent → tools → agent → tools → …, until the agent produces a tool-call-free response and the router returns __end__.

This is the canonical “ReAct loop” from Yao et al. (2023), albeit simpler than the paper’s textual-reasoning version because we’re using native function calling. Chapter 8 will cover the deeper ReAct pattern with explicit reasoning traces.

The first worked agent: an rm copilot q&a

Consider a concrete case. Imagine a Merehaven Bank RM asks: “What is the current balance for customer CUS-0012345678 and does their profile require Consumer Duty vulnerable-customer handling?”

Here is, roughly, what happens inside the graph:

Superstep 1 (agent): The LLM reads the message. It recognises it needs to look up the customer. It emits an AIMessage with a tool_call for get_customer(customer_id="CUS-0012345678").

Superstep 2 (tools): ToolNode executes get_customer, receives a dict with the customer’s name, balance, and vulnerability flag. It emits a ToolMessage with that dict as the content.

Superstep 3 (agent): The LLM reads the tool result. It now has enough to answer. It emits an AIMessage with the answer, citing the vulnerability policy by name (because the system prompt told it to).

Superstep 4 (END): The router sees no tool_calls in the last message and returns __end__. The graph halts. The final state contains four messages: human → AI (with tool_call) → tool_result → AI (answer).

This is thinking. Not thinking in the philosophical sense, but thinking in the operational sense: the agent decided, acted, observed, and decided again. It did what a human RM does in miniature, albeit with vastly less context and judgment. But the structure is the same.

Banking-grade considerations: streaming, timeouts, retries

The fifty-line example runs fine in a notebook. Running it in an operating environment requires a few more concerns.

Streaming: Relationship managers do not want to wait three seconds for a full response. Use graph.astream() to stream messages back. Pair it with FastAPI’s StreamingResponse to produce a Server-Sent-Events stream to the browser. The user sees tokens appear as the LLM generates them.

@app.post("/agent/stream")
async def agent_stream(req: AgentRequest):
    async def event_stream():
        async for chunk in agent.astream(
            {"messages": [HumanMessage(content=req.message)]}
        ):
            yield f"data: {json.dumps(serialise(chunk))}\n\n"
    return StreamingResponse(event_stream(), media_type="text/event-stream")

Timeouts: Every LLM invocation and every tool invocation should have a timeout. Vertex AI and Bedrock clients accept a request_timeout parameter. Tools should use httpx or aiohttp with explicit timeouts on every HTTP call. A single hanging tool can hold a graph invocation open for minutes, which in a Cloud Run service means your instance is tied up and other requests queue.

Retries: Use tenacity or the client-library built-in retry for transient failures, 429 rate limits, 503 service unavailable, connection resets. Retry at the call site, not at the graph level. The graph should see either a successful call or a final failure; it shouldn’t be involved in the retry loop.

Bounded loops: Always set a max-iterations guard. The simplest is graph.compile().with_config({"recursion_limit": 25}) which caps total supersteps. A more domain-aware version: track a tool_call_count field in state and force termination in the router if it exceeds a threshold.

Security: bind exactly what you mean

A common early mistake: binding too many tools to the LLM. Every tool you bind is a tool the LLM may decide to call. In banking, that has security implications. A tool named transfer_funds must not be bound to an LLM that handles unauthenticated or low-authorization traffic. It’s not enough to implement authorization inside the tool; the LLM should not even see it.

A useful rule: each distinct authorization level gets its own LLM binding. the scenario has llm_with_read_tools, llm_with_advisory_tools, llm_with_action_tools. A customer-facing chatbot uses read; an RM copilot uses advisory; a back-office workflow with human-approval gates uses action. The same Python function is never wired as a tool in more than one binding class.

The failure modes: three you will see in week one

Failure mode A: the tool that always fails. The LLM calls get_customer, which raises because CRM is down. ToolNode catches the exception and returns a ToolMessage with an error string. The LLM sees the error, tries again, and the tool fails again, and again, and again, until recursion limit stops the loop. The agent appears unresponsive.

Mitigation: design tools to surface “not available right now, try later” explicitly in their error content. Tune the system prompt to instruct the model: “If a tool returns an error, apologise and do not retry.” Cap the per-tool retry count in state.

Failure mode B: the agent that never stops looking things up. The user asks a simple chat question; the LLM calls a tool anyway because the system prompt implies it should. Every response ping-pongs through a tool call. This is a prompt-engineering problem. Fix by making the system prompt explicit: “Only call tools when you need specific customer data or policy content. For conversational turns, reply directly.”

Failure mode C: prompt injection. User text or retrieved material may contain instructions that compete with application policy. Treat retrieved content as untrusted data, expose only least-privilege tools, validate typed actions outside the model and require independent authorisation for irreversible effects.

📈 observability for agents

Every agent run should emit:

  1. A trace showing every superstep, every LLM call, every tool call, with latencies and token counts.
  2. Logs with the trace ID, user ID, and a structured record of any tool invocations.
  3. Metrics for success rate, end-to-end latency (p50/p95/p99), tool-call-per-run distribution, and cost per run.

On GCP, we integrate OpenTelemetry with Cloud Trace. Each node emits a span; tool calls are child spans. On AWS, the same OpenTelemetry integration exports to X-Ray. The SDK abstractions (LangChain’s callbacks, LangSmith) plug into this for free, giving you both a structured trace and a human-readable trace viewer.

For banking, the trace is also the audit trail. When a regulator asks “why did the agent decline this?”, the answer is: here are the tool results the agent saw, here is the LLM’s response, here is the routing decision. All of that is in the trace. This is why observability is not an afterthought; it is a regulatory requirement.

A richer example: a RAG-aware rm copilot

Extend the agent with a retrieval tool. This adds an evidence route to the worked copilot.

@tool
async def retrieve_policy(query: str, k: int = 5) -> list[dict]:
    """Search the internal policy library and return top snippets."""
    results = await matching_engine.aretrieve(query, k=k)
    return [{"title": r.title, "snippet": r.snippet, "source_url": r.url} for r in results]

@tool
async def get_customer(customer_id: str) -> dict:
    """Return basic details for a customer by ID."""
    return await crm_client.aget(customer_id)

@tool
async def get_exposures(customer_id: str) -> list[dict]:
    """Return list of credit exposures for a customer."""
    return await exposure_service.aget(customer_id)

tools = [retrieve_policy, get_customer, get_exposures]

With these three tools bound, the agent can answer:

  • “What is our chargeback policy for digital-only customers?” (retrieve_policy)
  • “What products does CUS-0012345678 hold?” (get_customer, get_exposures)
  • “Does the vulnerable-customer policy require a cooling-off period for credit-card applications?” (retrieve_policy)

The same graph topology handles all of them. The LLM decides which tool (or tools) to call based on the question. If a question requires both customer data and policy context, the LLM may fan out two tool calls in parallel in a single superstep; ToolNode handles the parallelism automatically.

Thought experiment: what happens if you remove the system prompt?

[!tip] Exercise You remove the system prompt from the agent node. What happens?

The LLM still receives the user’s message and the tool schemas. It will still make reasonable decisions in many cases, modern LLMs are good at inferring “this is an assistant context.” But consistency drops. The model may give inconsistent tone (formal vs casual turn to turn). It may refuse to use tools because it doesn’t know what role it’s playing. It may cite policies inaccurately because it doesn’t know it should cite at all.

The system prompt is not decoration; it is the agent’s identity and standing orders. Removing it is like asking an employee to do a job without telling them what the job is.

A decision view of assemble a bounded tool-using agent, showing how state, route, authority and evidence change together.

Separate persistence from memory

The taxonomy of agent memory

Cognitive psychology distinguishes several kinds of memory. LangGraph gives us tools to implement each.

Short-term memory is the conversation buffer: the messages in the current thread. In our Chapter 5 agent, this is the messages field in state. It lasts for the duration of the graph invocation; when the invocation ends, it’s gone.

Long-term memory persists across invocations. “Alice prefers morning calls. Her last mortgage query was on 2026-03-14. She is flagged Consumer Duty vulnerable because of a recent bereavement.” These facts are not in the current message thread; they’re in a separate store that the agent can query.

Episodic memory records specific events. “On 2026-02-12 at 14:23, Alice asked about overpaying her mortgage. The conversation resolved with an offer of a callback, which the RM scheduled for 2026-02-14.” Episodic memory is structured log of discrete interactions.

Semantic memory is general knowledge: “Merehaven Bank policy allows mortgage overpayments of up to 10% of the outstanding balance per year without early-repayment charges.” Semantic memory often lives in the knowledge base and is accessed via retrieval (Chapter 11).

Procedural memory is “how to do things”: the system prompts, tool descriptions, and system rules that shape the agent’s behaviour. These live in code and configuration, not in a runtime store.

LangGraph addresses short-term memory through the checkpointer (thread-level persistence) and long-term/episodic memory through the store abstraction (cross-thread persistence). We’ll cover both.

Short-term memory: the checkpointer

A checkpointer is an object that persists graph state after each superstep. LangGraph ships with several implementations: MemorySaver (in-memory, for development), SqliteSaver (local file, for experiments), PostgresSaver (production). You attach one at compile time.

from langgraph.checkpoint.postgres import PostgresSaver

with PostgresSaver.from_conn_string("postgresql://user:pass@host/db") as saver:
    agent = builder.compile(checkpointer=saver)

Once compiled with a checkpointer, every graph invocation can be associated with a thread_id. Invocations sharing the same thread_id share state; the checkpointer reads prior state at the start of each invocation and writes updated state at the end. The thread_id is the unit of conversation.

# First turn
await agent.ainvoke(
    {"messages": [HumanMessage(content="Hi, I'm Alice")]},
    config={"configurable": {"thread_id": "alice-session-001"}}
)

# Second turn, same thread
await agent.ainvoke(
    {"messages": [HumanMessage(content="What did we talk about last time?")]},
    config={"configurable": {"thread_id": "alice-session-001"}}
)

The second invocation loads the state left by the first, appends the new human message (via add_messages), runs the graph, and saves the updated state. The agent sees the full message history and can respond accordingly.

Thread IDs are opaque strings; use what makes sense for your domain. For a logged-in web app, thread_id = f"{user_id}:{conversation_id}" is natural. For a voice channel, thread_id = f"call:{call_sid}". For a multi-session RM engagement, it might be thread_id = f"customer:{customer_id}" (all conversations with a customer share a thread).

What gets checkpointed

Every superstep produces a checkpoint. A checkpoint contains:

  1. The full state after the superstep.
  2. Metadata, which node ran, what edges fired, the superstep number.
  3. A parent_id pointing at the previous checkpoint, forming a linked list (actually a tree, if branches occur).

The checkpointer writes all of this to its backing store. On the next invocation with the same thread_id, the checkpointer reads the most recent checkpoint and restores state.

For PostgresSaver, the default schema is a few tables: checkpoints, writes, and pending writes. These scale to millions of threads if indexed correctly. The migration script ships with LangGraph.

Thread-level persistence in an operating environment: lessons learned

Lesson 3: thread hygiene matters. Do not reuse thread_ids across semantically different conversations. If a customer starts a new topic, start a new thread (or explicitly clear prior state). Otherwise, old context leaks into new conversations and causes surreal model behaviour. One rule of thumb: new subject → new thread.

Viewing thread history: time-travel and debugging

LangGraph exposes thread history via graph.get_state_history(config). You can iterate over checkpoints, inspect the state at any point, and even rewind.

config = {"configurable": {"thread_id": "alice-session-001"}}
history = [state async for state in agent.aget_state_history(config)]
for checkpoint in history:
    print(checkpoint.metadata.get("step"), checkpoint.values.get("messages", [])[-1])

This is astonishingly useful in an operating environment debugging. A customer reports “the assistant said something weird ten minutes ago.” You fetch the thread by ID, inspect each checkpoint, and can see exactly what state looked like at each step. For regulatory audit, this is a ready-made trail.

You can also time-travel: update the state at a specific checkpoint and re-run from that point, producing a divergent branch. This is the foundation for “what if I had answered differently?” patterns, which Chapter 9 (human-in-the-loop) uses heavily.

# Rewind to checkpoint X, modify state, re-run
await agent.aupdate_state(
    config={"configurable": {"thread_id": "alice-session-001", "checkpoint_id": "X"}},
    values={"draft_memo": "Revised version..."}
)
await agent.ainvoke(None, config=...)  # Continue from the updated checkpoint

Long-term memory: the store

Checkpointer persistence is thread-scoped. Long-term memory is cross-thread: it follows the user, not the conversation. LangGraph provides the store abstraction for this.

from langgraph.store.postgres import PostgresStore

store = PostgresStore(conn_string="postgresql://...")

# Put a memory
store.put(
    namespace=("memories", "alice-0012345678"),
    key="preferred_contact_time",
    value={"value": "morning", "reason": "stated during 2026-03-14 call"}
)

# Retrieve all memories in a namespace
memories = store.search(namespace=("memories", "alice-0012345678"))

The namespace is a tuple that organises memories; (memories, user_id) is a common pattern. key is a string identifier; value is a JSON-serialisable payload. You can also attach vector embeddings for semantic search over memories.

In graph nodes, the store is accessed via a config parameter:

def agent_node(state: AgentState, config, *, store) -> dict:
    user_id = config["configurable"]["user_id"]
    memories = store.search(("memories", user_id), limit=10)
    context = format_memories(memories)

    system = SystemMessage(content=f"{BASE_PROMPT}\n\nRelevant memories:\n{context}")
    response = llm_with_tools.invoke([system] + state["messages"])
    return {"messages": [response]}

The node injects long-term memories into the system prompt at every invocation. Combined with short-term memory via the checkpointer, the agent now has both “what we said in this conversation” and “what I know about this person from across conversations.”

Writing memories: when and how

An underappreciated question: when does an agent write to long-term memory? Three common patterns.

Explicit memory node: a dedicated node in the graph that, after the agent node, extracts facts from the conversation and writes them. “The customer said their preferred contact time is mornings, store that as preferred_contact_time.” This is reliable because the write is deterministic.

Tool-based memory: a remember tool that the LLM can call. This gives the LLM agency over what to remember. More flexible but harder to control; the LLM may write spurious facts or omit important ones.

Background memory: a separate, asynchronous process consumes conversation transcripts and extracts facts in batch. This decouples memory extraction from live response, reducing latency. In the Merehaven worked scenario, the scenario uses a combination: explicit memory nodes for known-important facts (preferences, flags), plus a nightly batch process that runs on fresh transcripts to extract additional semantic memories.

An important banking consideration: never write unverified claims to long-term memory. If a customer says “I’m a doctor,” do not write occupation=doctor unless you have corroboration. The agent is not a verification service; claims should be stored as claims, not facts. At most, write customer_claimed(occupation, "doctor", at=2026-03-14). The distinction between fact and claim is the difference between a useful memory store and a liability.

Semantic memory search via embeddings

Long-term memory scales naturally via embeddings. Every memory is embedded; retrieval is by vector similarity. This lets the agent ask questions like “what did this customer tell us about their financial goals?” and get relevant memories ranked by semantic match.

store = PostgresStore(
    conn_string="...",
    index={
        "dims": 768,
        "embed": vertex_ai_embedding_model,
    }
)

# Store a memory with automatic embedding
store.put(
    namespace=("memories", customer_id),
    key="goal_2026",
    value={"text": "Planning to buy a home in the next two years, saving for a deposit"}
)

# Search semantically
results = store.search(
    namespace=("memories", customer_id),
    query="financial goals",
    limit=5
)

This pattern is directly applicable to BFSI. For a wealth RM, pulling the “what are this client’s known goals?” memories into the system prompt before every conversation step means the agent speaks with context no human could hold in their head across two hundred clients.

A richer graph with memory

Let’s update the Chapter 5 agent with both short-term (checkpointer) and long-term (store) memory.

from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.postgres import PostgresStore

class AgentState(TypedDict):
    messages: Annotated[List[BaseMessage], add_messages]

def retrieve_memories_node(state, config, *, store):
    user_id = config["configurable"]["user_id"]
    memories = store.search(("memories", user_id), query=last_user_text(state), limit=5)
    context = "\n".join(f"- {m.value['text']}" for m in memories)
    # inject into state as a system message? Or keep separate?
    return {"memory_context": context}

def agent_node(state, config, *, store):
    system = SystemMessage(
        content=f"{BASE_PROMPT}\n\nLong-term memories about this user:\n{state.get('memory_context', '(none)')}"
    )
    response = llm_with_tools.invoke([system] + state["messages"])
    return {"messages": [response]}

def write_memory_node(state, config, *, store):
    # Extract new facts via a small structured-output LLM call
    user_id = config["configurable"]["user_id"]
    facts = extract_facts(state["messages"][-4:])
    for fact in facts:
        store.put(
            namespace=("memories", user_id),
            key=fact.key,
            value={"text": fact.text, "source_turn": len(state["messages"])}
        )
    return {}

builder = StateGraph(AgentState)
builder.add_node("retrieve_memories", retrieve_memories_node)
builder.add_node("agent", agent_node)
builder.add_node("tools", tool_node)
builder.add_node("write_memory", write_memory_node)
builder.set_entry_point("retrieve_memories")
builder.add_edge("retrieve_memories", "agent")
builder.add_conditional_edges("agent", should_continue)
builder.add_edge("tools", "agent")
# After agent produces a final response, write memories before END
builder.add_edge("agent", "write_memory")  # conditional: only when no tool calls
builder.add_edge("write_memory", END)

agent = builder.compile(checkpointer=saver, store=store)

This graph runs two additional nodes around the core loop: retrieve_memories at the start, write_memory at the end. Both interact with the store. The checkpointer handles the messages field automatically.

A state-topology view of a richer graph with memory, using position, line and pattern so the decision remains legible without colour.

Figure 6.1: An agent with both short-term and long-term memory. The retrieve_memories node injects relevant long-term memories at the start; the write_memory node extracts new facts at the end.

Regulated banking memory patterns: a specific design

For the Merehaven worked scenario, a wealth-management RM copilot, the scenario maintains five kinds of memory:

  1. Identity memories: name, preferred name, contact details, preferred contact time. Small, stable, written rarely.
  2. Goal memories: stated financial goals with dates and amounts. Written when the customer shares a goal; updated if they revise.
  3. Constraint memories: “Do not recommend crypto products,” “Strongly risk-averse,” etc. Written at onboarding or when stated.
  4. Event memories: significant life events that affect advice, bereavement, redundancy, relocation. Written (with consent) when disclosed.
  5. Conversation-summary memories: a distilled summary of each significant conversation, written at end of session. Keeps context without requiring the full transcript.

We namespace by customer ID: ("memories", customer_id, kind). Each namespace has its own retention policy. Identity memories live indefinitely (with deletion support for GDPR); conversation-summary memories expire after two years; goal and constraint memories are reviewed annually during the suitability review.

Memory failure modes

Failure A: memory drift. Over time, the store accumulates contradictory memories (“prefers morning” from 2025 and “prefers evening” from 2026). Mitigations: timestamp every memory and, at retrieval, prefer the most recent; implement a consolidation job that merges or supersedes older memories; design the extraction prompt to recognise updates.

Failure B: memory pollution. An LLM hallucinates a fact and writes it to memory. Later retrievals return the hallucination. Mitigations: never let the LLM write directly; route writes through a validation layer; keep an audit trail of every write; offer a “forget” path for users.

Failure C: memory overreach. The agent retrieves a memory that’s embarrassingly relevant (“you told us about your divorce”) and surfaces it in an off-topic conversation. Mitigations: tag memories with sensitivity levels; only retrieve sensitive memories when the current turn explicitly warrants it; err on the side of not bringing things up.

A decision view of separate persistence from memory, showing how state, route, authority and evidence change together.

Route by evidence and limit

The three kinds of routing

Think of routing in three tiers of sophistication.

Static routing is the fixed edge: add_edge(A, B). A to B, always. Use this for sequencing that never varies.

Conditional routing is the add_conditional_edges pattern. A routing function examines state and returns the name of the next node (or a list, for fan-out). The router is a pure function of state.

Command-based routing is a newer LangGraph pattern where a node itself returns a Command object specifying the next node. This is the cleanest way to express “the agent decided what to do next”, the decision lives in the node (close to the reasoning) rather than in a separate router function.

All three compose. A production graph uses static routing for deterministic sequences, conditional routing for structural decisions (classification, validation), and command-based routing for LLM-driven handoffs.

Conditional routing: the disciplined pattern

The conditional edge pattern shines when the decision is based on structural state, not LLM judgement. For example: “if the credit score is below 0.5, escalate to human; otherwise finalise.”

def route_after_score(state: CreditState) -> str:
    if state["credit_score"] < 0.5:
        return "escalate"
    if state["requires_committee"]:
        return "committee_review"
    return "finalise"

builder.add_conditional_edges(
    "score",
    route_after_score,
    {
        "escalate": "human_review",
        "committee_review": "committee_review",
        "finalise": "finalise",
    }
)

The router is deterministic, pure, and testable. I can write unit tests for route_after_score that cover every branch in seconds, without touching an LLM. in an operating environment, when a regulator asks “why was this case escalated?”, the answer is “because credit_score < 0.5 in the router.” Clear and auditable.

A useful rule: any routing decision that can be expressed as structural logic should be expressed that way, not delegated to an LLM. LLM-based routing is a hammer; sometimes you need a scalpel.

LLM-based routing: classification as a node

When routing does require LLM judgement, “is this question a general enquiry, a complaint, or a compliance issue?”, express it as a classification node whose output feeds a structural router.

class Intent(BaseModel):
    kind: Literal["general", "complaint", "compliance"]
    confidence: float

def classify_intent_node(state, *, llm):
    prompt = f"Classify the user's intent: {last_user(state).content}"
    intent: Intent = llm.with_structured_output(Intent).invoke(prompt)
    return {"intent": intent}

def route_by_intent(state) -> str:
    if state["intent"].confidence < 0.6:
        return "clarify"
    return state["intent"].kind

builder.add_node("classify_intent", classify_intent_node)
builder.add_conditional_edges("classify_intent", route_by_intent, {...})

Two advantages over embedding the classification in the router directly. First, the classification result is now in state, where observability and audit can see it. Second, the router is still pure: it reads state and returns a name. This separation of concerns, LLM produces structured output; router dispatches on it, is the cleanest pattern for LLM-assisted routing.

Command-based routing: the modern idiom

LangGraph 0.2+ supports Command, a construct that lets a node return both state updates and a routing directive in one object.

from langgraph.types import Command

def agent_node(state, config):
    response = llm_with_tools.invoke([...])
    if response.tool_calls:
        return Command(
            update={"messages": [response]},
            goto="tools"
        )
    return Command(
        update={"messages": [response]},
        goto="__end__"
    )

This collapses the agent node and the should_continue router into a single place. The decision “should this edition calls tools or finish?” is made where the reasoning happens, not in a separate function looking at artifacts of the reasoning. For multi-agent architectures (Chapter 12), Command is essential: one agent finishing decides which other agent should pick up, and that decision is naturally expressed in the finishing agent’s node.

Command also supports cross-subgraph navigation via graph=Command.PARENT, allowing a subgraph to hand control back to its parent explicitly. This is the foundation for hierarchical agent teams.

Dynamic routing: fan-out and fan-in

Sometimes a router doesn’t pick one next node; it picks several. Returning a list of names causes fan-out: all named nodes run in parallel in the next superstep.

def fan_out_scoring(state) -> list[str]:
    """Run PD, LGD, and EAD scoring models in parallel."""
    return ["score_pd", "score_lgd", "score_ead"]

All three scoring nodes run concurrently. When they complete, their state updates merge, and this is where reducers matter. If each writes a distinct field (pd, lgd, ead), there’s no conflict. If they all write to scores as a list, you need Annotated[List, operator.add] so that the three updates concatenate instead of overwriting each other.

Fan-in happens implicitly: once all parallel branches complete their current superstep, LangGraph moves to the next superstep, which sees all updates merged. A combining node then reads the multi-value state and produces a single decision.

Loop routing: the bounded cycle

Many agent patterns involve controlled loops: a ReAct agent loops until done; a reflection pattern loops until a critic approves. Loops are expressed by edges that go “back” in the graph, with a router enforcing termination.

def should_continue_reflection(state) -> str:
    if state["iteration"] >= 3:
        return "finalise"
    if state["critic_approved"]:
        return "finalise"
    return "reviser"

builder.add_edge("generator", "critic")
builder.add_conditional_edges("critic", should_continue_reflection, {
    "reviser": "reviser",
    "finalise": "finalise",
})
builder.add_edge("reviser", "critic")

Generator → Critic → Reviser → Critic → … until the critic approves or the iteration cap is reached. Every loop in a LangGraph should have both a content-driven exit (the critic’s decision) and a count-driven exit (the iteration cap). The content exit handles the happy path; the count exit prevents pathological looping.

Routing in a regulated banking context

Consider a worked routing pattern from the Merehaven Bank RM copilot. The RM asks a question. The graph classifies the question, fetches appropriate context, routes to a specialised answerer, and writes an audit log. The routing diagram:

A state-topology view of routing in a regulated banking context, using position, line and pattern so the decision remains legible without colour.

Figure 7.1: A BFSI routing pattern. One classification, three specialised paths, one compliance gate, one audit. Each diamond is a routing decision.

The classifier does one job: decide the kind of request. The compliance gate does one job: verify the proposed answer is compliant (no restricted advice, no unauthorised customer data disclosure). Three specialist answerers each own their domain. The graph has three routing decisions; each is small and testable.

A decision view of route by evidence and limit, showing how state, route, authority and evidence change together.

Make reasoning observable without worshipping it

Why ReAct still matters

In October 2022, Shunyu Yao and collaborators at Princeton published a paper with a wordy title: ReAct: Synergizing Reasoning and Acting in Language Models. The pattern it described, interleave reasoning traces (“Thought: the customer asked about overpayments; I should look up the policy”) with actions (“Action: search_policies(‘overpayment’)”) and observations (“Observation: …”), was simple enough that many reviewers thought it was obvious. It was not obvious to the engineers actually shipping agents, and the paper became one of the most cited in the agent literature.

Why? Because ReAct did three things at once. It made reasoning explicit (not a black box inside the model). It made actions grounded in reasoning (the model had to justify each tool call). And it produced traces that humans could read and debug. In a banking context, all three of those properties are not nice-to-haves; they are compliance requirements.

The structure of ReAct

A ReAct turn has three parts: Thought, Action, Observation. The agent produces a thought describing its reasoning, then an action (tool call with arguments), then reads back an observation (tool result). The loop continues until the agent’s thought concludes no more action is needed and produces an Answer.

In explicit form:

Thought: I need to find the customer's recent exposures before I can draft a memo.
Action: get_exposures(customer_id="CUS-0012345678")
Observation: [{product: "mortgage", balance: 250000, ...}, ...]
Thought: I have the exposures. Now I need the latest credit policies to reference.
Action: search_policies(query="commercial credit memo template")
Observation: [{title: "...", snippet: "..."}]
Thought: I have enough context. Drafting the memo now.
Answer: <memo text>

This is the literal format in Yao’s paper. In modern implementations, we often separate the Thought into its own message, emit the Action as a tool call, and get the Observation back as a tool message. The structure is preserved; only the format differs.

Building a ReAct agent in LangGraph

Here’s a ReAct-style agent that emits explicit thoughts:

from langchain_core.messages import AIMessage, ToolMessage, SystemMessage

REACT_SYSTEM = """You are a credit memo drafting assistant.
For each step, emit a "Thought:" section explaining your reasoning, then either
call a tool or produce a final "Answer:" block. Always justify tool calls."""

class ReActState(TypedDict):
    messages: Annotated[List[BaseMessage], add_messages]
    iteration: int

def agent_node(state: ReActState):
    response = llm_with_tools.invoke([
        SystemMessage(content=REACT_SYSTEM),
        *state["messages"]
    ])
    return {
        "messages": [response],
        "iteration": state.get("iteration", 0) + 1
    }

def should_continue(state: ReActState) -> str:
    last = state["messages"][-1]
    if state["iteration"] >= 8:
        return "__end__"  # safety bound
    if hasattr(last, "tool_calls") and last.tool_calls:
        return "tools"
    return "__end__"

The iteration counter is the bound. Without it, a confused model can loop indefinitely, racking up cost. Eight iterations is a reasonable default for most BFSI tasks; complex multi-step drafting may warrant twelve.

ReAct with a dedicated thinking phase

A richer variant separates “thinking” from “acting” as distinct nodes. The thinker node produces reasoning; the actor node converts the thought into a tool call.

def thinker_node(state):
    prompt = "Given the conversation so far, what should we do next? Think step by step."
    thought = thinking_llm.invoke([*state["messages"], HumanMessage(content=prompt)])
    return {"messages": [AIMessage(content=f"Thought: {thought.content}")]}

def actor_node(state):
    prompt = "Based on the thought above, produce a tool call or a final answer."
    response = acting_llm_with_tools.invoke([*state["messages"], HumanMessage(content=prompt)])
    return {"messages": [response]}

builder = StateGraph(ReActState)
builder.add_node("thinker", thinker_node)
builder.add_node("actor", actor_node)
builder.add_node("tools", tool_node)
builder.set_entry_point("thinker")
builder.add_edge("thinker", "actor")
builder.add_conditional_edges("actor", should_continue)
builder.add_edge("tools", "thinker")

Why separate? Because the thinker can be a different model. For routine decisions, use a cheaper/faster model (a pinned model or Claude Haiku) for thinking; for actions, use the strong model. Or the opposite: use the strong model for deliberation and a smaller model for the well-constrained tool-call generation.

A state-topology view of react with a dedicated thinking phase, using position, line and pattern so the decision remains legible without colour.

Figure 8.1: ReAct with separated thinking and acting phases, allowing different models per phase.

Self-reflection: ReAct with a critic

The pure ReAct loop trusts the agent to know when it’s done. Real agents sometimes finish wrong. Reflection adds a critic node that evaluates the proposed answer and either approves it or sends the agent back for another iteration.

class Review(BaseModel):
    approved: bool
    reason: str
    suggested_fixes: list[str]

def critic_node(state):
    proposed = last_answer(state)
    review: Review = critic_llm.with_structured_output(Review).invoke(
        f"Review this draft memo. Check: (1) all exposures covered; (2) policy citations correct; "
        f"(3) risk rating justified. Draft:\n{proposed}"
    )
    return {"review": review, "messages": [AIMessage(content=f"Critic: {review.reason}")]}

def route_after_critic(state) -> str:
    if state["review"].approved:
        return "finalise"
    if state["iteration"] >= 3:
        return "escalate"
    return "thinker"  # go back and revise

Self-reflection is capable for structured outputs, credit memos, compliance narratives, customer correspondence, where a critic with a clear rubric can catch errors the generator missed. It is less useful for fast conversational turns, where the latency cost of a critic loop exceeds the quality benefit.

Worked decision rule: reflection for any document that will be saved to a customer file or submitted externally; no reflection for conversational responses shown in-line.

The failure modes

Over-reasoning. The model emits ten thoughts before every action, tripling cost for marginal quality improvement. Fix: tune the system prompt to request concise thoughts (“One sentence of reasoning, then act”) and enforce the iteration cap.

Thought-drift. The model’s thoughts become disconnected from the actions. It thinks “I should look up policies” and then calls get_customer. Fix: use function calling with strict schemas (the thought is advisory; the action is a structured tool call that the model cannot invent); add a validation step that rejects a tool call inconsistent with the stated thought.

Loop collapse. The model’s thoughts converge: “I already tried X, let me try X again” on each iteration. Fix: include the iteration count in the system prompt (“This is iteration 5; try something different”); consider a forced-termination path after N iterations that composes whatever partial answer exists.

A regulated banking ReAct in practice: autonomous mortgage triage

At a UK lender, we built a ReAct agent that triages mortgage applications. The agent sees the application, thinks about what checks are needed, calls tools to check affordability, credit bureau, employment, property valuation, identity; and after each observation, thinks about whether enough evidence exists to recommend a decision.

The traces are beautiful. A typical run reads:

Thought: Standard residential application, £320k on £400k, LTV 80%, first-time buyer.
  I should check affordability first.
Action: check_affordability(income=65000, dependents=1, ...)
Observation: {dti: 0.28, affordable: true, buffer: 800/month}
Thought: Affordability is fine. Check credit history.
Action: credit_bureau_lookup(customer_id="CUS-...")
Observation: {score: 780, flags: []}
Thought: Strong credit. Employment verification next.
Action: verify_employment(company="Example Ltd", role="Senior Engineer")
Observation: {confirmed: true, years: 5}
Thought: Property valuation.
Action: property_valuation(address="...")
Observation: {value: 412000, confidence: high}
Thought: All checks pass. LTV 80%, DTI 28%, stable employment, good credit,
  property valued slightly higher than purchase. Recommending conditional approval
  pending solicitor's report.
Answer: Conditional approval. Reasons: [...]

Every thought becomes an audit record. Every action is a discrete tool call with arguments logged. Every observation is a tool result. A human underwriter reviewing the decision can read the trace in under a minute and either agree, disagree, or ask clarifying questions that the agent can answer by re-running a specific sub-step.

A decision view of make reasoning observable without worshipping it, showing how state, route, authority and evidence change together.

Part III: Human authority and executable plans

Consequential action needs a clean break between proposal and permission. Human review is a control surface only when it receives evidence, time and a real veto.

Chapter map for Part III: Human authority and executable plans: Interrupt before consequence; Why human review is not optional in regulated banking; The interrupt primitive; Worked example: the approval interrupt in action; Dynamic interrupts: the interrupt function.
Mermaid chapter map. Part III: Human authority and executable plans connects Interrupt before consequence, Why human review is not optional in regulated banking, The interrupt primitive, Worked example: the approval interrupt in action, Dynamic interrupts: the interrupt function.

Interrupt before consequence

Why human review is not optional in regulated banking

Consider three regulatory regimes that apply to a typical LangGraph agent In the Merehaven worked scenario,.

The common thread across all four regimes is the same. Autonomous systems in financial services need human intervention points, and those points must be designed, not accidental.

The interrupt primitive

See this in code. The simplest HITL pattern in LangGraph uses interrupt_before or interrupt_after on specific nodes.

from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
from typing import TypedDict, List, Optional, Literal

class MemoState(TypedDict):
    customer_id: str
    draft_memo: Optional[str]
    reviewer_decision: Optional[Literal["approve", "reject", "edit"]]
    reviewer_edits: Optional[str]
    final_memo: Optional[str]

def draft_memo(state: MemoState) -> dict:
    # Call LLM to produce draft
    draft = llm.invoke([...])
    return {"draft_memo": draft.content}

def apply_reviewer_decision(state: MemoState) -> dict:
    decision = state["reviewer_decision"]
    if decision == "approve":
        return {"final_memo": state["draft_memo"]}
    elif decision == "edit":
        return {"final_memo": state["reviewer_edits"]}
    else:  # reject
        return {"final_memo": None}

builder = StateGraph(MemoState)
builder.add_node("draft", draft_memo)
builder.add_node("apply_decision", apply_reviewer_decision)
builder.set_entry_point("draft")
builder.add_edge("draft", "apply_decision")
builder.add_edge("apply_decision", END)

checkpointer = PostgresSaver.from_conn_string(POSTGRES_URL)
graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["apply_decision"]
)

The control point is the interrupt_before=["apply_decision"] argument. When execution reaches the apply_decision node, LangGraph pauses. The state is persisted to the checkpointer. The API call that invoked the graph returns with a status of “interrupted” and the current state.

Some time later (a minute, an hour, or next morning when the reviewer returns from lunch), the UI updates the state with the reviewer’s decision and calls graph.invoke(None, config) again. The graph resumes from apply_decision, runs that node with the newly-populated state, and proceeds to END.

The important property is that this is durable and idempotent. If the agent container crashes during the wait, the state is safe in Postgres. If the reviewer’s session times out and they log back in, they see exactly the same draft. If the same update arrives twice, it does not corrupt the state. These are the properties a bank requires.

Worked example: the approval interrupt in action

Trace the full lifecycle of one credit memo through a HITL graph.

Step 2. The return value.

print(result)
# {"customer_id": "CUS-0012345678", "draft_memo": "..."}
# But the graph's state has more: it's paused at "apply_decision"

The returned dict is the state at the moment of interrupt. The client SDK returns a “paused” status. The UI shows the draft memo to the reviewer with three buttons: Approve, Edit, Reject.

Step 3. The reviewer reads the draft. They take nine minutes. In those nine minutes, they check the customer’s file, confirm the financial metrics, and read the policy justification the agent cited. They decide to approve with one edit (a clarification on the security).

Step 4. The reviewer submits their decision at 14:13. The UI calls:

graph.update_state(
    config,
    {
        "reviewer_decision": "edit",
        "reviewer_edits": "<edited memo text>"
    }
)
result = graph.invoke(None, config=config)

The update_state call writes the reviewer’s decision into the checkpointed state. The invoke(None, ...) call resumes the graph from where it paused. The apply_decision node runs with the updated state, writes final_memo, and proceeds to END.

Step 5. The final result. The client gets the final memo back. The audit log now contains: the original input, the draft, the reviewer’s identity (captured in update_state via LangGraph’s config metadata), the reviewer’s decision and edits, and the final memo. Every step is queryable via graph.get_state_history(config).

What is worth dwelling on here is how much governance this tiny bit of code buys you. The durability across restarts. The reviewer’s decision as a first-class state update. The full history of the run. Without interrupt, you would be building this yourself with Redis queues, custom REST endpoints, ad-hoc persistence, and flaky reconnection logic. LangGraph makes it a one-liner, and it works in an operating environment.

A state-topology view of worked example: the approval interrupt in action, using position, line and pattern so the decision remains legible without colour.

Dynamic interrupts: the interrupt function

Static interrupts (configured at graph compile time) are capable but coarse. You interrupt before a specific node every time. In practice, you often want to interrupt conditionally: only interrupt if the proposed action is high-risk, only interrupt if confidence is below a threshold, only interrupt if the customer is in a vulnerable category.

Dynamic interruption can pause from inside a node when case state crosses a risk boundary. Confirm the exact interrupt and resume semantics against the pinned LangGraph version, then test persistence, expiration and idempotent replay.

from langgraph.types import interrupt, Command

def risk_gate(state: MemoState) -> dict:
    draft = state["draft_memo"]
    risk_score = compute_risk_score(draft, state)

    if risk_score > 0.7:
        # Dynamic interrupt: pause here, present the draft for review
        human_decision = interrupt({
            "draft": draft,
            "risk_score": risk_score,
            "risk_factors": identify_risk_factors(draft, state),
            "question": "This memo contains higher-risk elements. Please review and decide."
        })
        # Resume with the human's response available here
        return {
            "reviewer_decision": human_decision["decision"],
            "reviewer_edits": human_decision.get("edits"),
        }
    else:
        # Low risk: auto-approve
        return {
            "reviewer_decision": "approve",
        }

The interrupt function throws a special exception that LangGraph catches. The value passed to interrupt becomes the payload returned to the client. When the client is ready to resume, it calls graph.invoke(Command(resume=value), config), where value is the human’s decision. That value becomes the return value of interrupt inside the node, and execution continues.

This pattern lets you encode your risk policy in code rather than in graph topology. The risk gate is a normal Python function: it can read any fields of state, call any classifier, apply any heuristic. If risk is low, no human is bothered; the path is fully automated. If risk is high, the human is engaged with full context.

A state-topology view of dynamic interrupts: the interrupt function, using position, line and pattern so the decision remains legible without colour.

Deeper mechanism: resume patterns and idempotency

The resume pattern has three important subtleties that trip up teams new to HITL.

First, the resume value replaces the interrupt call’s return value. It does not re-run the node from scratch. If your node does work before calling interrupt, that work is done before the pause and is part of the state. When you resume, execution picks up inside the node, right after interrupt. This is efficient but it has implications.

Second, state changes made after the interrupt depend on the state at resume time. If you interrupted thirty minutes ago, and in that time some other process updated a piece of referenced data (say, the customer’s credit bureau score refreshed), your resumed node will see the fresh value if it reads it. This can be a feature or a bug. Make it a feature: re-read any time-sensitive data at the point of use.

Third, resume is idempotent in the sense that the same resume payload produces the same outcome. If the client accidentally sends the same resume twice (network retry, user double-clicks), LangGraph will resume once from the checkpoint and discard the duplicate. This is critical for HTTP-based UIs where retries are common.

The worked scenario handles this by having the resume node re-read the customer’s financial state at the moment of resume, not use the snapshot from the time of draft. If the fresh state was materially different from the stale state, the agent would produce a “your situation has changed; here is the updated assessment” draft and loop back for another underwriter review. This felt like extra work but it was the right thing to do: it ensured decisions were always made on current data, not stale snapshots.

The failure mode: the rubber-stamp reviewer

The fix is not “tell them to read more carefully.” The fix is structural. Four mitigations, in order of effectiveness.

Mitigation 1: reduce the review burden. If the reviewer sees 200 drafts per day, they will rubber-stamp. If they see 40, they will actually read. The way to reduce the number is to raise the bar for what the agent auto-produces. Tighten the confidence threshold. Add more risk checks. Accept a lower automation rate in exchange for more meaningful reviews on the ones that need them.

Mitigation 2: make the review tool demanding. Do not show just the draft and Approve/Reject buttons. Show the reviewer’s attention a structured checklist: “Does the memo cite the correct policy version? Is the rating justified? Are the exposures complete?” Each item is a checkbox the reviewer must actively tick. A checkbox they skip turns red. The friction of the UI forces engagement.

Mitigation 3: random audit probes. Inject synthetic cases that are designed to look plausible but contain a deliberate error. Track the reviewer’s detection rate. A reviewer whose probe-detection rate drops below a threshold gets retraining. This is not a gotcha; it is quality assurance, and every reviewer knows it is happening because they are told it is.

The combination of these four mitigations is what turns HITL from theatre into actual risk mitigation. Without them, HITL is a fig leaf; with them, it is meaningful governance.

Escalation patterns: when the human pauses too long

A second failure mode is the abandoned review. A reviewer starts a review, gets interrupted, never comes back. Meanwhile, the customer’s request sits unresolved. After a day, it is late. After three days, it breaches the bank’s service level. After a week, a complaint arrives. The agent did its job; the human broke the loop.

The solution is an escalation ladder built into the graph. The interrupt is time-bounded. If the review is not complete within a defined SLA, a timer fires and the interrupt escalates.

import datetime as dt
from langgraph.types import interrupt, Command

def draft_review_gate(state: MemoState) -> dict:
    review_request_time = dt.datetime.utcnow().isoformat()

    decision = interrupt({
        "draft": state["draft_memo"],
        "review_request_time": review_request_time,
        "sla_hours": 4,
        "next_escalation_at": (dt.datetime.utcnow() + dt.timedelta(hours=4)).isoformat(),
    })

    return {
        "reviewer_decision": decision["decision"],
        "reviewer_id": decision["reviewer_id"],
        "review_completed_at": decision["completed_at"],
    }

Externally, a Cloud Scheduler job (on GCP) or an EventBridge rule (on AWS) runs every fifteen minutes. It queries for paused runs that have exceeded their SLA. For each, it triggers an escalation: a notification to the reviewer’s line manager, a reassignment to a back-up reviewer, or an automatic fallback (decline with reasons, offer a callback).

The escalation is itself an update to the graph’s state via update_state, followed by a resume with the escalation decision. The graph never silently stalls; every path ends deterministically within an SLA-bounded window.

Operating boundary: the reviewer queue

In the Merehaven worked scenario, the scenario runs a shared reviewer queue for agent drafts. It is a simple Firestore collection, with documents representing pending reviews. Each document has the thread ID, the graph name, the agent’s proposed action, the risk signals, and the SLA.

The reviewer’s UI pulls from this queue in priority order: SLA-urgent first, then risk-sorted. A reviewer clicks “Claim next item,” the item gets locked to them (with an expiring lock), and they see the context. When they submit their decision, the UI calls the agent service’s /resume endpoint, which calls graph.update_state and graph.invoke(Command(resume=...)), and the graph continues.

We started with one queue per agent and found that reviewers developed “tunnel vision” for their specific agent’s idiosyncrasies. We now pool reviewers across related agents (credit memo, limit renewal, facility restructure) so they see a variety of contexts and stay alert. The cost is a small amount of context-switching overhead; the benefit is much better detection of unusual drafts.

A second production observation. The SLA you set in code matters less than the SLA your reviewers can actually meet. If you set a 4-hour SLA and your reviewers are a team of six covering London business hours, a draft produced at 5pm will almost certainly miss the SLA. Design for the real staffing; use escalation to absorb the mismatches; do not pretend your reviewers are available 24/7 unless they are.

A thought experiment: consumer duty and a declined credit card

You work on a credit card application agent. The agent decides applications in under ten seconds, which is a competitive necessity for the market. The agent produces a decision (approve with limit, approve with lower limit, decline) and, if declining, a reason. The reason is shown to the customer.

Where should HITL interrupts fit in this agent?

Not on every decline. You would flood your queue. Not on every approve. Same problem. Candidate interrupts, in order of risk: declines where the customer has been declined more than three times in the last year (risk of vulnerable customer being repeatedly harmed); declines where the model’s confidence is low (risk of wrong decline); approves where the proposed limit is significantly above the customer’s income (risk of affordability concerns); declines where the reason would not pass a Consumer Duty test of comprehensibility.

That last one is interesting because it requires the agent to reason about the quality of its own reason. You can build a second LLM call that evaluates the decline reason against a Consumer Duty rubric and, if the reason fails, interrupts for a human to produce a better explanation. This is an LLM-grading-LLM pattern applied to governance.

Sketch the state schema for this agent on paper before reading further. What fields do you need? What decisions can trigger an interrupt? What information does a reviewer need at the moment of interrupt?

Here is a reasonable sketch.

class CreditCardDecisionState(TypedDict):
    # Inputs
    customer_id: str
    requested_limit: float
    application_payload: dict
    # Working state
    messages: Annotated[List, add_messages]
    credit_bureau: Optional[dict]
    affordability: Optional[dict]
    vulnerability_flags: List[str]
    # Outputs
    decision: Optional[Literal["approve", "approve_lower", "decline"]]
    proposed_limit: Optional[float]
    decline_reason: Optional[str]
    decline_reason_comprehensibility: Optional[float]
    # HITL
    requires_human: bool
    human_reason: Optional[str]
    reviewer_decision: Optional[dict]
    # Governance
    consumer_duty_checks: dict
    audit: AuditTrail

The key insight is that the graph needs to be able to answer the regulator’s question “how did you make sure this decline was Consumer-Duty compliant?” The answer is visible in decline_reason_comprehensibility, consumer_duty_checks, and the reviewer’s decision. All three are first-class state fields.

Human review anti-patterns

Anti-pattern 1: human as fallback for undertrained model. A team deploys an agent that is only 75% accurate and uses HITL to catch the other 25%. The reviewers buckle under the load. The right approach: fix the model’s accuracy first; use HITL for the residual high-risk cases. HITL is not a substitute for training.

Anti-pattern 2: the decision is already baked in. The UI shows the human only the agent’s proposed decision, with Approve/Reject buttons. The human’s attention gets anchored on the proposed decision. The right approach: show the human the inputs, the data, and the reasoning, and let them form their own view before seeing the agent’s proposal. Or, even better, have the human decide first on a small sample, then show the agent’s proposal for alignment training.

Anti-pattern 3: no feedback loop. The human’s decisions do not flow back into model retraining or evaluation. The agent keeps making the same mistakes; the humans keep catching them. The right approach: every human override is a labelled example. Feed them into the eval set at minimum; into the fine-tuning set if volume allows.

Anti-pattern 4: overconfident HITL SLAs. The team promises four-hour turnaround and staffs for six. Most drafts miss SLA. The right approach: either staff realistically, or set SLA realistically, or segment drafts by urgency and staff for the urgent ones. Do not set an aspirational SLA and hope.

Connecting to earlier chapters: state as the ledger

Go back to Chapter 3’s analogy: state as the ledger. HITL depends completely on this ledger being trustworthy. When a reviewer updates state with a decision, that update is a journal entry: timestamped, attributable, auditable. The checkpointer is the general ledger. The full history of state updates (available via graph.get_state_history(config)) is the complete audit trail.

This is why TypedDict/Pydantic state design matters so much for HITL agents. A poorly designed state (free-form strings, inconsistent naming, optional fields everywhere) makes it hard to know what a reviewer’s decision actually updated. A well-designed state (typed, schematised, with explicit reviewer_* fields) makes reviewer actions legible both to humans and to auditors.

Reviewers’ actions are also what feed the evaluation flywheel we will cover in Chapter 13. Every override is a labelled example of “the agent said X, the reviewer changed it to Y, for reason Z.” These are gold-label training and evaluation data. The agent does not get smarter by itself; it gets smarter because humans correct it and those corrections loop back.

A decision view of interrupt before consequence, showing how state, route, authority and evidence change together.

Turn plans into revisable contracts

Why ReAct alone is not enough

ReAct, which we built in Chapter 8, is a tight loop: think, act, observe, repeat. It is brilliant for short tasks where each step depends on the previous one. “Look up this customer’s exposures, then draft a memo about them” is two steps; ReAct handles it elegantly.

But ReAct has three structural weaknesses for longer tasks.

First, it is myopic. At each step, the agent thinks only about the immediate next action. It does not maintain a view of the whole task. For a fifteen-step task, this means the agent may take redundant steps, miss necessary ones, or get distracted by intermediate findings that should have been bracketed.

Second, it is expensive. Each step is an LLM call, and each call prompts the model with the full message history including all prior thoughts and observations. Message history grows quadratically in practice, because each call includes all prior ones. For a fifteen-step task, the last call may have 8,000 tokens of context, most of which is irrelevant to the current step.

Third, it is non-reviewable until complete. A human reviewer watching a ReAct agent has no way to see what the agent intends to do; they see only what it has done so far. For regulated tasks (investment proposals, credit strategies), this is unacceptable. Regulators want to review intent, more than execution.

Plan-and-execute fixes all three. The plan is visible upfront and reviewable. Each step’s context is narrower (just the task and the current step). The plan itself can be optimised as a whole, rather than step-by-step myopically.

The basic plan-and-execute graph

Build the simplest possible plan-and-execute graph, then add sophistication.

from typing import TypedDict, Annotated, List, Optional
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from pydantic import BaseModel, Field

class Step(BaseModel):
    description: str
    tool: Optional[str] = None
    expected_output: str

class Plan(BaseModel):
    goal: str
    steps: List[Step]

class PlanExecuteState(TypedDict):
    input: str
    plan: Optional[Plan]
    past_steps: Annotated[List[dict], lambda a, b: a + b]
    response: Optional[str]

planner_prompt = """You are a planner. Given a user task, produce a plan of 3-7 discrete steps.
Each step must have a clear description, optionally a tool to use, and an expected output.
Return the plan as JSON matching the Plan schema."""

def plan_node(state: PlanExecuteState) -> dict:
    plan_llm = llm.with_structured_output(Plan)
    plan = plan_llm.invoke([
        SystemMessage(content=planner_prompt),
        HumanMessage(content=state["input"])
    ])
    return {"plan": plan}

def execute_node(state: PlanExecuteState) -> dict:
    plan = state["plan"]
    past = state["past_steps"]
    next_step_idx = len(past)
    if next_step_idx >= len(plan.steps):
        return {}
    step = plan.steps[next_step_idx]
    step_prompt = f"Goal: {plan.goal}\nStep {next_step_idx + 1}: {step.description}\nExpected output: {step.expected_output}"
    result = agent_executor.invoke({"input": step_prompt})
    return {"past_steps": [{"step": step.description, "result": result["output"]}]}

def replan_or_respond(state: PlanExecuteState) -> str:
    if len(state["past_steps"]) >= len(state["plan"].steps):
        return "respond"
    return "execute"

def respond_node(state: PlanExecuteState) -> dict:
    synth_prompt = f"Goal: {state['plan'].goal}\nResults so far: {state['past_steps']}\nProduce a final response for the user."
    response = llm.invoke(synth_prompt)
    return {"response": response.content}

builder = StateGraph(PlanExecuteState)
builder.add_node("plan", plan_node)
builder.add_node("execute", execute_node)
builder.add_node("respond", respond_node)
builder.set_entry_point("plan")
builder.add_edge("plan", "execute")
builder.add_conditional_edges("execute", replan_or_respond, {"execute": "execute", "respond": "respond"})
builder.add_edge("respond", END)
graph = builder.compile()

Three properties matter. First, the plan is typed via Pydantic, so the LLM’s output is validated. Any non-conforming plan triggers a retry with a corrective message. Second, the executor is a separate LangGraph subgraph (or a langchain AgentExecutor); it handles the messy details of running each step, including tool calls. Third, the state tracks past steps separately from the plan, so you can always see what has been done versus what remains.

A state-topology view of the basic plan-and-execute graph, using position, line and pattern so the decision remains legible without colour.

Worked example: the investment proposal agent

Trace a real task through this pattern. The task: “Produce an investment proposal for a new client, the synthetic client, risk profile moderate, investable assets £2.3M, wants capital growth with some income, UK tax resident.”

Step 1. Planner produces the plan. The planner LLM returns:

{
  "goal": "Produce an investment proposal for the synthetic client",
  "steps": [
    {"description": "Retrieve the synthetic client's current portfolio", "tool": "get_portfolio", "expected_output": "List of holdings with values and asset classes"},
    {"description": "Pull current house views on asset allocation for moderate risk profile", "tool": "get_house_views", "expected_output": "Recommended allocation across equity, bonds, alternatives"},
    {"description": "Identify mismatches between current portfolio and house view", "tool": null, "expected_output": "List of over/underweight positions"},
    {"description": "Propose rebalancing trades with tax optimisation", "tool": "propose_rebalance", "expected_output": "List of buy/sell trades with quantities and tax implications"},
    {"description": "Draft proposal document with rationale and risk commentary", "tool": null, "expected_output": "A five-page document"}
  ]
}

This plan is visible in state before any step runs. A human wealth manager, in the UI, sees it and can approve, edit, or reject. Say they approve.

Step 2. Executor runs step 1. It calls get_portfolio for the synthetic client. The tool returns a list of eighteen holdings across equities, fixed income, and one real estate fund. The result is stored in past_steps.

Step 3. Executor runs step 2. It calls get_house_views. The view recommends 60% equity, 30% fixed income, 10% alternatives.

Step 4. Executor runs step 3. No tool needed; the LLM compares the portfolio to the target. It notes: 47% equity (underweight), 48% fixed income (overweight), 5% alternatives (underweight).

Step 5. Executor runs step 4. It calls propose_rebalance with the current and target allocations. The tool returns a list of trades with tax implications.

Step 6. Executor runs step 5. No tool; the LLM drafts the proposal document.

Step 7. Responder synthesises. The response summarises: here is the proposal document, here are the trades with tax implications, here is the risk commentary. The wealth manager reviews and edits before sending to the client.

Seven steps. The plan was visible upfront. Each step was narrowly focused. The artefacts (portfolio data, house view, rebalance proposal, document) are all in past_steps and can be inspected.

Adding replanning

The simple version above runs through the plan linearly. But what if the executor encounters a surprise? Say the propose_rebalance tool reveals that one of Hastings’s holdings is in a discontinued share class and cannot be traded normally. The plan’s step 4 is now wrong; the document in step 5 will be misleading.

This is where replanning comes in. After each executed step (or after a defined frequency), control returns to the planner, which sees the execution results so far and decides whether to revise the plan.

class ReplannerOutput(BaseModel):
    action: Literal["continue", "replan", "respond"]
    revised_plan: Optional[Plan] = None
    response: Optional[str] = None

replanner_prompt = """You are a replanner. Given the original plan and the results of executed steps,
decide whether to continue with the existing plan, replan with a revised plan, or respond if done."""

def replanner_node(state: PlanExecuteState) -> dict:
    replan_llm = llm.with_structured_output(ReplannerOutput)
    decision = replan_llm.invoke([
        SystemMessage(content=replanner_prompt),
        HumanMessage(content=f"Original plan: {state['plan']}\nSteps completed: {state['past_steps']}")
    ])
    if decision.action == "replan":
        return {"plan": decision.revised_plan}
    elif decision.action == "respond":
        return {"response": decision.response}
    return {}  # continue with existing plan

builder.add_node("replan", replanner_node)
builder.add_edge("execute", "replan")
builder.add_conditional_edges("replan", lambda s: "respond" if s.get("response") else "execute",
                               {"respond": "respond", "execute": "execute"})

Now every step flows through the replanner. If it detects a problem, it revises. If it detects completion, it transitions to respond. If everything is fine, it continues.

Deeper mechanism: plan granularity

A subtle design question: how granular should plan steps be?

Too coarse and you lose the benefits of planning. If the plan is “1. Understand the customer. 2. Produce a proposal,” then each step is really a miniature plan-and-execute, and you have gained nothing.

Too fine and you lose flexibility. If the plan has fifty steps, the planner is essentially writing the execution itself, and the plan becomes brittle to any deviation.

The sweet spot is 3-8 steps for most tasks. Each step is one conceptual unit of work: a tool call with some reasoning, or a synthesis across prior results, or a draft of one section. The planner stays at the level of “what” not “how.” The executor handles the “how” within each step.

The way to get the granularity right is through iteration. Run the agent on realistic tasks. Observe the plans. If steps are too coarse (the executor is doing too much in one step), split them. If they are too fine (the replanner is constantly reorganising), combine them. After five or six iterations, the planner typically settles into a good rhythm.

A practical heuristic: each step should take no more than three LLM calls in the executor. If a step routinely requires more, it is too coarse. If many steps require zero LLM calls (just a tool call), they are probably too fine, and you can fuse them.

Plan-and-execute in a commercial credit restructure

In the Merehaven worked scenario, the scenario uses plan-and-execute for a commercial credit restructure agent. The scenario: an existing corporate borrower is in breach of a covenant. The relationship manager wants options: extend the maturity, restructure the covenant, refinance with additional security, exit. The agent’s job is to help the RM think through the options and produce a restructure proposal paper.

The planner produces something like:

Goal: Produce a restructure options paper for customer X

Steps:
1. Retrieve the customer's current exposures, covenants, and breach details
2. Pull the customer's recent financial performance and projections
3. Retrieve internal restructure policy and playbook for this sector
4. Analyse covenant breach severity and likelihood of cure without restructure
5. Generate 3-4 restructure options with pros/cons each
6. Stress-test each option against a baseline and downside scenario
7. Recommend the preferred option with rationale
8. Draft the options paper (typically 6-8 pages)

The RM sees this plan before the agent runs. If they want a different emphasis (say, stress-test against 3 scenarios instead of 2, or skip option 3 because they know the customer will refuse it), they edit the plan. The agent then executes the edited plan.

Replanning kicks in routinely. For example, in step 5, if the agent discovers that the customer has a material guarantee we didn’t know about, the set of viable options changes, and the replanner extends the plan to re-evaluate.

This pattern has been materially better than our original ReAct attempts for this use case. It makes the agent’s intent inspectable by the RM, lets the RM steer without deep-diving into prompt engineering, and produces higher-quality outputs because each step is narrowly focused.

Failure modes of plan-and-execute

Three failure modes are worth understanding.

Failure mode 1: plan that looks good but is wrong. The planner produces a plan that reads plausibly but misses a important step or includes a redundant one. Without close scrutiny, the plan is executed and the output has a gap. Mitigation: have the planner’s output reviewed by a second LLM call that checks for completeness against a task-specific rubric. For high-value tasks, have a human approve the plan.

Failure mode 2: executor drift. The executor, within a step, interprets the step description loosely and does something different than the planner intended. Mitigation: structured step descriptions with explicit expected outputs; validation of the actual output against the expected description; retry or escalation on mismatch.

Failure mode 3: premature commitment. The planner commits to a plan before enough context is gathered, and the first few execution steps reveal that the plan is fundamentally wrong. The replanner can fix this, but at the cost of wasted execution. Mitigation: allow the planner to include “gather-and-plan” as an early step, where it runs a few information-gathering operations before committing to the full plan.

The analogy revisited: plans as contracts

The second analogy for plan-and-execute: plans as contracts. When a law firm engages on a matter, they produce an engagement letter specifying the scope. The engagement letter is the contract. If the scope changes, the letter changes.

A LangGraph plan is an engagement letter. It specifies what the agent will do. The human (RM, wealth manager, underwriter, customer) can read it, agree to it, or modify it. The agent’s execution is bounded by the agreed scope. Scope changes require replanning, which requires re-agreement.

This framing is valuable when you are explaining plan-and-execute to business stakeholders. It is something they recognise: scope, agreement, execution, change orders. The machine is doing something they have done for years in their own work.

A thought experiment: plan-and-execute for a complaint response

Sketch a plan-and-execute decomposition on paper. What are the steps? Which are tool calls? Which are synthesis?

A candidate plan.

Goal: Produce a DISP-compliant response to a customer complaint

Steps:
1. Parse the complaint letter into discrete grievances with supporting detail (no tool)
2. For each grievance: retrieve relevant policy, product terms, and past precedent (tool: search_policy_corpus)
3. For each grievance: assess merit against the evidence (no tool, LLM reasoning)
4. For each meritorious grievance: propose redress (apology, explanation, refund, goodwill payment) aligned to policy (tool: redress_ladder)
5. Consolidate into a single draft response covering all grievances and a clear decision
6. Check the draft against DISP format requirements (tool: disp_format_check)
7. Final draft

The design shows how this plan scales naturally if the complaint has two grievances or seven: step 2, 3, and 4 are per-grievance loops. The planner can produce a plan with 7 steps for a three-grievance complaint or 10 steps for a four-grievance complaint.

This kind of task-specific planning, where the plan scales to the input, is where plan-and-execute shines. ReAct would navigate it, but would be slow and inconsistent. A hand-coded pipeline would be rigid. Plan-and-execute gives you the flexibility of an agent with the structure of a pipeline.

A decision view of turn plans into revisable contracts, showing how state, route, authority and evidence change together.

Part IV: Grounding and coordinated expertise

Retrieval and multi-agent patterns add surfaces, not certainty. Their value comes from evidence routing, specialisation boundaries and explicit disagreement.

Chapter map for Part IV: Grounding and coordinated expertise: Treat retrieval as an evidence route; The basic RAG pipeline; The chunking problem; Self-RAG: the model grading its own retrieval; Corrective RAG (crag).
Mermaid chapter map. Part IV: Grounding and coordinated expertise connects Treat retrieval as an evidence route, The basic RAG pipeline, The chunking problem, Self-RAG: the model grading its own retrieval, Corrective RAG (crag).

Treat retrieval as an evidence route

The basic RAG pipeline

The essential steps:

  1. Ingest: take your authoritative documents (policies, product terms, regulatory guidance), chunk them, embed them, store them in a vector database.
  2. Retrieve: at query time, embed the user’s question, find the most similar chunks in the vector database.
  3. Generate: construct a prompt that includes the user’s question and the retrieved chunks; send it to the LLM; return the response.
from langchain_google_vertexai import VertexAIEmbeddings, ChatVertexAI
from langchain_community.vectorstores import MatchingEngine
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

# Ingestion
embeddings = VertexAIEmbeddings(model_name="text-embedding-004")
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
chunks = splitter.split_documents(policy_documents)
vector_store = MatchingEngine.from_documents(
    documents=chunks,
    embedding=embeddings,
    index_id=VERTEX_INDEX_ID,
    endpoint_id=VERTEX_ENDPOINT_ID
)

# Retrieval + Generation
llm = ChatVertexAI(model_name="gemini-1.5-pro")

def rag_answer(question: str):
    relevant_chunks = vector_store.similarity_search(question, k=5)
    context = "\n\n".join(doc.page_content for doc in relevant_chunks)
    prompt = f"""Answer the question using only the context provided.
If the context does not contain the answer, say "I don't have that information in my knowledge base."

Context:
{context}

Question: {question}

Answer:"""
    return llm.invoke(prompt).content

This is naïve RAG. It works for simple questions where the answer is likely to be in one chunk. It fails on questions where the answer spans multiple chunks, where retrieval returns wrong chunks, or where the model needs to reason across chunks rather than quote from one.

A state-topology view of the basic rag pipeline, using position, line and pattern so the decision remains legible without colour.

The chunking problem

Ask any production RAG team what bit them first. The answer, usually, is chunking. It looks trivial: split documents into pieces, embed each piece. In practice it is the most consequential design decision in the pipeline.

Fixed-size chunking (the default: 1000 characters with 150 overlap) is fine for consistently-structured text like a single policy document. It breaks down for documents with mixed structures. A 40-page product terms document with tables, bullet lists, and narrative prose will chunk poorly: some chunks cut mid-sentence, some cut mid-table, some cut mid-bullet. Retrieval returns fragments that are missing context. The generator confabulates.

Hierarchical chunking chunks at multiple granularities: fine (paragraph), medium (section), coarse (chapter). At retrieval time, you can match against fine chunks for specificity and medium chunks for context. This is sometimes called parent-document retrieval: retrieve at fine granularity to get the matching snippet, then expand to the parent section to provide surrounding context to the generator.

In a commercial credit policy corpus In the Merehaven worked scenario, the scenario uses hierarchical chunking. The policy PDF is parsed by unstructured.io into sections and paragraphs. Each paragraph is embedded as a “leaf” chunk with metadata linking to its section and chapter. At query time, we retrieve the top 8 leaf chunks, then expand each to include the surrounding 2 paragraphs. The generator gets about 2500 tokens of contextualised evidence rather than 800 tokens of fragmented excerpts. Answer quality is materially better.

Self-RAG: the model grading its own retrieval

Self-RAG (Asai et al., 2024) adds a feedback loop: the model grades the retrieved chunks for relevance before using them, and grades its own answer for faithfulness to the chunks.

The pattern in LangGraph:

class SelfRAGState(TypedDict):
    question: str
    documents: List[Document]
    answer: Optional[str]
    relevance_grades: List[bool]
    answer_is_faithful: Optional[bool]

def retrieve_node(state: SelfRAGState) -> dict:
    docs = vector_store.similarity_search(state["question"], k=5)
    return {"documents": docs}

def grade_relevance_node(state: SelfRAGState) -> dict:
    grades = []
    for doc in state["documents"]:
        grade_prompt = f"Is this document relevant to the question?\nQuestion: {state['question']}\nDocument: {doc.page_content}\nAnswer only 'yes' or 'no'."
        resp = llm.invoke(grade_prompt).content.strip().lower()
        grades.append("yes" in resp)
    return {"relevance_grades": grades}

def filter_docs_node(state: SelfRAGState) -> dict:
    filtered = [d for d, g in zip(state["documents"], state["relevance_grades"]) if g]
    return {"documents": filtered}

def generate_node(state: SelfRAGState) -> dict:
    context = "\n\n".join(d.page_content for d in state["documents"])
    answer = llm.invoke(f"Context:\n{context}\n\nQuestion: {state['question']}").content
    return {"answer": answer}

def grade_faithfulness_node(state: SelfRAGState) -> dict:
    prompt = f"Is this answer fully supported by the context?\nContext:\n{state['documents']}\n\nAnswer: {state['answer']}\n\n'yes' or 'no'?"
    resp = llm.invoke(prompt).content.strip().lower()
    return {"answer_is_faithful": "yes" in resp}

def route_after_faithfulness(state: SelfRAGState) -> str:
    if state["answer_is_faithful"]:
        return "end"
    return "retrieve"  # retry with different strategy

The graph flow: retrieve, grade each chunk for relevance, filter, generate, grade the answer for faithfulness. If unfaithful, loop back and retry (possibly with different retrieval parameters).

The cost is several extra LLM calls per query. The benefit is that plainly irrelevant chunks do not pollute the context, and plainly unfaithful answers get rejected before reaching the user. For high-stakes BFSI contexts (policy interpretations, regulatory guidance), the cost is worth it.

A state-topology view of self-rag: the model grading its own retrieval, using position, line and pattern so the decision remains legible without colour.

Corrective RAG (crag)

Corrective RAG (Yan et al., 2024) goes further: if retrieval fails (no relevant chunks found), fall back to a different source, typically web search or a second corpus.

class CRAGState(TypedDict):
    question: str
    documents: List[Document]
    retrieval_quality: Optional[Literal["correct", "ambiguous", "incorrect"]]
    answer: Optional[str]

def assess_retrieval_node(state: CRAGState) -> dict:
    # Assess top chunks against question
    prompt = f"Are these documents sufficient to answer the question? Answer 'correct', 'ambiguous', or 'incorrect'.\nQuestion: {state['question']}\nDocuments: {state['documents']}"
    resp = llm.invoke(prompt).content.strip().lower()
    if "correct" in resp:
        return {"retrieval_quality": "correct"}
    elif "ambiguous" in resp:
        return {"retrieval_quality": "ambiguous"}
    return {"retrieval_quality": "incorrect"}

def fallback_node(state: CRAGState) -> dict:
    # Fallback: web search or second corpus
    web_results = web_search_tool.invoke(state["question"])
    docs = [Document(page_content=r["snippet"], metadata={"source": r["url"]}) for r in web_results]
    return {"documents": docs}

def refine_query_node(state: CRAGState) -> dict:
    # Rewrite for ambiguous retrieval
    prompt = f"Rewrite this question to be more searchable: {state['question']}"
    new_q = llm.invoke(prompt).content
    return {"question": new_q}

Adaptive RAG

Adaptive RAG (Jeong et al., 2024) picks the retrieval strategy based on the question type. Some questions need no retrieval (general knowledge, math). Some need single-hop retrieval (simple fact lookup). Some need multi-hop retrieval (reasoning across multiple documents).

Worked example: a customer asking about ISA rules

User question: “Can I transfer my cash ISA from another provider to your bank mid-tax-year, and does it count against my annual allowance?”

Step 1. Adaptive router. Classifies as a single-hop RAG query (one factual topic, one answer).

Step 2. Retrieval. Embed the question, find top 5 chunks. Top chunks include: (i) a page from the bank’s ISA terms on transfers; (ii) a page from HMRC guidance on ISA transfer rules; (iii) a page from an internal product FAQ; (iv) a page from the current ISA allowance rules; (v) an irrelevant page about cash savings accounts.

Step 3. Relevance grading. The bank’s ISA terms and HMRC guidance pages are graded relevant. The FAQ and irrelevant cash savings page are filtered out.

Step 4. Generation. The LLM composes: “Yes, you can transfer your cash ISA mid-tax-year. Transfers between ISAs do not count against your annual ISA allowance. To initiate the transfer, you should complete the transfer form available on the bank’s website. Note that you must use the formal transfer process, not close the existing ISA and deposit the funds yourself, as the latter would count as a new subscription.”

Step 5. Faithfulness grading. The answer is checked against the retrieved chunks. It passes: every claim in the answer is supported by the chunks.

Step 6. Return. The answer is returned with citations: each sentence linked to the chunk that supports it.

That last step, citations, is the piece most naïve RAG implementations skip. In a BFSI context, it is not optional. The customer, the relationship manager, and the complaints team all benefit from being able to trace an answer back to its authoritative source.

Citations: the regulated banking-critical finishing touch

Every answer in a regulated RAG system should have citations. The customer-facing answer might say “Cash ISAs allow mid-year transfers [1]. These transfers do not count against your annual allowance [2].” The citations link to the specific chunks (and their original document sources) that support each claim.

class Citation(BaseModel):
    text: str  # the claim in the answer
    source: str  # document title or URL
    chunk_id: str
    score: float  # retrieval similarity

def generate_with_citations(question: str, docs: List[Document]) -> tuple[str, List[Citation]]:
    prompt = f"""Answer the question using the context. For each factual claim in your answer,
append a citation marker [N] referring to the document index.

Question: {question}
Context (indexed):
{format_docs_indexed(docs)}

Answer with inline citations:"""
    response = llm.invoke(prompt).content
    citations = extract_citations(response, docs)
    return response, citations

In the Merehaven worked scenario, every customer-facing RAG answer includes citations surfaced as hoverable links in the UI. The reviewer can click to see the exact source. This is the property that turns a RAG system from “unverified model prose” into “evidence-backed response,” and it is what regulatory reviewers and complaint handlers actually need.

Production RAG: ingestion pipelines and freshness

The pipeline: 1. Source documents land in a GCS bucket (or S3) with metadata tags (document type, version, effective date). 2. A Cloud Run job (or Lambda) watches the bucket; when a new document appears, it triggers ingestion. 3. Ingestion parses the document, chunks it, embeds the chunks, and upserts to the vector store. Old chunks with the same source ID are marked superseded. 4. A freshness dashboard tracks time-since-last-refresh per document type. Alerts fire if any type exceeds its freshness SLA.

RAG failure modes

Five failure modes worth knowing.

Hallucination despite retrieval. The model ignores the retrieved context and answers from its parametric memory. Mitigation: stricter system prompt (“answer only from context”), faithfulness grading, strong citation requirement.

Retrieval miss. The relevant chunk exists in the knowledge base but isn’t retrieved. Mitigation: hybrid search (combine vector + keyword), query expansion, reranking.

Chunk boundary issues. The relevant information is split across two adjacent chunks and neither is fully relevant alone. Mitigation: chunk overlap (already standard), parent-document expansion.

Stale content. The knowledge base is out of date and the model answers confidently from stale chunks. Mitigation: freshness SLAs, version tagging, conservative phrasing for time-sensitive topics.

Context window overflow. Too many retrieved chunks pushed into the generator, exceeding the context or degrading quality. Mitigation: reranking, aggressive relevance filtering, hierarchical summarisation of retrieved content.

A decision view of treat retrieval as an evidence route, showing how state, route, authority and evidence change together.

Coordinate specialists without losing ownership

Why multi-agent?

Two reasons, one technical, one organisational.

Technically, specialisation helps. A generalist agent with thirty tools and a lengthy system prompt performs worse than specialist agents each with five tools and a tight system prompt. Context confuses LLMs. Long tool lists inflate the prompt and dilute attention. Experimental evidence is consistent: decomposing a complex task into specialist roles tends to improve accuracy, reduce token usage per decision, and simplify debugging.

Organisationally, specialisation matches how work gets done. Banks already have specialists (trade finance, financial crime, tax, legal, credit, treasury) with distinct mandates. Multi-agent architectures let you mirror this structure in code. A credit specialist agent is owned by the credit risk team. A compliance specialist agent is owned by compliance. Each team iterates on its own agent; the supervisor is owned by the platform team. This separation of concerns scales the number of agents you can maintain in an operating environment.

The cost is coordination. Every agent-to-agent handoff is an opportunity for confusion, duplication, or deadlock. The supervisor’s prompt gets complex. The debugging story is harder: when something goes wrong, which agent was responsible? These are real costs, and for simple tasks, a single agent is better.

A rule of thumb. Use single-agent (ReAct or plan-and-execute) up to about 8-10 tools in one domain. Beyond that, or across domains, consider multi-agent.

Supervisor pattern: the canonical example

The canonical multi-agent pattern is one supervisor and N specialists.

from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated, List, Literal
from langgraph.graph.message import add_messages

class SupervisorState(TypedDict):
    messages: Annotated[List, add_messages]
    next_agent: Optional[str]
    task_complete: bool

AGENT_NAMES = ["credit_specialist", "compliance_specialist", "tax_specialist"]

supervisor_prompt = f"""You are a supervisor coordinating specialist agents. Based on the conversation,
decide which specialist should handle the next turn, or whether the task is complete.

Available agents: {AGENT_NAMES}
Respond with JSON: {{"next": "agent_name"}} or {{"next": "DONE"}}."""

def supervisor_node(state: SupervisorState) -> dict:
    response = supervisor_llm.invoke([
        {"role": "system", "content": supervisor_prompt},
        *state["messages"]
    ])
    decision = parse_json(response.content)
    if decision["next"] == "DONE":
        return {"task_complete": True, "next_agent": None}
    return {"next_agent": decision["next"]}

def credit_specialist_node(state: SupervisorState) -> dict:
    response = credit_agent.invoke(state)
    return {"messages": [response], "next_agent": "supervisor"}

# Similarly for compliance_specialist and tax_specialist

def route(state: SupervisorState) -> str:
    if state["task_complete"]:
        return END
    return state["next_agent"]

builder = StateGraph(SupervisorState)
builder.add_node("supervisor", supervisor_node)
builder.add_node("credit_specialist", credit_specialist_node)
builder.add_node("compliance_specialist", compliance_specialist_node)
builder.add_node("tax_specialist", tax_specialist_node)

builder.set_entry_point("supervisor")
builder.add_conditional_edges("supervisor", route,
    {"credit_specialist": "credit_specialist",
     "compliance_specialist": "compliance_specialist",
     "tax_specialist": "tax_specialist",
     END: END})

builder.add_edge("credit_specialist", "supervisor")
builder.add_edge("compliance_specialist", "supervisor")
builder.add_edge("tax_specialist", "supervisor")

Pattern: all paths go through the supervisor. Specialists never hand off to each other directly. This makes control flow easy to reason about: every handoff is centralised. The supervisor has the full message history and can see what each specialist contributed.

A state-topology view of supervisor pattern: the canonical example, using position, line and pattern so the decision remains legible without colour.

Worked example: a trade finance facility request

A corporate relationship manager asks the RM Copilot: “Client X wants to increase their trade finance facility from £5m to £10m. They are in food manufacturing, exporting to Kenya and Ghana. Can you help me think this through?”

Turn 1. Supervisor decides: credit_specialist.

The credit specialist pulls the customer’s financials, current exposures, and covenant status. Produces analysis: “Client has serviced a £5m TF facility for 2 years with no breaches. Financials show 18% YoY turnover growth. Capital adequacy is within policy for the uplift to £10m. Sector-specific guidance does not flag concerns.”

Turn 2. Supervisor decides: compliance_specialist.

Turn 3. Supervisor decides: credit_specialist.

Credit incorporates compliance findings. Proposes a conditional limit of £8m (not the full £10m) pending receipt of the enhanced KYC documentation on buyers, with a review in 6 months. Produces a structured recommendation.

Turn 4. Supervisor decides: DONE.

Four turns total. Each specialist focused on their narrow expertise. The supervisor integrated the findings. The RM gets a coherent answer. If they had asked the credit specialist alone, they would have missed the compliance nuance. If they had asked compliance alone, they would have missed the credit context.

Network pattern: peer-to-peer collaboration

Sometimes specialists need to talk to each other directly without supervisor mediation. Consider a multi-step fraud investigation where the pattern analyst needs to pass findings directly to the case handler, who then passes to the KYC analyst, who may refer back to the pattern analyst.

In the network pattern, each specialist has edges to others. The supervisor role may still exist but as an arbiter for deadlocks, not a routine gatekeeper.

def pattern_analyst_node(state):
    response = pattern_analyst.invoke(state)
    next_agent = determine_next(response)  # the agent itself decides
    return {"messages": [response], "next_agent": next_agent}

builder.add_conditional_edges("pattern_analyst", lambda s: s["next_agent"],
    {"case_handler": "case_handler", "kyc_analyst": "kyc_analyst", "END": END})

Network patterns scale less well than supervisor patterns, because every added specialist increases the number of potential edges. The decision graph in each specialist’s head (“who should I pass to next?”) gets complex. Use network patterns only when domain flow genuinely requires peer-to-peer (like the fraud case above), and keep the total number of specialists to 3-4.

Hierarchical pattern: teams of teams

For complex enterprise workflows, a single supervisor with many specialists becomes unwieldy. A hierarchical pattern layers supervisors: a top-level supervisor delegates to sub-team supervisors, each of whom coordinates their own specialists.

Example In the Merehaven worked scenario,: a relationship manager copilot has a top-level supervisor that routes between three sub-teams. - Credit team: credit specialist, exposures specialist, collateral specialist. - Compliance team: financial crime specialist, sanctions specialist, KYC specialist. - Product team: lending specialist, trade finance specialist, FX specialist.

Each sub-team has its own supervisor. The top-level supervisor decides which team to engage. The team supervisor decides which specialist within their team handles the turn.

In LangGraph, this is implemented via subgraphs: each sub-team is a compiled graph with its own state; the top-level graph treats each sub-team as a single node.

# Sub-team graph: credit team
credit_team_builder = StateGraph(CreditTeamState)
credit_team_builder.add_node("credit_supervisor", credit_supervisor_node)
credit_team_builder.add_node("credit_specialist", credit_specialist_node)
credit_team_builder.add_node("exposures_specialist", exposures_specialist_node)
credit_team_builder.add_node("collateral_specialist", collateral_specialist_node)
# ... edges and entry point
credit_team_graph = credit_team_builder.compile()

# Top-level graph uses sub-team graph as a node
top_builder = StateGraph(TopState)
top_builder.add_node("top_supervisor", top_supervisor_node)
top_builder.add_node("credit_team", credit_team_graph)
top_builder.add_node("compliance_team", compliance_team_graph)
top_builder.add_node("product_team", product_team_graph)
# ... conditional edges from top_supervisor to each team

The hierarchical pattern scales to 20+ specialists by keeping each level’s decision space small (3-4 options per supervisor). No supervisor is overwhelmed with a long list. Debugging is easier because you can localise failures to a specific team.

A state-topology view of hierarchical pattern: teams of teams, using position, line and pattern so the decision remains legible without colour.

Specialised agent architectures

Some agent patterns deserve their own section because they recur across BFSI use cases.

The verifier pattern. One agent produces; another verifies. Used when output quality matters more than speed. Credit memo drafter + policy-compliance verifier is the canonical pair In the Merehaven worked scenario,. The verifier has its own policy corpus retrieval and runs checks that are too expensive to bake into the drafter.

The researcher-writer pattern. One agent gathers information broadly; another composes a narrative from the gathered material. Used for research-heavy outputs like market commentary or sector reports. The researcher focuses on recall; the writer focuses on synthesis.

The conductor-orchestra pattern. A conductor agent sequences many specialist calls, often in parallel, and assembles the results. Used for batch operations like scoring a portfolio or running a quarterly review across many customers.

The adversarial pair pattern. Two agents: one proposes, one critiques; they iterate until convergence. Used for negotiation simulations, dispute resolution drafts, or any task where pressure-testing improves the output. More expensive than single-agent but produces notably better outputs on open-ended tasks.

Multi-agent failure modes

Three failure modes every production multi-agent system encounters.

Failure 1: infinite ping-pong. Specialist A hands to specialist B, who hands back to A, who hands to B, indefinitely. Mitigation: iteration counter in supervisor state; force a decision after N turns. Detect thought-loops by comparing recent supervisor decisions.

Failure 2: supervisor dilution. The supervisor’s prompt grows with each added specialist, and its decisions become unreliable. Mitigation: migrate to hierarchical pattern; keep each supervisor’s option set to 3-4. Or use a learned router (small classifier fine-tuned on routing examples) instead of an LLM supervisor, which scales better to 10+ routes.

Failure 3: inconsistent context. Specialists see different slices of state and produce contradictory findings. Mitigation: agree on a canonical state schema up front; have the supervisor include a “state summary” in each specialist’s prompt; use structured outputs so findings are machine-mergeable.

A decision view of coordinate specialists without losing ownership, showing how state, route, authority and evidence change together.

Part V: Release, recovery and operating evidence

The graph earns wider authority only through operating evidence. Test the route, constrain the release, preserve receipts and design recovery before scale.

Chapter map for Part V: Release, recovery and operating evidence: Operate the graph after the demo; The four-phase lifecycle; Testing LangGraph agents; LLM-as-judge for evaluation; Deployment patterns.
Mermaid chapter map. Part V: Release, recovery and operating evidence connects Operate the graph after the demo, The four-phase lifecycle, Testing LangGraph agents, LLM-as-judge for evaluation, Deployment patterns.

Operate the graph after the demo

The four-phase lifecycle

Every LangGraph agent that reaches production moves through four phases, and each has its own risks and checks.

Phase 1: Prototype. A developer builds a working agent on their laptop. The scope is narrow, the data is synthetic or sampled, and nothing is at stake. Output: a demonstrable proof-of-concept that the task is feasible.

Phase 2: Pilot. A small group of real users (typically 10-50) uses the agent for real tasks. Outputs are closely monitored. Feedback is collected systematically. Failures are captured and categorised. Output: a confident claim about whether the agent can handle worked tasks and where its weaknesses are.

Phase 3: Rollout. The agent is expanded to larger populations, often incrementally (10%, 25%, 50%, 100%). Metrics are watched for regression. Feedback mechanisms scale. Output: broad adoption with predictable quality.

Phase 4: Operation. The agent runs steadily. Monitoring detects drift, performance changes, regulatory events. Updates are deployed carefully. Eventually, the agent is retired or succeeded. Output: durable value with acceptable risk.

Most teams nail phase 1. Many fail at phase 2 because they don’t instrument it properly. The most common failure is between 2 and 3: a pilot that looked good gets rolled out and breaks because the broader population has different patterns.

Testing LangGraph agents

Testing an LLM agent looks strange to engineers trained on conventional software. The outputs are non-deterministic. The inputs are natural language, so the input space is essentially infinite. Correctness is often subjective. Traditional unit tests don’t quite work.

But structured testing is possible and essential. Three layers.

Layer 1: unit tests for nodes. Each graph node is a Python function. Test it like any function: given input state, expect output state to have certain properties. For nodes that call the LLM, either mock the LLM (for structural tests) or use a small deterministic model (for regression).

def test_credit_memo_draft_node_includes_required_sections():
    state = {
        "customer_id": "CUS-TEST-001",
        "exposures": [...],
        "financials": {...},
    }
    result = draft_memo_node(state)
    draft = result["draft_memo"]
    assert "Executive Summary" in draft
    assert "Financial Analysis" in draft
    assert "Risk Rating" in draft
    assert "Recommendation" in draft

Layer 2: integration tests for graph paths. Invoke the whole graph with realistic inputs and assert on final state. These are slower (real LLM calls) but catch issues that unit tests miss: incorrect routing, state shape mismatches between nodes, missing fields.

def test_happy_path_credit_memo_completion():
    config = {"configurable": {"thread_id": "test-happy-001"}}
    result = graph.invoke(
        {"customer_id": "CUS-INTEGRATION-001", "requested_facility": 5_000_000},
        config=config
    )
    assert result["final_memo"] is not None
    assert result["audit"]["run_id"] == config["configurable"]["thread_id"]
    assert len(result["audit"]["tool_calls"]) > 0

Layer 3: evaluation suites. A curated set of (input, expected-quality) pairs, scored by LLM-as-judge or human. Run on every change. Track scores over time.

class EvalCase(BaseModel):
    input: dict
    expected_properties: List[str]
    expected_decision: Optional[str]

def run_evals(graph, cases: List[EvalCase]) -> dict:
    scores = []
    for case in cases:
        output = graph.invoke(case.input)
        score = score_output(output, case.expected_properties)
        scores.append(score)
    return {"mean": sum(scores) / len(scores), "pass_rate": sum(1 for s in scores if s > 0.8) / len(scores)}

Evaluation suites are the backbone of confident LLM deployment. Every prompt change, every model version upgrade, every policy update runs the eval suite before any release. If scores drop, release is blocked.

A state-topology view of testing langgraph agents, using position, line and pattern so the decision remains legible without colour.

LLM-as-judge for evaluation

How do you score an LLM’s output when “correctness” is subjective? A common answer is LLM-as-judge: use another LLM to grade outputs against a rubric.

judge_prompt = """You are evaluating a credit memo. Score it 1-5 per criterion.

Memo: {memo}

Criteria:
1. Completeness: exposures, financials, risk rating, recommendation?
2. Faithfulness: all claims supported by data?
3. Policy compliance: citations by version?
4. Clarity: would a new RM understand?
5. Tone: professional?

Return JSON: {"completeness":5,"faithfulness":4,"policy_compliance":5,"clarity":4,"tone":5,"reasoning":"..."}"""

def judge_memo(memo: str) -> dict:
    return judge_llm.with_structured_output(JudgeScores).invoke(judge_prompt.format(memo=memo))

Three subtleties.

First, judges are biased. They tend to rate longer outputs higher, favour verbose over concise, and miss subtle factual errors. Calibrate your judge against human ratings for a sample, and adjust prompts or use a different judge if calibration is poor.

Second, judges need rubrics, not free-form scoring. A rubric with named criteria produces more consistent scores than “rate this memo from 1 to 10.”

Judge diversity is a testable design choice. A different model family may reduce some common-mode errors, but disagreement alone is not quality. Calibrate each judge against blinded human adjudication and preserve failure exemplars.

In the Merehaven worked scenario, the scenario uses a trio: the generator is a pinned model; the judge for policy compliance is a pinned model (via Anthropic’s API); the judge for tone is a smaller model (a pinned model) because tone is a relatively easy signal. The diversification reduces common-mode blindness.

Deployment patterns

Three deployment patterns in an operating environment.

Pattern 1: Synchronous request/response. The user asks; the agent answers in decision-time. Used for chat interfaces, short queries, and anything interactive. Latency targets are 2-8 seconds. Implementation: agent behind a load balancer, stateless (state lives in checkpointer), auto-scaling on CPU/latency.

Pattern 2: Asynchronous job. The user submits a request; the system returns a job ID; the user polls or receives a webhook when done. Used for longer tasks (memo drafts, research reports, batch operations) that may take 30 seconds to several minutes. Implementation: Cloud Tasks or SQS queue; workers pull jobs, run the agent, store results; the UI polls.

Pattern 3: Event-triggered. An event fires (new application received, policy change detected, scheduled trigger), and the agent runs without direct user interaction. Used for monitoring, bulk processing, or routine operations. Implementation: Eventarc (GCP) or EventBridge (AWS) routes events to the agent service.

Observability: the three pillars

Production LLM agents need three kinds of observability.

Metrics. Counters and gauges: requests per second, p50/p95/p99 latency, error rate, tool call count, token usage. Dashboarded in Cloud Monitoring or CloudWatch. Alerts on anomalies.

Traces. Every agent run produces a distributed trace showing every node, every LLM call, every tool call, every retrieval. Used for debugging individual runs. Implemented via OpenTelemetry, propagating baggage across all spans.

Logs. Structured logs with consistent fields (thread_id, user_id, customer_id, agent_name, node_name, message). Used for audit, debugging, and compliance. Retained per the bank’s retention policy (typically 7 years for audit-tagged events).

LangSmith (from LangChain) provides LLM-specific observability: prompt/response pairs, chain steps, token usage per step. the scenario uses it alongside Cloud Trace because it surfaces LLM-specific details (prompt versions, model versions, temperature) that generic APM misses.

A state-topology view of observability: the three pillars, using position, line and pattern so the decision remains legible without colour.

Canary rollouts and feature flags

Rolling out an update to a production agent requires care. Two mechanisms.

Canary rollouts. Deploy the new version to a small percentage of traffic first (5%, then 25%, then 50%, then 100%). Compare metrics between canary and stable. If canary is worse, roll back automatically.

import random
def route_to_version(user_id: str, canary_pct: float = 0.05) -> str:
    hash_val = hash(user_id) % 100
    return "canary" if hash_val < canary_pct * 100 else "stable"

Feature flags. Control which users see which version at feature granularity. Flags are stored in a config service (Cloud Runtime Config, AWS AppConfig, or LaunchDarkly) and checked at runtime.

In the Merehaven worked scenario, the specimen combines both. Every significant update (prompt change, graph topology change, model version) is rolled out via canary. Features where we want user-level control (opt-in betas, pilots) are behind feature flags.

Monitoring: what to alert on

A pager goes off. Which signals matter?

Signal 1: error rate. Increase in HTTP 5xx or exception rate. Usually an infrastructure or code issue.

Signal 2: latency. P99 blowing up means something is slow. Often a downstream API issue or a blocking call.

Signal 3: token usage. Sudden increase means either usage spike (check traffic) or prompt bloat (check recent deployments). Token cost alerts are common because costs can balloon quickly.

Signal 4: evaluation score drift. If the online eval suite shows scores dropping, the agent’s quality is degrading. Usually caused by upstream changes (new customer patterns, model version update, corpus drift).

Signal 5: human override rate. If reviewers are overriding agent outputs more than usual, the agent is making mistakes. Track override rate per use case; alert if it exceeds the rolling baseline.

Signal 6: retrieval hit rate. If RAG retrieval is failing to find relevant chunks more often, the corpus may be stale or the embeddings may have drifted.

Signal 7: toxicity or safety flags. If the model’s safety filters are firing more often on outputs, something may have changed in inputs or in the model.

The evaluation flywheel

A key property of a mature production system is the evaluation flywheel.

  1. Production runs produce traces and outputs.
  2. A sample of outputs is labelled for quality (LLM-judge at volume, human-rated for gold set).
  3. Labels feed the evaluation suite.
  4. The suite detects regressions on every change.
  5. New failure modes discovered in an operating environment are added as new eval cases.
  6. The suite grows over time, covering the real distribution.

Without the flywheel, the eval suite becomes a museum piece: a set of tests written once, slowly drifting from reality. With the flywheel, every production failure becomes a future regression guard.

A state-topology view of the evaluation flywheel, using position, line and pattern so the decision remains legible without colour.

A thought experiment: your agent is being audited

What do you include?

At minimum: the model card (purpose, inputs, outputs, limitations). The training data lineage (where did the prompts come from, what RAG corpus). The evaluation results (how is the model tested, what are the scores, what is the gold set). The monitoring setup (what do you watch, what alerts, what SLOs). The governance record (who approved this, when, against what policy). The change history (every significant change since launch). The incident log (anything that went wrong, what caused it, what was changed).

Every one of these is a specific data source you need to have ready. The eval suite’s history (from the flywheel) is your evaluation evidence. The CI/CD pipeline’s log is your change history. The incident tooling’s records are your incident log. The observability stack is your monitoring evidence.

If you build these as you go, the audit is a week of pulling together existing artefacts. If you wait to build them at audit time, it is months of scrambling and the audit almost certainly fails.

A decision view of operate the graph after the demo, showing how state, route, authority and evidence change together.

The Merehaven controlled-graph lab

The synthetic Merehaven payment-support route receives a request, compiles permitted context, proposes a next step and pauses before any consequential action. The graph never treats a message history as world state. Account status, identity and payment outcome come from authoritative systems at decision time.

Typed state and receipts

from typing import Literal, NotRequired, TypedDict

class PaymentState(TypedDict):
    case_id: str
    customer_id: str
    request_text: str
    context_version: str
    proposed_action: NotRequired[dict]
    policy_result: NotRequired[Literal["allow", "review", "deny"]]
    effect_receipt: NotRequired[dict]
    outcome: NotRequired[Literal["verified", "failed", "unknown"]]

def may_execute(state: PaymentState) -> bool:
    return (
        state.get("policy_result") == "allow"
        and state.get("outcome") is None
        and state.get("effect_receipt") is None
    )

The predicate is deliberately boring. Boring logic is desirable at the authority boundary. The model may propose a structured action, but policy, idempotency and effect reconciliation stay deterministic.

Thought experiment: the flawless explanation

Suppose the assistant produces a perfectly clear explanation for a transfer that never occurred. Is the system successful? A language metric may say yes; the customer’s account says no. Now suppose the transfer occurred twice while the assistant apologised elegantly. The answer is worse than unhelpful because it hides an unresolved effect.

The thought experiment separates narrative quality from operational truth. Process philosophy treats entities as unfolding events rather than static substances. The engineering analogy is limited but useful: a graph state is not a permanent self. It is a versioned account of becoming, and each transition must preserve what changed, why and under whose authority.

Route contract

Transition Proposal Independent control Required evidence Failure route
Request to context Customer intent Identity and access policy Context lineage Ask, deny or narrow
Context to proposal Model output Schema and policy pre-check Prompt and model versions Repair or review
Proposal to approval Suggested action Human or deterministic authorisation Decision receipt Deny or expire
Approval to effect Typed action Idempotency and entitlement Effect receipt Reconcile unknown
Effect to outcome Claimed result Authoritative readback Outcome receipt Contain and investigate

First-hour graph incident runbook

  1. Freeze route identity: graph, prompt, model, tool, policy and schema versions.
  2. Withdraw consequential authority while leaving safe inspection paths available.
  3. Preserve the last checkpoint, attempted action contract and all returned receipts.
  4. Reconcile every unknown outcome before replay; do not convert uncertainty into failure or success by assumption.
  5. Reproduce the fault with synthetic state and a non-effecting tool double.
  6. Restore the last accepted route, then test the failing transition and adjacent veto dimensions.
  7. Record residual risk, owner, recovery evidence and the condition that would reopen the incident.

Release ladder

Stage Allowed route Evidence gate Withdrawal trigger
Inspect Read-only traces State and schema tests Any lineage gap
Shadow Propose without effect Route replay and slice coverage Policy disagreement
Assisted Human-authorised effect Decision and effect receipts Unknown outcome
Bounded Policy-limited delegation Canary, reconciliation and harm measures Drift or control failure

Appendix: field glossary

Workflow checkpoint: a resumable snapshot of graph execution. User memory: retained information about a person, subject to purpose and deletion controls. Enterprise knowledge: governed reusable information. World state: authoritative time-qualified facts. Decision receipt: evidence of policy or human authorisation. Effect receipt: evidence returned by an action system. Unknown outcome: an attempted effect whose success cannot yet be proved. Bounded autonomy: delegated choice within explicit authority, evidence and recovery limits. Readback: independent verification after action.

Proposal, authorisation, effect and readback form distinct contours. An unknown outcome contracts authority until reconciliation completes.