Python & AI Tutorials Logo
LangChain & LangGraph

15. Building Your First Graph with LangGraph

In Part IV, we defined tools, connected them to an LLM, and completed an agent loop that repeats the decide-and-execute cycle. Driving the loop, running tools when the LLM asked for them, knowing when to stop — we coded every part of that flow by hand.

In this chapter, we'll build the same agent in an entirely different way. Instead of writing the flow directly, we'll register steps (nodes) and connection rules (edges) with the LangGraph framework and let it handle execution. The behavior is identical to Chapter 14, but the way we build it changes.

This chapter covers four core concepts — StateGraph, nodes, edges, and State — then refactors the Chapter 14 agent loop into a LangGraph graph. In the following chapters, Chapter 16 covers conditional routing and prebuilt components, and Chapter 17 covers state persistence that lets an agent resume from where it was interrupted.

15.1) Why Graphs?

15.1.1) Limitations of the Existing Agent Loop

Let's revisit the agent loop from Chapter 14. Stripping away error handling and other details, the core structure looked like this:

python
# Chapter 14 agent loop — core structure (simplified)
messages = [
    SystemMessage(content="You are a helpful assistant."),
    HumanMessage(content=user_input),
]
 
for step in range(max_steps):
    # Ask the LLM to decide the next action
    ai_message = llm_with_tools.invoke(messages)
    messages.append(ai_message)
 
    # If no tool call is requested, return the final answer
    if not ai_message.tool_calls:
        return ai_message.content
 
    # Execute the requested tools
    for tool_call in ai_message.tool_calls:
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        messages.append(tool_message)

This code covers only the basics and nothing else. In a real production environment, though, much more is required. Here are a few examples.

  • Crash recovery — If an agent crashes at step 7 of a 10-step research task, it should be able to resume from step 7 instead of starting over from the beginning.
  • Approval requests — Before an agent performs a critical operation, it should be able to pause and ask a human "Is it okay to proceed?"
  • Real-time monitoring — Users should be able to see what the agent is currently doing and which tools it's calling.
  • Visualization and debugging — A diagram showing how the agent operates should be available so that when problems arise, you can trace which step went wrong.

Implementing these features yourself is not impossible, but it's not easy either. Crash recovery alone requires writing code to serialize the state at each step, save it to disk, restore it, and resume at the exact position. You could end up with more infrastructure code than business logic.

LangGraph was built to provide these features at the framework level. Crash recovery, approval requests, monitoring, visualization — the framework handles all of these. But there's one requirement: you have to build your agent in a structure the framework can understand.

The Chapter 14 agent loop handles all the logic directly, so there's nothing for the framework to hook into. To take advantage of what LangGraph offers, we need to rebuild the agent in a structure LangGraph understands — a graph. That's what this chapter is about.

15.1.2) What Is LangGraph?

LangGraph is an orchestration framework that defines and executes agent workflows as graphs. A graph here means a structure where each node (step) the agent performs is connected by edges (connection rules).

In LangGraph, you break the workflow into independent nodes and connect them with edges. LangGraph then walks the graph, executing each node along the way. Here's what the Chapter 14 agent loop looks like expressed as a graph:

Yes

No

START

LLM Call

Tool call requested?

Tool Execution

END

The rectangular boxes are nodes, and the arrows are edges. The diamond represents a conditional edge that branches to different paths based on a condition.

In Chapter 14, the entire workflow lived in for loops, if checks, and other hand-written code. With LangGraph, you define what each node does and wire the nodes together with edges. In short, you move from coding the workflow to declaring it as structure.

LangGraph doesn't replace anything you learned in Chapters 12–14. Tool definitions, bind_tools(), tool_calls, ToolMessage — all of these are still used inside nodes, exactly as before.

The next section covers the core components of LangGraph — StateGraph, State, nodes, and edges — one at a time.

15.2) LangGraph Components: StateGraph, State, Nodes, Edges

This section walks through LangGraph's four core components one at a time. We'll start with StateGraph — the class that bundles State, nodes, and edges into a graph — then cover each of the parts (State, nodes, edges) that go inside it.

15.2.1) StateGraph

StateGraph is the class used to build graphs in LangGraph. You specify the State the graph will manage, add nodes, connect them with edges, and then compile to produce an executable graph.

Let's see how it works.

Creating a StateGraph Instance

