آخرین به‌روزرسانی:

پاسخ یک مدل زبانی گاهی ده بیست ثانیه طول می‌کشد. اگر کاربر در این مدت فقط یک چرخ‌دنده‌ی چرخان ببیند، فکر می‌کند سایت هنگ کرده. در صفحه‌ی چت خود سرویس‌ها این حس را ندارید، چون متن کلمه‌به‌کلمه ظاهر می‌شود.

این مقاله همان تجربه را روی سایت خودتان می‌سازد و ادامه‌ی اولین اتصال به API هوش مصنوعی با PHP است.

استریم چیست و چه چیزی را عوض می‌کند؟#

در استریم، سرویس پاسخ را همزمان با تولید، تکه‌تکه می‌فرستد و شما هر تکه را همان لحظه نشان می‌دهید. زمان کل و هزینه عوض نمی‌شود؛ فقط کاربر اولین کلمه را خیلی زودتر می‌بیند.

قالب این ارسال معمولاً SSE است. رویدادهای ارسالی از سرور (Server-Sent Events) قالب متنی ساده‌ای است که در آن سرور روی یک اتصال باز HTTP خط‌هایی با پیشوند data: می‌فرستد و هر رویداد با یک خط خالی تمام می‌شود. در قالب سازگار با OpenAI، کافی است در درخواست "stream": true بگذارید. هر رویداد یک JSON کوچک با تکه‌ی تازه‌ی متن در choices[0].delta.content است و جریان با data: [DONE] تمام می‌شود.

پاسخ از چه مسیری به مرورگر می‌رسد؟#

مرورگر به PHP شما وصل می‌شود و PHP با کلید API به سرویس. هر تکه باید از همه‌ی حلقه‌های این زنجیر بی‌معطلی رد شود.

نمودار مسیر هر تکه‌ی پاسخ از سرویس هوش مصنوعی تا مرورگر و سه جایی که ممکن است خروجی جمع و یکجا فرستاده شود: بافر PHP، وب‌سرور و پروکسی

بافر خروجی یعنی لایه‌ای که خروجی را جمع می‌کند و وقتی پر شد یا اسکریپت تمام شد یکجا می‌فرستد. برای پاسخ معمولی خوب است؛ برای استریم، دشمن است.

هاست من استریم را پشتیبانی می‌کند؟#

با یک فایل آزمایشی در دو دقیقه معلوم می‌شود. پیش از نوشتن هر کد دیگری این را روی همان هاست بگذارید:

PHP
<?php
// stream-test.php — delete after testing
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');

while (ob_get_level() > 0) {
    ob_end_flush();
}

for ($i = 1; $i <= 5; $i++) {
    echo "data: {$i}\n\n";
    flush();
    sleep(1);
}

و از ترمینال:

Bash
curl -N https://example.com/stream-test.php

اگر عددها ثانیه‌به‌ثانیه آمدند، مسیر باز است. اگر هر پنج عدد با هم بعد از پنج ثانیه آمدند، جایی بافر هست:

لایهنشانه و راه‌حل
output_buffering در PHPبا ini_set در حین اجرا عوض نمی‌شود؛ حلقه‌ی ob_end_flush بالا بافرهای باز را می‌بندد
zlib.output_compressionباید پیش از هر خروجی خاموش شود
فشرده‌سازی آپاچی (mod_deflate)با متغیر no-gzip برای همین فایل خاموش می‌شود
Nginx جلوی PHPسرآیند X-Accel-Buffering: no
CDN یا پروکسیاز داخل PHP کاری از دستتان برنمی‌آید؛ تنظیمات همان سرویس

برای آپاچی، اگر اجازه‌ی .htaccess دارید:

Apache
SetEnvIf Request_URI "ai-stream\.php$" no-gzip=1

اگر هیچ‌کدام جواب نداد، صادقانه بگوییم: روی بعضی هاست‌های اشتراکی وب‌سرور خروجی را جمع می‌کند و تنظیمش دست شما نیست.

کد سمت سرور چیست؟#

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

PHP
<?php
// ai-stream.php
declare(strict_types=1);

require dirname(__DIR__) . '/private/config.php'; // defines AI_API_KEY, outside web root

session_start();
$token = $_SESSION['csrf'] ?? '';

if (! is_string($token) || $token === '' || ! hash_equals($token, (string) ($_POST['csrf'] ?? ''))) {
    http_response_code(403);
    exit;
}
// Also check login and the per-user quota here
session_write_close(); // release the session lock before the long request

$question = trim((string) ($_POST['q'] ?? ''));
if ($question === '' || mb_strlen($question) > 2000) {
    http_response_code(422);
    exit;
}

ini_set('zlib.output_compression', '0');
while (ob_get_level() > 0) {
    ob_end_flush();
}
if (function_exists('set_time_limit')) {
    set_time_limit(150);
}

header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');

function sse(string $event, array $data): void
{
    echo "event: {$event}\n";
    echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
    flush();
}

$payload = [
    'model' => 'MODEL_NAME',
    'messages' => [['role' => 'user', 'content' => $question]],
    'max_tokens' => 800,
    'stream' => true,
    'stream_options' => ['include_usage' => true], // remove if your provider rejects it
];

$buffer = '';
$errorBody = '';
$usage = null;

