13. Connecting Tools to the LLM
In Chapter 12 we built tools that an agent can use. Now it's time to connect these tools to the LLM so the agent can actually use them.
No matter how well a tool is built, the LLM can't use it without knowing it exists. It needs to know what's available and pick the right tool for each request. However, as we learned in Chapter 12, the LLM doesn't execute tools directly. Instead, it requests "call this tool with these arguments," and our agent code invokes the tool. This entire mechanism is called tool calling.
In this chapter we'll bind tools to the LLM, execute tool call requests, and pass results back to the LLM — implementing the complete cycle. This cycle is the foundation of the agent loop we'll build in Chapter 14.
13.1) Binding Tools and Inspecting Tool Call Requests
For the LLM to use tools, it first needs to know what's available. When we pass each tool's name, description, and input schema to the LLM, it learns when and how to use each tool. This process is called tool binding.
Once tools are bound, the LLM will do one of two things when it receives a question. If no tool is needed, it responds with text as usual. If a tool is needed, it returns a structured request: "call this tool with these arguments." Our agent then inspects this request and invokes the specified tool.
Let's start with how to bind tools.
13.1.1) Binding Tools with bind_tools()
Tool binding is handled by a single method: bind_tools(). Every chat model that supports tool calling provides this method. Calling bind_tools() returns a new model object with the given tools bound to it.
Let's bind the get_weather tool we built in Chapter 12.
from langchain_openai import ChatOpenAI
from langchain.tools import tool
llm = ChatOpenAI(model="gpt-5-mini")
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"It's always sunny in {city}!"
# Bind the tool to the model — a new model object with the tool bound is returned
llm_with_tools = llm.bind_tools([get_weather])That's all it takes. The returned llm_with_tools is a model that knows about the get_weather tool. Note that the original llm hasn't changed at all — it still knows nothing about any tools.
bind_tools() takes a list of tools as its argument and returns a new model object with those tools bound. Internally, it converts each tool's metadata (name, description, and input schema) into a format the LLM provider understands, so the tool schemas are sent along with every model invocation.
13.1.2) Inspecting Tool Call Requests
What happens when we send a weather question to a model with a weather tool bound?
response = llm_with_tools.invoke("What's the weather in Paris?")
print(f"type: {type(response)}\n")
print(f"tool_calls: {response.tool_calls}\n")
print(f"content: {repr(response.content)}\n")Output:
type: <class 'langchain_core.messages.ai.AIMessage'>
tool_calls: [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_hgXrHGD', 'type': 'tool_call'}]
content: ''Since llm_with_tools is still a chat model, it returns an AIMessage. But two things are different about this response.
First, response.tool_calls contains a list of tool call requests. Each request is a dictionary with four keys:
name— the name of the tool to call. This corresponds to the tool'snamefrom its metadata.args— the arguments to pass when calling the tool. The model looked at the input schema and the user's question to construct{'city': 'Paris'}.id— a unique identifier for this call. The LLM uses this later to match tool execution results with tool call requests.type— always'tool_call'.
Second, response.content is an empty string. This response isn't a final text answer — it's a tool call request.
To determine whether the response is a tool call request, check tool_calls rather than content. If tool_calls is non-empty, it's a tool call request. If it's empty, the LLM has provided a text answer directly.
13.2) Executing Tools and Passing Results Back
The LLM has requested a get_weather tool call. Now our agent code needs to execute the tool and pass the result back to the LLM.
Processing a tool call request involves three steps:
- Extract the tool name and arguments from
tool_callsand execute the corresponding tool. - Convert the execution result into a
ToolMessage. - Send the full conversation (user question + LLM's tool call request + tool execution result) back to the LLM to receive a response.
Here's a diagram of this process:
13.2.1) Executing the Tool and Creating a ToolMessage
First, let's extract the tool name and arguments from tool_calls and run the corresponding tool. To look up a tool by name, we create a dictionary keyed by tool name.
# A dictionary to look up tools by name
tool_map = {get_weather.name: get_weather}Next, we pull a tool call request from tool_calls, find the matching tool in tool_map, and execute it.
if response.tool_calls:
tool_call = response.tool_calls[0]
# {'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_hgXrHGD', 'type': 'tool_call'}
selected_tool = tool_map[tool_call["name"]]
result = selected_tool.invoke(tool_call["args"])
print(result) # Output: It's always sunny in Paris!To pass the tool execution result back to the LLM, we need to wrap it in a ToolMessage. A ToolMessage has two required fields:
content— the tool execution result as a string.tool_call_id— theidfromtool_calls. The LLM uses this to identify which request the result belongs to.
from langchain_core.messages import ToolMessage
if response.tool_calls:
tool_call = response.tool_calls[0]
selected_tool = tool_map[tool_call["name"]]
result = selected_tool.invoke(tool_call["args"])
tool_message = ToolMessage(
content=result,
tool_call_id=tool_call["id"], # must match the request's id
)
print(tool_message)Output:
content="It's always sunny in Paris!" tool_call_id='call_hgXrHGD'In the code above, we passed args to execute the tool, then combined the result with the tool_call_id to build the ToolMessage.
There's a simpler way to do this in one step. Instead of passing just args to .invoke(), pass the entire tool_call dictionary. LangChain will execute the tool and return a ToolMessage automatically.
if response.tool_calls:
tool_call = response.tool_calls[0]
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
print(tool_message)Output:
content="It's always sunny in Paris!" tool_call_id='call_hgXrHGD'Same result, but the code is much simpler and there's no chance of a tool_call_id mismatch. We'll use this approach going forward.
13.2.2) Sending Tool Results to the LLM
Now let's pass the tool execution result to the LLM. We send the full conversation history — user question, the LLM's tool call request, and the tool execution result — back to the LLM, and it generates an answer based on the tool result.
Let's put the entire process together from start to finish in a single piece of code.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
# Define tools and bind
llm = ChatOpenAI(model="gpt-5-mini")
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"It's always sunny in {city}!"
llm_with_tools = llm.bind_tools([get_weather])
tool_map = {get_weather.name: get_weather}
# Step 1: Send the user's question and receive the LLM's response
messages = [HumanMessage("What's the weather in Paris?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
# Step 2: If a tool call was requested, execute the tool and add the result
if ai_msg.tool_calls:
tool_call = ai_msg.tool_calls[0]
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)
# Step 3: Send the full conversation history back to the LLM
final_response = llm_with_tools.invoke(messages)
print(final_response.content)Output:
The current weather in Paris is sunny. Have a great day!The LLM generated a final text answer based on the tool execution result. Back in 13.1, content was an empty string — but after passing the tool result back, we received an actual answer.
Let's check what messages are in the messages list at Step 3.
for m in messages:
print(f"{type(m).__name__}: {m.content!r}")Output:
HumanMessage: "What's the weather in Paris?"
AIMessage: ''
ToolMessage: "It's always sunny in Paris!"Three messages are stored in order: the user's question, the LLM's tool call request (empty content), and the tool execution result. The LLM saw the user's question, decided it needed a tool, and requested a get_weather call. The agent code executed get_weather and added the result to the message list. Once it had everything it needed, the LLM generated its final response.
13.3) Giving the LLM Multiple Tools
Real-world agents typically have multiple tools. Everything we've covered so far works exactly the same with multiple tools.
Let's add calculate and search_web alongside get_weather to bind three tools.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
llm = ChatOpenAI(model="gpt-5-mini")
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"It's always sunny in {city}!"
@tool
def calculate(expression: str) -> str:
"""Evaluate a simple arithmetic expression like '123 * 456'."""
return str(eval(expression)) # Warning: eval() is a security risk. Do not use in production.
@tool
def search_web(query: str) -> str:
"""Search the web for current information on a topic."""
return f"Top result for '{query}': ..."
tools = [get_weather, calculate, search_web]
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}Let's ask a calculation question.
response = llm_with_tools.invoke("What is 123 multiplied by 456?")
print(response.tool_calls)Output:
[{'name': 'calculate', 'args': {'expression': '123 * 456'}, 'id': 'call_xyz789', 'type': 'tool_call'}]The LLM requested a calculate call. It compared each tool's description against the user's question and determined that calculate was the right tool.
We can execute the tool and get a final answer using the same pattern from 13.2.
messages = [HumanMessage("What is 123 multiplied by 456?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
if ai_msg.tool_calls:
for tool_call in ai_msg.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)
final_response = llm_with_tools.invoke(messages)
print(final_response.content) # Output: 123 multiplied by 456 is 56,088.In 13.2 we handled only the first request with tool_calls[0], but here we iterate over all requests with a for loop. The reason is that the LLM can request multiple tool calls at once.
Multiple Simultaneous Tool Requests
Let's see what happens when multiple tools are requested at the same time. The code is the same — only the question changes.
messages = [HumanMessage("What's the weather in Tokyo, and what is 123 * 456?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
if ai_msg.tool_calls:
for tool_call in ai_msg.tool_calls:
print(f"Tool: {tool_call['name']}")
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)
final_response = llm_with_tools.invoke(messages)
print(final_response.content)Output:
Tool: get_weather
Tool: calculate
The weather in Tokyo is currently sunny, and 123 multiplied by 456 is 56,088.The LLM requested both get_weather and calculate, and the for loop executed all the requests in tool_calls and added their results to the message list. The LLM then used these results to generate the final answer.