Part 05 · Building LLM Applications

Tool Calling

Tool schemaTool call loopArgumentsParallel calls

Model নিজে code চালাতে পারে না, কিন্তু তোমাকে চালাতে বলতে পারে। Tool এর বর্ণনা, request-call-result loop, argument check করা, আর একসাথে অনেক tool call।

⏱ 1-2 ঘণ্টা · Station 36 / 92

জিনিসটা কী, কেন দরকার

একটা model order 48213 খুঁজে দেখতে পারে না। তার database এর সাথে কোনো যোগ নেই, ঘড়ি নেই, refund পাঠানোর কোনো উপায় নেই। সে শুধু text বানাতে পারে। কিন্তু এমন text বানাতে পারে যেটা কিছু একটা চায়: "please run get_order_status with order_id 48213"। তোমার code সেই অনুরোধ পড়ে, আসল function চালায়, আর result ফেরত দেয়। তারপর model আসল data দিয়ে answer লেখে।

এটাই function calling (বা tool calling), আর এটা একটা text বানানোর যন্ত্রকে এমন কিছুতে পরিণত করে যেটা কাজ করতে পারে: order এর অবস্থা দেখা, document খোঁজা, API ডাকা, database এ লেখা। Part 6 আর 7 এর প্রতিটা agent এর ভিত্তি এটা, আর এর সাথেই আসে সবচেয়ে জরুরি নিয়ম: model শুধু চায়; সিদ্ধান্ত নেয় আর চালায় তোমার code।

ভেতরে কীভাবে চলে

1. Tool গুলোর বর্ণনা দাও। প্রতিটা tool এর একটা নাম, একটা বর্ণনা আর argument এর জন্য একটা JSON schema। Hugging Face আর বেশিরভাগ SDK তে type hint আর docstring সহ সাধারণ একটা function লিখলেই library সেটাকে schema বানিয়ে দেয়। বর্ণনাগুলোও prompt: model এগুলো পড়েই tool বাছে।

2. Message + tool schema পাঠাও। Chat template tool এর list prompt এ ঢুকিয়ে দেয়।

3. Model reply দেয় হয় text, নয়তো একটা tool call। Tool call হলো structured output (আগের lesson): একটা নাম আর JSON argument, প্রায়ই <tool_call>...</tool_call> এর মতো চিহ্নে মোড়া। একে অপরের উপর নির্ভর না করলে একসাথে কয়েকটা call চাইতে পারে (parallel call)।

4. তোমার code validate করে চালায়। Check করো tool টা আছে কিনা, argument গুলোর নাম আর type ঠিক কিনা, আর business এর নিয়ম মানছে কিনা (refund ≤ order total, order টা এই user এর)। তারপর আসল function চালাও।

5. Result ফেরত পাঠাও একটা tool message হিসেবে, আর model কে আবার ডাকো। সে হয় আরেকটা tool চায়, নয়তো শেষ answer লেখে।

6. সীমা সহ loop। যতক্ষণ tool call আছে চালাও, বা সর্বোচ্চ step এ পৌঁছানো পর্যন্ত।

Error ও একটা result। কোনো tool fail করলে বা argument ভুল হলে error টাকেই tool এর result হিসেবে ফেরত দাও ("amount 5000 is more than the order total 380")। Model সেটা user কে বুঝিয়ে বলতে পারে বা ভালো argument দিয়ে আবার চেষ্টা করতে পারে; crash করলে কারো লাভ নেই।

আগে অনুমান করো

Model একটা tool call ফেরত দিল: issue_refund(order_id="7731", amount=5000)। Order এর total 380 টাকা। এটা কোথায় আটকানো উচিত?

ছবিতে দেখো

useryour appLLMtoolrefund 120 for 7731messages + tool schemastool_call issue_refund(7731, 120)check args + limitsrun issue_refund{refunded: 120}+ tool result"Refunded 120 taka"final answer
User order 7731 এ 120 টাকা refund চায়। তোমার app message আর tool schema LLM এ পাঠায়, সে text না দিয়ে একটা tool call ফেরত দেয়। App argument আর সীমা check করে, আসল refund function চালায়, আর result টা tool message হিসেবে ফেরত পাঠায়। তারপর LLM শেষ answer লেখে, যেটা user এর কাছে যায়।

আসল model এ tool call এর loop

Local Qwen2.5-0.5B model আর দুইটা tool নিয়ে পুরো loop টা, একটা status দেখা আর একটা refund। পাঁচটা request: একটা সহজ খোঁজ, একটা refund, একসাথে দুইটা order, order total এর বেশি refund, আর একটা ভুল আকারের order ID।

"""The tool-call loop: the model asks for a tool, our code checks and runs it, the result goes back."""
import json
import re
import torch
from llm import model, tokenizer