Call the StateGraph constructor to create an instance. You need to pass the State schema (the class itself) as a parameter. Here we're using MessagesState, a predefined State that LangGraph provides for managing message lists. We'll cover the details in 15.2.2.

python
from langgraph.graph import StateGraph, MessagesState
 
builder = StateGraph(MessagesState)

Adding Nodes

Use add_node() to register a node. A node is a Python function that takes the current State and returns the parts it wants to change. We'll cover node functions in detail in 15.2.3.

python
def say_hello(state: MessagesState):
    return {"messages": [{"role": "ai", "content": "hello world"}]}
 
builder.add_node(say_hello)    # node name becomes "say_hello"

Connecting Edges

Use add_edge(source, target) to connect nodes. source is where the edge starts; target is where it goes. START and END are special markers for the graph's entry and exit points. We'll cover edges in 15.2.4.

python
from langgraph.graph import START, END
 
builder.add_edge(START, "say_hello")   # graph starts → run say_hello
builder.add_edge("say_hello", END)     # say_hello completes → end graph

Compiling and Running

Calling compile() validates the graph structure and produces an executable object. You run the compiled graph with invoke(), passing in the initial State values.

python
graph = builder.compile()
 
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)

Now let's put it all together and build a simple graph:

python
from langgraph.graph import StateGraph, MessagesState, START, END
 
def say_hello(state: MessagesState):
    return {"messages": [{"role": "ai", "content": "hello world"}]}
 
builder = StateGraph(MessagesState)
 
builder.add_node(say_hello)
builder.add_edge(START, "say_hello")
builder.add_edge("say_hello", END)
 
graph = builder.compile()
 
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)
print(result["messages"][-1].content)

Output:

hello world

When you call invoke(), the graph runs in the order STARTsay_helloEND. say_hello returned a dictionary with messages as the key, and that value was appended to the messages list in MessagesState. We'll dig into how this works in 15.2.2. The upshot is that pulling out the last message's content gives us "hello world".

15.2.2) State: Data That Flows Through the Graph

State is the data that every node in the graph shares. When a node runs, it receives the current State, does its work, and returns just the parts it wants to change. LangGraph folds those changes back into the State and hands the updated version to the next node.

Defining State

You define State by subclassing TypedDict. Pick the fields and types that match what your agent needs to track. Here's a simple example:

python
from typing_extensions import TypedDict
 
class AgentState(TypedDict):
    messages: list       # message list
    llm_calls: int       # LLM call count

From here, you pass AgentState when creating a StateGraph and use it as the type hint for your node functions.

Reducers

When a node returns a value, the corresponding State field gets updated. The default behavior is overwrite — if a node returns {"llm_calls": 3}, llm_calls simply becomes 3, no matter what it was before.

But some fields need appending, not overwriting. What happens if messages gets overwritten? Every time a node returns a new message, the entire conversation history vanishes. For messages, appending is the right behavior.

LangGraph lets you set a different update strategy per field through a reducer function. You specify the reducer as the second argument in Annotated:

python
from typing_extensions import TypedDict, Annotated
from langgraph.graph.message import add_messages
 
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]   # reducer: append
    llm_calls: int                            # no reducer: overwrite

add_messages is a reducer provided by LangGraph. Instead of replacing the list, it appends new messages to what's already there. Because this reducer is set on the messages field, any value a node returns for messages gets appended. llm_calls has no reducer, so returned values simply overwrite whatever was there before.

This is exactly why the message say_hello returned in 15.2.1 was appended to messages rather than replacing it — the reducer handled it.

MessagesState

LangGraph ships with a predefined State called MessagesState. Here's what it looks like under the hood:

python
class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

Same structure we just covered — a messages field with the add_messages reducer already wired up.

If you need extra fields, just subclass it:

python
from langgraph.graph import MessagesState
 
class AgentState(MessagesState):
    llm_calls: int  # default overwrite behavior

15.2.3) Nodes: Functions That Update State

A node is a Python function that performs a single, specific task inside the graph.

python
def say_hello(state: MessagesState):
    return {"messages": [{"role": "ai", "content": "hello world"}]}

Two things to know when writing node functions:

Rule 1: It receives the current State as its argument. LangGraph passes the current State object in when it runs the node.

Rule 2: It returns only the parts it wants to change, not the full State. Nodes don't modify State directly. Just return the fields you want to update, and LangGraph merges them into the existing State according to each field's reducer rules.

