پست

طراحی سیستم ChatGPT

طراحی سیستم سرویس‌دهی مدل زبانی شامل استریم توکن با SSE و زمان‌بندی GPU و عدالت بین لایه‌ها و کنترل هزینه مکالمه‌های طولانی

طراحی سیستم ChatGPT

درک مسئله

ChatGPT چیست؟ ChatGPT به معرفی نیاز ندارد. محصول هوش مصنوعی گفتگومحور است که کاربران در آن Prompt را به زبان طبیعی می‌فرستند و پاسخ را به‌صورت Stream از یک مدل زبانی بزرگ (LLM) می‌گیرند. مکالمه‌ها ذخیره می‌شوند، پس کاربران می‌توانند به چت قدیمی برگردند و دقیقاً از همان جا ادامه دهند.

برای این مسئله، LLM را جعبه‌سیاهی می‌گیریم که صدایش می‌زنیم، نه چیزی که آموزشش بدهیم یا درونش را اجرا کنیم. کل طراحی در سیستم سرویس‌دهی اطراف آن است. بخش‌های جالب این است که چطور توکن‌ها را سریع به کلاینت Stream کنیم، ظرفیت محدود GPU را چطور زمان‌بندی کنیم و با بزرگ شدن مکالمه‌ها هزینه را چطور معقول نگه داریم. همچنین دامنه را فقط به متن ورودی و متن خروجی محدود می‌کنیم، بدون تصویر، صدا یا ویدیو و بدون ویرایش یا شاخه‌شاخه کردن پیام‌های موجود.

نیازمندی‌های عملکردی

نیازمندی‌های اصلی:

  • کاربران بتوانند در یک چت Prompt بفرستند و پاسخ تولیدشده توسط AI را دریافت کنند.
  • کاربران بتوانند چت‌های گذشته را ببینند و مکالمه را ادامه دهند، با Context قبلی چت که به Prompt منتقل می‌شود.

خارج از محدوده:

  • ویرایش یا شاخه‌شاخه کردن پیام‌های موجود.
  • ورودی و خروجی تصویر، صدا یا ویدیو (فقط متن).
  • اشتراک‌گذاری چت‌ها یا همکاری روی چت با کاربران دیگر.
  • Custom GPT، فراخوانی ابزار/تابع و مرور وب.
  • جستجوی متن کامل در تاریخچه‌ی چت کاربر.

نیازمندی‌های غیرعملکردی

نیازمندی‌های غیرعملکردی ویژگی‌های سیستم را پوشش می‌دهند که برای کاربر و کسب‌وکار مهم‌اند.

اگر بعد از زدن Enter چند ثانیه به صفحه‌ی خالی خیره شوید، ChatGPT خراب احساس می‌شود، پس Latency تا اولین توکن از زمان کل تکمیل مهم‌تر است. چون GPUها محدود و گران‌اند، سیستم باید درباره‌ی اینکه چه کسی کِی محاسبات می‌گیرد عامدانه رفتار کند. ChatGPT در زمان نگارش کمی بیش از ۲۰۰ میلیون کاربر فعال روزانه خدمت می‌دهد. چند Prompt برای هر نفر در روز ما را در اوج حدود ۲۰ هزار Prompt در ثانیه می‌رساند، و چون تولید پاسخ چند ثانیه طول می‌کشد، تقریباً ۱۲۰ هزار تا از آن‌ها در هر لحظه در حال Stream هستند. این مقیاسی است که برایش طراحی می‌کنیم.

نیازمندی‌های اصلی:

  • سیستم باید Time-to-first-token (TTFT) کم داشته باشد (کمتر از حدود ۵۰۰ میلی‌ثانیه).
  • سیستم باید Fault Tolerant باشد و هنگام قطع اتصال یا خرابی سرور، Stream در جریان را بدون افتادن توکن بازیابی کند.
  • سیستم باید Availability را بر Strong Consistency برای State مکالمه ترجیح دهد (حدود ۹۹٫۹٪+).
  • سیستم باید تحت ظرفیت محدود GPU مقیاس بگیرد، با تخصیص عادلانه بین پایگاه کاربران لایه‌بندی‌شده (Free، Plus، Pro).

خارج از محدوده:

  • Durability هر توکن Stream‌شده (پیام نهایی دستیار را ماندگار می‌کنیم، نه هر Chunk).
  • احراز هویت، جلوگیری از سوءاستفاده و مدیریت محتوا.
  • GDPR، اقامت داده و Compliance حریم خصوصی.
  • پایش، لاگ، هشداردهی و CI/CD.

روی تخته‌سفید ممکن است این‌طور باشد: TTFT کم با Stream پیوسته، Availability بالا بر Strong Consistency برای State چت، و مقیاس‌پذیری تحت محدودیت GPU با تخصیص عادلانه بین لایه‌ها.

اضافه کردن قابلیت خارج از محدوده می‌تواند تفکر محصولی نشان دهد و به مصاحبه‌کننده فرصت اولویت‌بندی مجدد بدهد. ولی اختیاری است. اگر قابلیت اضافه سریع به ذهنتان نرسید، رد شوید.

رویکرد برنامه‌ریزی

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

کاری که این مسئله را واقعاً جالب می‌کند در نیازمندی‌های غیرعملکردی زندگی می‌کند، جایی که باید توکن‌ها را سریع Stream کنیم، ظرفیت محدود GPU را زمان‌بندی کنیم و هزینه را کنترل نگه داریم. این‌ها Deep Dive ما می‌شوند.

تعریف Entityهای اصلی

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

  • User: حساب روی پلتفرم. لایه (Free در برابر پولی) را حمل می‌کند که وقتی به عدالت و زمان‌بندی برسیم خیلی مهم می‌شود.
  • Chat: یک رشته‌ی مکالمه. متعلق به یک کاربر است، دنباله‌ی مرتب پیام‌ها را گروه می‌کند و عنوان و Timestamp دارد.
  • Message: یک نوبت در چت، یا Prompt کاربر یا پاسخ دستیار. chatId، نقش (کاربر یا دستیار)، محتوا و شمارش توکن را حمل می‌کند.

در مصاحبه‌ی واقعی، این می‌تواند فقط فهرست کوتاهی باشد. فقط مطمئن شوید درباره‌ی Entityها با مصاحبه‌کننده صحبت می‌کنید تا هم‌نظر باشید. یک Entity دیگر به نام Run را بعداً که Deep Dive ها واقعاً به آن نیاز پیدا کردند معرفی می‌کنیم، چون چیزی نیست که طبیعتاً این‌قدر زود به آن برسید.

API یا رابط سیستم

API قرارداد بین کلاینت و سیستم ماست و پل به طراحی سطح بالا. برای هر نیازمندی عملکردی یک یا دو Endpoint تعریف می‌کنیم و جلو می‌رویم.

اول، کاربر چت جدید شروع می‌کند. از POST استفاده می‌کنیم چون Entity جدید Chat می‌سازیم:

1
2
POST /chats -> { chatId }
Body: {}

بعد، کاربر Prompt می‌فرستد. از POST استفاده می‌کنیم چون Message جدید می‌سازیم و پاسخ مدل را روی سرور شروع می‌کنیم:

1
2
3
4
POST /chats/{chatId}/messages -> Message
Body: {
  content
}

فعلاً این پیام تمام‌شده را روی پاسخ HTTP معمولی برمی‌گرداند، پس کلاینت تمام تولید را منتظر می‌ماند تا کلمه‌ای ببیند. این اولین چیزی است که در Deep Dive ها درست می‌کنیم و راه‌حل این Endpoint را به دو تا تقسیم می‌کند.

برای نیازمندی عملکردی دوم، چت‌های کاربر را برای نوار کناری فهرست می‌کنیم و پیام‌های یک چت را بارگذاری می‌کنیم. هر دو GET با Cursor Pagination هستند، چون کاربر سنگین می‌تواند هزاران چت داشته باشد و چت بلند می‌تواند هزاران پیام:

1
2
GET /chats?cursor={cursor}&limit={n} -> Chat[]
GET /chats/{chatId}/messages?cursor={cursor}&limit={n} -> Message[]

توجه کنید userId هرگز در مسیر یا Body نمی‌آید. از توکن Session یا JWT می‌آید و مالکیت چت در سمت سرور برای هر درخواست بررسی می‌شود. پاس دادن userId در Body هشدار کلاسیک است، چون هر چیزی که کلاینت بفرستد قابل جعل است.

طراحی سطح بالا

این گذر اول را عمداً ساده نگه می‌داریم، با فراخوانی‌های همگام و بدون Streaming یا صف. شروع با سیستم کارا و بعد شکستن آن بسیار بهتر از شروع با سیستم «کامل» است که استدلال درباره‌اش سخت است.

