ข้ามไปยังเนื้อหาหลัก
ProgrammerThailand

AI & Vibe Coding

Tool Use / Function Calling: ให้ AI เรียกฟังก์ชันของระบบเรา

Tool Use / Function Calling: ให้ AI เรียกฟังก์ชันของระบบเรา

LLM เก่งเรื่องเข้าใจภาษาและเรียบเรียงคำตอบ แต่ไม่รู้ว่าสินค้าในร้านเราเหลือกี่ชิ้น ราคาวันนี้เท่าไร หรือคำสั่งซื้อของลูกค้าถึงไหนแล้ว ความสามารถที่เรียกว่า Tool Use หรือ Function Calling ทำให้โมเดล "ขอ" ให้ระบบของเราเรียกฟังก์ชันที่เตรียมไว้ แล้วนำผลลัพธ์กลับไปใช้ตอบผู้ใช้ บทความนี้อธิบายแนวคิดของ tool schema วงจรการเรียกและตอบ วิธี validate argument การคุมสิทธิ์ และตัวอย่างระบบค้นหาสินค้าด้วย PHP/Yii2 ที่นำไปปรับใช้ได้

แนวคิด: โมเดลไม่ได้รันโค้ด แค่ขอให้เรารัน

ความเข้าใจผิดที่พบบ่อยคือคิดว่าโมเดลเข้ามารันฟังก์ชันในระบบเราเอง ความจริงคือ

  1. เราบอกโมเดลว่ามี tool อะไรให้ใช้ แต่ละตัวรับ argument อะไร
  2. โมเดลอ่านคำถามของผู้ใช้ แล้วตัดสินใจว่าจะตอบเลย หรือจะขอเรียก tool พร้อม argument
  3. โค้ดของเรา เป็นคนตรวจ argument ตรวจสิทธิ์ แล้วรันฟังก์ชันจริง
  4. เราส่งผลลัพธ์กลับไปให้โมเดล โมเดลนำไปเรียบเรียงเป็นคำตอบ หรือขอเรียก tool เพิ่ม

ข้อดีของการออกแบบแบบนี้คือ อำนาจการตัดสินใจขั้นสุดท้ายว่าจะรันอะไรอยู่ที่โค้ดของเราเสมอ ซึ่งเป็นจุดที่เราใส่การควบคุมความปลอดภัยได้

Tool schema: คำอธิบายที่โมเดลอ่าน

tool แต่ละตัวประกอบด้วยชื่อ คำอธิบาย และ JSON Schema ของ argument ผู้ให้บริการส่วนใหญ่ใช้แนวคิดเดียวกัน แม้ชื่อ field ที่ห่อ schema จะต่างกันบ้าง (ตรวจตามเอกสารของผู้ให้บริการ)

{
  "name": "search_products",
  "description": "ค้นหาสินค้าที่เปิดขายในร้าน ใช้เมื่อผู้ใช้ถามหาสินค้า ราคา หรือของที่มีในสต็อก คืนรายการไม่เกิน 10 รายการ",
  "parameters": {
    "type": "object",
    "properties": {
      "keyword":   { "type": "string", "description": "คำค้น เช่น ชื่อสินค้าหรือประเภท" },
      "category":  { "type": "string", "enum": ["shoes", "shirts", "bags"] },
      "max_price": { "type": "number", "minimum": 0, "description": "ราคาสูงสุดเป็นบาท" },
      "in_stock":  { "type": "boolean" }
    },
    "required": ["keyword"],
    "additionalProperties": false
  }
}

