فاز 19 · درس 23

پیاده‌سازی دیسپچر فراخوانی ابزارها (Dispatcher) در ایجنت‌های هوش مصنوعی

نوع
ساختنی
زمان
حدود 90 دقیقه
زبان‌ها
Python
یک سیستم راه‌آهن مدرن با چند ریل موازی و چراغ‌های هوشمند هدایت‌کننده که قطارها را کنترل و تقسیم می‌کنند
فهرست مطالب
  1. اهداف یادگیری
  2. جایگاه دیسپچر
  3. تایم‌اوت‌ها
  4. تلاش مجدد با عقب‌گرد نمایی
  5. حذف درخواست‌های تکراری با کلید یکتایی تراکنش
  6. قالب خطا
  7. سقف هم‌زمانی در توزیع موازی
  8. فلو برای یک فراخوانی
  9. راهنمای خواندن کد
  10. گام‌های بعدی

اتصال مدل‌های زبانی به ابزارهای بیرونی در محیط‌های واقعی هرگز بی‌نقص پیش نمی‌رود؛ تاخیر شبکه، قفل شدن سرورها و خطاهای گذرا می‌توانند کل حلقه‌ی اجرای ایجنت را متوقف کنند یا در صورت تکرار ناخواسته، عملیات مخربی مثل دو بار کسر وجه از حساب انجام دهند. اینجاست که لایه‌ی دیسپچر (Dispatcher) به عنوان ضربه‌گیر سیستم وارد میدان می‌شود تا فاصله‌ی میان برنامه‌ریز ایجنت و ابزارهای خارجی را امن کند.

در این درس می‌بینید که چطور با جداسازی وظایف و کپسوله‌سازی منطق اجرای ابزار، مواردی مثل تایم‌اوت به ازای هر فراخوانی، الگوریتم تلاش مجدد با عقب‌گرد نمایی (Exponential Backoff) و حذف درخواست‌های تکراری با کلید یکتایی (Idempotency Key) پیاده‌سازی می‌شوند. این معماری تضمین می‌کند که هیچ خطایی بدون ساختار استاندارد به حلقه‌ی اصلی بازنگردد و منابع سیستمی با اجرای بی‌رویه هدر نروند.

دیسپچر جایی است که بستر آزمایشی (harness) تاوان تک‌تک وعده‌های اسکیما را پس می‌دهد: تایم‌اوت‌ها، تلاش‌های مجدد، حذف فراخوانی‌های تکراری و نگاشت خطاها؛ همگی در یک مرز مشترک.

اهداف یادگیری

  • کپسوله‌سازی هندلر ابزار در تایم‌اوت مجزا به‌ازای هر فراخوانی، به‌طوری که به‌جای قفل کردن حلقه، خطایی با تایپ مشخص برگرداند.
  • پیاده‌سازی تلاش مجدد با عقب‌گرد نمایی (Exponential Backoff) همراه با نوسان تصادفی (Jitter) و سقف مشخص برای تعداد تلاش‌ها.
  • حذف درخواست‌های تکراری بر اساس کلید یکتایی تراکنش (Idempotency key)، تا تلاش مجددی که با درخواست اولیه‌ی کند هم‌زمان می‌شود، دو بار اجرا نشود.
  • نگاشت استثناهای هندلر و خطاهای بستر انتقال (Transport) روی یک ساختار خطای واحد (Error envelope) که حلقه‌ی بستر آزمایشی ساختار آن را می‌شناسد.
  • محدود کردن ارسال موازی با سقف هم‌زمانی، تا توزیع موازی (Fan-out) 40 فراخوانی ابزار باعث اتمام منابع حلقه‌ی رویداد نشود.