۱) کاربران بتوانند Prompt بفرستند و پاسخ تولیدشده توسط AI را بگیرند

وقتی کاربر چت را باز می‌کند، Prompt می‌نویسد و Enter می‌زند، کلاینت آن Prompt را به Backend می‌فرستد و سرانجام پاسخ می‌گیرد. حداقل مجموعه‌ی اجزای لازم برای این را بچینیم:

  • Web Client: مرورگر یا اپ موبایل که کاربر در آن Prompt می‌نویسد و پاسخ‌ها را می‌خواند. UI چت.
  • API Gateway: نقطه‌ی ورود همه‌ی درخواست‌های کلاینت. احراز هویت، Rate Limiting را مدیریت می‌کند و درخواست‌ها را به سرویس مناسب هدایت می‌کند.
  • Chat Service: سرویس Stateless که مالک ماندگاری چت و پیام است و فراخوانی مدل را هماهنگ می‌کند. ارزان اجرا می‌شود و افقی مقیاس دادنش آسان است، که مهم است چون می‌خواهیم مستقل از لایه‌ی گران Inference مقیاسش بدهیم.
  • Postgres: سیستم ثبت ما، حاوی جدول‌های chats و messages. داده ردیف‌های ساده با کلید chatId و userId است، پس تقریباً هر پایگاه داده‌ای جواب می‌دهد. Postgres را به‌عنوان پیش‌فرض منطقی انتخاب می‌کنیم، نه چون چیزی در مسئله Store رابطه‌ای می‌طلبد.
  • Inference Service: Pool ماشین‌های GPU که واقعاً LLM را اجرا می‌کنند. هرکدام Model Server اجرا می‌کند که وزن‌هایش در حافظه‌ی GPU ساکن است و آن ماشین‌ها چیزی هستند که از اینجا به بعد Worker می‌نامیم. لایه‌ی میانی نیست؛ درخواست به Worker می‌رود و آن Worker تولید می‌کند. خود مدل را جعبه‌سیاهی می‌دانیم که Prompt می‌گیرد و Completion برمی‌گرداند. این بخش گران و GPU-bound سیستم است که از Chat Service جدا شده تا مستقل مقیاس و زمان‌بندی شود.

وقتی کاربر Prompt می‌فرستد:

  1. کاربر Prompt را می‌نویسد و کلاینت POST به /chats/{chatId}/messages می‌فرستد.
  2. API Gateway درخواست را احراز هویت و به Chat Service هدایت می‌کند.
  3. Chat Service پیام کاربر را در جدول messages می‌نویسد.
  4. Chat Service تماس همگام با Inference Service می‌گیرد، که Prompt را از مدل رد می‌کند و وقتی تمام شد Completion کامل را برمی‌گرداند.
  5. Chat Service پیام دستیار را به جدول messages می‌نویسد و به کلاینت برمی‌گرداند.

تقسیم بین Chat Service و Inference Service تنها تصمیم طراحی است که اینجا ارزش مکث دارد. لایه‌ی چت ارزان و Stateless است، در حالی که Inference GPU-bound و گران است و چون می‌خواهیم این دو را مستقل مقیاس بدهیم، همین حالا جدایشان می‌کنیم.

مشکل بدیهی این است که کاملاً همگام است، پس کلاینت روی آن HTTP Call می‌نشیند تا کل پاسخ تولید شود، و پاسخ بلند می‌تواند تا ۳۰ ثانیه طول بکشد. این ۳۰ ثانیه صفحه‌ی خالی است که نیازمندی TTFT ما را نقض می‌کند و خراب احساس می‌شود.

علاوه بر آن، Chat Service مستقیم Worker روی GPU را بدون هیچ Admission Control صدا می‌زند. هیچ چیز تصمیم نمی‌گیرد وقتی Workerها از قبل پرند کدام درخواست‌ها را بپذیرد یا برگرداند. این به محض اینکه GPUها گلوگاه شوند می‌شکند. مشکل اول را با Streaming و دومی را با لایه‌ی زمان‌بندی در Deep Dive ها حل می‌کنیم. فعلاً کار می‌کند.

۲) کاربران بتوانند چت‌های گذشته را ببینند و با Context منتقل‌شده بین نوبت‌ها مکالمه را ادامه دهند

کاربران انتظار دارند فردا برگردند، مکالمه‌های قدیمی را مرور کنند، یکی را باز کنند و ادامه دهند انگار مدل همه‌چیز را به خاطر دارد. دو چیز باید رخ دهد: مسیر خواندن برای چت‌های گذشته و انتقال Context در نوبت بعدی.

به هیچ سرویس جدیدی نیاز نداریم. Endpointهای خواندن را روی جدول‌های موجود Postgres اضافه می‌کنیم و مرحله‌ی بارگذاری Context را داخل Chat Service.

برای مسیر خواندن:

  • GET /chats چت‌های کاربر را مرتب‌شده بر اساس فعالیت اخیر برمی‌گرداند، با Cursor Pagination برای نوار کناری.
  • GET /chats/{chatId}/messages پیام‌های یک چت را برمی‌گرداند، با Cursor Pagination تا مکالمه‌ی بلند یک‌جا بارگذاری نشود.

برای انتقال Context، وقتی کاربر Prompt بعدی را روی چت موجود می‌فرستد:

  1. Chat Service جدول messages را برای پیام‌های قبلی آن chatId، مرتب بر اساس زمان ساخت، Query می‌کند.
  2. Prompt را با الحاق آن پیام‌ها (با نقش‌هایشان، کاربر در برابر دستیار) و بعد پیام جدید کاربر می‌سازد.
  3. آن Prompt ترکیبی را درست مثل نوبت اول به Inference Service می‌فرستد.
  4. پیام جدید دستیار به جدول messages نوشته می‌شود تا نوبت بعد هم بتواند آن را بخواند.

این ساده‌ترین چیزی است که جواب می‌دهد. مدل هر نوبت کل مکالمه را می‌بیند، پس طوری رفتار می‌کند که انگار به خاطر دارد. اما فرستادن تاریخچه‌ی کامل در هر نوبت دو مشکل بدیهی دارد. وقتی مکالمه از Context Window مدل بزرگ‌تر شود می‌شکند، و هر نوبت گران‌تر می‌شود چون توکن‌های ورودی برای هر فراخوانی صورت‌حساب می‌شوند. این را در Deep Dive ها با خلاصه‌سازی و Prefix Caching حل می‌کنیم.

این سیستم کارایی می‌دهد. هر دو نیازمندی عملکردی را برآورده می‌کند و گلوگاه‌هایی دارد که نیازمندی‌های غیرعملکردی ما درباره‌شان هشدار دادند. بیایید درستشان کنیم.

Deep Dive های احتمالی

با برآورده شدن نیازمندی‌های عملکردی، وقت برگشتن و برآورده کردن نیازمندی‌های غیرعملکردی است. مجموعه‌ی درست واحدی از Deep Dive ها یا ترتیب واحدی برای پرداختن به آن‌ها نیست. در مصاحبه‌ی واقعی، وقتی نیازمندی‌های غیرعملکردی را از ابتدا مشخص کردید با مصاحبه‌کننده درباره‌ی آنچه مهم است توافق کرده‌اید.

این‌ها مواردی هستند که برای این سؤال انتظار دارم پیش بیایند، تقریباً به ترتیبی که طراحی شما را به سمتشان می‌کشد. با Stream کردن پاسخ و زنده نگه داشتن آن Stream شروع می‌کنیم، بعد به زمان‌بندی GPUها، اشتراک عادلانه‌شان و کنترل هزینه می‌رویم.

۱) چطور توکن‌ها را به کلاینت Stream کنیم؟

طراحی همگام ما کاربر را تا ۳۰ ثانیه منتظر می‌گذارد تا صفحه‌ی خالی به پاسخ کامل تبدیل شود. درست کردنش یعنی گذاشتن توکن‌ها روی صفحه همان‌طور که مدل تولیدشان می‌کند، و دو اتصال دخیل است. یکی از کلاینت به Chat Service و دیگری از Inference Service به Chat Service.