Use add_node() to add a node to the StateGraph:

python
builder.add_node(say_hello)          # function name "say_hello" becomes the node name
builder.add_node("my_node", my_func) # you can also specify the name explicitly

15.2.4) Edges: Rules That Connect Nodes

An edge determines "after this node finishes, what runs next?" There are two types.

Normal Edges

A normal edge wires a fixed "go-to" between two nodes. Use add_edge(source, target)source is the starting node, target is the destination.

python
builder.add_edge(START, "say_hello")       # when the graph starts, run say_hello
builder.add_edge("say_hello", "llm_call")  # after say_hello, run llm_call
builder.add_edge("llm_call", END)          # after llm_call, end the graph

Conditional Edges

A conditional edge picks the next node at runtime based on the current State. Use add_conditional_edges(source, routing_function)source is the starting node, and routing_function is a function that takes the current State and returns the name of the next node:

python
from langgraph.graph import END
 
def should_continue(state: AgentState):
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"   # tool call requested → go to tool_node
    return END               # no tool call → end
 
builder.add_conditional_edges("llm_call", should_continue)

add_conditional_edges("llm_call", should_continue) tells LangGraph: "when llm_call finishes, call should_continue to decide what runs next." should_continue routes to "tool_node" if the last message has tool_calls, or to END if it doesn't. In practice, that means the graph continues to the tool execution node when the LLM requests a tool call, and terminates when it doesn't.

Now that we've covered all four components, the next section uses them to refactor the Chapter 14 agent loop into a LangGraph graph.

15.3) Refactoring the Agent Loop into a Graph

Let's rebuild the Chapter 14 agent loop using LangGraph. The behavior is identical to Chapter 14 — the LLM decides, tools execute based on the LLM's requests, and the cycle repeats until complete. The only thing that changes is how we structure this flow.

Here's what the finished graph will look like:

Yes

No

START

llm_call

Tool call requested?

tool_node

END

The graph cycles between llm_call and tool_node until the LLM stops requesting tool calls, at which point it exits to END. Let's build it step by step.

15.3.1) Defining State

We subclass MessagesState from 15.2.2 to define the agent's State. The messages field is inherited from MessagesState, and we add an llm_calls field to track the number of LLM calls.

python
from langgraph.graph import MessagesState
 
class AgentState(MessagesState):
    llm_calls: int    # LLM call count (overwrite)

The messages list will accumulate user inputs (HumanMessage), LLM responses (AIMessage), and tool execution results (ToolMessage) in order.

15.3.2) Building Nodes

First, let's set up the tools and model from Chapter 14:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import ToolMessage
from langchain.tools import tool
 
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """Calculate a simple arithmetic expression. Example: '3 * 21'."""
    return str(eval(expression))  # Warning: eval() is a security risk. Do not use in production.
 
tools = [get_weather, calculate]
tool_map = {t.name: t for t in tools}
 
llm = ChatOpenAI(model="gpt-5-mini")
model_with_tools = llm.bind_tools(tools)

Now let's write the two node functions.

llm_call node — Calls the LLM and returns the response:

python
def llm_call(state: AgentState):
    """Call the LLM and return the response."""
    response = model_with_tools.invoke(state["messages"])
    return {
        "messages": [response],
        "llm_calls": state.get("llm_calls", 0) + 1,
    }

model_with_tools.invoke() calls the LLM, and the response is packaged under the messages key in the return dictionary. The reducer appends it to the existing messages in AgentState. llm_calls returns the current count plus 1, overwriting the previous value.

tool_node node — Executes the tools requested by the LLM and returns the results:

python
def tool_node(state: AgentState):
    """Execute the tools requested by the LLM."""
    last_message = state["messages"][-1]
    results = []
    for tool_call in last_message.tool_calls:
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        results.append(tool_message)
    return {"messages": results}

Because tool_node always runs right after llm_call, the last message in messages is guaranteed to be the AIMessage the LLM just produced. That message's tool_calls field contains the tool calls the LLM requested. The node runs each tool, collects the results in results, and returns them under the messages key — the reducer takes care of appending them to the existing list.

15.3.3) Conditional Edge

Once llm_call finishes, we need a conditional edge to decide whether to run tool_node or end the graph. This follows the same pattern from 15.2.4:

