LangGraph Tool Calling — How to Build Agents That Take Real Actions
Your LangGraph agent can think through problems. It can write solid responses. But right now, it can’t do anything. No web searches. No database lookups. No API calls. It’s a brain with no arms.
Tool calling fixes that. You hand the LLM a menu of functions. When the model spots one that would help, it sends back a structured request instead of plain text. LangGraph grabs that request, runs the function, and passes the result back. The LLM then uses that output to craft its final answer.
That’s the whole idea. Let’s build it step by step.
What Is Tool Calling in LangGraph?
When an LLM “calls a tool,” it doesn’t actually run any code. It outputs a structured message that says, “Please run this function with these arguments.” Your code does the real work — it runs the function and feeds the result back to the model.
Three pieces make this work in LangGraph. First, you create tools with LangChain’s @tool decorator. Second, you attach them to your chat model using .bind_tools(). Third, you use ToolNode from langgraph.prebuilt to handle the actual execution when the LLM asks for it.
Here’s a small example with all three parts. We define a math tool, bind it to the model, and let LangGraph take care of running it.
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")
Setup complete — model with tool binding ready
Three lines of setup and you’re done. The @tool decorator turns a normal function into something the LLM can find and call. The .bind_tools() method lets the model see what tools exist, what they accept, and what they do. LangGraph handles the rest.
Key Insight: The LLM never runs your tools itself. It sends back a “tool call” message. Your code (through ToolNode) does the actual work. This split is what keeps tool calling safe and under your control.
How Do You Define Tools with the @tool Decorator?
Every tool starts as a plain Python function. The @tool decorator from langchain_core.tools turns it into something the LLM can work with. Two things matter most: the function name and the docstring.
The name becomes the tool’s ID. The docstring is what the LLM reads when it decides which tool to pick. In my experience, vague docstrings are the biggest reason models choose the wrong tool. Spend time writing clear ones.
@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}")
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 attention to the type hints. They aren’t just for show. The @tool decorator uses them to build the JSON schema that tells the LLM what each argument looks like. Drop the type hints, and the schema breaks.
Tip: Write docstrings for the model, not for developers. Instead of “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 LLM reads this text to decide when to reach for the tool.
How Does .bind_tools() Work?
Creating tools is only half the job. The LLM also needs to know they exist. That’s where .bind_tools() comes in — it attaches tool schemas to every request so the model can see what’s on the menu.
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}")
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 answer the math question itself — it decided multiply was the better move and sent back a structured tool_calls list with the function name and arguments.
Without .bind_tools(), the model would just say “15 times 23 is 345” in plain text. With tool binding, it hands the math off to your function. For simple math, that’s a small win. For web searches, database queries, or API calls, it changes everything.
But what if the question doesn’t need a tool at all?
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}")
Content: 'The capital of France is Paris.'
Tool calls: []
The model answered on its own — no tool calls. It only reaches for tools when it thinks one would help. That’s the behavior you want. The model reasons about whether a tool is needed before it asks for one.
How Do You Build the Tool-Calling Graph with ToolNode?
So far, we’ve called the model on its own. But a tool call is just a request — nobody has actually run the function yet. That’s where the graph comes in.
ToolNode from langgraph.prebuilt does the heavy lifting. It reads tool calls from the last AI message, runs the right functions, and sends back the results as ToolMessage objects. Pair it with tools_condition for routing, and you get a full tool-calling loop.
You need two nodes: one for the LLM (the “agent”) and one for running tools. tools_condition checks if the LLM asked for any tools — if yes, the graph goes to the tool node. If no, it ends.
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")
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 to call a tool. If it asks for a tool, tools_condition sends the graph to the tool node. The tool runs, the result goes back to the agent, and the LLM puts together a final answer.
result = graph.invoke(
{"messages": [HumanMessage(content="What is 15 times 23?")]}
)
for msg in result["messages"]:
print(f"{msg.type}: {msg.content}")
human: What is 15 times 23?
ai:
tool: 345
ai: 15 times 23 is 345.
Four messages tell the whole story. The human asks. The AI sends an empty message with a tool call. The tool returns 345. Then the AI writes the answer using that result. That’s one full tool-calling cycle.
Key Insight: tools_condition is a ready-made routing function. It checks the last AI message for tool calls. If there are any, it returns "tools". If not, it returns END. You don’t have to write this logic yourself.
How Are Tool Results Stored in State?
Each tool run adds a ToolMessage to the conversation history. This message holds the tool’s output plus a tool_call_id that ties it back to the original request. The LLM reads this on its next turn to form its answer.
Why does tool_call_id matter? When the model fires off several tool calls at once, each result needs to match up with its request. Without this link, the model can’t tell which output belongs to which call.
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]}")
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 Do You Use Multiple Tools Together?
Real agents need 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 it choose the right one each time.
@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())
Bind all three and build a new graph. The LLM reads each docstring and picks the tool that fits. You don’t write any routing logic for tool selection — the model does all of that on its own.
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")
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. Math went to calculate. Geography went to lookup_capital. Word counting went to word_count. The LLM got it right every time, guided by the docstrings alone.
Warning: Don’t use eval() in production. The calculate tool above uses it for simplicity. In a real app, use a math parser like numexpr or sympy to avoid code injection risks.
How Do You Handle Tool Errors Without Crashing?
Tools break. APIs time out. Databases go offline. Bad inputs sneak through. Your agent needs to deal with these failures without taking down the whole graph.
ToolNode has built-in error handling through handle_tool_errors. Set it to True, and it catches exceptions. Instead of crashing, it sends the error message back as a ToolMessage. The LLM sees the error and responds in a helpful way.
@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}")
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 graph. With it, the error turns into a message the LLM can read and explain. The model even tells the user why the math failed — much better than a raw stack trace.
Tip: Write clear error messages in your tools. Instead of a vague raise ValueError("error"), try raise ValueError("Cannot divide by zero — use a non-zero number"). The LLM reads this text, so make it useful.
How Do You Build a Search-Capable Agent?
Let’s put several tools together into something more real. This agent can both search and calculate, handling questions where the model might need to call tools one after another.
We’ll fake a Tavily search tool here. In production, you’d use TavilySearchResults from langchain_community with a TAVILY_API_KEY in your environment.
@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}"
Build the graph with both tools and try a factual question.
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)
LangGraph is a framework by LangChain for building stateful, multi-actor applications with LLMs. The latest version is 0.2.x (2025).
The model knew this was a facts question. It chose tavily_search, got the answer, and wrapped it up. A math question like “What’s 15% of 200?” would have gone to calculator instead.
Real-World Example: How Would You Build a Customer Data Lookup Agent?
Here’s something closer to a real deployment. This agent answers customer service questions by pulling order info and checking stock levels. It shows how tools connect your LLM to actual data.
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']}"
Build the graph with a system message that sets the tone, then test with real customer queries.
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}")
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 blueprint for production agents. Swap the dictionaries for real database queries or API calls, and you’ve got a working customer service system.
When Should You NOT Use Tool Calling?
Tool calling isn’t right for every situation. Here are some cases where you should pick a different approach:
-
Simple Q&A from training data. If your agent just answers questions it already knows, tool calling adds overhead for no gain. A plain
ChatOpenAIcall is faster and simpler. -
Apps that need sub-second responses. Each tool call adds a round trip — the LLM asks for a tool, your code runs it, then the LLM reads the result. For very tight latency needs, try loading the data upfront and putting it in the prompt.
-
Models that don’t support function calling. Older or smaller open-source models may not produce clean tool calls. If
.bind_tools()gives you garbled output, you need a model with native function calling support (GPT-4o, Claude, Llama 3.1+, Mistral).
Note: For production, use create_react_agent from langgraph.prebuilt. It builds the entire tool-calling graph in one line: graph = create_react_agent(model, tools). The manual setup we did here teaches you what’s going on under the hood. For real deployments, the prebuilt version saves time and avoids mistakes.
What Does the Full Tool-Calling Cycle Look Like?
Here’s a step-by-step breakdown of one complete cycle. Knowing this flow makes debugging much easier.
| Step | What Happens | Message Type |
|---|---|---|
| 1 | User sends a question | HumanMessage |
| 2 | Agent node sends it to 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 function |
— |
| 6 | Result goes into state | ToolMessage |
| 7 | Graph loops back to the agent node | — |
| 8 | LLM reads the tool result and writes an answer | AIMessage (with content) |
| 9 | tools_condition routes to END |
— |
Steps 3–8 can repeat. If the LLM decides it needs another tool after reading the first result, the cycle loops. That’s how agents handle multi-step tasks — the model keeps calling tools until it has enough info to answer.
What Are the Most Common Mistakes?
Mistake 1: No docstring on the tool function
Wrong:
@tool
def get_price(item: str) -> float:
return prices.get(item, 0.0)
Why it breaks: The LLM has nothing to read. The @tool decorator throws a ValueError if the docstring is missing.
Right:
@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: Forgetting .bind_tools() before building the graph
Wrong:
llm = ChatOpenAI(model="gpt-4o-mini") # No .bind_tools()!
Why it breaks: The model has no idea tools exist. It answers everything in plain text, even when a tool would give a better result.
Right:
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
Mistake 3: No edge from tools back to the agent
Wrong:
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
# Forgot: graph.add_edge("tools", "agent")
Why it breaks: The tool runs, but the result has nowhere to go. The graph stops right after the tool node. The user gets a raw ToolMessage instead of a proper answer.
Right:
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: No error handling on ToolNode
Wrong:
ToolNode(tools) # Default: handle_tool_errors=False
Why it breaks: Any exception in a tool crashes the whole graph. The user sees a stack trace instead of a response.
Right:
ToolNode(tools, handle_tool_errors=True)
Complete Code
Click to expand the full script (copy-paste and run)
# Complete code from: LangGraph Tool Calling -- Build Agents That Take Action
# Requires: pip install langchain-openai langchain-core langgraph python-dotenv
# Python 3.9+
# Set OPENAI_API_KEY in your .env file
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()
# --- Section 1: Define Tools ---
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
@tool
def search_web(query: str) -> str:
"""Search the web for current information about a topic."""
return f"Search results for '{query}': Python 3.13 was released in October 2024."
@tool
def get_weather(city: str, unit: str = "celsius") -> str:
"""Get the current weather for a city."""
return f"Weather in {city}: 22°{unit[0].upper()}, partly cloudy"
@tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression."""
try:
return str(eval(expression))
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 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
# --- Section 2: Build a Tool-Calling Graph ---
tools = [multiply, search_web, get_weather, calculate, lookup_capital]
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
def agent_node(state: MessagesState):
return {"messages": [llm.invoke(state["messages"])]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", ToolNode(tools))
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")
graph = graph_builder.compile()
# --- Section 3: Test the Agent ---
test_questions = [
"What is 15 times 23?",
"What's the capital of Japan?",
"Search the web for LangGraph latest version",
]
for q in test_questions:
result = graph.invoke({"messages": [HumanMessage(content=q)]})
print(f"Q: {q}")
print(f"A: {result['messages'][-1].content}\n")
# --- Section 4: Error Handling ---
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_graph = StateGraph(MessagesState)
error_graph.add_node("agent", error_agent)
error_graph.add_node("tools", ToolNode(error_tools, handle_tool_errors=True))
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="Divide 10 by 0")]}
)
print(f"Error handling test: {result['messages'][-1].content}")
print("\nScript completed successfully.")
Summary
Tool calling bridges the gap between an LLM that thinks and an agent that acts. Here’s what we covered:
-
@tooldecorator turns any Python function into a tool the LLM can find and use -
.bind_tools()shows the model what tools are on the table -
ToolNodefromlanggraph.prebuiltruns the tools when the LLM asks for them -
tools_conditionroutes the graph based on whether the LLM made a tool call or not -
Error handling with
handle_tool_errors=Truekeeps tool failures from crashing your graph -
Multiple tools work side by side — the LLM picks the right one based on your docstrings
Practice exercise: Build an agent with three tools: get_stock_price(ticker: str) that returns a fake price, convert_currency(amount: float, from_currency: str, to_currency: str) for currency math, and format_number(number: float, decimals: int) for formatting. Test it with “What’s the stock price of AAPL in euros?”
Solution
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition
@tool
def get_stock_price(ticker: str) -> str:
"""Get the current stock price for a ticker symbol."""
prices = {"AAPL": 189.50, "GOOGL": 142.30, "MSFT": 415.80}
price = prices.get(ticker.upper(), 0.0)
if price == 0.0:
return f"Ticker {ticker} not found"
return f"{ticker.upper()}: ${price}"
@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
"""Convert an amount from one currency to another."""
rates = {"USD_EUR": 0.92, "USD_GBP": 0.79, "EUR_USD": 1.09}
key = f"{from_currency.upper()}_{to_currency.upper()}"
rate = rates.get(key, None)
if rate is None:
return f"Rate not available for {from_currency} to {to_currency}"
return f"{amount} {from_currency} = {amount * rate:.2f} {to_currency}"
@tool
def format_number(number: float, decimals: int) -> str:
"""Format a number to a specific number of decimal places."""
return f"{number:.{decimals}f}"
tools = [get_stock_price, convert_currency, format_number]
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
def agent(state: MessagesState):
return {"messages": [llm.invoke(state["messages"])]}
graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")
graph = graph.compile()
result = graph.invoke(
{"messages": [HumanMessage(content="What's the stock price of AAPL in euros?")]}
)
print(result["messages"][-1].content)
The agent calls get_stock_price("AAPL") first to get $189.50, then calls convert_currency(189.50, "USD", "EUR") to do the conversion. Two tool calls in a row, handled by the graph loop.
Next up: we’ll use tool calling to build a full ReAct agent — one that reasons step by step before acting.
Frequently Asked Questions
Can an LLM call multiple tools in one turn?
Yes. Models like GPT-4o and Claude support parallel tool calls. They send back several entries in the tool_calls list at once, and ToolNode runs all of them. This is faster than calling tools one at a time when they don’t depend on each other.
# 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’s the difference between @tool and StructuredTool?
@tool is a quick decorator for turning functions into tools. StructuredTool is a class that gives you more control over the schema, naming, and validation. Use @tool most of the time. Reach for StructuredTool when you need to build tools on the fly or tweak input schemas beyond what type hints allow.
How do I use create_react_agent instead of building manually?
Call create_react_agent from langgraph.prebuilt: graph = create_react_agent(model, tools). It wires up the agent node, tool node, and conditional routing for you. It builds the same graph we made by hand, just in one line.
Does tool calling work with open-source models?
Yes, but results vary. Models trained for function calling (Llama 3.1+, Mistral, Command R+) work well with .bind_tools(). Smaller or older models may not produce clean structured calls. Check your model’s docs for function calling support before building a tool-calling agent.
How do I cap the number of tool-calling loops?
Set a recursion limit in the config: graph.invoke(inputs, config={"recursion_limit": 10}). The default is 25. If the agent hits this limit, LangGraph raises a GraphRecursionError. Keep the limit sensible — if an agent needs 20+ tool calls to answer one question, the design likely needs rethinking.
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
Build a strong Python foundation with hands-on exercises designed for aspiring Data Scientists and AI/ML Engineers.
Start Free Course →