machine learning +
Build a Python AI Chatbot with Memory Using LangChain
LangGraph Tool Calling: Build Agents That Take Action
Build LangGraph agents that call tools with @tool, bind_tools, and ToolNode. Step-by-step examples for web search, database queries, and API calls in Python.
Tool calling lets your LangGraph agent go beyond text — it can search the web, query databases, and hit APIs, all within a structured loop you control.
Your LangGraph agent can think through problems. It can write thoughtful replies. But it can’t actually do anything on its own. It can’t look things up online, check a database, or call an API. It talks a good game, but takes no action.
That changes with tool calling. You hand the LLM a set of functions it can reach for. When the model decides one of those functions would help, it sends back a structured request rather than plain text. LangGraph catches that request, runs the function, and feeds the output back to the model. The LLM then uses that output to craft its final answer.
That’s the full pattern. Let’s walk through it step by step.
What Does Tool Calling Mean in LangGraph?
At its core, tool calling is a way for the LLM to ask your code to run a specific function. The model does not run anything itself — it sends a structured message that says “please call this function with these inputs.” Your code then runs the function and passes the result back.
In LangGraph, three pieces work together to make this happen. First, you create tools with LangChain’s @tool decorator. Second, you link those tools to your chat model using .bind_tools(). Third, you use ToolNode from langgraph.prebuilt to run the tools when the LLM asks for them.
Here’s a small example that shows all three parts. We’ll make a simple math tool, link it to the model, and let LangGraph handle the rest.
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
load_dotenv()
# Step 1: Define a tool
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
# Step 2: Bind the tool to the model
tools = [multiply]
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
print("Setup complete — model with tool binding ready")
python
Setup complete — model with tool binding ready
Three lines of setup is all it takes. The @tool decorator wraps a plain function so the LLM can find and call it. The .bind_tools() method tells the model what tools are on offer, what inputs they expect, and what they do. LangGraph handles everything from there.
Key Insight: The LLM never runs your tools on its own. It sends back a structured “please call this” message — and your code (through ToolNode) does the actual work. This split is what keeps tool calling safe and under your control.
How Do You Create Tools with @tool?
Every tool starts as a normal Python function. The @tool decorator from langchain_core.tools turns it into something the LLM can discover. Two things matter most: the function name and the docstring.
The name becomes the tool’s label. The docstring becomes the text the LLM reads when it picks which tool to use. In my experience, vague docstrings are the biggest reason models pick the wrong tool — so spend time writing clear ones.
python
@tool
def search_web(query: str) -> str:
"""Search the web for current information about a topic.
Use this when the user asks about recent events,
facts you're unsure about, or anything requiring
up-to-date information."""
# In production, this would call a real search API
return f"Search results for '{query}': Python 3.13 was released in October 2024 with improved error messages."
@tool
def get_weather(city: str, unit: str = "celsius") -> str:
"""Get the current weather for a city.
Args:
city: The city name (e.g., 'London', 'New York')
unit: Temperature unit - 'celsius' or 'fahrenheit'
"""
return f"Weather in {city}: 22°{unit[0].upper()}, partly cloudy"
print(f"Tool name: {search_web.name}")
print(f"Tool description: {search_web.description}")
python
Tool name: search_web
Tool description: Search the web for current information about a topic.
Use this when the user asks about recent events,
facts you're unsure about, or anything requiring
up-to-date information.
Pay close attention to the type hints on each parameter. They’re not just nice-to-have. The @tool decorator reads them to build the JSON schema that tells the LLM what each argument should look like. Leave them out, and the schema breaks.
Tip: Write docstrings for the LLM, not for fellow developers. Rather than “Fetches weather data from API,” write “Get the current weather for a city. Use this when the user asks about weather, temperature, or forecasts.” The model reads this text to decide *when* a tool is the right fit.
How Does bind_tools Connect Tools to the Model?
Creating tools is just the first step. The LLM still doesn’t know they exist. That’s where .bind_tools() comes in — it attaches the tool schemas to every call so the model can see what’s on the table.
python
tools = [multiply, search_web, get_weather]
llm_with_tools = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
# Ask something that should trigger tool use
response = llm_with_tools.invoke(
[HumanMessage(content="What is 15 times 23?")]
)
print(f"Content: '{response.content}'")
print(f"Tool calls: {response.tool_calls}")
python
Content: ''
Tool calls: [{'name': 'multiply', 'args': {'a': 15, 'b': 23}, 'id': 'call_abc123', 'type': 'tool_call'}]
Look at what happened. The content field is empty. The model didn’t try to answer the math question with words — it chose the multiply tool and sent back a tool_calls list with the function name and its arguments.
Without .bind_tools(), the model would just say “15 times 23 is 345” in plain text. With binding, it hands the work off to your function. For basic math, that’s a small shift. For web lookups, database queries, or API calls, it changes everything.
But what if the question doesn’t call for a tool?
python
response = llm_with_tools.invoke(
[HumanMessage(content="What is the capital of France?")]
)
print(f"Content: '{response.content}'")
print(f"Tool calls: {response.tool_calls}")
python
Content: 'The capital of France is Paris.'
Tool calls: []
The model answered on its own with no tool calls. It only reaches for a tool when it judges one would help. This is the right behavior — the model thinks about whether a tool fits before using one.
How Do You Wire the Tool-Calling Graph with ToolNode?
So far we’ve talked to the model one-on-one. But a tool call just returns a request — nobody has actually run the tool yet or fed the output back. That’s the job of the LangGraph graph.
ToolNode from langgraph.prebuilt takes care of running tools. It reads the tool calls from the latest AI message, runs the right functions, and sends results back as ToolMessage objects. Pair it with tools_condition for routing and you get a full LangGraph tool calling loop.
We need two nodes: one for the LLM (the “agent”) and one for running tools. tools_condition checks whether the LLM sent any tool calls. If yes, flow goes to the tool node. If no, the chat wraps up.
python
def agent_node(state: MessagesState):
"""Call the LLM with the current message history."""
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
# Build the graph
graph_builder = StateGraph(MessagesState)
# Add nodes
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", ToolNode(tools))
# Add edges
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")
# Compile
graph = graph_builder.compile()
print("Graph compiled with agent and tool nodes")
python
Graph compiled with agent and tool nodes
Here’s how the flow works: a user message comes in and hits the agent node. The LLM either replies directly or asks for a tool. If it asks for a tool, tools_condition routes to the tool node. The tool runs, its output goes back to the agent, and the LLM puts together its final reply.
python
result = graph.invoke(
{"messages": [HumanMessage(content="What is 15 times 23?")]}
)
for msg in result["messages"]:
print(f"{msg.type}: {msg.content}")
python
human: What is 15 times 23?
ai:
tool: 345
ai: 15 times 23 is 345.
Four messages in the chat. The user asks a question. The AI sends an empty message with a tool call. The tool sends back 345. Then the AI writes the final answer using that result. That’s one full trip through the tool-calling cycle.
Key Insight: `tools_condition` is a ready-made router. It peeks at the last AI message for tool calls. If it finds any, it returns `”tools”`. If not, it returns `END`. You don’t need to write this check yourself.
How Are Tool Results Tracked in State?
Each time a tool runs, it adds a ToolMessage to the chat state. This message holds the tool’s output along with a tool_call_id that ties it back to the original request. The LLM reads this on its next turn to build a final answer.
Why does the tool_call_id matter? When the model fires off several tool calls in one turn, each result needs to link to its matching request. Without that link, the model can’t tell which answer goes with which call.
python
result = graph.invoke(
{"messages": [HumanMessage(content="Search the web for LangGraph latest version")]}
)
for msg in result["messages"]:
prefix = msg.type.upper()
if hasattr(msg, "tool_calls") and msg.tool_calls:
print(f"{prefix}: [tool_call: {msg.tool_calls[0]['name']}({msg.tool_calls[0]['args']})]")
elif hasattr(msg, "tool_call_id") and msg.tool_call_id:
print(f"{prefix} (id={msg.tool_call_id[:12]}...): {msg.content[:80]}")
else:
print(f"{prefix}: {msg.content[:100]}")
python
HUMAN: Search the web for LangGraph latest version
AI: [tool_call: search_web({'query': 'LangGraph latest version'})]
TOOL (id=call_abc123...): Search results for 'LangGraph latest version': Python 3.13 was released in O
AI: Based on the search results, here's what I found about LangGraph's latest versio
How Does the LLM Pick the Right Tool from Many?
Real agents carry more than one tool. The LLM picks the best fit based on the user’s question and the docstrings you wrote. Here’s an agent with three tools — watch how it selects the right one each time.
python
@tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression.
Use this for any math calculation the user asks for.
Examples: '2 + 2', '100 * 0.15', '(50 + 30) / 4'
"""
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {e}"
@tool
def lookup_capital(country: str) -> str:
"""Look up the capital city of a country."""
capitals = {
"france": "Paris", "germany": "Berlin",
"japan": "Tokyo", "brazil": "Brasília",
"india": "New Delhi", "australia": "Canberra"
}
return capitals.get(country.lower(), f"Capital not found for {country}")
@tool
def word_count(text: str) -> int:
"""Count the number of words in a piece of text."""
return len(text.split())
Link all three to a model and set up a fresh graph. The LLM reads each tool’s docstring and decides which one fits the question. You don’t write routing code for this — the model handles tool choice on its own.
python
multi_tools = [calculate, lookup_capital, word_count]
multi_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(multi_tools)
def multi_agent(state: MessagesState):
return {"messages": [multi_llm.invoke(state["messages"])]}
multi_graph = StateGraph(MessagesState)
multi_graph.add_node("agent", multi_agent)
multi_graph.add_node("tools", ToolNode(multi_tools))
multi_graph.add_edge(START, "agent")
multi_graph.add_conditional_edges("agent", tools_condition)
multi_graph.add_edge("tools", "agent")
multi_graph = multi_graph.compile()
questions = [
"What is 25% of 840?",
"What's the capital of Japan?",
"How many words are in: 'The quick brown fox jumps over the lazy dog'?"
]
for q in questions:
result = multi_graph.invoke({"messages": [HumanMessage(content=q)]})
final = result["messages"][-1].content
print(f"Q: {q}")
print(f"A: {final}\n")
python
Q: What is 25% of 840?
A: 25% of 840 is 210.0.
Q: What's the capital of Japan?
A: The capital of Japan is Tokyo.
Q: How many words are in: 'The quick brown fox jumps over the lazy dog'?
A: The sentence "The quick brown fox jumps over the lazy dog" contains 9 words.
Each question hit a different tool. The math one used calculate. The geography one used lookup_capital. The word count one used word_count. The LLM chose right every time, guided only by the docstrings.
Warning: Never use `eval()` in real production code. The `calculate` tool above uses it for a quick demo. In a live app, use a safe math parser like `numexpr` or `sympy` to block code injection.
What Happens When a Tool Fails?
Tools break. APIs time out. Databases go down. Bad inputs slip through. Your agent must handle these bumps without crashing the whole graph.
ToolNode has built-in error handling through its handle_tool_errors flag. Set it to True, and it catches exceptions. Instead of blowing up, it sends the error message back as a ToolMessage. The LLM reads that and responds in a helpful way.
python
@tool
def divide(a: float, b: float) -> float:
"""Divide the first number by the second number."""
if b == 0:
raise ValueError("Cannot divide by zero!")
return a / b
error_tools = [divide]
error_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(error_tools)
def error_agent(state: MessagesState):
return {"messages": [error_llm.invoke(state["messages"])]}
error_tool_node = ToolNode(error_tools, handle_tool_errors=True)
error_graph = StateGraph(MessagesState)
error_graph.add_node("agent", error_agent)
error_graph.add_node("tools", error_tool_node)
error_graph.add_edge(START, "agent")
error_graph.add_conditional_edges("agent", tools_condition)
error_graph.add_edge("tools", "agent")
error_graph = error_graph.compile()
result = error_graph.invoke(
{"messages": [HumanMessage(content="What is 10 divided by 0?")]}
)
for msg in result["messages"]:
print(f"{msg.type}: {msg.content}")
python
human: What is 10 divided by 0?
ai:
tool: Error: ValueError('Cannot divide by zero!')
ai: Division by zero is undefined — you cannot divide 10 by 0.
Without handle_tool_errors=True, that ValueError would crash the whole run. With the flag on, the error turns into a message the LLM can read and explain. The model even tells the user why the math failed — far better than a raw stack trace.
Tip: Use clear error messages in your tools. Rather than a vague `raise ValueError(“error”)`, write `raise ValueError(“Cannot divide by zero — please provide a non-zero divisor”)`. The LLM reads this text, so make it useful.
How Do You Build a Search-Capable Agent?
Let’s mix several tools into a more life-like agent. This one uses both search and math, and it can handle multi-step questions where the model calls tools one after another.
We’ll fake a Tavily search tool here. In a real project, you’d use TavilySearchResults from langchain_community — it needs a TAVILY_API_KEY in your environment.
python
@tool
def tavily_search(query: str) -> str:
"""Search the web for real-time information.
Use for current events, recent data, or facts
that might have changed recently.
"""
search_db = {
"langgraph": "LangGraph is a framework by LangChain for building stateful, multi-actor applications with LLMs. Latest version: 0.2.x (2025).",
"python": "Python 3.13 released October 2024. Key features: improved error messages, JIT compiler.",
}
for key, value in search_db.items():
if key in query.lower():
return value
return f"No relevant results found for: {query}"
@tool
def calculator(expression: str) -> str:
"""Calculate a mathematical expression.
Supports: +, -, *, /, **, parentheses.
Example inputs: '100 * 1.15', '(50 + 30) / 2'
"""
try:
return str(eval(expression))
except Exception as e:
return f"Calculation error: {e}"
Wire up the graph with both tools and test it with a factual query.
python
search_tools = [tavily_search, calculator]
search_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(search_tools)
def search_agent(state: MessagesState):
return {"messages": [search_llm.invoke(state["messages"])]}
search_graph = StateGraph(MessagesState)
search_graph.add_node("agent", search_agent)
search_graph.add_node("tools", ToolNode(search_tools))
search_graph.add_edge(START, "agent")
search_graph.add_conditional_edges("agent", tools_condition)
search_graph.add_edge("tools", "agent")
search_graph = search_graph.compile()
result = search_graph.invoke(
{"messages": [HumanMessage(content="What is LangGraph and what version is it on?")]}
)
print(result["messages"][-1].content)
python
LangGraph is a framework by LangChain for building stateful, multi-actor applications with LLMs. The latest version is 0.2.x (2025).
The model saw this was a fact-based question, picked tavily_search, got back the info, and summed it up. Had the question been “What’s 15% of 200?”, it would have reached for calculator instead.
Real-World Example: A Customer Lookup Agent
Here’s something closer to what you’d ship in practice. This agent answers customer questions by looking up orders and checking stock. It shows how tools bridge the gap between your LLM and real data.
python
ORDERS_DB = {
"ORD-001": {"customer": "Alice", "item": "Laptop", "status": "shipped", "total": 1299.99},
"ORD-002": {"customer": "Bob", "item": "Headphones", "status": "delivered", "total": 199.99},
"ORD-003": {"customer": "Alice", "item": "Mouse", "status": "processing", "total": 49.99},
}
INVENTORY_DB = {
"Laptop": {"stock": 15, "price": 1299.99},
"Headphones": {"stock": 42, "price": 199.99},
"Mouse": {"stock": 0, "price": 49.99},
"Keyboard": {"stock": 28, "price": 79.99},
}
@tool
def check_order_status(order_id: str) -> str:
"""Check the status of a customer order by order ID.
Order IDs look like 'ORD-001', 'ORD-002', etc.
"""
order = ORDERS_DB.get(order_id.upper())
if not order:
return f"No order found with ID: {order_id}"
return (f"Order {order_id}: {order['item']} for "
f"{order['customer']} — Status: {order['status']}, "
f"Total: ${order['total']}")
@tool
def check_inventory(product_name: str) -> str:
"""Check if a product is in stock and its price."""
product = INVENTORY_DB.get(product_name)
if not product:
return f"Product '{product_name}' not found in catalog"
status = "In stock" if product["stock"] > 0 else "Out of stock"
return f"{product_name}: {status} ({product['stock']} units), Price: ${product['price']}"
Put together the support graph with a system prompt and try some real-sounding questions.
python
cs_tools = [check_order_status, check_inventory]
cs_llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(cs_tools)
def cs_agent(state: MessagesState):
system_msg = {
"role": "system",
"content": "You are a helpful customer service agent. "
"Use tools to look up order and inventory info. "
"Be concise and friendly."
}
messages = [system_msg] + state["messages"]
return {"messages": [cs_llm.invoke(messages)]}
cs_graph = StateGraph(MessagesState)
cs_graph.add_node("agent", cs_agent)
cs_graph.add_node("tools", ToolNode(cs_tools))
cs_graph.add_edge(START, "agent")
cs_graph.add_conditional_edges("agent", tools_condition)
cs_graph.add_edge("tools", "agent")
cs_graph = cs_graph.compile()
result = cs_graph.invoke(
{"messages": [HumanMessage(content="Check order ORD-001 please")]}
)
print("Q: Check order ORD-001 please")
print(f"A: {result['messages'][-1].content}\n")
result = cs_graph.invoke(
{"messages": [HumanMessage(content="Is the Mouse in stock?")]}
)
print("Q: Is the Mouse in stock?")
print(f"A: {result['messages'][-1].content}")
python
Q: Check order ORD-001 please
A: Your order ORD-001 for a Laptop has been shipped! The total was $1,299.99.
Q: Is the Mouse in stock?
A: Unfortunately, the Mouse is currently out of stock. The price is $49.99. Would you like me to check another product?
This is the shape most real agents follow. Swap the dicts for live database queries or API calls and you’ve got a working support system.
typescript
{
type: 'exercise',
id: 'tool-calling-ex1',
title: 'Exercise 1: Create and Bind a Custom Tool',
difficulty: 'intermediate',
exerciseType: 'write',
instructions: 'Create a tool called `string_reverse` that takes a string and returns it reversed. Then bind it to a ChatOpenAI model and invoke the model with a message asking it to reverse the word "LangGraph". Print the tool_calls from the response.',
starterCode: 'from langchain_core.tools import tool\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import HumanMessage\n\n# Define the tool\n@tool\ndef string_reverse(text: str) -> str:\n \"\"\"Reverse a string of text.\"\"\" \n # YOUR CODE: return the reversed text\n pass\n\n# Bind and invoke\n# YOUR CODE: bind the tool to a model and invoke it\n\nprint("DONE")',
testCases: [
{ id: 'tc1', input: 'print(string_reverse.invoke("hello"))', expectedOutput: 'olleh', description: 'string_reverse should reverse a string' },
{ id: 'tc2', input: 'print(string_reverse.name)', expectedOutput: 'string_reverse', description: 'Tool name should be string_reverse' },
],
hints: [
'Use Python slicing to reverse a string: text[::-1]',
'Full solution: return text[::-1], then llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([string_reverse])',
],
solution: '@tool\ndef string_reverse(text: str) -> str:\n \"\"\"Reverse a string of text.\"\"\"\n return text[::-1]\n\ntools = [string_reverse]\nllm = ChatOpenAI(model=\"gpt-4o-mini\").bind_tools(tools)\nresponse = llm.invoke([HumanMessage(content=\"Reverse the word LangGraph\")])\nprint(response.tool_calls)\nprint(\"DONE\")',
solutionExplanation: 'The @tool decorator converts string_reverse into a LangChain tool. bind_tools() attaches the tool schema to the model. When invoked with a reversal request, the model returns a tool_call instead of answering directly.',
xpReward: 15,
}
When Should You Skip Tool Calling?
Tool calling isn’t the best fit for every case. Here are times to consider other paths:
- Simple, single-turn tasks. If your agent just answers from its training data and doesn’t need outside info, adding tools is needless overhead. A plain
ChatOpenAIcall is faster and simpler. - Speed-critical apps. Each tool call adds a round trip — the LLM asks for a tool, your code runs it, then the LLM reads the result. If you need answers in under a second, think about loading the data upfront and dropping it straight into the prompt.
- Models that can’t do function calling. Some older or smaller open-source models can’t produce clean tool calls. If
.bind_tools()gives you garbled output, switch to a model with native function calling support (GPT-4o, Claude 3.5, Llama 3.1+, Mistral).
Note: For production work, look at `create_react_agent` from `langgraph.prebuilt`. It builds the whole tool-calling graph in a single call: `graph = create_react_agent(model, tools)`. The step-by-step build we walked through here shows you the inner workings. But for shipped code, the prebuilt version saves time and cuts down on errors.
What Happens in One Full Tool-Calling Cycle?
Here’s the exact flow during a single LangGraph tool calling round. Knowing these steps makes debugging much less painful.
| Step | What happens | Message type |
|---|---|---|
| 1 | User sends a question | HumanMessage |
| 2 | Agent node calls the LLM with tool schemas | — |
| 3 | LLM sends back a tool call request | AIMessage (with tool_calls) |
| 4 | tools_condition routes to the tool node | — |
| 5 | ToolNode runs the matching function | — |
| 6 | Tool result is added to state | ToolMessage |
| 7 | Flow goes back to the agent node | — |
| 8 | LLM reads tool result and writes its answer | AIMessage (with content) |
| 9 | tools_condition routes to END | — |
Steps 3 through 8 can repeat. If the LLM needs a second tool call after seeing the first result, the loop goes again. This is how agents tackle multi-step problems — the model keeps reaching for tools until it has enough info to reply.
What Are the Most Common Mistakes?
Mistake 1: No docstring on the tool function
Wrong:
python
@tool
def get_price(item: str) -> float:
return prices.get(item, 0.0)
What goes wrong: The LLM has nothing to read about the tool. Even worse, @tool throws a ValueError if the docstring is missing.
Right:
python
@tool
def get_price(item: str) -> float:
"""Look up the current price of a product by name."""
return prices.get(item, 0.0)
Mistake 2: Skipping bind_tools before building the graph
Wrong:
python
llm = ChatOpenAI(model="gpt-4o-mini") # No .bind_tools()!
What goes wrong: The model has no idea tools exist. It answers everything in plain text, even when a tool would help.
Right:
python
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
Mistake 3: No edge from tools back to agent
Wrong:
python
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
# Forgot: graph.add_edge("tools", "agent")
What goes wrong: The tool runs, but there’s no way back to the agent. The graph stops after the tool. The user gets a raw ToolMessage instead of a proper answer.
Right:
python
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent") # Results go back to the agent
Mistake 4: Leaving tool errors unhandled
Wrong:
python
ToolNode(tools) # Default: handle_tool_errors=False
What goes wrong: Any error in a tool crashes the entire run. The user sees a stack trace, not a reply.
Right:
python
ToolNode(tools, handle_tool_errors=True)
typescript
{
type: 'exercise',
id: 'tool-calling-ex2',
title: 'Exercise 2: Build a Complete Tool-Calling Graph',
difficulty: 'intermediate',
exerciseType: 'write',
instructions: 'Build a complete LangGraph tool-calling graph with a `celsius_to_fahrenheit` tool. The tool should convert a Celsius temperature to Fahrenheit using the formula: F = C * 9/5 + 32. Create the agent node, tool node, edges, and compile the graph. Then invoke it with "Convert 100 degrees Celsius to Fahrenheit" and print the final message content.',
starterCode: 'from langchain_core.tools import tool\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import HumanMessage\nfrom langgraph.graph import StateGraph, MessagesState, START, END\nfrom langgraph.prebuilt import ToolNode, tools_condition\n\n@tool\ndef celsius_to_fahrenheit(celsius: float) -> float:\n \"\"\"Convert a temperature from Celsius to Fahrenheit.\"\"\"\n # YOUR CODE: implement the conversion\n pass\n\n# YOUR CODE: bind tools, build graph, invoke\n\nprint(\"DONE\")',
testCases: [
{ id: 'tc1', input: 'print(celsius_to_fahrenheit.invoke({"celsius": 0}))', expectedOutput: '32.0', description: '0°C should be 32°F' },
{ id: 'tc2', input: 'print(celsius_to_fahrenheit.invoke({"celsius": 100}))', expectedOutput: '212.0', description: '100°C should be 212°F' },
],
hints: [
'The formula is: fahrenheit = celsius * 9/5 + 32',
'After the tool, follow the pattern: bind_tools → agent function → StateGraph → add_node/add_edge → compile → invoke',
],
solution: '@tool\ndef celsius_to_fahrenheit(celsius: float) -> float:\n \"\"\"Convert a temperature from Celsius to Fahrenheit.\"\"\"\n return celsius * 9/5 + 32\n\ntools = [celsius_to_fahrenheit]\nllm = ChatOpenAI(model=\"gpt-4o-mini\").bind_tools(tools)\n\ndef agent(state: MessagesState):\n return {\"messages\": [llm.invoke(state[\"messages\"])]}\n\ngraph = StateGraph(MessagesState)\ngraph.add_node(\"agent\", agent)\ngraph.add_node(\"tools\", ToolNode(tools))\ngraph.add_edge(START, \"agent\")\ngraph.add_conditional_edges(\"agent\", tools_condition)\ngraph.add_edge(\"tools\", \"agent\")\ngraph = graph.compile()\n\nresult = graph.invoke({\"messages\": [HumanMessage(content=\"Convert 100 degrees Celsius to Fahrenheit\")]})\nprint(result[\"messages\"][-1].content)\nprint(\"DONE\")',
solutionExplanation: 'The tool implements the Celsius-to-Fahrenheit formula. The graph follows the standard pattern: agent node → conditional routing → tool node → back to agent. When invoked, the LLM calls celsius_to_fahrenheit(100), gets 212.0, and formulates a human-readable response.',
xpReward: 20,
}
Complete Code
Summary
Tool calling is what turns an LLM that thinks into an agent that acts. Here’s what you learned:
@tooldecorator wraps any Python function so the LLM can find and call it.bind_tools()tells the chat model which tools are available and what they expectToolNodefromlanggraph.prebuiltruns tools when the LLM asks for themtools_conditionsteers the graph based on whether the LLM made a tool call- Error handling via
handle_tool_errors=Truestops tool failures from crashing your graph - Multiple tools work side by side — the LLM picks the right one by reading docstrings
Practice exercise: Build an agent with three tools: get_stock_price(ticker: str) that sends back a fake price, convert_currency(amount: float, from_currency: str, to_currency: str) for money conversion, and format_number(number: float, decimals: int) for number display. Test with “What’s the stock price of AAPL in euros?”
Next up: we’ll use tool calling to build a full ReAct agent — one that reasons through each step before it acts.
Frequently Asked Questions
Can the LLM fire off many tools in one turn?
Yes. Models like GPT-4o and Claude 3.5 can do parallel tool calls. They put several entries in the tool_calls list at once, and ToolNode runs them all. This is faster than one-by-one calls when the tools don’t depend on each other.
python
# The model might return:
# tool_calls: [
# {"name": "get_weather", "args": {"city": "London"}},
# {"name": "get_weather", "args": {"city": "Tokyo"}}
# ]
# ToolNode runs both and returns both results
What sets @tool apart from StructuredTool?
@tool is a quick decorator for turning functions into tools. StructuredTool is a class that gives you finer control over the schema, name, and input checks. Use @tool most of the time. Reach for StructuredTool when you need to build tools on the fly or tweak the input schema beyond what type hints allow.
How do I use create_react_agent instead of building by hand?
create_react_agent from langgraph.prebuilt wraps the whole tool-calling graph into one call: graph = create_react_agent(model, tools). It sets up the agent node, tool node, and routing for you. It builds the same graph we made step by step, just in a single line.
Does LangGraph tool calling work with open-source models?
It does, but results vary by model. Models tuned for function calling (Llama 3.1+, Mistral, Command R+) work well with .bind_tools(). Smaller or older ones may not send back clean tool calls. Check your model’s docs for function calling support before you start.
How do I cap the number of tool-calling loops?
Pass a recursion limit in the config: graph.invoke(inputs, config={"recursion_limit": 10}). The default cap is 25. If the agent goes past it, LangGraph raises a GraphRecursionError. Keep the limit sensible — if an agent needs 20+ tool calls to answer one question, the design likely needs a rethink.
References
- LangGraph Documentation — Quickstart Guide. Link
- LangChain Documentation — Tool Calling. Link
- LangGraph Prebuilt — ToolNode API Reference. Link
- LangChain Blog — Tool Calling with LangChain. Link
- OpenAI Documentation — Function Calling. Link
- LangGraph GitHub — Prebuilt Tool Node Source Code. Link
- LangChain Documentation — Creating Custom Tools with @tool. Link
Free Course
Master Core Python — Your First Step into AI/ML
Build a strong Python foundation with hands-on exercises designed for aspiring Data Scientists and AI/ML Engineers.
Start Free Course →Trusted by 50,000+ learners
Related Course
Master Gen AI — Hands-On
Join 5,000+ students at edu.machinelearningplus.com
Explore Course
Up Next in Learning Path
LangGraph ReAct Agent: Build from Scratch
