LangGraph β Components Deep Dive
A study guide to LangGraph 1.x (Python), taken one component at a time: state, nodes, edges, compile and run, persistence, interrupts, fault tolerance, subgraphs, workflow patterns, the Functional API and streaming. Each part says what it is, shows the code, says when to use it, and lists the traps.
How to read this guide. Each component has (1) a mental model, (2) the code you will type, (3) a when to use / when not to verdict, (4) traps, and (5) π doc links. Section 12 is the decision table for βwhen do I use which kind of memory?β and section 9 for βwhich workflow pattern?β. The companion page is LangChain β Components Deep Dive (tools, middleware, memory from the agent side). Version note: written 17 Oct 2026 from docs.langchain.com. β marks features that need a minimum version (table in Β§14). The Functional API section (Β§10) draws on the graph-API docs and my prior knowledge more than on a full re-read, so verify it against the linked pages.
0. The map: what LangGraph is
Analogy: a factory floor with a conveyor and a clipboard. The clipboard is the shared state. Each station (node) reads the clipboard, does a job, and writes updates. Signs on the floor (edges) say which station comes next. A supervisor with a camera (the checkpointer) photographs the clipboard after every shift, so if the power fails you restart from the last photo, not from the start.
- Three primitives: State (shared snapshot plus the rules for merging updates), Nodes (functions that do the work and return updates), Edges (functions or fixed links that choose what runs next). Nodes do the work, edges say what to do next. Nodes and edges are plain functions: they may contain an LLM or just ordinary code.
- Execution model (Pregel-inspired message passing): the program runs in discrete super-steps. Nodes that run in parallel share a super-step; sequential nodes are separate ones. A node becomes active when it receives a message on an incoming edge, runs, and emits updates. The run ends when every node is inactive and nothing is in transit. Checkpoints are written at super-step boundaries, which explains most of the behaviour in Β§5-7.
- LangGraph is a low-level runtime. It gives you durable execution, streaming, human-in-the-loop and persistence. LangChain's
create_agentis built on it, so you can start high-level and drop down. The docs' guidance: use LangGraph for fine-grained orchestration control, long-running stateful work, workflows mixing deterministic and agentic steps, and production-grade deployment. - Two APIs: the Graph API (
StateGraph, explicit nodes and edges; this page's main subject) and the Functional API (@entrypointand@taskon ordinary Python control flow; Β§10).
| You need⦠| Use |
|---|---|
| A model plus tools in a loop, memory, approvals | create_agent (LangChain) |
| Branching, fan-out/fan-in, explicit loops, deterministic steps between model calls | LangGraph Graph API |
| Mostly linear Python with loops and ifs, but you want checkpointing and interrupts | Functional API |
| Planning, files and subagents out of the box for long autonomous tasks | Deep Agents (built on LangGraph) |
π Overview Β· Thinking in LangGraph Β· Graph API overview Β· Choosing between Graph and Functional Β· LangGraph runtime
1. State
Analogy: a shared whiteboard with one marker per column. Each column (state key) has its own rule for what happens when someone writes: overwrite the old value, or append to it.
1.1 Schemas
TypedDictis the main documented choice. Use adataclassif you want default values. A PydanticBaseModeladds recursive validation but is slower. (create_agentin LangChain only supports TypedDict state.)- Input/output/private schemas. By default input and output equal the graph schema. Pass
input_schema=andoutput_schema=to constrain what the graph accepts and returns; nodes can also write to extra private channels declared by a schema they use.
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class InputState(TypedDict):
user_input: str
class OutputState(TypedDict):
graph_output: str
class OverallState(TypedDict):
foo: str
user_input: str
graph_output: str
class PrivateState(TypedDict):
bar: str
def node_1(state: InputState) -> OverallState: return {'foo': state['user_input'] + ' name'}
def node_2(state: OverallState) -> PrivateState: return {'bar': state['foo'] + ' is'}
def node_3(state: PrivateState) -> OutputState: return {'graph_output': state['bar'] + ' Lance'}
b = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)
b.add_node('node_1', node_1); b.add_node('node_2', node_2); b.add_node('node_3', node_3)
b.add_edge(START, 'node_1'); b.add_edge('node_1', 'node_2'); b.add_edge('node_2', 'node_3'); b.add_edge('node_3', END)
b.compile().invoke({'user_input': 'My'}) # {'graph_output': 'My name is Lance'}Trap: private is not secret. Schemas constrain what a node reads and what invoke returns. When streaming with stream_mode='values' the graph emits all channels, private ones included. Pass output_keys=[...] to restrict, or use stream_mode='updates'.
1.2 Reducers
Each key has its own reducer: a binary function new = reducer(left=current, right=update). With no reducer, the update overwrites.
from operator import add
from typing import Annotated
class State(TypedDict):
foo: int # overwrite
bar: Annotated[list[str], add] # append
# state {'foo':1,'bar':['hi']}; a node returns {'foo':2} then {'bar':['bye']} -> {'foo':2,'bar':['hi','bye']}- Messages: use
Annotated[list[AnyMessage], add_messages], or subclassMessagesState(class State(MessagesState): documents: list[str]).add_messagestracks message IDs, so updating an existing message replaces it andRemoveMessagedeletes one;operator.addwould just append duplicates. It also deserialises dicts ({'type': 'human', 'content': ...}) into message objects, so read attributes with dot notation. - Parallel writes: two nodes writing the same key in one super-step need a reducer, or the update is ambiguous.
- Overwrite: a merging reducer cannot be cleared by returning an empty value (
[]is merged in). Wrap the update:{'errors': Overwrite([])}(fromlanggraph.types). This matters for error buffers and retry counters. - RemainingSteps: a managed value,
remaining_steps: RemainingSteps, tells a node how many super-steps are left before the recursion limit (Β§4).
When to put something in state: if more than one node needs it or it must survive an interrupt or crash. When not to: connections, clients, secrets and per-run config belong in runtime context (Β§2), because state is checkpointed and must be serialisable.
π State Β· Use the graph API (how-tos)
2. Nodes
Analogy: a station on the floor. It receives the clipboard, does one job, writes back only the lines it changed.
A node is a sync or async Python function. It can accept state, then optionally config (a RunnableConfig with thread_id, tags) and runtime (a Runtime[Context]). A node returns a partial update, not the whole state.
from dataclasses import dataclass
from langgraph.runtime import Runtime
@dataclass
class Context:
user_id: str
def my_node(state: State, runtime: Runtime[Context]):
print(runtime.context.user_id, runtime.execution_info.thread_id)
return {'results': 'Hello, {}!'.format(state['input'])}
builder = StateGraph(State, context_schema=Context)
builder.add_node('my_node', my_node) # name defaults to the function name if omittedRuntime field | Use |
|---|---|
context | Per-run immutable config (context_schema; pass context= to invoke) |
store | Long-term memory store |
stream_writer | Emit custom stream events |
execution_info β | thread_id, run_id, checkpoint_id, task_id, node_attempt (1-indexed), node_first_attempt_time |
server_info β | Assistant/graph ID and authenticated user when running on LangGraph Server |
heartbeat() β | Reset an idle timeout manually (Β§7) |
control / drain_requested β | Graceful-shutdown signals (Β§7) |
2.1 Re-execution and idempotency (the rule behind many bugs)
Checkpoints are saved at super-step boundaries, not mid-function. After an interrupt or a retry the affected node runs again from its first line, so any side effect before the pause runs again. Design nodes so a second run is harmless: upserts instead of inserts, idempotency keys, read-before-write. You may freely add or remove nodes and edges for existing threads; determinism rules apply only to tasks and interrupts inside a node.
2.2 Tasks inside a node
If a node does several expensive or non-idempotent operations, wrap each in a @task (from langgraph.func). Task results are checkpointed, so on resume completed tasks are skipped instead of re-run.
from langgraph.func import task
@task
def fetch(url: str): return requests.get(url).text[:100]
def call_api(state):
futures = [fetch(u) for u in state['urls']] # run concurrently
return {'results': [f.result() for f in futures]}2.3 Node caching
from langgraph.types import CachePolicy
from langgraph.cache.memory import InMemoryCache
builder.add_node('expensive', expensive, cache_policy=CachePolicy(ttl=3)) # ttl seconds; key_func defaults to a hash of the input
graph = builder.compile(cache=InMemoryCache())Use caching for pure, expensive functions (embedding a fixed text, a slow lookup). Do not cache nodes with side effects or time-dependence. The result carries __metadata__: {'cached': True} on a hit.
π Nodes
3. Edges, Send and Command
Analogy: signs on the factory floor. Some arrows are painted on (normal edges). Some depend on what is on the clipboard (conditional edges). A foreman can also say 'make one copy of this job per item on the list' (Send) or 'do this update and go there next' (Command).
3.1 Edge types
graph.add_edge('a', 'b') # always a -> b
graph.add_edge(START, 'a'); graph.add_edge('b', END) # entry / finish
graph.add_conditional_edges('a', route_fn) # route_fn(state) returns a node name or list of names
graph.add_conditional_edges('a', route_fn, {True: 'b', False: 'c'}) # optional path map
graph.add_conditional_edges(START, route_fn) # conditional entry point- Fan-out is automatic: a node with several outgoing edges runs all destinations in parallel in the next super-step. A conditional edge that returns a list does the same.
- One routing mechanism per node. Use normal edges or dynamic routing (conditional edges,
Command, tool-drivengoto), never both.Commandonly adds dynamic edges; static edges still fire, so you would run both destinations.
3.2 Command
Command(update=..., goto=..., graph=..., resume=...). Return it from a node to update state and route in one step. You must annotate the return type with the possible destinations, -> Command[Literal['next_node']], so rendering and validation know where it can go.
def decide(state: State) -> Command[Literal['approve', 'reject']]:
return Command(update={'checked': True}, goto='approve' if state['score'] > 5 else 'reject')graph=Command.PARENTnavigates from inside a subgraph to a node in the parent (the basis of multi-agent handoffs). If the key is shared by parent and subgraph schemas, the parent needs a reducer for it.- Input to
invoke:Command(resume=...)is the only Command intended as run input. Never sendCommand(update=...)to continue a conversation: any Command input resumes from the latest checkpoint, so a finished graph looks stuck. Send a plain input dict ({'messages': [...]}) to start a new turn on an existing thread. - Use
Commandversus a conditional edge: Command when you must update state and route; conditional edge when you only route.
3.3 Send (map-reduce)
Analogy: a teacher hands each student a different page of the exam to mark, then collects the marks into one pile.
from langgraph.types import Send
def assign_workers(state: State):
return [Send('write_section', {'section': s}) for s in state['sections']] # one worker per item, each with its own input
class State(TypedDict):
sections: list[Section]
completed: Annotated[list, operator.add] # workers write here in parallel, so it needs a reducer
graph.add_conditional_edges('orchestrator', assign_workers, ['write_section'])Use Send when the number of parallel branches is unknown until runtime and each branch needs its own input. A Send can carry its own timeout= β that overrides the target node's default.
π Edges Β· Command Β· Map-reduce and Send how-to
4. Compile and run
You must compile() before use. Compilation checks structure (no orphan nodes) and is where you attach runtime pieces.
graph = builder.compile(
checkpointer=InMemorySaver(), # thread persistence, HITL, time travel
store=my_store, # cross-thread memory
cache=InMemoryCache(), # node caching
interrupt_before=['node_a'], # static breakpoints (debugging only)
)
config = {'configurable': {'thread_id': 't1'}, 'recursion_limit': 50}
result = graph.invoke({'messages': [...]}, config, context={'llm_provider': 'anthropic'})thread_idlives inconfigurable.recursion_limitis a top-level key of config, not insideconfigurable. Context is a separatecontext=argument.- Recursion limit = max super-steps in one run. Default 1000 since LangGraph 1.0.6; exceeding it raises
GraphRecursionError. The step counter isconfig['metadata']['langgraph_step']. - Proactive handling (recommended): a
RemainingStepsstate field plus a router that sends the graph to a fallback node whenremaining_steps <= 2, so the run ends gracefully with partial results and checkpoints. Reactive: catchGraphRecursionErroroutside; simpler, but the run is terminated. - Other metadata in
config['metadata']:langgraph_node,langgraph_triggers,langgraph_path,langgraph_checkpoint_ns. - Visualise:
graph.get_graph().draw_mermaid_png()(usexray=Trueto expand subgraphs). - v2 output β:
invoke(..., version='v2')returns an object with.value(state) and.interrupts, cleanly separating interrupts from state.
π Recursion limit Β· Quickstart Β· Run a local server
5. Persistence: checkpointers, threads, stores
Analogy: save slots in a video game. A thread is one save file. A checkpoint is a save point written at the end of every super-step. Loading a slot continues from the last save; you can also load an older save and branch (time travel). The store is the shared hall of fame that every save file can read.
- Checkpointer: compile with one and LangGraph snapshots state at every super-step, organised in threads.
thread_idis the primary key: without it nothing is saved and nothing can resume after an interrupt. - Pending writes: if one node fails mid-super-step, successful sibling nodes' writes are stored, so a retry does not redo them.
- What persistence unlocks: human-in-the-loop, conversational (short-term) memory, time-travel replay and forking, and fault-tolerant execution.
- Backends:
InMemorySaver(dev), SQLite, Postgres (PostgresSaver,AsyncPostgresSaver), MongoDB, Azure Cosmos DB. Call.setup()once for SQL backends. On the Agent Server all of this is provisioned for you. - Inspect:
graph.get_state(config); addsubgraphs=Trueto see nested state (only works when the subgraph is statically discoverable: added as a node or called inside a node, not when hidden inside a tool).
5.1 Stores (cross-thread)
A store holds JSON documents under a namespace tuple and a key, accessible from any thread. It is the right place for user preferences and learned facts.
from langgraph.store.memory import InMemoryStore # PostgresStore / MongoDB / Redis in production
store = InMemoryStore(index={'embed': embeddings, 'dims': 1536, 'fields': ['food_preference', '$']})
ns = (user_id, 'memories')
store.put(ns, str(uuid7()), {'food_preference': 'I like pizza'}, index=['food_preference']) # index=False: stored, not searchable
store.search(ns, query='What does the user like to eat?', limit=3)
store.list_namespaces(prefix=(user_id,), max_depth=2)
graph = builder.compile(checkpointer=checkpointer, store=store)
async def call_model(state: MessagesState, runtime: Runtime[Context]):
hits = await runtime.store.asearch((runtime.context.user_id, 'memories'), query=state['messages'][-1].content, limit=3)searchtraps:namespace_prefixmatches by prefix (('alice',)also returns('alice','memories')); results beyondlimit(default 10) are silently dropped, so paginate withoffset; ordering differs by backend (Postgres: most recently updated first; in-memory: insertion order), so sort client-side if order matters.- Custom store: subclass
BaseStoreand implement the five async methods (aput,aget,adelete,asearch,alist_namespaces). No conformance suite exists, so test againstInMemoryStore.
Checkpointer versus store: the checkpointer saves the whole graph state for one thread; the store saves chosen facts for many threads. You normally want both.
Time travel (replay a past execution, or fork from a past checkpoint to explore another path) has its own how-to page; I did not re-read it for this guide.
π Persistence Β· Checkpointers Β· Stores Β· Time travel Β· Add memory
6. Interrupts and human-in-the-loop
Analogy: a bookmark with a note in the margin. The graph stops, marks its place, and waits as long as needed. When you hand back an answer, it re-reads the chapter from the start of the page and carries on.
Call interrupt(value) inside a node. LangGraph saves state, surfaces value (any JSON-serialisable object), and waits. Resume by invoking again with Command(resume=answer) on the same thread_id; answer becomes the return value of interrupt(). You need a checkpointer and a thread ID.
from langgraph.types import interrupt, Command
def approval(state) -> Command[Literal['proceed', 'cancel']]:
ok = interrupt({'question': 'Proceed?', 'details': state['action']})
return Command(goto='proceed' if ok else 'cancel')
stream = graph.stream_events(inputs, config, version='v3')
_ = stream.output # drive the run
if stream.interrupted: print(stream.interrupts) # tuple of Interrupt(value=..., id=...)
graph.stream_events(Command(resume=True), config, version='v3').output6.1 The rules (each one is a classic bug)
- The node restarts from its first line on resume. Everything before
interrupt()runs again. - Never wrap
interrupt()in a baretry/except. It pauses by raising a special exception; swallowing it silently disables the pause. Catch specific exception types only. - Do not reorder, skip or loop interrupts non-deterministically. Resume values are matched by index within the node. No conditional skipping, no loops over data that may change.
- No
while Truevalidation loops withinterrupt()in one node. Each resume replays earlier iterations. Instead: callinterrupt()once, store apending_questionin state on invalid input, and use a conditional edge to loop back. - Payloads must be serialisable (strings, numbers, dicts of simple values). No functions or class instances.
- Side effects before
interrupt()must be idempotent: upsert, not insert; or move the side effect after the interrupt or into a separate node.
6.2 Patterns
| Pattern | Shape |
|---|---|
| Approve or reject | interrupt returns bool; route with Command(goto=...) |
| Review and edit state | edited = interrupt({'content': state['draft']}); return {'draft': edited} |
| Interrupt inside a tool | The tool calls interrupt({...}) before acting; resume with {'action': 'approve', 'subject': 'new subject'} to approve with edits |
| Validate human input | One interrupt per node call plus a conditional-edge loop (rule 4) |
| Several parallel interrupts | Resume with Command(resume={interrupt.id: answer, ...}) for all pending IDs at once |
| Typed answers β | interrupt(payload, response_schema=Decision) (Pydantic, TypedDict or dataclass); invalid resume raises ValidationError and you can resend to the same thread. A raw JSON-schema dict is exposed to clients but not validated. Needs langgraph 1.2.12. |
- Static breakpoints (
interrupt_before/interrupt_afterat compile or run time, resume withinvoke(None, config)) are for debugging, not for human-in-the-loop. Useinterrupt()for that. - Subgraph called as a function: both the parent node and the subgraph node restart from their beginnings on resume.
- Drive a UI loop: call
stream_eventsin a loop, stream tokens fromstream.messages, checkstream.interrupted, ask the user, then resume withCommand(resume=...), until nothing is interrupted.
π Interrupts Β· HITL concepts
7. Fault tolerance
Analogy: a delivery driver with three escalating responses. Knock again (retry). Don't wait forever at one door (timeout). If the house is truly unreachable, follow the 'undeliverable' procedure (error handler): leave a note and return to depot.
β Per-node timeouts, error handlers, set_node_defaults and graceful drain need langgraph 1.2+. The order is fixed: an attempt raises (including a timeout) β the retry policy decides β only after retries are exhausted does the error handler run.
7.1 Retries
from langgraph.types import RetryPolicy, default_retry_on
builder.add_node('call_api', call_api, retry_policy=RetryPolicy(max_attempts=3))
# custom: retry_on can be an exception type, a sequence of types, or a callable(exc) -> bool
def retry_on(exc): return False if isinstance(exc, MyBug) else default_retry_on(exc)| Parameter | Default |
|---|---|
max_attempts (includes the first) | 3 |
initial_interval / backoff_factor / max_interval | 0.5 s / 2.0 / 128 s |
jitter | True |
retry_on | default_retry_on |
Default is conservative: it retries any exception except ValueError, TypeError, ArithmeticError, ImportError, LookupError, NameError, SyntaxError, RuntimeError, ReferenceError, StopIteration, StopAsyncIteration, OSError (programming errors). For requests and httpx only 5xx are retried. NodeTimeoutError is retryable. Inside a node, runtime.execution_info.node_attempt lets you switch to a fallback API on attempt 2+.
7.2 Timeouts
from langgraph.types import TimeoutPolicy
builder.add_node('call_model', call_model, timeout=60) # seconds, wall clock
builder.add_node('call_model', call_model, timeout=TimeoutPolicy(run_timeout=120, idle_timeout=30))- Async nodes only. A sync node with a timeout is rejected at compile time. Wrap blocking I/O with
asyncio.to_threadinside an async node. run_timeout: hard cap per attempt, never refreshed.idle_timeout: fires only if the node stops making progress; the clock resets on state writes, stream chunks, child-task scheduling, stream-writer calls and any LangChain callback event (tokens, tool calls).refresh_on='heartbeat'restricts refresh to explicitruntime.heartbeat()calls for long work with no natural progress signal.- On timeout:
NodeTimeoutError(withnode,elapsed,kind), writes from that attempt are cleared, then the retry policy decides. The clock resets per attempt.
7.3 Error handlers (compensation, the Saga pattern)
from langgraph.errors import NodeError
def payment_failed(state: State, error: NodeError) -> Command:
return Command(update={'status': 'compensated: ' + str(error.error)}, goto='finalize')
builder.add_node('charge_payment', charge_payment,
retry_policy=RetryPolicy(max_attempts=3, retry_on=ConnectionError),
error_handler=payment_failed)- The handler gets the state and, if you annotate
error: NodeError, the failingnodeanderror. It may also takeruntimeorconfig. Return a state update or aCommandto route elsewhere. - Failure provenance is checkpointed: if the process crashes after the failure but before the handler finishes, the handler sees the same
NodeErroron resume. interrupt()bypasses retries and handlers (it is control flow, not failure). A failing subgraph surfaces to the parent node's handler. If the handler itself raises, the error propagates as if there were no handler. One handler per node.
7.4 Graph-wide defaults
graph = (StateGraph(State)
.set_node_defaults(retry_policy=RetryPolicy(max_attempts=3), error_handler=mark_failed, timeout=TimeoutPolicy(run_timeout=30))
.add_node('step_a', step_a)
.add_node('charge', charge, error_handler=refund) # per-node value overrides the default
.compile())| Default | Regular nodes | Error-handler nodes |
|---|---|---|
retry_policy | Yes | Yes |
timeout | Yes | Yes |
error_handler | Yes | No (a handler must not catch itself) |
cache_policy | Yes | No (caching a handler is unsafe) |
Defaults are not inherited by subgraphs. The same timeout= and retry_policy= exist on @task and @entrypoint in the Functional API.
7.5 Graceful shutdown (drain)
import signal
from langgraph.runtime import RunControl
from langgraph.errors import GraphDrained
control = RunControl()
signal.signal(signal.SIGTERM, lambda *_: control.request_drain('sigterm'))
try:
graph.invoke(inputs, config, control=control)
except GraphDrained as e:
pass # checkpoint saved; resume later
graph.invoke(None, config) # resume with the same thread_idDrain is cooperative and works between super-steps: a running node (including one mid-retry) finishes first. It does not cancel asyncio tasks or kill threads; pair it with a hard timeout. If the graph finishes on the same tick, it just returns normally (check control.drain_requested). Inside a node, runtime.drain_requested lets you skip expensive work.
7.6 Changing a deployed graph (migrations)
- Threads at the end of the graph: any topology change is fine.
- Threads interrupted: any change except renaming or removing a node the thread is about to enter.
- State keys: adding or removing is safe both ways; renaming a key loses its saved value in existing threads; incompatible type changes can break old threads.
- Changes that are technically compatible but alter behaviour (a new tool set, a restructured flow): pin a behavioural version in state so old threads keep the old path while new threads take the new one.
Traps. Retrying a non-idempotent node duplicates side effects. A timeout on a sync node fails at compile time. Putting the handler on every node via defaults and then also expecting handler nodes to be covered (they are not).
π Fault tolerance Β· Backward compatibility
8. Subgraphs
Analogy: a department inside the company. The company chart (parent graph) treats it as one box. Inside, it has its own stations. Whether the department remembers last week's work depends on its filing policy (checkpointer mode).
8.1 How parent and subgraph talk
| Pattern | Use when | How |
|---|---|---|
| Call inside a node | Different state schemas (no shared keys), or you must transform state | A wrapper node maps parent state to subgraph input, calls subgraph.invoke(...), maps the result back |
| Add as a node | Shared keys (for example both use messages) | builder.add_node('agent', compiled_subgraph); no wrapper |
# different schemas
def call_subgraph(state: Parent):
out = subgraph.invoke({'bar': state['foo']})
return {'foo': out['bar']}
# shared schema
builder.add_node('node_2', compiled_subgraph)8.2 Persistence mode: the checkpointer argument of compile()
| Mode | checkpointer= | Behaviour | Pick it for |
|---|---|---|---|
| Per-invocation (default) | None | Each call starts fresh; inherits the parent's checkpointer so interrupts and durable execution work within the call | Most apps; subagents handling independent requests |
| Per-thread | True | State accumulates across calls on the same thread | A subagent that needs multi-turn memory |
| Stateless | False | No checkpointing; runs like a function; no interrupts, no durability | Cheap pure helpers; accept that a crash means rerun |
- Per-thread caveats: no parallel calls to the same per-thread subgraph (checkpoint conflict); several different per-thread subgraphs called inside one node get namespaces by call order, so reordering mixes up their states. Give each its own wrapper
StateGraphwith a unique node name for a stable namespace. Subgraphs added as nodes already get name-based namespaces. - The parent must have a checkpointer for interrupts, state inspection and per-thread memory to work in a subgraph.
- Inspect:
graph.get_state(config, subgraphs=True).tasks[0].state. Per-invocation shows only the current call; per-thread shows accumulated state. - Stream:
stream_events(..., version='v3')and iteratestream.subgraphs(each hasgraph_name,path,messages,values); or classicstream(..., subgraphs=True). Without that flag,stream_mode='messages'on a parent does not emit tokens from an inner agent. - Navigate up:
Command(goto='other', graph=Command.PARENT).
π Subgraphs
9. Workflow and agent patterns, one by one
Workflows follow predetermined code paths; agents choose their own path. All of these are just graphs. Pick the simplest that fits.
| Pattern | Shape | Use when | Avoid when |
|---|---|---|---|
| Prompt chaining | A β gate β B β C; each call consumes the previous output | Well-defined task splittable into verifiable steps (translate, then verify) | Steps are not really sequential |
| Parallelization | START fans out to N nodes; all feed an aggregator | Independent subtasks for speed, or repeated runs for confidence | Branches depend on each other |
| Routing | Classifier node (structured output) β conditional edge β specialised node | Distinct input types needing different handling | Categories overlap heavily |
| Orchestrator-worker | Plan β Send one worker per subtask β synthesiser | Subtasks cannot be predefined (edit N unknown files) | Subtasks are fixed (use parallelization) |
| Evaluator-optimizer | Generate β evaluate β loop back with feedback until accepted | Clear success criteria that need iteration | No reliable evaluator; unbounded cost (add an iteration cap) |
| Agent | LLM node β tool node loop until no tool calls | Open-ended problems where steps are unpredictable | You know the steps in advance |
# Agent loop in the Graph API
def llm_call(state: MessagesState):
return {'messages': [llm_with_tools.invoke([SystemMessage('You are a calculator.')] + state['messages'])]}
def tool_node(state: MessagesState):
out = []
for tc in state['messages'][-1].tool_calls:
out.append(ToolMessage(content=tools_by_name[tc['name']].invoke(tc['args']), tool_call_id=tc['id']))
return {'messages': out}
def should_continue(state) -> Literal['tool_node', '__end__']:
return 'tool_node' if state['messages'][-1].tool_calls else END
b = StateGraph(MessagesState)
b.add_node('llm_call', llm_call); b.add_node('tool_node', tool_node)
b.add_edge(START, 'llm_call')
b.add_conditional_edges('llm_call', should_continue, ['tool_node', END])
b.add_edge('tool_node', 'llm_call')
agent = b.compile()# Routing: structured output as the router
class Route(BaseModel):
step: Literal['poem', 'story', 'joke']
router = llm.with_structured_output(Route)
def route_node(state): return {'decision': router.invoke(state['input']).step}
graph.add_conditional_edges('route_node', lambda s: {'story': 'write_story', 'joke': 'write_joke', 'poem': 'write_poem'}[s['decision']])9.1 ToolNode
ToolNode (from langgraph.prebuilt) is the prebuilt node that runs tool calls: it handles parallel tool execution, error handling and state injection. Add it with builder.add_node('tools', ToolNode([search, calculator])). Tools read graph state and run context through the injected ToolRuntime argument. Tools only see the state passed to the ToolNode: if you call a ToolNode manually from another node, pass the full state (tool_node.invoke(state)), not just {'messages': ...}, or custom state fields are invisible. A tool returning Command must include a matching ToolMessage.
Hand-written loop versus create_agent: write the loop yourself only when you need control the middleware cannot give (custom routing, extra nodes between model and tools). Otherwise use create_agent and embed it as a node.
π Workflows and agents Β· Agentic RAG Β· SQL agent
10. The Functional API
Analogy: a recipe written as ordinary steps instead of a wiring diagram. You write normal Python (
if,while,for); the runtime still saves progress after each marked step.
from langgraph.func import entrypoint, task
@task
def generate(topic: str): return llm.invoke('Write a joke about ' + topic).content
@task
def improve(joke: str): return llm.invoke('Make this funnier: ' + joke).content
@entrypoint(checkpointer=InMemorySaver())
def workflow(topic: str):
joke = generate(topic).result() # tasks return futures; .result() waits
return improve(joke).result()- Tasks are the checkpoint unit. Their results are saved, so a resumed run skips completed tasks. Launch several before calling
.result()to run in parallel. - Determinism and idempotency rules are stricter here, because the whole entrypoint re-runs on resume: keep task and
interruptorder stable, and keep side effects inside tasks. @taskand@entrypointacceptretry_policy=andtimeout=. An entrypoint compiles to a single node.- Choose it when the flow reads naturally as code, you do not need a drawn graph, and you want minimal state plumbing. Choose the Graph API when you want explicit visualisation, fine-grained state reducers, parallel branches via edges, or per-node policies. The two can be mixed (a task inside a graph node, Β§2.2).
π Functional API Β· Use the functional API
11. Streaming
| Mode | Yields |
|---|---|
values | Full state after each step (includes private channels) |
updates | Only what each node changed |
messages | (token, metadata) from any LLM call inside nodes |
custom | Whatever you write with runtime.stream_writer / get_stream_writer() |
- Classic:
graph.stream(inp, config, stream_mode=['updates', 'messages'], version='v2')yields dicts withtype,ns,data. Interrupts appear under__interrupt__inupdates. - Event streaming (recommended for new apps):
stream = graph.stream_events(inp, config, version='v3')gives typed projections you consume independently:stream.messages,stream.values,stream.subgraphs,stream.interrupted,stream.interrupts,stream.output. Passoutput_keys=[...]to restrict streamed state. - Inside a node,
get_stream_writer()only works within a LangGraph execution context. - Disable token streaming for one model with
streaming=False(ordisable_streaming=True); useful on the platform for models whose output must not reach the client.
π Streaming Β· Event streaming Β· Frontend
12. Memory in LangGraph: when to use which
Analogy: the checkpointer is the save file of this playthrough; the store is the profile that follows the player across playthroughs; state is the HUD you see right now.
| Need | Use | Where it lives |
|---|---|---|
| Continue the same conversation later | Checkpointer + thread_id | Thread (all graph state) |
| Pause for a human, survive a crash | Checkpointer | Thread |
| Keep history from overflowing the context window | Trim, RemoveMessage or a summarise node | State (rewritten) or model input (transient) |
| Remember facts about a user across threads | Store (runtime.store) | Cross-thread, namespaced |
| Find memories by meaning | Store with index config and search(query=...) | Cross-thread |
| Per-request configuration or clients | Runtime context | Not persisted |
| Data that should not be checkpointed (live connections) | Keep out of state; use context or closures | n/a |
Pattern for long-term memory in a graph: a call_model node reads relevant memories from the store and adds them to the prompt; a separate update_memory node (or background job) writes new ones. Keep the write path in its own node so retries and interrupts do not duplicate writes, and use user-scoped namespaces. Memory design from the agent side (tools, middleware, summarisation) is on the LangChain page.
13. LangChain agents are LangGraph graphs
create_agentreturns a compiled graph. Embed it as a node or subgraph, and its middleware (HITL, summarisation, PII, retries) travels with it.- As a subgraph, tokens from the inner model only appear in the parent's
messagesstream withsubgraphs=True(orstream.subgraphs). Give each agent anameto seelc_agent_namein metadata. - Multi-agent handoffs use
Command(goto=..., graph=Command.PARENT)under the hood; the per-thread subagent rules in Β§8.2 apply. - Deep Agents adds planning, a filesystem and subagents on top of this same runtime.
14. Production, version gates and the trap list
- Deploy: LangSmith Cloud or the Agent Server provisions checkpointer and store automatically and exposes the graph through an API; locally you pass them yourself. Use LangSmith Studio to inspect state, set breakpoints and replay runs. Trace with LangSmith.
- Test: unit-test nodes as plain functions, and graphs with in-memory persistence; use
get_stateto assert on checkpoints. - Operate: graceful drain on SIGTERM, per-node timeouts and retries, a default error handler that marks the external job failed, recursion limits with proactive fallback.
| β Feature | Minimum |
|---|---|
| Default recursion limit 1000 | langgraph 1.0.6 |
v2 stream/invoke format (GraphOutput) | 1.1 |
runtime.execution_info, server_info | 1.1.5 |
Per-node timeouts, node error handlers, set_node_defaults, RunControl drain, runtime.heartbeat | 1.2 |
interrupt(..., response_schema=...) | 1.2.12 |
Event streaming (stream_events(version='v3')) | langchain 1.3-era releases; check your install |
Trap checklist:
- No checkpointer yet using
interrupt, threads or time travel; or nothread_id. - Non-idempotent code before an
interrupt()or in a retried node. - Bare
try/exceptaroundinterrupt();while Trueprompt loops; reordered interrupts. - Parallel writes to one key with no reducer; expecting an empty list to clear a reducer field.
- Mixing static edges with
Command/conditional routing from the same node. - A
Commandreturn annotation missing; a tool returningCommandwithout aToolMessage. - Sending
Command(update=...)as input to continue a chat (the graph looks stuck). recursion_limitplaced insideconfigurable; no proactive fallback.- Timeout on a sync node; relying on defaults being inherited by subgraphs.
- Renaming a state key or a node while threads are in flight.
- Treating private state channels as hidden in
valuesstreams. store.searchsilently truncated at the default limit of 10.
15. Exercises
- Build the agent loop from Β§9 by hand with
MessagesState, compile it withInMemorySaver, and show that twoinvokecalls on onethread_idshare history. - Add a node that calls
interrupt()before a tool runs. Resume with approval, then repeat and reject. Then deliberately put a counter-incrementing database call before the interrupt and observe the double run. - Make a state key
errors: Annotated[list[str], add]. Write a node that tries to clear it by returning[], see that nothing changes, then fix it withOverwrite. - Add a flaky async node with
RetryPolicy(max_attempts=3), aTimeoutPolicy(run_timeout=2), and anerror_handlerthat routes to afinalizenode. Make it fail on every attempt and read the final state. - Fan out with
Sendover a list of 5 items into a worker node writing toresults: Annotated[list, add], then synthesise. Add aRemainingStepsguard.
Solution notes
- The checkpointer reloads the whole state for that
thread_id; themessagesreducer appends the new turn. - On resume the node restarts from its first line, so the pre-interrupt side effect runs twice. Fix by upserting, or by moving the call after the interrupt or into its own node.
- A merging reducer merges the empty list into the old value;
{'errors': Overwrite([])}bypasses the reducer. - The order is attempts β retry policy β handler. The handler sees
NodeErrorwith the node name and last exception; status is whatever your handler wrote. Remember timeouts need an async node. - Each
Sendgets its own worker input; the reducer onresultsmerges parallel writes. Without the reducer the parallel writes conflict.
Sources: docs.langchain.com (Python): LangGraph overview, Graph API, persistence, stores, interrupts, fault tolerance, subgraphs, workflows and agents, runtimes, frameworks, harnesses, LangGraph v1 release notes. Time travel and the Functional API were not fully re-read (see scope notes above).