Tools in LangChain
Rebuild the weather tool with @tool: LangChain reads the name, docstring and type hints and writes the tool schema for you.
What you will be able to do
- Explain what a tool is, and who decides versus who executes
- Turn a Python function into a LangChain tool with @tool
- Read the tool schema LangChain builds from the name, docstring and type hints
- Call a tool directly with invoke(), and run a tool call the way a framework does
- Write tool descriptions a model can choose between
- Tell a chain, a tool and an agent apart
The idea, in plain English
A tool is a function an agent can use to do something outside the model - look up the weather, do arithmetic, query a database. The model cannot run Python. It can only ask for a tool by name, with arguments. Your code runs it and hands back the result. The model decides; your code executes.
In Lesson 1.5 you did all of it by hand: described get_weather in the system message, told the model to write TOOL: get_weather(city="Mumbai"), matched that line with a regular expression, looked the name up in a TOOLS dict and called the function. Each of those steps was code you wrote and could get wrong.
In LangChain you put @tool above the function. LangChain reads three things - the function name, the docstring and the type hints - and builds a tool with a schema: a JSON description of the name, what it does, and which arguments it takes with which types. That schema is what a tool-calling model receives instead of your hand-written description.
Everything here was run against langchain-core 1.6. Our local llama3 does not support tool calling, so the model’s side - choosing a tool - is shown as the message a tool-calling model produces, and the next lesson runs it.
Worked example: Rebuild the weather tool the LangChain way.
1 - An ordinary function
get_weather looks up a city in a dict. Only your Python program knows it exists.
What @tool builds, what the model sees, and who runs what.
The model decides, your code executes
A model produces text - or, with tool calling, a structured request. It never runs get_weather. When a user asks for the weather in Mumbai, the model’s job is to say "call get_weather with city Mumbai"; your program (or the framework) runs the function and returns the real result.
That separation is Lesson 1.5’s, and it does not change in LangChain. What changes is who writes the description, and who reads the request.
Lesson 1.5, by hand
You wrote the tool description into the system message, defined an exact format - TOOL: get_weather(city="CityName") - parsed it with TOOL_PATTERN, checked the name in TOOLS = {"get_weather": get_weather}, and called the function. A model that wrote TOOLS: instead of TOOL:, or answered without the tool, slipped past.
With LangChain tools and a tool-calling model, the description is generated from your function and the request arrives as structured data. The regex disappears. The registry stays - as a dict of tools keyed by name.
@tool and the schema it builds
from langchain_core.tools import tool, then @tool above the function. get_weather is now a StructuredTool with .name ("get_weather"), .description (the docstring) and .args ({"city": {"type": "string"}}).
The full schema - what bind_tools sends to a model - is a small JSON object: name, description, and parameters listing each argument’s type and which are required. Arguments without a default are required.
nameThe function name.descriptionThe docstring - required.parametersThe type hints: str becomes string, int becomes integer.requiredEvery argument without a default.Type hints become argument types
user_id: int appears in the schema as "type": "integer", and the tool validates input against it: invoke({"user_id": "42"}) converts to 42, invoke({"user_id": "abc"}) raises a ValidationError. search_products(query: str, limit: int) becomes two required parameters, a string and an integer.
Leave the hint off and the argument has no type in the schema at all - {"city": {"title": "City"}}. The model then has to guess, and nothing checks what it sends.
The docstring is the model’s documentation
The docstring becomes the description, and it is not optional: @tool on a function without one raises ValueError: Function must have a docstring if description not provided.
It also has to be useful. """Weather.""" produces a valid schema with description "Weather." - legal, but it tells the model nothing about when to use it. When tools sit side by side, the descriptions are what the model chooses between: "Find a customer by name." versus "Find an order by order ID, e.g. ORD123." Say what the tool does, what the input looks like, and when to use it.
Calling a tool yourself
A decorated tool is no longer called with brackets: get_weather("Mumbai") raises TypeError: ‘StructuredTool’ object is not callable. Use get_weather.invoke({"city": "Mumbai"}) - the same invoke() as every runnable in Lesson 2.4. The original function is still at get_weather.func.
Invoke with a tool call - the dict a tool-calling model puts in AIMessage.tool_calls - and you get a ToolMessage back, with the result and the call’s id. That is what a framework does on each turn of the loop: look the tool up by name, invoke it with the call, send the ToolMessage back to the model.
Watch out: Our llama3 cannot take this step: bind_tools works, but the call fails with "llama3 does not support tools". Tool calling needs a model trained for it, such as llama3.1.
A calculator tool - without eval()
The quick version is return str(eval(expression)). It works - "47 * 89" gives 4183 - and with the same tool, __import__(‘os’).getcwd() returned the project directory. eval runs any Python the model writes.
Lesson 1.6 replaced eval with a small evaluator that only allows numbers and + - * /. Wrap that in @tool instead. The model sees the same schema; your machine is no longer at its mercy.
Chain, tool, agent
A chain is a fixed path: prompt | llm | parser runs the same steps every time. A tool is one action the program can take. An agent is a model in a loop that decides, each turn, whether to call a tool, which one, or to answer.
This lesson builds the tools. Lesson 2.7 gives them to an agent and lets LangChain run the loop you wrote by hand in Lesson 1.6.
ChainConnects steps in a fixed order.ToolOne action, with a schema the model can read.AgentChooses which tool to call - or none - in a loop.Step-by-step code
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
weather = {
"mumbai": "Rainy, 27°C",
"delhi": "Sunny, 34°C",
"hyderabad": "Cloudy, 29°C",
}
return weather.get(city.lower(), "Unknown city")
print(get_weather.invoke({"city": "Mumbai"})) # Rainy, 27°C
print(get_weather.invoke({"city": "Paris"})) # Unknown city
print(get_weather.func("Hyderabad")) # Cloudy, 29°C - the original function
get_weather("Mumbai")
# TypeError: 'StructuredTool' object is not callablefrom langchain_core.utils.function_calling import convert_to_openai_tool
print(type(get_weather).__name__) # StructuredTool
print(get_weather.name) # get_weather
print(get_weather.description) # Get the current weather for a city.
print(get_weather.args) # {'city': {'title': 'City', 'type': 'string'}}
print(convert_to_openai_tool(get_weather)) # the schema a model receives{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"type": "object"
}
}
}get_user(user_id: int)
schema {'user_id': {'type': 'integer'}}
invoke "42" user 42 - converted
invoke "abc" ValidationError - unable to parse string as an integer
get_weather.invoke({"town": "Mumbai"})
ValidationError - city: Field required
search_products(query: str, limit: int)
parameters query: string, limit: integer - both required
def get_weather(city) - no type hint
schema {'city': {'title': 'City'}} - no type at all
no docstring ValueError: Function must have a docstring if description not provided.
"""Weather.""" accepted - description "Weather."import ast
import operator
from langchain_core.tools import tool
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def evaluate(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPERATORS:
return OPERATORS[type(node.op)](evaluate(node.left), evaluate(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in OPERATORS:
return OPERATORS[type(node.op)](evaluate(node.operand))
raise ValueError("only numbers and + - * / are allowed")
@tool
def calculate(expression: str) -> str:
"""Calculate an arithmetic expression using numbers, + - * / and brackets, e.g. '47 * 89'."""
try:
return str(evaluate(ast.parse(expression, mode="eval").body))
except (SyntaxError, ValueError, ZeroDivisionError) as error:
return f"Calculation error: {error}"
print(calculate.invoke({"expression": "47 * 89"})) # 4183
print(calculate.invoke({"expression": "12 * (3 + 4)"})) # 84
print(calculate.invoke({"expression": "__import__('os').getcwd()"})) # Calculation error: only numbers and + - * / are allowed
print(calculate.invoke({"expression": "10 / 0"})) # Calculation error: division by zerofrom langchain_core.messages import AIMessage
tools = {t.name: t for t in [get_weather, calculate]}
# What a tool-calling model replies with (llama3 cannot - see below)
reply = AIMessage(content="", tool_calls=[
{"name": "get_weather", "args": {"city": "Hyderabad"}, "id": "call_1"},
{"name": "calculate", "args": {"expression": "47 * 89"}, "id": "call_2"},
])
print(reply.tool_calls[0])
# {'name': 'get_weather', 'args': {'city': 'Hyderabad'}, 'id': 'call_1', 'type': 'tool_call'}
for call in reply.tool_calls:
result = tools[call["name"]].invoke(call)
print(type(result).__name__, result.name, repr(result.content), result.tool_call_id)
# ToolMessage get_weather 'Cloudy, 29°C' call_1
# ToolMessage calculate '4183' call_2from langchain_ollama import ChatOllama
llm = ChatOllama(model="llama3").bind_tools([get_weather, calculate])
llm.invoke("What's the weather in Hyderabad?")
# ResponseError: registry.ollama.ai/library/llama3:latest does not support tools (status code: 400)
# Tool calling needs a model trained for it - for example llama3.1.# Lesson 1.5 - describe, parse, register, execute: all by hand
SYSTEM = 'You have access to one tool: get_weather(city) ... TOOL: get_weather(city="CityName")'
TOOL_PATTERN = re.compile(r'TOOL:\s*(\w+)\(\s*city\s*=\s*"([^"]*)"\s*\)')
TOOLS = {"get_weather": get_weather}
match = TOOL_PATTERN.search(output)
result = TOOLS[match.group(1)](match.group(2))
# Lesson 2.6 - the description comes from the function, the request is structured
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
...
tools = {t.name: t for t in [get_weather]}
result = tools[call["name"]].invoke(call)Tip: Test every tool with invoke() before giving it to a model. If the tool is wrong on its own, no model will make it right.
Tools at a glance
@toolTurn a function into a LangChain tool.
from langchain_core.tools import tool
.name / .description / .argsWhat the schema is built from.
get_weather.args
invoke(dict)Run the tool yourself; returns the result.
get_weather.invoke({"city": "Mumbai"})invoke(tool_call)Run a model’s tool call; returns a ToolMessage.
tools[call["name"]].invoke(call)
.funcThe original Python function.
get_weather.func("Mumbai")convert_to_openai_toolPrint the schema a model receives.
convert_to_openai_tool(get_weather)
bind_toolsGive tools to a tool-calling model.
llm.bind_tools([get_weather, calculate])
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Add Chennai to get_weather and invoke it with "CHENNAI".”
“Print convert_to_openai_tool() for get_weather, then add a units: str = "celsius" argument and compare.”
“Delete the str type hint from city and look at get_weather.args.”
“Write an AIMessage with a calculate tool call and run it through the tools dict.”
What usually goes wrong
@tool replaces the function with a tool object. Use invoke(), or .func for the original.
✗ get_weather("Mumbai")✓ get_weather.invoke({"city": "Mumbai"})No docstring is an error. A one-word docstring is worse: valid, and useless to the model choosing between tools.
✗ """Weather."""✓ """Get the current weather for a city."""Without a hint the argument has no type in the schema, and nothing validates what the model sends.
✗ def get_user(user_id):✓ def get_user(user_id: int) -> str:The model’s argument becomes Python code on your machine. Use an arithmetic-only evaluator, as in Lesson 1.6.
✗ return str(eval(expression))✓ return str(evaluate(ast.parse(expression, mode="eval").body))The model only asks. If your code does not invoke the tool and send back the result, nothing happened - whatever the model says next.
Key points
- A tool is a function an agent can use to act outside the model.
- The model chooses the tool; your application executes it.
- @tool turns a function into a StructuredTool with a schema.
- Name from the function, description from the docstring, argument types from the type hints.
- The docstring is required, and it is what the model reads when choosing.
- Call tools with invoke(); a tool call returns a ToolMessage.
- Tool calling needs a tool-calling model - llama3 is not one.
Quick check before you move on
Quiz
- 1.
You write get_weather("Mumbai") after decorating it with @tool. What happens?
- 2.
get_user(user_id: int) is invoked with "abc". What happens?
- 3.
You invoke a tool with a tool call from AIMessage.tool_calls. What comes back?
- 4.
What is still needed from Lesson 1.5 when you use LangChain tools?
Interview questions
What is a tool in an agent system?
A function with a schema - name, description, typed arguments - that a model can request. The model decides when to call it; the application executes it and returns the result.
How do you design a good tool?
One clear job, a descriptive name, a docstring that says what it does and when to use it, typed arguments, and safe execution - validate input, return errors as results, never run model output as code.
Why is native tool calling more reliable than a text format like TOOL: ...?
The request arrives as structured data - name, arguments, id - instead of text a regex has to find. It still depends on a model trained for tool calling, and you still validate the arguments.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned