Modern agentic applications rarely make a single model call. They route requests, call tools, loop until a condition is met, and sometimes wait for human oversight. Writing this logic as plain scripts quickly becomes hard to follow and even harder to change. This is where LangGraph comes in.
Core concepts
LangGraph is a low-level agent orchestration framework that lets you express your application as a graph of small, testable steps. It is the runtime underneath LangChain's create_agent, so you can start high-level and drop down to LangGraph when you need full control.
LangGraph is a Python library, so all you need is to install the package:
pip install langgraph A LangGraph application is a state machine. This means that shared state flows from one node to another. The state acts as the shared memory for all nodes. A node is simply a Python function that reads the current state, performs a particular task, and returns an update to the state.
Edges connect nodes. An edge tells LangGraph which node runs next. Some edges are fixed, while conditional edges pick the next node at runtime based on the current state. Two special markers, START and END, define where execution begins and ends.
LangGraph also has reducers. When a node returns an update, LangGraph must decide how to merge it into the state. By default, new values replace old ones. But, for conversation history, that would be a disaster — each turn would erase the previous ones. A reducer such as add_messages tells LangGraph to append instead of replace. You can also build your own reducer functions.
Once you have defined the state, nodes, and edges, you compile the graph. Compilation produces a runnable object that you can invoke, stream, and inspect.
Your first graph
Let's put these pieces together and build a small chatbot. First, import the necessary packages and initialize a chat model:
# install more packages: pip install langchain langchain-openai python-dotenv
from typing import Annotated
from typing_extensions import TypedDict
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
load_dotenv() # load API keys from your environment
model = init_chat_model("gpt-5.4-mini")You do not need LangChain to create agents with LangGraph, but it offers a seamless integration. Next, let’s initialize our agent’s state:
class State(TypedDict):
messages: Annotated[list, add_messages]The State class defines the shape of our shared memory: a single messages key with the add_messages reducer attached. Then a chatbot node:
def chatbot(state: State):
response = model.invoke(state["messages"])
return {"messages": [response]}This chatbot node reads the messages, calls the model, and returns the response as an update. Because of the reducer, the response is appended to the history rather than overwriting it. Finally, we wire START → chatbot → END and compile:
builder = StateGraph(State)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "Explain recursion in one sentence."}]})
print(result["messages"][-1].content)The graph is just one node for now, but we can add more nodes, such as a tool-calling step or a routing decision later without rewriting the application.
Using the Functional API
In LangGraph, you're not limited to defining your agent with the Graph API. In some cases, you may prefer to compose the logic using traditional Python constructs such as if statements, for loops, and while loops. This is particularly useful when prototyping or when the workflow maps naturally to ordinary procedural code.
The Functional API uses two decorators: @entrypoint and @task. An entrypoint defines the beginning of the workflow and contains its control flow. A task represents a discrete unit of work, such as calling a model or accessing an external service. Calling a task returns a future, so synchronous entrypoints retrieve its value with .result(), while asynchronous entrypoints use await. Tasks also provide durable execution boundaries, allowing completed work to be saved and reused when a workflow resumes.
State management is also more implicit than with the Graph API. Instead of defining a shared state schema and reducers, you pass values between tasks with regular function arguments and return values. When an entry point has a checkpointer, its previous parameter can access the value saved by the preceding invocation for the same thread. This lets you preserve short-term state without manually defining nodes and edges.
Let's see how our chatbot changes when we implement it with the Functional API:
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langgraph.func import entrypoint, task
load_dotenv()
model = init_chat_model("gpt-5.4-mini")
@task
def call_model(messages: list):
return model.invoke(messages)
@entrypoint()
def chatbot(messages: list):
response = call_model(messages).result()
return [*messages, response]
result = chatbot.invoke(
[{"role": "user", "content": "Explain recursion in one sentence."}]
)
print(result[-1].content)Here, chatbot is the workflow's entry point, while call_model is a task whose result can be checkpointed independently. The message history is managed with an ordinary list rather than a state schema and reducer.
As you can see, this has a much lower footprint and keeps the control flow close to regular Python. The Graph API is often clearer when you want to visualize explicit nodes, edges, and routing decisions, while the Functional API is convenient when loops and branches are easier to express directly in code. Both APIs use the same LangGraph runtime and support features such as persistence, streaming, human-in-the-loop interrupts, and durable execution.
Streaming
LangGraph supports various streaming modes:
values— the full state after each step.updates— only the changes each node made.messages— LLM tokens with metadata, as they are generated.custom— arbitrary data you emit from inside your nodes.debug— detailed execution traces.
The updates mode is great for showing which step is running:
for chunk in graph.stream(
{"messages": [{"role": "user", "content": "What is project-based learning?"}]},
stream_mode="updates",
version="v2"
):
for node_name, state in chunk["data"].items():
print(f"Node {node_name} updated: {state}")
# output: Node chatbot updated state: {'messages': [AIMessage(content='Project-based learning (PBL) is a teaching method...For a chat interface, you want messages mode:
for message_chunk, metadata in graph.stream(
{"messages": [{"role": "user", "content": "What is project-based learning?"}]},
stream_mode="messages",
):
if message_chunk.content:
print(message_chunk.content, end="", flush=True)Note that the node itself still uses model.invoke(). LangGraph taps into the model call and surfaces the tokens for you. You can also pass a list of modes, such as ["updates", "messages"], to receive both kinds of events in a single stream.
Persistence
If we try asking our chatbot a follow-up question, it won’t remember the first one. Each invoke call starts from a blank slate, because nothing is saved between runs. With a checkpointer, you can save a snapshot of the state after each step in the graph. Snapshots are organized into threads, and each thread represents one conversation. To enable checkpointing, pass a checkpointer at compile time and a thread_id at invocation time:
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "conversation-1"}}
result1 = graph.invoke({"messages": [{"role": "user", "content": "Explain the concept of recursion in one sentence."}]}, config)
result2 = graph.invoke({"messages": [{"role": "user", "content": "I'm sorry, could you use a simple analogy?"}]}, config)
print(result1["messages"][-1].content)
print(result2["messages"][-1].content)
# outputs:
# Recursion is when a function solves a problem by calling itself on smaller versions of the same problem until it reaches a stopping condition.
# Sure — recursion is like looking into two mirrors facing each other: each reflection contains another reflection, and it keeps going until something stops it.The second call picks up the saved state of conversation-1, so the model sees the full history. A different thread_id would start a fresh conversation. As the name suggests, the InMemorySaver checkpointer keeps everything in RAM, which is perfect for development. In production, you swap it for a database-backed checkpointer such as the SQLite or Postgres savers — the rest of your code stays the same.
Persistence is the foundation for almost everything else LangGraph offers: memory, human-in-the-loop workflows, fault tolerance, and time travel. We will explore all of these in the next topic.
Conclusion
You have built your first LangGraph application. We covered the core model — state, nodes, edges, and reducers — and compiled a working chatbot. You then learned how to stream updates and tokens to keep your application responsive, and how checkpoints and threads give your graph a memory of past runs. With these foundations in place, you are ready for the advanced capabilities that LangGraph offers.