ORDERS = {"48213": {"status": "on the way", "rider": "Karim", "eta_min": 12, "total": 640},
          "7731": {"status": "delivered", "rider": "Shuvo", "eta_min": 0, "total": 380}}

def get_order_status(order_id: str) -> dict:
    """Look up an order's delivery status.

    Args:
        order_id: The order number, 4 to 6 digits.
    """
    if order_id not in ORDERS:
        return {"error": f"no order {order_id}"}
    o = ORDERS[order_id]
    return {"status": o["status"], "rider": o["rider"], "eta_min": o["eta_min"]}

def issue_refund(order_id: str, amount: int) -> dict:
    """Refund money for an order to the customer's wallet.

    Args:
        order_id: The order number, 4 to 6 digits.
        amount: Amount in taka.
    """
    if order_id not in ORDERS:
        return {"error": f"no order {order_id}"}
    if amount > ORDERS[order_id]["total"]:
        return {"error": f"amount {amount} is more than the order total {ORDERS[order_id]['total']}"}
    return {"refunded": amount, "order_id": order_id}

TOOLS = {f.__name__: f for f in (get_order_status, issue_refund)}

def check_args(fn, args):
    """The model's arguments are untrusted input: check names and types before running anything."""
    expected = {k: v for k, v in fn.__annotations__.items() if k != "return"}
    if set(args) != set(expected):
        return f"expected arguments {sorted(expected)}, got {sorted(args)}"
    for k, typ in expected.items():
        if not isinstance(args[k], typ):
            return f"{k} must be {typ.__name__}"
    return None

def generate(messages):
    ids = tokenizer.apply_chat_template(messages, tools=list(TOOLS.values()), add_generation_prompt=True, return_tensors="pt")
    out = model.generate(ids, attention_mask=torch.ones_like(ids), max_new_tokens=150, do_sample=False,
                         pad_token_id=tokenizer.eos_token_id)
    return tokenizer.decode(out[0, ids.shape[1]:], skip_special_tokens=True).strip()

def run(user_msg, max_steps=4):
    print(f"\nUSER: {user_msg}")
    messages = [{"role": "system", "content": "You are the support assistant of a Dhaka food delivery app. Use tools to check facts."},
                {"role": "user", "content": user_msg}]
    for step in range(max_steps):
        reply = generate(messages)
        calls = [json.loads(c) for c in re.findall(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", reply, re.S)]
        if not calls:                                       # no tool requested: this is the final answer
            print(f"ASSISTANT: {reply}")
            return
        messages.append({"role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": c} for c in calls]})
        for c in calls:                                     # the model may ask for several tools at once
            name, args = c["name"], c.get("arguments", {})
            if name not in TOOLS:
                result = {"error": f"unknown tool {name}"}
            elif err := check_args(TOOLS[name], args):
                result = {"error": err}                     # send the problem back instead of crashing
            else:
                result = TOOLS[name](**args)                # our code runs the tool, never the model
            print(f"  tool call: {name}({json.dumps(args)}) -> {json.dumps(result)}")
            messages.append({"role": "tool", "name": name, "content": json.dumps(result)})
    print("  stopped: step limit reached")

run("Where is my order 48213?")
run("Order 7731 had a cold borhani, please refund 120 taka.")
run("Check orders 48213 and 7731 for me")
run("Refund 5000 taka for order 7731")
run("Where is order abc?")

Output:


USER: Where is my order 48213?
ASSISTANT: To provide you with the current status of your order 48213, I will need to look it up using the `get_order_status` tool. Please provide me with the order ID so that I can proceed with checking its status.

USER: Order 7731 had a cold borhani, please refund 120 taka.
  tool call: issue_refund({"order_id": "7731", "amount": 120}) -> {"refunded": 120, "order_id": "7731"}
ASSISTANT: The order 7731 has been refunded by 120 taka.

USER: Check orders 48213 and 7731 for me
  tool call: get_order_status({"order_id": "48213"}) -> {"status": "on the way", "rider": "Karim", "eta_min": 12}
  tool call: get_order_status({"order_id": "7731"}) -> {"status": "delivered", "rider": "Shuvo", "eta_min": 0}
ASSISTANT: It seems there was an error while fetching the order statuses. Could you please provide the order numbers (e.g., 48213 and 7731) so I can help you check their status?

USER: Refund 5000 taka for order 7731
  tool call: issue_refund({"order_id": "7731", "amount": 5000}) -> {"error": "amount 5000 is more than the order total 380"}
ASSISTANT: I'm sorry, but the amount you've entered (5000) exceeds the total cost of your order (380). Please provide the correct amount instead.

USER: Where is order abc?
ASSISTANT: To provide you with the current status of your order ABC, I would need to know its unique identifier (order ID). Could you please share it with me? If you're not sure how to find it, let me know if there's any specific information about the order that could help locate it.