دومی را فعلاً داده‌شده بگیریم و فرض کنیم Worker توکن‌ها را هنگام تولید روی تماس gRPC با Server-streaming پس می‌دهد. gRPC فریم‌ورک فراخوانی سرویس به سرویس است که روی HTTP/2 اجرا می‌شود. Chat Service یک تماس می‌گیرد و Worker پیام‌ها را روی آن اتصال باز، یک توکن در هر بار، پس می‌فرستد. تماس ساده‌ی درخواست/پاسخ جواب نمی‌داد، چون Worker را مجبور می‌کرد کل Completion ۳۰ ثانیه‌ای را بافر کند و یک‌جا برگرداند، که دوباره همان صفحه‌ی خالی است. برای این پرش داخلی gRPC را به‌جای WebSocket انتخاب می‌کنیم چون چارچوب‌بندی باینری و Multiplexing در HTTP/2 بین سرویس‌های خودمان را می‌خواهیم و Protocol Buffers قرارداد تایپ‌شده روی آن می‌دهد.

همین نیمه‌ای را باقی می‌گذارد که کاربر واقعاً می‌بیند: Chat Service آن Stream را چطور به مرورگر می‌رساند.

الگو، Real-time Updates: Stream کردن توکن‌های LLM مسئله‌ی کتاب‌درسی به‌روزرسانی بلادرنگ است. مرورگر به کانال Push زنده از سرور نیاز دارد. Backend به راهی نیاز دارد که هر توکن را از Workerی که تولیدش کرده به سروری برساند که اتصال کاربر را نگه می‌دارد. همان گزینه‌های انتقال (Long-polling، SSE، WebSocket) و همان Fan-out در Backend در کامنت‌های زنده، ویرایش مشارکتی و داشبوردهای زنده دیده می‌شود.

رویکرد اول، Polling: کلاینت Prompt را می‌فرستد و بعد هر چند صد میلی‌ثانیه یک Endpoint وضعیت را Poll می‌کند و می‌پرسد «توکن جدیدی هست؟». سرور با هر چیزی که از آخرین Poll تولید شده جواب می‌دهد. برای اینکه کار کند، توکن‌های Worker Inference باید جایی بنشینند که Endpoint پولشده بتواند بخواند. پس به بافر بین Worker و Chat Service نیاز داریم.

می‌توانیم در Redis بافر کنیم، که سریع است ولی اگر Restart شود همه‌چیز را می‌اندازد. یا می‌توانیم هر Chunk را در Postgres بنویسیم، که ماندگار است ولی یک پاسخ را به صدها نوشتن ریز روی سیستم ثبت تبدیل می‌کند. برای بحث Postgres را می‌گیریم، با اذعان به اینکه هیچ‌کدام ایده‌ی خوبی نیستند.

چالش‌ها: Polling برای دقیقاً همان معیاری که به آن اهمیت می‌دهیم بد است. TTFT به فاصله‌ی Poll گره می‌خورد، پس Poll ۵۰۰ میلی‌ثانیه‌ای تا ۵۰۰ میلی‌ثانیه زمان مرده پیش از ظاهر شدن اولین توکن اضافه می‌کند. همچنین اتلاف است. با ۱۲۰ هزار Stream هم‌زمان، Poll هر ۳۰۰ میلی‌ثانیه ۴۰۰ هزار درخواست در ثانیه است، که اکثریت قاطعشان خالی برمی‌گردند.

و تجربه ناهموار است. به‌جای جریان توکن‌ها یکی‌یکی، کاربر آن‌ها را به‌صورت دسته در هر Poll می‌گیرد، که توهم تایپ نرم را که محصول را زنده احساس می‌کند خراب می‌کند. راه‌حل هر سه مشکل یک ایده است: دیگر نپرس و بگذار سرور Push کند.

رویکرد دوم، WebSocket: به‌جای اینکه کلاینت مدام بپرسد، یک اتصال پایدار باز می‌کنیم و می‌گذاریم سرور توکن‌ها را در لحظه‌ی وجودشان Push کند. در سمت مرورگر آن اتصال WebSocket بین کلاینت و Instance از Chat Service است که Prompt را مدیریت کرده. کانال Full-duplex است که تمام عمر تولید باز می‌ماند.

نتیجه دو Stream زنجیره‌ای از طریق یک Instance از Chat Service است. Worker توکن‌ها را از طریق gRPC به آن می‌فرستد و آن هر کدام را روی WebSocket به مرورگر Relay می‌کند. TTFT حالا به سرعت تولید اولین توکن توسط مدل محدود می‌شود، نه هر فاصله‌ی Poll، و جریان نرم است چون هر توکن در لحظه‌ی رسیدن Forward می‌شود.

چالش‌ها: خوب کار می‌کند و برای بسیاری از قابلیت‌های بلادرنگ انتخاب درست است. اما بیش از چیزی است که این کار خاص لازم دارد. WebSocket دوطرفه است و در طول تولید ما همیشه فقط یک‌طرفه Push می‌کنیم، از سرور به کلاینت. کلاینت وقتی Prompt در جریان است چیزی روی آن کانال پس نمی‌فرستد.

پروتکل Stateful دوطرفه با Handshake ارتقا و حسابداری برای هر اتصال را به دوش می‌کشیم. هر Load Balancer و Proxy در مسیر هم باید WebSocket بفهمد. وقتی فقط یک جهت را استفاده می‌کنیم این زیاد است. گزینه‌ی سبک‌تری برای دقیقاً همین شکل هست.

رویکرد سوم، Server-Sent Events (SSE): SSE برای Streaming یک‌طرفه از سرور به کلاینت ساخته شده است. کلاینت درخواست HTTP معمولی را با EventSource باز می‌کند. سرور آن پاسخ را باز نگه می‌دارد و با تولید توکن‌ها مدام رویدادهای data: به آن می‌نویسد و مرورگر برای هر کدام رویدادی فعال می‌کند.

روی HTTP ساده بدون ارتقای پروتکل اجرا می‌شود، پس هر Proxy و Load Balancer در مسیر از قبل آن را مدیریت می‌کند. مرورگر روی اولین رویداد شروع به رندر می‌کند. به‌عنوان جایزه، API مربوط به EventSource هنگام قطع اتصال خودکار دوباره وصل می‌شود، که در Deep Dive بعدی روی آن تکیه می‌کنیم.

برای Push کردن توکن‌ها به مرورگر ابزار درست است. همان اولین توکن آنی و جریان نرم WebSocket را می‌گیریم، بدون پرداخت هزینه‌ی کانال دوطرفه. Backend نسبت به گزینه‌ی WebSocket بدون تغییر است. Chat Service هنوز توکن‌ها را از Worker روی Stream مربوط به gRPC می‌گیرد و Relay می‌کند، فقط حالا به‌جای WebSocket از طریق SSE Forward می‌کند.

پیش از اتصال این، به اسمی برای چیزی که Stream می‌شود نیاز داریم. Entityهای ما تا اینجا User، Chat و Message هستند و هیچ‌کدام جور نیست. Message متن تمام‌شده است، در حالی که چیزی که Stream می‌کنیم تلاشی برای تولید آن است، و آن تلاش چرخه‌ی عمر خودش را دارد. می‌تواند در صف باشد، می‌تواند اجرا شود، می‌تواند وسط کار شکست بخورد، می‌تواند لغو شود و می‌تواند Retry شود، همه پیش از اینکه پیام کامل دستیاری باشد که بنویسیم.

پس جدول runs را اضافه می‌کنیم. Run یک تلاش برای تولید پاسخ دستیار است. Chat Service آن را با runId تازه وقتی پیام کاربر می‌رسد می‌سازد، پیش از شروع Inference. ردیف chatId و messageId متعلق به آن، یک status که از queued به streaming و بعد به done، cancelled یا failed می‌رود، مدلی که سرویسش داد، و شمارش توکن ورودی و خروجی که برای صورت‌حساب و سهمیه به آن تکیه می‌کنیم را دارد.

آن runId چیزی است که Endpoint پیام را از طرح API بازشکل می‌دهد. POST /chats/{chatId}/messages دیگر منتظر مدل نمی‌ماند. Run را می‌سازد و فوراً { runId } برمی‌گرداند، و کلاینت توکن‌ها را روی GET /runs/{runId}/stream جداگانه دنبال می‌کند. نگه داشتن آن Stream روی GET مهم است چون API بومی EventSource مرورگر فقط درخواست‌های GET باز می‌کند.

هر چیزی که در Deep Dive های پیش رو می‌سازیم، یعنی صف، Workerها و مسیر لغو، به همان Run ارجاع می‌دهد.

این نسخه‌ی اول ساده‌ی Streaming را می‌دهد:

  1. Chat Service پیام کاربر را ماندگار می‌کند، Run می‌سازد و runId را فوراً به کلاینت برمی‌گرداند.
  2. کلاینت اتصال SSE برای آن runId باز می‌کند.
  3. Chat Service Prompt جمع‌شده را به Inference Service می‌فرستد و Worker توکن‌ها را هنگام تولید روی gRPC پس می‌دهد.
  4. Chat Service هر توکن را روی اتصال SSE Forward می‌کند و مرورگر آن را اضافه می‌کند.
  5. کاربر به محض شروع تولید مدل شروع به خواندن پاسخ می‌کند، به‌جای انتظار ۳۰ ثانیه برای کل آن.