$ch = curl_init('https://api.openai.com/v1/chat/completions');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 120,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . AI_API_KEY],
    CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
    CURLOPT_WRITEFUNCTION => function (CurlHandle $ch, string $chunk) use (&$buffer, &$errorBody, &$usage): int {
        if (connection_aborted()) {
            return 0; // visitor left: stop the transfer
        }
        if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) {
            $errorBody .= $chunk;
            return strlen($chunk);
        }

        $buffer .= $chunk;

        while (($pos = strpos($buffer, "\n")) !== false) {
            $line = trim(substr($buffer, 0, $pos));
            $buffer = substr($buffer, $pos + 1);

            if (! str_starts_with($line, 'data:') || $line === 'data: [DONE]') {
                continue;
            }

            $event = json_decode(substr($line, 5), true);
            $text = $event['choices'][0]['delta']['content'] ?? '';

            if ($text !== '') {
                sse('token', ['t' => $text]);
            }
            if (isset($event['usage'])) {
                $usage = $event['usage'];
            }
        }

        return strlen($chunk);
    },
]);

$ok = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($ok === false || $status !== 200) {
    error_log("AI stream failed: {$status} {$errorBody}");
    sse('error', ['message' => 'پاسخ کامل نشد. دوباره تلاش کنید.']);
} else {
    sse('done', ['usage' => $usage]);
}

چند نکته‌ی عمدی در این کد:

  • تکه‌های شبکه با خط‌ها هم‌مرز نیستند. یک رویداد ممکن است نصفش در یک تکه و نصفش در تکه‌ی بعد بیاید؛ برای همین $buffer نگه می‌داریم و فقط خط کامل را می‌خوانیم.
  • متن را JSON می‌کنیم. اگر خود متن خط جدید داشته باشد و خام فرستاده شود، قالب SSE می‌شکند.
  • session_write_close قفل نشست را آزاد می‌کند؛ بدون آن، بقیه‌ی درخواست‌های همان کاربر تا پایان پاسخ معطل می‌مانند.
  • usage در رویداد آخر می‌آید و می‌شود آن را مثل ثبت مصرف و هزینه در دیتابیس نوشت.

در مرورگر چطور تکه‌ها را نشان دهیم؟#

با fetch و خواندن تدریجی بدنه. EventSource فقط GET می‌فرستد و برای فرستادن سؤال مناسب نیست:

JavaScript
async function askStream(question, csrf, output) {
  const body = new FormData();
  body.append('q', question);
  body.append('csrf', csrf);

  const res = await fetch('/ai-stream.php', { method: 'POST', body });
  if (!res.ok || !res.body) {
    output.textContent = 'اتصال برقرار نشد.';
    return;
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    let end;
    while ((end = buffer.indexOf('\n\n')) !== -1) {
      const block = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);

      let event = 'message';
      let data = '';
      for (const line of block.split('\n')) {
        if (line.startsWith('event:')) event = line.slice(6).trim();
        if (line.startsWith('data:')) data += line.slice(5).trim();
      }
      if (!data) continue;

      const msg = JSON.parse(data);
      if (event === 'token') output.textContent += msg.t;
      if (event === 'error') output.textContent += '\n' + msg.message;
    }
  }
}

گزینه‌ی { stream: true } در TextDecoder برای فارسی حیاتی است: هر حرف فارسی چند بایت است و ممکن است بین دو تکه نصف شود. بدون این گزینه، وسط متن علامت سؤال یا حرف خراب می‌بینید. متن را هم با textContent اضافه کنید، نه innerHTML؛ پاسخ مدل ورودی غیرقابل‌اعتماد است.

روی هاست اشتراکی چه محدودیتی دارد؟#

محدودیت اصلی این است که هر استریم یک پردازه‌ی PHP را تا پایان پاسخ اشغال می‌کند. چند کاربر همزمان یعنی چند پردازه‌ی طولانی:

  • سقف پردازه‌های همزمان هاست زود پر می‌شود و خطای ۵۰۸ می‌گیرید. برای هر کاربر فقط یک استریم باز مجاز کنید.
  • اگر set_time_limit در توابع بسته‌ی هاست باشد، max_execution_time سقف طول پاسخ است. max_tokens را طوری بگذارید که پاسخ پیش از آن تمام شود.
  • اگر آزمایش بالا شکست خورد، پاسخ عادی بدون استریم با پیام «در حال نوشتن…» تجربه‌ی بدی نیست؛ از استریم نیمه‌کاره که گاهی کار می‌کند بهتر است.

این موضوع به کجا می‌رود#

استریم برای رابط چت به حالت پیش‌فرض تبدیل شده و جریان‌ها غنی‌تر می‌شوند: غیر از متن، رویدادهایی مثل فراخوانی ابزار و وضعیت کار هم در همان جریان می‌آیند. قالب SSE و fetch استاندارد و پایدارند و منطق این مقاله با آن‌ها کهنه نمی‌شود.

برای آماده شدن:

  • خواندن جریان را در یک تابع جدا نگه دارید تا نوع رویدادهای تازه را بدون بازنویسی بقیه اضافه کنید.
  • اگر پروژه رشد می‌کند، هاستی بگیرید که بافر و تعداد پردازه‌اش دست خودتان باشد.

خلاصه#

  • استریم زمان و هزینه را عوض نمی‌کند؛ انتظار کاربر را کوتاه می‌کند.
  • اول با فایل آزمایشی ببینید هاست بافر می‌کند یا نه.
  • سرور جریان را خط‌به‌خط می‌خواند و فقط متن را، JSON‌شده، به مرورگر می‌فرستد.
  • در مرورگر fetch با TextDecoder در حالت stream و textContent.
  • روی هاست اشتراکی تعداد استریم همزمان را محدود کنید و نسخه‌ی بدون استریم را آماده داشته باشید.