python
from typing import Literal
from langgraph.graph import END
 
def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
    """Decide whether to execute tools or end the graph."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"
    return END

If last_message.tool_calls is present, the LLM is asking for a tool call, so we route to tool_node. Otherwise, we route to END and the graph terminates.

The Literal["tool_node", "__end__"] return type hint declares the possible destinations this function can return. LangGraph needs this hint to draw conditional edge paths correctly in graph visualizations. It has no effect on runtime behavior.

"__end__" is the underlying string value of END. Because Literal only accepts string literals, we write "__end__" instead of END.

15.3.4) Assembling and Running the Graph

Time to wire everything together. Let's assemble the State, nodes, and conditional edge into a StateGraph and compile:

python
from langgraph.graph import StateGraph, START, END
 
builder = StateGraph(AgentState)
 
builder.add_node("llm_call", llm_call)
builder.add_node("tool_node", tool_node)
 
builder.add_edge(START, "llm_call")                         # start → llm_call
builder.add_conditional_edges("llm_call", should_continue)  # llm_call → tool_node or END
builder.add_edge("tool_node", "llm_call")                   # tool_node → llm_call (loop)
 
agent = builder.compile()

The edge from tool_node back to llm_call creates a loop. Execution keeps cycling until the LLM responds with a final answer instead of requesting another tool call, at which point the loop exits.

Let's run it:

python
from langchain_core.messages import HumanMessage
 
result = agent.invoke({
    "messages": [HumanMessage(content="Get the temperature in Cairo, then multiply the number by 3.")],
    "llm_calls": 0,
})
 
print(result["messages"][-1].content)
print(f"\nTotal LLM calls: {result['llm_calls']}")

Output:

Current temperature in Cairo: 31°C. Multiplied by 3 = 93.
 
Total LLM calls: 3

The agent called get_weather("Cairo"), saw the result, called calculate("31 * 3"), and produced the final answer — the same result we got in Chapter 14.

Inspecting the full message history shows every step recorded in messages, in order:

python
for message in result["messages"]:
    message.pretty_print()

Output:

================================ Human Message =================================
Get the temperature in Cairo, then multiply the number by 3.
================================== Ai Message ==================================
Tool Calls:
  get_weather (call_DiL9WF)
  Args:
    city: Cairo
================================= Tool Message =================================
Name: get_weather
31°C, sunny
================================== Ai Message ==================================
Tool Calls:
  calculate (call_wa6RqWST)
  Args:
    expression: 31 * 3
================================= Tool Message =================================
Name: calculate
93
================================== Ai Message ==================================
Current temperature in Cairo: 31°C. Multiplied by 3 = 93.

15.3.5) Graph Visualization

In a Jupyter notebook, agent.get_graph().draw_mermaid_png() renders the graph structure as an image right in the cell output.

python
from IPython.display import Image, display
 
display(Image(agent.get_graph().draw_mermaid_png()))

In a terminal environment, save it as a PNG file instead.

python
agent.get_graph().draw_mermaid_png(output_file_path="agent_graph.png")

Generated image:

__start__

llm_call

tool_node

__end__

Solid lines are normal edges and dotted lines are conditional edges. This diagram is automatically generated from the code.

15.3.6) Recursion Limit

Just as we used max_steps to guard against infinite loops in Chapter 14, LangGraph has a built-in safety net. Every time a node runs during graph execution, an internal counter ticks up by one. When that counter exceeds the configured limit, LangGraph raises a GraphRecursionError.

To see how the count works, look at the previous run. Calling both get_weather and calculate visited nodes in this order:

llm_call(1) → tool_node(2) → llm_call(3) → tool_node(4) → llm_call(5) → END

That's 5 node visits in total. If you set recursion_limit to 3, the limit kicks in at the 3rd visit and the run is cut short:

python
from langgraph.errors import GraphRecursionError
 
try:
    result = agent.invoke(
        {"messages": [HumanMessage(content="Get the temperature in Cairo, then multiply the number by 3.")],
         "llm_calls": 0},
        config={"recursion_limit": 3},
    )
except GraphRecursionError:
    print("Agent hit the recursion limit — stopping execution.")

Output:

Agent hit the recursion limit — stopping execution.

Set the limit by passing config={"recursion_limit": number} to invoke(). The right value depends on your use case and the complexity of your graph. Start with a generous number and tune it through testing.