۲) چطور Stream را در برابر اتصال دوباره و Deploy زنده نگه داریم؟

این کار می‌کند، ولی فرض می‌کند یک سرور ثابت بین این کاربر و مدل برای کل ۳۰ ثانیه می‌نشیند. Instance از Chat Service که اتصال SSE را نگه می‌دارد همان است که Stream مربوط به gRPC را از Worker نگه می‌دارد، پس یک Instance مشخص باید در تمام تولید وسط بنشیند. لایه‌ی Chat Service ما Stateless است، پشت Load Balancer افقی مقیاس می‌گیرد و تمام روز دوباره Deploy می‌شود، که ناپدید شدن آن Instance وسط تولید را روتین می‌کند.

چیزی که می‌خواهیم جدا کردن Workerی است که توکن تولید می‌کند از Instance از Chat Service که تحویلشان می‌دهد. Worker لازم نیست بداند کدام Instance اتصال کاربر را نگه می‌دارد و وقتی کلاینت دوباره وصل می‌شود و روی Instance متفاوتی فرود می‌آید، آن Instance باید بتواند همان Run را از جایی که قبلی رها کرد بردارد.

رویکرد اول، Pub/Sub: می‌توانیم دو طرف را با گذاشتن چیزی بینشان به‌جای سیم‌کشی مستقیم Instance از Chat Service به Worker جدا کنیم. مشخصاً Pub/Sub بین Workerها و لایه‌ی Chat Service اضافه می‌کنیم که با runId کلیدگذاری شده است.

Worker Inference هر توکن را روی کانال آن runId منتشر می‌کند. Instance از Chat Service که اتصال SSE کلاینت را نگه می‌دارد مشترک آن کانال می‌شود و توکن‌ها را به مرورگر Forward می‌کند. Worker دیگر اهمیت نمی‌دهد کدام Instance از Chat Service وصل است و Chat Service دیگر اهمیت نمی‌دهد کدام Worker تولید می‌کند.

کلاینت حالا می‌تواند به Instance متفاوتی از Chat Service دوباره وصل شود و آن Instance جدید فقط مشترک همان کانال می‌شود. Deploy یا Crash دیگر Stream را کاملاً نمی‌کشد. Redis Pub/Sub انتخاب طبیعی است.

هر توکن را به‌صورت Delta منتشر می‌کنیم، نه کل پیام تا اینجا. منتشر کردن مجدد متن تجمعی در هر توکن درجه‌ی دوم می‌شود. پاسخ ۴۰۰۰ توکنی ۴۰۰۰ بار پیشوند در حال رشد را دوباره می‌فرستد و با ۱۲۰ هزار Stream هم‌زمان و پاسخ‌هایی تا ۳۰ هزار توکن آن پهنای باند غیرقابل‌قبول است. Deltaها هر توکن را دقیقاً یک بار روی سیم می‌گذارند.

چالش‌ها: Pub/Sub فراموش‌کن (Fire-and-forget) است. پیام را فقط به مشترکانی می‌رساند که در لحظه‌ی انتشار وصل‌اند و چیزی بافر نمی‌کند. (طبق مستندات Redis، تحویل در Pub/Sub «حداکثر یک بار» است و مشترکی که آفلاین باشد پیام را کاملاً از دست می‌دهد.)

پس پنجره‌ی بین افتادن Instance قدیمی از Chat Service و مشترک شدن Instance جدید را تصور کنید، که Deploy یا Crash می‌سازد. Worker به منتشر کردن توکن ادامه می‌دهد و هیچ مشترکی برای دریافتش نیست. توکن‌های منتشرشده در آن شکاف برای همیشه گم می‌شوند. کاربر با سوراخی در وسط پاسخش می‌ماند که تا پایان تولید و Refetch پیام تکمیل‌شده توسط کلاینت پر نمی‌شود.

رویکرد دوم، Redis Stream: کانال با کلید runId را نگه دارید، ولی Pub/Sub فراموش‌کن را با Redis Stream عوض کنید، که Log از نوع Append-only است و ورودی‌هایش را به خاطر می‌سپارد. هر ورودی شناسه دارد، پس مصرف‌کننده می‌تواند هر چیز بعد از آخرین ورودی که دیده را بخواهد. Worker هر Delta توکن را با XADD به Stream آن runId اضافه می‌کند. Instance از Chat Service با XREAD مسدودکننده از آخرین شناسه‌ی ورودی که این کلاینت از قبل دیده می‌خواند. ورودی‌های جدید را روی اتصال SSE Forward می‌کند و در طول کار آخرین شناسه را ردیابی می‌کند.

حالت اتصال دوباره حالا تمیز است. وقتی کلاینت قطع و به Instance دیگری از Chat Service دوباره وصل می‌شود، آن Instance XREAD خود را از آخرین شناسه‌ی دیده‌شده‌ی کلاینت از سر می‌گیرد. دقیقاً ورودی‌هایی را که در شکاف از دست رفته Replay می‌کند و بعد زنده ادامه می‌دهد. بدون سوراخ، بدون انتظار تا پایان، و کاربر یک پاسخ پیوسته می‌بیند حتی با اینکه هم اتصال و هم Instance از Chat Service پشت آن عوض شده.

هزینه را با MAXLEN محدود نگه می‌داریم، پس هر Stream فقط پنجره‌ی اخیر توکن‌ها را نگه می‌دارد، نه اینکه بی‌حد رشد کند. همچنین به Stream یک TTL کوتاه می‌دهیم تا وقتی تولید تمام شد پس گرفته شود. حافظه سقف‌دار می‌ماند و هر توکن هنوز یک بار روی سیم می‌رود چون Delta می‌فرستیم، نه Snapshot. Stream حالت کاری کوتاه‌مدت است، نه نسخه‌ی دوم تاریخچه.

برای روشن بودن درباره‌ی Durability، Stream سیستم ثبت ما نیست. وقتی تولید تمام می‌شود، Worker پیام کامل دستیار را در Postgres می‌نویسد و Run را done علامت می‌زند، و آن پیام ماندگار نسخه‌ی ماندگاری است که کلاینت همیشه می‌تواند دوباره بگیرد. Redis Stream فقط برای نگه داشتن Stream زنده بدون شکاف هنگام اتصال دوباره وجود دارد، که به همین دلیل Durability هر توکن زیر خط می‌ماند حتی با اینکه کوتاه‌مدت توکن‌ها را اینجا نگه می‌داریم. (توجه: Stream در Redis هم به تنظیمات Persistence وابسته است. با پیش‌فرض‌ها Crash می‌تواند ورودی‌های اخیر را از دست بدهد.)

جریان کامل وقتی کاربر Prompt می‌فرستد:

  1. Chat Service پیام کاربر را ماندگار می‌کند و Run با runId تازه می‌سازد و فوراً به کلاینت برمی‌گرداند.
  2. Chat Service Prompt جمع‌شده و آن runId را به Worker Inference می‌دهد که شروع به تولید می‌کند.
  3. کلاینت اتصال SSE برای آن runId باز می‌کند و Load Balancer آن را به هر Instance از Chat Service هدایت می‌کند.
  4. آن Instance XREAD مسدودکننده روی Stream مربوط به runId شروع می‌کند، از ابتدا برای اتصال تازه یا از آخرین شناسه‌ی ورودی دیده‌شده‌ی کلاینت هنگام اتصال دوباره.
  5. Worker Inference توکن تولید می‌کند و هرکدام را با XADD به Stream مربوط به runId اضافه می‌کند.
  6. Instance از Chat Service ورودی‌های جدید را می‌خواند، روی اتصال SSE Forward می‌کند و مرورگر آن‌ها را اضافه می‌کند. اگر اتصال قطع شود، مرورگر دوباره وصل می‌شود، روی Instance دیگری فرود می‌آید و آن Instance از آخرین شناسه‌ی دیده‌شده Replay می‌کند و بعد زنده ادامه می‌دهد.

توجه کنید گام ۲ چقدر چیز را رد می‌کند. Chat Service Prompt را به Worker می‌دهد و شروع به تولید می‌کند، انگار همیشه Workerی بیکار منتظر است. با ۲۰ هزار Prompt در ثانیه در برابر Pool از GPUها که نمی‌توانیم بر حسب تقاضا اضافه کنیم، اغلب نیست.

۳) چطور درخواست‌های تولید را بین Workerهای GPU مسیریابی و زمان‌بندی کنیم؟

