uttapen

Tool calling در عمل: وصل کردن مدل به دیتابیس فروشگاهت

آموزش عملی function calling به فارسی: تعریف ابزار با JSON Schema، حلقهٔ اجرا در Python، استریم tool_calls در Node و قواعد امنیتی اتصال مدل به دیتابیس.

۱۶ شهریور ۱۴۰۵ · ۱۱ دقیقه مطالعه · تیم یوتاپن

مدل زبانی به‌تنهایی نمی‌داند کفش نایک در انبار تو موجود است یا نه؛ اگر بپرسی، یک عدد قشنگ حدس می‌زند. Tool calling (یا function calling) همان سازوکاری است که به مدل اجازه می‌دهد به‌جای حدس زدن، بگوید «این تابع را با این پارامترها صدا بزن و نتیجه را به من بده». در این مقاله یک دستیار فروشگاه می‌سازیم که به دیتابیس واقعی وصل است، حلقهٔ کامل اجرا را در Python می‌نویسیم، نسخهٔ استریم‌شده را در Node می‌بینیم، و بعد سراغ چیزی می‌رویم که در بیشتر آموزش‌ها نیست: چطور این کار را طوری انجام بدهی که مدل نتواند به دیتابیس تو آسیب بزند.

سازوکار در یک نگاه

Tool calling چهار مرحله دارد و مدل فقط در دو تای آن‌ها نقش دارد. اول، تو همراه پیام‌ها فهرستی از ابزارها می‌فرستی: نام، توضیح، و اسکیمای JSON پارامترها. دوم، مدل به‌جای متن، یک یا چند tool_call برمی‌گرداند که می‌گوید کدام ابزار با چه آرگومان‌هایی. سوم، خودت آن تابع را در کد خودت اجرا می‌کنی؛ مدل هیچ دسترسی مستقیمی به هیچ‌چیز ندارد. چهارم، نتیجه را به‌عنوان پیامی با نقش tool به تاریخچه اضافه می‌کنی و دوباره مدل را صدا می‌زنی تا با آن اطلاعات جواب نهایی را بسازد. این چرخه می‌تواند چند بار تکرار شود.

نکتهٔ اطمینان‌بخش: مدل فقط JSON تولید می‌کند. اگر تابعی را که مدل خواسته اجرا نکنی، هیچ اتفاقی نمی‌افتد. تمام قدرت و تمام مسئولیت در کد توست.

فروشگاه نمونه

دو ابزار می‌سازیم: جستجوی محصول و وضعیت سفارش. دیتابیس اینجا SQLite در حافظه است تا مثال خودکفا باشد؛ در پروژهٔ واقعی همان Postgres یا MySQL خودت را با ORM یا کوئری آماده وصل می‌کنی. مدل openai/gpt-5-mini است چون برای این نوع کار (تشخیص نیت و پر کردن آرگومان) کافی و ارزان است.

import json, os, sqlite3
from openai import OpenAI

client = OpenAI(
    base_url="https://api.uttapen.ir/v1",
    api_key=os.environ["UTTAPEN_API_KEY"],
)

# --- دیتابیس فروشگاه (اینجا SQLite در حافظه؛ در عمل همان Postgres/MySQL خودت) ---
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE products(id INTEGER PRIMARY KEY, name TEXT, price_toman INTEGER, stock INTEGER);
INSERT INTO products VALUES
 (1, 'Nike Air Max 90 — کفش ورزشی مردانه', 4200000, 3),
 (2, 'Nike Pegasus 41 — کفش دویدن', 3850000, 0),
 (3, 'Puma RS-X — کتانی روزمره', 2900000, 12);
