เรียก LLM API ด้วย PHP แบบเข้าใจทุกบรรทัด

หลายคนเริ่มต้นใช้ LLM ผ่าน SDK สำเร็จรูป ซึ่งสะดวก แต่พอมีปัญหา เช่น request ค้าง โดน rate limit หรือได้ response หน้าตาแปลก ๆ ก็มักไม่รู้ว่าข้างในเกิดอะไรขึ้น ความจริงการเรียก LLM API ส่วนใหญ่ก็คือ HTTP POST ที่ส่ง JSON ไปและรับ JSON กลับมา ไม่ต่างจากการเรียก payment gateway หรือ API ของขนส่ง บทความนี้จะพาเขียน client เรียก LLM ด้วย PHP และ Guzzle ทีละบรรทัด แบบไม่ผูกกับผู้ให้บริการรายใด โดยอ่าน endpoint ชื่อโมเดล และ key จาก environment พร้อมจัดการ timeout และ error ให้พร้อมใช้งานจริง
หน้าตาของ request และ response
ผู้ให้บริการแต่ละรายมีรูปแบบ JSON ต่างกันเล็กน้อย แต่โครงสร้างหลักคล้ายกันมาก คือมี
- Endpoint URL สำหรับสร้างคำตอบ เรียกด้วย method POST
- Header
Content-Type: application/jsonและ header สำหรับยืนยันตัวตน ส่วนใหญ่เป็นAuthorization: Bearer <key>แต่บางรายใช้ชื่อ header อื่น - Body ระบุชื่อโมเดล รายการข้อความ (messages) ที่มี role เช่น system, user, assistant และพารามิเตอร์อย่างจำนวน token สูงสุดหรือ temperature
- Response มีข้อความคำตอบ เหตุผลที่หยุดสร้างข้อความ และข้อมูลการใช้ token
ตัวอย่าง body ทั่วไปจะหน้าตาประมาณนี้
{
"model": "your-model-name",
"max_tokens": 500,
"messages": [
{ "role": "system", "content": "คุณคือผู้ช่วยตอบคำถามสินค้า ตอบสั้นและสุภาพ" },
{ "role": "user", "content": "สินค้านี้รับประกันกี่ปี" }
]
}
สำคัญ ชื่อ field ที่แน่นอน ทั้งฝั่ง request และ response ขึ้นกับเอกสารของผู้ให้บริการที่ใช้ ให้เปิด API reference ของเจ้านั้นประกอบเสมอ โค้ดในบทความนี้แยกส่วนที่ต่างกันไว้ในจุดเดียวเพื่อให้ปรับได้ง่าย
เตรียมโปรเจกต์และ config
ติดตั้ง Guzzle ด้วย Composer
composer require guzzlehttp/guzzle
ตั้งค่าใน environment (ตอน dev ใช้ไฟล์ .env ที่ไม่ commit)
LLM_API_URL=https://api.example-provider.com/v1/chat
LLM_MODEL=your-model-name
LLM_API_KEY=replace-with-real-key
เหตุผลที่ไม่ hardcode มีสามข้อ ข้อแรก key จะได้ไม่หลุดเข้า Git ข้อสอง เปลี่ยนโมเดลหรือผู้ให้บริการได้โดยไม่ต้องแก้โค้ด ข้อสาม dev กับ production ใช้คนละค่าได้ เรื่องการจัดการ key อ่านเพิ่มได้ในบท "อย่าใส่ Secret ลงใน Prompt: จัดการ API Key ให้ปลอดภัย"
เขียน client ทีละส่วน
มาดูโค้ดเต็มก่อน แล้วค่อยอธิบายทีละช่วง
<?php
namespace common\components;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
final class LlmClient
{
private Client $http;
private string $url;
private string $model;
private string $apiKey;
public function __construct()
{
$this->url = self::env('LLM_API_URL');
$this->model = self::env('LLM_MODEL');
$this->apiKey = self::env('LLM_API_KEY');
$this->http = new Client([
'connect_timeout' => 5, // วินาที: รอเชื่อมต่อ
'timeout' => 60, // วินาที: รอทั้ง request
'http_errors' => false, // ตรวจ status code เอง
]);
}
public function ask(string $system, string $userText, int $maxTokens = 500): string
{
$payload = [
'model' => $this->model,
'max_tokens' => $maxTokens,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userText],
],
];
try {
$response = $this->http->post($this->url, [
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Accept' => 'application/json',
],
'json' => $payload, // Guzzle encode JSON และตั้ง Content-Type ให้
]);
} catch (ConnectException $e) {
throw new LlmException('เชื่อมต่อ LLM API ไม่ได้หรือหมดเวลา', 0, $e);
} catch (RequestException $e) {
throw new LlmException('ส่ง request ไม่สำเร็จ', 0, $e);
}
$status = $response->getStatusCode();
$body = (string)$response->getBody();
if ($status === 429) {
throw new LlmRateLimitException('ถูกจำกัดอัตราการเรียก', $status);
}
if ($status >= 400) {
// log เฉพาะส่วนต้นของ body เพื่อ debug ห้าม log header ที่มี key
\Yii::error("LLM HTTP {$status}: " . mb_substr($body, 0, 500), __METHOD__);
throw new LlmException("LLM API ตอบกลับด้วยสถานะ {$status}", $status);
}
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
return $this->extractText($data);
}
// จุดเดียวที่ต้องแก้ตามรูปแบบ response ของผู้ให้บริการ
private function extractText(array $data): string
{
$text = $data['choices'][0]['message']['content'] ?? null;
if (!is_string($text)) {
throw new LlmException('รูปแบบ response ไม่ตรงที่คาดไว้');
}
return $text;
}
private static function env(string $name): string
{
$v = $_ENV[$name] ?? getenv($name);
if ($v === false || $v === null || $v === '') {
throw new \RuntimeException("Missing environment variable: {$name}");
}
return (string)$v;
}
}
class LlmException extends \RuntimeException {}
class LlmRateLimitException extends LlmException {}
ในโปรเจกต์จริงควรแยก exception class ไว้คนละไฟล์ตาม PSR-4 ที่รวมไว้ตรงนี้เพื่อให้อ่านง่าย
อธิบายจุดสำคัญทีละบรรทัด
connect_timeout และ timeout
ค่าเริ่มต้นของ Guzzle คือไม่มี timeout หมายความว่าถ้าปลายทางค้าง PHP worker ของเราก็ค้างไปด้วย พอมี request ค้างพร้อมกันหลายตัว worker ของ PHP-FPM จะหมดและเว็บทั้งเว็บช้าตาม connect_timeout ควรสั้น เพราะการเชื่อมต่อปกติใช้เวลาไม่นาน ส่วน timeout ต้องเผื่อเวลาให้โมเดลสร้างคำตอบยาว ๆ ปรับตามความยาวคำตอบที่คาดไว้ และอย่าลืมว่า max_execution_time ของ PHP กับ timeout ของ nginx ต้องยาวพอด้วย ถ้าคำตอบยาวมากจนรอไม่ไหว ให้พิจารณา streaming ซึ่งอธิบายในบท "Stream คำตอบ AI ไปหน้าเว็บด้วย Server-Sent Events"
http_errors => false
ปกติ Guzzle จะ throw exception เมื่อได้ status 4xx/5xx เราปิดไว้เพื่อตรวจ status เองและแยกกรณีได้ชัด เช่น 429 แปลว่าถูกจำกัดอัตรา ควรรอแล้วลองใหม่ 401/403 แปลว่า key ผิดหรือไม่มีสิทธิ์ ลองใหม่ไปก็ไม่หาย ส่วน 5xx เป็นปัญหาฝั่งผู้ให้บริการ ลองใหม่ได้
'json' => $payload
Guzzle จะ json_encode ให้ และตั้ง header Content-Type: application/json อัตโนมัติ ภาษาไทยถูก encode เป็น \uXXXX ซึ่งถูกต้องตามมาตรฐาน JSON ไม่ต้องกังวล
json_decode กับ JSON_THROW_ON_ERROR
ถ้า response ไม่ใช่ JSON (เช่น proxy ตอบกลับเป็นหน้า HTML) เราอยากรู้ทันที ไม่ใช่ได้ null แล้วไปพังทีหลังแบบหาสาเหตุยาก
extractText แยกไว้ที่เดียว
ตัวอย่างนี้อ่านจากโครงสร้างแบบ choices[0].message.content ซึ่งพบในผู้ให้บริการหลายเจ้า แต่บางเจ้าใช้โครงสร้างอื่น ให้แก้ฟังก์ชันนี้ตามเอกสารของผู้ให้บริการ ส่วนอื่นของโค้ดไม่ต้องเปลี่ยน
อย่าลืมดูข้อมูลการใช้ token
response ส่วนใหญ่มีข้อมูลจำนวน token ที่ใช้ทั้งฝั่ง input และ output แนบมาด้วย ค่านี้คือตัวเลขที่ผู้ให้บริการใช้คิดเงิน ควรบันทึกลงตาราง log พร้อม user id ชื่อฟีเจอร์ และเวลาที่ใช้ตอบ เมื่อเวลาผ่านไปเราจะตอบคำถามสำคัญได้ เช่น ฟีเจอร์ไหนใช้ token มากที่สุด ผู้ใช้คนไหนใช้ผิดปกติ หรือ prompt เวอร์ชันใหม่ทำให้ต้นทุนต่อคำถามเพิ่มขึ้นหรือไม่ ข้อมูลชุดนี้ยังใช้ประกอบการตั้ง rate limit ต่อผู้ใช้ได้อย่างมีเหตุผล แทนการเดาตัวเลข
อีกเรื่องที่ควรตรวจคือเหตุผลที่โมเดลหยุดสร้างข้อความ ถ้าหยุดเพราะชนเพดาน max_tokens แปลว่าคำตอบถูกตัดกลางคัน ควรจัดการแยก เช่น แจ้งผู้ใช้ หรือเพิ่มเพดานสำหรับงานประเภทนั้น ไม่ใช่แสดงคำตอบครึ่งประโยคราวกับว่าจบแล้ว ชื่อ field ของทั้งสองเรื่องนี้ต่างกันไปตามผู้ให้บริการ ให้อ่านใน extractText() หรือเพิ่มเมธอดแยกในจุดเดียวกัน
Retry อย่างมีสติ
error บางประเภทหายเองถ้ารอสักครู่ ให้ retry เฉพาะกรณีที่สมเหตุสมผล คือ 429, 5xx และ timeout โดยเว้นระยะห่างแบบ exponential backoff และจำกัดจำนวนครั้ง
<?php
function askWithRetry(LlmClient $llm, string $system, string $text, int $maxAttempts = 3): string
{
$attempt = 0;
while (true) {
$attempt++;
try {
return $llm->ask($system, $text);
} catch (LlmException $e) {
$code = $e->getCode();
$retryable = $code === 0 || $code === 429 || $code >= 500;
if (!$retryable || $attempt >= $maxAttempts) {
throw $e;
}
// 1, 2, 4 วินาที บวกค่าสุ่มเล็กน้อยกัน request ชนกัน
usleep((2 ** ($attempt - 1)) * 1_000_000 + random_int(0, 250_000));
}
}
}
ข้อควรระวัง การ retry ภายใน web request ทำให้ผู้ใช้รอนานขึ้น ถ้างานไม่ต้องตอบทันที เช่น สรุปเอกสาร ควรย้ายไปทำใน queue (เช่น yii2-queue) แล้วแจ้งผลทีหลัง
เรียกใช้ใน Controller อย่างปลอดภัย
<?php
public function actionAsk()
{
$question = trim((string)Yii::$app->request->post('question', ''));
if ($question === '' || mb_strlen($question) > 2000) {
throw new \yii\web\BadRequestHttpException('คำถามต้องยาว 1-2000 ตัวอักษร');
}
try {
$answer = (new \common\components\LlmClient())->ask(
'คุณคือผู้ช่วยตอบคำถามสินค้า ตอบสั้น สุภาพ เป็นภาษาไทย',
$question
);
} catch (\common\components\LlmException $e) {
Yii::warning($e->getMessage(), __METHOD__);
$answer = 'ขออภัย ระบบตอบคำถามไม่พร้อมใช้งานชั่วคราว';
}
return $this->render('ask', ['answer' => $answer]);
}
ใน view ให้แสดงด้วย <?= Html::encode($answer) ?> เสมอ เพราะ output ของโมเดลถือเป็นข้อมูลที่ไม่น่าไว้ใจ และอย่าแสดงข้อความ error ดิบจาก API ให้ผู้ใช้เห็น
Checklist ก่อนใช้งานจริง
- endpoint, model, key มาจาก environment ไม่มีค่าใด hardcode
- ตั้ง
connect_timeoutและtimeoutทุกครั้ง - แยกการจัดการ 429, 4xx, 5xx และ timeout ออกจากกัน
- log แค่ข้อมูลที่จำเป็น ไม่ log header หรือ key
- จำกัดความยาว input และกำหนด
max_tokensเพื่อคุมค่าใช้จ่าย - escape output ก่อนแสดงผล
สรุป
- การเรียก LLM API คือ HTTP POST ที่ส่งและรับ JSON รูปแบบ field ที่แน่นอนให้ยึดตามเอกสารของผู้ให้บริการ
- อ่าน endpoint ชื่อโมเดล และ key จาก environment เพื่อความปลอดภัยและเปลี่ยนผู้ให้บริการได้ง่าย
- timeout เป็นเรื่องบังคับ ไม่ใช่ตัวเลือก เพราะ request ที่ค้างจะกิน worker ของทั้งเว็บ
- retry เฉพาะ error ที่หายเองได้ ใช้ backoff และจำกัดจำนวนครั้ง
ลองทำ
นำคลาส LlmClient ไปใส่ในโปรเจกต์ Yii2 ของคุณ สร้าง console command php yii llm/ping "สวัสดี" ที่เรียก ask() แล้วพิมพ์คำตอบ จากนั้นลองทดสอบกรณีผิดพลาดสามแบบ คือ ตั้ง key ผิด ตั้ง URL ไปที่ host ที่ไม่มีอยู่ และตั้ง timeout เป็น 1 วินาที ดูว่าแต่ละกรณีได้ exception และ log ตามที่คาดไว้หรือไม่
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