เคล็ดลับการเขียน schema ที่ดี

  • description สำคัญมาก โมเดลใช้ข้อความนี้ตัดสินใจว่าจะเรียก tool ไหนเมื่อไร เขียนให้ชัดว่าใช้เมื่อไรและคืนอะไร
  • ใช้ enum เมื่อค่าเป็นชุดจำกัด ลดโอกาสที่โมเดลจะส่งค่าที่ระบบไม่รู้จัก
  • ระบุหน่วย เช่น ราคาเป็นบาท วันที่เป็นรูปแบบ YYYY-MM-DD
  • tool น้อยแต่ชัด ดีกว่า tool เยอะที่หน้าที่ทับซ้อนกัน ถ้ามี search_products กับ find_items ที่ทำคล้ายกัน โมเดลจะเลือกสลับไปมา
  • อย่าใส่ argument ที่ระบบรู้อยู่แล้ว เช่น user_id ให้โค้ดดึงจาก session เอง

วงจรการเรียกและตอบ

บทสนทนาที่มี tool ไม่ได้จบในการเรียก API ครั้งเดียว แต่เป็นลูป ลองดูตัวอย่างผู้ใช้ถามว่า "มีรองเท้าวิ่งราคาไม่เกิน 2,000 บาทไหม"

  1. เราส่ง messages พร้อมรายการ tool ไปให้โมเดล
  2. โมเดลตอบกลับว่าขอเรียก search_products ด้วย {"keyword":"รองเท้าวิ่ง","category":"shoes","max_price":2000} พร้อม id ของการเรียก
  3. เรารันฟังก์ชัน ได้รายการสินค้า แล้วเพิ่มข้อความ 2 ส่วนต่อท้าย messages คือ ข้อความของโมเดลที่ขอเรียก tool และผลลัพธ์ของ tool ที่อ้าง id เดิม
  4. ส่ง messages ทั้งหมดกลับไปให้โมเดลอีกรอบ
  5. โมเดลอาจตอบเป็นข้อความสุดท้าย หรือขอเรียก tool อื่นต่อ เช่น เช็กสต็อกตามไซซ์

ต้องมี เพดานจำนวนรอบ เสมอ ป้องกันกรณีโมเดลเรียก tool วนไม่จบ ซึ่งเปลืองทั้งเวลาและค่าใช้จ่าย

<?php
// โครงลูปแบบ vendor-neutral: callModel() และรูปแบบ message ต้องปรับตามผู้ให้บริการ
$messages = [
    ['role' => 'system', 'content' => 'คุณคือผู้ช่วยร้านค้า ใช้ tool เพื่อดูข้อมูลสินค้าจริงเท่านั้น'],
    ['role' => 'user', 'content' => $question],
];

$maxTurns = 5;
for ($turn = 0; $turn < $maxTurns; $turn++) {
    $reply = $llm->callModel($messages, $toolSchemas); // คืน ['text' => ?, 'tool_calls' => [...], 'raw' => ...]

    if (empty($reply['tool_calls'])) {
        return $reply['text']; // โมเดลตอบเสร็จแล้ว
    }

    $messages[] = $reply['raw']; // ข้อความของโมเดลที่ขอเรียก tool
    foreach ($reply['tool_calls'] as $call) {
        $result = $toolRunner->run($call['name'], $call['arguments']);
        $messages[] = $llm->toolResultMessage($call['id'], $result);
    }
}

return 'ขออภัย ไม่สามารถหาคำตอบได้ในตอนนี้';

Validate argument: อย่าเชื่อสิ่งที่โมเดลส่งมา

argument จากโมเดลอาจผิดรูปแบบ ขาด field เกินช่วง หรือถูกบิดโดย prompt injection ให้ปฏิบัติเหมือนข้อมูลจากฟอร์มที่ผู้ใช้กรอก ใน Yii2 ใช้ DynamicModel ได้สะดวก

<?php
namespace common\ai\tools;

use Yii;
use yii\base\DynamicModel;
use yii\db\Query;

