Study Notes
Notefoundation

LangGraph — Agentic Workflows

LangGraph fundamentals — building multi-step, stateful agent workflows on top of LangChain, defining nodes and edges, conditional branching, and orchestrating tool-calling agents as composable graphs.

35 min read·verified Content review 2026·reviewed 2026-08-30

After this you should be able to

  • Explain the core idea in LangGraph — Agentic Workflows
  • Apply LangGraph — Agentic Workflows in a representative exercise
LangGraphAgent WorkflowsLangChain

Agentic AI · LangGraph

How LangGraph turns a graph definition into a frozen, thread-safe executable, and how an LLM that can only produce text learns to reach into databases, APIs, and calculators through tools.

Updated July 2026 · Read ~18 min · Scope: Session 7.1 (compilation) + tool-calling paradigm


// the big idea

A LangGraph app follows one rule: compile once, invoke many. Compilation validates the whole graph and freezes it into an immutable, thread-safe object built at startup. And because the model at its center is a read-only text function, every real action — reading a DB, calling an API, doing exact math — must be delegated to a tool: the LLM only requests the call, the framework executes it.

Section 1 · Notebook Summary — hand-copy this

Edges (recap)#

  • Standard edge → mandatory / sequential. No decision.
  • Conditional edge → dynamic. A router node picks the next node.
  • Entry point → where execution starts. Terminal (END) → final state, returns.

Graph compilation & immutability#

  • builder.compile() → validates graph → returns immutable executable.
  • Checks: entry point is a real node · every edge target registered · every router return value mapped · every path reaches END.
  • After compile: cannot add nodes / remove edges / change routing at runtime.
  • Compiled graph is thread-safe; shared across invocations. Each graph.invoke() gets isolated state.
  • Pattern = compile once, invoke many.
  • ⭐Compile at module level, not inside the request function. In-function compile re-validates every request (~50–200 ms wasted).✎ doodle: box "compiled graph" at top; 3 arrows down = invoke, invoke, invoke; each arrow ends in its own little state bubble.
  • ⚠Infinite loop = router always returns same node, no exit. Fix: wire ≥1 branch to END + retry counter (cap ~3).
  • ⚠State-mutation bug = editing state dict in place. Fix: return a new dict, never assign into the input state.

Classifier agent shape#

  • Flow: raw ticket → classify → route → {technical · billing · fraud · general} handler → END.
  • Classifier pipeline: raw LLM output → normalize → validate → fallback ("general").
  • 💡DAG = recipe (assumes every step works). Agent = chef (tastes, decides, loops).

