Tools & Tool Calling
An LLM can write, but it cannot check today’s weather, convert currency at a fixed rate or calculate an EMI reliably. Tools are Python functions the LLM can ask you to run. The key surprise: the LLM never runs the tool itself. It replies “please call emi_calculator with these arguments”, your code runs it, and sends the result back. That loop is the heart of every agent.
In this session
- What a tool is, and who really runs it
- @tool: name, docstring and type hints become the schema
- Pydantic args_schema for validation
- StructuredTool and ready community tools
- bind_tools and ai_msg.tool_calls
- The manual loop: LLM → tool → LLM
01The big idea: the manager and the assistant
Think of a bank manager and an assistant. The manager (LLM) cannot leave the cabin, but writes notes: “Get the balance of account 1234.” The assistant (your code) does the work and brings back the answer. The manager reads it and writes the next note, or the final reply. The manager decides what to do; the assistant actually does it.
@tool reads three things to build the schema the LLM sees, so write them carefully:
| Part of your function | Becomes | Example |
|---|---|---|
| function name | tool name | get_weather |
| docstring | description: when to use it | “Get the current weather of an Indian city.” |
type hints / args_schema | argument schema, validated | city: str, principal: float |
Real output of the notebook cell multiply.name, multiply.description, multiply.args.
02bind_tools and the manual loop
tools = [multiply, get_weather, convert_currency, emi_calculator]
llm_with_tools = llm.bind_tools(tools) # the LLM now sees the 4 schemas
ai_msg = llm_with_tools.invoke("What is the weather in Mumbai?")
ai_msg.content # usually empty
ai_msg.tool_calls # [{'name': 'get_weather', 'args': {'city': 'Mumbai'}, 'id': '...'}]| Tool design tip | Why |
|---|---|
| One clear job per tool, clear name | the LLM picks tools by name and description |
| Docstring says when to use it | “Use for loan EMI questions” beats “calculates stuff” |
| Typed arguments (str, int, float, Literal…) | wrong inputs are rejected before your code runs |
| Return short text or JSON | long outputs waste tokens |
| Handle errors inside, return a message | the agent can recover instead of crashing |
03Hands-on: run it here
All the notebook’s tools run for real. The stand-in LLM asks for the same two tool calls a real model would, and your loop runs them. Notice the Pydantic schema rejecting "five lakh" as a principal. (USD = ₹84 and EUR = ₹91 are the notebook’s fixed example rates, not live rates.)
from standins import ScriptedModel, call
from langchain.tools import tool
from langchain_core.tools import StructuredTool
from langchain.messages import HumanMessage, ToolMessage
from pydantic import BaseModel, Field
@tool
def get_weather(city: str) -> str:
"""Get the current weather of an Indian city."""
data = {"pune": "28°C, cloudy", "mumbai": "31°C, humid", "delhi": "35°C, sunny"}
return data.get(city.lower(), "25°C, clear")
@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
"""Convert money between INR, USD and EUR."""
rate_to_inr = {"INR": 1, "USD": 84, "EUR": 91}
value = amount * rate_to_inr[from_currency.upper()] / rate_to_inr[to_currency.upper()]
return f"{amount} {from_currency} = {round(value, 2)} {to_currency}"
class EMIInput(BaseModel):
principal: float = Field(description="loan amount in INR")
annual_rate: float = Field(description="interest rate per year in percent")
years: int = Field(description="loan tenure in years")
@tool(args_schema=EMIInput)
def emi_calculator(principal: float, annual_rate: float, years: int) -> str:
"""Calculate the monthly EMI of a loan."""
r = annual_rate / 12 / 100; n = years * 12
return f"Monthly EMI = Rs {round(principal * r * (1 + r) ** n / ((1 + r) ** n - 1))}"
print("schema the LLM sees:", get_weather.name, "|", get_weather.description, "|", get_weather.args)
print("emi args :", list(emi_calculator.args))
print("direct call :", emi_calculator.invoke({"principal": 500000, "annual_rate": 9, "years": 5}))
print("StructuredTool :", StructuredTool.from_function(func=lambda a, b: a + b, name="add", description="Add two numbers").invoke({"a": 5, "b": 7}))
try:
emi_calculator.invoke({"principal": "five lakh", "annual_rate": 9, "years": 5})
except Exception as e:
print("bad args rejected :", type(e).__name__)
# The manual loop. The stand-in LLM asks for 2 tools, then answers (a real LLM decides this itself).
tools = [get_weather, convert_currency, emi_calculator]
llm_with_tools = ScriptedModel([
call("convert_currency", {"amount": 1500, "from_currency": "USD", "to_currency": "INR"}, "c1"),
call("emi_calculator", {"principal": 126000, "annual_rate": 9, "years": 3}, "c2"),
"1500 USD is Rs 1,26,000. For that amount at 9% for 3 years, the EMI is Rs 4,007 per month.",
])
tools_by_name = {t.name: t for t in tools}
messages = [HumanMessage("What is 1500 USD in INR, and what's the EMI for that INR amount at 9% for 3 years?")]
for step in range(5):
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
if not ai_msg.tool_calls: break
for c in ai_msg.tool_calls:
result = tools_by_name[c["name"]].invoke(c["args"])
print(f"🔧 {c['name']}({c['args']}) → {result}")
messages.append(ToolMessage(content=str(result), tool_call_id=c["id"]))
print("final:", ai_msg.content)
print("message types:", [m.type for m in messages])RuleModel or ScriptedModel from standins.py), so every output below is from a real run of this code. In Colab you swap one line: llm = ChatGroq(model="openai/gpt-oss-20b").04Hands-on: Ajeetkumar’s Colab notebook
groq_key with your free key from console.groq.com, switch on Notebook access, then run the cells from the top.05Practice tasks
- Make a
get_stock_price(symbol)tool with fake data and ask “Compare TCS and Infosys”. - Make a
send_email(to, subject, body)tool that only prints. - Use the Wikipedia tool + LLM to write a 5-line bio of any scientist.
Try these in the same Colab notebook after you finish the lab. Change one thing at a time and look at the output again.
06Summary
- A tool is a Python function the LLM can ask to call; your code runs it.
@toolturns name, docstring and type hints into a schema.args_schema(Pydantic) validates arguments.bind_toolsgives tools to the LLM; readai_msg.tool_calls.- Send results back as
ToolMessage; repeat until no tool calls. Agents automate this loop.
07Quick quiz
1. Who actually runs the tool?
2. What does the LLM read to decide when to use a tool?
3. Where do you find the requested calls?
4. How do you send a tool result back?
5. When does the manual loop stop?
08References
- LangChain: Tools
- Tool calling concepts
- Ajeetkumar’s notebook: LC_12_Tools_and_Tool_Calling.ipynb (download)