Migrating a LangChain Agent Chain to LangGraph: A Refactoring Guide
From AgentExecutor to StateGraph — a step-by-step mapping of the old LangChain agent pattern onto LangGraph concepts, with the v1 migration rules.
Published on • October 7, 2026
AI Assistant

The classic LangChain agent was a loop: call the model, parse a tool call, execute the tool, feed the result back, repeat until the model stopped asking. AgentExecutor wrapped that loop with max_iterations, memory, and callbacks. LangGraph replaces the loop with an explicit graph — and once you’ve made the mental shift, most of the migration is mechanical.
Here’s the refactoring, from the old pattern to the new, using the LangGraph docs.
The Mental Model
LangGraph is a low-level orchestration runtime inspired by Pregel and Apache Beam, with an interface influenced by NetworkX. It provides durable execution, streaming, human-in-the-loop, and persistence — and it’s usable without LangChain at all.
Three components:
- State — a shared data structure every node reads and writes.
- Nodes — functions that take state and return an update.
- Edges — functions that decide what runs next; plain or conditional.
“Nodes do the work, edges tell what to do next.”
Execution happens in discrete super-steps. Parallel nodes run in the same super-step. The graph halts when no nodes are active and no messages are in transit.
Minimal Graph
from langgraph.graph import StateGraph, MessagesState, START, END
def mock_llm(state: MessagesState):
return {"messages": [{"role": "ai", "content": "hello world"}]}
graph = StateGraph(MessagesState)
graph.add_node("mock_llm", mock_llm)
graph.add_edge(START, "mock_llm")
graph.add_edge("mock_llm", END)
graph = graph.compile() # you MUST compile before use
graph.invoke({"messages": [{"role": "user", "content": "hi!"}]})
START and END are virtual nodes for entry and exit. set_entry_point/set_finish_point exist but are legacy equivalents.
The Direct Migration: create_agent
If you were using create_react_agent or hand-rolling AgentExecutor, the one-line answer in LangChain v1 is:
# v0 (old)
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(model, tools, prompt="You are a helpful assistant.")
# v1 (new)
from langchain.agents import create_agent
agent = create_agent(model, tools, system_prompt="You are a helpful assistant.")
create_agent returns a CompiledStateGraph — the same object you’d build by hand — with the model/tool loop built in, plus automatic tool-input validation (the old ValidationNode is gone). Canonical usage:
from langchain.agents import create_agent
agent = create_agent(
model="openai:gpt-5.5",
tools=[get_weather],
system_prompt="You are a helpful assistant",
)
result = agent.invoke({"messages": [{"role": "user", "content": "..."}]})
But sometimes you need the explicit graph — branching, parallel tool execution, custom state. That’s where the real mapping lives.
The Classic Pattern, Rewritten
The old should_continue + ToolNode pattern translates directly:
builder = StateGraph(AgentState)
builder.add_node("llm", call_model)
builder.add_node("tools", tool_node)
builder.add_conditional_edges(
"llm",
should_continue, # returns "continue" or "end"
{"continue": "tools", "end": END},
)
builder.add_edge("tools", "llm") # tool results flow back
builder.set_entry_point("llm")
graph = builder.compile(checkpointer=InMemorySaver())
Compare with the executor you’re replacing:
# old
agent = create_tool_calling_agent(model, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=10)
The max_iterations: 10 becomes recursion_limit in config — with a conversion quirk below.
Migration Mapping Table
| Old (AgentExecutor / LCEL) | New (LangGraph / LangChain v1) |
|---|---|
AgentExecutor(agent=..., tools=...) | create_agent(model, tools, system_prompt=...) |
create_react_agent(..., prompt=) | create_agent(..., system_prompt=) |
langgraph.prebuilt.AgentState | langchain.agents.AgentState |
MessageGraph | StateGraph with a messages key |
max_iterations | recursion_limit — multiply by 2 and add 1 |
| recursion error handling | from langgraph.errors import GraphRecursionError |
chat memory | checkpointer (MemorySaver/InMemorySaver) + thread_id |
trim_intermediate_steps | message trimmer reducer (remove_messages/trim_messages) |
pre_model_hook / post_model_hook | middleware: before_model / after_model |
HumanInterruptConfig, ActionRequest | langchain.agents.middleware.human_in_the_loop.InterruptOnConfig, HITLRequest |
ValidationNode | removed — create_agent validates tool input automatically |
manual should_continue + ToolNode | built into create_agent, or explicit graph as above |
The recursion-limit trap: each super-step counts, and one old AgentExecutor iteration (model call + tool execution) is two graph steps. So RECURSION_LIMIT = 2 * n + 1. Setting recursion_limit=10 gives you roughly 4 old-style iterations, not 10.
Memory: the old conversation memory object is replaced by checkpointing. Compile with a checkpointer and pass a thread id:
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "user-42"}}
graph.invoke({"messages": [...]}, config)
Checkpoints are saved at super-step boundaries. A node that’s interrupted or retried re-runs from the start — so design nodes idempotently.
State Schema Details
TypedDictis the documented default;dataclassworks for defaults; PydanticBaseModelis allowed but less performant.- Default reducer overwrites. Use
Annotated[list, add_messages]to append,Overwrite(...)to bypass a merging reducer,UntrackedValuefor non-checkpointed values. - The
messagesconvention:Annotated[list[AnyMessage], add_messages]— or just use the prebuiltMessagesState. - Nodes can accept
(state, config: RunnableConfig, runtime: Runtime).
Edge types you’ll reach for:
builder.add_edge("a", "b") # static
builder.add_conditional_edges("a", route, {"x": "b", "y": "c"}) # dynamic
builder.add_conditional_edges(START, route) # conditional entry
Multiple outgoing edges from one node = parallel execution in the next superstep. Don’t mix static and dynamic routing from the same node.
For map-reduce fan-out, return Send("node", state) from a conditional edge. Command(update=..., goto=..., resume=...) combines a state update with routing, and Command(resume=...) resumes after an interrupt().
The v1 Migration Rules
LangGraph v1 (langgraph>=1.0.0, langchain-core>=0.3.0; Python 3.10+ required) is largely backward compatible — custom StateGraphs, checkpointers, interrupt(), Command, Send, and MemorySaver are unchanged. The rules for evolving a live graph:
Topology:
- Threads never interrupted: add, remove, or rename nodes freely.
- Threads interrupted: all topology changes allowed except renaming or removing nodes — a resume would look for a node that no longer exists.
State:
- Adding or removing keys is fully backward/forward compatible.
- Renamed keys lose their saved state.
- Incompatible type changes can break old threads.
Upgrade command:
pip install -U langgraph langchain-core
Legacy LangChain imports move to the langchain-classic package.
Suggested Refactor Order
- Swap the entry point. Replace
AgentExecutorwithcreate_agent— you get a compiled graph immediately, with no behavior change. - Add a checkpointer and
thread_idto replace conversation memory. - Fix
max_iterations→recursion_limitusing the2n+1rule, and catchGraphRecursionError. - Only then hand-build the graph if you need branching, parallel tools, or custom state reducers.
- Make nodes idempotent before enabling interrupts — they’ll re-run from the top.
The executor hid a loop; the graph makes you draw it. That’s the whole migration — and the reason people don’t go back.