تصور کنید کاربری دکمه ارسال را میزند و هشت ثانیه به یک صفحه کاملاً سفید خیره میشود تا مدل فکر کند. این شکست رایج در برنامههای هوش مصنوعی بهندرت مشکلِ خودِ مدل است؛ بلکه یک مشکلِ «مرزگذاری» در لایه رابط کاربری است. سرعت ادراکشده در یک اپلیکیشن هوش مصنوعی کاملاً به این بستگی دارد که توسعهدهندگان مرزهای Suspense (سیستمی برای مدیریت وضعیتهای در حال بارگذاری) را کجا قرار دهند.
اکثر برنامهنویسان با استریمینگ (Streaming) — شبیه به پخش زنده ویدیو که منتظر دانلود کامل فایل نمیماند — بهعنوان یک انتخاب معماری به آن نگاه میکنند، اما در واقع این یک مسئلهی جایگذاری است. در مدل سنتی رندر سمت سرور (SSR)، سرور یک بلوک HTML تولید میکند و آن را تنها زمانی میفرستد که کل صفحه آماده باشد. اما Next.js با استفاده از App Router و پروتکل React Flight، درخت کامپوننتها را بهصورت سریالشده روی HTTP استریم میکند، به جای اینکه صرفاً HTML خام ارسال کند.
این تفاوت ساختاری اجازه میدهد سرور بخشهای آمادهشده — مثل منوها، تیترها، وضعیتهای خالی (Empty States) و کادرهای ورودی — را فوراً ارسال (Flush) کند و اتصال را برای تولید کندِ هوش مصنوعی باز نگه دارد. شما در واقع استریمینگ را به مدل رندری که با آن میجنگد اضافه نمیکنید، بلکه از سیستمی استفاده میکنید که اساساً برای این کار طراحی شده است. به همین دلیل است که App Router معمولاً اولین انتخاب برای اپلیکیشنهای متکی به هوش مصنوعی است. در همین راستا، برخی ابزارها فراتر از متن رفته و رابطهای کاربری تعاملی را بهصورت تکهتکه استریم میکنند تا تجربه کاربری غنیتری ایجاد کنند.
خطر استفاده از Await در سطح بالا
یک اشتباه رایج، قرار دادن دستور await در ابتدای یک کامپوننت صفحه است. وقتی توسعهدهندهای صفحهای را بهگونهای مینویسد که کل خروجی منتظر تابع getAnswer() بماند، به این شکل:
export default async function AnswerPage() { const answer = await getAnswer(); // 8s return <article>{answer}</article>; }
در این حالت، کل مسیر (Route) منتظر کندترین عملیات (مثلاً یک فراخوانی ۸ ثانیهای مدل) میماند. طبق مستندات React، هیچچیز در مورد کامپوننتهای سروری (RSC) نمیتواند کاربر را از دیدن صفحه سفید نجات دهد اگر دستور await بالای بقیه درخت قرار گرفته باشد. برای رفع این مشکل، توسعهدهندگان باید به ریاکت دقیقاً بگویند کدام بخش از صفحه اجازه دارد دیرتر برسد.
بهینهسازی جایگذاری مرزها
قاعده استریمینگ در AI ساده است: یک مرز را دقیقاً دور فراخوانی کندِ مدل قرار دهید. نه دور کل صفحه و نه دور تکتک عناصر.
- بیش از حد بالا: اگر مرز را خیلی بالا ببرید، دوباره با مشکل صفحه سفید مواجه میشوید چون تمام بخشهای داخل آن — حتی بخشهای استاتیک — منتظر پاسخ مدل میمانند.
- بیش از حد پایین: اگر مرزها را خیلی خرد کنید، با «دیواری از اسپینرها» روبرو میشوید که در زمانهای مختلف ظاهر میشوند. هر اسپینر باعث تغییر اندازه کانتینر خود میشود و چشم خواننده را مجبور میکند برای دنبال کردن لایه (Layout) در محیط نمایشگر جابجا شود.
با استفاده از چندین مرز Suspense، مجموع زمان انتظار برابر با کندترین فراخوانی است، نه مجموع تمام آنها. ریاکت ابتدا پوسته (Shell) را فوراً ارسال میکند و سپس محتوای هر مرز را بهمحض آماده شدن استریم میکند. در سمت مدل، Next.js توکنها (Token) — تکههای کوچکی از متن که مدل تکهتکه تولید میکند — را بهمحض تولید به مرورگر میفرستد تا کاربر در اولین رفتوبرگشت شبکه، خروجی را ببیند، به جای اینکه منتظر تولید کامل پاسخ بماند.
نقش اسکلتها (Skeletons)
بخشهای جایگزین (Fallbacks) باید به اندازه رندر نهایی اهمیت داشته باشند. جایگزینی یک اسپینر ۱۲ پیکسلی با ۴۰۰ پیکسل متن، باعث لرزش بصری (Visual Jank) میشود. رابطهای کاربری حرفهای از Skeleton Screens استفاده میکنند که فضای تقریبی محتوای نهایی را رزرو میکنند.
بهعنوان مثال، یک کامپوننت ModelAnswer را میتوان در یک مرز Suspense با یک AnswerSkeleton قرار داد. با استفاده از یک div با ارتفاع حداقل ثابت (مثلاً min-h-64)، یک انیمیشن پالس و یک پسزمینه خنثی، رابط کاربری پایدار میماند. در یک پیادهسازی استاندارد، PromptHeader در اولین ارسال (Flush) روی صفحه ظاهر میشود و ModelAnswer هر زمان که آماده شد میرسد. این کار از پرشهای لایه جلوگیری کرده و یک لنگر بصری برای کاربر ایجاد میکند تا مدل توکنها را تولید کند.
رندر پیشرندر جزئی (PPR) در Next.js 15
نسخه Next.js 15 قابلیت Partial Prerendering را معرفی کرده است که یک پوسته استاتیک را با استریمینگ کامپوننتهای پویا ترکیب میکند. این قابلیت زمان تا نخستین بایت (TTFB) را بهبود میبخشد چون بخشهای استاتیک از حافظه لبه (Edge Cache) سرو میشوند در حالی که خروجی مدل در مرز مربوطه استریم میشود. برای پایداری بیشتر در این نسخهها، برخی توسعهدهندگان از ترکیب Zod و لایههای پاکسازی برای حذف توقفهای API استفاده میکنند تا ساختار دادههای استریم شده تضمین شود.
- زمانی که مفید است: وقتی صفحه شما یک اسکلت استاتیک واقعی (منو، تیتر، ورودی، فوتر) و یک حفره پویای مشخص (پاسخ AI) دارد. در این حالت پوسته از Edge Cache ارسال شده و پاسخ مدل استریم میشود.
- زمانی که بیفایده است: وقتی صفحه از بالا تا پایین پویا است. مثلاً در یک داشبورد شخصیسازی شده که سلام، شمارنده استفاده و موارد اخیر همگی مختص کاربر هستند، هیچ پوسته استاتیکی برای بالا کشیدن وجود ندارد.
فعال کردن PPR روی یک مسیر کاملاً پویا بدون اندازهگیری نتایج، اغلب باعث میشود توسعهدهندگان به این نتیجه برسند که این ویژگی کار نمیکند، در حالی که در واقعیت، اصلاً چیزی برای پیشرندر وجود نداشته است. باید صادقانه تشخیص دهید که صفحه شما از کدام نوع است.
الگوی کامپوننتهای همسطح (Sibling Component Pattern)
فراخوانیهای متوالی (Sequential awaits) عامل اصلی تأخیرهای مصنوعی هستند. اگر یک صفحه ابتدا پروفایل کاربر (۴۰۰ میلیثانیه)، سپس تاریخچه (۳۰۰ میلیثانیه) و در نهایت پاسخ AI (۶ ثانیه) را بهصورت متوالی در یک کامپوننت والد بگیرد، مجموع انتظار ۶.۷ ثانیه خواهد بود:
export default async function Page() { const profile = await getProfile(); // 400ms const history = await getHistory(); // 300ms const answer = await getAnswer(); // 6s return <Layout profile={profile} history={history} answer={answer} />; }
توسعهدهندگان باید از الگوی کامپوننتهای همسطح استفاده کنند. با تقسیم فراخوانیها به کامپوننتهای مجزا که هر کدام مرز Suspense خود را دارند، مجموع انتظار تنها برابر با کندترین فراخوانی (۶ ثانیه) میشود:
export default function Page() { return ( <main> <Suspense fallback={<Skeleton height={120} />}> <Profile /> </Suspense> <Suspense fallback={<Skeleton height={180} />}> <History /> </Suspense> <Suspense fallback={<AnswerSkeleton />}> <ModelAnswer /> </Suspense> </main> ); }
در این حالت، پروفایل و تاریخچه تقریباً فوراً ظاهر میشوند و کاربر در حالی که منتظر پاسخ است، محتوایی برای دیدن دارد. کامپوننت والد دیگر async نیست؛ زیرا به محض اینکه یک await دوباره در سطح بالا قرار گیرد، توسعهدهنده ناخودآگاه تمام موارد زیر آن را دوباره سریال میکند.
سه اشتباه بحرانی در استریمینگ
بر اساس بررسیهای فنی، سه اشتباه زیر عامل اکثر باگهای استریمینگ هستند:
۱. فراخوانی مدلها در useEffect: این کار صفحه را به کامپوننت کلاینت تبدیل میکند. در این حالت، هیچ اتفاقی نمیافتد تا زمانی که مرورگر جاوااسکریپت را دانلود، تجزیه (Parse) و درخت را هیدراته (Hydrate) کند و سپس افکت را اجرا کند. این یعنی هزینه کامل هیدراته کردن به فراخوانی اضافه میشود که همین حالا هم کندترین بخش صفحه است. این فراخوانیها را به کامپوننتهای سرور منتقل کنید تا درخواست در حالی که HTML هنوز در حال استریم است، آغاز شود.
۲. بافِر کردن کل پاسخ: جمعآوری تکهها (Chunks) در یک رشته و تنظیم وضعیت (State) تنها یکبار در انتها، عملاً استریمینگ را نابود میکند. توکنها زود رسیدهاند، اما توسعهدهنده تصمیم گرفته آنها را نگه دارد. هر تکه را بهمحض رسیدن رندر کنید.
۳. جایگذاری بالای 'use client': این دستور به صورت سرایتی به پایین منتقل میشود. یک دکمه تعاملی در بالای لایه میتواند کل مسیر را به کلاینت منتقل کرده و مرزهای سمت سرور را خنثی کند. این دستور را در کوچکترین برگ تعاملی ممکن نگه دارید و فرزندانی که در سرور رندر شدهاند را به آن پاس دهید، به جای اینکه آنها را در زیر آن ایمپورت کنید.
تست کردن استریم
تست کامپوننتهای استریمینگ نیازمند چیزی فراتر از Mock کردن SDK است، زیرا Mock کردن فقط خودِ Mock را تست میکند. توسعهدهندگان باید از یک تولیدکننده استریم جعلی استفاده کنند که تکهها را با یک تایمر ارسال میکند و از همان اینترفیسی استفاده میکند که فراخوانی واقعی به کار میبرد:
export async function* fakeStream(chunks: string[], delayMs = 20) { for (const chunk of chunks) { await new Promise((resolve) => setTimeout(resolve, delayMs)); yield chunk; } }
تستها باید روی آنچه کاربر در طول زمان میبیند تأکید کنند، نه فقط رشته نهایی. برای مثال، یک تست باید تأیید کند که اولین تکه متن پیش از پایان استریم قابل مشاهده است:
it("shows the first chunk before the stream finishes", async () => { render(await ModelAnswer({ query: "hello" })); expect(await screen.findByText(/Streaming/)).toBeInTheDocument(); // stream is still open here });
تست وضعیت بارگذاری (Loading state) به اندازه تست خروجی نهایی حیاتی است؛ زیرا کاربر چندین ثانیه به اسکلت صفحه خیره میشود و یک اسکلت خراب، باگی بسیار مشهودتر از یک رندر نهایی است که کمی دیرتر اتفاق افتاده است.
سوالات متداول (FAQ)
چگونه پاسخهای AI را در Next.js استریم کنم؟
فراخوانی مدل را در یک کامپوننت سروری async قرار دهید، آن کامپوننت را در یک مرز Suspense با یک Fallback واقعبینانه بپیچید و اجازه دهید App Router بقیه صفحه را فوراً استریم کند. توکنها بهمحض تولید توسط مدل به مرورگر میرسند، بنابراین کاربر در اولین رفتوبرگشت شبکه خروجی را میبیند.
بهترین الگو برای استریمینگ AI با React Server Components چیست؟
یک مرز دور فراخوانی کند مدل و استفاده از کامپوننتهای همسطح (Siblings) برای هر مورد دیگری که نیاز به Fetch دارد. نگه داشتن هر فراخوانی در کامپوننت و مرز خودش باعث میشود مجموع انتظار برابر با کندترین فراخوانی باشد، نه مجموع آنها، و مانع از آن میشود که یک فراخوانی کند، کل صفحه را گروگان بگیرد.
Partial Prerendering در اپلیکیشنهای AI چگونه کار میکند؟
این قابلیت یک پوسته استاتیک را سرو کرده و کامپوننتهای سروری پویا را در آن استریم میکند که TTFB را بهبود میبخشد. این روش زمانی جواب میدهد که صفحه شما واقعاً یک اسکلت استاتیک دور یک حفره پویای AI داشته باشد. در مسیری که کاملاً پویا است، پوستهای برای پیشرندر وجود ندارد و تأثیر زیادی نخواهید دید.
این تغییر تمرکز از «چگونه استریم کنیم» به «مرزها را کجا قرار دهیم»، شیوه مهندسی فرانتاند در AI را تغییر میدهد. این کار گلوگاه را از لایه شبکه به لایه لایهبندی (Layout) منتقل میکند و تفاوت بین اپلیکیشنی که «خراب» به نظر میرسد و اپلیکیشنی که «آنی» حس میشود را رقم میزند.
گام بعدی شما
- بررسی کنید آیا در صفحات AI خود از
awaitدر سطح بالای کامپوننت استفاده کردهاید یا خیر؛ در صورت مثبت، آنها را به کامپوننتهای فرزند منتقل کنید. - برای هر فراخوانی کند مدل، یک Skeleton Screen با ارتفاع ثابت طراحی کنید تا از پرشهای بصری (Layout Shift) جلوگیری شود.
- اگر از Next.js 15 استفاده میکنید، قابلیت PPR را روی مسیرهایی که پوسته استاتیک دارند فعال کنید.
اما داستان سختافزاری این تحول حتی شگفتانگیزتر است — به تحلیل ما دربارهی تراشههای Blackwell مراجعه کنید.




گفتگو