You want to build an AI agent. Not a chatbot that answers questions in one shot — an agent that reasons, picks tools, calls them, and decides what to do next based on the result. The kind that loops through think → act → observe until the job is done.
LangGraph is the framework for that. It models your agent as a directed graph: nodes do the work (call an LLM, run a tool), edges control the flow (go to tool A if the LLM asked for it, end if the answer is ready). The state persists between nodes, so the agent remembers what happened three steps ago.
By the end of this tutorial, you will have a working agent that can search the web and do math, all in under 100 lines of Python.
Prerequisites
- Python 3.10+ (check with
python3 --version) - An Anthropic API key (get one at
console.anthropic.com) or OpenAI key pip install langgraph langchain-anthropic langchain-community tavily-python
We will use Anthropic's Claude (Sonnet) for the LLM and Tavily for web search. You can swap both out — the graph structure stays the same.
pip install langgraph langchain-anthropic langchain-community tavily-python
Set your API keys:
export ANTHROPIC_API_KEY="sk-ant-..."
export TAVILY_API_KEY="tvly-..."
Tavily has a generous free tier (1,000 searches/month). Sign up at tavily.com.
Step 1: Define the Agent's State
LangGraph needs to know what data flows through the graph. We define a typed state object:
from typing import Annotated, List
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[List, add_messages]
One field: messages. It is a list of LangChain message objects (HumanMessage, AIMessage, ToolMessage). The add_messages reducer handles appending new messages instead of overwriting — the state accumulates across nodes.
That is the whole state. If your agent needs more context (user preferences, session data, file handles), you add more fields here. LangGraph serializes and passes this between every node.
Step 2: Define the Tools
Tools are functions the LLM can call. We will define two: a web search tool and a calculator.
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.tools import tool
# Web search via Tavily
tavily_tool = TavilySearchResults(
max_results=3,
search_depth="basic", # "advanced" for deeper, slower searches
include_answer=True, # Tavily's AI-generated summary
)
# Calculator tool
@tool
def calculator(expression: str) -> str:
"""Evaluate a mathematical expression. Use for arithmetic, percentages, and simple math."""
try:
result = eval(expression, {"__builtins__": {}}, {})
return str(result)
except Exception as e:
return f"Error: {e}"
tools = [tavily_tool, calculator]
The docstring on calculator matters. The LLM reads it to decide when to call this tool instead of the search tool. Be specific about what the tool does and when to use it.
A note on eval(): it is fine here because we sandbox it with empty builtins. In production, use a proper math parser (like numexpr). This is a tutorial.
Step 3: Create the LLM with Tool Binding
We bind the tools to the LLM so it knows they exist:
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(
model="claude-sonnet-4-20250514",
temperature=0,
)
llm_with_tools = llm.bind_tools(tools)
temperature=0 keeps the routing consistent. When the LLM is deciding which tool to call, you want determinism, not creativity.
Step 4: Build the Nodes
Nodes are Python functions. They receive the state, do something, and return an update:
from langchain_core.messages import ToolMessage
def agent_node(state: AgentState):
"""Call the LLM with the current message history."""
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
def tool_node(state: AgentState):
"""Execute tool calls requested by the LLM."""
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
# Find the matching tool
selected_tool = next(t for t in tools if t.name == tool_name)
output = selected_tool.invoke(tool_args)
results.append(ToolMessage(
content=str(output),
tool_call_id=tool_call["id"],
))
return {"messages": results}
The agent_node sends the full message history to the LLM. The tool_node finds the function the LLM requested, runs it, and wraps the result as a ToolMessage. The ToolMessage includes the tool_call_id so the LLM can match results to requests.
Step 5: Define the Routing Logic
The graph needs to know where to go after each node:
from langgraph.graph import END
def should_continue(state: AgentState):
"""Decide: call tools or finish."""
last_message = state["messages"][-1]
# If the LLM asked for tools, route to tool_node
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
# Otherwise, we are done
return END
This is a conditional edge. After the LLM responds, we inspect the message. If it contains tool_calls, we route to the tool node. If it is a plain text response, we end the graph and return to the user.
Step 6: Compile the Graph
Now we wire everything together:
from langgraph.graph import StateGraph
# Create the graph with our state schema
workflow = StateGraph(AgentState)
# Add nodes
workflow.add_node("agent", agent_node)
workflow.add_node("tools", tool_node)
# Set entry point
workflow.set_entry_point("agent")
# Add conditional edge from agent
workflow.add_conditional_edges(
"agent",
should_continue,
{
"tools": "tools", # If should_continue returns "tools"
END: END, # If should_continue returns END
}
)
# After tools, always go back to agent for the next reasoning step
workflow.add_edge("tools", "agent")
# Compile
agent = workflow.compile()
The flow: entry → agent → (conditional) → tools → agent → (conditional) → END.
The agent can loop through tools multiple times. If the user asks "Search for the GDP of Indonesia and calculate 15% of it," the agent will call search, get the number, call calculator, and then synthesize both results.
Step 7: Run the Agent
from langchain_core.messages import HumanMessage
# Run with a query that needs both tools
result = agent.invoke({
"messages": [HumanMessage(content="Find the population of Jakarta in 2024 and calculate what 3.5% of it is")]
})
# Print the conversation
for msg in result["messages"]:
if hasattr(msg, "tool_calls") and msg.tool_calls:
print(f"\n🔧 LLM requested tools:")
for tc in msg.tool_calls:
print(f" • {tc['name']}({tc['args']})")
elif hasattr(msg, "content"):
role = "🤖 Agent" if msg.type == "ai" else "👤 You"
print(f"{role}: {msg.content[:200]}...")
Expected output:
👤 You: Find the population of Jakarta in 2024 and calculate what 3.5% of it is
🔧 LLM requested tools:
• tavily_search_results_json({'query': 'Jakarta population 2024'})
🔧 LLM requested tools:
• calculator({'expression': '11000000 * 0.035'})
🤖 Agent: The population of Jakarta in 2024 is approximately 11 million. 3.5% of that is 385,000 people.
The agent autonomously decided: first search for population data, then run the calculation, then synthesize the final answer. Two tool calls, zero manual routing.
Adding Human-in-the-Loop
For production agents, you want to pause before certain tool calls. LangGraph supports this with interrupt_before:
agent = workflow.compile(
interrupt_before=["tools"] # Pause before any tool execution
)
# Start execution — it pauses before tools
config = {"configurable": {"thread_id": "1"}}
for event in agent.stream(
{"messages": [HumanMessage(content="Search for the latest Go 1.24 release notes")]},
config,
):
print(event)
# At the interrupt point, inspect state with agent.get_state(config)
When the graph pauses, you can inspect what tool the LLM requested and approve or modify it. Call agent.invoke(None, config) to resume with the current state. This is useful for financial transactions, database writes, or any tool with side effects.
Adding Persistence (Memory Across Sessions)
LangGraph has a built-in checkpointer. Swap the in-memory state for SQLite:
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("agent_memory.db") as checkpointer:
agent = workflow.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "user-123"}}
# First conversation
agent.invoke(
{"messages": [HumanMessage(content="My name is Arya")]},
config,
)
# Second conversation — same thread_id, agent remembers
result = agent.invoke(
{"messages": [HumanMessage(content="What is my name?")]},
config,
)
# Response: "Your name is Arya."
The thread_id groups messages into a conversation. Different thread IDs get isolated state. Restart your script and the agent still remembers.
When to Use LangGraph vs Alternatives
Use LangGraph when:
- Your agent needs multi-step reasoning with tool calls (think → act → observe → repeat)
- You want explicit control over the flow (you define exactly which node goes where)
- You need persistence, human-in-the-loop, or streaming
- You are building something that runs in production, not a notebook demo
Use the OpenAI Assistants API when:
- You want a managed solution and do not want to manage state yourself
- Your agent is simple (one tool, one call pattern)
- You are okay with vendor lock-in
Use CrewAI when:
- You want role-based multi-agent collaboration out of the box
- You need agents with "personalities" (researcher, writer, reviewer)
- You are prototyping fast and the exact flow is not critical
Use raw function calling (Anthropic/OpenAI SDK directly) when:
- You have a single-turn tool call pattern
- You want minimal dependencies
- You do not need state management across turns
LangGraph sits between raw SDKs and high-level frameworks. It gives you the graph abstraction without prescribing agent personalities or communication patterns.
Common Mistakes
Forgetting the add_messages reducer. Without it, each node overwrites the message list instead of appending. The agent loses all context from previous steps.
Not returning dicts from nodes. Nodes must return {"messages": [...]}, not bare message objects. LangGraph merges the return dict into state.
Binding tools after creating the graph. Tool binding happens on the LLM object, not the graph. If you add tools later, you need to re-bind and re-compile.
Using invoke() when you meant stream(). invoke() blocks until the graph finishes. For long agent runs, use stream() to get intermediate results. Users get feedback while the agent is working.
Not setting temperature=0 for routing. The LLM decides which tool to call based on its output. Non-zero temperature means inconsistent routing — the same input can produce different tool choices. For agent graphs, keep temperature at 0.
Going Further
Once your agent works, these are natural next steps:
- Add more tools. Database queries, file operations, API calls. The graph pattern does not change — just add nodes and update
should_continue. - Add subgraphs. For complex agents, nest graphs inside graphs. A "research" subgraph might have its own search → read → summarize loop.
- Add LangSmith tracing. Call
langsmithfor debugging: see every node transition, every tool call, and every state change in a visual timeline. - Deploy with LangGraph Platform. LangChain offers a deployment platform with built-in persistence, streaming, and horizontal scaling. Not free, but saves weeks of infrastructure work if you are building a product around agents.
Conclusion
You now have a working agent that reasons, calls tools, and loops until it has an answer. The same graph pattern scales from a 2-tool research assistant to a 20-tool production agent — add nodes, add edges, update the routing function.
The hardest part of building agents is not the LLM. It is the orchestration: tracking state, deciding what happens next, recovering from errors. LangGraph handles that part. You focus on what your agent should do, not how to wire it together.