درز توزیع‌کننده (DISPATCHER SEAM)مهلت زمانی، عقب‌گرد، حذف تکرار، یک قالب پاسخ
DISPATCHER SEAMAttempt one runs to its per-call timeout and returns a typed error instead of hanging the loop. Backoff doubles with jitter before each retry, and a duplicate that races the in-flight attempt collapses into it on the idempotency key. Whatever happens, the loop receives one envelope shape: result or mapped error, never a raw stack trace.حلقه بستر آزمایشیتوزیع‌کنندهمهلت زمانی · تلاش مجدد · حذف تکرارگردانندهtتلاش 1تلاش 2تلاش 31s2s + نوسان تصادفیتکراری، کلید a1f3کلید a1f3قالب نتیجه نوع‌دارتوزیع موازی محدودشده: سقف در حال اجرا
تلاش اول تا مهلت زمانی تعیین‌شده برای هر فراخوانی اجرا می‌شود و به جای معلق نگه‌داشتن حلقه، یک خطای نوع‌دار برمی‌گرداند. عقب‌گرد قبل از هر بار تلاش مجدد همراه با نوسان تصادفی (jitter) دو برابر می‌شود، و درخواستی تکراری که با تلاشِ در جریان رقابت می‌کند، به کمک کلید یکتایی تراکنش با آن ادغام می‌شود. در هر حال، حلقه تنها یک ساختار پاسخ دریافت می‌کند: نتیجه یا خطای نگاشت‌شده، و هرگز ردگیری خام پشته را دریافت نمی‌کند.

جایگاه دیسپچر

بین حلقه‌ی بستر آزمایشی (درس 20) و رجیستری ابزار (درس 21). بستر انتقال (درس 22) ورودی را به حلقه می‌رساند. حلقه فراخوانی ابزار (Tool call) را تحویل دیسپچر می‌دهد. دیسپچر سراغ رجیستری می‌رود، هندلر را اجرا می‌کند و در نهایت نتیجه یا یک بسته‌ی خطای شبیه JSON-RPC را برمی‌گرداند.

دریافت ناماعتبارسنجی آرگومان‌هاasyncio.wait_for handler argstimeoutموفقیتTimeoutError - تلاش مجدد یاخطاException - نگاشت به کد خطانتیجه Ok یا DispatchErrorحلقه بستر آزمایشیتوزیع‌کنندهرجیستری ابزارگرداننده

دیسپچر تنها لایه‌ای است که از تایمرها، تلاش‌های مجدد و بی‌اثر در تکرار بودن (Idempotency) خبر دارد. حلقه از این‌ها بی‌خبر است؛ رجیستری خبر ندارد؛ هندلر هم خبر ندارد. اصل قضیه همین جداسازی وظایف است.

تایم‌اوت‌ها

هر ابزار یک تایم‌اوت پیش‌فرض دارد. رکورد ثبت‌شده در رجیستری، فیلد timeout_ms را در خود دارد. وقتی بستر آزمایشی تایم‌اوت اختصاصی بفرستد، دیسپچر مقدار پیش‌فرض را با آن بازنویسی می‌کند. ما از asyncio.wait_for استفاده می‌کنیم. با اتمام مهلت زمانی، وظیفه‌ی (Task) هندلر لغو می‌شود و دیسپچر خطای DispatchError(kind="timeout") را برمی‌گرداند.

برای ابزارهای غیر Idempotent، تایم‌اوت به‌صورت پیش‌فرض خطایی قابل تلاش مجدد محسوب نمی‌شود. مثلاً عملیات db.write که با تایم‌اوت مواجه شده، ممکن است commit شده باشد یا نشده باشد. تلاش مجدد، نوشتن در دیتابیس را تکرار می‌کند. دیسپچر از فلگ idempotent در رکورد رجیستری پیروی می‌کند: ابزارهای Idempotent دوباره امتحان می‌شوند و ابزارهای غیر Idempotent نه.

تلاش مجدد با عقب‌گرد نمایی

سیاست تلاش مجدد حداکثر 3 بار است. عقب‌گرد به‌صورت نمایی همراه با نوسان تصادفی اعمال می‌شود.

attempt 1  -> delay 0
attempt 2  -> delay 0.1s * (1 + random[0..0.5])
attempt 3  -> delay 0.4s * (1 + random[0..0.5])

