LangChain Hands-on · Module 3 · Lab 12

Tools & Tool Calling

⏱ 20 min + hands-on🎯 Beginner🖼 1 diagram🧪 2 live demos🐍 Python code

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.

UserquestionLLM“call get_weather(city=Pune)”Your coderuns the functionToolMessage“28°C, cloudy”LLMfinal answer
The LLM only asks. Your code runs the tool and sends the result back.

@tool reads three things to build the schema the LLM sees, so write them carefully:

Part of your functionBecomesExample
function nametool nameget_weather
docstringdescription: when to use it“Get the current weather of an Indian city.”
type hints / args_schemaargument schema, validatedcity: str, principal: float
('multiply', 'Multiply two numbers.', {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}})

Real output of the notebook cell multiply.name, multiply.description, multiply.args.

02bind_tools and the manual loop

bind_tools.py
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': '...'}]
Demo · The manual loop (from the hands-on run below)
Tool design tipWhy
One clear job per tool, clear namethe 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 JSONlong outputs waste tokens
Handle errors inside, return a messagethe 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.)

lab12_tools.py
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])
schema the LLM sees: get_weather | Get the current weather of an Indian city. | {'city': {'title': 'City', 'type': 'string'}} emi args : ['principal', 'annual_rate', 'years'] direct call : Monthly EMI = Rs 10379 StructuredTool : 12 bad args rejected : ValidationError 🔧 convert_currency({'amount': 1500, 'from_currency': 'USD', 'to_currency': 'INR'}) → 1500.0 USD = 126000.0 INR 🔧 emi_calculator({'principal': 126000, 'annual_rate': 9, 'years': 3}) → Monthly EMI = Rs 4007 final: 1500 USD is Rs 1,26,000. For that amount at 9% for 3 years, the EMI is Rs 4,007 per month. message types: ['human', 'ai', 'tool', 'ai', 'tool', 'ai']
Runs here without any API key. A small stand-in plays the LLM (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

Colab setup (same in every notebook): click 🔑 Secrets in the Colab sidebar, add groq_key with your free key from console.groq.com, switch on Notebook access, then run the cells from the top.
Demo · Notebook player · Lab 12

05Practice tasks

  1. Make a get_stock_price(symbol) tool with fake data and ask “Compare TCS and Infosys”.
  2. Make a send_email(to, subject, body) tool that only prints.
  3. 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

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

Created by Ajeetkumar