final class SearchProductsTool
{
    public function __invoke(array $args): array
    {
        // กำหนดทุก attribute ไว้ก่อน (ค่า null) แล้วรับเฉพาะ key ที่ประกาศไว้ใน schema
        $defaults = ['keyword' => null, 'category' => null, 'max_price' => null, 'in_stock' => null];
        $m = DynamicModel::validateData(
            array_merge($defaults, array_intersect_key($args, $defaults)),
            [
                ['keyword', 'required'],
                ['keyword', 'string', 'max' => 100],
                ['category', 'in', 'range' => ['shoes', 'shirts', 'bags']],
                ['max_price', 'number', 'min' => 0, 'max' => 1000000],
                ['in_stock', 'boolean', 'strict' => true, 'trueValue' => true, 'falseValue' => false],
            ]
        );
        if ($m->hasErrors()) {
            // ส่ง error กลับให้โมเดล เพื่อให้แก้ argument แล้วลองใหม่ได้
            return ['error' => 'invalid_arguments', 'details' => $m->getFirstErrors()];
        }

        $query = (new Query())
            ->select(['id', 'name', 'price', 'stock'])
            ->from('{{%product}}')
            ->where(['is_published' => true])
            ->andWhere(['ilike', 'name', $m->keyword])
            ->andFilterWhere(['category' => $m->category])
            ->andFilterWhere(['<=', 'price', $m->max_price])
            ->orderBy(['price' => SORT_ASC])
            ->limit(10);

        if ($m->in_stock === true) {
            $query->andWhere(['>', 'stock', 0]);
        }

        return ['products' => $query->all()];
    }
}

จุดที่ควรสังเกต

  • array_intersect_key ตัด argument ที่ไม่ได้ประกาศไว้ทิ้ง และ $defaults ทำให้ทุก attribute มีอยู่เสมอแม้โมเดลไม่ได้ส่งมา
  • Query builder ของ Yii2 ใช้ parameter binding ให้ และเงื่อนไข ilike ของ Yii2 จะ escape อักขระ % และ _ ในคำค้นให้เอง
  • is_published = true ถูกบังคับในโค้ด โมเดลไม่มีทางขอดูสินค้าที่ยังไม่เปิดขายได้
  • limit(10) จำกัดขนาดผลลัพธ์ ประหยัด token และกันข้อมูลออกไปมากเกินจำเป็น
  • เมื่อ argument ผิด ส่ง error ที่อ่านเข้าใจได้กลับไปให้โมเดล โมเดลมักแก้แล้วเรียกใหม่ได้เอง

ออกแบบผลลัพธ์ของ tool

ผลลัพธ์ที่ส่งกลับไปให้โมเดลสำคัญไม่แพ้ schema ของ argument เพราะโมเดลจะใช้มันเรียบเรียงคำตอบให้ผู้ใช้ หลักการที่ใช้ได้ดีมีดังนี้

  • คืนเฉพาะ field ที่จำเป็น ไม่ต้องส่งทุกคอลัมน์ของตาราง ยิ่งข้อมูลน้อย โมเดลยิ่งโฟกัสถูก ประหยัด token และลดโอกาสข้อมูลภายใน เช่น ต้นทุนสินค้า หลุดไปอยู่ในคำตอบ
  • ใช้ชื่อ field ที่สื่อความหมาย เช่น price_thb ดีกว่า p1 โมเดลไม่ได้เห็น schema ของฐานข้อมูลเรา
  • บอกเมื่อไม่พบข้อมูลอย่างชัดเจน คืน {"products": [], "note": "ไม่พบสินค้าที่ตรงเงื่อนไข"} ดีกว่าคืนค่าว่างเปล่า เพราะโมเดลจะได้ไม่เดาคำตอบเอง
  • error ต้องอ่านแล้วแก้ได้ เช่น บอกว่า category ต้องเป็นค่าใดบ้าง โมเดลจะแก้ argument แล้วเรียกใหม่ได้ในรอบถัดไป
  • อย่าคืน stack trace หรือข้อความ error ของฐานข้อมูล ให้ log ไว้ฝั่ง server แล้วส่งข้อความสั้น ๆ ที่ปลอดภัยกลับไปแทน

สิทธิ์และความปลอดภัย

