uttapen

استریم پاسخ مدل در وب‌اپ: SSE با Next.js و Laravel

استریم توکن‌به‌توکن پاسخ مدل با Server-Sent Events: route handler در Next.js 16، کنترلر Laravel با Guzzle، پارسر مرورگر، تنظیم nginx و لغو درخواست.

۱۶ شهریور ۱۴۰۵ · ۱۱ دقیقه مطالعه · تیم یوتاپن

فرق یک چت‌بات که «کند» به نظر می‌رسد با یکی که «زنده» به نظر می‌رسد، معمولاً مدل نیست؛ استریم است. وقتی پاسخ توکن به توکن روی صفحه می‌آید، کاربر بعد از چند صد میلی‌ثانیه چیزی می‌بیند و منتظر ده ثانیه تولید کامل نمی‌ماند. این مقاله استریم را از دروازهٔ یوتاپن تا مرورگر، یک بار با Next.js و یک بار با Laravel، پیاده می‌کند. همهٔ کدها علیه دروازه اجرا شده‌اند و جاهایی که در production گیر می‌کنند (بافرینگ nginx، خروجی PHP، لغو درخواست) را جدا گفته‌ایم.

پروتکل: SSE چیست و چرا نه WebSocket

استریم پاسخ مدل در قرارداد OpenAI با Server-Sent Events انجام می‌شود: یک پاسخ HTTP معمولی با Content-Type: text/event-stream که بدنه‌اش تا مدت‌ها باز می‌ماند و سرور هر وقت چیزی داشت، یک بلوک متنی با پیشوند data: می‌فرستد. هر بلوک یک JSON کوچک است که در choices[0].delta.content تکهٔ جدید متن را دارد، و در انتها یک خط data: [DONE] می‌آید.

چرا WebSocket نه؟ چون ارتباط یک‌طرفه است (کلاینت یک بار درخواست می‌دهد، سرور مدت‌ها جواب می‌فرستد)، SSE روی HTTP معمولی و همهٔ پروکسی‌ها و CDNها کار می‌کند، احراز هویت با همان هدر و کوکی همیشگی انجام می‌شود، و نیازی به نگهداری اتصال دائمی نیست. برای چت، SSE به‌سادگی کافی است.

یک نکتهٔ مخصوص دروازهٔ ما: اگر هدر X-Uttapen-Include-Meta: 1 بفرستی، درست قبل از [DONE] یک رویداد اضافه با "object":"uttapen.meta" می‌آید که cost_toman، balance_toman و hold_toman را دارد. بدون این هدر، خروجی دقیقاً همان فرمت OpenAI است. این خروجی واقعی از یک استریم کوتاه است:

data: {"choices":[{"delta":{"content":"سلام! این یک پاسخ ","role":"assistant"},"finish_reason":null,"index":0}],"id":"gen-95b8…","model":"google/gemini-2.5-flash","object":"chat.completion.chunk"}

data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],"id":"gen-95b8…","object":"chat.completion.chunk","usage":{"prompt_tokens":23,"completion_tokens":24,"total_tokens":47}}

data: {"balance_toman":"97507.621838","cost_toman":"8.822438","hold_toman":"1553.460466","id":"gen-95b8…","object":"uttapen.meta"}

data: [DONE]

اول با curl مطمئن شو

قبل از نوشتن حتی یک خط کد بک‌اند، استریم را با curl ببین. سوئیچ -N بافرینگ خود curl را خاموش می‌کند تا رویدادها همان لحظه که می‌رسند چاپ شوند:

curl -N https://api.uttapen.ir/v1/chat/completions \
  -H "Authorization: Bearer $UTTAPEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Uttapen-Include-Meta: 1" \
  -d '{"model":"google/gemini-2.5-flash","messages":[{"role":"user","content":"یک جملهٔ کوتاه دربارهٔ تهران بگو."}],"stream":true}'

اگر خط‌ها یکی‌یکی و با فاصلهٔ زمانی آمدند، دروازه درست کار می‌کند و هر مشکلی بعد از این، در لایهٔ خودت است. اگر همه‌چیز یک‌جا در انتها چاپ شد، اول مطمئن شو -N را گذاشته‌ای؛ این رایج‌ترین اشتباه هنگام گزارش «استریم کار نمی‌کند» است. اولین خط خروجی معمولاً : processing است؛ این یک کامنت SSE است که اتصال را زنده نگه می‌دارد و پارسر باید نادیده‌اش بگیرد (کد بالا چون فقط خط‌های data: را می‌خواند، همین کار را می‌کند).

