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

LLM เก่งเรื่องเข้าใจภาษาและเรียบเรียงคำตอบ แต่ไม่รู้ว่าสินค้าในร้านเราเหลือกี่ชิ้น ราคาวันนี้เท่าไร หรือคำสั่งซื้อของลูกค้าถึงไหนแล้ว ความสามารถที่เรียกว่า Tool Use หรือ Function Calling ทำให้โมเดล "ขอ" ให้ระบบของเราเรียกฟังก์ชันที่เตรียมไว้ แล้วนำผลลัพธ์กลับไปใช้ตอบผู้ใช้ บทความนี้อธิบายแนวคิดของ tool schema วงจรการเรียกและตอบ วิธี validate argument การคุมสิทธิ์ และตัวอย่างระบบค้นหาสินค้าด้วย PHP/Yii2 ที่นำไปปรับใช้ได้
แนวคิด: โมเดลไม่ได้รันโค้ด แค่ขอให้เรารัน
ความเข้าใจผิดที่พบบ่อยคือคิดว่าโมเดลเข้ามารันฟังก์ชันในระบบเราเอง ความจริงคือ
- เราบอกโมเดลว่ามี tool อะไรให้ใช้ แต่ละตัวรับ argument อะไร
- โมเดลอ่านคำถามของผู้ใช้ แล้วตัดสินใจว่าจะตอบเลย หรือจะขอเรียก tool พร้อม argument
- โค้ดของเรา เป็นคนตรวจ argument ตรวจสิทธิ์ แล้วรันฟังก์ชันจริง
- เราส่งผลลัพธ์กลับไปให้โมเดล โมเดลนำไปเรียบเรียงเป็นคำตอบ หรือขอเรียก 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 บาทไหม"
- เราส่ง messages พร้อมรายการ tool ไปให้โมเดล
- โมเดลตอบกลับว่าขอเรียก
search_productsด้วย{"keyword":"รองเท้าวิ่ง","category":"shoes","max_price":2000}พร้อม id ของการเรียก - เรารันฟังก์ชัน ได้รายการสินค้า แล้วเพิ่มข้อความ 2 ส่วนต่อท้าย messages คือ ข้อความของโมเดลที่ขอเรียก tool และผลลัพธ์ของ tool ที่อ้าง id เดิม
- ส่ง messages ทั้งหมดกลับไปให้โมเดลอีกรอบ
- โมเดลอาจตอบเป็นข้อความสุดท้าย หรือขอเรียก 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)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