Why LLMs need tools#

  • LLM = token-in → token-out text function. Everything else is emergent.
  • Three limits: temporal isolation (frozen knowledge) · environment isolation (no FS/net/DB) · deterministic passivity (can describe, can't do).
  • Without tools it will fabricate, hallucinate, or refuse.
  • ⚠Never let an LLM compute money / IDs / DB writes in its text — those need deterministic tool code.

Tool-calling paradigm#

  • 3 components: Python function (does the work) · LLM request (structured, not a real call) · result returned as a tool message.
  • Loop: query → LLM emits tool_call → ToolNode executes fn → tool message appended → LLM writes final answer.
  • ⭐LLM is always the passenger, never the driver — it never touches an external system directly.✎ doodle: query → [LLM] → tool_call → [ToolNode] → (external box) → result → back to [LLM] → answer. Draw as a loop.
  • Register a tool with @tool (from langchain_core.tools): schema extraction + docstring parsing + wrapping.
  • bind_tools() = Python→JSON handshake; returns a new LLM object, sends no request.
  • ⭐The docstring + type hints ARE the tool's spec — the LLM never sees your source, only the JSON schema.
  • ⚠Untyped param defaults to string → runtime type error inside the executor. Annotate everything.
  • 💡Return a dict, not a formatted string — structured JSON, easy to extract fields. Return only needed fields (context window is finite).

ToolNode (execution layer)#

gives youbecause
Testabilitytest fn in isolation; mock the node
Error containmentcatch at node boundary → error tool message, not a crash
Observabilityone place to log / meter / trace every call
  • ⚠Tool name must exactly match the bound name. Renaming a fn without rebinding → silent "no matching tool".
  • AI message can carry: text only · tool_calls only · both · multiple tool_calls.
  • tool_call fields: name · args · id.

Section 2 · Detailed Study Notes

The graph you can't change, and the model that can't act#

Picture a support agent that has just correctly classified a ticket as "billing." What happens next? In the version we built first: nothing. The model returned the word "billing" and stopped. A real human agent would open the customer's account, check the payment history, pull the invoice, confirm whether they were really charged twice. The model can't do any of that — not because it's dumb, but because it has no hands. This lesson is about two structural facts that shape every serious LangGraph app: the graph is frozen once you build it, and the model at its center can only produce text until you give it tools.

2.1

Edges: the wiring, in one breath#

An edge connects one node to the next; the type of edge decides whether that connection is fixed or chosen. Before compilation makes sense, the four edge types need to be clear.

standard edge

A mandatory, sequential step. No decision — node A always leads to node B.

conditional edge

Dynamic. A router function inspects state and returns which node to go to next.

entry point

Declares where execution begins — the first node to run on every invocation.

terminal (END)

Marks the final state. Execution stops and the accumulated state is returned.

2.2

Compilation: turning a definition into a frozen machine#

Calling builder.compile() validates the entire graph definition and produces an immutable, executable graph object. Up to this point you've been describing a graph — adding nodes, drawing edges. Compilation is the moment the description is checked for correctness and then sealed.

Analogy · the printed map

Building the graph is sketching a route in pencil — you can erase and redraw. compile() is sending it to the printer. Once it's printed, the map is fixed: reliable, shareable, and impossible to accidentally scribble over mid-journey. Every traveler carries the same map but walks their own path.

The validation pass runs several critical checks before it will hand back a graph:

  • The declared entry point points to a node that actually exists.
  • Every node referenced by an edge is a registered node.
  • Every conditional edge has a mapping covering all the return values its router function can produce.
  • Every path through the graph has a terminal condition — it can reach END.

Once those pass, the graph becomes immutable. You cannot add nodes, remove edges, or change routing logic at runtime. This is deliberate: if behavior is fully fixed by the definition, the graph is predictable, inspectable, and safe to run in concurrent environments where many invocations execute at once. The compiled graph is thread-safe and can be shared across invocations — each graph.invoke() spins up its own isolated state, so parallel requests never step on each other.

builder .add_node/edge .compile() immutable graph validated · frozen compiled ONCE at startup invoke invoke() · req A isolated state A invoke() · req B isolated state B invoke() · req C isolated state C
compile once at startup · invoke many with per-request isolated state

Where you compile matters more than it looks#

There are two ways to place the graph definition. The wrong way puts build_graph().compile() inside the function that handles each request. The right way defines and compiles the graph once at module level, then reuses that compiled object across every invocation.

✗ inside the request fn#

  • Re-validates the full graph on every request
  • Wastes ~50–200 ms per request
  • Expensive and entirely unnecessary

✓ at module level#

  • Compiled once at application startup
  • Reused across all requests
  • The production deployment pattern
the production pattern — compile at module load
# WRONG — recompiles on every call
def handle_request(ticket: str):
    builder = StateGraph(SupportState)
    # ... add nodes / edges ...
    graph = builder.compile()          # ~50-200ms wasted, every request
    return graph.invoke({"raw_input": ticket})

# RIGHT — compile once, at module level
builder = StateGraph(SupportState)
# ... add nodes / edges ...
graph = builder.compile()              # runs ONCE when the module loads

def handle_request(ticket: str):
    return graph.invoke({"raw_input": ticket})   # reuse the frozen graph
⭐ TRICK

Compile at module scope, invoke in the handler. The compiled graph is immutable and thread-safe, so there is zero benefit to rebuilding it per request — only the cost of re-running validation every single time. Define it once where the module loads; call invoke() inside the request path.

⚠ PITFALL · infinite loop

A conditional edge whose router always returns the same node, with no exit condition, traps execution forever. Fix: wire at least one branch to END, and add a retry-count field to state that caps iterations (roughly three) for that router.

⚠ PITFALL · state mutation

Modifying the state dictionary in place (state["key"] = value) instead of returning a new dict corrupts shared state and breaks the isolation guarantee. Fix: return a new dictionary from the node; never assign directly into the input state.

2.3

The classifier agent: a graph that decides#

The worked example is a support-ticket classifier: it takes a raw ticket, classifies it as technical, billing, fraud, or general, and routes it to the matching handler. The model is Gemini 2.5 Flash at temperature 0. [!] The session summary mentions "Gemini 1.5 Flash," but the actual code sets gemini-2.5-flash; the code is authoritative here.

Analogy · recipe vs. chef

A plain DAG is a recipe — it lists every step in order and assumes each one succeeds. An agent built on LangGraph is a chef: it reads the recipe, tastes as it goes, decides whether to add salt, and loops back when something's off. The typed-dict state is the chef's working memory, nodes are their actions, and conditional edges are their decisions.

The central contract is the state — a typed dictionary that flows through every node (the real thing carried ~17 fields). The classifier node runs a defensive pipeline rather than trusting the model's raw output:

raw LLM output→ normalize→ validate→ fallback → "general"

Normalize strips stray spaces and punctuation and lowercases the result; validate checks it against the four known categories; if it doesn't match, the category falls back to general rather than crashing. The graph then routes on that category to one of four handler nodes.

entry classify conditional edge technical billing fraud general END
entry → classify → conditional route → one handler → END
💡 TIP

The mental model to keep: we stopped writing recipes and started building chefs. Nodes are actions, conditional edges are decisions, and the typed-dict state is the chef's memory. That framing is what separates an agent from a static pipeline in an interview answer.

2.4

Why an LLM is a read-only system#

An LLM is a text-transformation function: tokens in, tokens out. Reasoning, summarizing, coding, answering — all of it is an emergent property of that single token-in/token-out architecture. That architecture has three consequences every engineer has to internalize before shipping.

temporal isolation

Knowledge is a frozen snapshot at the training cutoff. It can't know a payment failed this morning or that an API's schema changed yesterday.

environment isolation

No file system, no sockets, no DB connections, no processes. It runs sealed off; calling it is just an HTTP request to a remote server that can't touch your systems.

deterministic passivity

It can describe, recommend, or generate code for an action — but it cannot execute anything. It reacts to input and emits output, nothing more.

The passivity is a deliberate design choice, not a bug: it's what makes a model safe to run across shared, multi-tenant cloud environments. But it means anything that depends on information outside the training data will be fabricated, hallucinated, or refused — none of which is acceptable in production.

WITHOUT TOOLS query LLM text black box · no external connections WITH TOOLS query LLMpassenger tool_call ToolNodeexecutor externalDB / API tool result appended → LLM writes final answer result
the LLM never touches an external system — it requests, the framework executes, the result comes back

These map onto four hard limits of a tool-free LLM, useful as a debugging checklist when a production agent misbehaves — is the root cause a missing tool, or something else?

LimitWhat it can't do
No database accessCan't issue a SQL query, a Mongo find, or read a Redis key.
No API accessCan't make an HTTP request — no Stripe, Slack, Salesforce, email.
No real-time dataPrices, exchange rates, weather, health metrics, incident status are all invisible.
No deterministic executionIt's a probabilistic text generator, not a reliable calculator — exact math and IDs aren't safe.
⚠ PITFALL · the hallucinated number

A model that says "your refund is $47.83" in its text has invented that number. A model that calls a calculate_refund tool and reports what the tool returned has executed real code. The two outputs can look identical — the difference is entirely architectural. In 2023 a legal tool cited six court cases that didn't exist and the lawyer was sanctioned. Never use LLM text for anything requiring deterministic correctness.

2.5

The tool-calling paradigm: three components, one loop#

A tool is a Python function the LLM can request to be called, whose result is returned to the LLM as part of its context. That single sentence hides a three-party contract.

Analogy · the consultant in the building

A brilliant consultant who can't enter your building can only give advice. Tools are the specialists inside the building — the accounts team, the database admin — that the consultant can phone. The consultant (LLM) decides who to call and what to ask; the specialists do the actual work and report back.

1 · Python function

Does the real work — query a DB, call an API, calculate, read a file. Runs in the agent's runtime, with full access to packages, network, and disk.

2 · LLM request

The model doesn't call the function. It emits a structured request: "call fn X with args Y." Passive, not executed.

3 · result returned

The framework runs the fn, packages the return value as a tool message, appends it to the conversation, and the LLM reasons over the enriched history to write the final reply.

That last step closes the loop that turns a read-only transformer into an actor: request → execute → append result → answer.

2.6

Defining a tool: @tool, type hints, and the docstring#

The @tool decorator (from langchain_core.tools) turns a plain function into something the LLM can understand. It performs three operations: schema extraction (inspects the signature and parameter types), docstring parsing (uses the docstring as the tool's description), and wrapping (returns a structured tool object that carries its own name, schema, and description).

a production-grade tool definition
from langchain_core.tools import tool

@tool
def get_customer_details(customer_id: str) -> dict:
    """Retrieve a customer's billing information and purchase history from the CRM.

    Call this tool when the user's query involves billing, invoices, payment
    status, or subscription management and is actionable.

    Args:
        customer_id: The unique customer identifier, format 'CXXXX' (e.g. 'C1042').

    Returns a dict with billing_status, last_payment_date, and purchase_history.
    """
    record = crm.lookup(customer_id)          # real DB call, in the agent runtime
    # return ONLY the fields the LLM needs — not the whole raw row
    return {
        "billing_status":    record["billing_status"],
        "last_payment_date": record["last_payment_date"],
        "purchase_history":  record["purchase_history"][:3],
    }
⭐ TRICK · the docstring IS the tool's brain

The LLM never sees your Python source. Its entire understanding of a tool comes from two things: the JSON schema (parameters + types) and the docstring (the description). A vague docstring means the model doesn't know when to call the tool or what to pass. Describe the action, not the implementation — "retrieves billing info from the CRM," never "queries the postgres customers table."

A production docstring answers four questions, in the order the model needs them: what does this do (describe the action), when should it be called (explicit triggering conditions), what does each parameter mean and what format (with an example like C1042), and what does it return (so the model doesn't guess).

⚠ PITFALL · unannotated params

Every parameter needs an explicit type annotation. An unannotated parameter defaults to string. If the LLM then passes a string where the function expects an int, you get a type error at runtime — silently, inside the tool executor, and the graph crashes.

💡 TIP · return a dict, not a string

Return structured data (a dict → serialized JSON), not a pre-formatted string, so specific fields can be extracted downstream. And return only the fields the LLM needs to answer — every field of a customer row is hundreds of tokens of noise stealing context from the model's reasoning. Filter inside the tool.

2.7

Binding tools to the LLM#

Defining a tool and binding it are two distinct operations. @tool creates the object; bind_tools() tells a specific LLM those tools are available. Binding sends no API request — it returns a new model object that, when invoked, will include the tools' JSON schema in every request. Under the hood, bind_tools calls each tool's schema method (the schema the decorator built from the signature and docstring) and attaches it.

bind, then invoke
llm_with_tools = llm.bind_tools([get_customer_details])   # no request sent; new object

response = llm_with_tools.invoke("Check account C1042")
print(type(response))          # AIMessage
print(response.tool_calls)     # [{'name': 'get_customer_details',
                               #   'args': {'customer_id': 'C1042'},
                               #   'id':   'call_abc123'}]

When a tool-bound LLM decides to call a tool, it doesn't produce normal text — it produces an AI message whose tool_calls attribute holds a list of dicts, each with three fields:

name

The tool/function to call.

args

A dict of arguments matching the tool's schema, constructed by the model.

id

A unique identifier for this call, used to match the tool message response.

An AI message can carry several shapes, worth recognizing when debugging:

contenttool_callsmeaning
textemptyLLM answered from context; no tool needed.
empty / nullone callPure tool request — data needed before it can answer.
textone call"Thinking out loud" — explains, then requests.
emptymultiple callsSeveral tools needed to satisfy the query.
💡 TIP · one API, any provider

Tool calling was standardized by OpenAI and adopted by Google (Gemini), Anthropic (Claude), and others. LangChain's bind_tools transparently translates between its tool schema and each provider's function-declaration format, so your code looks identical regardless of the model — one of LangChain's core value propositions.

2.8

ToolNode: execution and error containment#

ToolNode is LangChain's built-in execution layer that sits between the LLM and the rest of the graph. It intercepts the tool-call request, runs the matching Python function, and packages the result as a tool message. Its existence as a separate component is a deliberate principle: separate reasoning from execution. The LLM node reasons about what to do; the tool node does it. That separation buys three things.

testability

Test the tool function in isolation in a unit test; mock the whole node in integration tests with stubbed results.

error containment

A DB timeout or bad arg is caught at the node boundary and turned into an error tool message the LLM can reason about — retry, tell the user, or escalate — instead of crashing the graph.

observability

One centralized place to log which tool ran, with what args, when, with what result, in how many ms — not duplicated inside every function.

ToolNode is instantiated with the same list of @tool objects bound to the LLM — that's what connects tool names to references. The tool name in the LLM's response must exactly match a tool name in the node's list.

⚠ PITFALL · silent name mismatch

LangChain tools use the function name as the tool name by default. Rename a function without re-binding, and the LLM keeps requesting the old name — the node finds no match and the call silently fails. And any unhandled exception that isn't contained propagates up through the node, crashes the graph, and the user sees a 500.

user query LLM nodereasons: what tool_call ToolNodeexecutes: how tool message back final answer
reasoning (LLM node) is separated from execution (ToolNode) — the tool message closes the loop
2.9

Interview & practice questions#

Why is a compiled LangGraph immutable, and why does that matter?

Compilation validates the definition and freezes it so nodes can't be added, edges removed, or routing changed at runtime. Immutability makes behavior fully determined by the definition — predictable, inspectable, and safe to share across concurrent invocations, since each invoke gets its own isolated state and none can mutate the shared graph.

Where should you compile the graph, and what's the cost of getting it wrong?

Compile once at module level, at application startup, and reuse the object. Compiling inside the per-request handler re-runs full graph validation on every call, wasting roughly 50–200 ms per request for no benefit — the pattern is "compile once, invoke many."

What are the three architectural limitations of an LLM that motivate tools?

Temporal isolation (knowledge frozen at the training cutoff), environment isolation (no file system, network, or DB access), and deterministic passivity (it can describe or recommend an action but never execute one). Together these mean any query depending on outside or real-time data will be fabricated or refused without a tool.

Walk through what actually happens when an LLM "calls" a tool.

The model doesn't execute anything. It emits a structured AI message with a tool_calls list (name, args, id). The ToolNode intercepts it, validates the args, runs the matching Python function, and appends the return value as a tool message. The LLM then reasons over that enriched history and produces the final text answer.

Why is the docstring so important on a @tool function?

The LLM never sees the Python source — its whole understanding comes from the JSON schema (params and types) and the docstring (description). A vague or missing docstring means the model can't tell when to call the tool or what arguments to pass, so the docstring is effectively the translation contract between the model and the function.

Why return a dict instead of a formatted string from a tool, and why filter fields?

A dict serializes to structured JSON, so downstream code can extract specific fields cleanly. You filter to only the fields the LLM needs because the return value is injected into the context window — a full raw row is hundreds of tokens of noise that steal budget from the model's reasoning, so the tool should trim it before returning.

What does bind_tools() do, and what does it NOT do?

It returns a new LLM object that will include the tools' JSON schema in every subsequent request. It does not send any API call at bind time, and it doesn't define the tools — the @tool decorator already created them. Binding is just the Python-to-JSON handshake that makes the model aware they exist.

What three benefits come from ToolNode being a separate component?

Testability (test the function alone; mock the node), error containment (exceptions are caught at the node boundary and returned as error tool messages instead of crashing the graph), and observability (a single place to log, meter, and trace every tool call rather than instrumenting each function).

How would you prevent an agent from getting stuck in an infinite loop?

Ensure the router isn't unconditionally returning the same node with no exit. Wire at least one branch to END, and add a retry-count field to the state that the router checks, capping iterations at around three so the graph is guaranteed to terminate.


Key takeaways#

  • compile() validates and freezes the graph; do it once at module level and invoke many times.
  • A compiled graph is immutable and thread-safe; every invocation gets isolated state.
  • An LLM is a read-only text function — temporally isolated, environment-isolated, and passive.
  • Never trust an LLM's text for deterministic work (money, IDs, DB writes); delegate to a tool.
  • Tool calling has three parts: the Python function, the model's structured request, and the returned tool message.
  • The docstring and type hints are the tool's real spec — the model never sees the source.
  • Return trimmed dicts, not strings, to keep the context window clean.
  • bind_tools() sends nothing; ToolNode executes and contains errors so the graph doesn't crash.
compile once, invoke many immutable graph thread-safe isolated state conditional edge router → END retry counter token in / token out temporal isolation environment isolation deterministic passivity @tool docstring = spec bind_tools() tool_call: name/args/id ToolNode error containment return a dict

Build it · Chapter 09 · Atlas

Build it — the engine underneath LangGraph#

LangGraph is a state machine. Three primitives: State (a shared dict), Nodes (functions state → partial update), and Edges (who runs next). compile freezes the wiring; invoke walks it, merging each node's output into the running state. Start with the linear core:

File atlas/engine.pyRun python3 -m atlas.engineDeps Python 3 · stdlib only
This is the Chapter 09 core. Chapter 10 adds conditional edges and cycles to this same engine.py — keep the file open.

atlas/engine.py — the linear StateGraph:

END = "__end__"


class StateGraph:
    def __init__(self):
        self.nodes = {}        # name -> fn(state) -> dict
        self.edges = {}        # name -> next node
        self.entry = None

    def add_node(self, name, fn):
        self.nodes[name] = fn
        return self

    def add_edge(self, src, dst):
        self.edges[src] = dst
        return self

    def set_entry(self, name):
        self.entry = name
        return self

    def compile(self):
        assert self.entry, "no entry point set"
        return CompiledGraph(self)


class CompiledGraph:
    def __init__(self, g):
        self.g = g

    def invoke(self, state, trace=False):
        state = dict(state)
        node = self.g.entry
        while node != END:
            update = self.g.nodes[node](state) or {}
            state.update(update)                 # merge this node's output in
            if trace:
                print(f"  [{node}] -> {update}")
            node = self.g.edges.get(node, END)   # follow the edge
        return state


if __name__ == "__main__":
    def greet(state):   return {"msg": f"hello {state['name']}"}
    def shout(state):   return {"msg": state["msg"].upper() + "!"}

    g = StateGraph()
    g.add_node("greet", greet).add_node("shout", shout)
    g.add_edge("greet", "shout").add_edge("shout", END)
    g.set_entry("greet")

    print("final:", g.compile().invoke({"name": "atlas"}, trace=True))

Run it — you'll see:

  [greet] -> {'msg': 'hello atlas'}
  [shout] -> {'msg': 'HELLO ATLAS!'}
final: {'name': 'atlas', 'msg': 'HELLO ATLAS!'}

This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.