معماری: چرا کلید در مرورگر نیست

مرورگر هرگز مستقیم به api.uttapen.ir وصل نمی‌شود، چون کلید sk-up- نباید به کلاینت برسد. الگو این است: مرورگر به بک‌اند خودت درخواست می‌زند، بک‌اند با کلید به دروازه وصل می‌شود، و بدنهٔ استریم را بدون بافر کردن به مرورگر پاس می‌دهد. بک‌اند اینجا فقط یک لولهٔ نازک است؛ همین نازک بودن، کلید موفقیت است. هر جا وسط این لوله چیزی بدنه را «جمع کند تا کامل شود»، استریم می‌میرد و کاربر همه‌چیز را یک‌جا در انتها می‌بیند.

Next.js 16: route handler

فایل app/api/chat/route.ts. روی runtime نود اجرا می‌شود و بدنهٔ پاسخ دروازه را عیناً برمی‌گرداند:

// app/api/chat/route.ts
export const runtime = "nodejs";

export async function POST(req: Request) {
  const { messages, model = "google/gemini-2.5-flash" } = await req.json();

  const upstream = await fetch("https://api.uttapen.ir/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UTTAPEN_API_KEY}`,
      "Content-Type": "application/json",
      "X-Uttapen-Include-Meta": "1",
    },
    body: JSON.stringify({ model, messages, stream: true, max_tokens: 800 }),
    signal: req.signal, // کاربر تب را بست → درخواست بالادستی هم لغو می‌شود
  });

  if (!upstream.ok || !upstream.body) {
    const err = await upstream.text();
    return new Response(err, { status: upstream.status, headers: { "Content-Type": "application/json" } });
  }

  // بدنهٔ SSE را همان‌طور که هست به مرورگر پاس می‌دهیم؛ هیچ بافرینگی در میانه نیست.
  return new Response(upstream.body, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  });
}

سه تصمیم در این کد مهم است. signal: req.signal باعث می‌شود وقتی کاربر صفحه را بست، درخواست به دروازه هم قطع شود؛ دروازه فقط بابت توکن‌هایی که تا آن لحظه تولید شده شارژ می‌کند و بقیهٔ مبلغ بلوکه‌شده را آزاد می‌کند، پس لغو کردن واقعاً پول را نگه می‌دارد. no-transform به پروکسی‌های میانی می‌گوید فشرده‌سازی یا تغییر نکنند. و X-Accel-Buffering: no به nginx می‌گوید این پاسخ را بافر نکند، حتی اگر تنظیمات سراسری بافرینگ روشن باشد.

خطاهای دروازه (مثلاً 402 وقتی موجودی کم است) با همان کد وضعیت و همان بدنهٔ JSON به مرورگر می‌رسد؛ در فرانت می‌توانی پیام «موجودی کافی نیست» را از error.uttapen.required_toman بسازی. فهرست خطاها در صفحهٔ کدهای خطا است.

مرورگر: خواندن استریم با fetch

EventSource مرورگر فقط GET می‌فرستد و هدر سفارشی نمی‌گیرد، پس برای چت که بدنهٔ POST دارد، از fetch و خواندن دستی بدنه استفاده می‌کنیم. این پارسر همان است که در تست ما ۱۰۰ درصد رویدادها را درست جدا کرد:

async function streamChat(messages: { role: string; content: string }[], onToken: (t: string) => void) {
  const controller = new AbortController();
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
    signal: controller.signal,
  });
  if (!res.ok || !res.body) throw new Error(await res.text());

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let meta: { cost_toman?: string; balance_toman?: string } | null = null;

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? ""; // خط ناقص را برای دور بعد نگه دار
    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;
      const payload = line.slice(6);
      if (payload === "[DONE]") continue;
      const evt = JSON.parse(payload);
      if (evt.object === "uttapen.meta") { meta = evt; continue; }
      const token = evt.choices?.[0]?.delta?.content;
      if (token) onToken(token);
    }
  }
  return { meta, cancel: () => controller.abort() };
}

دو نکتهٔ پارسر: decode(value, { stream: true }) ضروری است، چون یک حرف فارسی دو بایت است و ممکن است بین دو chunk شبکه نصف شود؛ بدون stream: true حرف خراب می‌شود. و تقسیم بر اساس خط (\n) به‌جای انتظار برای خط خالی، پارسر را در برابر تفاوت‌های کوچک بین سرورها مقاوم می‌کند.

