استریم پاسخ مدل در وباپ: 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 را زنده نگه میدارد
این چهار مورد بیشترین تیکت پشتیبانی را میسازند:
- بافر خروجی PHP. در
php.iniیا.user.iniمقدارoutput_buffering = Offوzlib.output_compression = Off. اگر نمیتوانی سراسری عوضش کنی، ابتدای closure استریمwhile (ob_get_level()) ob_end_flush();بگذار. - php-fpm و nginx. در بلاک
locationمربوط به PHP:fastcgi_buffering off;وproxy_buffering off;اگر پشت پروکسی دیگری هستی. هدرX-Accel-Buffering: noکه در کنترلر گذاشتیم، همین کار را برای همان پاسخ میکند و معمولاً کافی است، ولی تنظیم صریح مطمئنتر است. - timeout اجرا.
max_execution_timeپیشفرض ۳۰ ثانیه است و پاسخهای بلند مدل استدلالی ممکن است بیشتر طول بکشد. برای این route باset_time_limit(0)ابتدای closure یا با تنظیم مجزا در pool مربوطه، محدودیت را بردار.fastcgi_read_timeoutدر nginx را هم به ۳۰۰ ثانیه ببر. - 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 مطمئن شو که رویدادها تکتک میرسند؛ بعد سراغ بکاند برو، تا اگر جایی بافر شد، بدانی مشکل از کدام لایه است.
مقالههای مرتبط
- چطور کد OpenAIات را با تغییر یک خط به یوتاپن وصل کنی (Python، Node، PHP)راهنمای عملی تغییر base_url در SDK رسمی OpenAI برای Python، Node.js و PHP، با کد تستشده، خواندن هزینهٔ تومانی از هدر پاسخ و نکات مهاجرت بدون شکستن کد.
- ساخت ربات تلگرام فارسی با GPT در ۳۰ دقیقه — پایتون، استریم پاسخ، کنترل هزینهربات تلگرام فارسی با python-telegram-bot و SDK رسمی OpenAI: پاسخ استریمی با ویرایش پیام، حافظهٔ گفتگو، سه قفل هزینه، و نکتهٔ دسترسی به api.telegram.org.
- بهترین مدل هوش مصنوعی برای برنامهنویسی در ۱۴۰۵ — تست روی ۵ وظیفهٔ واقعیبهجای جدول امتیاز آماده، یک harness باز میگیری: ۵ وظیفهٔ واقعی، تست خودکار، هزینهٔ تومانی هر مدل. خودت اجرا کن و نتیجهٔ کدِ خودت را ببین.
با شماره موبایل ثبتنام کن، کیف پول را شارژ کن و کلید بگیر. ساخت کلید API ←