استفاده از API هوش مصنوعی در PHP و لاراول؛ نمونه کد کامل
اگر توسعهدهنده لاراول باشید و بخواهید هوش مصنوعی را وارد پروژهتان کنید، احتمالاً همان مسیر همیشگی را رفتهاید: جستجوی فارسی، رسیدن به ده صفحه «آموزش API نویسی با PHP» که ربطی به مدلهای زبانی ندارند، و در نهایت کپی کردن یک نمونهکد پایتون که باید خودتان ترجمهاش کنید. مشکل هم فقط ترجمهٔ سه خط نیست؛ چیزی که در پروژهٔ واقعی شما را زمین میزند، تایماوت درخواستهای طولانی، قفل شدن worker، مدیریت خطای ۴۲۹ و نبود گزارش مصرف است. این مقاله همان بخشهاست: پیکربندی درست، کلاینت، صف، استریم و ثبت هزینه، با کدی که میتوانید مستقیم داخل یک پروژهٔ لاراول ۱۲ بگذارید.
همهٔ نمونهها با درگاه API هوش مصنوعی نِت اَرز نوشته شدهاند که با فرمت OpenAI سازگار است؛ یعنی اگر فردا تصمیم گرفتید مستقیم به ارائهدهندهٔ اصلی وصل شوید، فقط base_url و کلید عوض میشود و بقیهٔ کد دستنخورده میماند. امروز روی این درگاه مدلهای OpenAI فعال است و بقیهٔ ارائهدهندهها در حال اتصالاند؛ فهرست بهروز مدلهای قابل فراخوانی در صفحهٔ مدلها است.
پیکربندی: کلید کجا بماند و base_url کجا تعریف شود؟
اولین اشتباه رایج، نوشتن کلید داخل کنترلر یا کامیت کردن آن در گیت است. کلید را در .env بگذارید و از طریق config() بخوانید تا در محیط عملیاتی کش پیکربندی هم کار کند.
# .env
NETARZ_AI_KEY=sk-ntz-v1-xxxxxxxxxxxxxxxx
NETARZ_AI_BASE=https://netarz.ir/api/ai/v1
NETARZ_AI_MODEL=gpt-5-mini
// config/services.php
'netarz_ai' => [
'key' => env('NETARZ_AI_KEY'),
'base' => env('NETARZ_AI_BASE', 'https://netarz.ir/api/ai/v1'),
'model' => env('NETARZ_AI_MODEL', 'gpt-5-mini'),
],
هرگز env() را بیرون از فایلهای config صدا نزنید؛ بعد از php artisan config:cache مقدارش null برمیگردد و همان لحظهای که سایت روی سرور میرود، درخواستها با خطای ۴۰۱ رد میشوند. این یکی از شایعترین «چرا روی لوکال کار میکرد» های همین حوزه است.
کلاینت: یک سرویس کوچک بهجای Http::post پراکنده
بهجای اینکه در هر کنترلر یک Http::post بنویسید، یک کلاس سرویس بسازید تا تایماوت، تلاش مجدد و هدرها یکجا کنترل شوند. کلاینت داخلی لاراول همهٔ چیزی است که لازم دارید و نیازی به نصب SDK جداگانه نیست.
// app/Services/NetarzAi.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class NetarzAi
{
public function client(): PendingRequest
{
return Http::baseUrl(config('services.netarz_ai.base'))
->withToken(config('services.netarz_ai.key'))
->acceptJson()
->connectTimeout(10)
->timeout(60)
->retry(3, 1500, function ($exception) {
if ($exception instanceof ConnectionException) {
return true;
}
return $exception instanceof RequestException
&& in_array($exception->response->status(), [429, 500, 502, 503, 504], true);
}, throw: false);
}
/** @param array<int, array{role: string, content: string}> $messages */
public function chat(array $messages, ?string $model = null, float $temperature = 0.3): array
{
$response = $this->client()->post('/chat/completions', [
'model' => $model ?? config('services.netarz_ai.model'),
'messages' => $messages,
'temperature' => $temperature,
]);
$response->throw();
return $response->json();
}
}
استفادهاش در یک کنترلر سه خط است:
public function ask(Request $request, NetarzAi $ai)
{
$data = $ai->chat([
['role' => 'system', 'content' => 'You are a support agent. Always answer in Persian, briefly.'],
['role' => 'user', 'content' => $request->string('question')->toString()],
]);
return response()->json([
'answer' => $data['choices'][0]['message']['content'],
]);
}
دو نکتهٔ ریز که بعداً به دردتان میخورد: اول، retry با throw: false یعنی بعد از سه تلاش ناموفق، خودتان تصمیم میگیرید با پاسخ خطا چه کنید. دوم، پیام system را فارسی ننویسید مگر لازم باشد؛ دستور انگلیسی کوتاه معمولاً پایدارتر رعایت میشود، ولی خروجی را میتوانید صریح فارسی بخواهید. اصول نوشتن همین دستورها را در راهنمای پرامپتنویسی جمع کردهایم.
کدام کار داخل request بماند و کدام برود به صف؟
مدلهای زبانی کند هستند؛ نه بهخاطر شبکه، بلکه چون تولید متن ذاتاً زمان میبرد. اگر یک درخواست ۴۰ ثانیهای را داخل چرخهٔ عادی HTTP نگه دارید، هم کاربر منتظر صفحهٔ سفید میماند و هم یک worker پیاچپیافپیام را قفل کردهاید. قاعدهٔ ساده این است:
| نوع کار | زمان معمول | تایماوت پیشنهادی | کجا اجرا شود |
|---|---|---|---|
| طبقهبندی یا برچسبگذاری کوتاه | ۱ تا ۳ ثانیه | ۱۵ ثانیه | داخل همان request |
| پاسخ چت به کاربر | ۳ تا ۲۰ ثانیه | ۶۰ ثانیه | request، اما حتماً با استریم |
| خلاصهسازی سند بلند | ۳۰ تا ۱۸۰ ثانیه | ۳۰۰ ثانیه | صف |
| تولید انبوه (مثلاً هزار توضیح محصول) | دهها دقیقه | بهازای هر آیتم | صف، با تکهبندی و محدودیت همزمانی |
نمونهٔ یک Job که خلاصهٔ یک سند را میسازد و اگر ارائهدهنده موقتاً پاسخ نداد، با فاصلهٔ فزاینده دوباره تلاش میکند:
// app/Jobs/SummarizeDocument.php
namespace App\Jobs;
use App\Models\Document;
use App\Services\NetarzAi;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SummarizeDocument implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 300;
public array $backoff = [15, 60, 180];
public function __construct(public Document $document)
{
}
public function handle(NetarzAi $ai): void
{
$data = $ai->chat([
['role' => 'system', 'content' => 'Summarize the document in Persian, as five short bullets.'],
['role' => 'user', 'content' => mb_substr($this->document->text, 0, 40000)],
], model: 'gpt-5-mini');
$this->document->update([
'summary' => $data['choices'][0]['message']['content'],
]);
}
}
php artisan queue:work --queue=ai --timeout=320 --tries=1
دقت کنید که --timeout ورکر باید از $timeout خود Job بزرگتر باشد، وگرنه ورکر پیش از تمام شدن کار، پروسه را میکشد و شما یک خطای گیجکنندهٔ «job timed out» میگیرید که هیچ ربطی به مدل ندارد. برای صف هم SummarizeDocument::dispatch($document)->onQueue('ai'); کافی است تا کارهای سنگین هوش مصنوعی صف مستقل خودشان را داشته باشند و پشت ایمیلها گیر نکنند.
استریم پاسخ به مرورگر در لاراول
وقتی کاربر منتظر یک پاسخ ۲۰ ثانیهای است، تفاوت بین «اسپینر میچرخد» و «کلمهها یکییکی میآیند» تفاوت بین رها کردن صفحه و ماندن است. لاراول با response()->stream این کار را انجام میدهد و کلاینت Http هم حالت stream دارد:
public function stream(Request $request, NetarzAi $ai)
{
$question = $request->string('question')->toString();
return response()->stream(function () use ($ai, $question) {
$response = $ai->client()
->withOptions(['stream' => true])
->timeout(120)
->post('/chat/completions', [
'model' => config('services.netarz_ai.model'),
'stream' => true,
'messages' => [['role' => 'user', 'content' => $question]],
]);
$body = $response->toPsrResponse()->getBody();
$buffer = '';
while (! $body->eof()) {
$buffer .= $body->read(1024);
while (($break = strpos($buffer, "\n")) !== false) {
$line = trim(substr($buffer, 0, $break));
$buffer = substr($buffer, $break + 1);
if (! str_starts_with($line, 'data: ')) {
continue;
}
$payload = substr($line, 6);
if ($payload === '[DONE]') {
echo "data: [DONE]\n\n";
break 2;
}
$delta = json_decode($payload, true)['choices'][0]['delta']['content'] ?? '';
if ($delta !== '') {
echo 'data: ' . json_encode(['t' => $delta], JSON_UNESCAPED_UNICODE) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache, no-transform',
'X-Accel-Buffering' => 'no',
]);
}
هدر X-Accel-Buffering: no را جدی بگیرید؛ بدون آن انجینایکس کل پاسخ را بافر میکند و کاربر بعد از بیست ثانیه سکوت، یکباره همهٔ متن را میبیند — یعنی دقیقاً همان چیزی که میخواستید حذف کنید. سمت مرورگر و جزئیات رندر کردن توکنها، لغو درخواست و نگهداشتن تاریخچه را در آموزش چتبات سایت با پاسخ استریمی جداگانه و کامل نوشتهایم.
ثبت مصرف: بدون گزارش، هزینه غافلگیرتان میکند
هر پاسخ غیراستریم یک بخش usage دارد. همان را ذخیره کنید تا بدانید کدام قابلیت محصولتان چقدر خرج میتراشد. یک جدول ساده کافی است:
Schema::create('ai_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('purpose', 40);
$table->string('model', 60);
$table->unsignedInteger('prompt_tokens')->default(0);
$table->unsignedInteger('completion_tokens')->default(0);
$table->timestamps();
$table->index(['purpose', 'created_at']);
});
AiUsage::create([
'user_id' => $request->user()?->id,
'purpose' => 'support_reply',
'model' => $data['model'] ?? config('services.netarz_ai.model'),
'prompt_tokens' => $data['usage']['prompt_tokens'] ?? 0,
'completion_tokens' => $data['usage']['completion_tokens'] ?? 0,
]);
در حالت استریم، بستهٔ usage فقط وقتی میآید که در بدنهٔ درخواست 'stream_options' => ['include_usage' => true] را اضافه کنید؛ در غیر این صورت باید تعداد توکنها را تخمین بزنید یا از گزارش مصرف پنل بخوانید. روش محاسبهٔ هزینه از روی توکن را با مثال در توکن چیست و هزینهٔ API چطور حساب میشود آوردهایم.
خطاهایی که در محیط عملیاتی میبینید
- ۴۰۱: کلید غلط یا خواندهنشده. اول
php artisan config:clearو بعد صحت مقدارNETARZ_AI_KEYرا چک کنید. - ۴۰۴ روی نام مدل: مدل در فهرست فعال شما نیست. نام دقیق را از فهرست مدلها بردارید؛ نامهای حدسی مثل نسخههای تاریخدار همیشه پذیرفته نمیشوند.
- ۴۲۹: از سقف نرخ عبور کردهاید. همان الگوی
retryبالا با فاصلهٔ فزاینده مشکل را حل میکند؛ اگر مرتب تکرار شد، همزمانی Job ها را باWithoutOverlappingیا محدودسازی صف کم کنید. - تایماوت cURL: معمولاً یعنی کار را در جای اشتباه اجرا کردهاید؛ به جدول بالا برگردید و کار را به صف ببرید.
- پاسخ ناقص: مدل به سقف
max_tokensخورده است. مقدارfinish_reasonرا بخوانید؛ اگرlengthبود، سقف را بالا ببرید یا ورودی را کوتاهتر کنید.
تست بنویسید بدون اینکه هزینه بدهید
یکی از دلایلی که تیمها از افزودن هوش مصنوعی میترسند این است که فکر میکنند تستشان یا شکننده میشود یا گران. هیچکدام لازم نیست. کلاینت Http لاراول قابلیت جعل پاسخ دارد، پس در تستها اصلاً به شبکه وصل نمیشوید: یک پاسخ نمونهٔ واقعی را یک بار ذخیره کنید و در تست همان را برگردانید. اینطور مطمئن میشوید که منطق شما — استخراج متن از ساختار پاسخ، ذخیرهٔ مصرف، رفتار در برابر خطا — درست کار میکند، بدون آنکه یک ریال خرج شود یا تست به دلیل تغییر جملهٔ مدل قرمز شود.
قاعدهٔ کلی این است که هرگز روی متن خروجی مدل ادعا ننویسید، چون متن هر بار فرق میکند. بهجایش روی چیزهای قطعی ادعا کنید: اینکه درخواست به مسیر درست رفته، نام مدل درست فرستاده شده، رکورد مصرف ساخته شده، و در پاسخ ۴۲۹ سه بار تلاش مجدد انجام شده است. اگر خروجی ساختاریافته میگیرید، اعتبارسنجی ساختار را هم به تست اضافه کنید؛ همان جایی است که در محیط عملیاتی میشکند.
یک لایهٔ کش هم بگذارید. اگر ورودیهای تکراری دارید — مثلاً توضیح یک محصول ثابت یا پاسخ به پرسشهای متداول — نتیجه را با کلیدی از جنس هش ورودی بههمراه نام مدل ذخیره کنید. در محصولات واقعی، نرخ برخورد کش روی چنین مسیرهایی معمولاً بالاست و هم هزینه و هم تأخیر را همزمان پایین میآورد. فقط یادتان باشد نام مدل را حتماً در کلید کش بگذارید، وگرنه بعد از تعویض مدل، پاسخهای قدیمی را تحویل میگیرید و ساعتها دنبال دلیلش میگردید.
کد آمادهٔ همین راهنما در گیتهاب
سرویس لاراولی که در این مقاله ساختیم، بهصورت یک فایل مستقل در ai-api-examples/laravel است: کلاس NetArzAi که فقط به Http خود لاراول نیاز دارد، بهعلاوهٔ routes-example.php که نشان میدهد مرورگر چطور از راه سرور شما به مدل میرسد و کلید هرگز به صفحه نمیآید. اگر لاراول ندارید، پوشهٔ php/ دو راه دیگر دارد: کتابخانهٔ openai-php/client، و cURL خالص برای هاست اشتراکی، هم برای جواب کامل و هم برای استریم. مجوز مخزن MIT است؛ نمونههای پایتون و Node.js همین وبسرویس هم در کدهای متنباز نِت اَرز معرفی شدهاند.
از کجا شروع کنید
ترتیب پیشنهادی ما این است: اول یک قابلیت کوچک و بیخطر بسازید (مثلاً برچسبگذاری خودکار تیکتها)، مصرفش را یک هفته ثبت کنید، بعد سراغ قابلیتهای سنگینتر بروید. برای شروع به یک کلید و کمی اعتبار نیاز دارید؛ روش تهیهٔ آن از داخل ایران و بدون کارت ارزی را در خرید API چتجیپیتی در ایران و تصویر کلی سرویس را در API هوش مصنوعی با پرداخت ریالی توضیح دادهایم. اگر ترجیح میدهید بخشی از کار را بدون کدنویسی جلو ببرید، اتصال n8n و Make با یک base_url مسیر سریعتری است، و اگر دنبال یک نمونهٔ کامل پایتون هستید، ساخت ربات تلگرام فارسی همان الگو را در زبان دیگری نشان میدهد.
مستندات فنی مسیرها، پارامترها و نمونههای بیشتر در مستندات API نِت اَرز است و شارژ اعتبار برای همین کلید از صفحهٔ درگاه هوش مصنوعی انجام میشود؛ قیمت لحظهای و مقدار اعتبار پیش از تأیید پرداخت نمایش داده میشود.
آموزشهای کاربردی در گیسو
گیسو برند هوش مصنوعی نِت اَرز است، با همین حساب و همین اعتبار. این راهنماها قدمبهقدم نشان میدهند همین کار را چطور با هوش مصنوعی انجام دهید.
پرسشهای پرتکرار
برای اتصال لاراول به API هوش مصنوعی حتماً باید پکیج نصب کنم؟
خیر. کلاینت Http خود لاراول برای همهٔ مسیرها کافی است و مزیتش این است که تایماوت، تلاش مجدد و لاگ را با ابزارهای آشنای فریمورک کنترل میکنید. پکیجهای آماده فقط کمی کد را کوتاهتر میکنند و وابستگی اضافه میآورند.
چرا درخواستهای طولانی روی سرور تایماوت میشوند ولی روی لوکال نه؟
روی سرور معمولاً هم انجینایکس و هم پیاچپیافپیام سقف زمانی جداگانه دارند و زودتر از کد شما اتصال را میبندند. راهحل درست بالا بردن این سقفها نیست، بلکه بردن کار سنگین به صف و استفاده از استریم برای پاسخهای تعاملی است.
در حالت استریم چطور بفهمم هر پاسخ چقدر توکن مصرف کرده؟
در بدنهٔ درخواست پارامتر stream_options با include_usage برابر true را اضافه کنید تا در انتهای استریم یک بستهٔ usage دریافت کنید. اگر این پارامتر را نفرستید، گزارش دقیق مصرف را میتوانید از پنل کاربریتان به تفکیک روز، پروژه و مدل ببینید.
همین کد با مدلهای غیر OpenAI هم کار میکند؟
ساختار درخواستها با فرمت OpenAI سازگار است و برای تغییر مدل فقط مقدار model عوض میشود. روی درگاه نِت اَرز هماکنون مدلهای OpenAI فعال است و بقیهٔ ارائهدهندهها در حال اتصالاند؛ فهرست بهروز مدلهای قابل فراخوانی را در صفحهٔ مدلها ببینید.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.