در کامپوننت React، onToken فقط setText(prev => prev + token) است. اگر ترجیح می‌دهی این‌ها را دستی ننویسی، Vercel AI SDK با createOpenAI({ baseURL: "https://api.uttapen.ir/v1", apiKey }) و streamText همین کار را در سمت سرور انجام می‌دهد و useChat سمت کلاینت را مدیریت می‌کند؛ فقط توجه کن که پروتکل استریم آن کتابخانه بین سرور و مرورگر با SSE خام فرق دارد و مسیر /api/chat را باید با toUIMessageStreamResponse() برگردانی.

Laravel: کنترلر استریم

در PHP سه لایه می‌توانند استریم را ببلعند: بافر خروجی PHP، php-fpm، و nginx. کد کنترلر با Guzzle و stream => true تا [DONE] را در تست ما درست خواند:

<?php
// app/Http/Controllers/ChatController.php
namespace App\Http\Controllers;

use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ChatController extends Controller
{
    public function stream(Request $request): StreamedResponse
    {
        $messages = $request->validate(['messages' => 'required|array'])['messages'];

        return response()->stream(function () use ($messages) {
            $http = new Client(['base_uri' => 'https://api.uttapen.ir/v1/', 'timeout' => 0]);

            $res = $http->post('chat/completions', [
                'headers' => [
                    'Authorization'          => 'Bearer ' . config('services.uttapen.key'),
                    'Accept'                 => 'text/event-stream',
                    'X-Uttapen-Include-Meta' => '1',
                ],
                'json'   => ['model' => 'deepseek/deepseek-chat', 'messages' => $messages, 'stream' => true, 'max_tokens' => 800],
                'stream' => true,   // Guzzle بدنه را بافر نکند
            ]);

            $body = $res->getBody();
            $buffer = '';
            $emit = function (string $line): void {
                if ($line === '') return;
                echo $line, "\n\n";       // همان خط SSE را به مرورگر پاس بده
                if (ob_get_level() > 0) ob_flush();
                flush();
            };
            while (!$body->eof()) {
                if (connection_aborted()) return;   // کاربر رفت → از حلقه بیرون بیا، Guzzle اتصال را می‌بندد
                $buffer .= $body->read(1024);
                while (($pos = strpos($buffer, "\n")) !== false) {
                    $emit(substr($buffer, 0, $pos));
                    $buffer = substr($buffer, $pos + 1);
                }
            }
            if ($buffer !== '') $emit($buffer);
        }, 200, [
            'Content-Type'      => 'text/event-stream; charset=utf-8',
            'Cache-Control'     => 'no-cache, no-transform',
            'X-Accel-Buffering' => 'no',
        ]);
    }
}

اگر می‌خواهی سمت سرور هزینهٔ هر گفتگو را ثبت کنی، داخل همان حلقه خطی که با data: شروع می‌شود را json_decode کن و وقتی object برابر uttapen.meta بود، cost_toman را در جدول خودت بنویس؛ در تست ما همین منطق مقدار cost=4.673650 تومان را برای یک پاسخ کوتاه DeepSeek ثبت کرد.

تنظیماتی که استریم PHP را زنده نگه می‌دارد

این چهار مورد بیشترین تیکت پشتیبانی را می‌سازند:

  1. بافر خروجی PHP. در php.ini یا .user.ini مقدار output_buffering = Off و zlib.output_compression = Off. اگر نمی‌توانی سراسری عوضش کنی، ابتدای closure استریم while (ob_get_level()) ob_end_flush(); بگذار.
  2. php-fpm و nginx. در بلاک location مربوط به PHP: fastcgi_buffering off; و proxy_buffering off; اگر پشت پروکسی دیگری هستی. هدر X-Accel-Buffering: no که در کنترلر گذاشتیم، همین کار را برای همان پاسخ می‌کند و معمولاً کافی است، ولی تنظیم صریح مطمئن‌تر است.
  3. timeout اجرا. max_execution_time پیش‌فرض ۳۰ ثانیه است و پاسخ‌های بلند مدل استدلالی ممکن است بیشتر طول بکشد. برای این route با set_time_limit(0) ابتدای closure یا با تنظیم مجزا در pool مربوطه، محدودیت را بردار. fastcgi_read_timeout در nginx را هم به ۳۰۰ ثانیه ببر.
  4. Octane. اگر با Swoole یا FrankenPHP اجرا می‌کنی، StreamedResponse پشتیبانی می‌شود ولی flush() سنتی اثر ندارد و باید از پاسخ استریم خود Octane استفاده کنی. تست کن، فرض نکن.