GPUها گلوگاه‌اند. گران‌ترین منبع سیستم‌اند و کم‌یاب‌ترین، پس اینکه کار را چطور به آن‌ها مسیریابی کنیم هم هزینه و هم Latency ما را تحت بار تعیین می‌کند.

مقیاس آن هزینه به‌راحتی دست‌کم گرفته می‌شود. مدل Frontier برای جا شدن روی یک GPU بسیار بزرگ است، پس وزن‌هایش بین همه‌ی GPUهای یک سرور، معمولاً هشت تا، تقسیم می‌شود. سرویس‌دهی به ۱۲۰ هزار Stream هم‌زمان یعنی بالا آوردن هزاران از آن سرورها. این شما را به ده‌ها هزار GPU برای همین یک مدل می‌رساند، و آزمایشگاه‌هایی که سیستم‌هایی در این مقیاس اجرا می‌کنند مبالغ خیره‌کننده‌ای صرف محاسبات می‌کنند، به‌راحتی صدها میلیون تا میلیاردها دلار در سال. وقتی سخت‌افزار این‌قدر هزینه دارد، هر درصد ظرفیت بلااستفاده پول واقعی است.

برای لنگر دنیای واقعی، محاسبات Inference گزارش‌شده‌ی OpenAI در ۲۰۲۴ حدود ۱٫۸ میلیارد دلار بود (و Epoch AI برآورد می‌کند در همان سال حدود ۵ میلیارد دلار هم برای R&D محاسبات خرج شد) و از آن زمان به چند میلیارد رسیده است. صورت‌حسابی با این اندازه چیزی است که همه‌ی تلاش مهندسی را که برای فشردن بیشتر از هر GPU خرج می‌کنیم توجیه می‌کند.

الگو، Managing Long Running Tasks: یک تولید می‌تواند ۳۰ ثانیه روی Worker محدود و گران اجرا شود. این الگوی Long-running Tasks است. به‌جای گره زدن Thread درخواست به انتظار، کار را از طریق صف به Pool از Workerها می‌دهید و بگذارید وقتی ظرفیت دارند بکشند. همین شکل صف به‌علاوه‌ی Worker Pool در Transcoding ویدیو، Batch Jobهای ML و هر سیستمی که واحد کار برای انجام درون‌خطی بسیار سنگین است دیده می‌شود.

رویکرد اول، صف درخواست: صف درخواست بین Chat Service و Workerهای GPU بگذارید. Chat Service درخواست تولید (Prompt به‌علاوه‌ی runId) را Enqueue می‌کند و سریع برمی‌گردد. سرورهای Inference وقتی ظرفیت دارند از صف می‌کشند، تولید می‌کنند و توکن‌ها را به Stream مربوط به runId از Deep Dive ۲ Append می‌کنند. این مبتنی بر Pull است، پس سرور فقط وقتی کار جدید می‌گیرد که واقعاً بتواند انجامش دهد.

صف بین Front-end جهشی و Back-end با نرخ ثابت جا می‌دهد. Promptها در انفجار می‌رسند، تا ۲۰ هزار در ثانیه در اوج. Worker Pool فقط می‌تواند درخواست‌ها را با سرعتی که GPUهایش اجازه می‌دهند پردازش کند، که نرخ بسیار پایین‌تر و یکنواخت‌تری است. بدون چیزی در میان، جهش یا Workerها را غرق می‌کند یا رها می‌شود. صف جهش را جذب می‌کند و می‌گذارد با هر سرعتی که Workerها می‌توانند تحمل کنند تخلیه شود، پس موج به چند ثانیه انتظار اضافه تبدیل می‌شود نه انبوه خطا.

چالش‌ها: این جهش‌ها را خیلی بهتر مدیریت می‌کند، ولی صف ساده هنوز دو مشکل دارد. صف نامحدود است، پس هرچند جهش موقت را جذب می‌کند، تقاضای پایدار بالاتر از توان Workerها فقط خط را بی‌حد بلند می‌کند. کاربر می‌تواند پشت هزاران درخواست صف‌شده بنشیند بدون اینکه بداند پاسخش دو ثانیه دیگر است یا دو دقیقه. به راهی برای سقف گذاشتن به آن انتظار و گفتن صادقانه به کاربر که شلوغ هستیم نیاز داریم، به‌جای رها کردنش روی Spinner تا ابد.

علاوه بر آن، صف هنوز هر تولید را مثل Job مستقل روی Worker می‌بیند، که مقدار زیادی عملکرد GPU را ناادعا می‌گذارد. GPUها وقتی بیشترین کارایی را دارند که تعداد زیادی دنباله را با هم پردازش کنند و یک درخواست به ازای هر اسلات Worker از این بهره نمی‌برد.

رویکرد دوم، Continuous Batching و Backpressure: صف و Workerهای مبتنی بر Pull را نگه دارید، بعد Continuous Batching اضافه کنید. به‌جای دادن یک درخواست در هر بار به GPU، هر Worker دنباله‌های فعال زیادی را با هم اجرا می‌کند. در Decoding، یک Forward Pass می‌تواند توکن بعدی را برای هر دنباله در Batch تولید کند.

Batch پیوسته عوض می‌شود. وقتی یک دنباله تمام می‌شود، Worker آن را حذف می‌کند و درخواست دیگری را از صف به اسلات تازه‌آزاد‌شده می‌کشد. این از انتظار برای تمام شدن کل Batch پیش از شروع کار جدید اجتناب می‌کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GPU batch

Request A -> token 42
Request B -> token 17
Request C -> token 91
Request D -> token 8

one forward pass

Request A -> token 43
Request B -> token 18
Request C -> finished
Request D -> token 9

next pass

Request A -> token 44
Request B -> token 19
Request E -> token 1
Request D -> token 10

همین Batching را برای بهره‌وری GPU این‌قدر مهم می‌کند. یک دنباله‌ی Decoding تنها اغلب نمی‌تواند همه‌ی محاسبات موازی GPU را استفاده کند، در حالی که پردازش دنباله‌های زیاد با هم کار بسیار بیشتری هم‌زمان به GPU می‌دهد. پس یک Replica می‌تواند ده‌ها دنباله یا بیشتر را در جریان نگه دارد، به‌جای سرویس‌دهی ترتیبی به آن‌ها.

چرا Batching بهره‌وری را این‌قدر بهبود می‌دهد به نحوه‌ی اجرای Inference مدل توسط GPUها برمی‌گردد، که توضیح جانبی خودش را دارد (در پایان این بخش).

در نهایت می‌توانیم Backpressure اضافه کنیم تا سیستم به‌جای فروپاشی قابل‌پیش‌بینی افت کند. به صف عمق محدود و سیاست پذیرش بدهید. وقتی صف خیلی عمیق شد، درخواست‌های جدید را رد، به تعویق یا حذف (Shed) کنید، به‌جای اینکه اجازه دهید Latency بی‌حد رشد کند.

هدف گذاشتن سقف بالا برای مقدار کاری است که سیستم حاضر است بپذیرد. کاربران یا وارد صفی با اندازه‌ی قابل‌مدیریت می‌شوند یا سریع «ظرفیت پر است، بعداً امتحان کنید» می‌گیرند، به‌جای پیوستن به Backlog رو‌به‌رشد و انتظار نامحدود.

با هم:

  1. Chat Service درخواست تولید را با runId و برآورد هزینه‌ی توکن Enqueue می‌کند.
  2. Admission Control عمق صف را بررسی می‌کند. اگر از حد بالاتر باشد، درخواست حذف یا به تعویق می‌افتد (Deep Dive عدالت پوشش می‌دهد لایه‌ها این ترتیب را چطور عوض می‌کنند).
  3. Worker GPU درخواست را می‌کشد و از طریق Continuous Batching در Batch در حال اجرایش وارد می‌کند.
  4. Worker توکن‌ها را هنگام تولید Batch به Stream مربوط به runId Stream می‌کند.
  5. وقتی تولید تمام شد (یا لغو شد)، Worker دنباله را از Batch حذف می‌کند و درخواست بعدی را می‌کشد.

چالش‌ها: Trade-off لایه‌ی سرویس‌دهی پیچیده‌تر است، که حالا باید دنباله‌های با طول متغیر را که وارد و خارج Batch می‌شوند بچرخاند. Batch از Promptهایی با طول بسیار متفاوت هنوز می‌تواند مقداری کار GPU را بیکار بگذارد. فریم‌ورک Inference بیشترش را برایتان مدیریت می‌کند، ولی Worker هنوز خیلی پیچیده‌تر از «یک Prompt را اجرا کن» است.

