Skip to content
Blog

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.AgentStatelangchain.agents.AgentState
MessageGraphStateGraph with a messages key
max_iterationsrecursion_limit — multiply by 2 and add 1
recursion error handlingfrom langgraph.errors import GraphRecursionError
chat memorycheckpointer (MemorySaver/InMemorySaver) + thread_id
trim_intermediate_stepsmessage trimmer reducer (remove_messages/trim_messages)
pre_model_hook / post_model_hookmiddleware: before_model / after_model
HumanInterruptConfig, ActionRequestlangchain.agents.middleware.human_in_the_loop.InterruptOnConfig, HITLRequest
ValidationNoderemoved — create_agent validates tool input automatically
manual should_continue + ToolNodebuilt 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

  • TypedDict is the documented default; dataclass works for defaults; Pydantic BaseModel is allowed but less performant.
  • Default reducer overwrites. Use Annotated[list, add_messages] to append, Overwrite(...) to bypass a merging reducer, UntrackedValue for non-checkpointed values.
  • The messages convention: Annotated[list[AnyMessage], add_messages] — or just use the prebuilt MessagesState.
  • 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

  1. Swap the entry point. Replace AgentExecutor with create_agent — you get a compiled graph immediately, with no behavior change.
  2. Add a checkpointer and thread_id to replace conversation memory.
  3. Fix max_iterations → recursion_limit using the 2n+1 rule, and catch GraphRecursionError.
  4. Only then hand-build the graph if you need branching, parallel tools, or custom state reducers.
  5. 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.

Further Reading