لغو، قطع، و پول

سه سناریو را باید عمداً تست کنی. کاربر وسط استریم صفحه را بست: در Next.js با req.signal و در Laravel با connection_aborted() قطع به دروازه می‌رسد و شارژ فقط تا همان لحظه است. اتصال به دروازه وسط کار افتاد: [DONE] هرگز نمی‌آید؛ در مرورگر یک timeout بی‌فعالیتی بگذار (مثلاً اگر ۳۰ ثانیه هیچ رویدادی نیامد، خطا نشان بده) و در بک‌اند به timeout کلی تکیه نکن، چون استریم طولانی مشروع است. پاسخ دروازه از اول خطا بود: بدنهٔ JSON خطا استریم نیست؛ همان‌طور که در route handler دیدی، اول upstream.ok را چک کن و بدنه را به‌صورت عادی برگردان.

دو محدودیت را هم بدان. دروازه برای هر کلید سقف تعداد استریم هم‌زمان دارد و عبور از آن 429 too_many_concurrent_streams می‌دهد؛ برای اپلیکیشنی با کاربران زیاد، درخواست‌ها را در بک‌اند صف کن یا چند کلید بساز. و مسیر ایران به مدل از relay خارج از کشور می‌گذرد که حدود ۱۰۰ میلی‌ثانیه به زمان تا اولین توکن اضافه می‌کند؛ در استریم این تأخیر یک بار در ابتدا حس می‌شود و بعد از آن توکن‌ها با همان سرعت خود مدل می‌آیند.

چند نکتهٔ رابط کاربری

استریم درست در بک‌اند، هنوز به معنی تجربهٔ خوب در مرورگر نیست. سه چیزی که بعد از چند پروژه به آن رسیدیم: اول، هر توکن را جداگانه به state ننویس؛ توکن‌ها را در یک متغیر جمع کن و با requestAnimationFrame یا هر ۵۰ میلی‌ثانیه یک بار state را به‌روز کن، وگرنه با مدل‌های سریع، React ده‌ها بار در ثانیه رندر می‌کند و صفحه لخت می‌شود. دوم، اگر پاسخ را Markdown رندر می‌کنی، رندر تدریجی باعث پرش می‌شود (یک بلوک کد نیمه‌کاره لحظه‌ای متن ساده نمایش داده می‌شود و بعد تبدیل می‌شود)؛ یا رندر Markdown را تا پایان استریم عقب بینداز و در حین استریم متن خام نشان بده، یا از کتابخانه‌ای استفاده کن که بلوک ناقص را تحمل می‌کند. سوم، اسکرول خودکار به پایین را فقط وقتی انجام بده که کاربر خودش پایین صفحه است؛ اگر کاربر بالا رفته تا چیزی را دوباره بخواند، کشیدنش به پایین آزاردهنده است.

مدل مناسب استریم

برای چت کاربرمحور، مدل‌هایی که زمان تا اولین توکن کوتاه دارند حس بهتری می‌دهند: Gemini 2.5 Flash و GPT-5 mini در تست‌های ما سریع‌ترین شروع را داشتند و DeepSeek V3 با فاصلهٔ کم پشت سرشان بود. مدل‌های استدلالی قبل از اولین توکن مکث محسوسی دارند؛ اگر از آن‌ها استفاده می‌کنی، در رابط یک نشانگر «در حال فکر کردن» تا اولین توکن نشان بده، نه یک اسپینر خالی. راهنمای کامل پارامترها در مستندات استریم است. اگر می‌خواهی این کدها را روی پروژهٔ خودت امتحان کنی، یک کلید بساز و اول با curl -N مطمئن شو که رویدادها تک‌تک می‌رسند؛ بعد سراغ بک‌اند برو، تا اگر جایی بافر شد، بدانی مشکل از کدام لایه است.

مقاله‌های مرتبط

می‌خواهی همین کد را اجرا کنی؟

با شماره موبایل ثبت‌نام کن، کیف پول را شارژ کن و کلید بگیر. ساخت کلید API ←