تنها خطاهای timeout و transient مشمول تلاش مجدد می‌شوند. خطاهای schema، not_found یا internal دوباره تکرار نمی‌شوند. خطاهای اسکیما قطعی هستند؛ تلاش مجدد نتیجه را تغییر نمی‌دهد و فقط بودجه را می‌سوزاند.

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

حذف درخواست‌های تکراری با کلید یکتایی تراکنش

تلاش مجددی که حین در جریان بودن درخواست اولیه شلیک شود، یک باگ واقعی در محیط عملیاتی (پروداکشن) است. فراخوانی اول در ثانیه‌ی 4.9 گیر می‌کند (درست کمی قبل از تایم‌اوت). تلاش مجدد در ثانیه‌ی 5 شلیک می‌شود. حالا دو درخواست روی بک‌اند یکسان با هم رقابت می‌کنند. اگر ابزار مورد نظر payments.charge باشد، دو بار از کاربر پول کم کرده‌اید.

دیسپچر یک آرگومان اختیاری به نام idempotency_key می‌گیرد. اگر هنگام رسیدن یک فراخوانی، کلیدی مشابه همچنان در حال پردازش باشد، دیسپچر منتظر همان فیوچرِ در جریان می‌ماند و نتیجه‌ی آن را برمی‌گرداند. کشْ کلیدها را تا 60 ثانیه پس از اتمام نگه می‌دارد تا تلاش‌های مجدد دیرهنگام را خنثی کند.

تولید کلید بر عهده‌ی فراخواننده است. بستر آزمایشی آن را از پلنر می‌گیرد: f"{step_id}:{tool_name}:{hash(args)}". دیسپچر خودش کلید نمی‌سازد؛ چون مشتق کردن کلید صرفاً از روی آرگومان‌ها باعث می‌شود دو فراخوانی با معنای کاملاً متفاوت، یکسان به نظر برسند.

قالب خطا

فراخوانی ناموفق فقط در قالب یک ساختار واحد خروجی می‌دهد.

DispatchError
  kind        : "timeout" | "transient" | "schema" | "not_found" | "internal" | "budget_exceeded"
  message     : str
  attempts    : int
  jsonrpc_code: int   (one of -32601, -32602, -32603)

حلقه‌ی بستر آزمایشی نوع فیلد kind را به وضعیت بعدی نگاشت می‌کند. خطاهای schema و not_found به on_error می‌روند و برنامه‌ریزی مجدد (Replan) را تحریک می‌کنند. خطاهای timeout و transient به on_error فرستاده می‌شوند و بسته به تعداد تلاش‌ها ممکن است به برنامه‌ریزی مجدد ختم شوند یا نشوند. خطای budget_exceeded هم وضعیت on_budget_exceeded را فعال می‌کند.

سقف هم‌زمانی در توزیع موازی

دستور gather(*calls) همه‌ی کوروتین‌ها را هم‌زمان اجرا می‌کند. با 40 فراخوانی ابزار، یعنی 40 سوکت باز یا 40 پایپِ ساب‌پراسس. بیشتر بک‌اندها روی خوشی به 40 اتصال موازی از سمت یک کلاینت نشان نمی‌دهند.

دیسپچر دستور gather را داخل یک سمافور (Semaphore) قرار می‌دهد. سقف هم‌زمانی پیش‌فرض 8 است. هر فراخوانی قبل از ارسال، سمافور را دریافت می‌کند و پس از اتمام آن را آزاد می‌سازد. فراخواننده خروجی‌ای درست مشابه gather دریافت می‌کند، اما زمان‌بندی واقعی فراخوانی‌ها دارای محدودیت است.

فلو برای یک فراخوانی

خطاهاتاییدیافت شد: در حال اجرایافت شد: اخیریافت نشدبلهخیربله، تلاش‌های باقی‌ماندهبه پایان رسیدفراخوان: dispatch name, args, optsregistry.validate name, argsDispatchError kind=schemaحافظه موقت بی‌اثر در تکرار؟انتظار برای future موجودبازگرداندن نتیجه کش‌شدهasyncio.wait_for handler args, timeoutکش + بازگرداندن نتیجهTimeoutError + بی‌اثر در تکرار؟تلاش مجدد با عقب‌گردDispatchErrorTransientError؟نگاشت Exception به نوع، بدون تلاش مجددDispatchError