CREATE TABLE orders(id INTEGER PRIMARY KEY, phone TEXT, status TEXT, tracking TEXT);
INSERT INTO orders VALUES (1042, '09120000001', 'shipped', 'TRK-778-IR'), (1043, '09120000001', 'processing', NULL);
""")

def search_products(query: str, in_stock_only: bool = False) -> list[dict]:
    sql = "SELECT id, name, price_toman, stock FROM products WHERE name LIKE ?"
    if in_stock_only:
        sql += " AND stock > 0"
    rows = db.execute(sql, (f"%{query}%",)).fetchall()
    return [{"id": r[0], "name": r[1], "price_toman": r[2], "stock": r[3]} for r in rows]

def get_order_status(order_id: int, phone: str) -> dict:
    row = db.execute("SELECT status, tracking FROM orders WHERE id = ? AND phone = ?", (order_id, phone)).fetchone()
    if not row:
        return {"error": "order_not_found"}
    return {"order_id": order_id, "status": row[0], "tracking": row[1]}

دقت کن که هر دو تابع با پارامتر bind شده کوئری می‌زنند و get_order_status بدون تطابق شمارهٔ موبایل چیزی برنمی‌گرداند. این‌ها تصادفی نیست؛ در بخش امنیت برمی‌گردیم.

تعریف ابزارها

اسکیما همان JSON Schema است. توضیح‌ها (description) را فارسی و دقیق بنویس؛ مدل از همین توضیح‌ها می‌فهمد کِی کدام ابزار را صدا بزند و چه چیزی در هر پارامتر بگذارد:

TOOLS = [
    {"type": "function", "function": {
        "name": "search_products",
        "description": "جستجوی محصولات فروشگاه بر اساس بخشی از نام (برند یا مدل). قیمت‌ها به تومان است.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "عبارت جستجو؛ نام برند یا مدل، مثلاً Nike"},
                "in_stock_only": {"type": "boolean", "description": "فقط کالاهای موجود در انبار"},
            },
            "required": ["query"],
        },
    }},
    {"type": "function", "function": {
        "name": "get_order_status",
        "description": "وضعیت یک سفارش. فقط وقتی شمارهٔ سفارش و موبایل مشتری هر دو داده شده باشد صدا بزن.",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "integer"}, "phone": {"type": "string"}},
            "required": ["order_id", "phone"],
        },
    }},
]
FUNCS = {"search_products": search_products, "get_order_status": get_order_status}

جملهٔ «فقط وقتی هر دو داده شده باشد صدا بزن» در توضیح ابزار دوم، رفتار مدل را عوض می‌کند: اگر کاربر فقط شمارهٔ سفارش بدهد، مدل به‌جای حدس زدن موبایل، از او می‌پرسد. توضیح ابزار، بخشی از پرامپت است و همان‌قدر اهمیت دارد.

حلقهٔ اجرا

این بخش قلب کار است و همان چیزی است که در آموزش‌های سطحی ناقص می‌ماند. مدل ممکن است در یک دور چند ابزار بخواهد، یا بعد از دیدن نتیجه، ابزار دیگری بخواهد؛ پس یک حلقه با سقف تعداد دور لازم است:

messages = [
    {"role": "system", "content": "تو دستیار فروشگاه کفش هستی. هر اطلاعاتی دربارهٔ محصول یا سفارش را فقط از ابزارها بگیر و هیچ قیمت یا موجودی‌ای را حدس نزن. کوتاه و به فارسی جواب بده."},
    {"role": "user", "content": "کفش نایک موجود دارید؟ قیمتش چنده؟"},
]

for round_no in range(5):  # سقف تعداد دور؛ بدون آن ممکن است در حلقه بیفتی
    resp = client.chat.completions.create(
        model="openai/gpt-5-mini",
        messages=messages,
        tools=TOOLS,
        tool_choice="auto",
    )
    msg = resp.choices[0].message
    messages.append(msg)  # پیام assistant همراه tool_calls باید در تاریخچه بماند
    if not msg.tool_calls:
        print("پاسخ نهایی:", msg.content)
        break
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        print(f"→ مدل خواست: {call.function.name}({args})")
        result = FUNCS[call.function.name](**args)
        print(f"← نتیجهٔ دیتابیس: {json.dumps(result, ensure_ascii=False)}")
        messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False)})

خروجی اجرای این کد (متن پاسخ نهایی برای نمایش است؛ دو خط اول عیناً از اجرای ما):

→ مدل خواست: search_products({'query': 'Nike', 'in_stock_only': True})
← نتیجهٔ دیتابیس: [{"id": 1, "name": "Nike Air Max 90 — کفش ورزشی مردانه", "price_toman": 4200000, "stock": 3}]
پاسخ نهایی: بله، Nike Air Max 90 مردانه موجود است، قیمتش ۴٬۲۰۰٬۰۰۰ تومان و ۳ عدد در انبار داریم. Pegasus 41 فعلاً تمام شده.

سه نکته در حلقه: messages.append(msg) باید خود شیء پیام assistant (با tool_calls) باشد، نه فقط متنش؛ وگرنه مدل در دور بعد نمی‌داند چه خواسته بود و خطای اعتبارسنجی می‌گیری. tool_call_id در پیام tool باید دقیقاً همان call.id باشد. و اگر مدل چند ابزار هم‌زمان خواست (parallel tool calls)، برای هر کدام یک پیام tool جدا می‌فرستی، به همان ترتیب.

استریم و tool calling با هم

در رابط چت معمولاً استریم می‌کنی، و آن‌وقت tool_calls تکه‌تکه می‌آید: اول نام و شناسه، بعد آرگومان‌ها به‌صورت رشته‌های ناقص JSON که باید بر اساس index به هم بچسبانی. این کد Node در تست ما آرگومان‌ها را درست جمع کرد:

import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.uttapen.ir/v1", apiKey: process.env.UTTAPEN_API_KEY });

const stream = await client.chat.completions.create({
  model: "google/gemini-2.5-flash",
  messages: [{ role: "user", content: "سفارش 1042 با شمارهٔ 09120000001 کجاست؟" }],
  tools: [{ type: "function", function: { name: "get_order_status", parameters: {
    type: "object", properties: { order_id: { type: "integer" }, phone: { type: "string" } }, required: ["order_id", "phone"] } } }],
  stream: true,
});

// tool_call در استریم تکه‌تکه می‌آید؛ بر اساس index جمعش می‌کنیم
const calls = {};
for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta;
  for (const tc of delta?.tool_calls ?? []) {
    const c = (calls[tc.index] ??= { id: "", name: "", arguments: "" });
    if (tc.id) c.id = tc.id;
    if (tc.function?.name) c.name = tc.function.name;
    if (tc.function?.arguments) c.arguments += tc.function.arguments;
  }
  if (chunk.choices[0]?.finish_reason) console.log("finish_reason:", chunk.choices[0].finish_reason);
}
for (const c of Object.values(calls)) console.log(c.name, JSON.parse(c.arguments), "id:", c.id);
finish_reason: tool_calls
get_order_status { order_id: 1042, phone: '09120000001' } id: call_243c6fe0b12f

finish_reason برابر tool_calls یعنی مدل منتظر نتیجهٔ ابزار است، نه اینکه گفتگو تمام شده. تا وقتی این مقدار را می‌بینی، در رابط چیزی به کاربر نشان نده جز یک نشانگر «در حال بررسی». آرگومان‌ها را فقط بعد از پایان استریم JSON.parse کن؛ وسط راه JSON ناقص است.

امنیت: مدل را به دیتابیس وصل نکن، به توابع وصل کن

این بخش را از همه جدی‌تر بگیر. الگوهایی که در اینترنت می‌بینی و باید از آن‌ها بترسی: ابزاری به نام run_sql که مدل خودش کوئری می‌نویسد. این یعنی هر کسی که با چت‌بات تو حرف می‌زند، به‌طور غیرمستقیم به دیتابیس تو دسترسی دارد و فقط باید مدل را قانع کند. قواعدی که ما رعایت می‌کنیم:

  1. ابزارها باریک و مشخص باشند. search_products(query) نه query_db(sql). هر ابزار یک کار مشخص با پارامترهای محدود. مدل فقط از میان همین‌ها انتخاب می‌کند.
  2. آرگومان‌ها را اعتبارسنجی کن، حتی اگر اسکیما داده‌ای. اسکیما راهنمای مدل است، نه تضمین. طول رشته، بازهٔ عدد، مقادیر مجاز؛ همه را قبل از اجرا با Pydantic یا zod چک کن. برای فیلترها کوئری با پارامتر bind شده بنویس، هرگز رشته را در SQL بچسبان.
  3. دسترسی با کاربر واقعی محدود شود، نه با آنچه مدل می‌گوید. در مثال ما شمارهٔ موبایل پارامتر ابزار است تا مثال ساده بماند، ولی در محصول واقعی شناسهٔ کاربر از session خودت می‌آید و در تابع تزریق می‌شود؛ کاربر (و مدل) نمی‌تواند سفارش کس دیگری را بپرسد.
  4. اتصال دیتابیس فقط‌خواندنی برای ابزارهای خواندنی. یک نقش جدا در Postgres با SELECT روی جدول‌های لازم. اگر جایی اشتباه کردی، سقف خسارت مشخص است.
  5. کنش‌های نوشتنی، تأیید انسانی می‌خواهند. «لغو سفارش» را مستقیم اجرا نکن؛ ابزار فقط یک درخواست لغو ثبت کند که کاربر با کلیک تأیید می‌کند، یا حداقل idempotent باشد تا اگر مدل دو بار صدایش زد، دو بار اجرا نشود.
  6. نتیجهٔ ابزار را کوتاه و ساخت‌یافته برگردان. به‌جای کل ردیف با بیست ستون، فقط فیلدهای لازم. هم ارزان‌تر است (نتیجهٔ ابزار توکن ورودی دور بعد است) هم مدل کمتر گیج می‌شود.
  7. خطا را به مدل بده، نه به کاربر. اگر تابع exception داد، به‌جای crash، یک JSON مثل {"error": "timeout"} به‌عنوان نتیجه برگردان تا مدل خودش محترمانه توضیح بدهد؛ کاربر نباید stack trace ببیند.

اشتباه‌های رایج که خودمان هم کرده‌ایم

نام ابزاری که وجود ندارد. مدل‌ها گاهی ابزاری را صدا می‌زنند که تعریف نکرده‌ای، مخصوصاً وقتی توضیح ابزارها شبیه هم است. FUNCS[call.function.name] در کد بالا در این حالت KeyError می‌دهد؛ در production به‌جای crash، یک نتیجهٔ {"error": "unknown_tool"} برگردان تا مدل خودش را اصلاح کند.

آرگومان JSON خراب. به‌ندرت، ولی پیش می‌آید که arguments JSON معتبر نباشد (مثلاً یک ویرگول اضافه). json.loads را در try بگذار و همان الگوی خطا را برگردان. مدل‌های جدیدتر با strict: true در تعریف تابع، خروجی را دقیقاً مطابق اسکیما تضمین می‌کنند؛ اگر مدل انتخابی‌ات پشتیبانی می‌کند (در supported_parameters با structured_outputs می‌آید)، روشنش کن.

تاریخچهٔ پر از نتیجهٔ ابزار. بعد از چند دور، پیام‌های tool تاریخچه را سنگین می‌کنند و در هر دور دوباره فرستاده می‌شوند. برای گفتگوهای طولانی، نتایج قدیمی را با یک خلاصهٔ یک‌خطی جایگزین کن یا فقط دورهای اخیر را نگه دار.

اجرای موازی بدون فکر. وقتی مدل چند ابزار هم‌زمان می‌خواهد، وسوسه می‌شوی همه را موازی اجرا کنی. برای ابزارهای خواندنی خوب است؛ برای ابزارهایی که حالت را تغییر می‌دهند، ترتیب مهم است و باید یکی‌یکی اجرا شوند.

هزینه و انتخاب مدل

هر دور از حلقه یک درخواست کامل است و تعریف ابزارها در هر دور به‌عنوان توکن ورودی شمرده می‌شود. دو ابزار مثال ما حدود ۲۰۰ توکن است؛ با ده ابزار و توضیح‌های مفصل، به هزار توکن می‌رسد که در هر دور تکرار می‌شود. یک گفتگوی سه‌دوره با ده ابزار روی GPT-5 mini هنوز زیر ۲۰۰ تومان می‌ماند، ولی روی مدل‌های گران چند برابر می‌شود؛ جزئیات در مقالهٔ قیمت‌ها.

از ۴۳۰ مدل کاتالوگ، ۳۶۳ مدل پارامتر tools را پشتیبانی می‌کنند. قبل از انتخاب، در پاسخ GET /v1/models فیلد supported_parameters را چک کن؛ اگر tools در آن نبود، درخواستت با 400 برمی‌گردد. در تجربهٔ ما برای تشخیص نیت و پر کردن آرگومان‌های ساده، Gemini 2.5 Flash و GPT-5 mini دقیق و سریع‌اند؛ برای جریان‌های چندمرحله‌ای پیچیده که مدل باید نتیجهٔ چند ابزار را با هم ترکیب کند، Claude Sonnet 4.5 کمتر اشتباه می‌کند. با tool_choice می‌توانی رفتار را کنترل کنی: "auto" پیش‌فرض، "none" برای غیرفعال کردن موقت، "required" وقتی حتماً باید ابزاری صدا زده شود، یا نام یک ابزار مشخص برای اجبار. راهنمای کامل پارامترها در مستندات tool calling است.

قبل از production

حلقه را با ورودی‌های بد تست کن: کاربری که می‌گوید «سفارش من کجاست» بدون شماره، کاربری که شمارهٔ سفارش دیگری را می‌دهد، کاربری که می‌خواهد مدل SQL بنویسد. رفتار درست در هر سه، پرسیدن یا رد کردن مؤدبانه است، نه اجرای چیزی. لاگ هر tool_call با آرگومان‌هایش و شناسهٔ درخواست (هدر X-Uttapen-Request-Id) را نگه دار؛ وقتی مشتری گفت «ربات گفت موجود است ولی نبود»، همین لاگ می‌گوید مدل چه پرسیده و دیتابیس چه جواب داده. اگر می‌خواهی همین مثال را اجرا کنی، یک کلید بساز و کد بالا را بدون تغییر اجرا کن؛ دیتابیس داخل خود اسکریپت ساخته می‌شود و اولین خروجی معمولاً زیر دو ثانیه می‌آید.

مقاله‌های مرتبط

می‌خواهی همین کد را اجرا کنی؟

با شماره موبایل ثبت‌نام کن، کیف پول را شارژ کن و کلید بگیر. ساخت کلید API ←