امنح وكيل الذكاء الاصطناعي لديك جهازاً عصبياً: دورة مكثفة في 90 دقيقة
15 مفهوماً، نحو 80% من الاستخدام الحقيقي: الحواس (المحفّزات)، والمنعكسات (التنفيذ المتين)، والتوازن (التحكم في التدفّق).
لقد بنيت وكيلاً يعمل. المشكلة: أنه يعمل فقط ما دمت تراقبه. تفتح Claude Code أو OpenCode، وتكتب، فيردّ. ابتعد فيتوقّف. وسدُّ تلك الفجوة، بين وكيل تشغّله أنت وعامل يشتغل من تلقاء نفسه، هو موضوع هذه الدورة.
ما يسدّ الفجوة ليس وكيلاً أذكى. وكيلك يملك أصلاً ما يحتاجه لأداء العمل: نموذج لغوي ليفكّر، وأدوات وخوادم MCP ليعمل، ومهارات للأعمال التي يعرفها. ما ينقصه هو جهاز عصبي.
فكّر في جسدك أنت. دماغك يفكّر وعضلاتك تعمل. لكن نظاماً ثانياً يعمل تحت ذلك، من دونك: نبض قلبك، ومنعكساتك، والإشارات التي تبقيك حيّاً وأنت نائم. توقّف عن الانتباه فيظلّ قلبك ينبض. الوكيل لا يملك شيئاً من هذا القبيل. فما إن تتوقّف عن قيادته حتى يتوقّف.
الجهاز العصبي يُغلق الحلقة من تلقاء نفسه، بلا إنسان في كل دورة. يستشعر العالم ويوقظ الوكيل عندما يحدث شيء. ويتفاعل بردّ فعل منعكس عندما تفشل خطوة، ويحفظ موضعه ساعات وهو ينتظر شخصاً أو واجهة بطيئة. ويبقي الوكيل ثابتاً حين تصل خمسمائة طلب دفعة واحدة. هذا هو الفرق بين وكيل تشغّله أنت وموظف رقمي بدوام كامل يشتغل من تلقاء نفسه. أنت تضيف هذا الجهاز العصبي إلى وكيلك. أنت لا تعيد كتابة الوكيل. تلك هي الفكرة الوحيدة التي بُنيت عليها هذه الدورة.
📚 وسيلة تعليمية
اعرض العرض التقديمي الكامل — الجهاز العصبي لوكيل الذكاء الاصطناعي
لهذه الأداة اسم تقني: محرك تنفيذ متين. نستخدم واحداً اسمه Inngest. وتعمل الأنماط نفسها في Temporal وRestate وDapr Agents. وهذه ليست مجرد صورة تعليمية: فشركة Day AI، وهي CRM للشركات الأصلية في الذكاء الاصطناعي، تصف Inngest بأنها «الجهاز العصبي» لمنتجها. وطبقة Hobby المجانية من Inngest هي أسهل مدخل للبدء: بلا بطاقة ائتمان، وخادم تطوير بأمر واحد، ولوحة تحكّم تستطيع مراقبتها أثناء البناء.
قبل أي شيء آخر، إليك الإعداد كلّه في صورة واحدة:
1. an EVENT happens (e.g. a customer emails)
|
v
2. the INNGEST ENGINE catches it
(you do NOT build this. it runs your agent for you:
retries, waits, remembers every step, shows a dashboard)
|
| it reaches your code over a thin web wire (FastAPI)
v
3. YOUR AGENT runs
(the only part you write. it thinks and acts.)
هذا هو النموذج كلّه: برنامجان. المحرك (أنت لا تكتبه) يلتقط الأحداث ويشغّل وكيلك (أنت تكتبه)، واصلاً إليه عبر سلك ويب رفيع، وهو السبب الوحيد لظهور خادم ويب (FastAPI) في هذه الدورة أصلاً. تبدأ كليهما في المكسب السريع وتراقب المحرك وهو يقود وكيلك.
المثال صغير عمداً: وكيل دعم عملاء يبحث عن بضعة عملاء نموذجيين، ويصوغ رداً، ولا يصدر ردّ أموال إلا بعد أن يوافق إنسان. الوكيل ليس موضع الصعوبة، لذا نبقيه صغيراً ونصرف جهدنا على الجهاز العصبي حوله. تبنيه هنا من الصفر. يلتقط الخيط من حيث تتركه دورة الموظف الرقمي بدوام كامل السابقة، غير أن D0 يهيّئ عاملاً أدنى من الصفر إذا تخطّيتها. وهو Python أولاً على inngest-py: توجّه وكيلك العام بإنجليزية بسيطة فيكتب الكود.
إليك كيف بُنيت الدورة، لتقرأها على النحو الصحيح. البناء هو العمود الفقري. تهيّئ البيئة مرة واحدة في المكسب السريع، ثم يبني الجزء 4 العامل كاملاً في سبع تعليمات قصيرة، طبقةً عصبية واحدة في كل مرة. ذلك هو المسار، وأداؤه هو الذي يرسّخ النموذج. أما المفاهيم الخمسة عشر في الأجزاء 1-3 فهي المرجع الذي يستند إليه البناء: فكرة واحدة لكلٍّ منها، «لماذا» تحت كل طبقة أنت على وشك إضافتها. وهناك طريقان جيّدان للعبور. اقرأ الأجزاء 1-3 أولاً إن كنت تحب الفكرة قبل لوحة المفاتيح. أو اذهب مباشرة إلى المكسب السريع والجزء 4، وعُد إلى مفهومٍ ما لحظة تجعلك طبقةٌ تسأل «لكن لماذا يعمل هكذا؟». في كلتا الحالتين، الجزء 4 هو حيث تبني.
الوكيل لا يستورد الجهاز العصبي أبداً، فتستطيع استبدال Inngest بTemporal أو Restate وتترك الوكيل دون مساس. تعطُّل وكيل واحد في منتصف مهمة أمر مزعج. أما قوة عمل من خمسين وكيلاً يتولّون عملاً يواجه العملاء بلا جهاز عصبي تحتهم فأمر مستحيل: إمّا أن تتبنّى منصّة تمنحك إياه، أو تقضي ستة أشهر في بناء نسخة أسوأ بنفسك. أربع خصائص تجعل هذا الجهاز العصبي بالغ الأهمية للوكلاء على نحو فريد: Day AI، وهي CRM للشركات الأصلية في الذكاء الاصطناعي، تشغّل منتجها على كل بدائية تعلّمها هذه الدورة: سير عمل LLM متين، وتنسيق بالانتظار للحدث، وreplay عند الفشل، وdebounce مع throttle مع concurrency، وعدالة متعدّدة المستأجرين. اثنان من مهندسيها المؤسّسين امتدّا إلى صورة الجهاز العصبي نفسها من تلقاء نفسيهما. إنها لغة إنتاج، لا علامة تجارية مناهجية. أطروحة Agent Factory تصف سبعة ثوابت يجب أن يحقّقها أي نظام وكلاء إنتاجي. العامل الذي تبنيه هنا يحقّق الثابت 4 (محرك) والثابت 5 (نظام سجلّ، وهنا سجلّ تدقيق صغير). وتضيف هذه الدورة اثنين آخرين، إضافة إلى جزء من الثابت 1:لماذا يحتاج وكيل الذكاء الاصطناعي إلى جهاز عصبي (أربع خصائص)
step.wait_for_event (المفهوم 15)، تبني طابور موافقة بنفسك: جدول قاعدة بيانات، واستطلاع متكرّر، ومعالجة مهلة، وسجلّ تدقيق. ذلك مشروع، لا ميزة.أين تقع هذه الدورة في أطروحة Agent Factory
step.wait_for_event هو أنظف تعبير عن ذلك على أي منصّة: الوكيل يتوقّف، وإنسان يُصدر الحدث المنتظَر، فيستأنف الوكيل.
المفاهيم الخمسة عشر في لمحة. تنطبق على الأعمال الثلاثة التي يؤدّيها الجهاز العصبي: الحواس (المحفّزات توقظ العامل)، والمنعكسات (التنفيذ المتين يبقيه صحيحاً عندما ينكسر شيء)، والتوازن (التحكم في التدفّق يبقيه سليماً تحت الحمل). هذه نسخة المرور الأول، مفهوم مع فحوى من سطر واحد. وعندما ينكسر شيء أثناء البناء، يحتوي المرجع السريع في النهاية على تشخيص يربط العَرَض بالمفهوم ويعيدك إلى المفهوم الذي يخصّه الفشل.المفاهيم الخمسة عشر في سطر واحد لكلٍّ منها (وسّع للخريطة الكاملة)
# المفهوم الفحوى في سطر واحد الحواس (المحفّزات) كيف يصل العالم إلى العامل 1 الأحداث مقابل الطلبات الطلب متزامن وأحدهم ينتظر؛ والحدث غير متزامن وقد مضى العالم في طريقه. 2 محفّزات cron جدول يوقظ الدالة. سطر واحد: TriggerCron(cron="0 9 * * *").3 محفّزات webhook حمولة HTTP واردة تصير حدثاً مسمّى؛ ودالتك تتفاعل مع الاسم. 4 Idempotency ودلالات الأحداث معرّفات الأحداث وأسماء الخطوات تجعل حدثاً مكرّراً (أو إعادة محاولة) بلا أثر. 5 Fan-out وتفويض الوكلاء الفرعيين حدث واحد، وN دالة مشترِكة؛ أو أبٌ واحد يُطلق N حدثاً ابناً. المنعكسات (التنفيذ المتين) إبقاء العامل صحيحاً عندما ينكسر شيء 6 step.run ونموذج الدالة المتينةكل step.run نقطة تفتيش؛ والدالة تستطيع التعطّل بين الخطوات وتستأنف.7 التذكّر، الآلية تحت الغطاء الخطوات المكتملة تعيد مخرجاً مخزّناً بدل إعادة التنفيذ. 8 step.sleep وstep.wait_for_eventكلاهما يوقف الدالة بمتانة، لمدّة أو لحدث. 9 إعادات المحاولة، معالجة الأخطاء، dead-letter إعادات محاولة تلقائية بتراجع تصاعدي؛ وبعد N محاولة يبقى التشغيل الفاشل للإعادة. 10 step.run لاستدعاءات الذكاء الاصطناعي في Pythonغلّف استدعاءات OpenAI في step.run؛ وstep.ai.infer يُفرّغ الاستدلال (step.ai.wrap لTypeScript فقط).التوازن والتعافي التحكم في التدفّق تحت الحمل، والتعافي، والبوابة البشرية 11 Concurrency وThrottling concurrency يحدّ التشغيلات النشطة؛ وthrottle يحدّ البدايات في الثانية.12 الأولوية والعدالة الأولوية ترتّب الطابور؛ وconcurrency لكل مفتاح يمنح كل مستأجر حصّة عادلة. 13 Batching راكِم الأحداث في استدعاء دالة مجمَّع واحد للعمل بالجملة الرخيص. 14 Replay والإلغاء بالجملة أعِد تشغيل التشغيلات الفاشلة بكود جديد؛ وألغِ بالجملة التشغيلات التي لم تعد تريدها. 15 بوابات HITL مع step.wait_for_eventالدالة تتوقّف حتى يوافق إنسان، ثم تستأنف بالقرار.
المتطلّبات المسبقة. تفترض هذه الدورة أنك أنجزت من وكيل إلى موظف رقمي بدوام كامل. فإن كنت قد أنجزتها، فأنت تحقّق أصلاً كل ما يلي ولديك عامل يستحقّ التغليف: جهاز الجزء 4 العصبي يشير إليه مباشرة، وتتخطّى الإعداد من الصفر في D0. وإن لم تكن قد أنجزتها، فأنجز تلك الدورة أولاً، أو تابع القراءة على أي حال: D0 يبني عاملاً أدنى من الصفر فتبقى بقيّة الدورة قائمة بذاتها. في كلتا الحالتين، تحتاج أربعة أشياء.
- تستطيع قيادة وكيل عام. Claude Code أو OpenCode، مثبَّت ومصرَّح به. وضع التخطيط، وملفّات القواعد، وسير اقرأ-أولاً-ثم-اكتب: إن كان ذلك الإيقاع مألوفاً، فأنت مهيَّأ. وتغطّيه دورة البرمجة بالوكلاء المكثفة إن لم يكن كذلك.
- مفتاح
OPENAI_API_KEY(أو مفتاح نموذج آخر يستطيع وكيلك العام استخدامه) وحساب Neon لنظام سجلّ العامل في Postgres. العامل يشغّل نموذجاً حقيقياً ويقرأ ويكتب عملاءه وسجلّ تدقيقه في Neon. وNeon مجانية (بلا بطاقة)، وتأذن لها بنقرة متصفح واحدة أثناء الإعداد؛ سجّل في neon.com في نحو دقيقة إن لم يكن لديك حساب. أمّا خادم تطوير Inngest نفسه فلا يحتاج حساباً.- توفُّر Node.js 20+، رغم أن العامل بلغة Python. خادم تطوير Inngest يُوزَّع بصفته Node CLI (
npx inngest-cli@latest dev).- نموذج ذهني عامل ل«المعتمد على الأحداث» مقابل «الطلب/الاستجابة». إن كان «العالم يُطلق حدثاً فتتفاعل معه صفر أو واحدة أو دوال كثيرة» يبدو مألوفاً، فأنت مهيَّأ. وإن لم يكن، فالمفهوم 1 يمنحك الشكل.
كيف تقرأ هذه الصفحة في المرور الأول
المروران. المرور الأول يضع النموذج العصبي وطبقاته الثلاث في رأسك؛ والمرور الثاني، ويداك على لوحة المفاتيح في الجزء 4، هو حيث تبني. وإن كنت تفضّل أن تبني أولاً ويتشكّل النموذج وأنت تمضي، فذلك ينفع أيضاً: ابدأ من المكسب السريع، وشغّل الجزء 4، وعامِل كل مفهوم بوصفه المرجع الذي تفتحه حين تثير طبقةٌ سؤال «لماذا». وسّع أي شيء موسوم ب*«تمّ عندما»* أو «ماذا تراقب»: سلوك قابل للتشغيل تقارن به تنبّؤاتك. في الجزء 4 تستطيع تصفّح المقتطفات الحاملة في القراءة الأولى؛ فالنثر حول كلٍّ منها يخبرك بما تفعله الطبقة، ووكيلك يكتب الكود عندما تبني. وكتل «جرّب مع الذكاء الاصطناعي» تعليمات امتداد اختيارية. وكل مفهوم يُختَم بتنبّأ (التزِم بإجابة قبل أن تتابع القراءة) أو تحقّق سريع (اختبر القاعدة التي قرأتها للتوّ)؛ وكلاهما موجود ليجعلك تتوقّف، لا ليقيّمك. وكل مصطلح معرَّف في سياقه حيث يظهر أول مرة.
حديثة حتى مايو 2026. بناء الجزء 4 كلّه شُغِّل من البداية إلى النهاية مقابل خادم تطوير Inngest حيّ ونموذج حقيقي على inngest 0.5.18، وopenai-agents 0.17.x (بُني وأُعيد التحقّق منه على 0.17.3 و0.17.4 معاً)، وfastapi 0.136.3، وPython 3.12، وInngest CLI. وكل مقتطف في الجزء 4 من ذلك البناء العامل، لا مكتوب من الذاكرة. البنية التي تعلّمها هذه الدورة لا تتغيّر حين يتغيّر الSDK؛ فالSDK هو واجهة هذا العام إليها. والمكان الوحيد الذي قد تعضّ فيه ترقية openai-agents صغرى هو تفصيل الاستئناف في D5 (كيف يعالج تسلسل حالة-التشغيل سياقاً مخصّصاً)، لذا يربط ذلك القرار الوثائق الحيّة مباشرة. وإن اختلفت يوماً صفحة وثائق حيّة وهذه الصفحة على تفصيل بناء جملة، فالوثائق هي الفيصل: ثبّت إصداراتك، وراجِع بداية Inngest السريعة لPython ووثائق OpenAI Agents SDK عندما تبني.
الأقسام التي تتشعّب بين Claude Code وOpenCode فيها مبدّل؛ اختر واحداً فتتزامن الصفحة عبر الزيارات.
المكسب السريع في خمس عشرة دقيقة: هيّئ القاعدة، وشاهد المنعكس
قبل المفاهيم الخمسة عشر التي تشرح لماذا يعمل هذا، هيّئ البيئة التي تعمل فيها الدورة وشاهد مهمة واحدة تنجو من تعطُّل. تجري هذا الإعداد مرة واحدة؛ والجزء 4 يبني العامل الحقيقي على القاعدة نفسها. وبنهايته سيكون لديك:
- القاعدة مفتوحة في وكيلك العام، بمهاراتها وأدواتها مهيّأة لك،
- قاعدة بيانات Neon جديدة بجدولين (
customersوaudit_log) أنشأهما وكيلك، - عامل صغير واحد يعمل، بلوحة تحكّم تستطيع مراقبته فيها،
- تشغيلاً راقبتَه يخلد إلى النوم وهو ينتظر، محترقاً حوسبةً صفرية طوال الوقت،
- تشغيلاً كسرتَه عمداً، ثم راقبتَ النظام يعيد المحاولة: أبقى العمل الذي انتهى أصلاً وأعاد تنفيذ الجزء المكسور وحده فقط،
- وتلك الدالة نفسها مع وكيل حقيقي يكتب التحية داخل الخطوة المتينة، فتنتهي وقد شاهدت عامل ذكاء اصطناعي يعمل، لا مجرد مؤقّت.
هاتان النقطتان الأخيرتان هما المقصد: إعادة المحاولة هي المنعكس الذي تدور حوله الدورة كلّها، والوكيل العامل داخلها هو الوعد الذي يخدمه ذلك المنعكس. إنها جلسة واحدة، لا بناء الجزء 4 الكامل، فأنجزها، ثم عُد للمفاهيم.
الآن تبدأ البرنامجين من الافتتاحية: عاملك (كودك) وخادم تطوير Inngest (المحرك الذي يعمل بجانبه، بلوحة تحكّمه على http://127.0.0.1:8288، حيث يسرد /runs كل تشغيل). يتّصلان عبر طبقة ويب صغيرة دائمة التشغيل، FastAPI، الباب الذي يطرقه خادم التطوير ليبدأ تشغيلاً. الحلقة كلّها في سطر واحد: يصل حدث، فيصل خادم التطوير إلى عاملك عبر ذلك الباب، فتُنفَّذ دالتك المتينة خطوةً في كل مرة، وتُسجَّل كل خطوة في لوحة التحكّم. وكيلك العام يكتب الاثنين ويبدؤهما لك؛ ومهمّتك أن تراقب.
حدّ واحد آخر مهمّ، نفس الذي رسمته دورة الموظف الرقمي بدوام كامل. عاملك يحفظ عملاءه وسجلّ ما فعله في قاعدة بيانات Neon، وتُلمَس تلك القاعدة بطريقتين مختلفتين. بينما تبني، وكيلك العام يمدّ يده إلى Neon من أجلك، بإنجليزية بسيطة، لينشئ الجداول ويفحص الصفوف. وبينما يعمل العامل، يتحدّث إلى القاعدة نفسها عبر اتصال عادي خاص به. أداة وقت-البناء لا تُوصَل أبداً بالعامل العامل؛ ووثائق Neon نفسها صريحة بأنها للبناء والفحص، لا للإنتاج. وNeon مجانية بنقرة واحدة؛ وخادم تطوير Inngest لا يحتاج حساباً على الإطلاق.
احصل على القاعدة وافتحها
حمّل القاعدة وافتح المجلّد في وكيلك العام. الوكيل يجري الإعداد بنفسه، من التعليمات أدناه مباشرة. تهيّئ هذا مرة واحدة: ai-agent-nervous-system/ هو مجلّدك للدورة كلّها، المكسب السريع والجزء 4 سواء. ولن تعيد التحميل أو فكّ الضغط أبداً.
حمّل ai-agent-nervous-system-base.zip
cd ai-agent-nervous-system
claude
cd ai-agent-nervous-system
opencode
تفترض هذه القاعدة وكيلاً عاماً قادراً (Claude Code، أو OpenCode يشغّل Claude Sonnet أو Opus، أو GPT-5، أو ما يماثلها). نموذج أصغر سينحرف على تعليمة البناء؛ فإن بدت خطّته الأولى غامضة بدل أن تكون محدّدة، فبدّل إلى نموذج أقوى قبل أن تمضي أبعد.
هيّئ القاعدة (~3 دقائق)
القاعدة تشحن قواعدها في AGENTS.md ووصلات MCP الخاصة بها؛ أمّا المهارات، ومفتاحك، وإذن Neon فتأتي تالياً. اجعل وكيلك يهيّئ نفسه. الصِق هذا:
اقرأ AGENTS.md، ثم هيّئ هذه القاعدة: ثبّت المهارات التي تسردها لأيّ وكيل تكون، وانسخ
.env.exampleإلى.envمن أجلي، وأخبرني بالضبط بما تحتاجه منّي لتشغيل خادمَي Neon وContext7 لMCP.
راقب: الوكيل يثبّت مهارات Inngest الأربع ومهارة neon-postgres (ترى تشغيلات التثبيت وتأكيدات Installed)، وينشئ .env، ثم يطلب منك أمرين: مفتاحك OPENAI_API_KEY لتلصقه في .env، ونقرة متصفح واحدة لتأذن لNeon عبر OAuth. وNeon مجانية؛ فإن لم يكن لديك حساب بعد، سجّل في neon.com في نحو دقيقة، أو أنشئ واحداً عند شاشة الإذن مباشرة. INNGEST_DEV=1 موجود أصلاً في .env، فيشتغل الSDK في وضع التطوير المحلي بلا مفتاح توقيع. وعندما ينتهي التثبيت والوصل، يخبرك الوكيل أن تشغّل خادم التطوير (الخطوة التالية) ثم تعيد تشغيله، لأن المهارات الجديدة وMCP الموسوم inngest-dev لا تُحمَّل في منتصف الجلسة.
تمّ عندما: تُثبَّت المهارات، ويحمل .env مفتاحك، ويكون Context7 قابلاً للوصول، وتُؤذَن Neon. ويأتي inngest-dev لMCP حالما يعمل خادم التطوير، وهو الخطوة التالية.
شغّل خادم التطوير، وأكّد أن الوكيل يصل إليه (~2 دقيقة)
تضيف هذه الدورة حدّين يصل إليهما وكيلك عبر MCP: قاعدة بيانات Neon يبنيها ويفحصها، وخادم التطوير العامل يرسل إليه أحداثاً ويراقبه. لذا قبل أن تبني أي شيء، شغّلهما وأكّد أنهما حيّان.
شغّل خادم تطوير Inngest في وحدته الطرفية الخاصة (إنه Node CLI؛ اتركه يعمل):
npx inngest-cli@latest dev
تظهر لوحة التحكّم على http://127.0.0.1:8288، ويعرض خادم التطوير نقطة نهاية MCP الخاصة به على /mcp. الآن أعِد تشغيل وكيلك العام (اخرج وأعِد الإطلاق في مجلّد ai-agent-nervous-system) كي تُحمَّل المهارات المثبَّتة حديثاً وMCP الموسوم inngest-dev معاً. ثم الصِق هذا:
اسرد أدوات Neon وأدوات inngest-dev التي تستطيع رؤيتها.
راقب: قائمتين حقيقيتين. أدوات Neon (إنشاء مشروع، وتشغيل SQL، ووصف الجداول، وجلب سلسلة اتصال، ونحوها) هي يد وكيلك على القاعدة. وأدوات inngest-dev (list_functions، وsend_event، وinvoke_function، وget_run_status، والبقيّة) هي يده على خادم التطوير العامل. كل ما أدناه يركب على الاثنين.
البوابة مفتوحة: الردّ يسرد أسماء أدوات Neon حقيقية وأسماء أدوات inngest-dev حقيقية. إن كانت أدوات Neon مفقودة: لم يكتمل OAuth؛ أعِد إذن Neon من خطوة التهيئة. وإن كانت أدوات inngest-dev مفقودة: خادم التطوير لا يعمل (شغّله)، أو تخطّيت إعادة التشغيل (اخرج، أعِد الإطلاق في هذا المجلّد، واسأل ثانيةً).
ابنِ المتجر، والتقط سلسلة اتصاله (~3 دقائق)
الآن أنشئ نظام سجلّ العامل عبر MCP الخاص بNeon، ثم سلّم العامل الشيء الوحيد الذي سيحتاجه ليصل إليه لاحقاً: سلسلة اتصال. العامل الذي تبنيه في الجزء 4 يقرأ عملاءه ويكتب سجلّ تدقيقه هنا. الصِق هذا:
الصِق هذا لوكيلك العام. خطّط أولاً؛ نفّذ عند الموافقة.
على مشروع Neon جديد، أنشئ جدولين:
customers(id، email، tier) وaudit_log(سجلّ لكل إجراء يتّخذه العامل). ثم استدعِ أداة Neon التي تعيد سلسلة الاتصال واكتب ذلك الURL في.envالخاص بي بصفتهDATABASE_URL. استخدم أدوات Neon لكل ذلك؛ لا تكتب لي SQL لأشغّله.
راقب: الوكيل يستدعي أدوات MCP الخاصة بNeon لإنشاء المشروع والجدولين (ترى تلك الاستدعاءات للأدوات، لا SQL كتبته أنت)، ثم يكتب DATABASE_URL في .env. تلك السلسلة هي التسليم: MCP الخاص بNeon زوّد المتجر، وعاملك سيستخدم السلسلة، لا خادم MCP.
تمّ عندما: يوجد مشروع Neon جديد بجدول customers وجدول audit_log، ويحمل .env قيمة DATABASE_URL. افتح console.neon.tech، واختر المشروع الذي صنعه الوكيل للتوّ، وافتح Tables: هناك يجلس customers وaudit_log، فارغين الآن. سترى صفوفاً تظهر في D0 حين يعمل العامل. (الجدول مجرد جدول بيانات: كل صف شيء واحد، وكل عمود تفصيل واحد.)
ابنِ أول دالة متينة، وقُدها من وكيلك (~3 دقائق)
الآن ابنِ أصغر دالة متينة، باستخدام المهارات التي ثبّتّها للتوّ. مهارات Inngest في أمثلتها TypeScript أولاً، فيأخذ وكيلك الأنماط منها (ما الخطوة، وكيف تُشكَّل الدالة المتينة) ويؤكّد توقيعات Python الدقيقة من الوثائق (grep_docs/read_doc الخاصة بMCP لخادم التطوير، أو Context7)، لا من الذاكرة. الصِق هذا:
باستخدام مهارات Inngest، اكتب دالة Inngest متينة صغيرة واحدة (سمِّها
greet-customer، يحفّزها حدثdemo/greet) تؤلّف تحية فيstep.runواحد، وتنام خمس عشرة ثانية بstep.sleep، ثم تؤلّف وداعاً فيstep.runثانٍ وتعيد كليهما. قدّمها من مضيف FastAPI في وضع التطوير المحلي، وابدأ المضيف على المنفذ 8000 مع إعادة التحميل التلقائي، كي تُلتقَط التعديلات التي أجريها لاحقاً بلا إعادة تشغيل يدوية.
الشكل الذي يكتبه، لتعرفه حين تراه: الدالة async def عادية، واستدعاءا step.run يغلّفان عملاً ينبغي تذكّره، وstep.sleep بينهما يوقف التشغيل بمتانة. وتستطيع العمليّة أن تتعطّل أو تُعاد تشغيلها أو تُعاد نشرها خلال ذلك النوم، ويظلّ التشغيل يستأنف عند السطر التالي حين يُطلق المؤقّت. تفصيل واحد تؤكّده في كود الوكيل: عميل Inngest يُبنى بis_production=False، أو يقرأ INNGEST_DEV=1 الموجود أصلاً في .env. وبدون أحدهما، يلجأ الSDK بهدوء إلى Cloud افتراضياً ولا تُسجَّل دالتك محلياً أبداً.
تمّ عندما: مضيف FastAPI (الباب من قبل) يعمل على المنفذ 8000، وخادم التطوير (يعمل أصلاً من الخطوة الأخيرة) اكتشفه تلقائياً. اطلب من وكيلك أن يؤكّد بأداة list_functions الخاصة بinngest-dev (أو افتح http://127.0.0.1:8288، وانقر Functions، وانظر greet-customer مسروداً). من هنا ترسل أحداثاً من وكيلك وتراقب التشغيلات في لوحة التحكّم.
حفّزها، وشاهد خطوة تنام عند حوسبة صفرية (أنت تقود)
أرسل حدث التحفيز من وكيلك. الصِق هذا:
أرسل حدث
demo/greetبnameيساوي Sara باستخدام أداةsend_eventالخاصة بinngest-dev.
(تفضّل لوحة التحكّم؟ في http://127.0.0.1:8288، انقر Events، ثم Send event، والصِق الحمولة أدناه، وانقر Send. في كلتا الحالتين يبدأ التشغيل نفسه.)
{
"name": "demo/greet",
"data": { "name": "Sara" }
}
الآن راقب النوم المتين، ولديك نحو خمس عشرة ثانية لتلتقطه حيّاً. طريقتان، اختر واحدة:
- دع الوكيل يستطلع (الطريقة الأصلية للوكيل): «استطلِع
get_run_statusعلى ذلك التشغيل حتى ينتهي.» في منتصف النوم يُبلِّغ الوكيل عن التشغيل بأنه Running بلا وقت انتهاء بعد، ووحدتك الطرفية المضيفة خاملة طوال الوقت؛ ثم ينقلب إلى Completed بقاموس المخرج وفجوة من البداية إلى النهاية نحو خمس عشرة ثانية. تلك الفجوة هي النوم. - راقب لوحة التحكّم: افتح
http://127.0.0.1:8288← Runs ← أحدث تشغيل، فوراً. الخطوة الأولى منتهية وخطوة النوم تُظهِر Sleeping مع وقت استئناف؛ وبعد خمس عشرة ثانية تستأنف من تلقاء نفسها وتنقلب إلى Completed، والقاموس المعاد في لوحة Output.
في كلتا الحالتين، لا شيء في كودك يعمل خلال تلك الخمس عشرة ثانية: خادم التطوير يحفظ وقت الاستئناف والمضيف يجلس خاملاً. تلك هي الفكرة، انتظار متين يكلّف حوسبة صفرية. (افتح التشغيل بعد انتهائه فترى Completed فقط مع المخرج، والنوم الحيّ قد ذهب أصلاً؛ أعِد الإرسال وانظر أسرع، أو دع الوكيل يستطلع.)
اكسر خطوة، وشاهد إعادة المحاولة تتخطّى العمل الذي أنجزته (المكافأة)
الآن اجعل خطوة تفشل عمداً، كي تشاهد التذكّر يحمل العمل المكتمل عبر إعادة المحاولة. الصِق هذا لوكيلك:
اجعل خطوة الوداع تطلق خطأ عمداً، كي أشاهد تشغيلاً يفشل. أبقِ كل شيء آخر كما هو.
أرسل حدث demo/greet نفسه ثانيةً، ثم اقرأ أثر التشغيل الفاشل خطوةً بخطوة في لوحة التحكّم (Runs ← أحدث). إليك المكافأة، وهي في هذا التشغيل الفاشل الواحد: خطوة التحية تُظهِر محاولة مكتملة واحدة، وخطوة الوداع تُظهِر عدّة Attempts، كلٌّ مُعادة بتراجع (Inngest يلجأ افتراضياً إلى عدّة محاولات) قبل أن يهبط التشغيل في Failed. تأمّل ما يعنيه ذلك العدّ من المحاولات: خطوة التحية المكتملة مدفوعة مرة واحدة، لا مرة لكل إعادة محاولة. ذلك تنفيذ متين تراه بأمّ عينك. أمّا لماذا تعيد الخطوة المكتملة فوراً بدل أن تُنفَّذ ثانيةً فهو الآلية التي ستلقاها في المفهوم 7؛ والآن، اكتفِ بمشاهدتها تحدث.
شيئان تتوقّعهما حين تشغّل هذا:
- إثبات الخطوة-بالخطوة في لوحة التحكّم، لا الوكيل. وكيلك يُطلق الحدث ويستطيع الإبلاغ عن حالة على مستوى التشغيل، لكنّ
get_run_statusالخاص بMCP لخادم التطوير يعيد ملخّص التشغيل بsteps: null؛ فهو لا يوسّع محاولات الخطوة-بالخطوة. وعدّات المحاولة التي هي إثبات التذكّر (التحية عند واحدة، والوداع يتسلّق) تعيش في عرض Runs بلوحة التحكّم. هذا هو المكان الوحيد في المكسب السريع الذي تمدّ فيه يدك إلى المتصفح، لا الوكيل. - بلوغ Failed يستغرق دقائق قليلة. بإعادات المحاولة الافتراضية والتراجع الأسّي، يظلّ التشغيل يعيد محاولة خطوة الوداع لعدّة دقائق (استغرق تشغيل حقيقي نحو أربع دقائق ونصف) قبل أن ينقلب إلى Failed. ولست مضطراً للانتظار حتى النهاية: إثبات التذكّر يظهر من أول إعادة محاولة، والتحية ثابتة عند محاولة واحدة بينما يراكم الوداع المزيد. راقب محاولتين، ثم تابع.
(بناء خادم التطوير هذا لا يُظهِر أيضاً شارة «memoized» منفصلة. التذكّر هو عدّ المحاولات: الخطوة المكتملة جالسة عند محاولة واحدة بينما تتسلّق الخطوة المكسورة هو بالضبط ما يبدو عليه «معاد من التذكّر، لا مُنفَّذ ثانيةً» هنا.)
الآن أصلحها:
الآن أرجِع خطوة الوداع إلى النسخة العاملة.
المضيف يعيد التحميل تلقائياً (ذلك ما اشتراه لك --reload؛ فإن تخطّيته، أعِد تشغيل المضيف يدوياً). أرسل حدث demo/greet جديداً فتعمل الدالة كلّها الآن نظيفةً حتى Completed على الكود المُصلَح. شيء واحد عن التعافي يُربك الناس. زرّ Rerun في لوحة التحكّم يبدأ تشغيلاً جديداً تماماً من الأعلى بكودك الحالي، وكل خطوة تُنفَّذ من جديد من الصفر. ذلك هو الأداة الصحيحة للتعافي من الحوادث: نشرٌ سيّئ كسر دفعة من التشغيلات، فتشحن إصلاحاً وتعيد تشغيلها. لكنه ليس الاستئناف الحافظ للتذكّر. الاستئناف الحافظ للتذكّر هو إعادة المحاولة التلقائية التي شاهدتها للتوّ داخل التشغيل الفاشل، حيث بقيت الخطوة المكتملة في مكانها.
اجعله عامل ذكاء اصطناعي حقيقياً (الجسر إلى الجزء 4)
حتى الآن الدالة تتلاعب بالسلاسل فقط، وكان ذلك عمداً: المتانة أسهل رؤيةً بلا شيء آخر في الطريق. الآن اجعل التحية تأتي من وكيل فعلي، كي تشاهد الجهاز العصبي نفسه يحمل استدعاء ذكاء اصطناعي حقيقياً. تعليمة واحدة تستبدل بالتحية المكتوبة يدوياً وكيلاً صغيراً؛ والنوم، والمتانة، ولوحة التحكّم كلّها تبقى تماماً كما هي. الصِق هذا:
استبدل بالتحية المكتوبة يدوياً استدعاءً من سطر واحد لوكيل hello-world أدنى مبني على OpenAI Agents SDK (يكتب التحية فقط)، ما يزال داخل
step.runنفسه. أبقِstep.sleepوالوداع دون تغيير. ثم أطلق حدثdemo/greetوأرني التشغيل.
الشيء الوحيد الذي تغيّر هو ما يملأ خطوة التحية: بدل سلسلة-f، نموذج يكتبها. ولأن ذلك الاستدعاء يجلس داخل step.run نفسه الذي أثبتّ متانته أصلاً، فهو متذكَّر وآمن من التعطُّل مجاناً، بلا وصلات جديدة. راقب التشغيل كما فعلت من قبل (استطلِع من الوكيل، أو افتحه في لوحة التحكّم): الأثر نفسه من ثلاث خطوات والنوم صفري-الحوسبة نفسه، إلا أن مخرج الخطوة الأولى الآن جاء من وكيل. ومفتاحك OPENAI_API_KEY موجود أصلاً في .env من خطوة التهيئة، فليس هناك جديد لتهيّئه.
تمّ عندما: يكتمل تشغيل demo/greet والتحية في المخرج جاءت من الوكيل، لا من سلسلة مكتوبة يدوياً. تأمّل ما تنظر إليه، لأنه الدورة كلّها في جملة واحدة: وكيل ذكاء اصطناعي، أيقظه حدث، يعمل بمتانة داخل جهاز عصبي، ناجياً من تعطُّل. الجزء 4 يستبدل بوكيل hello-world هذا عامل دعم عملاء حقيقياً ويغلّفه في الجهاز العصبي الكامل (محفّز حدث حقيقي، وcron يتفرّع، وتحكّم في التدفّق، وبوابة موافقة بشرية)، لكنّ الشكل على شاشتك الآن هو الشكل.
هيّأت للتوّ بيئة الدورة كلّها وشاهدت الجهاز العصبي يعمل بأمّ عينك: المهارات مثبَّتة، ومتجر Neon مزوَّد بDATABASE_URL في .env، وMCP لخادم التطوير حيّ، وشغّلت دالة متينة، وراقبت خطوة تنام بلا استهلاك حوسبة، وكسرت خطوة وراقبت إعادة المحاولة التلقائية تعيد الخطوة المكتملة من التذكّر بينما تُعاد المكسورة وحدها، ثم شاهدت وكيلاً حقيقياً يولّد التحية داخل تلك الخطوة المتينة نفسها. تلك هي البنية التي تدور حولها الدورة. وبقيّة الدورة تكبّرها: حواس حقيقية (cron، وwebhook، وfan-out)، ومنعكسات أقوى (استدعاء الوكيل داخل step.run)، وتوازن حقيقي تحت الحمل، وبوابة الموافقة البشرية التي تحوّل «قد يفسد الوكيل هذا» إلى «الوكيل يصوغ، وإنسان يوافق، فيصدر الإجراء».
إن لم يعمل شيء، فأربع مشكلات تغطّي معظمه تقريباً:
- خادم التطوير لا يصل إلى مضيف الدالة: أكّد أن المضيف يعمل على المنفذ 8000.
- العميل في وضع Cloud: أسقط الوكيل
is_production=Falseو.envينقصهINNGEST_DEV=1، فلا تُسجَّل الدوال محلياً أبداً. اطلب منه أن يضبط أحدهما (قيمةis_productionصريحة تغلب متغيّر البيئة). - الدالة مفقودة من لوحة التحكّم: المضيف لم يُعِد التحميل؛ أعِد تشغيله.
- تشغيل يتعلّق بلا خطأ وبلا تقدّم: مضيف غير متزامن يتوقّف بصمت؛ أعِد تشغيل المضيف وخادم التطوير معاً، وشغّل مضيفاً واحداً مقابل خادم تطوير واحد. (سبب خفيّ واحد: إن كان
:8288مأخوذاً وجاء خادم التطوير على8289+، فإعادة توجيه URL الخاص بMCP لinngest-devلا تكفي؛ فالمضيف ما يزال يتحدّث إلى:8288. اضبطINNGEST_BASE_URL=http://127.0.0.1:<port>على المضيف كي يتبع خادم التطوير إلى المنفذ الجديد.)
إن واجهت أيّاً من هذه، فحركة التعافي الشاملة تنفع هنا أيضاً: «شيء لم يعمل. اقرأ الخطأ، وأخبرني بإنجليزية بسيطة بما تراه، واقترح إصلاحاً واحداً أستطيع الموافقة عليه.»
ما بنيته، وأين ينمو
البيئة مهيّأة: القاعدة مفتوحة، والمهارات مثبَّتة، وخوادم MCP الثلاثة موصولة (Neon، وContext7، وinngest-dev)، ومتجر Neon لديه جدولا customers وaudit_log مع DATABASE_URL في .env، وخادم التطوير يعمل. وشاهدت أيضاً الفكرة الوحيدة التي ترتكز عليها الدورة كلّها، منعكس التنفيذ المتين، بأمّ عينك، وراقبت وكيلاً حقيقياً يعمل داخله. الجزء 4 يستبدل بوكيل hello-world ذاك عامل دعم العملاء، على القاعدة نفسها، في المجلّد نفسه: يقرأ أولئك العملاء ويكتب صفوف التدقيق تلك، ثم يغلّف الكل في الجهاز العصبي الكامل، محفّز حدث حقيقي، وcron يومي يتفرّع، وتحكّم في التدفّق، وبوابة الموافقة البشرية المتينة على ردود الأموال. الجزء 4 يكبّر هيكل step.run-وstep.sleep هذا إلى عامل يؤدّي عملاً حقيقياً على متجر Neon لديك. إن عمل هذا المكسب السريع، فالمفاهيم القادمة تشرح لماذا كل قطعة مُشكَّلة بهذا النحو.
الجزء 1: الحواس، كيف يصل العالم إلى العامل
من هنا، الأجزاء 1-3 هي رفّ المرجع خلف البناء: خمسة عشر مفهوماً، فكرة واحدة لكلٍّ منها، مجمَّعة حسب الأعمال الثلاثة التي يؤدّيها الجهاز العصبي. تستطيع قراءتها على التوالي، أو تمدّ يدك إلى واحد حين تجعلك طبقةٌ في الجزء 4 تسأل لماذا تعمل. هذه المجموعة الأولى هي الحواس.
وكيل ذكاء اصطناعي تستدعيه يدوياً يعمل حين تستدعيه. أمّا عامل الذكاء الاصطناعي الحقيقي فله حواس: يعمل حين يصل إليه العالم. عميل يراسل بالبريد، وwebhook يصل، وcron يُطلق عند 09:00 يومياً، وعامل آخر يسلّم عملاً. كلٌّ من هذه إشارة واردة، والمحفّز هو كيف يحسّها الوكيل. مفاهيم الجزء 1 الخمسة هي تلك الحواس: النموذج الذهني المعتمد على الأحداث، والطرق الثلاث التي يصل بها العالم (cron، وwebhook، وحدث)، والدلالات التي تمنع المعالجة المزدوجة، وأنماط fan-out التي تتيح لإشارة واحدة إيقاظ عمّال كثيرين.
المفهوم 1: الأحداث مقابل الطلبات، النقلة الذهنية المتينة
كل ما يلي في هذه الدورة يرتكز على نقلة ذهنية واحدة: من الطلبات إلى الأحداث.
الطلب محادثة متزامنة. أحدهم يستدعي؛ فتعالج؛ فتعيد؛ فيتابع. يبقى اتصال مفتوحاً؛ وإنسان أو خدمة ينتظر. وإن تعطّلت، يحصل المستدعي على خطأ. ووكيل تدردش معه عند الموجِّه طلبٌ: أنت كتبت، فتدفّق إليك راجعاً، والمحادثة ملك لجلسة وحدتك الطرفية.
الحدث رسالة غير متزامنة. حدث شيء في العالم (عميل سجّل، أو وصل بريد، أو مقاصّة دفعة)، فيُصدر المُنشئ سجلّاً مسمّى لتلك الحقيقة. وصفر أو واحدة أو دوال كثيرة تتفاعل مع الحدث كلٌّ على حدة. لا يبقى اتصال مفتوحاً. والمُنشئ لا يعرف من يستمع، ولا ينتظر النتائج، وليس محجوباً. وقد مضى العالم في طريقه.
# A request: I'm here, waiting, blocking
result = await agent.handle_customer_message(text=user_input)
print(result) # I unblock when the agent finishes
# An event: I fire-and-forget
await inngest_client.send(events=[
inngest.Event(
name="customer/email.received",
data={"customer_id": "c-4429", "body": email_body, "subject": subject},
),
])
# I return immediately. Somewhere else, one or more Inngest
# functions react to this event on their own schedule.
الطلب يجعل المُنتِج ينتظر؛ والحدث يحرّره، والحدث المخزَّن ينجو من تعطُّل.
تبدو النقلة صغيرة. وليست كذلك. حالما تفكّر بالأحداث، تتساقط المتانة والقياس شبه مجاناً، لأن:
- المُنتِج لا يستطيع المستهلك إبطاءه (مستقبِل البريد لا ينتظر الوكيل حتى ينتهي من صياغة ردّ).
- المستهلك يستطيع التعطّل وإعادة التشغيل دون فقدان العمل (الحدث مخزَّن بمتانة؛ وInngest يعيد تسليمه).
- يمكن إضافة مستهلكين جدد دون تغيير المُنتِجين (دالة ثانية، مثلاً عدّاد تحليلات، تستطيع الاشتراك في
customer/email.receivedدون علم مستقبِل البريد). - الضغط العكسي يصير سياسة تحكّم في التدفّق، لا تغييراً في الكود (Inngest يحدّ concurrency؛ والمُنتِج يظلّ يُطلق؛ والأحداث تنتظر في الطابور).
تنبّأ. عاملك لدعم العملاء يستغرق 8 ثوانٍ للردّ على بريد: ثلاث ثوانٍ لاستدلال الوكيل، وأربع ثوانٍ لاستدعاءَي أداة MCP، وثانية لكتابة قاعدة البيانات. عند ذروة الحمل تستقبل 50 بريداً في الدقيقة. إن استخدمت نموذج الطلب (محلّل البريد يُحجَب حتى ينتهي الوكيل)، فكم اتصال HTTP متوازياً إلى محلّل بريدك يعني ذلك؟ وإن استخدمت نموذج الحدث (محلّل البريد يُطلق حدثاً ويعود فوراً)، فكم؟ الثقة 1-5.
الجواب: نموذج الطلب يحتاج نحو 7 محلّلات متزامنة (50/دقيقة × 8 ثوانٍ هو ~6.7 معالجاً متوازياً، زائد هامش). ونموذج الحدث يحتاج محلّلاً واحداً. يُطلق الحدث ويعود في ~10 مللي ثانية، وطابور الأحداث يستوعب ذروة ال50/دقيقة، ودوال Inngest تستهلك الطابور بأيّ concurrency تسمح به.
تلك الفجوة هي المقصد كلّه. الحدث يصير حدّاً متيناً بين «ما حدث في العالم» و«ما يفعله العامل حياله»، وكل شيء جيّد يتبع تلك الحركة الواحدة: المُنتِج لا ينتظر أبداً، والمستهلك المتعطّل يعيد المحاولة من الحدث المخزَّن، والمستهلكون الجدد يلتصقون دون مساس المُنتِج. الأحداث هي كيف تتوقّف عن امتلاك توقيت العمل.جرّب مع الذكاء الاصطناعي
Walk me through three scenarios. For each, classify it as REQUEST-MODEL
or EVENT-MODEL, and explain which one fits better:
A) A user clicks "Submit refund request" in the support portal and
expects to see "Refund issued: $30" within 2 seconds.
B) A nightly cron job at 02:00 runs a customer-health-check across
all 5,000 customers and writes a report to Slack.
C) A customer sends an email to support@; we want a draft response
ready within 60 seconds for the on-call agent to review and send.
For each, name (a) what the human's expectation of timing is and
(b) what failure looks like if the model crashes mid-execution.
المفهوم 2: محفّزات cron، عمل يعمل لأن الوقت مضى
أبسط محفّز هو الساعة. كثير ممّا يفعله عامل الذكاء الاصطناعي ليس ردود فعل على أحداث خارجية؛ بل عمل مجدول: تقارير صحّة يومية، وتنظيفات أسبوعية، وإعادات حساب كل ساعة. محفّز cron في Inngest سطر واحد من الكود.
import inngest
@inngest_client.create_function(
fn_id="daily-customer-health-check",
trigger=inngest.TriggerCron(cron="0 9 * * *"), # 09:00 every day, UTC
)
async def daily_health_check(ctx: inngest.Context) -> dict[str, int]:
"""Run a customer-health pass for every Pro/Enterprise customer."""
customers = await ctx.step.run("fetch-pro-customers", fetch_pro_customer_ids)
# fan out: one event per customer, one worker run per event
events = [
inngest.Event(name="customer/health_check.requested", data={"customer_id": cid})
for cid in customers
]
await ctx.step.send_event("fan-out", events)
return {"customers_scheduled": len(customers)}
ثلاثة أشياء تلاحظها:
-
الجدول مجرد بناء جملة cron قياسي.
0 9 * * *هو 09:00 بتوقيت UTC يومياً؛ و*/15 * * * *هو كل 15 دقيقة؛ و0 9 * * 1هو أيام الإثنين عند 09:00. وInngest يقيّم cron بتوقيت UTC افتراضياً؛ فإن احتجت منطقة زمنية أخرى، فأنت تسبق سلسلة cron نفسها (مثلاًTZ=Europe/Paris 0 12 * * 5)، لا تمرّر وسيطاً منفصلاً. -
الدالة ما تزال تستخدم الخطوات المتينة نفسها. سواء حفّزها cron أو حدث، شكل الدالة متطابق:
ctx.step.runللآثار الجانبية، وctx.step.send_eventللتفرّع. المتانة تعمل بالطريقة نفسها. والتحكّم في التدفّق يعمل بالطريقة نفسها. المحفّز مجرد كيف تبدأ الدالة. -
مخرج cron تشغيلُ دالة Inngest عادي. يظهر في لوحة التحكّم، وله معرّف تشغيل، وله أثر، ويدعم replay. فإن فشل تشغيل cron يوم الإثنين عند الخطوة 3، فسيعمل cron الثلاثاء عادياً ويبقى فشل الإثنين متاحاً للإعادة بعد إصلاح العطل.
ماذا يحدث إن كانت خدمتك معطّلة حين يُطلق cron؟ هذا هو السؤال الذي يفصل المجدول المتين عن الهشّ. تشغيلات cron في Inngest تُسجَّل بمتانة لحظة إطلاق الجدول. فإن كانت نقطة نهاية دالتك غير قابلة للوصول، يعيد Inngest المحاولة بتراجع حتى ينجح أو يبلغ سقف إعادات المحاولة. وcron الذي أُطلق عند 09:00 لا «يفوت» لأن نشرك كان قيد التدوير عند 09:00؛ فالتشغيل ينتظر، وتنهي نشرك، فيكتمل التشغيل. ولمحفّزات cron في التطوير غرابة واحدة تستحقّ المعرفة: خادم التطوير المحلي يُطلق crons فقط ما دام يعمل. أمّا الإنتاج فيشغّلها على بنية Inngest التحتية، الدائمة التشغيل.
تحقّق سريع. ثلاث ادّعاءات. ضع على كلٍّ منها صحيح أو خطأ. (أ) إن كانت دالة cron تستغرق 45 دقيقة للتشغيل ومجدولة كل 15 دقيقة، فستعمل ثلاث نسخ متزامنة في أي لحظة. (ب) تستطيع استخدام
step.sleepداخل دالة محفَّزة بcron لتوزيع العمل عبر اليوم. (ج) دالة محفَّزة بcron يمكن أيضاً استدعاؤها يدوياً من لوحة التحكّم للاختبار.
الأجوبة: (أ) يعتمد على سياسة concurrency: افتراضياً سيضع Inngest التشغيلات المتداخلة في الطابور؛ فإن ضبطت concurrency=1 تسلسلت؛ وإن ضبطت concurrency=10 تتوازت. والافتراضي عاقل. (ب) صحيح، وهو نمط شائع ل«توزيع العمل اليومي عبر الساعات لتسوية الحمل». (ج) صحيح: لوحة تحكّم Inngest تتيح استدعاء أي دالة عند الطلب للاختبار، بصرف النظر عن محفّزها.جرّب مع الذكاء الاصطناعي
With my AI coding assistant connected to the Inngest dev server MCP,
write a cron-triggered Inngest function in Python that:
1. Runs every Monday at 09:00 UTC.
2. Queries the audit_log table for all conversations resolved in the
prior week (status='resolved' in that window).
3. Computes per-agent metrics: total conversations resolved, average
resolution time, count of escalations, count of refunds issued.
4. Returns the metrics as a JSON object.
After you write the function, test it now instead of waiting for
Monday: trigger it on demand from the Inngest dev dashboard (the
Invoke button), since the dev server only fires crons while it is
running. Confirm the audit query is correct by running the SQL
directly against the database and checking the rows it returns;
grep_docs can confirm your step.run pattern matches Inngest's
examples, but only running the query proves the SQL itself.
المفهوم 3: محفّزات webhook، حين يستدعي العالم الخارجي
المحفّز الأول كان الساعة. والثاني هو HTTP: شيء خارج نظامك (Stripe، أو مزوّد بريدك، أو نموذج على موقعك، أو حدث GitHub) يريد الوصول إلى عاملك.
كن دقيقاً في أيّ جزء هو الصعب، لأنه ليس الجزء الذي قد تخمّنه. استقبال الPOST سهل: إطار ويب مثل FastAPI يمنحك @app.post(...) في ثلاثة أسطر. الجزء الصعب هو كل شيء بعد هبوط الPOST: ضع الاستدعاء في الطابور، وأعِد محاولته عند الفشل، وانجُ من تعطُّل في منتصف العمل، وارفض المعالجة المزدوجة لإعادة التسليم، وشغّل الوكيل، واحفظ موافقة لأربع ساعات، وأعِد تشغيل أي تشغيل من لوحة تحكّم. الباب رخيص؛ والمطبخ خلفه هو العمل، وذلك المطبخ هو Inngest.
فيبقى المسار صغيراً. مهمّته كلّها أن يستقبل الPOST، ويسلّم الحدث إلى Inngest، ويردّ 200 بسرعة. والعمل المتين يجري في دالة Inngest خلفه. ولو فعلت ذلك العمل داخل معالج الطلب، فستصطدم بأعطال webhook الكلاسيكية: المرسِل تنتهي مهلته فيعيد الإرسال وأنت ما تزال تعمل، وإعادة تشغيل تفقد المهمة، وإعادة تسليم تردّ المال للعميل مرّتين. (خيار Inngest المستضاف يستطيع حتى سكّ URL عام inn.gs/e/... فتتخطّى كتابة المسار أصلاً.)
الآن الجزء الذي يُربك الجميع. ينتهي تطبيقك ببابين، يواجهان اتجاهين متعاكسين:
DOOR 1: the webhook door (you write it, or use the hosted URL)
Stripe knocks here with DATA -> it just calls send() and is done
DOOR 2: /api/inngest (auto-made by inngest.fast_api.serve)
the ENGINE knocks here to RUN YOUR CODE, one step at a time
it speaks Inngest's own protocol, so a raw Stripe POST here is rejected
هذان لا يتحدّثان أبداً مباشرة بعضهما إلى بعض. يتّصلان فقط عبر الحدث: الباب 1 يُسقط حدثاً، فيلتقطه المحرك ويعود عبر الباب 2 ليشغّل دالتك. والإنشاء التلقائي للباب 2 (الذي فعله المكسب السريع أصلاً) لا يفعل شيئاً للباب 1؛ فذلك هو الذي ما تزال تكتبه.
فماذا يستدعي باب webhook فعلاً؟ send() فقط. المسار كلّه بهذا الصغر:
@app.post("/webhooks/stripe")
async def stripe_webhook(request: fastapi.Request):
payload = await request.json()
# verify the signature, reshape Stripe's envelope, then hand it off:
await inngest_client.send(
inngest.Event(name="stripe/charge.refund.failed", data=reshape(payload)),
)
return {"ok": True} # ack Stripe in milliseconds
ذلك send() يُسقط الحدث في تدفّق Inngest وينتهي المسار. هو لا يستدعي دالتك، ولا يستدعي /api/inngest. وInngest يتولّى ذلك النصف: يطابق اسم الحدث مع on_refund_failed ويعود عبر الباب 2 ليشغّل خطوات الدالة. من البداية إلى النهاية:
Stripe → Door 1 (webhook) → send() → Inngest → Door 2 (/api/inngest) → your function
@inngest_client.create_function(
fn_id="handle-stripe-refund-failed",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def on_refund_failed(ctx: inngest.Context) -> dict[str, str]:
"""Triggered by Stripe webhook → Inngest event → this function."""
charge_id = ctx.event.data["charge_id"]
# Find the support ticket this refund belongs to
ticket = await ctx.step.run(
"find-ticket-for-refund", lookup_ticket_by_charge, charge_id,
)
# Hand the support worker the full context.
# step.run takes (step_id, handler, *args): pass args positionally, not as kwargs.
await ctx.step.run(
"notify-support-agent",
notify_support_agent_of_refund_failure,
ticket["id"], charge_id,
)
return {"ticket": ticket["id"], "action": "notified"}
تلك هي الدالة خلف الباب: Inngest طابق الحدث معها وشغّلها، باحثاً عن التذكرة ومُخطِراً عامل الدعم، والطابور وإعادات المحاولة وidempotency كلّها متولّاة لك. وعمل webhook شبه دائماً غير متزامن هكذا: الدالة تعمل في الخلفية بعد الإقرار السريع، لا أثناء الطلب.
نمطان يستحقّان اسماً:
- webhooks بصيغة JSON عامة. المرسِل لا يجب أن يكون مورّداً مشهوراً. وجّه أي خدمة تستطيع إرسال JSON بPOST إلى النوع نفسه من الURL واختر اسم الحدث بنفسك. وأسلوب
vendor/event.subtypeمجرد عُرف، لكنّ لوحة التحكّم تجمّع الأحداث بأناقة حين تتبعه. - تحويلات webhook. حمولات المورّدين كبيرة ومتداخلة، وكثيراً ما يرسل مورّد واحد أنواع أحداث كثيرة إلى URL واحد. والتحويل دالة إعادة تشكيل صغيرة تعمل على خوادم Inngest لحظة وصول الحمولة، قبل أن تصير حدثاً. (تُكتَب بJavaScript حتى حين يكون عاملك بPython، لأنها تعمل في جانب Inngest، لا في تطبيقك.) وتؤدّي عملين: اختيار اسم حدثك، وتسطيح الحمولة إلى الحقول القليلة التي تستخدمها فعلاً. فيبقى كود دالتك حرّاً من JSON الخاص بالمورّد.
تنبّأ. webhook خاص بStripe يُطلق
stripe/charge.refund.failedفي المللي ثانية نفسها التي يستدعي فيها عاملك لدعم العملاء أيضاًinngest_client.sendليُصدر حدثاً مختلفاً اسمهcustomer/refund.investigation_needed. كلا الحدثين يصلان إلى النظام في آن واحد؛ والدالة أعلاه تُحفَّز على حدث Stripe فقط. هل ستعمل الدالة مرة أم مرّتين؟ الثقة 1-5.
الجواب: مرة واحدة. الدالة تُطلق فقط لاسم الحدث المسجَّلة له. وstripe/charge.refund.failed وcustomer/refund.investigation_needed اسمان مختلفان، فيوقظان دوالاً مختلفة (أو لا شيء)، مهما هبطا في اللحظة نفسها. اسم الحدث هو مفتاح التوجيه.
ولذلك أيضاً ليست التسمية شكلية. خطأ مطبعي واحد، customer/email_received حيث تستمع الدالة لcustomer/email.received، وتظلّ الدالة لا تعمل أبداً بصمت. لا شيء يُخطئ؛ العمل ببساطة لا يحدث. ولوحة التحكّم شبكة أمانك: الأحداث التي لا تطابق أي دالة تظهر في تدفّق غير مطابَق منفصل تستطيع مراقبته.
محلياً، لا يوجد URL تلصقه. كل ما سبق هو مسار الإنتاج. على حاسوبك المحمول لا تملك URL عاماً، وStripe لا يستطيع الوصول إلى localhost. فبينما تبني، تؤدّي أنت دور webhook بنفسك: send_event (أو زرّ «Send to Dev Server» في لوحة تحكّم التطوير) يحقن الحدث نفسه الذي كان سينتجه webhook حقيقي. ولذلك يختبر التطبيق العملي أدناه بsend_event ولا يلمس Stripe أبداً.
الانقسام يستحقّ التمسّك به:
| كيف يدخل الحدث | |
|---|---|
| الإنتاج | Stripe يرسل POST إلى URL الwebhook الحيّ لديك؛ فيصير حدثاً في تدفّقك |
| التطوير المحلي (أنت) | تحقن الحدث المُشكَّل أصلاً بsend_event |
كود دالتك متطابق في كلتا الحالتين؛ فهو يتفاعل فقط مع اسم الحدث ولا يعرف أبداً أجاء الحدث من webhook حقيقي أم من send_event لديك.جرّب مع الذكاء الاصطناعي
I need to handle three webhook sources for my customer-support worker:
A) Stripe: refund failed, charge disputed
B) Postmark (email service): bounced email, complaint
C) My internal admin UI: manual "investigate this ticket" button
For each, decide:
1. What event names you'd use (vendor/event.subtype format).
2. Whether the function reacting to it should run synchronously (the
caller is waiting) or asynchronously (fire and continue).
3. Whether you'd write a webhook transform to reshape the payload, or
consume it raw.
Then write the Inngest function for the Stripe refund-failed case in
Python, using the MCP's grep_docs to find the current syntax for
TriggerEvent and the dev-server MCP's send_event tool to test it.
المفهوم 4: Idempotency، حين يصل الحدث نفسه مرّتين
الحدث نفسه سيصل إليك أحياناً مرّتين. عميل ينقر «أصدِر ردّ المال»، والصفحة بطيئة، فتُطلق النقرة مرّتين؛ أو يمرّ الطلب لكنّ الإقرار العائد إلى المستدعي يُفقَد، فيعيد المستدعي المحاولة. في كلتا الحالتين يرى عاملك الآن حدثَي customer/refund.requested لردّ مال حقيقي واحد. فإن أصدر ردّ المال على كلٍّ منهما، استُرِدّ مال العميل مرّتين.
هذا أشيع عطل في أنظمة الأحداث، لا حالة حافة نادرة. المرسِلون يظلّون يعيدون المحاولة حتى يحصلوا على إقرار (الشبكات تُسقط الحزم، والخوادم تُعاد تشغيلها، ونقاط النهاية تنتهي مهلتها)، فما يُوعَد لك هو التسليم مرة واحدة على الأقل، لا مرة واحدة بالضبط. والعلاج أن تجعل النسخة الثانية غير مؤذية: اعمل على الأولى، ولاحظ المكرّرة، وتخطّاها. ولتلك الخاصية اسم. الشيء idempotent حين يترك تشغيله مرّتين النتيجة نفسها التي يتركها تشغيله مرة واحدة.
يبني Inngest طبقتين من هذا.
الطبقة 1: معرّف الحدث يزرع عند المصدر. حين ترسل حدثاً بنفسك (بدل استقباله من webhook)، تستطيع إرفاق مفتاح idempotency:
await inngest_client.send(events=[
inngest.Event(
name="customer/refund.requested",
data={"order_id": "o-4429", "amount_cents": 5000},
id=f"refund-request-{order_id}", # idempotency key: identical on every retry
),
])
إن أُرسِل حدث ثانٍ بid نفسه ضمن نافذة إزالة التكرار (24 ساعة افتراضياً)، أسقط Inngest المكرّر. حدث منطقي نفسه، ومعرّف نفسه، وتشغيل دالة واحد فقط. والمفتاح يجب أن يكون متطابقاً على كل مكرّر، وذلك هو المقصد كلّه. ابنِه من شيء ثابت عن الطلب (هنا، معرّف الطلب)، لا أبداً من طابع زمني أو قيمة عشوائية، تتغيّر مع كل إرسال وتُفشِل إزالة التكرار بصمت.
وهذا أيضاً كيف تُروّض webhook المُعاد من بداية هذا القسم. أنت لا تضبط id على حدث webhook مباشرة، لكنّ من يحوّل الPOST إلى حدث (التحويل المستضاف، أو مسار الاستقبال الخاص بك) يضبطه من معرّف حدث المزوّد نفسه. وStripe يختم معرّفاً فريداً على كل حدث ويعيد إرساله دون تغيير عند إعادة المحاولة، فيحمل webhook المُعاد التسليم id نفسه ويزيل التكرار تماماً كحدث مُرسَل ذاتياً.
الطبقة 2: idempotency على مستوى الخطوة. داخل دالة، كل step.run يُعرَّف باسمه. فإن تعطّلت دالة بين الخطوة 3 والخطوة 4، تعيد إعادة المحاولة تنفيذ كود الدالة من الأعلى، لكن للخطوات 1 و2 و3، يعيد Inngest المخرجات المخزَّنة دون إعادة تنفيذ جسم الخطوة. والخطوة 4 تعمل عادياً للمرة الأولى. هذا ما يجعل الدالة «متينة»: الآثار الجانبية للخطوات المكتملة لا تتكرّر عند إعادة المحاولة.
@inngest_client.create_function(
fn_id="issue-customer-refund",
trigger=inngest.TriggerEvent(event="customer/refund.requested"),
)
async def issue_refund(ctx: inngest.Context) -> dict[str, str]:
# Step 1: look up the order. If the function retries, this returns
# the SAME order data it computed the first time, from Inngest's memo.
order = await ctx.step.run(
"lookup-order", lookup_order_by_id, ctx.event.data["order_id"],
)
# Step 2: call Stripe. If the function retries AFTER this step
# succeeded, the Stripe call does NOT happen again. The refund is
# issued exactly once even if the function runs three times.
refund = await ctx.step.run(
"issue-stripe-refund",
lambda: call_stripe_refund_api(
charge_id=order["stripe_charge_id"],
amount=ctx.event.data["amount_cents"],
),
)
# Step 3: write the audit row. Same property: runs at most once.
await ctx.step.run(
"audit-refund",
lambda: write_audit_refund_issued(order_id=order["id"], refund=refund),
)
return {"refund_id": refund["id"]}
إن تعطّلت هذه الدالة خلال الخطوة 3، تعيد إعادة المحاولة الدخول إلى الخطوة 1 (تحصل على بيانات الطلب المخبّأة، بلا استدعاء قاعدة بيانات)، وتعيد الدخول إلى الخطوة 2 (تحصل على بيانات ردّ المال المخبّأة، بلا استدعاء Stripe)، وتُنفّذ الخطوة 3 فعلاً، وتعيد. بطاقة العميل تُحمَّل مرة واحدة، حتى لو عملت الدالة ثلاث مرّات. هذه هي الخاصية الأهمّ. وهي ما يجعل Inngest مختلفاً نوعياً عن طابور بحلقة إعادة محاولة.
(الخطوة 1 تمرّر وسيطها الواحد موضعياً. أمّا الخطوتان 2 و3 فتغلّفان استدعاءهما في lambda بدلاً من ذلك، لأن step.run يمرّر وسائط موضعية فقط، فالlambda هي كيف تسلّم خطوةً استدعاءً يستخدم وسائط مسمّاة. وكلا الشكلين ينفع، والlambda تجعل جسم الخطوة أيضاً وحدة قائمة بذاتها يستطيع Inngest تذكّرها.)
التذكّر يمنح اكتمال الخطوة مرة-واحدة-بالضبط من وجهة نظر الدالة: حالما تُسجَّل خطوة ناجحة، لا تُعاد أبداً. لكن هناك نافذة ضيّقة. إن استدعت خطوة Stripe وماتت العمليّة بعد أن يحمّل Stripe المبلغ لكن قبل أن يسجّل Inngest النتيجة، تستدعي إعادة المحاولة Stripe ثانيةً، لأن الخطوة لم تنتهِ قطّ بالنسبة إلى Inngest. والعلاج أن تقرن تذكّر الخطوة بمفتاح idempotency الخاص بالمزوّد نفسه (ترويسة Idempotency-Key الخاصة بStripe، أو أي معرّف إزالة تكرار يعرضه مزوّدوك الآخرون). والاثنان متكاملان، لا بديلان: step.run يبقي المنطق الداخلي لدالتك مرة-واحدة-بالضبط؛ ومفتاح المزوّد يبقي الأثر الجانبي الخارجي مرة-واحدة-بالضبط.
تحقّق سريع. صحيح أم خطأ. (أ)
step.runيجعل الخطوة idempotent فقط إن كانت الدالة بداخلها idempotent أيضاً. (ب) حدث بمعرّف مكرّر خارج نافذة إزالة التكرار سيُعامَل بصفته حدثاً جديداً. (ج) إن فشلstep.runفي منتصف التنفيذ (كود الخطوة يطلق استثناءً)، يخزّن Inngest الفشل ويعيد محاولة الخطوة في المحاولة التالية دون إعادة تشغيل الخطوات السابقة.
الأجوبة: (أ) خطأ: step.run يمنح الخطوة مرة-واحدة-على-الأكثر-عند-النجاح من تلقاء نفسه؛ ولا يحتاج الكود بداخله أن يكون idempotent. حالما تُسجَّل خطوة ناجحة، لا يُعاد جسمها أبداً عند إعادة المحاولة. والاستثناء الوحيد هو النافذة في الملاحظة أعلاه: إن ماتت العمليّة بعد أن حمّل Stripe المبلغ لكن قبل أن يسجّل Inngest الخطوة، تعيد إعادة المحاولة استدعاء Stripe، وذلك بالضبط لماذا يدعمها مفتاح idempotency للمزوّد. أمّا منطق دالتك الداخلي فلست مضطراً أبداً لجعله idempotent يدوياً. (ب) صحيح: نافذة إزالة التكرار في Inngest 24 ساعة افتراضياً؛ والأحداث بالمعرّف نفسه بعد تلك النافذة تُعامَل بصفتها جديدة. (ج) صحيح: إعادة المحاولة التلقائية متذكَّرة؛ يعرف Inngest أن الخطوة 3 فشلت في المحاولة 1 ويعيد محاولة الخطوة 3 فقط في المحاولة 2. والخطوات الناجحة السابقة لا تُعاد. (هذه هي إعادة المحاولة داخل التشغيل، لا زرّ Replay في لوحة التحكّم، وهو تشغيل جديد، المفهوم 14.)جرّب مع الذكاء الاصطناعي
Here are three scenarios. For each, decide: idempotency PROBLEM or
NO PROBLEM, and if it's a problem, what's the fix:
A) Stripe sends the same charge.refund.failed webhook three times
in 90 seconds (because their first two attempts timed out at
your endpoint). Your function emails the customer.
B) A customer clicks "Issue refund" three times because the page
was slow. Your function calls Stripe and writes audit_log.
C) Your nightly cron at 09:00 sends a customer-health-check event
to each Pro customer. If two crons fire at the same time (a deploy
bug), what happens?
For each problem case, propose ONE specific fix: event ID seed
inside the function, idempotency key in inngest_client.send, or
function-level deduplication on the trigger.
المفهوم 5: Fan-out وتفويض الوكلاء الفرعيين، حدث واحد وعمّال كثيرون
كثيراً ما يحتاج حدث واحد إلى تحفيز عمل في أماكن كثيرة. حدث Stripe charge.refund.failed قد يحتاج إلى: إخطار وكيل الدعم، والكتابة إلى التدقيق، وتحديث درجة مخاطر العميل، وتنبيه عمليّات التمويل، والنشر على Slack. خمسة تفاعلات، كلّها مستقلّة، كلّها من حدث واحد.
نمط Inngest: اشترِك بدوال كثيرة في الحدث نفسه. لا كود fan-out؛ مجرد عدّة مزيّنات @inngest_client.create_function بTriggerEvent نفسه. كل دالة تعمل باستقلال، ولها إعادات محاولتها، ولها أثر خطوتها، وتفشل باستقلال عن الأخريات.
@inngest_client.create_function(
fn_id="refund-failed-notify-support",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def notify_support(ctx: inngest.Context) -> dict[str, str]:
# ... runs the customer-support worker to draft a response ...
return {"status": "drafted"}
@inngest_client.create_function(
fn_id="refund-failed-update-risk-score",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def update_risk_score(ctx: inngest.Context) -> dict[str, float]:
# ... runs the risk-scoring worker ...
return {"new_risk_score": 0.42}
@inngest_client.create_function(
fn_id="refund-failed-post-slack",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def post_to_slack(ctx: inngest.Context) -> None:
# ... posts a Slack notification ...
return None
يصل webhook واحد من Stripe. ينشئ Inngest حدثاً واحداً. تُطلق ثلاث دوال، كلٌّ في تشغيلها الخاص. فإن فشل post_to_slack لأن Slack معطّل، فالأخريان غير متأثّرتين وتكتملان عادياً. ويجلس التشغيل الفاشل في لوحة التحكّم للإعادة حالما يتعافى Slack. هذا هو لبّ تنسيق العمّال المتعدّدين، وهو النمط المعماري الذي ستؤلّفه طبقة مديرك المستقبلية (دورة لاحقة) على نطاق واسع.
نمط fan-out الآخر: أبٌ-يُطلق-N-أبناء. أحياناً يكون fan-out ديناميكياً. cron اليومي لديك يحتاج إلى إطلاق حدث صحّة عميل لكل عميل Pro، وقد يكون 500 أو 5,000 حسب الأسبوع. والدالة الأمّ ترسل N حدثاً:
async def fan_out_per_customer_events(
ctx: inngest.Context,
customers: list[str],
run_day: str, # pinned by the caller (the cron's scheduled date), never date.today()
) -> int:
events = [
inngest.Event(
name="customer/health_check.requested",
data={"customer_id": cid},
id=f"daily-health-{cid}-{run_day}", # stable id: identical on every retry
)
for cid in customers
]
# ctx.step.send_event memoizes the send, so a retry of this function
# does not re-fire the fan-out (and even if it did, the stable ids dedup).
await ctx.step.send_event("fan-out", events)
return len(events)
تلك ال5,000 حدث تخرج في خطوة send_event واحدة (قائمة كبيرة تُقطَّع إلى بضعة استدعاءات مجمَّعة تحت الغطاء، لا طلب HTTP واحد حرفياً). تُطلق 5,000 تشغيل دالة، كلٌّ بcustomer_id خاص، كلٌّ معزول، كلٌّ قابل لإعادة المحاولة باستقلال. والتحكّم في التدفّق (المفهوم 11) يحدّ كم منها يعمل متزامناً كي لا تُذيب واجهاتك التالية. والدالة cron تعيد في ثوانٍ؛ وfan-out يعمل بأيّ معدّل تسمح به سياسات التحكّم في التدفّق لدى Inngest.
تفويض الوكلاء الفرعيين حالة خاصة من fan-out. داخل تشغيل عامل، تفوّض مهام فرعية إلى أنواع عمّال أخرى بإرسال مزيد من الأحداث (await ctx.step.send_event(...)، فيكون التفويض متذكَّراً كأي خطوة أخرى). والأب لا ينتظر الأبناء إلا إن استخدم صراحةً step.invoke (الذي يشغّل دالة ابناً وينتظر نتيجتها) لجمع نتائجها.
تنبّأ. لديك ثلاث دوال كلّها محفَّزة ب
customer/email.received: وكيل دعم العملاء الذي يصوغ رداً (15 ثانية)، وعدّاد تحليلات (50 مللي ثانية)، و«كاشف VIP» يتحقّق إن كان العميل عالي القيمة (200 مللي ثانية). حين يصل بريد، كيف يبدو الكمون المرئي للمستخدم لكلٍّ منها؟ ثلاثة خيارات: (أ) الثلاثة تتجمّع إلى ~15 ثانية؛ (ب) الثلاثة تعمل بالتوازي، والكمون الكلّي ~15 ثانية (الأبطأ)؛ (ج) كلٌّ يعمل باستقلال بلا كمون مشترك على الإطلاق. الثقة 1-5.
الجواب: (ج). كل دالة تشغيلها الخاص، في فتحة عمليّتها الخاصة. وكيل دعم العملاء لا يحجب عدّاد التحليلات؛ وكاشف VIP لا يحجب الوكيل. من الخارج، الكمون لأيّ دالة بعينها هو زمن تلك الدالة الخاص فقط. ولهذا يتمدّد fan-out: المستهلكون معزولون، وإن تعطّل الوكيل فعدّاد التحليلات غير متأثّر. التحفّظ الوحيد، الذي يطوّره المفهوم 11: هذا العزل بين دوال مختلفة. فحين تتفرّع دالة واحدة إلى آلاف التشغيلات لنفسها، يجعل حدّ concurrency التشغيلات اللاحقة تنتظر عمداً، فتلك الأشقّاء من الدالة نفسها تنتظر دورها فعلاً. الدوال المختلفة لا يحجب بعضها بعضاً أبداً؛ وتشغيلات كثيرة للدالة نفسها يمكن أن تفعل.جرّب مع الذكاء الاصطناعي
Design the fan-out architecture for these three scenarios. For each,
sketch the event names and the functions that subscribe:
A) New customer signs up. Need to: send welcome email, create
Stripe customer, post to Slack #new-customers, write to
audit_log, schedule a 7-day follow-up.
B) Customer support email arrives. Need to: draft a reply (agent),
detect sentiment, check if VIP, update customer's "last contact"
timestamp, attach to the right ticket thread.
C) Daily cron at 09:00 needs to run customer-health-check on
~5,000 Pro customers. Each check takes ~30 seconds. We want
the whole batch to complete by 11:00 (a 2-hour window).
For each, decide: how many event types, how many subscriber
functions, what the idempotency story is, and one specific failure
mode this design protects against.
الجزء 2: المنعكسات، ماذا يحدث حين ينكسر شيء
كان الجزء 1 عن كيف يصل العمل إلى العامل. والجزء 2 عن ماذا يحدث حين ينكسر ذلك العمل في منتصف الطريق.
تصوّر دورة واحدة لعامل حقيقي. يستدعي وكيلاً، فيستدعي الوكيل بضع أدوات، وتلك الأدوات تضرب قاعدة بيانات، وواجهة دفع، ونموذجاً. ذلك عدّة استدعاءات شبكة على التوالي، وأيٌّ منها يمكن أن يفشل: مهلة، أو اتصال مقطوع، أو خدمة معطّلة لبضع ثوانٍ. وبلا حماية، فشل صغير واحد يرمي كل ما فعله العامل للتوّ ويبدأ الدورة كلّها من الأعلى.
المتانة هي العلاج، ويسهل قولها بوضوح: حين يفشل شيء في منتصف الطريق، تبقى الخطوات التي انتهت أصلاً منتهية، ويلتقط العامل من النقطة التي انكسرت بدل أن يبدأ من جديد. في صورة الجهاز العصبي، هذا هو المنعكس: يحدث، بسرعة، دون أن يضطرّ الوكيل للتفكير فيه.
يمنحك Inngest هذا بأداة واحدة، step.run، وآلية اسمها التذكّر تعمل تحتها. والجزء 2 يغطّي كليهما، ثم النسخ المعتمدة على الزمن (step.sleep وstep.wait_for_event)، وكيف تتصرّف إعادات المحاولة، ومساعدات step.ai.
إن كنت تتصفّح بسرعة: الاثنان الأهمّ هما المفهوم 6 (
step.run) والمفهوم 7 (التذكّر). كل شيء آخر في الجزء 2 يُبنى عليهما، فاقرأ هذين ببطء. وحالما يستقرّان، تمضي المفاهيم 8 حتى 10 بسرعة.
المفهوم 6: step.run ونموذج الدالة المتينة
دالة Python عادية تعمل مرة واحدة، من الأعلى إلى الأسفل. فإن تعطّلت في منتصف الطريق، تبدأ من الأعلى. وإن أجرت ثلاثة استدعاءات API قبل التعطُّل، تجري المحاولة التالية تلك الاستدعاءات الثلاثة ثانيةً، وتدفع مقابلها، وربما تحمّل أحدهم مرّتين، ثانيةً.
دالة Inngest متينة. كل عمليّة تريد أن تكون منقطة تفتيش تُغلَّف في step.run(name, fn, ...). ثم يقود Inngest الدالة خطوةً في كل مرة. يشغّل معالجك من الأعلى، وحين يبلغ خطوة لم يفعلها بعد، يشغّل تلك الخطوة، ويحفظ النتيجة، ويعيد الدخول إلى المعالج من الأعلى ثانيةً، هذه المرة معيداً مخرَج كل خطوة مكتملة المخزَّن بدل إعادة تنفيذها. ت«لحق» الدالة بالموضع الذي توقّفت عنده، وتأخذ الخطوة التالية، وتكرّر. (فجسم المعالج يعمل مرّات كثيرة لدالة واحدة، مرة لكل خطوة، لا فقط حين ينكسر شيء.)
لماذا إعادة الدخول إلى المعالج أصلاً، بدل المتابعة من حيث توقّف؟ بسبب البرنامجين من الافتتاحية. المحرك ودالتك برنامجان منفصلان. ولا يستطيع برنامج أن يتوقّف في منتصف كود برنامج آخر ويحفظ موضعه. فيقود المحرك دالتك بالطريقة الوحيدة الممكنة: يستدعي دالتك عبر الويب، ويشغّلها حتى الخطوة غير المنتهية التالية، ويترك تلك الخطوة تعمل، ويستعيد النتيجة. ثم يخزّن تلك النتيجة في جانبه ويستدعي دالتك ثانيةً للخطوة التالية، مسلّماً كل ما خزّنه أصلاً.
ENGINE YOUR FUNCTION (host)
| call: run from the top -----------> runs to step 1, does it
| <---------------------------------- returns step 1's result
stores result 1
| call again -----------> step 1 from memo, runs step 2
| <---------------------------------- returns step 2's result
stores result 2
| call again -----------> steps 1-2 from memo, runs step 3
| ...and so on, one call per step
تلك هي الآلية كلّها. «يُعاد من الأعلى، والخطوات المكتملة من التذكّر» هو مجرد المحرك يستدعي دالتك مرة لكل خطوة ويُبقي النتائج في جانبه. ولأن النتائج تعيش في جانب المحرك، فخطوة منتهية تنجو حتى لو تعطّل مضيفك وأُعيد تشغيله في منتصف التشغيل.
@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
customer_id = ctx.event.data["customer_id"]
# Step 1: load the customer record (one DB call)
customer = await ctx.step.run(
"load-customer", load_customer_by_id, customer_id,
)
# Step 2: load the conversation thread (one DB call)
thread = await ctx.step.run(
"load-thread", load_thread_for_customer, customer_id,
)
# Step 3: run the OpenAI Agents SDK agent (your worker).
# step.run forwards only positional args, so a call that needs keyword
# args is wrapped in a lambda (the step body becomes a no-arg callable).
response = await ctx.step.run(
"run-agent",
lambda: run_customer_support_agent(
customer=customer,
thread=thread,
email_body=ctx.event.data["body"],
),
)
# Step 4: write the draft reply to the database
await ctx.step.run(
"save-draft-reply",
lambda: save_reply(customer_id=customer_id, text=response.draft),
)
# Step 5: notify the on-call human reviewer via Slack
await ctx.step.run(
"notify-reviewer",
lambda: post_slack_for_review(response=response),
)
return {"status": "drafted", "reviewer_notified": True}
خمس خطوات. كلٌّ منها منقطة تفتيش باستقلال.
ما تشتريه لك المتانة، في ثلاثة إخفاقات يمكن أن تصيب هذه الدالة بالذات:
| إن فشل هذا | بدون step.run | مع step.run |
|---|---|---|
| الوكيل تنتهي مهلته (الخطوة 3) | تعيد إعادة المحاولة تحميل العميل والمحادثة وتعيد تشغيل الوكيل من الصفر، دافعةً مقابل رموز OpenAI مرّتين | الخطوتان 1-2 تعودان من التذكّر؛ والخطوة 3 وحدها تُعاد، وInngest يعالج الخطأ العابر لك |
| العمليّة تُقتَل بين الخطوتين 3 و4 (نشر، إعادة تشغيل، OOM) | ردّ الوكيل يُفقَد؛ والبريد يبقى بلا ردّ حتى يلاحظ أحد | الدالة تستأنف بعد إعادة التشغيل: الخطوات 1-3 تعود من التذكّر في مللي ثوانٍ، والخطوتان 4-5 تعملان، فيحصل العميل على الردّ |
| Slack يعيد 503 (الخطوة 5) | تفقد العمل، أو تكتب يدوياً إعادة-محاولة-وتراجع لSlack فقط | Inngest يعيد محاولة الخطوة 5 بتراجع حتى يتعافى Slack أو تنفد ميزانية إعادة المحاولة؛ والخطوات 1-4 تبقى منتهية، والمسودة محفوظة أصلاً |
أنت لا تكتب أي حلقات إعادة محاولة، ولا أي فحوص «هل فعلت هذا أصلاً»، ولا أي آلة حالات خاصة بك. آلة الحالات هي تسلسل استدعاءات step.run.
قاعدة step.run الوحيدة. يجب أن تكون الخطوة آمنة لإعادة التشغيل: إن فشلت وشغّلها Inngest ثانيةً، يجب ألّا يفسد التشغيل الثاني أي شيء.
- الدوال الصرفة آمنة تلقائياً.
- استدعاءات API الidempotent آمنة (
idempotency_keyالخاص بStripe، وأدوات خادم MCP الخاص بك): التكرار بلا أثر. - العمل غير الحتمي ما يزال آمناً لإعادة التشغيل؛ قد تحصل فقط على نتيجة مختلفة عند إعادة المحاولة. معرّف عشوائي جديد، أو استدعاء LLM عند الحرارة الافتراضية، سيختلف في محاولة ثانية. وذلك جيّد لردّ وكيل (أي مسودة صالحة تفي). وحين يجب أن تكون القيمة الدقيقة ثابتة عبر إعادات المحاولة، ثبّتها: مرّر بذرة، أو ولّدها مرة واحدة في خطوتها الخاصة الأبكر واقرأها ثانيةً.
تحقّق سريع. صحيح أم خطأ. (أ) جسم الدالة يُعاد تنفيذه من الأعلى كل مرة يتقدّم فيها Inngest إلى الخطوة التالية، لا فقط عند إعادات المحاولة، معيداً تشغيل الكود العادي (إسنادات المتغيّرات، التفرّع) بين استدعاءات
step.runلديك. (ب) إن استغرقت خطوة 30 ثانية للاكتمال، وتعطّلت الدالة بعد 25 ثانية، تتابع إعادة المحاولة تلك الخطوة من الثانية 25. (ج) مخرجاتstep.runتُخزَّن في بنية Inngest التحتية، لا في تطبيقك.
الأجوبة: (أ) صحيح، وهو يفاجئ الناس: Inngest يعيد الدخول إلى معالجك من الأعلى عند كل خطوة، متخطّياً الخطوات المكتملة من التذكّر. فالكود خارج step.run يعمل مرّات كثيرة في تشغيل نظيف، لا فقط عند إعادات المحاولة. والكود داخل خطوة يعمل مرة واحدة، ثم يعود من التذكّر. (استيرادات import على مستوى الوحدة تُحمَّل مرة واحدة بصرف النظر؛ فقط جسم المعالج يُعاد.) هذا هو السبب الحقيقي لإبقاء العمل داخل step.run. (ب) خطأ: step.run هو الوحدة الذرّية؛ فإن قُوطعت خطوة، تعيد إعادة المحاولة تشغيل الخطوة كاملةً. وإن كانت خطوتك طويلة جداً بحيث لا يمكن السماح بإعادة تشغيلها، تكسرها إلى خطوات أصغر. (ج) صحيح: متجر مخرج الخطوة جزء من Inngest، لا قاعدة بياناتك. ولذلك تستطيع إعادة تشغيل التشغيلات حتى بعد تغيّر مخطّط قاعدة بياناتك.جرّب مع الذكاء الاصطناعي
With my AI coding assistant connected to the Inngest dev server MCP,
shape a customer-support worker into an Inngest durable function.
Take a Runner.run call that processes a customer email and wrap each
of these inside its own step.run:
1. Load the customer record
2. Load the related conversation thread
3. Run the agent (the OpenAI Agents SDK Runner)
4. Persist the draft reply
5. Notify the on-call reviewer
Use grep_docs to find the current Python SDK syntax. Use
invoke_function to test it with a synthetic email payload. Then
deliberately raise an exception in step 4 and use get_run_status
to confirm steps 1-3 don't re-execute on retry.
المفهوم 7: التذكّر، الآلية تحت قابلية الاستئناف
قال المفهوم 6 «الخطوات التي اكتملت أصلاً تعيد مخرجاتها المخزَّنة بدل إعادة التنفيذ». تلك الآلية هي التذكّر، وتستحقّ نظرة قريبة لأن كل بدائية Inngest أخرى مبنية عليها.
حين تستدعي await ctx.step.run("load-customer", load_customer_by_id, "c-4429")، يحفظ Inngest متجر تذكّر مفتاحه (run_id, step_name). والسطر نفسه يتصرّف بطريقة مختلفة حسب امتلاء ذلك المفتاح أصلاً أم لا:
- المحاولة الأولى: التذكّر فارغ، فيعمل
load_customer_by_idفعلاً، ويحفظ Inngest ما يعيده قبل أن يسلّمك النتيجة. - كل إعادة لاحقة (Inngest يعيد الدخول إلى المعالج حين ينتقل إلى الخطوة التالية، وثانيةً عند أي إعادة محاولة): التذكّر يحمل أصلاً
load-customer، فلا يعملload_customer_by_id، ولا يحدث استدعاء قاعدة البيانات أبداً، وتعود القيمة المحفوظة في مللي ثوانٍ.
ولهذا تكون إعادات المحاولة رخيصة (العمل الغالي مخبّأ أصلاً)، ولماذا تكون المتانة صحيحة (العمل الغالي لا يحدث مرّتين أبداً)، ولماذا يكون «الجسم يُعاد من الأعلى إلى الأسفل» مقبولاً رغم أنه يبدو مهدِراً: العمل داخل الخطوات لا يُعاد فعلاً؛ فقط كود التنسيق بين الخطوات يُعاد.
الخطوة المكتملة مدفوعة مرة واحدة، لا مرة لكل إعادة محاولة.
التضمين الذي يفاجئ المستخدمين الجدد. الكود خارج step.run يعمل كل مرة يعيد فيها Inngest الدخول إلى المعالج، وهي مرة لكل خطوة، لا فقط عند إعادات المحاولة. إن فعلت هذا:
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
# ANTI-PATTERN: this re-runs every time Inngest advances a step. Don't do this.
expensive_thing: dict = await fetch_expensive_data(ctx.event.data["id"])
await ctx.step.run("do-something", do_something_with, expensive_thing)
return {"status": "done"}
fetch_expensive_data يعمل ثانيةً عند كل خطوة تأخذها الدالة، حتى بلا إخفاقات. وهذا المثال أحادي الخطوة يستدعيه أصلاً مرّتين في تشغيل نظيف (مرة لكل إعادة دخول للمعالج)، وكل خطوة تضيفها استدعاء آخر. فعند 0.10$ للاستدعاء، يهدر مالاً أصلاً قبل أن ينكسر شيء، وإعادة المحاولة تدفع مقابله كلّه من جديد. والعلاج أن تغلّف الشيء الغالي في خطوته الخاصة:
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
expensive_thing: dict = await ctx.step.run(
"fetch-expensive-data", fetch_expensive_data, ctx.event.data["id"],
)
await ctx.step.run("do-something", do_something_with, expensive_thing)
return {"status": "done"}
الآن fetch_expensive_data متذكَّر؛ وإعادات المحاولة لا تدفع مقابله ثانيةً.
اسم الخطوة هو مفتاح التذكّر. الSDK لPython لا يتصادم على اسم مكرّر؛ بل يرقّمها تلقائياً حسب ترتيب الاستدعاء (load-customer، ثم load-customer:1، ثم load-customer:2)، فيحصل كلٌّ على فتحة تذكّر خاصة. لكن لا تتّكئ على ذلك: الأرقام التلقائية لا تحمل معنى، فأثر لوحة تحكّم يُظهِر load-customer:7 لا يخبرك شيئاً عن أيّ عميل، وإدراج خطوة أو حذفها يزيح كل رقم لاحق. أعطِ كل استدعاء اسماً ثابتاً مشتقّاً من البيانات بدلاً من ذلك، step.run(f"load-customer-{customer_id}", ...) في حلقة، فيكون مفتاح التذكّر مربوطاً بالبيانات، لا بترتيب الاستدعاء.
تنبّأ. دالتك لها ثلاث خطوات. الخطوة 1 (
load-customer) تكلّف 0.01$ في استدعاءات قاعدة البيانات وتستغرق 100 مللي ثانية. الخطوة 2 (run-agent) تكلّف 0.20$ في رموز OpenAI وتستغرق 12 ثانية. الخطوة 3 (save-draft) تكلّف 0.005$ في استدعاءات قاعدة البيانات وتستغرق 50 مللي ثانية. الخطوة 2 تفشل 30% من الوقت بسبب حدود معدّل OpenAI؛ وInngest يعيد المحاولة بتراجع. ما فرق الكلفة بين (أ) تغليف الثلاث كلّها فيstep.runو(ب) تغليف الخطوة 2 فقط فيstep.run؟ الثقة 1-5.
الجواب: مع (أ)، إعادة محاولة واحدة للخطوة 2 تكلّف كلفة الخطوة 2 فقط (0.20$)؛ والخطوة 1 متذكَّرة ومتخطّاة، والخطوة 3 لم تعمل بعد. مع (ب)، الخطوة 1 خارج step.run، فتُعاد عند كل إعادة محاولة للخطوة 2: نحو 0.21$ لكل إعادة محاولة (0.01$ للخطوة 1 زائد 0.20$ للخطوة 2). الخطوة 3 ليست الكلفة هنا، فهي تعمل مرة واحدة، بعد أن تنجح الخطوة 2 أخيراً؛ والمقصد أن أي عمل قبل خطوة فاشلة يُعاد ما لم تغلّفه. عبر ألف بريد بمعدّل إعادة محاولة 30%، ذلك نحو 3$ من استدعاءات قاعدة بيانات الخطوة 1 المهدورة، والخطر الحقيقي أكبر من المال: لو كان للخطوة 1 أثر جانبي (كتابة، تحميل)، فإبقاؤها خارج step.run يجعل ذلك الأثر الجانبي يحدث ثانيةً عند كل إعادة محاولة. غلّف كل ما لا تريد إعادة تنفيذه في step.run. ليس اختيارياً حالما تفهم الآلية.جرّب مع الذكاء الاصطناعي
With my AI coding assistant: review the Inngest function we built
in Concept 6's Try-with-AI and identify any code BETWEEN step.run
calls that should be wrapped in its own step but isn't. Common
candidates:
- Computed values (timestamps, IDs, formatting) that we want to be
stable across retries
- Calls to logging or metrics services
- Reads from Redis, environment variables, secret managers
Then propose a refactor that moves each of these into its own step
with a meaningful name. For each, explain whether the side effect
is one you want to happen once (use step.run) or every retry
(leave it outside).
المفهوم 8: step.sleep وstep.wait_for_event، المتانة عبر الزمن
بعض العمل عليه أن ينتظر. خطّ بريد ترحيب يرسل بريداً فوراً، ثم ينتظر ثلاثة أيام، ثم يرسل متابعة. وتحقيق ردّ مال يحتاج إلى انتظار موافقة إنسان. وتدفّق تحويل تجريبي يراقب «ترقّى المستخدم إلى مدفوع» خلال 7 أيام ويرسل بريداً مختلفاً حسب ما يراه.
في دالة Python عادية، «انتظر ثلاثة أيام» يعني إبقاء عمليّة مفتوحة ثلاثة أيام. وذلك لا يُحتمَل: عمليّتك تُعاد تشغيلها، واستضافتك تحاسبك على 72 ساعة من حوسبة خاملة، ومؤقّتك يُفقَد. في Inngest، «انتظر ثلاثة أيام» سطر واحد:
from datetime import timedelta
@inngest_client.create_function(
fn_id="trial-welcome-series",
trigger=inngest.TriggerEvent(event="user/trial.started"),
)
async def welcome_series(ctx: inngest.Context) -> dict[str, str]:
user_id = ctx.event.data["user_id"]
await ctx.step.run("send-welcome-email", send_welcome_email, user_id)
# Wait three days. The function gets paged out of memory. Nothing
# is consuming compute. Three days later, Inngest pages it back in
# and resumes execution at the next line.
await ctx.step.sleep("wait-three-days", timedelta(days=3))
await ctx.step.run("send-followup", send_followup_email, user_id)
return {"status": "completed"}
step.sleep متين، الجهاز العصبي مستريح. الدالة تتوقّف؛ ويخزّن Inngest وقت الاستئناف؛ ولا شيء يستهلك حوسبة بينما تنتظر؛ والدالة تستأنف في الوقت الصحيح، بكل مخرجات الخطوات السابقة ما تزال متذكَّرة. وstep.sleep (وstep.sleep_until) يستطيع الانتظار حتى سنة على الخطط المدفوعة، وحتى سبعة أيام على خطة Hobby المجانية (حدود استخدام Inngest). وسقف Hobby ذو السبعة أيام واسع بما يكفي لكل نوم تستخدمه هذه الدورة.
الشقيق الأقوى هو step.wait_for_event. بدل الانتظار للزمن، انتظر حدثاً آخر. الدالة تتوقّف حتى يصل حدث مطابق، أو حتى تنقضي مهلة تضبطها. وهذا ما يجعل Inngest أنظف تعبير عن HITL (المفهوم 15) وأنماط تنسيق الوكلاء فيما بينهم:
@inngest_client.create_function(
fn_id="refund-with-approval",
trigger=inngest.TriggerEvent(event="customer/refund.requested"),
)
async def refund_with_approval(ctx: inngest.Context) -> dict[str, str]:
request = ctx.event.data
request_id = request["request_id"]
# If amount is over $100, require approval before issuing
if request["amount_cents"] >= 10_000:
# Notify a human via Slack/email/whatever
await ctx.step.run("notify-approver", notify_human_approver, request)
# Wait for an approval event. Up to 24 hours; expires otherwise.
approval = await ctx.step.wait_for_event(
"wait-for-approval",
event="refund/approval.decided",
timeout=timedelta(hours=24),
if_exp=f"async.data.request_id == '{request_id}'",
)
if approval is None or not approval.data.get("approved"):
return {"status": "rejected_or_timeout"}
# Either it was under $100, or it was approved
refund = await ctx.step.run(
"issue-stripe-refund", call_stripe_refund_api, request,
)
return {"status": "issued", "refund_id": refund["id"]}
ما يحدث، من الأعلى إلى الأسفل:
the function reaches wait_for_event -> it SUSPENDS (zero compute)
|
| a human sees the Slack note, clicks Approve in your admin UI
| the UI sends a refund/approval.decided event
v
Inngest matches that event to THIS waiting run (if_exp picks the right one)
|
v
the function RESUMES, with the event as the `approval` value
|
v
the refund step runs -> Stripe refund happens, after the human approved
الجزء الدقيق الوحيد هو المطابقة في الوسط: if_exp هو ما يجعل حدث الموافقة يوقظ تشغيل هذا الطلب لا تشغيل غيره.
step.sleep وstep.wait_for_event مهلتان لا تدفع مقابلهما. الدالة تبدو متزامنة في كودك («انتظر ثلاثة أيام، ثم أرسل البريد»)، لكنّ دلالات وقت التشغيل غير متزامنة ومتينة. هذا أحد الشيئين اللذين يشتهر بهما Inngest (إعادات المحاولة المتينة هي الآخر). وبدونه، البديل طابور زائد آلة حالات زائد قاعدة بيانات زائد مستطلِع، وكنت ستكتب ألف سطر بدل ثلاثة.
تحقّق سريع. ثلاث ادّعاءات. ضع على كلٍّ منها صحيح أو خطأ. (أ) إن ضُبِط
step.sleepل30 يوماً وأُعيد نشر خدمتك خمس مرّات في تلك ال30 يوماً، يتابع النوم دون انقطاع على خطة مدفوعة. (ب) إن انتهت مهلةstep.wait_for_event، تطلق الدالة استثناءً. (ج) استدعاءاstep.wait_for_eventفي الدالة نفسها يمكن أن ينتظرا الحدث نفسه متزامنين.
الأجوبة: (أ) صحيح على خطة مدفوعة: النوم مخزَّن في بنية Inngest التحتية، لا في ذاكرة خدمتك، فإعادات النشر لا تفقده. لاحظ سقف الطبقة: نوم 30 يوماً مقبول على خطة مدفوعة لكنه يتجاوز سقف نوم خطة Hobby المجانية ذي السبعة أيام. (ب) خطأ: عند المهلة، يعيد wait_for_event القيمة None. وكودك يتحقّق منها ويقرّر ماذا يفعل (رفض، تصعيد، موافقة افتراضية، أيّاً كانت السياسة). (ج) خطأ في الكود التسلسلي العادي: الدالة تبلغ wait_for_event واحداً، فتتوقّف، ولا تبلغ التالي إلا بعد استئناف الأول، فالانتظاران يجريان بالتسلسل، وحدث مطابق واحد يستأنف أيّ انتظار متوقّف حالياً. ولن يتداخلا إلا إن أطلقتهما خطوتين متوازيتين، وهو نمط يتجاوز هذه الدورة. القاعدة اليومية: حدث واحد يستأنف نقطة انتظار واحدة.جرّب مع الذكاء الاصطناعي
Build a delayed-investigation flow with my AI coding assistant.
Specification:
1. Triggered by event 'customer/refund.failed'.
2. Immediately notify the on-call human via Slack with the refund
details and a "Investigate" button.
3. Wait for the human to click the button (which fires
'customer/refund.investigation_started') for up to 4 hours.
4. If the click arrives in time: run the agent to draft an
investigation summary.
5. If 4 hours pass without a click: escalate to a senior reviewer
by firing 'customer/refund.escalated'.
Use the dev-server MCP's send_event tool to simulate the
human-click event during testing. Use get_run_status to inspect
how the suspended function shows up in the dashboard. Before
writing, use list_docs to scan the Inngest documentation tree
for the right page on wait_for_event semantics, then
read_doc on the page you find to get the exact syntax for
the if_exp filter expression.
المفهوم 9: إعادات المحاولة، ومعالجة الأخطاء، وdead-letter
هذا هو المنعكس عن قرب. افتراضياً، يعيد Inngest محاولة الخطوات الفاشلة. والافتراضات عاقلة: ~4 إعادات محاولة بتراجع أسّي، تتراوح من بضع ثوانٍ إلى بضع دقائق بين المحاولات. وبعد فشل إعادة المحاولة الأخيرة، يدخل التشغيل حالة failed ويبقى فيها للفحص و(اختيارياً) للإعادة. وتستطيع ضبط هذا لكل دالة: retries=10، أو retries=0 لعدم إعادة المحاولة أبداً. ولتخطّي إعادات المحاولة لفشل محدّد (بطاقة مرفوضة، أو 401)، أطلق inngest.NonRetriableError من داخل الخطوة، كما يفعل المثال أدناه.
@inngest_client.create_function(
fn_id="charge-customer",
trigger=inngest.TriggerEvent(event="order/checkout.completed"),
retries=2, # transient Stripe errors (503, timeout) retry twice
)
async def charge_customer(ctx: inngest.Context) -> dict[str, str]:
try:
charge = await ctx.step.run(
"call-stripe", call_stripe_charge, ctx.event.data,
)
return {"status": "charged", "charge_id": charge["id"]}
except inngest.NonRetriableError as e:
# call_stripe_charge raises NonRetriableError on a declined card, which
# tells Inngest NOT to retry the step (a decline will not become an
# approval on attempt 2). So we land here on the FIRST failure, with no
# wasted retries, mark the order, and kick off the dunning flow.
await ctx.step.run(
"mark-failed",
lambda: mark_order_failed(ctx.event.data["order_id"], reason=str(e)),
)
await ctx.step.run(
"emit-dunning-event", emit_dunning, ctx.event.data["order_id"],
)
return {"status": "card_declined"}
ثلاثة أنماط مهمّة.
النمط 1: إخفاقات عابرة مقابل دائمة. يعيد Inngest محاولة كل شيء افتراضياً، لكنّ بعض الأخطاء ليست عابرة. خطأ بطاقة-مرفوضة من Stripe سيُرفَض ثانيةً عند إعادة المحاولة. و401-غير-مصرَّح من واجهتك التالية لن يصير 200 لمجرّد أنك انتظرت. ودالتك ينبغي أن تلتقط هذه تحديداً وتعالجها: اكتب إلى قاعدة بياناتك، وأصدِر حدثاً تالياً، وأعِد بنظافة، كي لا تهدر ميزانية إعادة المحاولة على محاولات يائسة. وNonRetriableError الخاص بInngest يخبر Inngest صراحةً أن يتخطّى إعادات المحاولة لاستثناء مُطلَق.
النمط 2: أخطاء على مستوى الخطوة مقابل على مستوى الدالة. الخطوة التي تطلق استثناءً تُعاد. وبعد استنفاد إعادات المحاولة على مستوى الخطوة، تفشل الدالة. وأحياناً تريد أن تنجو الدالة من خطوة فاشلة: سجّل الفشل، وعلّم العمل بأنه «جزئي»، وتابع. غلّف step.run في try/except. الخطوة ما تزال تحصل على إعادات محاولتها؛ فإن فشلت كلّها، ينتشر الاستثناء إلى كتلة الالتقاط لديك، حيث تقرّر ماذا تفعل.
النمط 3: dead-letter وreplay. دالة فشلت تماماً لا تختفي؛ بل تهبط في عرض «failed runs» بلوحة التحكّم بأثرها الكامل، ومخرجات خطواتها، واستثنائها، بجانب زرّ Replay. أصلح العطل، واشحنه، وأعِد التشغيل، بلا معالج dead-letter لكتابته. (Replay تشغيل جديد من الأعلى، لا استئناف حافظ للتذكّر، فأبقِ الخطوات ذات الأثر الجانبي idempotent؛ المفهوم 14 يغطّيه كاملاً.)
تنبّأ. دالتك تستدعي Stripe في الخطوة 2 وخدمة بيانات عملائك في الخطوة 4. Stripe يعيد 503 (الخدمة غير متاحة، عابر) في المحاولة الأولى للخطوة 2. الخطوة 2 تُعاد 4 مرّات بتراجع أسّي (~1 ثانية، ~2 ثانية، ~5 ثوانٍ، ~12 ثانية)؛ وفي إعادة المحاولة الرابعة، يعود Stripe، فينجح التحميل. الآن تعمل الخطوة 4، وخدمة البيانات معطّلة ب500. هل يعيد Inngest محاولة الدالة كلّها، أم الخطوة 4 فقط؟ وكم مرة؟ الثقة 1-5.
الجواب: الخطوة 4 فقط، وتحصل على ميزانية إعادة محاولتها الخاصة. الخطوات لا تتشارك إعادات المحاولة. إعادات محاولة الخطوة 2 الأربع مستقلّة عن الخطوة 4. وسيعيد Inngest محاولة الخطوة 4 (افتراضياً ~4 مرّات) وإن عادت خدمة البيانات، تكتمل الخطوة 4، وتنجح الدالة. وتحميل Stripe من الخطوة 2 لا يُصدَر ثانيةً، لأن مخرج الخطوة 2 تُذُكِّر بعد إعادة محاولته الناجحة. فيُحمَّل العميل مرة واحدة بالضبط رغم أن الدالة قضت 20 ثانية عبر إعادات المحاولة.جرّب مع الذكاء الاصطناعي
With my AI coding assistant: extend the customer-support worker
function from Concept 6 with explicit retry and failure handling.
Specification:
1. The OpenAI Agents SDK call should retry 3 times on transient
failures (rate limit, timeout), but NOT retry on a content-policy
refusal from the model.
2. The Slack notification should retry up to 10 times (Slack is
often flaky; don't lose the notification).
3. The Postgres write should retry once; if it fails again, log the
failure and continue (don't fail the whole function over a
transient DB blip).
For each step, decide what's transient vs permanent and structure
the try/except accordingly. Use grep_docs to find the Python SDK's
NonRetriableError equivalent.
المفهوم 10: step.run لاستدعاءات الذكاء الاصطناعي في Python (step.ai.wrap لTypeScript فقط)
المفاهيم 6-9 تعمل لأي كود ذي أثر جانبي: كتابات قاعدة البيانات، واستدعاءات API، وكتابات الملفّات، واستدعاءات الوكلاء، وذلك يشمل استدعاءات LLM لديك. فإليك العنوان لاستدعاءات الذكاء الاصطناعي في Python، صراحةً: تظلّ تستخدم ctx.step.run. وInngest يشحن فعلاً بدائيات step.ai خاصة بالذكاء الاصطناعي، لكنها في Python إمّا غير متاحة أو متخصّصة جداً، والامتداد إليها هو المنعطف الخاطئ الشائع الذي يوجد هذا المفهوم لمنعه.
ملاحظة مهمّة عن Python مقابل TypeScript مقدّماً. وحدة
step.aiفي Inngest لها طريقتان، ولهما دعم لغوي مختلف.step.ai.infer()متاح في TypeScript وPython معاً (الSDK لPython v0.5+): يُفرّغ الاستدلال إلى بنية Inngest التحتية ويتتبّع الاستدعاء. أمّاstep.ai.wrap()فهو لTypeScript فقط: لا يوجد مكافئ في Python اليوم. وفي مشاريع Python (مثل عامل هذه الدورة)، النمط الصحيح لتغليف استدعاء OpenAI Agents SDK هوctx.step.run(...)، الذي يمنحك أصلاً متانة كاملة، وإعادات محاولة، وقابلية ملاحظة لمدخلات الخطوة المغلَّفة ومخرجاتها. لكنك لا تحصل على قياس بُعدي LLM-محدّد للتعليمة/الاستجابة الذي يضيفهstep.ai.wrapفي TypeScript. (تمّ التحقّق مقابل وثائق استدلال الذكاء الاصطناعي حتى مايو 2026.)
step.run يغلّف تشغيل الوكيل، لا استدعاء نموذج عارياً. في هذه الدورة عاملك وكيل OpenAI Agents SDK، فالوكيل يجري استدعاءات LLM والأدوات، لا أنت. تغلّف تشغيل الوكيل كاملاً في ctx.step.run(...). وInngest لا يبالي ما داخل الخطوة؛ فوكيلك مجرد الدالة التي تسلّمها إيّاه. يسجّل مدخل الخطوة ونتيجة الوكيل، ويعيد محاولة الخطوة عند فشل عابر، ويتذكّرها عند النجاح فلا تعيد الخطوات اللاحقة دفع كلفة الوكيل أبداً.
@inngest_client.create_function(
fn_id="summarize-customer-thread",
trigger=inngest.TriggerEvent(event="customer/thread.summary_requested"),
)
async def summarize_thread(ctx: inngest.Context) -> dict[str, str]:
thread = await ctx.step.run(
"load-thread", load_thread, ctx.event.data["thread_id"],
)
# The agent makes the model and tool calls internally. You wrap the whole
# AGENT RUN in step.run, so Inngest sees it as one step: it records the
# input and the agent's result, retries on a transient failure, and
# memoizes on success so later steps do not re-pay the agent's cost.
result = await ctx.step.run(
"run-agent",
lambda: run_support_agent(thread=thread),
)
return {"summary": result.summary}
لوحة التحكّم تُظهِر هذا التشغيل بصفته load-thread ثم run-agent، كلٌّ بمدخله ومخرجه. والشيء الوحيد الذي لا تحصل عليه، مقابل step.ai.wrap في TypeScript، هو قياس بُعدي LLM-محدّد (عدّات الرموز، اسم النموذج) مفصَّلاً في عرض الذكاء الاصطناعي بلوحة التحكّم؛ وتتبّع Agents SDK نفسه يغطّي ذلك.
تشغيل الوكيل خطوة واحدة. لأنك غلّفت الوكيل كاملاً، فاستدعاءات النموذج والأدوات داخله ليست خطوات Inngest منفصلة. فإن فشل تشغيل الوكيل في منتصف الطريق وأعاد Inngest محاولة run-agent، يُعاد الوكيل كاملاً من البداية، دافعاً ثانيةً مقابل الرموز التي صرفها أصلاً في تلك المحاولة. وذلك مقبول عادةً: مسودة وكيل رخيصة لإعادتها، وأي مسودة صالحة تفي. وحين يكون تشغيل وكيل واحد غالياً بما يكفي بحيث لا تريد إعادته جملةً، اكسر العمل إلى قطع أصغر، كلٌّ في step.run خاص بها (التحميل والاسترجاع في خطواتهما الخاصة، ثم استدعاء وكيل أقصر)، فتعيد إعادة المحاولة القطعة التي فشلت فقط.
لأن step.run يسجّل مدخلات كل خطوة ومخرجاتها إلى متجر قابلية الملاحظة لدى Inngest، فإن المحتوى الذي تمرّره عبر خطوة يُخزَّن ويُرى في لوحة التحكّم. فإن تضمّنت تعليمتك بيانات شخصية (أسماء، بريد، عناوين)، أو أسراراً (مفاتيح API، رموز داخلية)، أو بيانات تعاقدية أو مالية، أو محتوى منظَّماً (HIPAA، أو بيانات في نطاق GDPR، أو PCI)، فلا تمرّر المحتوى الخام إلى جسم الخطوة. أخفِ، أو جزّئ، أو لخّص، أو مرّر مرجعاً (customer_id وticket_id، لا نصّ التذكرة الكامل) وأعِد تحميل المحتوى الحسّاس داخل جسم الخطوة من متجرك المعتمد، حيث الاحتفاظ وضوابط الوصول تحت تحكّمك. والانضباط نفسه ينطبق على تتبّع OpenAI Agents SDK نفسه إن فعّلته. عامِل آثار الخطوات كما تعامل أي سجلّ إنتاج: مفيدة افتراضياً، منظَّمة بالسياسة.
step.ai.infer (مدعوم في Python، لكنه متخصّص). نادراً ما تمدّ يدك إليه؛ step.run هو الافتراضي لكل استدعاء ذكاء اصطناعي في هذه الدورة. غرضه الوحيد: بدل استدعاء OpenAI من عمليّتك، تطلب من بنية Inngest التحتية أن تجري الاستدعاء كي تستطيع عمليّتك إلغاء التخصيص بينما الطلب في الطريق. على المنصّات بلا خادم التي تحاسب على الوقت في الطريق، وللاستدلالات الطويلة (Deep Research، ودفعات تضمين كبيرة)، يوفّر ذلك مالاً حقيقياً؛ أمّا للاستدعاءات دون الثانية على خادم دائم التشغيل فيضيف كموناً فقط. وإن استخدمته، اسحب التوقيع الدقيق من وثائق استدلال الذكاء الاصطناعي لإصدارك؛ فهو يعيش في فضاء الأسماء التجريبي inngest.experimental.ai ولم يُمارَس في بناء هذه الدورة.
تحقّق سريع. صحيح أم خطأ. (أ) في Python، تغليف تشغيل وكيلك في
ctx.step.run("run-agent", run_support_agent, ...)يجعله متيناً، ومُعاد المحاولة عند الإخفاقات العابرة، ومتذكَّراً عند النجاح. (ب)step.ai.inferمتطلّب صلب لاستخدام Inngest مع OpenAI Agents SDK في Python. (ج) استبدالstep.runبstep.ai.inferلاستدعاء OpenAI واحد سيجعل الدالة دائماً أرخص تشغيلاً.
الأجوبة: (أ) صحيح: هذا هو نمط Python المُوصى به. تشغيل الوكيل يدخل جسم الخطوة؛ ويعامل Inngest الخطوة كاملةً بصفتها وحدة العمل. (ب) خطأ: step.run يكفي لمعظم الحالات. وstep.ai.infer تحسين لكلفة الحوسبة بلا خادم، لا متطلّب. وتكامل OpenAI Agents SDK في المثال المشروح يستخدم step.run العادي. (ج) خطأ: step.ai.infer يوفّر مالاً فقط حين (1) تكون على منصّة بلا خادم تحاسب على الوقت في الطريق و(2) يكون الاستدعاء طويلاً بما يكفي بحيث تغلب وفورات تفريغ-الطلب الحملَ الزائد للتنسيق المضاف. وللاستدعاءات دون الثانية على خوادم دائمة التشغيل، يفوز step.run العادي.جرّب مع الذكاء الاصطناعي
With my AI coding assistant: take a customer-support agent
invocation and produce TWO versions of the Inngest function that
calls it:
Version A: The normal pattern. Wrap the Runner.run call (the whole
agent run) in step.run: durable, retried on transient failures,
memoized, with the standard step trace.
Version B: The niche exception, for comparison. step.ai.infer can
only offload ONE model call, not a whole agent, so write a SEPARATE
small function that makes a single direct OpenAI completion via
step.ai.infer (the Python-supported primitive that hands that one
call to Inngest's infrastructure to save serverless compute cost).
This is the one place you call the model directly instead of letting
the agent do it.
For each version, explain (a) what the dashboard trace shows for a
successful run, (b) what happens when the OpenAI call hits a 429
rate limit, and (c) on which kind of deployment (always-on server
vs serverless) Version B's offload saves real money.
الجزء 3: التوازن والتعافي، نطاق الإنتاج
أوصل الجزآن 1 و2 عاملك إلى التشغيل والنجاة من التعطُّل. والجزء 3 عن تشغيله على نطاق حقيقي: إبقاء عامل واحد مشغول من إغراق كل ما حوله، والتعافي بسرعة حين يخطئ شيء بالجملة. المفاهيم الخمسة، بعبارات بسيطة:
- Concurrency وThrottling (المفهوم 11): حُدّ كم تشغيلاً يحدث دفعةً واحدة، وبأي سرعة تبدأ الجديدة، كي لا يفتح طوفان من الأحداث ألف اتصال قاعدة بيانات أو يتجاوز حدّ معدّل OpenAI في ثانية واحدة.
- الأولوية والعدالة (المفهوم 12): تأكّد أن عميلاً واحداً يرسل 500 بريد لا يدفع الجميع إلى مؤخّرة الصفّ.
- Batching (المفهوم 13): عالِج 10,000 حدث بنحو 100 تشغيل مجمَّع بدل 10,000 منفصل.
- Replay والإلغاء (المفهوم 14): بعد نشر سيّئ، أعِد تشغيل التشغيلات التي فشلت، على الكود المُصلَح؛ أو ألغِ عملاً لم تعد تريد حدوثه.
- بوابات الموافقة البشرية (المفهوم 15): أوقِف الوكيل وانتظر شخصاً قبل إجراء عالي المخاطر، كردّ مال كبير.
معاً تحوّل عاملاً يعمل إلى واحد تستطيع وضعه بأمان أمام عملاء دافعين.
المفهوم 11: Concurrency وThrottling
نموذجك الأوّلي يعالج بضع رسائل في الدقيقة وهو بخير. ثم صباح مزدحم يرسل 1,000 دفعةً واحدة، فيحاول عاملك تشغيل ال1,000 كلّها في الوقت نفسه، فيفتح 1,000 استدعاء OpenAI و1,000 اتصال قاعدة بيانات في اللحظة نفسها، مستنفداً كليهما. هذه أشيع فجوة بين نموذج أوّلي وإنتاج، والعلاج حدّان صغيران، سطر لكلٍّ منهما:
- Concurrency كم تشغيلاً يجوز أن يُنفَّذ في الوقت نفسه.
- Throttling بأي سرعة يُسمَح للتشغيلات الجديدة أن تبدأ.
from datetime import timedelta
@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
concurrency=[inngest.Concurrency(limit=10)],
throttle=inngest.Throttle(limit=100, period=timedelta(minutes=1)),
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
...
concurrency=10 يقول: على الأكثر 10 من هذه الدوال تعمل في أي لحظة. والحدث الحادي عشر ينتظر في الطابور حتى ينتهي أحد ال10. وthrottle=100/minute يقول: على الأكثر 100 تشغيل جديد يبدأ في الدقيقة. والحدث المائة والأول ينتظر حتى لو كان هناك فسحة في concurrency.
لماذا تريد كليهما عادةً. Concurrency يحمي أنظمتك التالية من كثرة الاستدعاءات دفعةً واحدة (مشكلة ال1,000 اتصال أعلاه). وThrottle يحميها من دفقة: إن هبط 500 بريد عند 9:00 بالضبط، فلا تريد 500 تشغيل تبدأ في الثانية نفسها، حتى لو كان لديك فسحة concurrency؛ وthrottle يوزّع البدايات.
الجزء الدقيق، وسبب أن حدّ concurrency وحده ليس كافياً دائماً: concurrency يحدّ كم تشغيلاً في الطريق، لا بأي سرعة تبدأ الجديدة. فإن كانت تشغيلاتك سريعة، تملأ فتحة محرّرة في اللحظة التي ينتهي فيها أحدها. فconcurrency=10 ما يزال يستطيع إطلاق مئات البدايات في الثانية، أكثر من كافٍ لتجاوز حدّ «30 طلباً في الدقيقة» رغم أن 10 فقط تعمل في أي وقت. فطابِق المقبض بالحدّ الذي تحميه: حدّ عدد (مجمّع اتصالات قاعدة بيانات من 20) يريد concurrency؛ وحدّ معدّل (30 من OpenAI في الدقيقة) يريد throttle. وحين تكون التشغيلات بطيئة، يحدّ concurrency المعدّل أيضاً كأثر جانبي وقد لا تحتاج throttle؛ وحين تكون التشغيلات سريعة، throttle وحده يمسك المعدّل.
Concurrency لكل مفتاح. حدّ concurrency واحد ينطبق على الدالة عالمياً. والنمط الأكثر إثارة هو concurrency لكل مفتاح: حُدّ بخاصية ما للحدث. تمرّر قائمة من الحدود بدل واحد:
concurrency=[
inngest.Concurrency(limit=10), # global cap
inngest.Concurrency(limit=2, key="event.data.customer_id"), # per-customer cap
],
هذا يقول: على الأكثر 10 دوال تعمل عالمياً، وعلى الأكثر 2 لكل عميل في كل وقت. فإن أرسل عميل واحد 100 بريد في الدقيقة، يُعالَج 2 فقط من رسائله متزامناً؛ وال98 الأخرى تنتظر خلفها. وفي الوقت نفسه، رسائل العملاء الآخرين تتدفّق عادياً؛ فهم غير محجوبين بالعميل الثرثار. هذه عدالة متعدّدة المستأجرين في سطرين من الكود. والمفهوم 12 يطوّر النمط أبعد.
تصوّر السياسة كلّها تحت دفقة 9 صباحاً: throttle يبطّئ سرعة بدء التشغيلات، وحدّ concurrency يمسك كم منها يعمل دفعةً واحدة، ومفتاح لكل عميل يبقي طوفاناً واحداً من أخذ كل فتحة، بينما ينتظر كل شيء آخر في طابور متين.
لا شيء يُسقَط؛ العمل ينتظر في الطابور. ثلاثة مقابض تقرّر ما يعمل، وبأي سرعة يبدأ، ومن ينتظر.
تحقّق سريع. ثلاث ادّعاءات، صحيح أم خطأ. (أ) إن ضبطت
concurrency=10ووصل 1,000 حدث دفعةً واحدة، يُسقَط 990 منها. (ب) Throttling وحدود concurrency كلاهما يقلّل الإنتاجية الكلّية. (ج) Concurrency لكل مفتاح يتطلّب مفتاحاً حتمياً من بيانات الحدث.
الأجوبة: (أ) خطأ: الأحداث لا تُسقَط؛ بل تنتظر في الطابور. وطابور Inngest متين؛ وال990 حدثاً ينتظر حتى تنفتح فتحات concurrency. (ب) خطأ. Throttling يحدّ معدّل-البدء؛ وconcurrency يحدّ التشغيلات في الطريق. ولا أحد منهما يُسقط عملاً؛ كلاهما يشكّل متى يُنفَّذ العمل. والإنتاجية عبر نافذة طويلة لا تتغيّر إن كان حملك المتوسّط دون الحدود. والإنتاجية عبر ذروة تُشكَّل: الدفقات يستوعبها الطابور. (ج) صحيح: تعبير المفتاح يُقيَّم على بيانات الحدث؛ وعليه أن ينتج سلسلة ثابتة للنطاق المنطقي نفسه (customer_id مقبول؛ وcurrent_timestamp ليس كذلك).جرّب مع الذكاء الاصطناعي
With my AI coding assistant: design the concurrency and throttling
policy for the customer-support worker. Constraints:
- OpenAI rate limit: 30 requests per minute, hard cap.
- Postgres connection pool: 20 max connections (the worker takes 1 per run).
- Some customers send bursts of 30+ emails in a minute (an angry
customer); these shouldn't starve other customers.
- We expect ~1,000 emails per day, with peaks around 9am and 2pm.
Propose:
1. A global concurrency value
2. A per-customer concurrency value
3. A throttle (limit and period)
For each, explain what production failure it protects against and
what the cost is (in queue latency at peak).
المفهوم 12: الأولوية والعدالة، القياس متعدّد المستأجرين
حدود concurrency تنفع. وconcurrency لكل مفتاح يضيف عدالة أساسية. أمّا الأنظمة متعدّدة المستأجرين بدرجة-إنتاج فتحتاج أكثر: الأولويات (عملاء Enterprise لا ينبغي أن ينتظروا خلف الهواة على الحوسبة نفسها) والجدولة بالحصّة العادلة (لا مستأجر واحد يستطيع احتكار النظام حتى ضمن حدّ concurrency الخاص به).
الأولوية. يقيّم Inngest تعبير أولوية على كل حدث؛ والتشغيلات ذات الأولوية الأعلى تقفز الطابور أمام التشغيلات ذات الأولوية الأدنى. إنها وسيط آخر على create_function نفسها من المفهوم 11:
priority=inngest.Priority(
# Higher number wins (range -600..600). The producer puts the tier's
# priority on the event directly: Enterprise = 100, Pro = 0, Free = -100.
run="event.data.tier_priority",
),
حين يكون لطابور concurrency 50 تشغيلاً منتظراً، تذهب تشغيلات عملاء Enterprise أولاً، ثم Pro، ثم Free. وداخل الطبقة نفسها، ينطبق ترتيب FIFO. والأولوية لا تتجاوز حدود concurrency أو throttle؛ بل تقرّر فقط أيّ التشغيلات المنتظرة يحصل على الفتحة الحرّة التالية. وعميل Enterprise ما يزال ينتظر فتحة تنفتح؛ فقط يحصل على التالية.
الجدولة بالحصّة العادلة. حين يكون لديك مئات المستأجرين يتنافسون على مجمّع concurrency العالمي نفسه، FIFO زائد الأولوية لا يكفي. مستأجر واحد يرسل دفقة ما يزال يستطيع شغل معظم الفتحات لدقائق. والجدولة بالحصّة العادلة، المنفَّذة عبر معامل key على concurrency بحجم مدروس، تمنح كل مستأجر شريحة مضمونة:
concurrency=[
inngest.Concurrency(limit=50), # global pool
inngest.Concurrency(limit=3, key="event.data.tenant_id"), # max 3 per tenant
],
مع هذا: 50 فتحة كلّية، ولا مستأجر يأخذ أكثر من 3. فإن كان 20 مستأجراً نشطين، فذلك على الأكثر 60 فتحة مطلوبة لكن 50 فقط متاحة. والحصّة العادلة تدوّرهم، فيحصل كل مستأجر على بعض الحصّة، ولا أحد يُقصى.
تنبّأ. لديك دالة دعم عملاء ب
concurrency=10وconcurrency لكل عميل من 2. ولديك أيضاً أولوية مضبوطة: Enterprise = عالية، Free = منخفضة. عند 9:00 صباحاً، الطابور به: 5 أحداث من العميل A (Free)، و5 أحداث من العميل B (Enterprise)، و10 أحداث من عميل جديد واحد C (Free، اشترى أول خطّة له للتوّ). بأي ترتيب تُنفَّذ؟ الثقة 1-5.
الجواب: يُحلّ في ثلاثة تمريرات، بهذا الترتيب.
1. per-customer cap (2 each) -> eligible pool = 2 from A, 2 from B, 2 from C (6 runs)
2. priority sorts the pool -> B's 2 first (Enterprise), then A's 2 and C's 2 (Free, FIFO)
3. fill the 10 global slots -> all 6 fit, so 6 run now; the rest wait
مع انتهاء كل تشغيل، يصير حدث ذلك العميل المنتظِر التالي مؤهَّلاً (التمريرة 1)، وتذهب الفتحة الحرّة التالية إلى المنتظِر الأعلى أولويةً (التمريرة 2). وحدّ لكل عميل هو ما يمنع أحداث العميل C العشرة من أخذ الطابور كلّه.
التحكّم في التدفّق هو المكان الوحيد في هذه الدورة الذي لا يصمد فيه «شغّله وراقب» تماماً. من المقابض الأربعة في المفهومين 11 و12، concurrency وحده قابل للملاحظة على خادم التطوير المحلي: أرسل دفقة فترى N فقط تعمل دفعةً واحدة. والثلاثة الأخرى تضبطها وتفكّر فيها محلياً، ثم تؤكّد الأثر في Inngest Cloud (أو نشر فرع):
- Throttle حدّ معدّل لا يفرضه خادم التطوير، فمحلياً تبدأ تشغيلاتك بأسرع ما يمكن، بصرف النظر عن الحدّ. والإعداد صحيح؛ والمعدّل يعضّ في Cloud فقط.
- الأولوية والحصّة العادلة تظهران فقط تحت تنافس متعدّد-المستأجرين مستدام، طابور ممتلئ بمستأجرين كثيرين يتنافسون. وحفنة من أحداث الاختبار لا تنشئ ذلك أبداً، فتبقى غير مرئية محلياً حتى لو ضُبِطت صحيحاً.
فلهذه الثلاثة، «تمّ التحقّق» يعني أن الإعداد مقبول والدالة تعمل، وتستطيع التفكير في السلوك. لا تستنتج «لا شيء يُفرَض» من خادم تطوير هادئ؛ أكّد الأثر الحقيقي تحت الحمل في Cloud.
جرّب مع الذكاء الاصطناعي
With my AI coding assistant: extend the customer-support worker
configuration with a priority and fair-share scheme. Requirements:
1. Three customer tiers: Enterprise, Pro, Free.
2. Enterprise customers should never wait more than 5 seconds at
peak load.
3. Free tier customers should get fair access: no Free customer
should be starved for more than 60 seconds, even when the
global queue is full.
4. A single noisy customer (regardless of tier) should not occupy
more than 3 slots.
Write the concurrency + priority configuration. For each line of
config, explain which requirement it satisfies.
المفهوم 13: Batching، المعالجة بالجملة الفعّالة من حيث الكلفة
بعض العمل مجمَّع بطبيعته. أنت لا تلخّص كلاً من 10,000 محادثة عملاء باستقلال؛ بل تستدعي LLM بدفعة من 50 في كل مرة. وأنت لا تكتب 10,000 صف تدقيق واحداً واحداً؛ بل تكتبها في إدراج بالجملة واحد. ومحفّز الدفعة في Inngest يتيح لك مراكمة الأحداث واستدعاء دالة واحدة بالدفعة مدخلاً.
@inngest_client.create_function(
fn_id="batch-embed-tickets",
trigger=inngest.TriggerEvent(event="ticket/resolved"),
batch_events=inngest.Batch(
max_size=50, # invoke when 50 events accumulated, OR
timeout=timedelta(seconds=30), # invoke when 30 seconds pass, whichever first
),
)
async def batch_embed_resolved_tickets(ctx: inngest.Context) -> dict[str, int]:
# ctx.events (plural) instead of ctx.event
ticket_ids = [e.data["ticket_id"] for e in ctx.events]
tickets = await ctx.step.run(
"load-tickets", load_tickets_by_ids, ticket_ids,
)
# One embedding call for 50 tickets, not 50 calls for 1 ticket each
embeddings = await ctx.step.run(
"embed-batch", embed_texts_batch,
[t["text"] for t in tickets],
)
await ctx.step.run(
"store-embeddings", store_embeddings_batch,
ticket_ids, embeddings,
)
return {"batched": len(ctx.events)}
ما يتغيّر: ctx.events قائمة، لا حدثاً واحداً. والدالة تعمل مرة لكل دفعة بدل مرة لكل حدث. وواجهة تضمين OpenAI تُستدعى بدفعة من 50 نصاً بدل 50 استدعاء أحادي النص، وهو أرخص بكثير (تدفع لكل رمز، لكنّ الحمل الزائد لكل طلب ذهب) وأسرع (جولة API واحدة بدل 50).
Batching هو الأداة الصحيحة حين يكون العمل قابلاً للتجميع بطبيعته (تضمينات، وكتابات قاعدة بيانات بالجملة، ورسائل بالجملة) وتستطيع تحمّل قدر مهلتك من الكمون قبل أن يحدث العمل. وهو الأداة الخاطئة حين يتطلّب كل حدث استجابة تفاعلية أو حين يهمّ الترتيب عبر الأحداث بطرق لا يمكن التنبّؤ بها.
تحقّق سريع. صحيح أم خطأ. (أ) الدوال المجمَّعة ما تزال تحصل على إعادات محاولة وتذكّر؛ والدفعة ككلّ متذكَّرة بمتانة. (ب) إن انتهت مهلة الدفعة ب3 أحداث فقط متراكمة، فلن تعمل الدالة حتى تصل ال47 التالية. (ج) تستطيع جمع
batch_eventsمعconcurrencyلتحدّ كم دفعةً تعمل بالتوازي.
الأجوبة: (أ) صحيح: الدفعة هي وحدة العمل؛ وإعادات المحاولة تعيد الدفعة كلّها بكل أحداثها ما تزال في النطاق. (ب) خطأ: ذلك هو المقصد كلّه من المهلة. بعد 30 ثانية تعمل الدالة بما تراكم، حتى لو كان حدثاً واحداً. (ج) صحيح: هذا هو نمط الإنتاج. الدفعة زائد concurrency معاً يحدّان حملك التالي بأناقة.جرّب مع الذكاء الاصطناعي
With my AI coding assistant: write a batched Inngest function that
embeds resolved support tickets, converting a per-ticket event
handler into one batched call.
Triggers: 'ticket/resolved' event, batched at 50 events or 30 seconds.
The function should:
1. Load the ticket bodies in one query
2. Call OpenAI embeddings API with a 50-text batch (faster + cheaper)
3. Store the embeddings
4. Emit a 'ticket/embedded' event per ticket for downstream consumers
Use grep_docs to find the OpenAI batch-embedding pattern.
المفهوم 14: Replay والإلغاء بالجملة، التعافي في الإنتاج
أحياناً يخطئ كل شيء دفعةً واحدة. شحنت عطلاً؛ ففشل ألف تشغيل في الساعات الست الماضية. أو كانت واجهتك التالية معطّلة 30 دقيقة؛ فمات كل ما حاول استدعاءها خلال تلك النافذة. أو اكتشفت خطأ منطقياً وتريد إعادة عمل يوم بعد إصلاحه.
أولاً، التمييز الذي يعثر فيه الجميع. يمنحك Inngest طريقتين يمكن أن تُعاد بهما خطوة فاشلة، وتتصرّفان بشكل مختلف:
- إعادة المحاولة التلقائية (داخل التشغيل نفسه). حين تطلق خطوة استثناءً، يعيد Inngest محاولة الدالة بتراجع، معيداً الدخول من الأعلى. والخطوات المكتملة تعود من التذكّر ولا تُعاد؛ فقط الخطوة الفاشلة تعمل ثانيةً. هذا هو الاستئناف الحافظ للتذكّر، الذي شاهدته في المكسب السريع، والذي يجعل خاصية «0.20$ المصروفة عند الخطوة 3 لا تُصرَف ثانيةً» صحيحة. وهو تلقائي ويحدث داخل التشغيل الأصلي.
- Replay / Rerun (زرّ لوحة التحكّم، عبر تشغيلات كثيرة). هذا يبدأ تشغيلاً جديداً تماماً من الأعلى بكودك المنشور الحالي، وكل خطوة تُعاد من الصفر (إعادة التشغيل تحصل على معرّف تشغيل جديد وتعيد تشغيل الخطوة الأولى، لا استئنافاً للقديم). فعملياً، تذكّر التشغيل القديم لا ينقذك هنا. هو للتعافي من الحوادث، لا لتخطّي العمل المكتمل.
إبقاء هذين واضحين هو المفهوم كلّه. مكافأة التذكّر تعيش في إعادة المحاولة التلقائية؛ وReplay بداية جديدة. والصفّان أدناه هما الخطوات الخمس نفسها تحت كل مسار:
التذكّر يحميك داخل التشغيل؛ ومفتاح idempotency، لا التذكّر، يحميك عبر إعادات التشغيل.
بدائيتان تعافٍ متعاكستان. Replay يقول «هذا العمل فشل، أريده أن يعمل ثانيةً على الكود المُصلَح». والإلغاء بالجملة يقول «هذا العمل وُضِع في الطابور لكنّي لم أعد أريد حدوثه». سطح لوحة التحكّم نفسه، نيّة متعاكسة. ومعظم الفرق تحتاج كليهما خلال أول ثلاثة أشهر من تشغيل حركة حقيقية.
Replay هو بدائية التعافي. التشغيلات الفاشلة تبقى بتاريخ خطواتها الكامل، وحدث المدخل، والاستثناء من الخطوة الفاشلة. من لوحة التحكّم تفتح عرض Functions، وترشّح إلى دالة لها تشغيلات فاشلة، وتختار نافذة زمنية ونمط فشل (أي رسالة خطأ محدّدة أو مجرد «كل الإخفاقات»)، وتنقر Replay. ويجدول Inngest كلاً منها بصفته تشغيلاً جديداً من الأعلى على أيّ كود منشور الآن.
ثلاثة أشياء تفهمها عن replay.
- Replay يستخدم كودك المنشور الحالي. إن نشرت إصلاحاً بين وقت فشل التشغيلات ووقت إعادة تشغيلها، تستخدم التشغيلات المُعادة الكود الجديد. هذا هو المقصد كلّه: خذ مجموعة من التشغيلات التي ماتت على عطل، واشحن الإصلاح، وأعِد تشغيلها كلّها بلا تدخّل.
- Replay يعيد تنفيذ كل خطوة؛ ولا يعيد استخدام تذكّر التشغيل القديم. التشغيل المُعاد تشغيل جديد، فكل خطوة تعمل ثانيةً من الصفر على الكود المُصلَح. من حيث الكلفة، خطّط لكلفة الدالة كلّها لكل تشغيل مُعاد، لا الخطوة الفاشلة فقط. والشيء الذي يمنع replay من إصدار أثر جانبي واقعي ثانٍ (ردّ مال مكرّر، بريد مكرّر) ليس التذكّر، بل مفتاح idempotency على ذلك الأثر الجانبي (المفهوم 4): تشتقّ مفتاحاً ثابتاً من الطلب (لردّ مال، شيء مثل
(order_id, request_id)) ويعامل المزوّد التكرار بلا أثر. والعامل الأدنى في هذه الدورة يحذف ذلك المفتاح اختصاراً، فردّ ماله يطابق على العميل ويكتب دون شرط، فنسخة إنتاج كانت ستضيف واحداً قبل أن يتحرّك أي مال حقيقي. - Replay اختياري. التشغيلات الفاشلة تجلس في لوحة التحكّم حتى تتصرّف بشأنها. لا تعيد المحاولة إلى الأبد؛ ولا تختفي. تنتظرك.
الإلغاء بالجملة هو العكس. أحياناً يكون لديك آلاف التشغيلات المنتظِرة في الطابور أو النائمة التي لم تعد تريدها: حملة أُلغِيت، أو عميل غادر ولم تعد تريد إرسال رسائل متابعة له، أو ميزة أُعيدت. من لوحة التحكّم تختار دالة ونافذة زمنية أو مرشّح حدث، وتنقر Cancel. والتشغيلات المطابِقة تنتهي بنظافة: استدعاءاتها step.sleep وstep.wait_for_event لا تستأنف، والتشغيلات المنتظِرة لا تبدأ، والتشغيلات في الطريق تتحقّق من الإلغاء وتخرج عند حدّ الخطوة التالي. والإلغاء يحترم حدّ الخطوة؛ فstep.run في الطريق ينهي الخطوة التي هو فيها قبل أن ينتهي، فلا تحصل على تحميلات Stripe نصف-مكتملة أو كتابات قاعدة بيانات ممزّقة.
Replay مقابل الإلغاء بصفته قراراً. حين يخطئ شيء بمجموعة من التشغيلات، اسأل سؤالاً واحداً: هل أريد لهذا العمل أن ينجح أم أريده ألّا يحدث؟ إن كان العمل ينبغي أن ينجح (تعافٍ من إصلاح عطل)، فأعِد التشغيل. وإن كان العمل لا ينبغي أن يحدث (حملة ملغاة، عميل غادر، ميزة أُعيدت)، فألغِ. وإن كنت غير متأكّد (مثلاً، التشغيلات الفاشلة تتضمّن بعضاً تريد استرداده وبعضاً ما كان ينبغي أن يُطلق أصلاً)، فرشّح استعلام لوحة تحكّمك أضيق فيحصل كل مجموعة فرعية على المعالجة الصحيحة.
ثلاثة أنماط يتيحها هذا عملياً:
- تعافي «شحنّا عطلاً». اعثر على التشغيلات الفاشلة في النافذة الزمنية للنشر السيّئ، وأصلح العطل، واشحن الإصلاح، وأعِد تشغيل الإخفاقات. تجربة العميل: بريده لم يحصل على ردّ ساعةً لكنه حصل أخيراً، دون أن تكتب أي كود تعافٍ.
- استرجاع «الحملة ملغاة». سلسلة ترحيب تُطلق ثلاث رسائل متابعة عبر 14 يوماً؛ والعميل يغادر في اليوم 4. ولا تريد إرسال متابعتَي اليوم 7 واليوم 14. ألغِ بالجملة تشغيلات
wait-for-eventوsleepالمطابِقة. - replay «ترحيل المخطّط». غيّرت كيف يصوغ الوكيل الملخّصات؛ وتريد إعادة تلخيص تذاكر الأمس بالصيغة الجديدة. اعثر على تلك التشغيلات (ناجحة أو لا) وأعِد تشغيلها؛ ولأن replay تشغيل جديد من الأعلى، يعيد الوكيل تشغيل كل خطوة على الكود الجديد، وهو بالضبط ما تريده هنا. أبقِ خطواتك ذات الأثر الجانبي idempotent كي لا تُحمّل أو تُرسِل مرّتين بإعادة تشغيلها.
MCP لخادم التطوير يجعل التعافي متاحاً دون مغادرة وكيلك العام. أثناء التطوير تستطيع أن تطلب من الذكاء الاصطناعي استخدام get_run_status لفحص تشغيل فاشل، ثم تعافي العمل بإعادة إطلاق الحدث على الكود المُصلَح (أعطِه معرّف حدث جديداً، لأن إعادة الإطلاق بالمعرّف نفسه تُزال تكرارها إلى بلا-أثر بدلالات idempotency في المفهوم 4). وزرّ Rerun في لوحة التحكّم هو المسار المكافئ بنقرة واحدة. في كلتا الحالتين تحصل على تشغيل جديد على الكود الحالي، لا استئنافاً حافظاً للتذكّر.
تحقّق سريع. صحيح أم خطأ. (أ) Replay في لوحة التحكّم يعيد تشغيل العمل على الكود المنشور الجديد. (ب) Replay في لوحة التحكّم يعيد خطوات التشغيل الأصلي الناجحة من التذكّر ويعيد الفاشلة فقط. (ج) إعادة المحاولة التلقائية داخل تشغيل فاشل تعيد الخطوات المكتملة من التذكّر وتعيد الخطوة الفاشلة فقط. (د) الإلغاء بالجملة لدالة في الطريق سيجهض
step.runالمُنفَّذ حالياً في منتصف الخطوة لينتهي أسرع.
الأجوبة: (أ) صحيح: replay تشغيل جديد من الأعلى على أيّ كود منشور الآن، ولهذا هو أداة التعافي من إصلاح العطل. (ب) خطأ: هذا هو الفخّ. replay تشغيل جديد يعيد تنفيذ كل خطوة من الأعلى، فتذكّر التشغيل القديم لا ينتقل. وما يمنع أثراً جانبياً مُعاداً من الإطلاق مرّتين هو مفتاح idempotency، لا التذكّر. (ج) صحيح: هذا هو المسار الحافظ للتذكّر، وهو الذي شاهدته في المكسب السريع. الخطوة المكتملة تجلس عند محاولة واحدة بينما تعيد الخطوة الفاشلة المحاولة. (د) خطأ: الإلغاء يحترم حدّ الخطوة؛ وstep.run الحالي ينهي (أو يفشل) قبل أن ينتهي التشغيل. وهذا يمنع الكتابات الممزّقة.جرّب مع الذكاء الاصطناعي
Walk through a recovery scenario with my AI coding assistant:
Yesterday at 14:00 we deployed a change to the worker's agent step.
A bug in the new code made the agent step throw on every run.
From 14:00 to 18:00, 47 customer-support runs failed at that step.
At 18:30 we noticed, fixed the bug, and re-deployed.
Use the dev-server MCP's grep_docs to find Inngest's replay docs,
then:
1. Outline the exact dashboard steps to identify the 47 failed runs.
2. Explain what a dashboard Replay does for one of those runs: is it
a fresh run from the top on the fixed code, or a resume that
reuses the old run's memo? What does that mean for the cost of
replaying all 47?
3. Confirm whether the customers will see one reply or several if a
replayed run re-sends the email, and name the mechanism that
keeps it to one (hint: it is not memo).
4. Identify ONE scenario in this story where you'd prefer to
bulk-cancel instead of replay, and explain why.
المفهوم 15: بوابات HITL مع step.wait_for_event، الثابت 1 في وقت التشغيل
بعض الإجراءات أهمّ من أن تترك الوكيل يتّخذها وحده. إصدار ردّ مال 500$، أو إرسال إشعار قانوني، أو إغلاق حساب: تريد أن يحقّق الوكيل ويقترح الإجراء، لكن أن يوافق شخص قبل أن يحدث فعلاً. ذلك التوقّف لإنسان هو بوابة موافقة، وهو المكان الوحيد في هذا النظام كلّه الذي يتوقّف فيه العامل وينتظر شخصاً. (بمصطلحات Agent Factory هذا هو الثابت 1، الإنسان هو صاحب القرار: في قرار عالي المخاطر، قرار الشخص هو ما يُنفَّذ، لا قرار الوكيل.)
step.wait_for_event في Inngest (المفهوم 8) يجعل هذا نظيفاً. الوكيل يعمل حتى نقطة القرار، ثم يتوقّف وينتظر حدث موافقة. وإنسان يراجعه (في Slack، أو واجهة إدارة، أو بريد) وينقر موافقة أو رفض؛ وتلك النقرة تُطلق الحدث، فتستيقظ الدالة بالحكم، فتعمل. وكودك يتحكّم في ما يُسمَح للوكيل بفعله، لا كيف يستدلّ.
@inngest_client.create_function(
fn_id="refund-with-hitl-gate",
trigger=inngest.TriggerEvent(event="customer/refund.investigated"),
concurrency=[inngest.Concurrency(limit=5)],
)
async def refund_with_gate(ctx: inngest.Context) -> dict[str, str]:
request_id = ctx.event.data["request_id"]
amount_cents = ctx.event.data["amount_cents"]
# Step 1: the agent's analysis (your worker, run durably).
# Keyword-arg calls are wrapped in a lambda; step.run forwards only positional args.
analysis = await ctx.step.run(
"agent-investigates",
lambda: run_refund_investigation_agent(request_id=request_id),
)
# Step 2: if the agent thinks refund is warranted AND amount > $100,
# gate behind human approval
needs_approval = analysis.recommends_refund and amount_cents >= 10_000
if needs_approval:
await ctx.step.run(
"notify-approver",
lambda: send_slack_approval_request(
request_id=request_id,
analysis=analysis,
amount_cents=amount_cents,
),
)
# === THE HITL GATE ===
approval = await ctx.step.wait_for_event(
"wait-for-human-approval",
event="refund/approval.decided",
timeout=timedelta(hours=24),
if_exp=f"async.data.request_id == '{request_id}'",
)
if approval is None:
# Timeout: no human responded in 24h. Escalate.
await ctx.step.run(
"escalate-timeout",
lambda: escalate_to_senior_reviewer(request_id=request_id),
)
return {"status": "escalated_timeout"}
if not approval.data["approved"]:
await ctx.step.run(
"notify-rejected",
lambda: notify_customer_rejected(request_id=request_id),
)
return {"status": "rejected_by_human"}
# Either it was approved, or it didn't need approval
refund = await ctx.step.run(
"issue-refund",
lambda: call_stripe_refund(request_id=request_id, amount_cents=amount_cents),
)
await ctx.step.run(
"audit-approved-refund",
lambda: audit_refund(
request_id=request_id,
refund=refund,
approved_by="human" if needs_approval else "auto",
),
)
return {"status": "issued", "refund_id": refund["id"]}
ما تراه في الكود: تسلسل خطوات، مع wait_for_event واحد في الوسط. وما يحدث في وقت التشغيل:
- الوكيل يعمل (الخطوة 1، بمتانة).
- الدالة تقرّر إن كانت البوابة تنطبق (منطق في الكود، خالٍ من الآثار الجانبية).
- إن كانت مبوَّبة: إخطار Slack يُطلق (الخطوة 2، متينة). والدالة تتوقّف حتى 24 ساعة.
- إنسان في Slack ينقر موافقة أو رفض. والواجهة الخلفية للإدارة تستدعي
inngest_client.sendبrefund/approval.decidedوrequest_id. - Inngest يطابق الحدث مع الدالة المتوقّفة (مرشّح
if_expيضمن أن معرّفات الطلب المطابِقة فقط تطابق). والدالة تستأنف عند السطر التالي. - الدالة تستخدم قرار الإنسان لتصدر ردّ المال أو تُخطِر بالرفض. وكلا المسارين يدقّق القرار والمُوافِق.
هذا ما يجعل Inngest مختلفاً نوعياً عن طابور-زائد-آلة-حالات. نمط HITL بدائية واحدة. وكود الدالة يُقرأ من الأعلى إلى الأسفل، بالبوابة في الموضع. لا استدعاء عكسي، ولا استعادة حالة، ولا توزيع if state == waiting_for_approval: .... وقت التشغيل يعالج آلية التوقّف/الاستئناف؛ وكودك يعبّر عن السياسة.
الوكيل يقترح، وشخص يقرّر، والانتظار لا يكلّف شيئاً.
دورة لاحقة تطوّر الثابت 1 معمارياً: النيّة المؤلَّفة، وسير العمل المعتمد على المواصفات، وطبقة مدير-العمّال التي تقرّر أي بوابات تنطبق على أي إجراءات. وهذه الدورة تمنحك بدائية وقت التشغيل. وحين تصل طبقة المدير تلك، فالبوابة التي تنفّذها ستكون بالضبط نمط wait_for_event هذا، مؤلَّفاً فقط على نطاق الأسطول. ومعرفة البدائية الآن تعني أن النمط المعماري لاحقاً يُقرأ بصفته «تأليفاً عاقلاً» بدل «سحر».
هذا هو حجر الأساس الذي تبنيه في قرار الجزء 4 الخامس: موافقة ردّ المال، صائرةً متينة. والمفهوم هنا هو الشكل؛ والمثال المشروح يوصله بأداة needs_approval حقيقية ويُثبِت أن ردّ المال يُطلق مرة واحدة بالضبط.
تنبّأ. لديك بوابة HITL مضبوطة ب
timeout=timedelta(hours=24). طلب ردّ مال عميل يأتي عند 17:00 يوم جمعة. ولا إنسان متّصل في عطلة نهاية الأسبوع. ومهلة البوابة تُطلق عند 17:00 السبت. ومعالج مهلتك يسجّل ردّ مال محجوباً. والمراجع يقرأ الطلب الإثنين عند 9:00 صباحاً. تتبّع الخطّ الزمني: كم تشغيل دالة كان نشطاً خلال عطلة نهاية الأسبوع؟ وكم حوسبة حاسب عليها Inngest؟ الثقة 1-5.
الجواب: صفر تشغيلات دالة نشطة خلال عطلة نهاية الأسبوع. كانت الدالة متوقّفة: خزّن Inngest حالتها، وأخرج الدالة من الذاكرة، وانتظر إمّا الحدث أو المهلة. وInngest لا يحاسب على الوقت المتوقّف. وحين جاء السبت 17:00 وأُطلقت المهلة، استأنفت الدالة بضع مئات المللي ثانية التي استغرقها كتابة صف تدقيق ردّ المال المحجوب، ثم اكتملت. وكون المراجع لا ينظر حتى الإثنين لا يكلّف شيئاً من جانب العامل. واقتصاد سير عمل HITL على Inngest مختلف جذرياً عن الطوابير المعتمدة على الاستطلاع التي تحاسبك على كل ثانية من استطلاع «هل وُوفِق بعد؟».جرّب مع الذكاء الاصطناعي
With my AI coding assistant: design a durable refund-approval gate.
Specification:
1. The agent investigates and decides a refund is warranted, but the
refund tool needs human approval before it runs.
2. The gate should:
- Notify the on-call reviewer with the agent's recommendation
- Wait up to 4 hours for the reviewer to approve or reject
- On approve: issue the refund.
- On reject: do not issue; record a blocked refund.
- On 4-hour timeout: do not issue; record a blocked refund.
3. Every branch (approve/reject/timeout) writes an audit row from a
small fixed set of action names, capturing what was decided.
Use the dev-server MCP's send_event to simulate each branch of
the reviewer's decision during testing.
الجزء 4: المثال المشروح، عامل دعم عملاء بالذكاء الاصطناعي
هذا هو العمود الفقري للدورة: حيث تبني فعلاً. كل ما سبق هذا كان النموذج والمرجع. من هنا تجمّع العامل الحقيقي. أولاً العامل (تعليمة واحدة)، ثم الجهاز العصبي حوله، طبقةً لكل تعليمة. وكل طبقة تسمّي المفهوم الذي تستند إليه، فإن أثارت طبقة «لماذا»، فذلك المفهوم في الأجزاء 1-3 هو الصفحة لتفتحها. توجّه وكيلك العام بتعليمات قصيرة بإنجليزية بسيطة فيكتب الكود. والمقتطفات المُظهَرة أدناه هي الأسطر القليلة الحاملة لكل طبقة، لا الملفّات. والتنفيذ الكامل شُغِّل من البداية إلى النهاية مقابل خادم تطوير حيّ ونموذج حقيقي، فالأشكال التي تراها هي ما يعمل. وإن بدا توقيع غير مألوف، يراجع وكيلك الوثائق الحالية.
التدفّق الكامل الذي أنت على وشك بنائه، بريد واحد من البداية إلى النهاية:
a customer emails
|
v
the INNGEST ENGINE catches the event and drives your worker,
one step at a time, storing each result as it goes:
1. audit: "message received"
2. load the customer from Neon
3. YOUR AGENT drafts a reply (the thinking part; D1 makes it durable)
4. is it a refund? PAUSE for a human (waits hours, survives crashes; D5)
5. on approve: issue the refund; on reject: record it
6. audit: "reply sent"
if a step crashes, the engine re-runs only that step, never the
finished ones (D6). the same worker also wakes on a daily cron
and runs under flow-control caps (D3, D4).
صورة البرنامجين نفسها من الافتتاحية، المحرك يقود وكيلك، الآن العامل الحقيقي. تبنيه طبقةً في كل مرة:
الشكل: سبع تعليمات، على القاعدة التي هيّأتها أصلاً.
- D0 يبني العامل نفسه، قائماً بذاته.
- D1 يجعل تشغيل الوكيل متيناً.
- D2 يتيح لحدث أن يوقظه.
- D3 يضيف cron يومياً يتفرّع.
- D4 يضيف التحكّم في التدفّق.
- D5 هو حجر الأساس: بوابة موافقة بشرية متينة على ردود الأموال.
- D6 يُثبِت أن العامل ينجو من خطوة مكسورة: إعادة محاولة دون إعادة العمل المكتمل، ثم تعافٍ.
الوكيل لا يتغيّر بعد D0؛ كل طبقة هي الجهاز العصبي، مضافة من الخارج.
قبل أن تبدأ. بيئتك مهيّأة أصلاً من المكسب السريع: افتح مجلّد
ai-agent-nervous-systemنفسه، بمهارات Inngest وneon-postgresمثبَّتة، ومفتاحكOPENAI_API_KEYوDATABASE_URLالخاص بNeon في.env، وجدولاcustomersوaudit_logمزوَّدان، وخوادم MCP الثلاثة (Neon، وContext7، وinngest-dev) موصولة. تذكيران فقط:
- خادم التطوير يعمل. شغّله ثانيةً إن أغلقته:
npx inngest-cli@latest devفي وحدته الطرفية الخاصة. ولوحة التحكّم علىhttp://127.0.0.1:8288. (حين تنشر لاحقاً إلى Inngest Cloud، طبقة Hobby المجانية ب0$ بلا بطاقة ائتمان؛ وسقوفها في الجزء 5.)- ملاحظة حروف واحدة لاستدعاءات MCP أدناه. أسماء أدوات خادم التطوير
snake_case(send_event، وget_run_status، وinvoke_function)، لكنّ معاملاتهاcamelCase(get_run_statusيأخذrunId، وinvoke_functionيأخذfunctionId). والSDK لPythonsnake_caseكلّه؛ فقط معاملات استدعاء MCPcamelCase.
الملخّص
تبني عامل دعم عملاء صغيراً وتمنحه جهازاً عصبياً. العامل يقرأ عملاءه النموذجيين من جدول Neon customers (id، email، tier)، ويصوغ رداً دافئاً على بريد وارد، ويستطيع إصدار ردّ مال بموافقة بشرية فقط، ويكتب صف تدقيق في جدول Neon audit_log لكل إجراء، من مجموعة صغيرة ثابتة من أسماء الإجراءات يختار منها (مجموعة مغلقة، فيصير الخطأ المطبعي خطأ صاخباً بدل صف سيّئ صامت). ثم تضيف التعليمات السبع Inngest حوله: حدث يوقظه، واستدعاء الوكيل يعمل بمتانة، وcron يومي يتفرّع إلى فحص صحّة لكل عميل مؤهَّل، والتحكّم في التدفّق يحدّ concurrency وthrottle، وردّ المال يتوقّف على بوابة بشرية متينة، ومسار replay يتعافى من التشغيلات الفاشلة.
ملاحظة عن التعليمات التالية. كلٌّ منها مكتوبة بالطريقة التي ستقولها بها فعلاً لوكيل عام: قصيرة، وبسيطة، واثقة به أن يتولّى التفصيل. تعمل ملصوقةً باردة، وأفضل بعد لو طلبت من الوكيل أولاً أن يتوجّه («اقرأ المشروع وأخبرني بما تراه، ثم اسألني عن أي شيء غامض قبل أن تبدأ») مع تراكم الملفّات. التعليمات هي الوجهة؛ والتوجّه أولاً هو المنحدر إليها.
D0: ابنِ العامل، قائماً بذاته
أين أنت: القاعدة مفتوحة، وخادم التطوير يعمل، ومتجر Neon مزوَّد، لكن لا عامل موجود بعد. هذا القرار يبني العامل القائم بذاته؛ وبنهايته يعمل على بريد نموذجي ويكتب صف تدقيق إلى Neon.
القاعدة تشحن أصلاً AGENTS.md قرأه وكيلك عند الفتح، فهو يعرف المشروع. ولذلك تبقى هذه التعليمات قصيرة. والقاعدة الوحيدة فيه الجديرة بإبقائها في رأسك هي الثابت المعماري للدورة كلّها: كود العامل نفسه لا يستورد أبداً من inngest. الوكيل وأدواته يبقيان Python عادياً؛ والجهاز العصبي يغلّفهما من الخارج. ذلك الفصل، إبقاء الوكيل والجهاز العصبي متفرّقين، هو ما يتيح لك استبدال Inngest بTemporal أو Restate لاحقاً وترك العامل دون مساس.
نظام سجلّ Neon لديك مزوَّد أصلاً من المكسب السريع: جدولا customers وaudit_log موجودان، وDATABASE_URL في .env لديك. فالعامل يقرأ ويكتب تلك القاعدة من البداية. الآن ابنِ العامل. الصِق هذا:
ابنِ لي عامل دعم عملاء أدنى بOpenAI Agents SDK، يعمل في صندوق حماية محلي. يقرأ العملاء النموذجيين من جدول Neon
customers(كل صف له id، وemail، وtier)، ويصوغ رداً دافئاً على بريد عميل وارد، ويستطيع إصدار ردّ مال، لكن أداة ردّ المال تحتاج موافقة بشرية قبل أن تعمل. حين يُبلِّغ بريد عن تحميل مكرّر، أو تحميل زائد، أو طلب فاشل، يجب أن يستدعي الوكيل أداة ردّ المال فعلاً، لا أن يَعِد بردّ مال في النثر فقط. اكتب صف تدقيق في جدول Neonaudit_logلكل إجراء، باستخدام مجموعة صغيرة ثابتة من أسماء الإجراءات وDATABASE_URLفي.env. ازرع جدولcustomersبخمسة صفوف نموذجية أولاً إن كان فارغاً. أبقِه صغيراً؛ فهو موجود ليُغلَّف، لا ليُشحَن. ثم شغّله على بريد نموذجي وأرني الردّ.
العامل يصل إلى Postgres عبر DATABASE_URL، لا عبر MCP الخاص بNeon (ذلك أداة وقت-البناء لديك فقط). وسطر واحد ممّا يكتبه الوكيل حامل لبقيّة الدورة، مزيّن أداة ردّ المال:
@function_tool(needs_approval=True)
def issue_refund(order_id: str, amount_cents: int, reason: str) -> str:
...
needs_approval=True يجعل الوكيل يتوقّف بدل إصدار ردّ المال: يعود التشغيل وردّ المال معلَّق ليقرّره إنسان. وهو الخطّاف الذي يتعلّق به حجر أساس D5. (هذا الأساس يبوّب كل ردّ مال لإبقاء حجر الأساس بسيطاً؛ في الإنتاج كنت ستبوّب فوق عتبة فقط، نمط فوق-100$ من المفهوم 15. الوصلات نفسها.) شيء واحد أبقِه مُجزّأً، لأن D5 يتّكئ عليه: ابنِ الوكيل وإعداد تشغيل صندوق حمايته قطعتين منفصلتين، كي يستطيع D5 إعادة بناء الوكيل وإعادة تزويد صندوق الحماية عند الاستئناف.
تمّ عندما: الوكيل يعمل على بريد نموذجي ويطبع رداً قصيراً، وصف جديد في جدول Neon audit_log (افحصه في الوحدة الطرفية، أو اطلب من وكيلك قراءته ثانيةً عبر أدوات Neon). إن وصف البريد ردّ مال، يتوقّف التشغيل عند أداة ردّ المال بدل إصداره؛ وذلك التوقّف هو المقصد كلّه، وD5 يجعله متيناً.
التعليمات في هذا الجزء تفترض وكيلاً عاماً من فئة الحدود الأمامية (Claude Sonnet أو Opus، أو نموذجاً من فئة GPT-5، أو Gemini 2.5 Pro). بنية Inngest التي تتعلّمها (الأحداث، والخطوات، والتذكّر، والتحكّم في التدفّق) على مستوى الSDK وتصمد أيّ نموذج يقود وكيلك. لكنّ تجربة البناء تتّكئ على اتّباع قويّ للتعليمات، خاصةً حجر أساس D5. على نموذج أضعف، توقّع أن تكرّر تعليمة أكثر من مرة وأن تهجّئ أسماء الملفّات. البنية ليست مكسورة؛ التعليم فقط يحتاج مزيداً من السقالة.
D1: اجعل تشغيل الوكيل متيناً
أين أنت: عامل يعمل فقط حين تستدعيه، فاقداً كل شيء عند تعطُّل في منتصف التشغيل. هذا القرار يغلّف استدعاء الوكيل في step.run؛ وبنهايته يُظهِر تشغيل مكتمل خطوة الوكيل متذكَّرة في لوحة التحكّم.
الجهاز العصبي يبدأ هنا: غلّف استدعاء الوكيل كاملاً في step.run واحد كي يكون متيناً ومتذكَّراً. الصِق هذا:
غلّف تشغيل الوكيل في دالة Inngest متينة كي ينجو من التعطّلات ويعيد محاولة الإخفاقات العابرة. استدعاء الوكيل كاملاً يدخل
step.runواحداً كي يكون متذكَّراً. شغّله في وضع التطوير المحلي مقابل خادم تطوير Inngest، بمضيف FastAPI. أكّد أن تشغيلاً مكتملاً يُظهِر خطوة الوكيل متذكَّرة في لوحة التحكّم.
استدعاء الوكيل هو الجزء الغالي (رموز النموذج، عدّة ثوانٍ). داخل step.run تُذكَّر نتيجته، فحين تفشل خطوة لاحقة ويُعاد التشغيل، لا يعمل الوكيل ثانيةً. ذلك هو الفرق بين عامل يعيد الدفع ويعيد التصرّف عند كل إعادة محاولة وواحد يفعل كل شيء غالٍ مرة واحدة. أبقِ الوكيل مستدعى بتشغيل عادي (غير متدفّق)؛ فاستئناف D5 المتين يُبنى عليه.
يعمل بصفته عمليّتين: مضيف FastAPI، وخادم تطوير Inngest موجَّهاً إليه. ووكيلك يبدأ كليهما.
تمّ عندما: لوحة التحكّم تسرد الدالة وتشغيل مكتمل يُظهِر خطوة الوكيل. (توقظه بحدث حقيقي في D2؛ والآن، قابليّته للاكتشاف تكفي.)
D2: حفّزه على حدث
أين أنت: الدالة المتينة موجودة، لكنك ما تزال تحفّزها يدوياً ولا شيء يُسجَّل. هذا القرار يوقظها على حدث حقيقي ويكتب صف تدقيق على كل جانب من الوكيل.
هذه أول مرة تعمل فيها صورة الافتتاحية حقاً. بدل أن تستدعي العامل، يصل حدث customer/email.received، فيلتقطه المحرك، فيستدعي المحرك عاملك ليعمل. وتبدأ أيضاً تسجيل ما حدث: صف تدقيق واحد قبل الوكيل مباشرة، وواحد بعده مباشرة. الصِق هذا:
اجعل العامل يستيقظ على حدث
customer/email.receivedبدل تشغيله يدوياً. أضف خطوة تدقيق دخول قبل الوكيل وخطوة تدقيق ردّ بعده. أرسل حدث اختبار وأرني التشغيل يكتمل بصفّي التدقيق.
لاختباره محلياً، أرسل الحدث بنفسك بsend_event الخاص بMCP لخادم التطوير (حدث customer/email.received يحمل نصّ البريد ومعرّف العميل)، بلا webhook. في الإنتاج كنت ستوجّه مزوّد بريدك إلى URL لwebhook في Inngest، وهو إعداد لوحة تحكّم، لا كود.
تمّ عندما: حدث اختبار يقود تشغيلاً يكتمل بثلاث خطوات بالترتيب (تدقيق، وكيل، تدقيق) وصفّين جديدين في جدول Neon audit_log، واحد قبل الوكيل وواحد بعده.
لماذا خطوتان، لا واحدة. كل كتابة تدقيق step.run خاصّ بها، فكلٌّ متذكَّر على حدة. فإن فشلت خطوة الردّ وأُعيد التشغيل، لا يُكتَب صف الدخول مرّتين ولا يعمل الوكيل مرّتين، فيبقى سجلّ التدقيق مرة-واحدة-بالضبط عبر إعادات المحاولة (الخاصية التي يُثبِتها D6).
D3: cron يومي يتفرّع
أين أنت: عامل يوقظه العالم بريداً واحداً في كل مرة. هذا القرار يضيف cron يومياً يتفرّع إلى حدث واحد لكل عميل مؤهَّل؛ وبنهايته يحصل كلٌّ على تشغيله الابن المتين.
أضف عملاً مجدولاً: cron يومي يُطلق حدث فحص صحّة لكل عميل Pro وEnterprise، كل حدث يحفّز تشغيله المتين الخاص. الصِق هذا:
أضف cron يومياً يتفرّع إلى حدث
customer/health_check.requestedواحد لكل عميل Pro وEnterprise، كلٌّ مُفتاح-idempotency كي لا يُطلق تشغيل cron مُعاد التسليم مرّتين أبداً. كل حدث ابن يحفّز تشغيله المتين الخاص الذي يكتب صف تدقيق واحداً. استدعِ cron يدوياً وأرني تشغيلاً ابناً واحداً لكل عميل مؤهَّل.
شيئان يحملان هذا القرار. التفرّع يدخل خطوة (step.send_event، لا إرسال عميل عارٍ)، فإعادة محاولة cron لا تُصدِر مكرّرات. وكل حدث يحصل على id لidempotency مشتقّ من العميل ودقّة cron (شيء مثل health-{customer}-{cron_run}): إن سُلِّمت الدقّة نفسها مرّتين (إعادة نشر، إعادة محاولة)، يُسقَط المكرّر، فيحصل كل عميل على فحص واحد بالضبط ذلك اليوم. استدعِ cron من وكيلك بinvoke_function الخاص بMCP (لا تنتظر 09:00). غرابة تطوير واحدة: خادم التطوير يُطلق crons فقط ما دام يعمل؛ والإنتاج يشغّلها على بنية Inngest التحتية الدائمة التشغيل.
تمّ عندما: الأب يكتمل في ثوانٍ ولوحة التحكّم تُظهِر تشغيلاً ابناً واحداً لكل عميل مؤهَّل، مع تخطّي عملاء الطبقة القياسية صحيحاً.
لماذا fan-out، لا حلقة. الأب لا يعالج العملاء بنفسه؛ بل يرسل N حدثاً ويعود. وكل ابن تشغيله الخاص، معزول، قابل لإعادة المحاولة باستقلال، محدود بconcurrency الخاص به. وحلقة داخل دالة واحدة كانت ستقرنهم: عميل بطيء واحد يعطّل البقيّة، وتعطُّل يفقد الدفعة كلّها. fan-out هو كيف تصير استيقاظة مجدولة واحدة N تشغيلاً متيناً مستقلّاً.
D4: التحكّم في التدفّق
تراجع أولاً: بحلول الآن جمّعت عاملاً واحداً، يُوصَل إليه بثلاث طرق، كلّها تتشارك متجر Neon واحداً. هذا هو الشيء الذي يضع D4 عليه حدوداً.
INNGEST ENGINE (routes events, runs functions, stores steps)
|
┌──────────────┼────────────────┐
v v v
an email a daily cron one run per customer
arrives fans out a the cron emitted
(D2: the check per (D3: each isolated,
email worker) customer (D3) retryable on its own)
└────────── all run in YOUR host ───────────┘
|
Neon Postgres (customers + audit_log)
الوكيل نفسه داخل كل مسار؛ فقط كيف يصل العالم إليه يختلف. الآن تبقي كل ذلك سليماً تحت الحمل.
أين أنت: عامل يعالج كل بريد لكنه سيُطلقها كلّها دفعةً واحدة تحت دفقة. هذا القرار يضيف ثلاث سياسات تحكّم في التدفّق؛ وبنهايته دفقة من عشرين حدثاً تنتظر تحت الحدّ بلا صفوف مُسقَطة أو مكرّرة.
حين يهبط خمسمائة بريد عند 9 صباحاً، لا ينبغي للعامل أن يُطلق خمسمائة استدعاء نموذج دفعةً واحدة: ذلك يتجاوز حدّ المعدّل ويجوّع الجميع خلف العميل الثرثار. أضف حدّ concurrency عالمياً، وحدّاً لكل عميل، وthrottle. الصِق هذا:
أضف تحكّماً في التدفّق إلى معالج البريد: حدّ concurrency عالمي، ومفتاح concurrency لكل عميل كي لا يستطيع عميل ثرثار تجويع البقيّة، وthrottle لحماية حدّ معدّل OpenAI. أطلق دفقة من عشرين حدثاً عبر خمسة عملاء وأرني أنها تنتظر تحت الحدّ وتكتمل كلّها بلا صفوف تدقيق مُسقَطة أو مكرّرة.
ثلاثة مقابض تؤدّي ثلاثة أعمال: حدّ concurrency عالمي (كم تشغيلاً يُنفَّذ دفعةً واحدة)، ومفتاح concurrency لكل عميل (فحساب ثرثار واحد يأخذ على الأكثر فتحةً أو فتحتين ولا يجوّع البقيّة أبداً)، وthrottle (كم تشغيلاً يبدأ في الدقيقة). طابِق throttle بحدّك التالي الحقيقي: حدّ OpenAI في الملخّص نحو 30 في الدقيقة، ف30، لا 100 عام. (الدالة تحمل سياستَي concurrency على الأكثر؛ وزوج العالمي-زائد-لكل-مفتاح هو الشكل الشائع.)
حدّ concurrency يحمي سقفين: حدّ معدّل النموذج وميزانية اتصال Neon لديك. نسخة عاملة واحدة من عاملك تبقي أصلاً اتصالات قاعدة بياناتها محدودة، لأن كل تشغيل فيها يتشارك مجمّع اتصال واحداً. وحدّ concurrency هو ما يبقي المجموع عاقلاً حالما تشغّل عدّة نسخ دفعةً واحدة: عشر نسخ بحدّ 10 لكلٍّ نحو 100 اتصال، تحجّمها مقابل ميزانية Neon. المجمّع يحدّ نسخة واحدة؛ والحدّ يحدّ الأسطول.
أطلق الدفقة من وكيلك: عشرون حدث customer/email.received عبر خمسة عملاء عبر send_event.
تمّ عندما: الدفقة تنتظر تحت الحدّ (عدّ التشغيل يبقى عند أو دون الحدّ العالمي، وعند أو دون حدّ لكل عميل)، وكل تشغيل يكتمل، وسجلّ التدقيق له صف واحد بالضبط داخلاً وواحد خارجاً لكل حدث، بلا تشغيلات مُسقَطة، ولا مكرّرات، ولا أخطاء اتصال Neon.
لماذا هذه سياسة، لا كود. لا شيء من هذا يعيش في جسم دالتك؛ بل هو إعداد يفرضه وقت التشغيل. بدون الحدود، دفقة إمّا تُذيب نظاماً تالياً أو تترك مستأجراً واحداً يحتكر العامل. وكتابة العدالة نفسها يدوياً طابور زائد مجدول زائد محدّد معدّل، مئات الأسطر. هنا هي ثلاثة وسائط مزيّنات.
D5: بوابة موافقة بشرية متينة على ردود الأموال (حجر الأساس)
أين أنت: في D0 وكيلك يتوقّف أصلاً قبل ردّ مال، لكنّ ذلك التوقّف يعيش في الذاكرة فقط. هذا القرار يجعله ينجو من تعطُّل، أو نشر، أو مراجع يأخذ ساعات، كي يُطلق ردّ المال مرة واحدة بالضبط حين يوافق أخيراً.
إليك الفكرة كلّها قبل أي كود. وكيلك يقرّر أن ردّ مال مبرَّر، لكن يجب ألّا يصدره حتى يقول إنسان نعم. وتوقّف D0 يحفظ ذلك القرار في العمليّة العاملة فقط، فتعطُّل أو مراجع بطيء يفقده. وD5 يحوّل ذلك التوقّف إلى انتظار متين: الدالة تخلد إلى النوم (لا تكلّف شيئاً) ولا تستيقظ إلا حين يصل القرار.
the agent decides a refund is warranted
|
v
it PAUSES and asks a human (it does NOT issue the refund yet)
|
v
the function SLEEPS, waiting for the decision
(minutes or hours; free while it waits; survives a crash,
a deploy, a reviewer who goes to lunch)
|
v
a human clicks Approve or Reject -> sends the decision event
|
v
the function WAKES and finishes:
approved -> issue the refund (exactly once)
rejected -> no refund; record it
no answer in 4h -> no refund; record a timeout
الصِق هذا:
الآن الوكيل يتوقّف قبل ردّ مال، لكنّ ذلك التوقّف يُفقَد إن تعطّل العامل أو أخذ المراجع ساعات. اجعل التوقّف ينجو من ذلك: حين يتوقّف الوكيل للموافقة، احفظ أين توقّف، ثم انتظر حتى أربع ساعات موافقة-أو-رفض إنسان لهذا العميل. حين يأتي القرار، التقط بالضبط من حيث ترك الوكيل وأنهِ، كي يحدث ردّ المال مرة واحدة على الأكثر لكل تشغيل. وعند رفض، يجب أن يقول الردّ للعميل إن ردّ المال رُفِض، لا أبداً إنه صدر. ثم أثبته لي: قُد ردّ مال، وأرني التشغيل ينتظر، وأرسل موافقة، وأرني صف ردّ مال واحداً بالضبط. افعلها ثانيةً برفض وأرني صفاً محجوباً وبلا ردّ مال.
تلك الصورة كلّها سطر واحد من الكود. الدالة تتوقّف عند wait_for_event وتبدأ ثانيةً حين يظهر حدث القرار:
decision = await ctx.step.wait_for_event(
"await-refund-approval",
event="refund/approval.decided", # what we are waiting for
timeout=datetime.timedelta(hours=4), # give up after 4 hours
if_exp=f"async.data.customer_id == '{customer_id}'", # only THIS customer's decision
)
# no decision came in 4 hours -> write a blocked-refund row and stop
# approved or rejected -> pick the agent back up and finish
ذلك الاستدعاء الواحد هو البوابة كلّها. أنت لا تكتب طابوراً، ولا حلقة استطلاع، ولا أعلام «هل وُوفِق بعد؟» لتفحصها يدوياً. وقت التشغيل يحفظ التوقّف لك. وكودك يقول فقط ما ينتظره وما يفعل بالجواب. ثلاثة أشياء يسهل الخطأ فيها، رغم ذلك، وكلٌّ منها يكسر البوابة بصمت:
if_expيربط القرار بهذا العميل، فموافقة لعميل واحد لا تستأنف تشغيل آخر أبداً. وcustomer_idينفع هنا لأن العرض التوضيحي له ردّ مال واحد معلَّق على الأكثر لكل عميل؛ فإن أمكن أن يكون لعميل ردّا مال في الطريق دفعةً واحدة، اربط علىrequest_idفريد (المفتاح الذي يستخدمه المفهومان 8 و15) أو معرّف التشغيل بدلاً من ذلك، وإلا فقد تستأنف موافقة واحدة التشغيل الخاطئ.- حين يستأنف الوكيل، سلّمه الحالة التي حفظتها، لا محادثة جديدة تماماً. إليك ما يخطئ إن نسيت: محادثة جديدة لا تتذكّر أنها طلبت الموافقة أصلاً، فالوكيل المستأنف يضرب ردّ المال ثانيةً، ويطلب الموافقة ثانيةً، ويدور إلى الأبد. أعِد بناء الوكيل وأعِد تزويد إعداد تشغيله، ثم اطعمه الحالة المحفوظة فقط. (لهذا أبقى D0 بناء الوكيل وإعداد تشغيله مُجزّأين؛ إنه التفصيل الواحد الذي، إن فات، يجعل الاستئناف يفشل.)
- حفظ الحالة يُسقط سياقك المخصّص بصمت، فأعِده يدوياً. هذا هو الفخّ الذي يفشل بلا خطأ. حين يسلسل Agents SDK التشغيل المتوقّف، فهو لا ينقل سياق تشغيل مخصّصاً (الكائن الذي تقرأ منه أداة ردّ المال معرّف العميل ومفتاح idempotency)؛ بل يحفظ واحداً فارغاً ويحذّر فقط. فعند الاستئناف يجب أن تعيد تزويد ذلك السياق بنفسك، ب
RunState.from_string(agent, saved_state, context_override=your_context). تخطّاه فتعمل أداة ردّ المال الموافَق عليها بلا سياق: تكتب بصمت بلا صف ردّ مال، بينما يظلّ التشغيل يُبلِّغ عن نجاح. فترى «وُوفِق، لكن بلا صفrefund_issued» ولا شيء يفسّره. (تمّ التحقّق علىopenai-agents0.17.x؛ وقواعد التسلسل الدقيقة هي نوع تفصيل بيتا الذي يتغيّر بين الإصدارات الصغرى، فأكّد مقابل وثائق حالة-التشغيل في Agents SDK الحالية عندما تبني.)
قُدها من وكيلك: أرسل حدث customer/email.received يصف ردّ مال، وراقب التشغيل يتوقّف عند البوابة (لوحة التحكّم تُظهِره WAITING عند حوسبة صفرية)، ثم send_event حدث refund/approval.decided يحمل {"approved": true, ...} لذلك العميل. افعلها ثانيةً ب{"approved": false}.
تمّ عندما: عند الموافقة، يستأنف التشغيل المتوقّف وجدول Neon audit_log به صف refund_issued واحد بالضبط. وعند الرفض، يستأنف التشغيل، والتدقيق به صف refund_blocked ولا صف refund_issued، وردّ الوكيل يفسّر الرفض.
البوابة تمنحك مرة-واحدة-بالضبط داخل تشغيل واحد، والحدّ يستحقّ القول. إن قُيد ردّ المال نفسه عبر تشغيلين (حدث مُعاد الإرسال، replay يدوي)، فلا شيء هنا يمنع ردّ مال ثانياً من تلقاء نفسه؛ وذلك عمل مفتاح idempotency الثابت من المفهوم 4 (أو مفتاح المزوّد نفسه)، مفتاحاً على الطلب، بالضبط كما أظهر مثال ردّ المال هناك. والعامل الأدنى يترك ذلك المفتاح خارجاً ليبقى صغيراً، فأثبت «مرة واحدة بالضبط» مقابل تشغيل واحد، وامدد يدك إلى مفتاح المفهوم 4 لحظة يمكن أن يُقاد ردّ مال حقيقي مرّتين.
لماذا هذا حجر الأساس. كل طبقة أخرى (الحواس، والمنعكسات، والتوازن) تبقي العامل صحيحاً أو سليماً من تلقاء نفسها. وهذه هي حيث يعود العقل البشري إلى الحلقة على إجراء عالي المخاطر، بمتانة، مهما طال الأمر.
D6: أثبِت أن المتانة تنجو من خطوة مكسورة
أين أنت: عامل كامل بكل طبقة مغلَّفة. هذا القرار يُثبِت الخاصية التي برّرت كل ذلك؛ وبنهايته شاهدت تشغيلاً مكسوراً يعيد محاولة خطوته الفاشلة مرّات كثيرة بينما تعمل خطوة تدقيقه المكتملة مرة واحدة بالضبط، ثم تعافيت العمل على تشغيل جديد.
الخاصية الأخيرة لإثباتها هي الخاصية التي برّرت كل هذا، آلية التذكّر من المفهوم 7. فهمتها هناك؛ والآن أثبتها في عاملك الخاص. الصِق هذا:
اكسر خطوة الوكيل عمداً كي تفشل، وأطلق حدثاً، وأرني Inngest يعيد محاولتها بينما تبقى خطوة التدقيق الأبكر متذكَّرة، كي يكتب التشغيل الفاشل صف تدقيق دخوله مرة واحدة بالضبط عبر كل إعادات محاولة الوكيل. ثم أصلح الخطوة وتعافَ العمل، وأرني التعافي يكتمل.
اكسر خطوة الوكيل عمداً، وأطلق بضعة أحداث customer/email.received، واقرأ أثر كل تشغيل. الإثبات داخل كل تشغيل فاشل: خطوة تدقيق الدخول تُظهِر محاولة مكتملة واحدة (صفها مكتوب مرة واحدة) بينما تُظهِر خطوة الوكيل عدّة محاولات وهي تعيد المحاولة بتراجع ثم تفشل، وخطوة الردّ لا تعمل أبداً. وخطوة التدقيق عند محاولة واحدة بينما تتسلّق خطوة الوكيل هي تذكّر المفهوم 7، الآن في عاملك الخاص: التشغيل الفاشل يكتب صف دخوله مرة واحدة، مهما أعاد الوكيل المحاولة.
ثم أرجِع الكسر وتعافَ العمل بإعادة إطلاق الحدث على الكود المُصلَح (أو، لدفعة نشر-سيّئ حقيقية، زرّ Rerun في لوحة التحكّم؛ كلاهما يبدأ تشغيلاً جديداً من الأعلى، المفهوم 14). إليك الجزء الذي يفاجئ الناس، وهو صحيح، لا عطل: التعافي تشغيل جديد تماماً، فيكتب صف دخوله الخاص. وبعد كسر-ثم-تعافٍ، يكون لذلك العميل شرعياً صفّا دخول، واحد من التشغيل الفاشل، وواحد من التعافي. التذكّر ضمان داخل-التشغيل؛ ولا يمتدّ أبداً عبر تشغيلين منفصلين.
تمّ عندما: في أثر التشغيل الفاشل، خطوة الدخول جلست عند محاولة واحدة وكتبت صفاً واحداً بينما راكمت خطوة الوكيل عدّة محاولات وفشلت (تلك المحاولة-الواحدة-رغم-N-إعادة-محاولة هي التذكّر)، ثم يكتمل تشغيل التعافي على الكود المُصلَح. والتشخيص لكل تشغيل، لا لكل عميل: افتح أثر تشغيل واحد وأكّد أن خطوة الدخول تُظهِر محاولة واحدة. صفّا دخول عبر تشغيلين منفصلين صحيح؛ وخطوة الدخول تعمل مرّتين داخل تشغيل واحد هي العطل (عادةً اسم خطوة غير فريد).
لماذا هذا الخطّ الفاصل. عامل يفقد عمل العملاء على نشر سيّئ هو مجرد وكيل تستدعيه. أمّا عامل يأخذ النشر السيّئ نفسه، ويفشل بصوت عالٍ، ويعيد محاولة الخطوة المكسورة دون إعادة العمل الذي أنجزه أصلاً، ويتعافى بنظافة على تشغيل جديد بعد الإصلاح، فهو عامل ذكاء اصطناعي.
وجّه هذا الجهاز العصبي نفسه إلى عامل SandboxAgent الخاص بك بدل الأساس الأدنى؛ التغليف متطابق. وموافقة step.wait_for_event هذه تستبدل بجدول حالة-التشغيل المكتوب يدوياً من قرار تلك الدورة العاشر الاختياري: البوابة المتينة التي بنيتها للتوّ هي طبقة الاستمرارية، فتستطيع حذف الجدول.
ما حدث للتوّ
بنيت عامل دعم عملاء صغيراً ومنحته جهازاً عصبياً، طبقةً في كل مرة. داخليات العامل لم تتغيّر أبداً بعد D0: SandboxAgent نفسه، والأداتان نفساهما، وسجلّ تدقيق Neon Postgres نفسه. ما تغيّر هو كل شيء حوله. يستيقظ الآن على حدث customer/email.received وعلى cron يومي يتفرّع لكل عميل مؤهَّل، ويعمل بمتانة (استدعاء الوكيل داخل step.run)، ويحترم التحكّم في التدفّق (concurrency عالمي ولكل عميل، وthrottle)، ويبوّب ردود الأموال على موافقة بشرية متينة (step.wait_for_event)، ويتعافى من نشر سيّئ بإعادة تشغيل التشغيلات الفاشلة، مع سجلّ التدقيق يُظهِر أن كل خطوة داخل أي تشغيل واحد أُطلقت مرة واحدة بالضبط، مهما أعاد ذلك التشغيل المحاولة.
كود الوكيل هو نفسه؛ ومداه ليس كذلك. بدأت بوكيل تشغّله، تعطيه تعليمة، وتراقبه، وتعطيه تعليمة ثانيةً. والآن لديك عامل يشتغل من تلقاء نفسه: العالم يوقظه، ومنعكساته تحمله عبر الإخفاقات، ويبقي توازنه تحت الحمل، وإنسان يتدخّل فقط حيث تتطلّب المخاطر واحداً. ذلك هو الخطّ الذي رسمته الافتتاحية، بين وكيل تشغّله وموظف رقمي بدوام كامل يشتغل من تلقاء نفسه، وقد بنيت عبره للتوّ.
المخاوف المتبقّية هي قابلية الملاحظة على نطاق واسع، وتنسيق العمّال المتعدّدين، وطبقة المدير التي تقرّر أي عمّال يتولّون أي حركة. تلك دورات ما زالت أمامك في المسار. هذه الدورة تغطّي وحدة التنفيذ الجاهز للإنتاج؛ ودورات قوة العمل تؤلّف تلك الوحدات في قوة عمل.
الجزء 5: أين تتوقّف هذه الدورة
شكل كلفة عامل الذكاء الاصطناعي
سطحا كلفة مهمّان: كلفة البنية التحتية (Inngest، وأيّ متجر وحوسبة تشغّل العامل عليها) وكلفة الاستدلال (رموز النموذج). البنية التحتية تبقى مسطّحة تقريباً مع زيادة الحمل؛ والاستدلال يتمدّد خطّياً. والطريقة أدناه هي ما تتعلّمه؛ وأي رقم بالدولار يصبح قديماً الأسبوع الذي يُشحَن فيه، فعامِل الأرقام بصفتها توضيحية وراجِع صفحات التسعير الحالية قبل أن تضع رقماً في ميزانية.
تسعير Inngest. يحاسب Inngest لكل تنفيذ: كل تشغيل دالة، زائد كل إعادة محاولة على مستوى الخطوة، يُحسَب تنفيذاً واحداً.
| الطبقة | السعر | التنفيذات / شهر | الخطوات المتزامنة | جدير بالملاحظة |
|---|---|---|---|---|
| Hobby | 0$ | 50,000 | 5 | 3 مستخدمين، و50 اتصال لحظي، بلا بطاقة ائتمان |
| Pro | من 75$ / شهر | 1,000,000 | 100+ | 1000+ اتصال لحظي، و15+ مستخدماً، واحتفاظ أثر 7 أيام |
| Enterprise | مخصّص | مخصّص | 500-50,000 | SAML / RBAC، واحتفاظ أثر 90 يوماً، ودعم مخصّص |
لاحظ أن Inngest يقيس شيئين مختلفين. أحدهما التنفيذات (الجدول أعلاه): تشغيل دالة زائد كل إعادة محاولة خطوة. والآخر الأحداث (ما ترسله داخلاً): أول 1-5 مليون حدث في اليوم مشمولة، وفوقها يبدأ الفائض نحو 0.000050$ لكل حدث وينخفض عند حجم أعلى. على Pro، تجاوز سقف 1 مليون تنفيذ يضيف 50$ لكل 1 مليون تنفيذ إضافي.
سقوف طبقة Hobby المهمّة هنا. حدّ 5-خطوات-متزامنة يعني أنك حتى لو أعلنت concurrency=Concurrency(limit=10) في الكود، فإن حدّ الحساب على مستوى المنصّة يبقيك عند 5. كودك صحيح للإنتاج؛ والconcurrency المُلاحَظ على الطبقة المجانية 5. وstep.sleep وstep.sleep_until محدودان بالطبقة أيضاً: حتى سبعة أيام على خطة Hobby المجانية، وحتى سنة على الخطط المدفوعة (حدود استخدام Inngest).
كلفة الاستدلال تهيمن. تشغيل دعم عملاء نموذجي يستخدم بضعة آلاف إلى عشرة آلاف رمز نموذج لكل محادثة. اضرب سعرك لكل رمز في رموزك لكل بريد في رسائلك في اليوم فتحصل على السطر المهمّ؛ ولمعظم العمّال يقزّم كل شيء آخر. هذا ما تحسّنه. كل شيء آخر خطأ تقريب. والرافعتان الأعلى قيمةً: أبقِ بادئة تعليمة مخبّأة ثابتة (فيحاسب النموذج الجزء المكرّر بالسعر المخبّأ الأرخص، لا بالسعر الكامل عند كل استدعاء)، ووجّه الأدوار السهلة إلى نموذج أرخص.
ثلاث رافعات كلفة خاصة بInngest حالما تكون في منطقة التحسين:
- لا تغلّف الدوال الصرفة في
step.run. إن لم يكن للدالة آثار جانبية، فلا تحتاج متانة؛ وتغليفها يضيف رسم step-run بلا فائدة. احفظstep.runللإدخال/الإخراج والآثار الجانبية. - استخدم
batch_eventsللمسارات بالجملة. دفعة من 50 حدثاً تشغيل دالة واحد، لا 50. - توقّف برخص ب
step.sleepوstep.wait_for_event. الدوال المتوقّفة لا تحاسب على وقت التوقّف. ومتابعة مؤجّلة 3 أيام تكلّف كمتابعة 3 ثوانٍ.
الشكل على نطاق واسع: الاستدلال هو الفاتورة التي تنمو مع الحركة؛ وInngest، ومتجر بياناتك، والحوسبة تبقى مسطّحة نسبياً. أجرِ الضرب نفسه على حجمك الحقيقي بدل الثقة برقم مطبوع هنا.
دليل الاستبدال: الجهاز العصبي ثابت، والمنصّة ليست كذلك
تسمّي هذه الدورة Inngest في كل طبقة. ذلك لأن مثالاً تعليمياً يحتاج إجابات محدّدة، لا «استخدم أي منسّق تحبّه». لكنّ البنية تعمل مع أي بديل متوافق. خمسة استبدالات يتوقّعها تصميم الدورة صراحةً:
-
سطح المحفّز: أحداث Inngest ← إشارات Temporal، ومعالجات Restate، وAWS EventBridge + Lambda. كل منصّة لها طريقة للتعبير عن «هذا الكود يعمل حين يحدث هذا الشيء المسمّى». أسماء الأحداث، وأشكال الحمولات، وانضباط idempotency كلّها تنتقل. ما يتغيّر: بناء جملة مزيّن الSDK ولوحة التحكّم.
-
التنفيذ المتين:
step.runفي Inngest ← أنشطة Temporal، ومعالجات Restate، وآلات حالات مخصّصة مدعومة بPostgres. كلٌّ يمنحك دلالات «تذكّر هذا الاستدعاء ذا الأثر الجانبي، أعِد المحاولة عند فشل عابر، استأنف بعد التعطُّل». وTemporal هو النظير الأقرب والخيار الأقدم الأكثر اختباراً في المؤسّسات. وRestate هو الأحدث وله نكهة برمجة وظيفية أكثر. وآلات الحالات المخصّصة هي ما تكتبه الفرق حين لا تستطيع تبنّي منصّة مُدارة؛ عادةً 1,000-10,000 سطر كود تعيد إنشاء نحو 70% ممّا يمنحك Inngest مجاناً. -
بدائية HITL:
step.wait_for_event←await Workflow.execute_activity(approval_signal)في Temporal، وawakeables في Restate، وطوابير موافقة Redis/Postgres مخصّصة. النمط نفسه: الدالة تتوقّف، وإشارة خارجية تستأنفها، والتدقيق يلتقط القرار. وتعبير Inngest أنظف في الكتابة؛ وتعبير Temporal أكثر إسهاباً لكنه مختبَر ميدانياً على نطاق واسع. -
جدولة cron: محفّزات cron في Inngest ← Kubernetes CronJobs + طابور، وجداول GitHub Actions، وجداول AWS EventBridge. محفّزات cron سلعة. وميزة Inngest ليست امتلاك cron؛ بل أن الدوال المحفَّزة بcron تحصل على المتانة/الإعادة/التحكّم-في-التدفّق نفسه الذي تحصل عليه الدوال المحفَّزة بالأحداث، تلقائياً. والمنصّات الأخرى تجعلك توصل ذلك بنفسك.
-
التحكّم في التدفّق: concurrency + throttle في Inngest ← طوابير مهام Temporal بconcurrency للعامل، ومحدّدات معدّل مدعومة بRedis، ومهل رؤية رسائل AWS SQS. المنصّات الأخرى تستطيع فعل هذا؛ وInngest يفعله بكثافة الإعداد التي رأيناها (وسيط مزيّن واحد).
Dapr بصفته الرفيق المفتوح على نطاق الإنتاج. بديل أكثر طموحاً جدير بالتسمية: Dapr Agents بصفته الرفيق البنيوي لInngest على نطاق الإنتاج، بالطريقة التي يكون بها OpenCode لClaude Code. وصل Dapr Agents إلى v1.0 GA في 23 مارس 2026 تحت حوكمة CNCF (إعلان CNCF، المفاهيم الأساسية لDapr Agents). وDurableAgent هو الصنف الجاهز للإنتاج؛ والصنف الأقدم Agent مهمَل. اختر Dapr حين يهمّ النشر الأصلي لKubernetes وأطقم SDK متعدّدة اللغات أكثر من تجربة التطوير المحلية في Inngest. Inngest أداة التعلّم الأفضل (لوحة التحكّم تجعل النموذج الذهني مرئياً)؛ وDapr أداة النطاق الأفضل حين تبلغ سقوف طبقة Inngest أو تحتاج نشراً أصلياً لK8s متعدّد اللغات.
Inngest أيضاً مفتوح المصدر (github.com/inngest/inngest؛ إصدار 1.0 أضاف دعم الاستضافة الذاتية في سبتمبر 2024) وقابل للاستضافة الذاتية عبر Helm + KEDA. والمحاور المهمّة على نطاق واسع هي الحوكمة، والدعم، والنضج: Inngest محكوم بمورّد واحد بقصّة استضافة ذاتية فتيّة؛ وDapr محكوم بCNCF بسجلّ إنتاج أطول.
| مفهوم هذه الدورة | بدائية Inngest | نظير Dapr الإنتاجي | ملاحظة تعليمية |
|---|---|---|---|
| العمل المجدول | TriggerCron | ربط إدخال Cron / مجدول Dapr | الفكرة نفسها: الوقت يوقظ العامل. وDapr عادةً يتطلّب إعداد مكوّن. |
| دخول webhook/حدث | نقطة نهاية webhook في Inngest ← حدث | نقطة نهاية HTTP، أو روابط إدخال، أو دخول pub/sub | Inngest يخفي سباكة أكثر؛ وDapr يمنح تحكّماً في البنية التحتية. |
| الأحداث الداخلية | inngest_client.send() | Dapr pub/sub | النموذج الذهني المعتمد على الأحداث نفسه؛ والوسيط قابل للتوصيل في Dapr. |
| Fan-out | حدث واحد يحفّز دوالاً كثيرة | موضوع/حدث واحد يستهلكه خدمات كثيرة | البنية نفسها؛ وDapr يستخدم تأليف وسيط/موضوع/مشترِك. |
| الخطوات المتينة | step.run() + التذكّر | Dapr Workflows + أنشطة | غرض إنتاجي مشابه، نموذج مطوّر مختلف. |
| الانتظار بلا حوسبة | step.sleep() | مؤقّتات سير العمل المتينة | كلاهما يتجنّب إبقاء عمليّة مفتوحة أثناء الانتظار. |
| بوابة الموافقة البشرية | step.wait_for_event() | أحداث/إشارات سير العمل الخارجية، وpub/sub، وممثّلون | تعبير Inngest أبسط؛ وDapr أكثر قابلية للتأليف. |
| إعادات المحاولة | إعادات محاولة الدالة/الخطوة | إعادات محاولة سير العمل/النشاط + سياسات مرونة | Dapr يجعل المرونة سياسة وقت تشغيل إضافةً إلى سلوك سير العمل. |
| dead-letter / تشغيلات فاشلة | تشغيلات Inngest الفاشلة في لوحة التحكّم + replay | DLQ للوسيط + حالة/إعادة/أدوات يدوية لسير العمل | Inngest أكثر جاهزية هنا؛ وDapr أكثر أصالةً للبنية التحتية. |
| التحكّم في التدفّق | Concurrency، وthrottling، وأولوية، وbatching | قياس Kubernetes، وconcurrency للتطبيق، وضوابط الوسيط، وسياسات المرونة، وpub/sub بالجملة | Dapr يستطيع، لكنه ليس وسيطاً مزيّناً واحداً. Inngest أكثف. |
| التنسيق ذو الحالة | wait_for_event، ومفاتيح الأحداث، وحالة الخطوة | ممثّلون + متجر حالة + سير عمل | ممثّلو Dapr أقوى للهوية طويلة الأمد/التنسيق ذي الحالة. |
| وقت تشغيل الوكيل | وكيلك داخل دالة Inngest | DurableAgent / Dapr Agents v1.0 GA | Dapr Agents يجعل سير عمل الوكيل مدعوماً وقابلاً للاستئناف صراحةً. |
هذا الجدول دليل ترجمة، لا ادّعاء بواجهات API متطابقة. Inngest يعلّم نمط الإنتاج بتجربة مطوّر مدمجة: المحفّزات، والخطوات، والانتظارات، وreplay، والتحكّم في التدفّق في سطح منتج واحد. وDapr ينفّذ بنية الإنتاج نفسها عبر لبنات بناء أنظمة موزّعة: الروابط، وpub/sub، وسير العمل، والممثّلون، والحالة، والمرونة، والعمليّات الأصلية لKubernetes. المفاهيم تنتقل مباشرة؛ ونمط التنفيذ يتغيّر. تمّ التحقّق مقابل نظرة Dapr العامة على الروابط والمفاهيم الأساسية لDapr Agents حتى مايو 2026.
ثلاثة أسباب للامتداد إلى Dapr على نطاق الإنتاج:
- محكوم بCNCF، محايد المورّد بالميثاق: لا مورّد واحد يتحكّم في المنصّة أو في اعتمادك عليها.
- متعدّد اللغات بPython من الدرجة الأولى. Dapr Agents بPython أولاً؛ وكود الوكيل نفسه يستطيع العمل بجانب خدمات مكتوبة بJavaScript، أو Go، أو .NET، أو Java، أو PHP دون أن يتعلّم أحد إطاراً ثانياً.
- قابل للقياس أفقياً على Kubernetes بالتصميم. شغّل في عنقودك الخاص، أو في عرض مُدار (Diagrid Catalyst)، أو محلياً عبر
dapr init. وقصّة القياس هي البنية نفسها في كل بيئة.
التحفّظ الصادق: Dapr ليس منصّة بداية. تشغيله في الإنتاج يعني Kubernetes، ومتجر حالة، ووسيط pub/sub، وخدمة وضع، وقابلية ملاحظة، ومكوّنات YAML، وأذرع جانبية. ذلك سطح تشغيلي كبير حين يكون هدفك ما يزال تعلّم الأنماط، ولهذا تبدأ هذه الدورة على Inngest: أمر واحد، وتظهر لوحة التحكّم. امدد يدك إلى Dapr حالما تستقرّ الأنماط ويتحوّل السؤال إلى التشغيل على نطاق تنظيمي على بنية تحتية تتحكّم بها.
تعلّم المفاهيم على Inngest وOpenAI Agents SDK أولاً: حلقة تغذية راجعة سريعة، وبنية تحتية أدنى، وتركيز على الأنماط. وحين تبلغ النطاق الذي تصير فيه حوكمة Kubernetes، أو الفرق متعدّدة اللغات، أو الحياد عن المورّد غير قابلة للتفاوض، ترتفع الأنماط المعمارية نفسها إلى Dapr مع جدول الترجمة أعلاه مفتاحك. الأنماط تنتقل؛ والركيزة تتغيّر؛ وما تعلّمته في هذه الدورة يبقى المعرفة الحاملة.
ما لا تغطّيه هذه الدورة (بعد)
العامل الذي بنيته يحقّق أربعة من الثوابت السبعة التي تطرحها الأطروحة. وتحديداً: يعمل على محرك (الثابت 4، SandboxAgent)، ومقابل نظام سجلّ (الثابت 5، سجلّ التدقيق)، مع قدرة العالم على استدعائه (الثابت 7، المحفّزات التي أضفتها)، ومع الإنسان بصفته صاحب القرار عند قرار مبوَّب (الثابت 1، جزئياً: الآلية في وقت التشغيل موجودة هنا، والنمط المعماري الأوسع لاحقاً). أمّا الثوابت الثلاثة المتبقّية، والبنية الأوسع التي تصنع قوة عمل من العمّال، فهي دورات لاحقة. نقطة واحدة لكلٍّ منها:
- الثابت 2: كل إنسان يحتاج مندوباً. وكيل شخصي على الحافة يحمل سياقك، ويمثّل حكمك، ويوسّط العمل إلى قوة العمل. والأطروحة تسمّي OpenClaw بصفته التحقيق الحالي.
- الثابت 3: قوة العمل تحتاج مديراً. منسّق يوزّع العمل، ويفرض الميزانيات، ويدقّق التنفيذ، ويعرض التوظيف بصفته قدرة قابلة للاستدعاء. والأطروحة تسمّي Paperclip.
- الثابت 6: قوة العمل قابلة للتوسّع تحت سياسة. طبقة عليا حيث يولّد وكيل مصرَّح به تعليمة، ويزوّد وقت تشغيل، ويسجّل عاملاً جديداً، دون إيقاظ إنسان. وClaude Managed Agents تحقيق واحد لذلك.
عامل واحد يستيقظ على الأحداث، ويعمل بمتانة، ويبوّب على البشر هو أصغر وحدة في البنية التي تعلّمها هذه الدورة. الدورات المقبلة تمدّ ذلك العامل إلى قوة عمل: عمّال متعدّدون ينسّقهم مدير، قابلون للتوسّع عند الطلب، توقظهم المحفّزات، وتحكمهم المواصفات. وأساس OpenAI Agents SDK نفسه، وعادة التدقيق نفسها، وجهاز Inngest العصبي نفسه. البنية ثابتة.
كيف تتقن هذا فعلاً
قراءة هذه الدورة المكثفة لا تجعلك بارعاً في بناء عمّال الذكاء الاصطناعي. استخدامها يفعل. تبدأ ببناء العامل، فتشعر بالاحتكاك وأنت تغلّفه، وتدع كل قطعة احتكاك تعلّمك إلى أي مفهوم تنتمي.
التطابق لهذه الدورة:
- «لماذا لا تُطلق دالتي حين يصل الحدث؟» ← خطأ مطبعي في اسم الحدث أو عدم تطابق المجال (المفهوم 3). قارن سلسلة اسم الحدث في
TriggerEventبتلك فيinngest_client.sendبايتاً ببايت. - «لماذا أُطلقت دالتي مرّتين للحدث المنطقي نفسه؟» ← مفتاح idempotency مفقود (المفهوم 4). أضف
id=إلى الحدث ببذرة حتمية. - «لماذا 'فقدت دالتي العمل' بعد نشر؟» ← كود خارج
step.runيؤدّي العمل (المفهوم 7). غلّف الإدخال/الإخراج والآثار الجانبية في خطوات مسمّاة. - «لماذا حُمِّل العميل مرّتين؟» ← استدعاء Stripe كان خارج
step.run، أو اسم الخطوة لم يكن فريداً (المفهومان 6 و7). انقل الاستدعاء إلىstep.runمسمّى؛ واجعل اسم الخطوة فريداً عالمياً داخل الدالة. - «لماذا يعيد OpenAI أخطاء 429 عند ذروة 9 صباحاً؟» ← throttle مفقود (المفهوم 11). أضف
throttle=Throttle(limit=N, period=timedelta(minutes=1)). - «لماذا تجوّع دفقات عميل واحد بقيّة العملاء؟» ← concurrency لكل مفتاح مفقود (المفهوم 12). أضف
Concurrency(limit=2, key="event.data.customer_id")ثانياً. - «لماذا أُطلقت بوابة HITL لديّ بصمت طوال عطلة نهاية الأسبوع؟» ← معالج مهلة مفقود يكتب إلى التدقيق (المفهوم 15). تفرّع على
approval is Noneواكتب صف التدقيق صراحةً.
ابنِ البنية قطعةً في كل مرة. لهذا الجزء 4 سبع تعليمات، لا واحدة. ابنِ العامل (D0). غلّف الوكيل في step.run (D1) وراقب ما يتغيّر حين تعطّله عمداً في منتصف التشغيل. أيقظه على حدث (D2). أضف تفرّع cron (D3)، ثم التحكّم في التدفّق (D4) حالما تكون قد بلغت حدّ معدّل فعلاً، ثم بوابة الموافقة المتينة (D5) حين يحتاج إجراء عالي المخاطر إنساناً فعلاً. كل طبقة تعلّمها الخاص. ومجمَّعةً في إعادة كتابة كبيرة واحدة، تكون جداراً.
الانضباط الذي تعلّمه هذه الدورة (الاستيقاظ على الأحداث، والعمل بمتانة، والتبويب على البشر، وreplay عند الأعطال) هو الثابت المعماري. أيّ منصّة تنفّذه، فعقد الخصائص الأربع ذاك هو ما تلتزم به فعلاً. هذا هو رهان Lindy: تبني على الأجزاء التي دامت، الدوال الصرفة، وSQL، ولغة مُكتَبة، وناقل أحداث، لا غلاف هذا الموسم. المنتج قابل للاستبدال؛ والانضباط ليس كذلك.
المرجع السريع
فاصل بين الدورة السردية والمرجع أثناء البناء. الأقسام أدناه مقصودة للبحث، لا القراءة من الأعلى إلى الأسفل. والفحوى من سطر واحد لكل مفهوم في ورقة الغشّ المطويّة في المقدمة؛ وهذا القسم هو التشخيص أثناء البناء، وشجرتا القرار، وتخطيط الملفّات.
شجرة قرار: اختر سطح المحفّز
حين يحدث شيء جديد في العالم، من أين تأتي الاستيقاظة؟
- نظام خارجي أرسل لنا طلب HTTP. ← محفّز webhook. اضبط المصدر في لوحة تحكّم Inngest؛ وأعِد تشكيل الحمولة عبر التحويل؛ واستهلك الحدث الناتج.
- جدول يقول إن الوقت حان. ← محفّز cron.
TriggerCron(cron="..."). استخدم UTC؛ وcrons الإنتاج تُطلق حتى عندما تكون خدمتك في منتصف النشر. - دالة Inngest أخرى أصدرت حدثاً أثناء تشغيلها. ← محفّز حدث.
TriggerEvent(event="ns/name.subtype"). اشترِك دالةً واحدة أو كثيرة في الاسم نفسه. - مستخدم تفاعلي ينتظر استجابة فورية. ← ليس محفّز Inngest. أبقِ الطلب/الاستجابة في نقطة نهاية الويب العادية لديك؛ وإن تضمّنت الاستجابة عملاً ثقيلاً، أطلق حدثاً من داخل الطلب وعُد فوراً، تاركاً Inngest يعالج العمل بشكل غير متزامن.
شجرة قرار: اختر بدائية الخطوة
بافتراض أن دالة تعمل وتحتاج أن تفعل شيئاً، أي استدعاء step.* تختار؟
- استدعاء ذو أثر جانبي (API، أو قاعدة بيانات، أو كتابة ملف، أو استدعاء وكيل). ←
ctx.step.run("name", fn, ...). الافتراضي. متذكَّر عند النجاح، ومُعاد المحاولة عند فشل عابر. - استدعاء OpenAI طويل على منصّة بدون خادم تحاسب على وقت الطيران. ←
ctx.step.ai.infer(...). يُفرّغ الاستدلال إلى بنية Inngest التحتية كي تستطيع عمليّة دالتك إلغاء التخصيص. - انتظر مدّة ثابتة قبل المتابعة. ←
ctx.step.sleep("name", timedelta(...)). متين؛ حوسبة صفرية أثناء الانتظار (حتى سبعة أيام على الخطة المجانية، وسنة على المدفوعة). - انتظر حدثاً خارجياً (موافقة بشرية، اكتمال دالة شقيقة). ←
ctx.step.wait_for_event("name", event="...", timeout=..., if_exp=...). متين؛ يستأنف حين يصل الحدث أو يعيدNoneعند المهلة. - حساب حتمي صرف (تنسيق سلسلة، حساب تاريخ). ← فقط اكتب الكود. لا حاجة إلى
step.run؛ ولا رسم.
مرجع سريع لمواقع الملفّات
مشروع مسطّح، أربعة ملفّات، بلا تعشيش src/:
ai-agent-nervous-system/
├── .claude/
│ └── skills/ # the four Inngest skills (installed in the Quick Win)
│ ├── inngest-setup/SKILL.md
│ ├── inngest-events/SKILL.md
│ ├── inngest-steps/SKILL.md
│ └── inngest-durable-functions/SKILL.md
├── db.py # Neon Postgres access: pooled asyncpg, load_customers, record (closed-vocabulary audit) (D0)
├── worker.py # the worker: SandboxAgent + 2 tools (D0)
├── inngest_app.py # the nervous system: Inngest functions + FastAPI host (D1-D5)
├── .env # OPENAI_API_KEY, DATABASE_URL, INNGEST_DEV=1
└── AGENTS.md # the base's rules file (read on open)
أسماء الملفّات هذه تخطيط معقول واحد، لا متطلَّب؛ وقد يستقرّ وكيلك على agent.py وmain.py بدلاً منها، وذلك جيّد. ما يهمّ هو الحدّ، لا الأسماء: كود العامل لا يستورد inngest أبداً، وملفّ واحد بالضبط يوصل الجهاز العصبي فوقه. وبذلك التخطيط، يعيش العملاء وسجلّ التدقيق في قاعدة بيانات Neon لديك (مزوَّدة في المكسب السريع، مبذورة في D0)، لا في ملفّات محلية؛ وملفّات العامل لا تتغيّر أبداً بعد D0، وكل طبقة من طبقات الجهاز العصبي (من D1 إلى D5) تحرّر ملفّ Inngest الواحد.
جدول تشخيصي، العَرَض ← السبب الجذري ← المفهوم
| العَرَض | المشتبه به الأول | المفهوم لإعادة قراءته |
|---|---|---|
| الدالة لا تُطلق أبداً حين يصل الحدث المتوقَّع | خطأ مطبعي في اسم الحدث، عدم تطابق المجال | C3 (webhooks)، C5 (fan-out) |
| الدالة تُطلق مرّتين للحدث المنطقي نفسه | مفتاح idempotency مفقود | C4 (idempotency) |
| الدالة «فقدت العمل» بعد النشر | كود خارج step.run يؤدّي العمل | C7 (التذكّر) |
| جدول cron لم يُطلق عبر نشر | خادم التطوير المحلي فقط، والإنتاج يعمل على بنية Inngest التحتية | C2 (cron) |
| العميل حُمِّل مرّتين لردّ مال واحد | استدعاء Stripe خارج step.run، أو اسم الخطوة غير فريد | C6 (step.run)، C7 (التذكّر) |
| أخطاء حدّ معدّل OpenAI أثناء ذروة 9 صباحاً | throttle مفقود | C11 (concurrency + throttle) |
| دفقات عميل واحد تجوّع بقيّة العملاء | concurrency لكل مفتاح مفقود | C12 (الأولوية + العدالة) |
| الدالة متوقّفة إلى الأبد، لا تستأنف أبداً | اسم الحدث في wait_for_event لا يطابق الحدث المُرسَل | C8 (wait_for_event)، C15 (HITL) |
| مهلة HITL أُطلقت بصمت طوال عطلة نهاية الأسبوع | معالج مهلة مفقود يكتب إلى التدقيق | D5 (بوابة ردّ المال المتينة)، C15 (HITL) |
| تشغيلات الأمس الفاشلة اختفت من لوحة التحكّم | التشغيلات تبقى حتى يُعاد تشغيلها يدوياً أو بعد نافذة الاحتفاظ | C14 (replay) |
| Replay أعاد تحميل العملاء | Replay تشغيل جديد يعيد تنفيذ كل خطوة؛ والتحميل لم يكن له مفتاح idempotency | C4 (idempotency)، C14 (replay تشغيل جديد) |
| أثر الدالة لا يُظهِر تعليمة OpenAI | أثر الخطوة يُظهِر مدخلات/مخرجات الدالة لكن بلا قياس تعليمة/رمز خاصّ بLLM | C10 (Python يستخدم step.run؛ والقياس الخاصّ بLLM يحتاج تتبّع عميل OpenAI الخاص بك؛ وآثار step.ai.wrap على مستوى التعليمة لTypeScript فقط) |
ملحق: نسب اختياري وورقة غشّ لInngest
لا تحتاج دورة الموظف الرقمي بدوام كامل لتؤدّي الجزء 4: فD0 يبني العامل من الصفر. ملاحظتان قصيرتان للسياق.
A.1: إن كنت قادماً من دورة الموظف الرقمي بدوام كامل
دورة من وكيل إلى موظف رقمي بدوام كامل تبني عامل دعم عملاء أغنى: مهارات محمولة، ونظام سجلّ Postgres، وخادم MCP مخصّص. إن أنجزتها، فلديك أصلاً عامل SandboxAgent جالس على القرص، وتستطيع تخطّي أرضية D0 الأدنى: وجّه الجهاز العصبي (من D1 فصاعداً) إلى عاملك الخاص بدلاً منها. التغليف متطابق. مكافأة واحدة: بوابة ردّ المال المتينة التي تبنيها في D5 (step.wait_for_event) تستبدل بجدول حالة-التشغيل المكتوب يدوياً من القرار العاشر الاختياري لتلك الدورة، فتستطيع حذفه. وإن كنت لم تنجز تلك الدورة، فتجاهل كل هذا؛ D0 يمنحك كل ما تحتاجه.
A.2: أساسيات خاصة بInngest تستخدمها هذه الدورة
إن بدا أي شيء أدناه غير مألوف، تصفّح صفحة الوثائق المقابلة قبل الغوص في الجزء 4.
- إنشاء عميل Inngest. نسخة
inngest.Inngest(app_id=...)واحدة لكل مشروع Python، مصدَّرة من وحدة واحدة ومستورَدة حيثما تزيّن الدوال. بداية Python السريعة. - تزيين الدالة.
@inngest_client.create_function(fn_id=..., trigger=...). والمحفّز يمكن أن يكونTriggerEvent، أوTriggerCron، أو قائمة من كليهما لدوال متعدّدة المحفّزات. ctx.step.run، وctx.step.sleep، وctx.step.wait_for_event، وctx.step.ai.infer. بدائيات الخطوة الأربع التي تشكّل 90% ممّا ستكتبه في Python. (لTypeScript خامسة،step.ai.wrap، للتتبّع الخاصّ بLLM؛ ومشاريع Python تستخدمstep.runلاستدعاءات الذكاء الاصطناعي.)inngest_client.send(events=[...]). أصدِر الأحداث من أي مكان في كودك (داخل الدوال، وداخل أدوات الوكيل، ومن سكربتات CLI). استخدمid=لidempotency.- بدء خادم التطوير.
npx inngest-cli@latest dev. يعمل على:8288. لوحة التحكّم علىhttp://127.0.0.1:8288. وMCP علىhttp://127.0.0.1:8288/mcp. وإن كان:8288مأخوذاً يستخدم8289+؛ فاضبط حينهاINNGEST_BASE_URL=http://127.0.0.1:<port>على المضيف كي يتبع، لا URL الخاص بMCP فقط.
A.3: النقلتان الصعبتان فعلاً
أصعب شيء في هذه الدورة ليس بناء جملة Inngest. بل النقلة الذهنية من الطلب إلى الحدث (المفهوم 1) ومن التنفيذ داخل العمليّة إلى التنفيذ المتين (المفهوم 6). والبناء آليّ حالما تستقرّ هاتان. أعِد قراءة المفهومين 1 و6 أولاً إن بدا أي شيء آخر أصعب ممّا ينبغي.
مساعد دراسة البطاقات التعليمية
فحص المعرفة
فحص ذاتي سريع مبوَّب على الأفكار التي مررت بها للتوّ.