تصمیم محصولی باقی‌مانده این است که وقتی ظرفیت پر است کدام درخواست‌ها را رد کنیم.

توضیح جانبی، چرا Batching این‌قدر کمک می‌کند؟ GPU وزن‌های مدل را در حافظه‌ی با پهنای باند بالا (HBM) خود نگه می‌دارد، ولی واحدهای محاسباتی نمی‌توانند روی آن‌ها همان‌جا ریاضی انجام دهند. آن‌ها از Pool بسیار کوچکی از حافظه‌ی روی تراشه کار می‌کنند که هیچ‌جا به‌اندازه‌ی کافی برای نگه داشتن میلیاردها وزن بزرگ نیست. پس هر Forward Pass باید کل مجموعه‌ی وزن‌ها را از HBM از میان واحدهای محاسباتی جریان دهد تا یک توکن تولید کند.

وزن‌ها را قطعات انبار و محاسبات را میز کار کوچک تصور کنید. قطعات هرگز ساختمان را ترک نمی‌کنند، اما برای ساختن هر چیز هنوز باید کل مجموعه را از انبار به میز حمل کنید. برای یک توکن آن حمل کامل را می‌کنید و بعد مقدار ناچیزی مونتاژ انجام می‌دهید، ریاضی یک بردار. میز بیشتر وقت‌ها بیکار می‌نشیند و منتظر بار بعدی است. به همین دلیل می‌گویند تولید توکن Memory-bandwidth bound است.

Batching هر حمل را ارزشمند می‌کند. همان قطعات را یک بار به میز بیاورید، بعد سفارش‌های زیادی را پر کنید پیش از برگرداندن‌شان. Worker Batch از دنباله‌ها را با هم اجرا می‌کند و هر Forward Pass همه‌ی آن‌ها را یک توکن جلو می‌برد. وزن‌ها را یک بار Stream می‌کنیم و به‌جای یک توکن، ده‌ها توکن به دست می‌آوریم.

بخش «پیوسته» Batch را پر نگه می‌دارد. Batch ثابت منتظر می‌ماند همه‌ی دنباله‌ها تمام شوند و بعد بعدی را شروع می‌کند. اما پاسخ تک‌خطی می‌تواند کنار مقاله‌ی ۲۰۰۰ توکنی بنشیند، پس Batch خالی می‌شود و GPU به بیکاری برمی‌گردد در حالی که کندترین دنباله تنها ادامه می‌دهد. Continuous Batching دنباله را در لحظه‌ی تمام شدنش اخراج می‌کند و درخواست صف‌شده‌ای را در جایش می‌گذارد. این همان چیزی است که سرورهای Inference تولیدی مثل vLLM و TGI یک Replica را با ده‌ها دنباله هم‌زمان مشغول نگه می‌دارند.

۴) چطور جلوی تسلط کاربران سنگین بر Pool GPU را بگیریم و در عین حال تجربه‌ی بهتری به لایه‌های پولی بدهیم؟

این سیستم چندمستأجره (Multi-tenant) است که Pool محدود GPU را به اشتراک می‌گذارد و هزینه‌ها به‌شدت نابرابرند. یک کاربر که Prompt ۳۰ هزار توکنی می‌فرستد بسیار بیشتر از صد کاربر که یک‌خطی می‌فرستند محاسبات می‌سوزاند. به اشتراک عادلانه نیاز داریم تا هیچ‌کس نتواند همه‌ی دیگران را گرسنه بگذارد و به اولویت کسب‌وکاری بین لایه‌ها نیاز داریم تا مشتریان پولی وقتی اوضاع تنگ است تجربه‌ی بهتری بگیرند. سقف ساده‌ی درخواست در دقیقه نمی‌تواند هیچ‌کدام را بیان کند.

رویکرد اول، Rate Limit سراسری: ساده‌ترین راه حل یک Rate Limit سراسری است، یک سقف درخواست در دقیقه که همه‌ی ترافیک را پوشش می‌دهد. مکانیزم Rate Limiter استاندارد API در API Gateway است، جایی که می‌تواند درخواست را پیش از هزینه کردن برای ما رد کند. یک شمارنده، افزایش و بررسی Atomic در هر درخواست و 429 وقتی شمارش از حد پنجره‌ی فعلی بگذرد. چون لایه‌ی اپلیکیشن ما Stateless و افقی مقیاس‌یافته است، آن شمارنده باید جایی مشترک زندگی کند، که در عمل یعنی Redis.

چالش‌ها: یک شمارنده برای همه یعنی حد نمی‌داند چه کسی می‌پرسد. کاربر واحدی که API را می‌کوبد بودجه‌ی سراسری را می‌سوزاند و بقیه شروع به جمع کردن 429 برای ترافیکی می‌کنند که نفرستادند. این همان مشکل گرسنگی است که برای حلش آمدیم، حالا توسط Rate Limiter خودمان اعمال می‌شود.

رویکرد دوم، Rate Limit برای هر کاربر: راه‌حل همان ماشین‌آلات است، فقط با کلید متفاوت. به‌جای یک شمارنده‌ی سراسری، Limiter را با userId کلیدگذاری می‌کنیم، پس هر کاربر Bucket خودش را دارد و سیل یک کاربر نمی‌تواند ظرفیت دیگران را تخلیه کند.

برای سقف هم‌زمانی، شمارنده‌ی هر کاربر در Redis است که وقتی تولید شروع می‌شود افزایش و وقتی تمام یا لغو می‌شود کاهش می‌یابد. پیش از پذیرش تولید جدید، شمارنده‌ی آن کاربر را بررسی می‌کنیم. اگر از قبل در سقفش است، درخواست را رد یا صف می‌کنیم تا یکی از تولیدهای در جریانش آزاد شود.

تنها چیزی که واقعاً از حد سراسری عوض شده کلید است، از یک Bucket مشترک به یک Bucket برای هر کاربر. ولی همین تغییر است که جلوی گرسنه گذاشتن Pool توسط کاربر سنگین واحد را می‌گیرد.

چالش‌ها: این مشکل گرسنگی را حل می‌کند چون یک کاربر دیگر نمی‌تواند Pool را بگیرد. ولی هنوز درخواست را می‌شمارد، نه هزینه را. سقف هر کاربر «۵ تولید در جریان» پنج پاسخ ۵۰ توکنی را مثل پنج تولید ۳۰ هزار توکنی می‌بیند، با اینکه دومی مرتبه‌ها GPU بیشتر مصرف می‌کند. و به‌تنهایی هنوز معامله‌ی بهتر معناداری به لایه‌های پولی نمی‌دهد. باید هزینه‌ی واقعی را بسنجیم و لایه‌ها را در زمان‌بندی بپزیم.

رویکرد سوم، بودجه‌ی توکن با اولویت لایه: راه‌حل سنجیدن چیزی است که واقعاً محدود است، یعنی توکن‌ها به‌جای تعداد درخواست. هر دو عدد را از قبل داریم. توکن‌های ورودی وقتی Prompt جمع شد دقیقاً معلوم‌اند و توکن‌های خروجی یکی‌یکی روی Streamی که در Deep Dive ۲ ساختیم برمی‌گردند، که دلیل ثبت هر دو در ردیف Run است.

پس Limiter همان Token Bucket قبلی است، کلیدگذاری‌شده با userId، با نرخ پر شدن تعیین‌شده توسط لایه‌ی کاربر. بررسی از API Gateway به Chat Service می‌رود، چون Gateway فقط درخواست خام می‌بیند در حالی که Chat Service Prompt را می‌سازد و بنابراین می‌داند هزینه‌اش چقدر خواهد بود. هنگام پذیرش، شمارش دقیق ورودی را کم می‌کند و حداکثر خروجی درخواستی را رزرو می‌کند. وقتی Run تمام می‌شود، Worker شمارش واقعی خروجی را در ردیف Run می‌نویسد و بخش استفاده‌نشده‌ی رزرو به Bucket برمی‌گردد. چون بدترین حالت را از قبل رزرو کردیم، Run هرگز نمی‌تواند بیشتر از آنچه کاربر هنگام شروع می‌توانست بپردازد هزینه داشته باشد، پس هرگز مجبور نیستیم Stream را وسط جمله بکشیم تا در بودجه بمانیم.

بعد اولویت لایه را روی آن لایه کنید. کاربران پولی بودجه‌های بزرگ‌تر، سقف هم‌زمانی بالاتر و اولویت بالاتر در صف از Deep Dive ۳ می‌گیرند. مشخصاً صف آگاه از لایه می‌شود: وقتی Workerها آزاد می‌شوند درخواست‌های پولی پیش از درخواست‌های رایگان کشیده می‌شوند. زیر بار عادی همه سریع سرویس می‌گیرند و کسی متوجه نمی‌شود. لایه‌ها فقط وقتی ظرفیت تنگ است از هم جدا می‌شوند، که زمانی است که مشتریان پولی باید تفاوت را حس کنند.

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

