وقتی پروژهای در مرحلهی مدلسازی یا کار با نوتبوکها به سرانجام میرسد، گام بعدی ارائه یا آزمایش آن با کاربران واقعی و اعضای تیم است. ارائهی نوتبوک بهصورت مستقیم نه امنیت دارد و نه برای کاربری که تخصص فنی ندارد قابل فهم است؛ علاوهبر این، ارسال متنهای طولانی یا درخواستهای مکرر میتواند هزینههای فراخوانی API را بهسرعت افزایش دهد یا سرور را متوقف کند.
در این درس یاد میگیرید که چگونه پیش از انتشار هر پیوندی، قراردادی روشن میان ورودی، فراخوانی مدل و خروجی وضع کنید. ابتدا یک سرویس وب سبک و کامل را فقط با تکیه بر کتابخانهی استاندارد پایتون میسازیم تا مفاهیم بنیادینی مثل اعتبارسنجی پلهپله، استریم توکنها با Server-Sent Events، و مهار ترافیک با سطل توکن (Token bucket) را بدون پیچیدگی فریمورکها مرور کنیم.
در پایان نیز پیادهسازی همین سناریو را در دو فریمورک محبوب Gradio و Streamlit مقایسه خواهیم کرد تا نقاط قوت و کمبودهای پیشفرض هرکدام در حوزهی مهار هزینهها و حفظ حریم خصوصی لاگها مشخص شود.
یک دموی مدل فقط یک قرارداد دارد: ورودی، فراخوانی مدل و خروجی. پیش از آنکه پیوند آن را با دیگران به اشتراک بگذارید، برای هر بخش حدی تعیین کنید.
اهداف یادگیری
- نوشتن قرارداد ورودی، فراخوانی مدل و خروجی در یک دمو، همراه با تعیین سقف و محدودیت برای هر بخش.
- ساخت یک برنامهی HTTP با کتابخانهی استاندارد پایتون شامل یک صفحهی HTML، یک اندپوینت JSON و یک اندپوینت رویدادهای ارسالی از سرور (Server-Sent Events یا SSE).
- اعتبارسنجی ورودی به ترتیب هزینه، و بازگرداندن خطاهای JSON که اقدام بعدی (Next action) را به کاربر میگویند.
- محافظت از یک دموی عمومی با سطل توکن (Token bucket) بهازای هر کلاینت، سقف مصرف روزانه و توکن دسترسی اختیاری.
- ثبت لاگهای درخواست بدون ذخیرهی متن ورودی و آدرس کلاینت.
- مقایسهی دموی مبتنی بر کتابخانهی استاندارد با دو نسخهی فریمورکی، و شناخت نشانههایی که میگویند یک دمو باید به محصول تبدیل شود.
صورت مسئله
مدل شما داخل نوتبوک بهخوبی کار میکند. حالا یک ارزیاب میخواهد ده ورودی مختلف را بیازماید و یک مشتری هم میخواهد نتیجه را روی گوشی موبایلش ببیند. هیچکدام از آنها نوتبوک شما را باز نخواهند کرد.
سلول نوتبوک هیچ محدودیتی برای ورودی ندارد و روند پیشرفت را هم نشان نمیدهد. خطاهای آن لاگهای طولانی ردگیری خطا (Stack traces) هستند که فقط خودتان سر از آنها درمیآورید. علاوه بر این، نوتبوک با اعتبارنامهها و بودجهی شخصی شما اجرا میشود. اگر همان سلول را بدون تغییر پشت یک آدرس اینترنتی عمومی بگذارید، باید منتظر خطاهایی از این دست باشید:
- کاربری متنی 40,000 کاراکتری را جایگذاری میکند. فراخوانی مدل یک دقیقه طول میکشد و دستآخر با یک خطای طولانی ردگیری متوقف میشود.
- اسکریپتی در عرض یک دقیقه 500 درخواست میفرستد. هر درخواست یعنی یک فراخوانی پولیِ مدل.
- صفحه به مدت هشت ثانیه هیچ پاسخی نشان نمیدهد؛ در نتیجه کاربر چهار بار دیگر روی دکمه کلیک میکند.
- لاگ سرور تکتک ورودیها را در خود نگه میدارد، از جمله متنی که شمارهحساب مشتری در آن بوده است.
یک رابط کاربری مناسب برای دمو، با چند خط کد ساده جلوی این مشکلات را میگیرد. در این درس، چنین رابطی را فقط با کتابخانهی استاندارد پایتون پیاده میکنیم تا تمام اجزای آن را بهچشم ببینید. سپس همان دمو را با دو فریمورک محبوب میسازیم و رفتار پیشفرض هرکدام را مقایسه میکنیم.
مفاهیم پایه
دمو یک قرارداد است
هر دمو از سه بخش تشکیل میشود: ورودی دادهای است که کاربر اجازه دارد بفرستد. فراخوانی مدل ورودی را به خروجی تبدیل میکند. خروجی همان چیزی است که کاربر میبیند، از جمله پیامهای خطا.
پیش از نوشتن کدهای صفحه، این قرارداد را مکتوب کنید:
| بخش | پرسشی که باید پاسخ داد | پاسخ در این درس |
|---|---|---|
| ورودی | چه نوعی، چه اندازهای و شامل چه کاراکترهایی؟ | رشتهای با طول 1 تا 500 کاراکتر، بدون کاراکترهای کنترلی |
| فراخوانی مدل | هر فراخوانی چقدر زمان میبرد و هزینهاش چقدر است؟ | یک مدل آزمایشی مبتنی بر کلمات کلیدی با اجرای زیر یک میلیثانیه و بدون هزینه |
| خروجی | کاربر در صورت موفقیت یا شکست چه چیزی میبیند؟ | یک برچسب، یک امتیاز و یک پاسخ؛ یا خطای JSON همراه با راهنمای اقدام بعدی |
نوتبوک برای هیچکدام از این سه بخش حدی تعیین نمیکند. اما دمو برای هر بخش سقفی مشخص میگذارد و سرور این محدودیتها را برای تمام کلاینتها اعمال میکند.
بررسیها به ترتیبِ هزینه اجرا میشوند. اگر درخواستی نامعتبر باشد، در همان نخستین بررسی متوقف میشود و در نتیجه تقریباً هیچ هزینهای روی دست سرور نمیگذارد. فراخوانی مدل در آخرین مرحله انجام میگیرد، چون تنها مرحلهای است که هزینهی مالی دارد.
اعتبارسنجی پیش از اجرای مدل
مرورگر میتواند پیش از ارسال داده، ورودی را بررسی کند. مشخصههای maxlength و required در کادر متنی، بدون نیاز به فراخوانی شبکه بازخورد لازم را به کاربر میدهند. البته این بررسیها فقط به کار کاربران عادی میآیند. هر کلاینتی میتواند بدون استفاده از صفحه درخواست بفرستد؛ بنابراین سرور باید تکتک بررسیها را تکرار کند.
بررسیهای سمت سرور را به ترتیب هزینه بچینید:
- هدر
Content-Lengthرا بخوانید. اگر حجم بدنه از حد مجاز بیشتر بود، پیش از خواندن آن درخواست را رد کنید. - بدنهی JSON را پارس کنید. اگر بدنه یک آبجکت معتبر JSON نبود، آن را رد کنید.
- فیلدها را بررسی کنید. متنهای خالی، متنهای طولانیتر از حد مجاز و کاراکترهای کنترلی را نپذیرید.
- فراخوانی مدل را فقط صرف درخواستی کنید که از همهی بررسیها گذشته است.
| بررسیِ ناموفق | وضعیت HTTP | کد خطا |
|---|---|---|
نبود هدر Content-Length |
411 Length Required | length_required |
عددی نبودن Content-Length |
400 Bad Request | bad_length |
| بدنهی بزرگتر از 4,096 بایت | 413 Content Too Large | body_too_large |
قطع ارتباط بدنه پیش از رسیدن به میزان Content-Length |
408 Request Timeout | request_timeout |
| نوع محتوایی غیر از JSON | 415 Unsupported Media Type | unsupported_media_type |
| ساختار بدنه آبجکت JSON نباشد | 400 Bad Request | bad_json |
| متن وجود نداشته باشد، خالی یا بیش از حد طولانی باشد، یا کاراکتر کنترلی داشته باشد | 422 Unprocessable Content | missing_text، empty_text، text_too_long، control_characters |
استاندارد RFC 9110 این کدهای وضعیت و نامهای آنها را تعریف کرده است.
نمونههای آماده بخشی از طراحی ورودی هستند. یک نمونه به کاربر نشان میدهد که ورودی مناسب چه شکلی دارد. همچنین به ارزیاب اجازه میدهد با یک کلیک سیستم را بیازماید. برای هر کلاس خروجی یک نمونه در نظر بگیرید. در این دمو یک نمونهی مثبت، یک منفی و یک خنثی داریم.
نشان دادن وضعیت فعالیت مدل
فراخوانی مدل گاهی چند ثانیه طول میکشد. بدون نمایش بازخورد، کاربر نمیتواند کندیِ مدل را از خرابی صفحه تشخیص دهد. کاربری که هیچ تغییری نبیند دوباره کلیک میکند و در نتیجه سرور یک درخواست پولی دیگر دریافت خواهد کرد.
صفحهی طراحیشده در این درس چهار نوع بازخورد ارائه میدهد:
- غیرفعال کردن دکمهی ارسال در زمان اجرای درخواست.
- نمایش زمان سپریشده و بهروزرسانی آن هر 100 میلیثانیه.
- نمایش زمان تا اولین توکن بهمحض دریافت نخستین توکن.
- نمایش زمان کل پس از پایان پردازش درخواست.
در مبحث معیارهای استنتاج دیدیم که «زمان تا اولین توکن» (TTFT یا Time to first token) معیاری سمت سرور است. این صفحهی دمو همان عدد را که در مرورگر اندازهگیری شده به کاربر نشان میدهد.
ارسال خروجی بهصورت استریم
مدلهای زبانی پاسخ خود را توکنبهتوکن تولید میکنند. اگر سرور منتظر آخرین توکن بماند، کاربر باید تا پایان کل فرایند تولید صبر کند. اما اگر سرور هر توکن را بهمحض آماده شدن بفرستد، کاربر خواندن متن را از همان توکن اول شروع میکند.
استاندارد این کار «رویدادهای ارسالی از سرور» (Server-Sent Events یا SSE) است. پاسخ ارسالی دارای نوع محتوای text/event-stream است. هر رویداد از مجموعهای از خطوط event: و data: تشکیل میشود و با یک خط خالی پایان مییابد. استاندارد WHATWG HTML ساختار این قالب را تعریف کرده است.
event: token
data: {"index":0,"text":"Label:"}
event: token
data: {"index":1,"text":" positive."}
event: done
data: {"label":"positive","score":1.0,"tokens":11}
مرورگر رابطی به نام EventSource برای SSE دارد، اما این رابط فقط درخواستهای GET میفرستد. درخواست GET ورودی را در URL قرار میدهد، و وبسرورها و پروکسیها آدرسهای URL را در لاگهای دسترسی خود ذخیره میکنند. برای جلوگیری از این مشکل، این دمو در عوض از درخواست POST با بدنهی JSON استفاده میکند. صفحه پاسخ را با fetch() و response.body.getReader() میخواند و خودش قالب رویداد را پارس میکند.
استریمینگ مشکلی دارد که در پاسخهای تکیِ JSON دیده نمیشود: سرور کد وضعیت 200 را پیش از ارسال اولین توکن میفرستد. اگر مدل پس از آن شکست بخورد، سرور دیگر نمیتواند کد وضعیت را تغییر دهد. بنابراین سرور رویدادی از نوع error داخل استریم میفرستد و صفحه آن رویداد را بهصورت خطا به کاربر نشان میدهد.
هنگام ساخت برنامهی عملیاتی مدلهای زبانی، فریمورک کار ارسال توکنها روی SSE را به عهده میگرفت. در این درس همان پروتکل را دستی مینویسیم تا تکتک خطوط ارسالی سرور را ببینید.
نوشتن خطاهای کاربردی و قابلپیگیری
پیام خطا دو وظیفه دارد: به کاربر بگوید چه اتفاقی افتاده، و مشخص کند اقدام بعدی او چیست. یک لاگ خطای طولانی هیچکدام از این دو کار را برای یک کاربر ناآشنا انجام نمیدهد.
تمام خطاها در این دمو ساختار یکسان JSON دارند:
{
"error": {
"code": "text_too_long",
"message": "The text has 812 characters. The limit is 500.",
"hint": "Shorten the text to 500 characters or fewer.",
"request_id": "a8f3c2b435e3"
}
}
- مقداری ثابت در
codeقرار دارد که کدهای کلاینت و آزمونها آن را میسنجند. - فیلد
messageواقعیت را همراه با ارقام و اعداد مورد نیاز کاربر بیان میکند. - فیلد
hintیک اقدام مشخص به کاربر پیشنهاد میدهد. - فیلد
request_idگزارش کاربر را به یک لاگ مشخص در سرور وصل میکند.
استاندارد RFC 9457 قالبی استاندارد برای خطاهای APIهای HTTP تحت عنوان Problem Details تعریف میکند. این دمو برای همین منظور ساختار کوچکتری به کار میبرد. هرگاه تیمهای دیگر خواستند API شما را فراخوانی کنند، از RFC 9457 استفاده کنید.
اشتراکگذاری ایمن یک دموی عمومی
عمومی کردن پیوند باعث میشود افراد مختلفی بتوانند درخواست بفرستند. برای هر ریسک یک سازوکار کنترلی در نظر بگیرید:
| ریسک | سازوکار کنترلی در این دمو | سازوکار کنترلی برای مخاطبان بیشتر |
|---|---|---|
| ارسال درخواست بیش از حد توسط یک کلاینت | سطل توکن بهازای هر کلاینت، با کد 429 و هدر Retry-After |
محدودیتهای اختصاصی هر کاربر پس از ورود به سیستم |
| دسترسی افراد متفرقه به پیوند | توکن دسترسی اختیاری در هدر Authorization |
ورود یکپارچه (SSO) از طریق ارائهدهندهی هویت |
| افزایش بیرویهی هزینهها | سقف مصرف روزانه، با کد 503 و هدر Retry-After |
سقف هزینه و هشدارهای مالی در ارائهدهندهی مدل |
| ورودیهای بسیار بزرگ یا نامتعارف | محدودیت حجم بدنه، محدودیت طول متن، بررسی کاراکترهای کنترلی | پایش و پالایش محتوا (Moderation) روی ورودی و خروجی |
| ذخیرهی ورودیهای محرمانه در لاگها | ثبت طول ورودی و هش کلیددار از آدرس کلاینت | تعیین دورهی نگهداری و کنترل سطح دسترسی به لاگها |
الگوریتم سطل توکن با دو عدد کار میکند: ظرفیت (Capacity) که حداکثر حجم جهش ناگهانیِ (Burst) مجاز برای یک کلاینت است، و نرخ پر شدن مجدد (Refill rate) که میانگین نرخ ارسال درخواست در بلندمدت را تعیین میکند. هر درخواست یک توکن برمیدارد و سطل تا سقف گنجایش خود پیوسته پر میشود:
tokens = min(capacity, tokens + refill_rate * seconds_since_last_request)
accept the request if tokens >= 1, then subtract 1
هرگاه سطل خالی شود، سرور پاسخ 429 Too Many Requests میدهد. استاندارد RFC 6585 وضعیت 429 را تعریف کرده و به سرور اجازه میدهد هدر Retry-After را ضمیمه کند. این دمو همیشه این هدر را میفرستد و تعداد ثانیههای لازم تا آماده شدن توکن بعدی را در آن درج میکند. صفحه با خواندن این هدر، دکمهی ارسال را به همان مدت غیرفعال میکند.
این دمو از آدرس IP کلاینت بهعنوان کلید سطل استفاده میکند. پشت یک پروکسی معکوس (Reverse proxy)، همهی درخواستها با آدرس پروکسی میرسند و در نتیجه همهی کاربران در یک سطل مشترک قرار میگیرند. در این حالت، آدرس کلاینت را از هدری بخوانید که پروکسی خودتان تنظیم کرده است. این هدر را از هیچ منبع دیگری معتبر ندانید، چرا که کلاینتها میتوانند هر مقداری را در آن بگذارند.
در مبحث درگاههای هوش مصنوعی دیدیم که گیتوی محدودیت نرخ درخواست (Rate limit) را به تفکیک هر مستاجر (Tenant) اعمال میکرد. یک دمو نیز به همین کنترل در مقیاسی کوچکتر نیاز دارد. محدودیت نرخ درخواست هر کلاینت را مهار میکند، اما سقف مجموع کل را کنترل نمیکند؛ یعنی اگر صد کلاینت هرکدام زیر سقف مجاز خود بمانند، باز هم میتوانند کل بودجه را تمام کنند. سقف مصرف روزانه، تمام فراخوانیهای مدل در یک روز را میشمارد و پس از رسیدن به عددی مشخص، مانع فراخوانیهای بعدی میشود.
لاگها هم مثل ورودیها دقت نظر میخواهند. هندلر پیشفرض http.server آدرس IP کلاینت و خط درخواست را در خروجی خطای استاندارد مینویسد. اگر ورودی داخل URL باشد، مستقیماً وارد لاگ میشود. این دمو آن خط لاگ را جایگزین میکند. هر درخواست یک رکورد شامل مسیر، وضعیت، کد خطا، تاخیر و طول متن ورودی ثبت میکند. آدرس کلاینت به یک هش کلیددار تبدیل میشود؛ بنابراین میتوانید تعداد درخواستهای هر کلاینت را بدون ذخیرهی آدرس واقعی او بشمارید.
به کاربران بگویید چه اطلاعاتی را ذخیره میکنید. صفحهی دمو تصریح میکند که سرور فقط طول متن را نگه میدارد و هرگز خود متن را ذخیره نمیکند.
چه زمانی دمو باید به محصول تبدیل شود؟
یک دمو در نشستهایی کوتاه و در قالب یک فرایند به افرادی مشخص پاسخ میدهد. دیدن این نشانهها یعنی دموی شما آمادهی تبدیل شدن به محصول است:
- افرادی خارج از تیم شما هر هفته از آن استفاده میکنند.
- کسی سراغ تعهد پایداری سیستم (Uptime) را میگیرد یا قطعی سرویس را گزارش میدهد.
- کاربران درخواست ثبتنام، ذخیرهی نتایج یا دیدن تاریخچهی فعالیتها را دارند.
- هزینهی هفتگی مدل به ردیفی مشخص در بودجه تبدیل شده است.
- ورودی شامل دادههایی است که مشمول قراردادها یا قوانین حقوقی میشوند.
| ویژگی | دمو | محصول |
|---|---|---|
| کاربران | افراد مشخص، نشستهای کوتاه | افراد ناشناس، استفادهی روزمره |
| وضعیت (State) | ندارد | حسابهای کاربری، تاریخچه، نتایج ذخیرهشده |
| دسترسیپذیری | در حد توان، در قالب یک فرایند | هدفگذاری مکتوب، نمونههای موازی (Replicas)، مسئول نوبت پاسخگویی (آنکال) |
| کنترل هزینه | یک سقف مصرف روزانه | سهمیهبندی و صدور صورتحساب بهازای هر کاربر |
| مشاهدهپذیری | یک رکورد لاگ برای هر درخواست | ردگیری محاسبات (Traces)، معیارها و هشدارها |
با مشاهدهی این نشانهها، دمو را به معماری محیط عملیاتی (پروداکشن) معرفیشده در درس برنامهی عملیاتی مدلهای زبانی منتقل کنید. آن معماری احراز هویت واقعی، مدلهای طرح پشتیبان (Fallback)، ردگیری محاسبات و پایش هزینهها بهازای هر کاربر را به سیستم اضافه میکند.
پیادهسازی از صفر
کدها در یک فایل به نام code/main.py و بدون هیچ وابستگی خارج از کتابخانهی استاندارد نوشته شدهاند. ابتدا آزمون خودکار را اجرا کنید:
python3 code/main.py
این آزمون خودکار بخشهای اعتبارسنج، سطل توکن، قالب استریم و یک چرخهی کامل رفتوبرگشت واقعی HTTP را بررسی میکند؛ به این صورت که سرور را روی یک پورت آزاد بالا میآورد، سه درخواست میفرستد، سرور را متوقف میکند و خارج میشود. سرور را فقط زمانی که میخواهید با مرورگر کار کنید راه بیندازید:
python3 code/main.py --serve
آدرس http://127.0.0.1:8000 را در مرورگر باز کنید. برای متوقف کردن سرور، کلیدهای Ctrl+C را فشار دهید.
گام 1: فراخوانی مدل
مدل ما یک سیستم آزمایشی مبتنی بر شمارش کلمات کلیدی است. این مدل متن را در یکی از دستههای مثبت، منفی یا خنثی برچسبگذاری میکند، و یک پاسخ کوتاه را هم بهصورت توکنبهتوکن میسازد.
def classify(text):
words = WORD_RE.findall(text.lower())
positive = sum(1 for word in words if word in POSITIVE)
negative = sum(1 for word in words if word in NEGATIVE)
score = (positive - negative) / max(1, positive + negative)
if score > 0:
label = "positive"
elif score < 0:
label = "negative"
else:
label = "neutral"
return {"label": label, "score": round(score, 3), "positive_cues": positive,
"negative_cues": negative, "words": len(words)}
def generate(text):
result = classify(text)
reply = (
f"Label: {result['label']}. Positive cues: {result['positive_cues']}. "
f"Negative cues: {result['negative_cues']}. Words read: {result['words']}."
)
parts = reply.split(" ")
yield parts[0]
for part in parts[1:]:
yield " " + part
برای اتصال یک مدل واقعی، فقط کافی است تابع generate را تغییر دهید و یک فراخوانی API یا مدل محلی داخل آن بگذارید. امضای تابع را دستنخورده نگه دارید: رشته وارد میشود و توکنها خارج میشوند. اعتبارسنج، محدودیتها و صفحه بدون تغییر باقی میمانند.
گام 2: اعتبارسنجی و پاسخهای خطا
یک کلاس استثنا تمام خطاها را جابهجا میکند. این کلاس وضعیت HTTP، کد ثابت خطا، پیام، راهنمایی و در صورت نیاز هدرهای پاسخ را در خود نگه میدارد.
class DemoError(Exception):
def __init__(self, status, code, message, hint, headers=None):
super().__init__(message)
self.status = status
self.code = code
self.message = message
self.hint = hint
self.headers = dict(headers or {})
def validate_text(payload, max_chars=MAX_CHARS):
if "text" not in payload:
raise DemoError(422, "missing_text", 'The field "text" is missing.', 'Add a "text" field with your input.')
text = payload["text"]
if not isinstance(text, str):
raise DemoError(422, "text_not_string", 'The field "text" is not a string.', "Send the input as a string.")
text = text.strip()
if not text:
raise DemoError(422, "empty_text", "The text is empty.", "Type a sentence or pick an example.")
if len(text) > max_chars:
raise DemoError(422, "text_too_long", f"The text has {len(text)} characters. The limit is {max_chars}.",
f"Shorten the text to {max_chars} characters or fewer.")
if CONTROL_RE.search(text):
raise DemoError(422, "control_characters", "The text contains control characters.",
"Remove the hidden characters, then send the text again.")
return text
اعتبارسنج ابتدا فاصلههای خالی را از ابتدا و انتهای متن حذف میکند. متنی که فقط از فاصله تشکیل شده باشد در دستهی خالی قرار میگیرد و راهنمای سیستم به کاربر میگوید چه چیزی تایپ کند. توابع check_body_size و parse_json_body از همین الگو برای مدیریت خطاهای 411، 413، 415 و 400 استفاده میکنند. تابع error_body هر DemoError را به همان ساختار JSON مطرحشده در بخش مفاهیم تبدیل میکند.
گام 3: سطل توکن
class TokenBucket:
def __init__(self, capacity, refill_per_second, now):
if capacity < 1:
raise ValueError("capacity must be at least 1")
if refill_per_second <= 0:
raise ValueError("refill_per_second must be positive")
self.capacity = float(capacity)
self.refill_per_second = float(refill_per_second)
self.tokens = float(capacity)
self.updated = now
def take(self, now, cost=1.0):
elapsed = max(0.0, now - self.updated)
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
self.updated = now
if self.tokens >= cost:
self.tokens -= cost
return 0.0
return (cost - self.tokens) / self.refill_per_second
تابع take اگر درخواست را بپذیرد عدد 0.0 برمیگرداند. در غیر این صورت، ثانیههای باقیمانده تا پر شدن توکنها را تحویل میدهد. سرور این عدد را به بالا گرد میکند و در هدر Retry-After میفرستد.
کلاس RateLimiter برای هر کلاینت یک سطل در یک OrderedDict نگه میدارد. از آنجا که ThreadingHTTPServer هر درخواست را در رشتهی اجرایی (Thread) جداگانهای پردازش میکند، یک قفل (Lock) از این دیکشنری محافظت میکند. هرگاه تعداد ورودیهای دیکشنری از max_clients بیشتر شود، محدودکننده کلاینتی را که قدیمیترین درخواست را داشته حذف میکند. کلاینتِ حذفشده در درخواست بعدی خود یک سطل کاملاً پر دریافت خواهد کرد. مقدار max_clients را بیشتر از شمار کاربران فعال موردانتظار خود بگذارید.
محدودکننده آرگومانی به نام clock میگیرد. آزمونها یک FakeClock به آن پاس میدهند تا بتوان زمان را بدون معطلی 10 ثانیه جلو برد. کلاس UsageCap از همین الگوی ساعت برای مدیریت سقف مصرف روزانه استفاده میکند. این کلاس فراخوانیها را در پنجرههای زمانیِ ثابتِ 86,400 ثانیهای میشمارد و وقتی سهمیه پر شد، زمان باقیمانده تا شروع پنجرهی بعدی را اعلام میکند.
گام 4: استریمینگ
def sse_event(event, data):
payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
def stream_events(text, generate_fn=generate, delay=0.0, sleep=time.sleep):
count = 0
try:
for token in generate_fn(text):
yield sse_event("token", {"index": count, "text": token})
count += 1
if delay:
sleep(delay)
except Exception:
yield sse_event("error", MODEL_ERROR)
return
result = classify(text)
yield sse_event("done", {"label": result["label"], "score": result["score"], "tokens": count})
تابع json.dumps کاراکتر خط جدید را درون رشتهها بهصورت دو کاراکتر \n مینویسد. بنابراین هر رویداد یک خط داده دارد و هیچ خط جدیدِ خامی درون آن نخواهد بود؛ چرا که وجود خط جدید خام در دادهها ساختار رویداد را خراب میکند.
ژنراتور خطاهای احتمالی مدل را میگیرد و رویدادی از نوع error میفرستد؛ چون سرور پیشتر خط وضعیت را ارسال کرده است. آرگومانهای delay و sleep سرعت ارسال استریم را برای نمایش در مرورگر کم میکنند و البته سرعت اجرای آزمونها را بالا نگه میدارند.
گام 5: مدیریتکنندهی درخواست
کلاس DemoHandler از BaseHTTPRequestHandler ارثبری میکند. متد مدیریت درخواستهای POST بررسیها را دقیقاً طبق ترتیب بخش مفاهیم پیش میبرد:
def do_POST(self):
app = self.server.app
request_id = uuid.uuid4().hex[:12]
client_id = self.client_address[0]
started = time.perf_counter()
route = self.path if self.path in self.routes else "other"
status, code, chars = 200, None, 0
try:
declared = self.headers.get("Content-Length")
try:
if route == "other":
raise DemoError(404, "not_found", "This endpoint does not exist.",
"Send requests to /api/predict or /api/stream.")
size = check_body_size(declared, app.config.max_body_bytes)
except DemoError:
self.drain(int(declared) if declared and declared.isdigit() else 0)
raise
try:
body = self.rfile.read(size)
except TimeoutError:
self.close_connection = True
raise DemoError(408, "request_timeout", "The request body did not arrive in time.",
"Send the request again with the full body.") from None
app.admit(client_id, self.headers.get("Authorization"))
text = app.read_text(self.headers.get("Content-Type"), body)
chars = len(text)
app.charge()
if route == "/api/predict":
self.send_json(200, predict(text), request_id)
else:
code = self.send_stream(text, request_id)
except DemoError as err:
status, code = err.status, err.code
self.send_error_json(err, request_id)
except TimeoutError:
status, code = 408, "request_timeout"
self.close_connection = True
except Exception:
status, code = 500, "internal_error"
self.send_error_json(DemoError(500, "internal_error", "The server failed on this request.",
"Try again. Quote the request id if you report the problem."), request_id)
finally:
latency_ms = (time.perf_counter() - started) * 1000
app.log.record(request_id, route, client_id, status, chars, latency_ms, code)
متد admit توکن دسترسی و سطل توکن را بررسی میکند. متد charge سقف مصرف روزانه را میسنجد. این متد در آخرین مرحله اجرا میشود تا فقط درخواستهای معتبر از سهمیهی روزانه کم کنند. هر درخواستی، حتی درخواستهای ردشده، یک رکورد لاگ در بلوک finally ثبت میکند. این رکورد مسیر پردازش یعنی route را ذخیره میکند و نه مسیر خام URL را؛ چون مسیر خام ممکن است حاوی متغیرهای کاربر در رشتهی پرسمان (Query string) باشد.
چهار نکتهی فنی جلوی خطاهایی را میگیرند که ممکن است بهسادگی از دید پنهان بمانند:
- مقدار
timeout = 10روی کلاس هندلر، ارتباط را در صورت توقف ارسال داده از سمت کلاینت میبندد. بدون این محدودیت، کلاینتی که حجم داده را 100 بایت اعلام کرده اما فقط 10 بایت میفرستد، یک رشتهی اجرایی سرور را تا ابد معطل نگه میدارد. هندلر در این حالت خطای 408 را در قالب JSON همراه با شناسهی درخواست ارسال کرده و اتصال را قطع میکند. اگر این پایان زمان بعد از شروع استریم رخ دهد، سرور فقط ارتباط را قطع میکند؛ زیرا خط وضعیت قبلاً ارسال شده است. - متد
log_messageبدون خروجی بازمیگردد. این کار خط لاگ پیشفرض پایتون که حاوی آدرس کلاینت و خط درخواست است را حذف میکند. - متد
drainتا سقف 64 کیلوبایت از بدنهی درخواستی که رد شده را خوانده و دور میریزد. طبق استاندارد RFC 1122، اگر هاستی یک اتصال TCP را با دادههای خواندهنشده ببندد، پروتکل TCP پیام بازنشانی (Reset) میفرستد. در این صورت ممکن است پاسخ 413 به دست کلاینت نرسد. - متد
send_streamخطایBrokenPipeErrorرا گرفته و ژنراتور را میبندد. وقتی کاربر برگهی مرورگر را میبندد، اجرای مدل متوقف میشود و هزینهی بیشتری تحمیل نخواهد شد.
کلاس BaseHTTPRequestHandler بهطور پیشفرض از پروتکل HTTP/1.0 استفاده میکند. در این حالت، پایان بدنهی استریمشده همان لحظهای است که سرور اتصال را قطع میکند. سروری که از HTTP/1.1 استفاده میکند، برای همین منظور دادهها را با کدگذاری انتقال تکهای (Chunked transfer coding) ارسال میکند.
گام 6: صفحهی وب
تابع render_page یک سند HTML برمیگرداند که استایلها و اسکریپتهای آن بهصورت درونخطی قرار گرفتهاند. صفحه هیچ اسکریپت، فونت یا تصویر خارجی دیگری بارگذاری نمیکند. اسکریپت صفحه استریم را اینگونه میخواند:
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) { break; }
buffer += decoder.decode(chunk.value, { stream: true });
let cut = buffer.indexOf("\n\n");
while (cut >= 0) {
const frame = parseFrame(buffer.slice(0, cut));
buffer = buffer.slice(cut + 2);
cut = buffer.indexOf("\n\n");
if (frame.event === "token") { output.textContent += frame.data.text; }
}
}
یک تکه دادهی دریافتی از شبکه ممکن است درست وسط یک رویداد تمام شود. کلاینت این بخش ناقص را در buffer نگه میدارد تا خط خالی پایانی برسد.
صفحه خروجی مدل را همیشه با textContent در صفحه میگذارد و هرگز از innerHTML استفاده نمیکند؛ خروجی مدل متنی غیرقابلاعتماد است. اگر صفحه پاسخ را بهصورت کد HTML درج کند و آن متن حاوی عنصری با مشخصهی onerror باشد، آن مشخصه میتواند کدهای دلخواه را در مرورگر کاربر اجرا کند.
علاوه بر این، سرور هدر Content-Security-Policy را با مقدار default-src 'none' میفرستد. این سیاست امنیتی فقط به یک اسکریپت و یک استایل درونخطی که با هش SHA-256 شناسایی شدهاند اجازهی اجرا میدهد. مرورگر هر اسکریپت دیگری را رد میکند، از جمله اسکریپتهایی که شاید مهاجم سعی کرده به صفحه تزریق کند.
def csp_hash(source):
digest = hashlib.sha256(source.encode("utf-8")).digest()
return "'sha256-" + base64.b64encode(digest).decode("ascii") + "'"
گام 7: اجرا و تلاش برای آزمودن مرزهای سیستم
سرور را روشن کنید و از ترمینال دوم با دستور curl با آن ارتباط بگیرید. هفت درخواست پیاپی به سرور تازه بفرستید. سطل 5 توکن ظرفیت دارد و هر 2 ثانیه یک توکن جدید میگیرد:
for i in 1 2 3 4 5 6 7; do
curl -s -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:8000/api/predict \
-H 'Content-Type: application/json' -d '{"text": "ok"}'
done
200 200 200 200 200 429 429
10 ثانیه صبر کنید و سپس یک متن خالی بفرستید:
curl -s -X POST http://127.0.0.1:8000/api/predict \
-H 'Content-Type: application/json' -d '{"text": ""}'
{"error": {"code": "empty_text", "message": "The text is empty.", "hint": "Type a sentence or pick an example.", "request_id": "e3e3599feaea"}}
استریم رویدادها را با دستور curl -N ببینید که بافر خروجی را غیرفعال میکند:
curl -N -X POST http://127.0.0.1:8000/api/stream \
-H 'Content-Type: application/json' -d '{"text": "The screen is bright."}'
برای اجباری کردن توکن دسترسی، پیش از اجرای سرور متغیر محیطی DEMO_ACCESS_TOKEN را مقداردهی کنید. در این صورت کادری برای وارد کردن توکن در صفحه ظاهر میشود.
export DEMO_ACCESS_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
echo "$DEMO_ACCESS_TOKEN"
python3 code/main.py --serve
اجرای آزمونها:
python3 -m unittest discover -s code/tests -v
آزمونهای HTTP سرور را روی پورت 0 بالا میآورند تا خود سیستمعامل یک پورت خالی به آن اختصاص دهد. متد tearDown سرور را متوقف کرده و اطمینان حاصل میکند که رشتهی اجرایی آن بسته شده است.
استفاده از ابزارهای واقعی
دو فریمورک پایتونی دیگر میتوانند همین نوع دمو را با کد کمتری بسازند: Gradio و Streamlit. در هر دو مثال زیر، قرارداد اولیه از main.py وارد شده است؛ بنابراین اعتبارسنج و مدل تغییر نمیکنند. البته برای اجرای main.py یا آزمونهای آن به هیچکدام از این دو کتابخانه نیازی ندارید.
اجزای رابط کاربری حول یک تابع
فریمورک Gradio یک رابط وب دور یک تابع پایتون میکشد. کامپوننتهای ورودی و خروجی ساختار قرارداد را تعریف میکنند، و یک تابع ژنراتور خروجی را بهمرور میفرستد.
import os
import gradio as gr
from main import EXAMPLES, MAX_CHARS, DemoError, classify, generate, validate_text
def label_text(text):
try:
clean = validate_text({"text": text})
except DemoError as err:
raise gr.Error(f"{err.message} {err.hint}")
label = classify(clean)["label"]
reply = ""
for token in generate(clean):
reply += token
yield reply, label
demo = gr.Interface(
fn=label_text,
inputs=gr.Textbox(label="Text to label", lines=5, max_length=MAX_CHARS),
outputs=[gr.Textbox(label="Reply"), gr.Textbox(label="Label")],
examples=list(EXAMPLES),
flagging_mode="never",
)
if __name__ == "__main__":
demo.queue(max_size=20).launch(auth=("demo", os.environ["DEMO_PASSWORD"]))
هر دستور yield کل مقدار خروجی را جایگزین میکند؛ بنابراین این تابع در هر مرحله تمام پاسخی که تا آن لحظه تولید شده را بازمیگرداند.
پیش از آنکه پیوند برنامه را برای کسی بفرستید، حالت flagging_mode را عمداً مشخص کنید. حالت پیشفرض دکمهای برای گزارش (Flag) نشان میدهد. وقتی کاربر آن را بزند، Gradio ورودی و خروجی را در فایلی با فرمت CSV روی سرور مینویسد. قرار دادن مقدار "never" این دکمه را حذف میکند.
دستور queue(max_size=20) تعداد درخواستهای در صف انتظار را محدود میکند. سقف همزمانی پیشفرض برابر با یک اجرای همزمان بهازای هر رویداد است؛ در نتیجه یک مدل کند تا زمانی که این سقف را بالا نبرید فقط به یک کاربر در لحظه سرویس میدهد. ابزار Gradio هنگام بالا آمدن اندپوینتهای API هم میسازد؛ یعنی اسکریپتها میتوانند بدون دیدن صفحه دمو را فراخوانی کنند. محدودیت نرخ درخواست شما باید روی این اندپوینتها هم اعمال شود.
اسکریپتی که با هر ورودی دوباره اجرا میشود
فریمورک Streamlit با هر بار تغییر دادن یک ویجت توسط کاربر، کل اسکریپت را از ابتدا تا انتها دوباره اجرا میکند. یک فرم مقادیر تمام ویجتهایش را جمعآوری میکند و وقتی کاربر دکمهی ارسال را زد، همه را یکباره میفرستد.
import streamlit as st
from main import EXAMPLES, MAX_CHARS, DemoError, classify, generate, validate_text
st.title("Toy sentiment demo")
st.caption("Do not paste private data.")
example = st.selectbox("Start from an example", ["", *EXAMPLES])
with st.form("demo"):
text = st.text_area("Text to label", value=example, max_chars=MAX_CHARS)
submitted = st.form_submit_button("Run model")
if submitted:
try:
clean = validate_text({"text": text})
except DemoError as err:
st.error(f"{err.message} {err.hint}")
else:
st.write_stream(generate(clean))
st.write(f"Label: {classify(clean)['label']}")
تابع st.write_stream ژنراتور را مستقیماً دریافت کرده و هر تکه داده را بهمحض رسیدن مینویسد. برای بارگذاری یک مدل واقعی از دکوراتور @st.cache_resource استفاده کنید، چون این اسکریپت با هر بار ارسال دوباره از نو اجرا میشود. این دکوراتور یک نسخه از مدل را برای تمام نشستها به اشتراک میگذارد.
تنظیمات پیشفرض هر ابزار
| نیاز سیستم | کتابخانهی استاندارد (این درس) | Gradio | Streamlit |
|---|---|---|---|
| فرم، نمونهها و کادر نمایش نتیجه | HTML را خودتان مینویسید | کلاس Interface آنها را بر اساس کامپوننتها و examples میسازد |
ویجتهایی مثل st.text_area و st.selectbox |
| محدودیت طول ورودی | مشخصهی maxlength در صفحه و بررسی سمت سرور |
مقدار max_length در کامپوننت Textbox |
مقدار max_chars در تابع st.text_area |
| خروجی تکهتکه (استریم) | ساخت دستی SSE و خوانندهی استریم | تابع ژنراتور، که هر yield خروجی را جایگزین میکند |
تابع st.write_stream همراه با ژنراتور تکههای متن |
| نمایش پیشرفت کار | پیادهسازی دستی تایمر | نشانگر پیشرفت در محل خروجی، با تنظیم show_progress |
تابع st.write_stream که هر تکه را بهمحض رسیدن نشان میدهد |
| نمایش خطاها به کاربر | خطای JSON با code، message، hint و request_id |
خطای raise gr.Error(...) پیام را نشان میدهد |
تابع st.error(...) پیام را نشان میدهد |
| همزمانی (Concurrency) | یک رشتهی اجرایی برای هر درخواست، بدون محدودیت | صف با امکان اجرای پیشفرض یک درخواست در لحظه | هر تعامل، اسکریپت را برای آن نشست از نو اجرا میکند |
| پیوند عمومی | مدیریت سرور و دامنه با خودتان است | قابلیت launch(share=True) پیوندی یکهفتهای میدهد |
برنامه را روی یک سرور مستقر میکنید |
| ورود به سیستم | بررسی Bearer token | قابلیت launch(auth=...) با نام کاربری و رمز عبور |
تابع st.login() همراه با ارائهدهندهی OpenID Connect |
| محدودیت نرخ و سقف هزینه | سطل توکن و سقف روزانه | کدنویسی با خودتان، با استفاده از آدرس کلاینت در درخواست | کدنویسی با خودتان |
| ورودی ذخیرهشده روی دیسک | فقط طول ورودی در لاگ | نمونههای گزارششده در قالب فایل CSV، مگر اینکه flagging_mode="never" باشد |
بر عهدهی کد شماست |
فریمورکها بیشترین زحمت را در ساخت صفحه کم میکنند: طراحی فرم، افزودن نمونهها، نمایش وضعیت پیشرفت و خروجی استریم. اما در مهار خطرات یک پیوند عمومی کمک کمتری به شما میکنند. در هر سه حالت، اعمال محدودیت نرخ درخواست، مهار هزینهها و حفظ حریم خصوصی لاگها بر دوش خودتان است. پیادهسازی با کتابخانهی استاندارد تمام این اجزا را آشکار میکند تا هنگام کار با فریمورکها دقیقاً بدانید چه مواردی را باید بررسی کنید.
خروجی این درس
این درس چکلیستی در مسیر outputs/skill-demo-interface-checklist.md به شما تحویل میدهد. توصیف یا کدهای یک دموی مدل را به آن بدهید تا گزارشی از وضعیت قبولی یا ردی قرارداد، نحوهی نمایش بازخورد، خطاها و تمهیدات لازم برای اشتراکگذاری عمومی دریافت کنید. این گزارش برای هر ایراد، یک راهحل مشخص ارائه میدهد.
تمرینها
- مقدار
BUCKET_CAPACITYرا برابر با 2 وREFILL_PER_SECONDرا روی 1 تنظیم کنید. خروجی حلقهی هفتدرخواستی را پیشبینی کنید؛ سپس آن را اجرا کرده و نتیجه را بسنجید. - فیلد
languageرا به بدنهی درخواست بیفزایید. فقط مقدار"en"را بپذیرید. برای هر مقدار دیگر، خطای 422 همراه با راهنما بازگردانید و دو آزمون برای آن بنویسید. - جملهای پیدا کنید که مدل آزمایشی دستهی آن را اشتباه تشخیص دهد (مثل «The screen is not good.»). آن را بهعنوان نمونهی چهارم اضافه کنید و یادداشتی در صفحه برای توضیح این محدودیت بنویسید.
- دمو را پشت یک پروکسی معکوس روی سیستم خود بگذارید. گزینهای به نام
trust_proxyاضافه کنید که هدرX-Forwarded-Forرا فقط از مبدا آدرس پروکسی بخواند. آزمونی بنویسید که ثابت کند کلاینت نمیتواند کلید سطل توکن خود را انتخاب کند. - تابع
generateرا با فراخوانی یک API واقعی مدل که توکنها را بهصورت استریم برمیگرداند جایگزین کنید. اعتبارسنج، محدودیتها و قالب رویدادها را دستنخورده نگه دارید. زمان تا اولین توکن در صفحه را با میزان تاخیر در لاگ سرور مقایسه کنید.
اصطلاحات کلیدی
| اصطلاح | آنچه در گفتوگوها گفته میشود | مفهوم دقیق فنی |
|---|---|---|
| قرارداد دمو (Demo contract) | «همان رابط کاربری» | محدودیتهای ورودی، فراخوانی مدل و ساختار خروجی شامل خطاها |
| سطل توکن (Token bucket) | «ابزار محدودکنندهی نرخ» | شمارندهای برای هر کلاینت که با نرخی ثابت تا سقفی مشخص پر میشود. هر درخواست یک توکن برمیدارد |
| هدر Retry-After | «کمی بعدتر امتحان کن» | هدر پاسخ HTTP که تعداد ثانیهها یا تاریخی را مشخص میکند که کلاینت پس از آن مجاز به تلاش مجدد است |
| رویدادهای ارسالی از سرور (SSE) | «استریمینگ» | پاسخی با نوع text/event-stream متشکل از خطوط event: و data: که با یک خط خالی تمام میشوند |
| زمان تا اولین توکن (TTFT) | «تاخیر» | زمان سپریشده از لحظهی ارسال درخواست تا رسیدن اولین توکن روی صفحهی کاربر |
| سقف مصرف (Usage cap) | «همان بودجه» | محدودیت تعداد کل فراخوانیهای مجاز مدل در یک بازهی زمانی، برای مجموع همهی کلاینتها |
| هش کلیددار (Keyed hash) | «شناسهی ناشناس» | یک HMAC از آدرس کلاینت با یک سالت (Salt) محرمانه؛ نوعی نام مستعار است که هر کس سالت را داشته باشد میتواند آدرسهای مشخص را با آن بیازماید |
| سیاست امنیت محتوا (CSP) | «سیاسپی» | هدر پاسخی که به مرورگر میگوید کدام اسکریپتها، استایلها و ارتباطات شبکهای اجازهی فعالیت در صفحه را دارند |
برای مطالعهی بیشتر
- مستندات پایتون برای
http.server: کلاسهای سرور، پیشفرض HTTP/1.0، خط لاگ پیشفرض، و این هشدار که این ماژول برای محیط عملیاتی (پروداکشن) نیست. - استاندارد WHATWG HTML برای SSE: قالب استریم رویدادها و رابط
EventSource. - راهنمای MDN دربارهی استفاده از رویدادهای ارسالی از سرور: مثالهایی از قالب رویدادها و API مرورگر.
- راهنمای MDN دربارهی استفاده از جریانهای خواندنی: نحوهی خواندن تکهبهتکهی بدنهی پاسخ
fetch(). - راهنمای MDN برای Fetch API: ارسال درخواست با بدنهی JSON و مدیریت پاسخها.
- راهنمای MDN برای وضعیت 429 Too Many Requests و هدر Retry-After: وضعیت محدودیت نرخ و هدر مربوط به آن.
- راهنمای MDN دربارهی Content Security Policy: استفاده از هش برای اسکریپتهای درونخطی و دستورالعمل
default-src. - استاندارد RFC 6585: کدهای وضعیت تکمیلی HTTP، از جمله 429.
- استاندارد RFC 9110: معناشناسی HTTP، شامل کدهای 413، 415 و 422.
- استاندارد RFC 9457: ساختار استاندارد Problem Details برای گزارش خطاهای JSON در APIهای HTTP.
- استاندارد RFC 1122: الزامات ارتباطات هاست، شامل ارسال پیام TCP Reset هنگام قطع اتصال با دادههای خواندهنشده (بخش 4.2.2.13).
- راهنمای Gradio برای اشتراکگذاری برنامهها: پیوندهای اشتراکگذاری، احراز هویت، صفحهی API و محدودیتهای نرخ.
- راهنمای Gradio برای خروجیهای استریمینگ: استفاده از توابع ژنراتور برای تولید خروجی مرحلهبهمرحله.
- مستندات Gradio Interface: تنظیمات
examples،flagging_modeوconcurrency_limit. - راهنمای Flagging در Gradio: مسیر ذخیرهی نمونههای گزارششده روی دیسک.
- مفاهیم اولیهی Streamlit: جریان کاری اجرای مجدد برنامه.
- مستندات Streamlit برای
st.write_streamو کش کردن در Streamlit: تولید خروجی مرحلهبهمرحله و بارگذاری یکبارهی مدل. - مستندات Streamlit برای
st.login: ورود به سیستم با ارائهدهندهی OpenID Connect.
