When you need control over every step, branches and loops, LangGraph is the tool LangChain agents themselves use.
LangGraph models an application as a graph over shared state:
- State is a
TypedDict(or Pydantic model/dataclass). Each key can have a reducer that says how updates merge: by default a new value overwrites;Annotated[list, operator.add]appends;Annotated[list, add_messages]appends and updates messages by ID.MessagesStateis a ready-made state with amessageskey. - Nodes are functions
node(state) -> dictreturning only the keys they update. - Edges connect nodes:
add_edge(START, "a"),add_edge("a", "b"),add_edge("b", END). - Conditional edges route dynamically:
add_conditional_edges("check", route_fn)whereroute_fn(state)returns the next node's name (orEND).
builder = StateGraph(State)
builder.add_node("classify", classify)
...
graph = builder.compile()
graph.invoke({"text": "..."})The compiled graph is a runnable: invoke, stream (with modes values, updates, messages, custom), async variants, and graph.get_graph().draw_mermaid() for a diagram.
Loops are just edges back to earlier nodes, guarded by a condition; recursion_limit in config stops runaway loops.
Use LangGraph directly when the flow is known (classify, then branch, then validate), mixing deterministic code with LLM calls. Use create_agent when the model should decide the flow. They combine: an agent can be a node, and a graph can be a tool.
Example
import operator
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
text: str
category: str
log: Annotated[list[str], operator.add] # reducer: append updates
def classify(state: State):
cat = "refund" if "refund" in state["text"].lower() else "general"
return {"category": cat, "log": [f"classified as {cat}"]}
def route(state: State) -> str:
return "refund_flow" if state["category"] == "refund" else "general_flow"
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_node("refund_flow", lambda s: {"log": ["opened refund ticket"]})
builder.add_node("general_flow", lambda s: {"log": ["sent FAQ link"]})
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route, ["refund_flow", "general_flow"])
builder.add_edge("refund_flow", END)
builder.add_edge("general_flow", END)
graph = builder.compile()
print(graph.invoke({"text": "I want a refund", "log": []})["log"])
# ['classified as refund', 'opened refund ticket']Remember
- State is a TypedDict; reducers decide how node updates merge
- Nodes return partial updates; edges and conditional edges define the flow
- Use explicit graphs for known flows, agents when the model should decide
Watch out for
- Returning the whole state from a node while a key has an append reducer, which duplicates list entries.