Module 2: Tools and Tool Calling
bind_tools and the Tool Calling Flow
Capsule overview
You already know how to create tools with the @tool decorator — Python functions with a name, a description, and an argument schema a model can understand. But creating tools is useless if you don't connect them to a model. How do you tell the model "these are the tools you have available"?
The answer is bind_tools(). This method takes a list of tools and "attaches" them to the model, creating a new instance that knows which tools exist and when to use them. What the model returns when it decides to use a tool isn't the result of the execution — it's an instruction: "I want to call tool X with arguments Y". That instruction arrives inside the AIMessage as a list of tool_calls.
This is the point most people miss at first: the model does NOT execute tools. It only says what it wants to call. You're the one who runs the tool and hands back the result. In this capsule you'll get the full flow, how to inspect tool_calls, and how to steer the model's behavior with tool_choice.
bind_tools(): connecting tools to a model
bind_tools() takes a list of tools and returns a new model that knows about them. The original model isn't modified — you get a fresh version with the tools attached.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator])
response = model_with_tools.invoke("What's the weather in Madrid?")
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': 'Madrid'}, 'id': 'call_abc123'}]
print(response.content)
# '' (empty when there are tool_calls)
What just happened?
- You created two tools:
get_weatherandcalculator - You called
model.bind_tools([get_weather, calculator])to create a model that knows both tools - You invoked the model with a question about the weather
- The model didn't answer with text — it answered with an instruction: "call
get_weatherwithcity='Madrid'" - That instruction lives in
response.tool_calls
The original model (model) is still intact. You can use model without tools and model_with_tools with tools in the same application.
The model does NOT execute tools
This is the most important concept in this capsule, and the most common beginner mistake.
When you call model_with_tools.invoke("What's the weather in Madrid?"), the model does not call the get_weather function. It doesn't reach any API. It doesn't run code. All it does is return a message that says:
"I think you should call the
get_weatherfunction with the argumentcity='Madrid'."
That's it. Execution is your responsibility. This design is on purpose:
- ✅ Safety — You decide whether to run the tool. You can validate arguments, apply rate limiting, or ask the user to confirm
- ✅ Control — You can intercept, modify, or reject tool calls before they run
- ✅ Flexibility — The same tool call can run different ways depending on context
- ✅ Debugging — You can inspect exactly what the model wants to do before anything happens
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def delete_user(user_id: str) -> str:
"""Delete a user from the system."""
return f"User {user_id} deleted"
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([delete_user])
response = model_with_tools.invoke("Delete user 42")
print(response.tool_calls)
# [{'name': 'delete_user', 'args': {'user_id': '42'}, 'id': 'call_xyz789'}]
# The user was NOT deleted. All you have is the instruction.
# You decide whether to run it.
Inspecting AIMessage.tool_calls
When the model decides to call one or more tools, the response (AIMessage) carries a tool_calls list. Each element is a dictionary with three fields:
| Field | Type | Description |
|---|---|---|
name | str | Name of the tool the model wants to call |
args | dict | Arguments the model wants to pass to the tool |
id | str | Unique identifier for the tool call (needed for the ToolMessage you send back) |
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator])
response = model_with_tools.invoke("What's the weather in Tokyo?")
print(f"Number of tool calls: {len(response.tool_calls)}")
# 1
for tc in response.tool_calls:
print(f" Tool: {tc['name']}")
print(f" Args: {tc['args']}")
print(f" ID: {tc['id']}")
# Output:
# Tool: get_weather
# Args: {'city': 'Tokyo'}
# ID: call_abc123
When there are NO tool calls
If the model decides it doesn't need any tool (because it can answer directly), tool_calls will be empty and content will hold the text answer:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])
response = model_with_tools.invoke("What's the capital of France?")
print(f"tool_calls: {response.tool_calls}")
# tool_calls: []
print(f"content: {response.content}")
# content: The capital of France is Paris.
The question "What's the capital of France?" doesn't need any tool — the model just knows it. So it answers with plain text.
The key rule
| Situation | content | tool_calls |
|---|---|---|
| The model answers directly | The answer text | [] (empty list) |
| The model wants to call tools | "" (empty or brief) | A list with one or more tool calls |
tool_choice: controlling when the model uses tools
By default, the model decides on its own whether to use a tool. But you can steer that behavior with the tool_choice parameter in bind_tools().
"auto" — the model decides (default)
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(
[get_weather, calculator],
tool_choice="auto"
)
response = model_with_tools.invoke("Hi, how are you?")
print(f"tool_calls: {response.tool_calls}")
# tool_calls: [] (it doesn't need tools to say hello)
print(f"content: {response.content}")
# content: Hi! I'm doing well, thanks. How can I help you?
With "auto", the model looks at each question and decides whether any tool is relevant. If not, it answers with plain text.
"any" — force it to use some tool
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
model_forced = model.bind_tools(
[get_weather, calculator],
tool_choice="any"
)
response = model_forced.invoke("Hi, how are you?")
print(f"tool_calls: {response.tool_calls}")
# tool_calls: [{'name': 'get_weather', 'args': {'city': '...'}, 'id': '...'}]
# The model was FORCED to call some tool, even when it made no sense
With "any", the model always generates at least one tool call. Even if the question has nothing to do with the available tools. Use it carefully — it's useful when you know every interaction must result in a tool call.
Forcing a specific tool
You can pass a tool's name so the model always calls it:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
model_weather_only = model.bind_tools(
[get_weather, calculator],
tool_choice="get_weather"
)
response = model_weather_only.invoke("What's 2 + 2?")
print(f"tool_calls: {response.tool_calls}")
# [{'name': 'get_weather', 'args': {'city': '...'}, 'id': '...'}]
# It forced get_weather even though the question was about math
Forcing a specific tool is useful for pipelines where you know exactly which tool must run — for example, an API endpoint that always has to call a data-extraction tool.
Comparison: tool_choice "auto" vs "any" vs specific
tool_choice | Behavior | When to use it |
|---|---|---|
"auto" (default) | The model decides whether to use tools or answer with text | Conversational chat, general assistants |
"any" | The model MUST call at least one tool | Pipelines where every interaction needs a tool |
"tool_name" | The model MUST call that specific tool | Dedicated endpoints, deterministic flows |
A side-by-side example
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator]
question = "What's the capital of France?"
for choice in ["auto", "any"]:
m = model.bind_tools(tools, tool_choice=choice)
r = m.invoke(question)
has_calls = len(r.tool_calls) > 0
print(f"tool_choice='{choice}': tool_calls={has_calls}, content='{r.content[:50]}'")
# Output:
# tool_choice='auto': tool_calls=False, content='The capital of France is Paris.'
# tool_choice='any': tool_calls=True, content=''
With "auto", the model answers directly because it doesn't need tools. With "any", it's forced to generate an unnecessary tool call.
Multiple tools in bind_tools
You can pass any number of tools to bind_tools(). The model reads each one's description and decides which to use based on the question:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Results for '{query}': [result 1, result 2]"
@tool
def translate(text: str, target_language: str) -> str:
"""Translate text into another language."""
return f"Translation of '{text}' into {target_language}: [translation]"
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator, search_web, translate])
questions = [
"What's the weather in Buenos Aires?",
"What's 15 * 37?",
"Find information about LangChain",
"Translate 'hello world' into Spanish",
]
for q in questions:
response = model_with_tools.invoke(q)
if response.tool_calls:
tc = response.tool_calls[0]
print(f"Q: {q}")
print(f" → Tool: {tc['name']}, Args: {tc['args']}")
else:
print(f"Q: {q}")
print(f" → Text: {response.content[:60]}")
print()
# Output:
# Q: What's the weather in Buenos Aires?
# → Tool: get_weather, Args: {'city': 'Buenos Aires'}
#
# Q: What's 15 * 37?
# → Tool: calculator, Args: {'expression': '15 * 37'}
#
# Q: Find information about LangChain
# → Tool: search_web, Args: {'query': 'LangChain'}
#
# Q: Translate 'hello world' into Spanish
# → Tool: translate, Args: {'text': 'hello world', 'target_language': 'Spanish'}
The model picks the right tool based on each one's description ("""docstring"""). That's why clear, specific descriptions matter so much — they're the instructions the model uses to decide.
Connection to the project
In the Assistant with External Tools (Capsule 08), you'll use bind_tools() to connect at least three tools to the model: a weather API, web search, and a calculator. The model will decide on every conversation turn which tool to use (or whether to answer directly).
The base pattern will be:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Results for '{query}': ..."
model = init_chat_model("openai:gpt-4.1-mini")
assistant = model.bind_tools([get_weather, calculator, search_web])
In the next capsule, you'll learn to run those tool calls and hand the results back to the model — closing the loop that makes the assistant actually work.
Troubleshooting
Problem 1: the model doesn't call any tool
Symptom: response.tool_calls is always empty, even though the question should trigger a tool.
Cause: The tool's description isn't clear enough for the model to connect it to the question.
Fix: Improve the tool's docstring. The model uses the description to decide when to call it.
# Bad — vague description
@tool
def process(data: str) -> str:
"""Process data."""
return data
# Good — clear, specific description
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city, including temperature and conditions."""
return f"The weather in {city} is sunny, 22°C"
Problem 2: the model calls the wrong tool
Symptom: The model picks calculator when it should pick get_weather.
Cause: The tool descriptions overlap or are ambiguous.
Fix: Make each description unique and unambiguous. If you have similar tools, spell out the difference.
# Bad — similar descriptions
@tool
def tool_a(query: str) -> str:
"""Search for information."""
...
@tool
def tool_b(query: str) -> str:
"""Find data."""
...
# Good — differentiated descriptions
@tool
def search_web(query: str) -> str:
"""Search the internet for information using a search engine."""
...
@tool
def query_database(sql: str) -> str:
"""Run a SQL query against the internal database."""
...
Problem 3: bind_tools() throws an error
Symptom: TypeError or ValueError when calling bind_tools().
Cause: The tools aren't in the right shape. Every tool needs a name, a description, and a valid argument schema.
Fix: Make sure you're using the @tool decorator and that the function has a docstring and type hints:
# Bad — no docstring, no type hints
@tool
def my_tool(x):
return x
# Good — with docstring and type hints
@tool
def my_tool(x: str) -> str:
"""A clear description of what the tool does."""
return x
Exercises
Exercise 1: basic bind_tools (Easy)
Create two tools: convert_currency(amount: float, from_currency: str, to_currency: str) and get_exchange_rate(from_currency: str, to_currency: str). Use bind_tools() to connect them to a model. Try 3 different questions and show which tool the model picked for each.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@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, "EUR_USD": 1.09, "USD_MXN": 17.15, "MXN_USD": 0.058}
key = f"{from_currency}_{to_currency}"
rate = rates.get(key, 1.0)
result = amount * rate
return f"{amount} {from_currency} = {result:.2f} {to_currency}"
@tool
def get_exchange_rate(from_currency: str, to_currency: str) -> str:
"""Get the current exchange rate between two currencies."""
rates = {"USD_EUR": 0.92, "EUR_USD": 1.09, "USD_MXN": 17.15}
key = f"{from_currency}_{to_currency}"
rate = rates.get(key, "unknown")
return f"1 {from_currency} = {rate} {to_currency}"
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([convert_currency, get_exchange_rate])
questions = [
"Convert 100 dollars to euros",
"What's the exchange rate from USD to MXN?",
"What's the capital of Spain?",
]
for q in questions:
response = model_with_tools.invoke(q)
if response.tool_calls:
tc = response.tool_calls[0]
print(f"Q: {q}")
print(f" Tool: {tc['name']}, Args: {tc['args']}")
else:
print(f"Q: {q}")
print(f" Direct answer: {response.content[:60]}")
print()
# Output:
# Q: Convert 100 dollars to euros
# Tool: convert_currency, Args: {'amount': 100.0, 'from_currency': 'USD', 'to_currency': 'EUR'}
#
# Q: What's the exchange rate from USD to MXN?
# Tool: get_exchange_rate, Args: {'from_currency': 'USD', 'to_currency': 'MXN'}
#
# Q: What's the capital of Spain?
# Direct answer: The capital of Spain is Madrid.
Explanation: The model distinguishes between "convert" (which implies an amount) and "exchange rate" (only the currencies). The third question triggers no tool because it has nothing to do with currencies.
Exercise 2: inspecting tool_calls (Easy)
Write a function inspect_response(response) that takes an AIMessage and prints out everything available in detail: whether there are tool_calls (name, args, id of each), whether there's content, and the usage metadata. Try it with at least 2 different invocations (one that triggers tools and one that doesn't).
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import AIMessage
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
def inspect_response(response: AIMessage):
print(f"{'=' * 50}")
print(f"Type: {type(response).__name__}")
print(f"Content: '{response.content[:80]}'" if response.content else "Content: (empty)")
if response.tool_calls:
print(f"Tool calls: {len(response.tool_calls)}")
for i, tc in enumerate(response.tool_calls):
print(f" [{i}] name: {tc['name']}")
print(f" args: {tc['args']}")
print(f" id: {tc['id']}")
else:
print("Tool calls: none")
if response.usage_metadata:
usage = response.usage_metadata
print(f"Tokens: {usage['input_tokens']} in / {usage['output_tokens']} out")
print(f"{'=' * 50}\n")
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])
response1 = model_with_tools.invoke("What's the weather in Lima?")
inspect_response(response1)
# Output:
# ==================================================
# Type: AIMessage
# Content: (empty)
# Tool calls: 1
# [0] name: get_weather
# args: {'city': 'Lima'}
# id: call_abc123
# Tokens: 45 in / 15 out
# ==================================================
response2 = model_with_tools.invoke("What is Python?")
inspect_response(response2)
# Output:
# ==================================================
# Type: AIMessage
# Content: 'Python is a high-level programming language...'
# Tool calls: none
# Tokens: 12 in / 35 out
# ==================================================
Explanation: inspect_response gives you a full view of the AIMessage. When there are tool calls, content is usually empty. When there aren't, content holds the answer. This inspection pattern is handy for debugging.
Exercise 3: comparing tool_choice (Medium)
Create three different tools. Run the same question with tool_choice="auto", tool_choice="any", and tool_choice="tool_name". Print each configuration's result to compare the behavior.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
@tool
def translate(text: str, target_language: str) -> str:
"""Translate text into another language."""
return f"Translation: {text} → [{target_language}]"
model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator, translate]
question = "What's the capital of Japan?"
configs = [
("auto", "auto"),
("any", "any"),
("get_weather", "get_weather"),
("calculator", "calculator"),
]
for label, choice in configs:
m = model.bind_tools(tools, tool_choice=choice)
r = m.invoke(question)
if r.tool_calls:
tc = r.tool_calls[0]
print(f"tool_choice='{label}': → {tc['name']}({tc['args']})")
else:
print(f"tool_choice='{label}': → Text: '{r.content[:50]}'")
# Output:
# tool_choice='auto': → Text: 'The capital of Japan is Tokyo.'
# tool_choice='any': → get_weather({'city': 'Tokyo'})
# tool_choice='get_weather': → get_weather({'city': 'Tokyo'})
# tool_choice='calculator': → calculator({'expression': '...'})
Explanation: With "auto", the model answers directly because it doesn't need tools. With "any", it's forced to pick something — and it picks get_weather because the question mentions a place. With a specific name, it always calls that tool regardless of relevance.
Exercise 4: a tool router (Medium)
Write a function route_question(question) that uses bind_tools with tool_choice="auto" and returns a dictionary with type ("tool_call" or "direct_response"), tool_name (if it applies), tool_args (if it applies), and content (if it applies). Try it with 5 varied questions.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is sunny, 22°C"
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
return str(eval(expression))
@tool
def search_web(query: str) -> str:
"""Search the internet for up-to-date information."""
return f"Results for '{query}': ..."
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator, search_web])
def route_question(question: str) -> dict:
response = model_with_tools.invoke(question)
if response.tool_calls:
tc = response.tool_calls[0]
return {
"type": "tool_call",
"tool_name": tc["name"],
"tool_args": tc["args"],
"content": None,
}
return {
"type": "direct_response",
"tool_name": None,
"tool_args": None,
"content": response.content,
}
questions = [
"What's the weather in Santiago?",
"What's 1024 / 16?",
"What is a microservice?",
"Find the latest AI news",
"Hi, how are you?",
]
for q in questions:
result = route_question(q)
if result["type"] == "tool_call":
print(f"[TOOL] {q}")
print(f" → {result['tool_name']}({result['tool_args']})")
else:
print(f"[TEXT] {q}")
print(f" → {result['content'][:60]}")
print()
# Output:
# [TOOL] What's the weather in Santiago?
# → get_weather({'city': 'Santiago'})
#
# [TOOL] What's 1024 / 16?
# → calculator({'expression': '1024 / 16'})
#
# [TEXT] What is a microservice?
# → A microservice is an architectural pattern that splits...
#
# [TOOL] Find the latest AI news
# → search_web({'query': 'latest AI news'})
#
# [TEXT] Hi, how are you?
# → Hi! I'm doing well, thanks. How can I help you?
Explanation: route_question acts as a router — it works out whether the question needs a tool or a direct answer. This pattern is the foundation of how agents decide what to do on each turn.
Summary
In this capsule you learned:
bind_tools()connects a list of tools to a model, creating a new instance that knows about them- The model does NOT execute tools — it only returns instructions in
tool_callssaying what it wants to call and with what arguments AIMessage.tool_callsis a list of dicts withname,args, andid— each one represents an execution requesttool_choicecontrols when the model uses tools:"auto"(it decides),"any"(force one), or a specific name- When the model doesn't need tools,
tool_callsis empty andcontentholds the text answer - The tool descriptions (docstrings) are critical — the model uses them to decide which one to call
- You can pass Pydantic schemas as well as
@toolfunctions tobind_tools()
Next capsule: Tool Execution Loop — you'll learn to run the tools the model asks for, build ToolMessage objects with the results, and close the loop so the model can produce the final answer.
Further reading
- Tool Calling — LangChain Docs — Full conceptual guide to tool calling
- How to use chat models to call tools — Step-by-step tutorial with examples
- bind_tools API Reference — Reference for the
bind_toolsmethod - OpenAI Function Calling — OpenAI's implementation
- Anthropic Tool Use — Anthropic's implementation
- Google Gemini Function Calling — Google's implementation
- AIMessage API Reference — Reference for AIMessage and tool_calls
- How to force a specific tool call — Guide to tool_choice
Module 2 — LangChain & LangGraph: From Chains to Agents