tool คือประตูจาก AI เข้าสู่ระบบของเรา ต้องคุมเหมือน API endpoint ทั่วไป

  • Allowlist ชื่อ tool ตัว runner รันได้เฉพาะ tool ที่ลงทะเบียนไว้ ห้ามใช้ชื่อจากโมเดลไปเรียก method แบบ dynamic ตรง ๆ
  • ใช้ตัวตนของผู้ใช้จริง tool อย่าง get_my_orders ต้องดึง user id จาก Yii::$app->user และตรวจด้วย RBAC ตามปกติ
  • ให้ tool ตามบริบท แชตหน้าร้านสำหรับลูกค้าทั่วไปได้แค่ tool อ่านข้อมูลสาธารณะ ส่วนแชตหลังบ้านของพนักงานได้ tool มากกว่า
  • tool ที่เปลี่ยนข้อมูลต้องให้คนยืนยัน เช่น ยกเลิกคำสั่งซื้อหรือคืนเงิน ให้ tool สร้างคำขอที่รออนุมัติแทนการทำทันที
  • Log ทุกการเรียก เก็บชื่อ tool, argument, ผู้ใช้ และเวลา ไว้ตรวจย้อนหลัง
  • ผลลัพธ์ของ tool เป็นข้อมูล ถ้าคืนข้อความที่ผู้ใช้กรอกเอง เช่น รีวิวสินค้า ข้อความนั้นอาจมีคำสั่งแฝง ซึ่งเป็นช่องทางของ indirect prompt injection
<?php
final class ToolRunner
{
    /** @param array<string, callable> $tools */
    public function __construct(private array $tools) {}

    public function run(string $name, mixed $rawArgs): array
    {
        if (!isset($this->tools[$name])) {
            return ['error' => 'unknown_tool'];
        }
        $args = is_string($rawArgs) ? json_decode($rawArgs, true) : $rawArgs;
        if (!is_array($args)) {
            return ['error' => 'arguments_must_be_object'];
        }
        \Yii::info(['tool' => $name, 'args' => $args, 'user' => \Yii::$app->user->id], 'ai.tool');
        return ($this->tools[$name])($args);
    }
}

// แชตหน้าร้าน: ให้แค่ tool อ่านข้อมูลสาธารณะ
$runner = new ToolRunner([
    'search_products' => new \common\ai\tools\SearchProductsTool(),
]);

สรุป

  • โมเดลไม่ได้รันโค้ดเอง แค่ขอเรียก tool ตาม schema ส่วนโค้ดของเราเป็นผู้ตัดสินและรันจริง
  • description และ JSON Schema ที่ชัดเจน มี enum และหน่วย ช่วยให้โมเดลเลือก tool และส่ง argument ได้ถูก
  • วงจรการเรียกเป็นลูป ต้องมีเพดานจำนวนรอบเสมอ
  • validate argument เหมือน input จากผู้ใช้ บังคับเงื่อนไขธุรกิจในโค้ด และใช้สิทธิ์ของผู้ใช้จริงตาม session

ลองทำ

เพิ่ม tool ตัวที่สองชื่อ get_product_detail ที่รับ product_id (integer) แล้วคืนชื่อ ราคา สต็อก และรายละเอียดของสินค้าที่เปิดขายเท่านั้น เขียน schema, validate ด้วย DynamicModel และลงทะเบียนใน ToolRunner จากนั้นทดสอบด้วยการเรียก $runner->run() ตรง ๆ โดยไม่ต้องผ่านโมเดล ด้วย argument สี่แบบ คือ id ที่ถูกต้อง, id ของสินค้าที่ยังไม่เปิดขาย, id ติดลบ และ argument ที่เป็นข้อความ "1 OR 1=1" ตรวจว่าทุกกรณีได้ผลลัพธ์ที่ปลอดภัย

ติดปัญหาตอนทำตาม? ถามในชุมชน

ความคิดเห็น (0)

ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