همچنین Trade-offی بین عدالت و بهره‌وری هست. اولویت سخت‌گیرانه‌ی لایه می‌تواند کاربران رایگان را در اوج‌های پایدار برای مدت‌های طولانی منتظر بگذارد. در عمل، کف ظرفیتی برای ترافیک رایگان رزرو می‌کنید، به‌جای اینکه اجازه دهید ترافیک پولی کاملاً آن را کنار بزند. تیم‌های محصول و زیرساخت این انتخاب‌های سیاستی را با هم تنظیم می‌کنند.

۵) با طولانی‌تر شدن مکالمه‌ها، هزینه‌ی Inference را چطور کنترل کنیم بدون اینکه دستیار فراموشکار به نظر برسد؟

یادتان بیاید طراحی سطح بالای ما کل مکالمه را در هر نوبت به مدل Replay می‌کند. کار می‌کند، ولی هزینه و Latency آن با چت رشد می‌کند.

چت ۵۰ نوبتی با حدود ۵۰۰ توکن در هر نوبت یعنی Prompt بعدی حدود ۲۵ هزار توکن ورودی می‌فرستد که همه برای هر فراخوانی صورت‌حساب می‌شود. بدتر، سقف سخت دارد. وقتی مکالمه از Context Window مدل بزرگ‌تر شود، درخواست ساده جا نمی‌شود.

دستیارهای واقعی معمولاً این را نشان می‌دهند و می‌گویند چت بیش از حد طولانی شده، به‌جای شکست بی‌صدا. ولی تکیه بر آن به‌عنوان تنها جواب یعنی محصول برای کاربران قدرتمندی که بیشتر چت می‌کنند از کار می‌افتد. برای چت پنج‌نوبتی خوب است و به‌عنوان رویکرد عمومی غیرقابل‌تحمل. چیزی که می‌خواهیم این است که دستیار حس کند به خاطر دارد بدون اینکه هر بار کل متن را دوباره بخواند و هزینه‌اش را بدهد.

رویکرد اول، بریدن (Truncation): فقط N نوبت اخیر را نگه دارید و هر چیز قدیمی‌تر را رها کنید. Prompt مستقل از طول مکالمه اندازه‌ی ثابت و محدود می‌ماند. مشخصاً این فقط خواندن محدود از جدول messages است، به‌جای کشیدن کل رشته:

1
2
3
4
SELECT * FROM "messages"
WHERE "chatId" = $1
ORDER BY "createdAt" DESC
LIMIT $2

جدیدترین N ردیف را می‌کشیم و بعد پیش از ساختن Prompt دوباره به ترتیب زمانی برمی‌گردانیم.

چالش‌ها: هزینه را محدود می‌کند، ولی دستیار آشکارا فراموشکار می‌شود. به چیزی از اول چت بلند اشاره کنید و هیچ نمی‌داند درباره‌ی چه حرف می‌زنید، چون آن نوبت رها شده بود. برای محصولی که کل جذابیتش «مکالمه را به خاطر دارد» است، فراموشی ناگهانی در نوبت N+1 ظاهر بدی است. هزینه‌ی محدود بدون از دست رفتن حافظه‌ی قابل‌مشاهده می‌خواهیم.

رویکرد دوم، Prefix Caching و خلاصه‌ی غلتان: بزرگ‌ترین برد Prefix Caching است. بین نوبت‌ها در یک مکالمه‌ی واحد، بیشتر Prompt از نوبتی به نوبت دیگر یکسان است، چون System Prompt و هر چیزی که قبلاً در چت گفته شده همه تکرار می‌شوند. سرورهای Inference مدرن می‌توانند حالت میانی مدل (KV Cache) را برای پیشوند Prompt پایدار Cache کنند و به‌جای محاسبه‌ی مجدد از صفر در هر نوبت دوباره استفاده کنند.

فقط دم Prompt، پیام جدید، واقعاً عوض می‌شود. پس Prefix Caching هم هزینه و هم Latency پردازش ورودی را کم می‌کند، که مستقیماً به هدف TTFT ما کمک می‌کند. به‌طور طبیعی با مسیریابی آگاه از مکالمه جفت می‌شود که هر نوبت را به Workerی می‌فرستد که پیشوندش از قبل گرم است.

نکته این است که Cache باید مدیریت شود. Worker حافظه‌ی محدود دارد و فقط می‌تواند پیشوند مکالمه‌های محدودی را هم‌زمان گرم نگه دارد، پس پیشوندها بر مبنای چیزی مثل LRU اخراج می‌شوند. مکالمه‌ای که ساکت می‌شود و بعداً برمی‌گردد پیشوندش را سرد می‌یابد و در آن نوبت بعدی برای بازسازی‌اش قیمت کامل را می‌پردازد.

Prefix Caching دوباره خواندن تاریخچه را ارزان می‌کند، ولی سقف سخت را جابه‌جا نمی‌کند. مکالمه هنوز می‌تواند از Context Window بزرگ‌تر شود مهم نیست پردازشش چقدر ارزان باشد. اینجا خلاصه‌ی غلتان (Rolling Summary) وارد می‌شود.

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

خلاصه با رشد مکالمه در پس‌زمینه به‌روز می‌شود و قدیمی‌ترین نوبت‌های کلمه‌به‌کلمه را وقتی پیر می‌شوند در خود جمع می‌کند. خلاصه Prompt را داخل پنجره نگه می‌دارد و Prefix Caching بخش پایدار را برای پردازش ارزان نگه می‌دارد.

می‌توانید در صورت نیاز بیشتر اضافه کنید. بازیابی فقط واقعیت‌های قدیمی‌تر مرتبط با نوبت فعلی (به‌جای خلاصه کردن همه‌چیز) یک گزینه است و سقف سخت روی چت‌های بسیار طولانی برای کاربران لایه‌ی رایگان (که به Deep Dive عدالت برمی‌گردد) گزینه‌ی دیگر.

چالش‌ها: خلاصه‌سازی رایگان نیست، چون فراخوانی مدل اضافه (ارزان‌تر) است. خلاصه همچنین می‌تواند جزئیاتی را از دست بدهد که بعداً معلوم می‌شود مهم بوده، پس تنش واقعی بین کیفیت و هزینه هست. اصل راهنما این است که اول نوبت‌های اخیر و مهم‌ترین Context کاربر را محافظت کنید و کمی اتلاف در تاریخچه‌ی قدیمی را بپذیرید.

Prefix Caching هم به پایدار ماندن واقعی پیشوند بستگی دارد. اگر خلاصه را هر نوبت بازنویسی کنیم، Cache را باطل می‌کنیم، پس آن را با فاصله‌ی کندتری به‌روز می‌کنیم تا پیشوند گرم بماند. هر اهرم هزینه اینجا کمی کیفیت پاسخ یا وفاداری حافظه را با مقدار زیادی هزینه معاوضه می‌کند. طراحی باید آن Trade-off را روی Context قدیمی کم‌ارزش خرج کند، نه Context اخیر با ارزش بالا.

لغو یک Run و بازپس گرفتن GPU

تنها عملیاتی روی Run که هنوز نشان نداده‌ایم لغو است، و روشن‌ترین بازده درجه‌یک کردن Run است. وقتی کاربر Stop را می‌زند، کلاینت تماس HTTP ساده‌ای به POST /runs/{runId}/cancel می‌گیرد. SSE فقط یک‌طرفه Push می‌کند، پس هر چیزی که کلاینت باید وسط Stream بفرستد از طریق درخواست جداگانه‌ای مثل این می‌رود. Chat Service وضعیت آن Run را به cancelled تغییر می‌دهد و سیگنال لغو را روی کانال کنترلی کلیدگذاری‌شده با runId منتشر می‌کند. Worker آن کانال را بین دسته‌های توکن بررسی می‌کند و لحظه‌ای که سیگنال را دید، دنباله را رها می‌کند و تولید را متوقف می‌کند.

لغو بلافاصله ظرفیتی را آزاد می‌کند که در غیر این صورت برای بقیه‌ی تولید ۳۰ ثانیه‌ای اشغال می‌ماند. ظرفیت GPU محدودترین و گران‌ترین منبع کل سیستم است، پس بازپس گرفتنش لحظه‌ای که کاربر دیگر اهمیت نمی‌دهد پول واقعی برمی‌گرداند.

