Agent Frameworks
LangGraph
LCEL chains are directed and acyclic — a | b | c flows one way. An agent loop is a cycle: think, act, observe, think again.
JrCodex·6 min read
Jr Codex Agentic AI Notes
Level: Intermediate–Advanced Prerequisites: Chapter 2: LangChain Time to complete: ~25 minutes
Table of Contents
- Why a Graph
- State and Reducers
- Nodes and Edges
- Building the Agent Loop
- Checkpointing and Resumption
- Human-in-the-Loop Interrupts
- Summary & Next Steps
1. Why a Graph
LCEL chains are directed and acyclic — a | b | c flows one way. An agent loop is a cycle: think, act, observe, think again.
The Shape Mismatch
─────────────────────────────────────────
LCEL CHAIN AGENT
a ──► b ──► c ┌──► agent ──┐
│ │ │
acyclic, one pass │ ▼ │
└── tools ◄──┘
cyclic, conditional,
unknown length
─────────────────────────────────────────
What LangGraph Adds
─────────────────────────────────────────
CYCLES loops with conditional exits
SHARED STATE a typed object every node reads
and updates, rather than passing
values along a pipe
CHECKPOINTING state persisted after every
node, so runs survive crashes
and can pause for humans
The third is the one you cannot easily build
yourself, and the main reason to adopt it
(Chapter 1).
─────────────────────────────────────────
2. State and Reducers
The graph is organised around one typed state object.
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
import operator
class AgentState(TypedDict):
messages: Annotated[list, add_messages] # REDUCER: appends rather than replaces
plan: dict # no reducer: assignment REPLACES
step_count: Annotated[int, operator.add] # REDUCER: sums the updates
findings: Annotated[list, operator.add] # REDUCER: concatenates listsReducers Are the Concept to Get Right
─────────────────────────────────────────
A node returns a PARTIAL update, not the whole
state. The reducer decides how it merges.
NO REDUCER the returned value REPLACES the
field. Correct for a plan or a
status.
add_messages appends, and de-duplicates by
message id. Correct for history.
operator.add concatenates lists or sums
numbers. Correct for accumulating
findings or counters.
Get this wrong and you either lose history
(replacing when you meant to append) or grow
without bound (appending when you meant to
replace). It is the most common LangGraph bug.
─────────────────────────────────────────
Why Reducers Matter for Parallelism
─────────────────────────────────────────
When two branches run concurrently and both
update `findings`, the reducer defines how the
two results combine.
Without one, the second write silently overwrites
the first — and parallel work quietly disappears.
─────────────────────────────────────────
3. Nodes and Edges
The Vocabulary
─────────────────────────────────────────
NODE a function: state ──► partial
state update
EDGE unconditional: after A, go to B
CONDITIONAL EDGE a function inspects state and
returns the name of the next
node — this is where cycles and
branching live
START, END entry and terminal markers
─────────────────────────────────────────
from langgraph.graph import StateGraph, START, END
def call_model(state: AgentState) -> dict:
response = model_with_tools.invoke(state["messages"])
return {"messages": [response], "step_count": 1} # PARTIAL update; reducers merge
def call_tools(state: AgentState) -> dict:
outputs = []
for call in state["messages"][-1].tool_calls:
result = TOOLS[call["name"]].invoke(call["args"])
outputs.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
return {"messages": outputs}
def should_continue(state: AgentState) -> str:
"""The conditional edge — this function IS the loop's exit condition."""
if state["step_count"] >= 15: # Module 4's hard bound
return "end"
return "tools" if state["messages"][-1].tool_calls else "end"4. Building the Agent Loop
builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", call_tools)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue,
{"tools": "tools", "end": END})
builder.add_edge("tools", "agent") # THE CYCLE — back for another decision
graph = builder.compile()The Whole Agent, as a Picture
─────────────────────────────────────────
START ──► agent ──(tool_calls?)──► tools ──┐
▲ │
└──────────────────────────────┘
│
└──(no tool_calls, or budget)──► END
─────────────────────────────────────────
result = graph.invoke({"messages": [("user", "Investigate order ord_991")],
"step_count": 0, "findings": [], "plan": {}})
# Or watch it run, node by node:
for chunk in graph.stream(initial_state, stream_mode="updates"):
for node, update in chunk.items():
print(f"[{node}] {update}")Why the Explicit Graph Is Worth the Verbosity
─────────────────────────────────────────
The control flow is now DATA, not buried in a
while-loop.
It can be visualised, tested node by node,
extended with a new node without touching the
others, and — critically — CHECKPOINTED between
any two nodes.
─────────────────────────────────────────
5. Checkpointing and Resumption
The capability that justifies the framework.
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("agent_state.db") as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "investigation-991"}}
graph.invoke({"messages": [("user", "Investigate order ord_991")]}, config)
# ... the process crashes, or the user closes the tab, or a day passes ...
# Resume: state is reloaded from the checkpointer automatically.
graph.invoke({"messages": [("user", "Continue where you left off")]}, config)
# Inspect history — every checkpoint, in order:
for snapshot in graph.get_state_history(config):
print(snapshot.next, snapshot.values["step_count"])What Checkpointing Gives You
─────────────────────────────────────────
DURABILITY a run survives a process restart.
For a 40-step agent, this is the
difference between an experiment
and a product.
TIME TRAVEL replay from any prior checkpoint —
the only practical way to debug a
non-deterministic multi-step run.
FORKING resume from an earlier state with
a different input, to compare
paths.
PAUSE/RESUME the foundation of the interrupts
in Section 6.
Module 6, Chapter 2 develops all four.
─────────────────────────────────────────
6. Human-in-the-Loop Interrupts
graph = builder.compile(
checkpointer=checkpointer,
interrupt_before=["tools"], # PAUSE before executing any tool
)
state = graph.invoke(initial, config) # runs until it wants a tool, then stops
pending = graph.get_state(config).values["messages"][-1].tool_calls
print("about to run:", pending)
if approved(pending):
graph.invoke(None, config) # None = RESUME from the checkpoint
else:
graph.update_state(config, {"messages": [ # inject a correction instead
ToolMessage(content="Denied by operator: refunds over $500 need a manager.",
tool_call_id=pending[0]["id"])]})
graph.invoke(None, config) # the agent continues, now informed# More precisely: interrupt only for DESTRUCTIVE tools (Module 2, Chapter 3's metadata).
def route_tools(state) -> str:
calls = state["messages"][-1].tool_calls
if any(TOOLS[c["name"]].metadata.get("destructive") for c in calls):
return "approval" # a node that interrupts
return "tools" # safe tools proceed unattendedThe Design Point
─────────────────────────────────────────
Interrupting before EVERY tool makes an agent
useless — a human approves forty reads to reach
one write.
Interrupt on the property that matters:
destructiveness. Read-only tools run freely;
mutations stop for a human.
Module 8, Chapter 2 builds this into a full
approval design.
─────────────────────────────────────────
7. Summary & Next Steps
Key Takeaways
- An agent loop is cyclic and conditional, which LCEL chains cannot express — LangGraph exists to make control flow explicit data rather than a hidden while-loop.
- Reducers define how a node's partial update merges into shared state; choosing wrongly either loses history or grows it without bound, and is the most common bug.
- Checkpointing after every node delivers durability, time-travel debugging, forking and pause/resume — the capability that most justifies adopting a framework.
- Interrupt on tool destructiveness rather than on every tool, or human approval becomes the bottleneck that makes the agent pointless.
Concept Check
- Why can't an agent loop be expressed as an LCEL chain?
- Two parallel branches both append to
findings, but only the second branch's results survive. What is wrong? - Why is
interrupt_before=["tools"]a poor production default, and what should replace it?
Next Chapter
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index