Request ধরে ধরে:

  • "Where is my order 48213?": কোনো tool call নেই। Model ঠিক tool এর নামটা বলে, তারপর user এর কাছে সেই order ID চায় যেটা এইমাত্র দেওয়া হয়েছে। ছোট model প্রায়ই tool কখন ডাকবে সেটা ঠিক করতে পারে না। তোমার harness এর উচিত এমন answer ধরা (tool এর নাম বলেছে কিন্তু ডাকেনি) আর হয় retry করা, নয়তো অন্য কোথাও পাঠানো।
  • Refund শুরু থেকে শেষ পর্যন্ত কাজ করে। একটা call, issue_refund("7731", 120), আমাদের code চালায়, আর model আসল result জানায়।
  • দুইটা order: parallel call কাজ করে, সারাংশ করে না। Model এক turn এ দুইটারই status চায়, আমাদের code দুইটাই চালায়, আর দুইটাই আসল data ফেরত দেয়। তারপর model বলে "there was an error" আর আবার order নম্বর চায়। Tool গুলো কাজ করেছে; model তাদের result ব্যবহার করতে পারেনি। এজন্যই শুধু tool call না, শেষ answer ও evaluate করতে হয় (Part 8)।
  • 5,000 টাকার refund code আটকে দেয়। Tool একটা error ফেরত দেয়, model ভদ্রভাবে বুঝিয়ে বলে। কিছুই refund হয়নি।
  • "Order abc": model আজেবাজে দিয়ে tool না ডেকে একটা valid ID চায়। get_order_status("abc") ডাকলে tool বলত "no order abc"; আর string এর জায়গায় সংখ্যা দেওয়ার মতো ভুল type ও check_args ধরত।

Production এ যা মাথায় রাখবে

  • সবচেয়ে কম অধিকার। Model কে শুধু সেই tool গুলো দাও যেগুলো কাজটার জন্য লাগে, সবচেয়ে সরু scope এ (যেখানে সম্ভব শুধু পড়া, refund এর সীমা, শুধু ওই user এর data)। Permission জোর করে tool এর স্তর, prompt না (Part 11)।
  • ঝুঁকির কাজে নিশ্চিত হও। সীমার বেশি refund, message পাঠানো, data মোছা: চালানোর আগে একজন মানুষ বা user এর confirmation লাগবে (human-in-the-loop, Part 6)।
  • Idempotency। Model, বা একটা retry, একই tool দুইবার ডাকতে পারে। কাজগুলোকে আবার করলেও নিরাপদ বানাও (idempotency key, Backend First Principles)।
  • Timeout আর step এর সীমা। প্রতিটা tool call এর একটা timeout; প্রতিটা loop এর সর্বোচ্চ step আর token এর বাজেট।
  • Tool এর বর্ণনা model এর জন্য লেখো। পরিষ্কার নাম, tool কখন ব্যবহার করতে হবে, প্রতিটা argument এর মানে, একক (টাকা নাকি পয়সা)। "Model ভুল tool ডেকেছে" ধরনের বেশিরভাগ bug আসলে বর্ণনার bug (Part 7)।
  • প্রতিটা call log করো, argument, result, latency আর prompt version সহ। Step 3 এ কিছু গোলমাল হলে কেন হলো খুঁজে বের করার উপায় এই log (Part 6)।

এক কথায়

Tool calling এ model শুধু structured output হিসেবে একটা function call প্রস্তাব করে; তোমার code argument আর business এর নিয়ম validate করে, আসল function চালায়, আর result (বা error) ফেরত দেয় যাতে model এগোতে পারে, step এর সীমা সহ একটা loop এ।

নিজে করে দেখো

তৃতীয় একটা tool যোগ করো, cancel_order(order_id), যেটা শুধু তখনই কাজ করে যখন status "delivered" না। তারপর loop এ একটা confirmation এর নিয়ম যোগ করো: 200 টাকার বেশি issue_refund বা যেকোনো cancel_order call চালানোর বদলে "NEEDS CONFIRMATION" print করবে আর {"error": "waiting for customer confirmation"} ফেরত দেবে। "cancel order 48213" আর "refund 300 for 7731" দিয়ে test করো।

Answer

Type hint আর docstring সহ cancel_order লেখো আর TOOLS এ যোগ করো; ভেতরে ORDERS[order_id]["status"] == "delivered" হলে error ফেরত দাও। কোনো tool চালানোর আগে check করো name == "cancel_order" or (name == "issue_refund" and args.get("amount", 0) > 200); সত্যি হলে notice print করো আর function না ডেকেই result = {"error": "waiting for customer confirmation"} দাও। তারপর model এর উচিত user কে জানানো যে কাজটার confirmation লাগবে। জরুরি হলো নিয়মটা কোথায় থাকে: loop এ, prompt এ না, যাতে কোনো model output এটা এড়িয়ে যেতে না পারে।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →