Tool Calling in LangChain: A Practical Guide for Engineers
LangChain tool calling is the mechanism that lets a language model decide, mid-generation, to invoke a Python function instead of just returning text. The model doesn't run the function itself: it emits a structured request naming a tool and its arguments, your code executes that function, and the result gets fed back into the conversation. This guide walks through how LangChain tool calling works end to end: defining tools, binding them to a chat model, handling the request-execute-respond loop, and the patterns you need for production agents.
If you've used any modern LLM API directly, this will feel familiar. LangChain's contribution is a consistent interface across providers (OpenAI, Anthropic, Google, and others) so you write one @tool definition and one bind_tools call regardless of which model is underneath.
What tool calling actually is
A "tool" in LangChain is just a Python function with a name, a description, and a typed schema for its arguments. When you bind a set of tools to a chat model, LangChain serializes those tools into the format the underlying provider's API expects (OpenAI's function-calling schema, Anthropic's tool-use schema, etc.) and attaches them to every request.
The model then does one of two things when it responds:
- Return normal text, because it didn't need a tool.
- Return one or more "tool calls": a tool name plus a JSON object of arguments, with no natural-language content (or partial content plus the calls, depending on the provider).
LangChain never executes the function for you automatically in the base chat model interface. You get back an AIMessage with a tool_calls list, you run the functions yourself, and you append the results as ToolMessage objects before calling the model again. This loop, request, execute, respond, repeat, is the entire mechanism behind every LangChain agent.
Defining a tool with the @tool decorator
The simplest way to define a tool is the @tool decorator from langchain_core.tools. It inspects your function's signature, type hints, and docstring to build the schema the model sees.
from langchain_core.tools import tool
@tool
def get_weather(city: str, units: str = "celsius") -> str:
"""Get the current weather for a city.
Args:
city: The city name, e.g. "Mumbai" or "Tokyo".
units: Either "celsius" or "fahrenheit".
"""
# In production this would call a real weather API
return f"It is 28 degrees {units} and sunny in {city}."Three things matter here for tool calling to work well:
- The function name (
get_weather) becomes the tool name the model calls by. - The docstring becomes the tool description. This is what the model reads to decide when to use the tool, so write it like documentation for a new teammate, not a code comment.
- The type hints (
str, default values) become the JSON schema for arguments. LangChain infers required vs optional from whether a parameter has a default.
You can inspect what the model actually sees:
print(get_weather.name)
print(get_weather.description)
print(get_weather.args)For anything beyond a couple of scalar arguments, define the schema explicitly with Pydantic instead of relying on inference. It's more verbose but far less likely to produce a schema the model misunderstands.
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class GetWeatherInput(BaseModel):
city: str = Field(description="City name, e.g. 'Mumbai' or 'Tokyo'")
units: str = Field(
default="celsius",
description="Temperature units: 'celsius' or 'fahrenheit'",
)
@tool(args_schema=GetWeatherInput)
def get_weather(city: str, units: str = "celsius") -> str:
"""Get the current weather for a city."""
return f"It is 28 degrees {units} and sunny in {city}."Pydantic schemas also let you add validation (Field(gt=0), enums via Literal, nested objects) that plain type hints can't express, and they render more precisely into the JSON schema every provider consumes.
Binding tools to a chat model
Once you have tool objects, attach them to a chat model with bind_tools. This returns a new runnable; it doesn't mutate the original model.
from langchain.chat_models import init_chat_model
model = init_chat_model("gpt-4.1", model_provider="openai")
model_with_tools = model.bind_tools([get_weather])
response = model_with_tools.invoke("What's the weather like in Mumbai?")
print(response.tool_calls)response.tool_calls is a list of dictionaries, each with name, args, and id:
[
{
"name": "get_weather",
"args": {"city": "Mumbai", "units": "celsius"},
"id": "call_abc123",
"type": "tool_call",
}
]Note that response.content will often be empty or minimal when the model decides to call a tool. That's expected: the model is choosing to act rather than talk. If you print response.content and see nothing, check response.tool_calls before assuming something broke.
init_chat_model is worth using instead of importing a provider-specific class directly (ChatOpenAI, ChatAnthropic, and so on), because it gives you one line to swap models later without touching the rest of your tool-calling code. Tool calling behavior is normalized across providers at the bind_tools / tool_calls layer, so the same code above works if you swap in init_chat_model("claude-opus", model_provider="anthropic").
Executing tools and closing the loop
Binding tools only gets you the model's intent. You still have to run the function and send the result back. This is the part people miss when they first try LangChain tool calling: nothing happens automatically unless you build the loop or use a prebuilt agent.
Manual loop, so you understand what's happening underneath the abstractions:
from langchain_core.messages import HumanMessage, ToolMessage
tools_by_name = {"get_weather": get_weather}
messages = [HumanMessage("What's the weather in Mumbai and in Tokyo?")]
response = model_with_tools.invoke(messages)
messages.append(response)
for call in response.tool_calls:
tool_fn = tools_by_name[call["name"]]
result = tool_fn.invoke(call["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
final = model_with_tools.invoke(messages)
print(final.content)A few details that trip people up:
- `tool_call_id` must match. Every
ToolMessageneeds theidfrom the corresponding tool call, or the provider API will reject the request. This is how the model correlates results with the calls it made. - Order and pairing matter for multi-turn history. If you're storing conversation history for later turns, keep the
AIMessagewith tool calls and its matchingToolMessageresults adjacent. Splitting them across turns or dropping one will break subsequent calls to most providers. - `tool.invoke(args)` vs calling the raw function. Prefer
tool_fn.invoke(call["args"])over calling the underlying Python function directly. The.invoke()path runs through LangChain's runnable interface, which means callbacks, tracing, and error handling behave consistently, and it validates the args against the schema first.
Parallel tool calls
Most modern models can request several tool calls in a single turn, for example, calling get_weather for two cities at once rather than doing it sequentially over two round trips. The loop above already handles this correctly because it iterates response.tool_calls, but if you're timing performance or debugging apparent "double calls," check whether the model is genuinely calling the same tool twice in parallel with different arguments, which is normal and desirable, versus your loop appending duplicate messages.
If you specifically want to disable parallel calls (some workflows require strictly sequential tool use, like a multi-step approval flow), most provider integrations support a keyword on bind_tools:
model_with_tools = model.bind_tools([get_weather], parallel_tool_calls=False)This is provider-dependent, not every integration exposes it, so check the specific chat model's docs if it's load-bearing for your design.
Forcing a specific tool
Sometimes you don't want the model choosing whether to call a tool, you want to force it, either to always use tools (never answer in plain text) or to require one specific tool. bind_tools accepts a tool_choice argument for this:
# Force the model to always call some tool
model.bind_tools([get_weather], tool_choice="any")
# Force the model to call this exact tool
model.bind_tools([get_weather], tool_choice="get_weather")This is useful for structured extraction: if you have a single "record_answer" style tool whose only job is to shape output as a schema, forcing that tool means you never have to parse free text as a fallback.
Structured output via tool calling
A common pattern is using tool calling not to run real functions, but to force the model into producing a validated Pydantic object. LangChain exposes this directly as with_structured_output, which is implemented on top of tool calling for most providers:
from pydantic import BaseModel
class Itinerary(BaseModel):
city: str
days: int
highlights: list[str]
structured_model = model.with_structured_output(Itinerary)
result = structured_model.invoke("Plan a 3-day trip to Kyoto focused on temples.")
print(result.city, result.days, result.highlights)Under the hood this defines a single tool matching the Itinerary schema, forces tool_choice to that tool, and parses the returned arguments back into a Pydantic instance. Reach for with_structured_output when your goal is a typed object, and reach for bind_tools plus a manual loop when your goal is to actually execute code and continue the conversation.
Using prebuilt agents instead of hand-rolling the loop
Writing the request-execute-respond loop by hand is worth doing once to understand it, but for real applications you generally want LangGraph's prebuilt agent, which handles the loop, message history, and stopping conditions for you.
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
model = init_chat_model("gpt-4.1", model_provider="openai")
agent = create_react_agent(model, tools=[get_weather])
result = agent.invoke({
"messages": [{"role": "user", "content": "What's the weather in Mumbai?"}]
})
print(result["messages"][-1].content)create_react_agent builds a small graph: a model node that may emit tool calls, a tool-execution node (ToolNode) that runs whichever tools were requested, and an edge that routes back to the model node until the model stops calling tools and returns a final answer. This is the same loop from the manual example, just implemented as a graph with proper state management, streaming, and interrupt support built in.
If you need more control than create_react_agent gives you, for example a human-approval step before executing a sensitive tool, drop down to building the graph yourself with ToolNode and conditional edges. That's more setup but keeps you in the same mental model.
from langgraph.prebuilt import ToolNode
from langgraph.graph import StateGraph, MessagesState, START, END
def call_model(state: MessagesState):
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
def should_continue(state: MessagesState):
last_message = state["messages"][-1]
return "tools" if last_message.tool_calls else END
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode([get_weather]))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", should_continue)
graph.add_edge("tools", "agent")
app = graph.compile()ToolNode reads the tool_calls off the last AIMessage in state, runs each one (including in parallel where the underlying tools support it), and appends ToolMessage results. It also has reasonable default error handling: if a tool raises, ToolNode catches it and returns the exception as the content of a ToolMessage by default, so the model gets a chance to see the error and retry or explain rather than crashing your process.
Error handling inside tools
Tool calling introduces a new failure mode beyond normal exceptions: the model can call your tool with malformed or nonsensical arguments, because it's guessing based on a schema, not calling a typed function in code. Handle this defensively:
from langchain_core.tools import tool
@tool
def divide(a: float, b: float) -> str:
"""Divide a by b."""
if b == 0:
return "Error: cannot divide by zero. Ask the user for a nonzero divisor."
return str(a / b)Returning a descriptive error string as the tool's result, rather than raising, is often better than an exception when you're using ToolNode or a prebuilt agent, because it gives the model a chance to self-correct on the next turn, for example by asking the user for a different input or trying a different tool. Reserve raised exceptions for genuinely unrecoverable failures where you want the whole run to stop.
For validation errors specifically, Pydantic's args_schema will raise a ValidationError before your function body ever runs if the model supplies arguments that don't match the schema (wrong type, missing required field). Wrap .invoke() calls in a try/except at the loop level if you're not using ToolNode, so a validation error doesn't take down the whole request:
try:
result = tool_fn.invoke(call["args"])
except Exception as e:
result = f"Tool execution failed: {e}"
messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))Streaming tool calls
Tool calls can arrive incrementally when you stream a response, which matters if you're building a UI that shows "thinking" or "calling tool X" indicators. LangChain accumulates streamed chunks into a full tool call using tool_call_chunks:
gathered = None
for chunk in model_with_tools.stream("What's the weather in Delhi?"):
gathered = chunk if gathered is None else gathered + chunk
print(chunk.tool_call_chunks)
print(gathered.tool_calls)Individual chunks may have partial, invalid JSON in args as they stream in; only the fully accumulated message has reliably parseable tool_calls. Don't try to execute a tool off a partial chunk.
Choosing between multiple tools
When you bind more than one tool, the model picks based on name and description, so ambiguous tools cause ambiguous behavior. If you find the model calling the wrong tool, the fix is almost always to tighten descriptions and argument docs, not to add prompt instructions begging it to choose correctly.
@tool
def search_flights(origin: str, destination: str, date: str) -> str:
"""Search for flights between two cities on a given date.
Use this only for air travel, not trains or buses."""
...
@tool
def search_trains(origin: str, destination: str, date: str) -> str:
"""Search for train routes between two cities on a given date.
Use this only for rail travel, not flights."""
...Explicit negative instructions ("not trains or buses") inside the docstring genuinely help disambiguation because that text is exactly what the model conditions on when choosing a tool. Treat tool descriptions as the highest-leverage prompt engineering surface in a LangChain tool calling setup, more impactful per character than the system prompt in most agent architectures.
Also keep the total number of bound tools reasonable. Model providers don't hard-cap tool counts, but accuracy degrades as the tool list grows into the dozens, because the model has to discriminate among more near-duplicate options at every step. If you're past 15-20 tools, look into dynamically filtering which tools you bind per request based on the user's intent, rather than binding everything all the time.
Debugging a tool call that never happens
If you bind a tool and the model just answers in plain text instead of calling it, work through this checklist:
- Confirm the model you're using actually supports tool calling. Not all models do, and some support it only in specific modes.
- Check whether the user's request genuinely needs the tool. Models are usually conservative about calling tools when a plain-text answer would satisfy the question; that's correct behavior, not a bug.
- Look at your tool description for vagueness. "Get weather" is worse than "Get the current, real-time weather conditions for a specific city; use this whenever the user asks about weather, temperature, or forecasts."
- Try
tool_choice="any"temporarily to confirm the tool schema itself is valid and reaches the model correctly, isolating whether the issue is the model's judgment or a schema/wiring bug.
Wrapping up
LangChain tool calling comes down to four moving parts: a tool definition with a clear schema, bind_tools to attach it to a model, a loop (manual or via ToolNode/create_react_agent) that executes requested calls and feeds results back, and error handling that returns useful text instead of crashing. Everything else, parallel calls, forced tool choice, structured output, streaming, is a variation on that same request-execute-respond cycle. Get comfortable with the manual loop first; the prebuilt agents in LangGraph are just a more robust version of exactly what you already understand.
FAQ
Does LangChain execute my tool functions automatically? No, not with the base bind_tools API. The model returns a request (tool_calls) and you run the function yourself. Prebuilt agents like create_react_agent and the ToolNode component automate this loop for you, but the underlying mechanism is always request-then-execute.
What's the difference between `@tool` and defining an `args_schema` with Pydantic? @tool alone infers the argument schema from your function's type hints and docstring, which works fine for simple functions with a few scalar parameters. An explicit Pydantic args_schema gives you validation, enums, nested objects, and more precise field descriptions, and it's generally worth the extra code for anything used in production.
Can I use the same tool definitions across different model providers? Yes. That's the main value of LangChain's tool abstraction: you define a tool once with @tool, and bind_tools serializes it into whatever schema format the underlying provider (OpenAI, Anthropic, Google, and others) expects. Switching providers via init_chat_model doesn't require redefining your tools.
Why is `response.content` empty when the model calls a tool? Because the model chose to act instead of respond in text. Check response.tool_calls instead. Some providers include partial text alongside tool calls, but many return empty content when a call is being made.
How do I stop the model from calling tools in an infinite loop? Prebuilt agents like create_react_agent have built-in recursion limits you can configure. If you're hand-rolling a graph, add an explicit step counter to your state and route to an end node once you've hit a maximum number of tool-calling turns, so a model that keeps calling tools without resolving the task doesn't run forever.
Is tool calling the same thing as function calling? Yes, in this context. "Function calling" is the term OpenAI popularized; "tool calling" is the more provider-neutral term LangChain and most of the ecosystem use now, since not every provider frames it as literally calling a Python function, some support broader tool types like built-in web search or code execution alongside user-defined functions.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.