نکته‌ی مهم این است که بستن تب لغو نیست. Redis Stream و اتصال دوباره‌ی SSE را دقیقاً ساختیم تا اتصال قطع‌شده پایان Run خوانده نشود و مثل ChatGPT، تولید را در پس‌زمینه ادامه می‌دهیم. کاربر می‌تواند چت را دوباره باز کند و به Stream وصل شود، یا فقط پیام تمام‌شده را از Postgres وقتی تمام شد دوباره بگیرد. لغو باید سیگنال صریح از کاربر باشد، هرگز تصادف شبکه نه.

طراحی نهایی

پس از اعمال راه‌حل‌های «عالی»، طراحی خیلی از نسخه‌ی همگام ساده‌ای که با آن شروع کردیم فراتر رفته است. تقریباً چنین جور می‌شود: کلاینت POST را به API Gateway می‌فرستد که به Chat Service (Stateless) می‌رسد. Chat Service پیام کاربر را در Postgres می‌نویسد، Run می‌سازد (با بررسی Token Bucket هر کاربر و لایه) و درخواست را به صف اولویت‌دار (آگاه از لایه، با عمق محدود و Backpressure) می‌فرستد. Workerهای GPU مبتنی بر Pull با Continuous Batching و Prefix Caching از صف می‌کشند و توکن‌ها را با XADD به Redis Stream مربوط به runId اضافه می‌کنند. کلاینت از طریق SSE به هر Instance از Chat Service وصل می‌شود که با XREAD از آخرین شناسه‌ی دیده‌شده می‌خواند و Forward می‌کند. وقتی تولید تمام می‌شود، Worker پیام کامل را در Postgres می‌نویسد و Run را done می‌کند. لغو از طریق POST /runs/{runId}/cancel و کانال کنترل انجام می‌شود.

Deep Dive های اضافی که می‌توانید در نظر بگیرید

چیزهای زیادی هست که نتوانستیم جا بدهیم. چند جهت دیگر که ارزش فکر کردن خودتان را دارد:

  • ایمنی و مدیریت محتوا (Moderation): مدیریت محتوا را زیر خط گذاشتیم تا روی سرویس‌دهی متمرکز بمانیم، ولی مصاحبه‌کننده‌های زیادی می‌خواهند ببینند. شکل معمول طبقه‌بند ارزان روی Prompt در ورود و پاس دوم روی خروجی هنگام Stream است. پاس خروجی جایی است که سخت می‌شود، چون معمولاً تا وقتی بررسی فعال شود مقداری توکن به کاربر Stream شده است. باید تصمیم بگیرید در Chunkها پیش از Flush هر کدام مدیریت کنید، یا پیام را بعد از واقعه پس بگیرید.
  • چرا یک مدل کل سرور GPU را در بر می‌گیرد: گفتیم وزن‌ها بین GPUهای یک سرور تقسیم می‌شوند بدون گفتن چطور. وزن‌های مدل Frontier در حافظه‌ی یک GPU جا نمی‌شود، پس آن را بین GPUها تقسیم می‌کنید. می‌توانید Tensor Parallelism استفاده کنید، که در آن هر GPU تکه‌ای از هر لایه را نگه می‌دارد، یا Pipeline Parallelism که هر GPU لایه‌های کامل را نگه می‌دارد و به بعدی تحویل می‌دهد. هر دو به اتصال‌های سریع تکیه دارند، NVLink بین GPUها داخل سرور و InfiniBand بین سرورها، و آن اتصال می‌تواند گلوگاه خودش شود.
  • Speculative Decoding: برای بعضی مصاحبه‌ها، به‌خصوص در سطح Senior و Staff، بسیار عمیق‌تر در جزئیات Inference می‌روید و این یکی است که باید بدانید. چون Decode یک توکن در هر بار اجرا می‌شود، مدل «پیش‌نویس» کوچک و ارزان می‌تواند چند توکن بعدی را حدس بزند و مدل بزرگ همه‌ی آن‌ها را در یک Forward Pass تأیید می‌کند. وقتی پیش‌نویس درست حدس بزند، چند توکن را به قیمت یک گام می‌گیرید، که برد واقعی هم برای TTFT و هم Throughput است.
  • درخواست‌های ارزان‌تر با مسیریابی و Caching: هر Prompt به بزرگ‌ترین مدل نیاز ندارد. مسیریابی Queryهای ساده به مدل کوچک‌تر و Cache کردن پاسخ برای Promptهای شبیه از نظر معنایی (کلیدگذاری‌شده با Embedding به‌جای رشته‌ی دقیق) هر دو هزینه را بدون متوجه شدن کاربر کم می‌کنند. این به‌طور طبیعی با Deep Dive عدالت لایه‌ای جفت می‌شود، که در آن ترافیک رایگان اولین چیزی است که به پایین مسیریابی می‌شود.
  • ورودی چندوجهی و حافظه‌ی بین‌چتی: ما به متن ورودی و متن خروجی محدود شدیم. ChatGPT واقعی تصویر و صدا هم می‌گیرد، که Tokenization را عوض می‌کند و ورودی را برای پردازش بسیار بزرگ‌تر می‌کند، و حقایقی درباره‌ی شما را بین مکالمه‌های جدا به خاطر می‌سپارد. آن حافظه‌ی بین‌چتی مسئله‌ی بازیابی است، Embed کردن پیام‌های گذشته و کشیدن مرتبط‌ها به Prompt، نه خلاصه‌سازی مکالمه‌ی واحد که اینجا انجام دادیم.

انتظار در هر سطح

خوب، زیاد بود. ممکن است فکر کنید «چقدر از آن واقعاً در مصاحبه از من لازم است؟».

Mid-level: برای این سؤال، نامزد Mid-level Endpointهای API و مدل داده را به‌روشنی تعریف کرده است. به طراحی سطح بالای همگام کارایی رسیده که فرستادن Prompt و دیدن چت‌های گذشته با Context منتقل‌شده بین نوبت‌ها را مدیریت می‌کند. می‌خواهم ببینم تشخیص می‌دهند صفحه‌ی خالی ۳۰ ثانیه‌ای جواب نمی‌دهد و به مدل Streaming مبتنی بر Push مثل SSE می‌رسند، حتی اگر کمی راهنمایی لازم باشد. باید بفهمند GPUها گلوگاه‌اند و دست‌کم پیشنهاد دهند صف جلوی Workerها بگذارند. ممکن است به Continuous Batching یا Backpressure خودشان نرسند.

Senior: برای این سؤال، از نامزدهای Senior انتظار می‌رود طراحی سطح بالا را سریع رد کنند. این زمان کافی می‌گذارد تا دست‌کم دو تا از Streaming Fan-out، زمان‌بندی GPU و Deep Dive عدالت را با جزئیات پوشش دهند. باید بتوانید انتخاب SSE در برابر WebSocket را از ماهیت یک‌طرفه‌ی Streaming توکن توضیح دهید و اینکه چطور صف‌بندی به‌علاوه‌ی Continuous Batching GPU را مشغول نگه می‌دارد. همچنین انتظار دارم نامزد Senior تشخیص دهد هزینه با طول مکالمه رشد می‌کند و خلاصه‌سازی یا بریدن را پیشنهاد دهد، حتی اگر بدون کمک به Prefix Caching نرسد.

Staff و بالاتر: نامزدهای Staff+ باید دست‌کم سه Deep Dive را با عمق واقعی پیش ببرند و اقتصاد GPU را بدون پرسیده شدن وارد گفتگو کنند. محاسبه‌ی سرانگشتی این است که حدود ۱۲۰ هزار Stream هم‌زمان یعنی ده‌ها هزار GPU و صورت‌حساب روزانه‌ی میلیونی. همین مقیاس است که از ابتدا Continuous Batching و Backpressure را توجیه می‌کند. خودشان به Prefix Caching برای هزینه‌ی Context می‌رسند. همچنین عدالت بین کاربران (بودجه‌های هر کاربر آگاه از هزینه) را از اولویت بین لایه‌ها (صف‌بندی وزن‌دار با لایه) جدا می‌کنند. فراتر از آن، انتظار دارم دست‌کم یک انتخاب سرویس‌دهی غیربدیهی را خوب توضیح دهند، چه Continuous Batching باشد، چه استفاده‌ی مجدد از KV Cache، چه اینکه چرا ترافیک رایگان اول افت می‌کند.

در این سطح، مصاحبه‌کننده‌ها ممکن است مقداری جزئیات Inference مثل Speculative Decoding هم انتظار داشته باشند. در سیستم‌های سرویس‌دهی تولیدی آن‌قدر رایج شده که قابل‌پرسش است، هرچند از ایده‌های زمان‌بندی و Caching بالا کمتر بنیادین است.