راهنمای خواندن کد

فایل code/main.py کلاس‌های Dispatcher، DispatchError و TransientError را تعریف می‌کند. دیسپچر در زمان ساخت، رجیستری را تحویل می‌گیرد. متد ناهمگام dispatch(name, args, ...) تنها نقطه‌ی ورود به سیستم است. تایم‌اوت‌های هر تلاش به‌شکل درون‌خطی در تابع _run_with_retries و با استفاده از asyncio.wait_for اعمال می‌شوند. متد gather_bounded(calls) چندین فراخوانی را با رعایت سقف هم‌زمانی اجرا می‌کند.

تست‌های code/tests/test_dispatcher.py مواردی مثل فعال شدن تایم‌اوت، تلاش مجدد در خطاهای گذرا، عدم تلاش مجدد در خطای اسکیما، حذف فراخوانی‌های تکراری با کلید یکتایی (کاهش دو فراخوانی هم‌زمان با یک کلید به اجرای یک هندلر) و محدودسازی هم‌زمانی (کارکرد عملی سمافور) را پوشش می‌دهند.

تست‌ها از asyncio.sleep(0) و هندلرهای قطعی مبتنی بر Counter استفاده می‌کنند، بنابراین ظرف چند میلی‌ثانیه تمام می‌شوند و به زمان ساعت سیستم وابسته نیستند.

گام‌های بعدی

دو قابلیت تکمیلی که دیسپچرهای پروداکشن به سیستم اضافه می‌کنند: اول، لاگ‌گیری ساختاریافته در هر تغییر وضعیت (که جریان رویدادهای حلقه پیشاپیش در اختیارتان می‌گذارد، اما دیسپچر هم باید رویدادهای dispatch.attempt و dispatch.retry را ارسال کند). دوم، مدارشکن‌ها (Circuit Breakers): بعد از N بار شکست در یک بازه‌ی زمانی مشخص، ابزار وارد دوره‌ی استراحت (Cool-down) می‌شود و در این مدت، فراخوانی‌ها بدون تلاش برای اجرای هندلر، بلافاصله مقدار مستقیم (بی‌واسطه) با وضعیت kind="circuit_open" را برمی‌گردانند. هر دو قابلیت را می‌توان بدون دستکاری قرارداد این دیسپچر، روی آن پیاده کرد.

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

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسچرا اتمام مهلت زمانی در ابزارهای دارای اثر جانبی (غیر بی‌اثر در تکرار) به‌طور پیش‌فرض تکرار نمی‌شود؟
  2. پیش از درسحذف موارد تکراری بر پایه کلید یکتایی تراکنش در برابر چه چیزی محافظت می‌کند؟
  3. حین درسکدام انواع خطا در این توزیع‌کننده مجاز به تلاش مجدد هستند؟
  4. حین درسسمافور پیرامون _run_with_retries از چه چیزی محافظت می‌کند؟
  5. حین درسچرا کلید یکتایی تراکنش توسط فراخواننده مشتق می‌شود نه توزیع‌کننده؟
  6. پس از درسکدام کد خطای JSON-RPC به نقص اسکیما هنگام توزیع نگاشت می‌شود؟
  7. پس از درسپس از رسیدن به max_attempts با خطای مهلت زمانی در ابزاری بی‌اثر در تکرار، چه چیزی برگردانده می‌شود؟

سؤالات متداول

چرا دیسپچر روی خطاهای اسکیما دوباره تلاش (Retry) نمی‌کند؟

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

کلید یکتایی تراکنش (Idempotency Key) چطور جلوی خطای درخواست تکراری را می‌گیرد؟

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

محدود کردن سقف هم‌زمانی در دستور gather چه ضرورتی دارد؟

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

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.