امنح وكيل الذكاء الاصطناعي لديك جهازاً عصبياً
15 مفهوماً، نحو 80% من الاستخدام الحقيقي: الحواس (المحفزات)، وردود الفعل المنعكسة (التنفيذ المتين)، والتوازن (التحكم في التدفق).
لقد بنيت وكيلاً يعمل. لكنه يعمل فقط ما دمت تراقبه. تفتح Claude Code أو OpenCode، وتكتب، فيرد. وفي اللحظة التي تبتعد فيها، يتوقف. تلك الفجوة، بين وكيل تشغّله أنت وعامل يشتغل من تلقاء نفسه، هي موضوع هذه الدورة كله.
المفاجأة هي ما يسد هذه الفجوة، وليس وكيلاً أذكى. وكيلك يملك بالفعل ما يحتاجه لأداء العمل: نموذج لغوي كبير يفكّر، وأدوات وخوادم MCP ليتصرف، ومهارات لمسارات العمل التي يعرفها. ما لا يملكه هو جهاز عصبي. تأمّل جسدك أنت: دماغك يفكّر وعضلاتك تتصرف، لكن نظاماً ثانياً يعمل تحت ذلك من دونك، نبض قلبك وردود فعلك المنعكسة، تلك الإشارات التي تبقيك حياً وأنت نائم. توقّف عن الانتباه فيظل قلبك ينبض؛ أما الوكيل فلا نظير لديه لذلك، فما إن تتوقف عن قيادته حتى يتوقف. الجهاز العصبي هو النسيج الرابط الذي يغلق الحلقة من تلقاء نفسه، بلا إنسان يقود كل دورة: يستشعر العالم ويوقظ الوكيل عندما يحدث شيء، ويتفاعل بردة فعل منعكسة عندما تفشل خطوة (ويحفظ موضعه ساعات وهو ينتظر إنساناً أو واجهة بطيئة)، ويبقي الوكيل متوازناً عندما يصل خمسمئة طلب دفعة واحدة. ذلك هو الخط الفاصل بين وكيل تشغّله أنت وموظف رقمي بدوام كامل يشتغل من تلقاء نفسه. أنت تمنح وكيلك هذا الجهاز العصبي؛ ولا تعيد كتابة الوكيل. تلك هي الفكرة الواحدة التي بنيت حولها هذه الدورة.
الأداة التي تمنح وكيلك جهازاً عصبياً لها اسم تقني، محرك تنفيذ متين، ونستخدم واحداً اسمه Inngest. وتنتقل الأنماط نفسها إلى Temporal وRestate وDapr Agents. وهذه ليست مجرد صورة تعليمية. فشركة Day AI، وهي CRM مبنية للشركات الأصلية في الذكاء الاصطناعي، تصف Inngest بأنها «الجهاز العصبي» لمنتجها، وتعمل على كل جزء تعلمه هذه الدورة. وطبقة Hobby المجانية من Inngest هي أسهل مدخل للبدء: بلا بطاقة ائتمان، وخادم تطوير بأمر واحد، ولوحة تحكم تستطيع مراقبتها أثناء الكتابة.
المثال رفيع عمداً: وكيل دعم عملاء يبحث عن بضعة عملاء نموذجيين، ويصوغ رداً، ولا يصدر رد أموال إلا بعد أن يوافق إنسان. وهو رفيع بقصد: الوكيل ليس موضع الصعوبة، لذا نبقيه صغيراً ونصرف الجهد على الجهاز العصبي حوله. تبنيه هنا من الصفر. تشترك أفكاره مع دورة Digital FTE السابقة لكنه لا يفترض شيئاً منها. هيّئ البيئة مرة واحدة في المكسب السريع أدناه، ويبني الجزء 4 العامل في سبع تعليمات «الصق وراقب». الدورة تبدأ من Python على inngest-py: توجّه وكيلك البرمجي بإنجليزية بسيطة فيكتب الكود. إذا كنت تتعلم بالممارسة، فامسح الأجزاء 1-3 سريعاً واقفز إلى الجزء 4.
تعطّل وكيل واحد في منتصف مهمة أمر مزعج. أما قوة عمل من خمسين وكيلاً تتولى عملاً يواجه العملاء بلا جهاز عصبي تحتها فأمر مستحيل: إما أن تعتمد منصة تمنحك إياه، أو تقضي ستة أشهر في بناء نسخة أسوأ منها بنفسك. أربع خصائص تجعل هذا الجهاز العصبي مهماً للوكلاء على نحو فريد: Day AI، وهي CRM للشركات الأصلية في الذكاء الاصطناعي، تشغّل منتجها على كل بدائية تعلمها هذه الدورة: مسارات LLM متينة، وتنسيق بانتظار الحدث، وreplay عند الفشل، وdebounce مع throttle مع concurrency، وعدالة متعددة المستأجرين. وصل اثنان من مهندسيها المؤسسين إلى صورة الجهاز العصبي نفسها كل على حدة. إنها لغة إنتاج، لا تسمية تعليمية للدورة. تصف أطروحة Agent Factory سبعة ثوابت يجب أن يحققها أي نظام وكلاء إنتاجي. يحقق العامل الذي تبنيه هنا الثابت 4 (محرك) والثابت 5 (نظام سجل، وهو هنا سجل تدقيق صغير). تضيف هذه الدورة ثابتين آخرين، إضافة إلى جزء من الثابت 1:لماذا يحتاج وكيل الذكاء الاصطناعي إلى جهاز عصبي (أربع خصائص)
step.wait_for_event (المفهوم 15)، تبني طابور موافقات بنفسك: جدول قاعدة بيانات، وpolling، ومعالجة انتهاء المهلة، وسجل تدقيق. هذا مشروع، لا ميزة.أين تقع هذه الدورة في أطروحة 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 عددَ التشغيلات النشطة؛ ويحدّ throttle عددَ البدايات في الثانية.12 الأولوية والعدالة ترتّب الأولويةُ الطابورَ؛ ويمنح التزامنُ حسب المفتاح كلَّ مستأجر حصةً عادلة. 13 التجميع اجمع الأحداث في استدعاء دالة مجمَّع واحد لعمل جماعي رخيص. 14 إعادة التشغيل والإلغاء الجماعي أعد تشغيل التشغيلات الفاشلة بكود جديد؛ وألغِ جماعياً التشغيلات التي لم تعد تريدها. 15 بوابات HITL باستخدام step.wait_for_eventتتوقف الدالة حتى يوافق إنسان، ثم تستأنف بالقرار.
المتطلبات السابقة. أربعة أشياء، وفيما عدا ذلك تقف الدورة وحدها (يبني الجزء 4 عامله الخاص من الصفر).
- تستطيع قيادة وكيل برمجي. Claude Code أو OpenCode، مثبَّت ومصادَق عليه. وضع التخطيط، وملفات القواعد، وسير عمل اقرأ-أولاً-ثم-اكتب: إذا كان هذا الإيقاع مألوفاً، فأنت مضبوط. تغطيه الدورة المكثفة في البرمجة الوكيلة إن لم يكن كذلك.
- لديك
OPENAI_API_KEY(أو مفتاح نموذج آخر يستطيع وكيلك البرمجي استخدامه) وحساب Neon لنظام سجل العامل في Postgres. يشغّل العامل نموذجاً حقيقياً ويقرأ عملاءه وسجل تدقيقه ويكتبها في Neon. وNeon مجانية (بلا بطاقة)، وتأذن لها بنقرة متصفح واحدة أثناء الإعداد؛ سجّل في neon.com في نحو دقيقة إن لم يكن لديك حساب. أما خادم تطوير Inngest فلا يحتاج حساباً.- لديك Node.js 20+ متاح، حتى وإن كان العامل مكتوباً بلغة Python. يوزَّع خادم تطوير Inngest كأداة CLI مبنية على Node (
npx inngest-cli@latest dev).- لديك نموذج ذهني عملي للفرق بين "event-driven" و"request/response." إذا بدت عبارة «العالم يطلق حدثاً وتتفاعل معه صفر أو دالة واحدة أو دوال كثيرة» مألوفة، فأنت مضبوط. وإلا فسيعطيك المفهوم 1 الشكل.
أنجزت من وكيل إلى موظف رقمي بدوام كامل؟ لديك عامل أغنى لتغلّفه؛ يوجّه نداءٌ في نهاية الجزء 4 الجهازَ العصبي نحوه. هذه إضافة، لا شرط.
كيف تقرأ هذه الصفحة في المرور الأول، مع مسرد للمصطلحات التي ستقابلها
المرور الأول. وسّع أي شيء موسوم ب "Done when" أو "What to watch": سلوكيات قابلة للتشغيل تتحقق بها من توقعاتك. في الجزء 4 تستطيع أن تمسح سريعاً المقاطع الحاملة في قراءة أولى؛ يخبرك السرد حول كل مقطع بما تفعله الطبقة، ويكتب وكيلك الكود عندما تبني. وكتل "Try with AI" تعليمات تمديد اختيارية. هدف المرور الأول هو أن يستقر في ذهنك نموذجُ الجهاز العصبي بطبقاته الثلاث؛ أما المرور الثاني، ويداك على لوحة المفاتيح، فهو موضع البناء. يُختم كل مفهوم ب Predict (التزم بإجابة قبل أن تقرأ بقيته) أو Quick check (اختبر القاعدة التي قرأتها للتو)؛ وكلاهما موجود ليجعلك تتوقف، لا ليصححك.
مسرد (يُشرح كل مصطلح أيضاً في سياقه حيث يظهر أول مرة):
- عامل الإنتاج (Production Worker): وكيل ذكاء اصطناعي بجهاز عصبي حوله: حواس توقظه (المحفزات)، وردود فعل منعكسة تنجو من الفشل (التنفيذ المتين)، وتوازن يجعله يتوسع تحت الحمل (التحكم في التدفق).
- الحدث (Event): رسالة مسماة وغير قابلة للتغيير تصف أن شيئاً حدث. مثال:
{"name": "customer/email.received", "data": {"customer_id": "..."}}. هذا هو سطح التحفيز. - دالة Inngest: دالة Python مزيَّنة ب
@inngest_client.create_function، تعلن المحفزات والخطوات. إنها وحدة العمل المتين. - الخطوة (Step): وحدة عمل داخل دالة Inngest ملفوفة في
ctx.step.run()، أوctx.step.sleep()، أوctx.step.wait_for_event()، أوctx.step.ai.infer(). تُعاد كل خطوة وتُذكَّر مستقلةً عن غيرها. - التذكّر (Memoization): عندما تنهار دالة وتبدأ من جديد، يعيد Inngest تشغيل كود الدالة من الأعلى لكنه يعيد المخرجات المخزنة لأي
step.runخُزّنت نتيجته. تلحق الدالة بموضع الانكسار من دون إعادة العمل. - التحكم في التدفق (Flow control): سياسات لكل دالة:
concurrency(الحد الأقصى للتشغيلات النشطة)، وthrottle(الحد الأقصى للبدايات في الثانية)، وpriority(ترتيب الطابور)، وbatch_events(تجميع الأحداث قبل الاستدعاء). - إنسان داخل الحلقة (HITL / Human In The Loop): تتوقف دالة لتنتظر موافقة إنسان أو مدخله قبل المتابعة. والبدائية هي
step.wait_for_event. - إعادة التشغيل (Replay): تشغيل التشغيلات الفاشلة بوصفها تشغيلات جديدة من الأعلى، على الكود الحالي بعد إصلاح خطأ (يختلف عن إعادة المحاولة التلقائية داخل التشغيل، التي تستأنف من التذكّر). إنه زر Rerun في لوحة التحكم.
- خادم التطوير (Dev server): بيئة Inngest المحلية عبر
npx inngest-cli@latest dev. لوحة التحكم عندhttp://127.0.0.1:8288؛ ونقطة MCP عند/mcp.
محدّث حتى مايو 2026. شُغّل بناء الجزء 4 كله من البداية إلى النهاية مقابل خادم تطوير Inngest حي ونموذج حقيقي على inngest 0.5.18، وopenai-agents 0.17.3، وfastapi 0.136.3، وPython 3.12، وأداة Inngest CLI. وكل مقطع في الجزء 4 مأخوذ من ذلك البناء العامل، لا مكتوب من الذاكرة. البنية التي تعلمها هذه الدورة لا تتغير عندما يتغير SDK؛ فال SDK هو واجهة هذا العام إليها. وإذا اختلفت صفحةُ وثائق حية وهذه الصفحةُ يوماً في تفصيل من تفاصيل الصياغة، فالوثائق هي المرجع: ثبّت إصداراتك، وراجع بداية Inngest السريعة مع Python ووثائق OpenAI Agents SDK عندما تبني.
الأقسام التي تختلف بين Claude Code وOpenCode لها مفتاح تبديل؛ اختر واحدة، وتتزامن الصفحة عبر الزيارات.
المكسب السريع في خمس عشرة دقيقة: هيّئ الأساس، وشاهد ردة الفعل المنعكسة
قبل أن تقرأ المفاهيم الخمسة عشر التي تشرح لماذا تعمل هذه البنية، هيّئ البيئة التي تعمل عليها الدورة كلها وشاهد دالة متينة واحدة تنجو من تعطّل. هذا الإعداد تفعله مرة واحدة؛ ويبني الجزء 4 عامل دعم العملاء على الأساس نفسه تماماً. بنهايته سيكون لديك:
- الأساس مفتوحاً في وكيلك البرمجي، والمهارات مثبَّتة، وثلاثة خوادم MCP موصولة (Neon، وContext7، وخادم التطوير
inngest-dev)، - قاعدة بيانات Neon جديدة بجدولين،
customersوaudit_log، أنشأتهما عبر MCP ورأيتهما في الكونسول، مع كتابةDATABASE_URLفي.envليستخدمه العامل لاحقاً، - دالة متينة صغيرة واحدة (
step.run، وstep.sleep، ومضيف FastAPI) تعمل مقابل خادم تطوير Inngest، - تشغيلاً أطلقتَه وراقبتَه يتعلق عند النوم بحوسبة صفرية،
- وتشغيلاً كسرتَه عمداً، ثم راقبتَ Inngest يعيد المحاولة، فيعيد الخطوة المكتملة من التذكّر بينما تُنفَّذ الخطوة المكسورة وحدها من جديد.
تلك اللحظة الأخيرة هي بيت القصيد في الدورة كلها، مصغَّراً: ردة الفعل المنعكسة التي تراها بعينيك، خطوة تفشل والنظام يتعافى من دون إعادة العمل الذي أنهاه بالفعل. هذا ليس المثال العملي في الجزء 4 (العامل الكامل، سبع تعليمات)؛ هذه جلسة واحدة. افعلها، ثم عُد للمفاهيم.
عامل الإنتاج عمليتان جنباً إلى جنب، وفهمهما على حدة هو النموذج الذهني: مضيف دالة Python (كودك، يقدّم الدالة إلى Inngest) وخادم تطوير Inngest (الجهاز العصبي الذي يطلق التشغيلات، ويذكّر الخطوات، ويعرض لك لوحة التحكم). ووكيلك البرمجي يصل الاثنين، ويثبّت المهارات التي تعلّمه أنماط Inngest، ويحادث خادم التطوير عبر MCP الموسوم inngest-dev.
ثمة حدّ آخر مهم، وهو الحدّ نفسه الذي رسمته دورة Digital FTE. يحفظ عاملك عملاءه وسجل تدقيقه في قاعدة بيانات Neon Postgres، وثمة طريقتان متمايزتان تُمسّ بهما تلك القاعدة. يستخدم وكيلك البرمجي خادم Neon MCP ل بنائها وفحصها: إنشاء الجداول، وقراءة الصفوف، وسحب سلسلة الاتصال، كل ذلك بإنجليزية بسيطة في وقت التطوير. ويستخدم عاملك اتصال Postgres الخاص به (DATABASE_URL) لقراءتها والكتابة إليها في وقت التشغيل. لا يستدعي العامل خادم Neon MCP أبداً، ووثائق Neon نفسها صريحة في السبب: خادم MCP للتطوير والفحص، لا يُوصل أبداً داخل تطبيق يعمل. وNeon مجانية بنقرة OAuth واحدة؛ وخادم تطوير Inngest لا يحتاج حساباً بتاتاً.
احصل على الأساس وافتحه
نزّل الأساس وافتح المجلد في وكيلك البرمجي. ينفّذ الوكيل الإعداد بنفسه، من التعليمات أدناه مباشرة. تهيّئ هذا مرة واحدة: ai-agent-nervous-system/ هو مجلدك للدورة كلها، المكسب السريع والجزء 4 على حد سواء. لا تعيد التنزيل ولا فكّ الضغط.
نزّل ai-agent-nervous-system-base.zip
cd ai-agent-nervous-system
claude
يفترض هذا الأساس وكيلاً عاماً قادراً (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 مأذوناً لها. ويأتي MCP الموسوم inngest-dev متصلاً بمجرد أن يعمل خادم التطوير، وهو الخطوة التالية.
شغّل خادم التطوير، وأكّد أن الوكيل يصل إليه (نحو دقيقتين)
تضيف هذه الدورة حدّين يصل إليهما وكيلك عبر MCP: قاعدة بيانات Neon يبنيها ويفحصها، وخادم التطوير العامل يرسل إليه أحداثاً ويراقبه. لذا قبل أن تبني أي شيء، شغّلهما وأكّد أنهما حيّان.
شغّل خادم تطوير Inngest في طرفية خاصة به (إنه أداة CLI من Node؛ اتركه يعمل):
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 دقائق)
أنشئ الآن نظام سجل العامل عبر Neon MCP، ثم سلّم العامل الشيء الوحيد الذي سيحتاجه ليصل إليه لاحقاً: سلسلة اتصال. يقرأ العامل الذي تبنيه في الجزء 4 عملاءه ويكتب سجل تدقيقه هنا. الصق هذا:
الصق هذا لوكيلك البرمجي. خطّط أولاً؛ ونفّذ عند الموافقة.
على مشروع Neon جديد، أنشئ جدولين:
customers(id، وemail، وtier) وaudit_log(سجل لكل فعل يقوم به العامل). ثم استدعِ أداة Neon التي تعيد سلسلة الاتصال واكتب ذلك الرابط في.envبوصفهDATABASE_URL. استخدم أدوات Neon لكل ذلك؛ ولا تكتب لي SQL لأشغّله.
راقب: الوكيل يستدعي أدوات Neon MCP لإنشاء المشروع والجدولين (ترى استدعاءات الأدوات تلك، لا SQL كتبتَه أنت)، ثم يكتب DATABASE_URL في .env. تلك السلسلة هي التسليم: زوّد Neon MCP المخزنَ، وسيستخدم عاملك السلسلةَ، لا خادم 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 بصمت ولا تُسجَّل دالتك محلياً أبداً.
جاهز عندما: يعمل مضيف الدالة على المنفذ 8000، ويكون خادم التطوير (العامل أصلاً من الخطوة الأخيرة) قد اكتشفه تلقائياً. افتح http://127.0.0.1:8288، وانقر Functions، فتجد greet-customer مدرجاً. تقود البقية من المتصفح.
حفّزها، وشاهد خطوةً تنام عند حوسبة صفرية (أنت تقود)
أرسل حدث التحفيز. أبسط طريق هو لوحة التحكم: في http://127.0.0.1:8288، انقر Events، ثم Send event، والصق هذا، وانقر Send:
{
"name": "demo/greet",
"data": { "name": "Sara" }
}
(تفضّل البقاء في الوكيل؟ اطلب منه إرسال الحدث عبر MCP: "أرسل حدث demo/greet بالاسم Sara باستخدام أداة send_event في inngest-dev." في كلتا الحالتين يبدأ التشغيل نفسه.)
انقر Runs وافتح التشغيل الجديد. تكتمل الخطوة الأولى؛ وتعرض خطوة النوم حالة Sleeping مع وقت استئناف. لا شيء في كودك يعمل، وطرفية المضيف ساكنة، وذلك هو المغزى: انتظار متين يكلف حوسبة صفرية. بعد خمس عشرة ثانية يستأنف التشغيل من تلقاء نفسه، وتكتمل خطوة الوداع، وتنقلب الحالة إلى Completed. وتعرض لوحة Output القاموس المعاد.
اكسر خطوةً، وشاهد إعادةَ المحاولة تتخطى العمل الذي أنجزته (المردود)
اجعل الآن خطوة تفشل عمداً، لتشاهد التذكّر يحمل العمل المكتمل عبر إعادة المحاولة. الصق هذا لوكيلك:
اجعل خطوة الوداع ترمي خطأً عمداً، حتى أشاهد تشغيلاً يفشل. وأبقِ كل شيء آخر كما هو.
أرسل حدث demo/greet نفسه مرة أخرى، ثم افتح التشغيل واقرأ تتبّعه. هنا المردود، وهو في هذا التشغيل الفاشل الواحد: تعرض خطوة التحية محاولة واحدة مكتملة، وتعرض خطوة الوداع عدة Attempts، كلٌّ معادة بتراجع (يتخذ Inngest افتراضياً عدة محاولات) قبل أن يصل التشغيل إلى Failed. تأمّل ما يعنيه عدد المحاولات ذاك: خطوة التحية المكتملة مدفوعة مرة واحدة، لا مرة عند كل إعادة محاولة. ذلك تنفيذ متين تراه بعينيك. أما لماذا تعود الخطوة المكتملة فوراً بدلاً من إعادة التشغيل فهو الآلية التي ستقابلها في المفهوم 7؛ والآن، فقط شاهدها تحدث.
(لا يُظهر بناء خادم التطوير هذا شارة "memoized" منفصلة. التذكّر هو عدد المحاولات: الخطوة المكتملة الجالسة عند محاولة واحدة بينما تتسلق الخطوة المكسورة هو بالضبط ما يبدو عليه "عاد من التذكّر، لا أُعيد تشغيله" هنا.)
والآن أصلحها:
الآن أعد خطوة الوداع إلى الصيغة العاملة.
يعيد المضيف التحميل تلقائياً (ذلك ما اشتراه لك --reload؛ فإن تخطّيته، فأعد تشغيل المضيف يدوياً). أرسل حدث demo/greet جديداً فتعمل الدالة كلها الآن نظيفةً إلى Completed على الكود المصلَّح. ملاحظة صادقة واحدة عن التعافي، لأنها تخدع الناس: زر Rerun في لوحة التحكم يبدأ تشغيلاً جديداً تماماً من الأعلى بكودك الحالي، وكل خطوة تُنفَّذ من جديد من الصفر. تلك هي الأداة الصحيحة للتعافي من حادثة (نشرٌ سيئ كسر دفعةً من التشغيلات؛ تشحن إصلاحاً وتعيد تشغيلها)، لكنها ليست الاستئناف الحافظ للتذكّر. الاستئناف الحافظ للتذكّر هو إعادة المحاولة التلقائية التي شاهدتها للتو داخل التشغيل الفاشل، حيث بقيت الخطوة المكتملة في مكانها.
لقد هيّأت بيئة الدورة كلها وشاهدت الجهاز العصبي يعمل بعينيك: المهارات مثبَّتة، ومخزن Neon لديك مزوَّد ب DATABASE_URL في .env، وMCP خادم التطوير حيّ، وشغّلت دالة متينة، وشاهدت خطوة تنام بلا استهلاك حوسبة، ثم كسرت خطوة وشاهدت إعادة المحاولة التلقائية تعيد الخطوة المكتملة من التذكّر بينما تُعاد المكسورة وحدها. تلك هي البنية التي تتحدث عنها هذه الدورة. وبقية الدورة توسّعها: حواس حقيقية (cron، وwebhook، وfan-out)، وردود فعل منعكسة أقوى (استدعاء الوكيل داخل step.run)، وتوازن حقيقي تحت الحمل، وبوابة الموافقة البشرية التي تحوّل «قد يفسد الوكيل هذا» إلى «يصوغ الوكيل، ويوافق إنسان، فيصدر الفعل».
إذا لم يعمل شيء، فأربع مشكلات تغطي معظمه كله:
- خادم التطوير لا يصل إلى مضيف الدالة: أكّد أن المضيف يعمل على المنفذ 8000.
- العميل في وضع Cloud: أسقط الوكيل
is_production=Falseو.envيفتقر إلىINNGEST_DEV=1، فلا تُسجَّل الدوال محلياً أبداً. اطلب منه ضبط أحدهما (قيمةis_productionالصريحة تغلب متغيرَ البيئة). - الدالة غائبة من لوحة التحكم: لم يعد المضيف التحميل؛ أعد تشغيله.
- تشغيل يتعلق بلا خطأ وبلا تقدم: مضيف غير متزامن يتوقف بصمت؛ أعد تشغيل المضيف وخادم التطوير معاً، وشغّل مضيفاً واحداً مقابل خادم تطوير واحد. (سبب دقيق واحد: إذا كان
:8288مشغولاً وجاء خادم التطوير على8289+، فلا يكفي إعادة توجيه رابط 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 عامل دعم العملاء على هذا الأساس نفسه، في هذا المجلد نفسه: يقرأ أولئك العملاء ويكتب صفوف التدقيق تلك، ثم يغلّف الأمر كله بالجهاز العصبي الكامل، محفّز حدث حقيقي، وcron يومي يتفرّع، وتحكم في التدفق، وبوابة الموافقة البشرية المتينة على ردود الأموال. يوسّع الجزء 4 هيكلَ step.run-و-step.sleep هذا إلى عامل يؤدي عملاً حقيقياً على مخزن Neon لديك. إذا نجح هذا المكسب السريع، فالمفاهيم المقبلة تشرح لماذا تشكّلت كل قطعة بهذه الطريقة.
الجزء 1: الحواس، كيف يصل العالم إلى العامل
وكيلٌ تستدعيه بيدك يعمل حين تستدعيه. أما عامل الإنتاج الحقيقي فله حواس: يعمل حين يصل إليه العالم. عميل يرسل بريداً، أو 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 التزامن؛ ويواصل المنتج الإطلاق؛ وتصطف الأحداث).
توقّع. يستغرق عاملُ دعم العملاء لديك 8 ثوانٍ للرد على بريد: ثلاث ثوانٍ لاستدلال الوكيل، وأربع ثوانٍ لاستدعاءَي MCP، وثانية لكتابة قاعدة البيانات. في وقت الذروة تتلقى 50 بريداً في الدقيقة. إذا استخدمت نموذج الطلب (محلّل البريد يحجب نفسه حتى ينتهي الوكيل)، فكم اتصال HTTP متوازياً إلى محلّل البريد يعني ذلك؟ وإذا استخدمت نموذج الحدث (محلّل البريد يطلق حدثاً ويعود فوراً)، فكم اتصالاً؟ الثقة 1-5.
الإجابة: يحتاج نموذج الطلب إلى نحو 7 محلّلات متزامنة (50/دقيقة × 8 ثوانٍ = نحو 6.7 معالج متوازٍ، إضافة إلى هامش). أما نموذج الحدث فيحتاج إلى محلّل واحد (يطلق الحدث ويعود في نحو 10ms؛ يمتص طابور الأحداث طفرة 50/دقيقة؛ وتستهلك دوال Inngest الطابور بأي تزامن تسمح به). يفصل نموذج الحدث معدلَ الإنتاج عن معدل الاستهلاك. وهذه ليست حقيقة توسع فقط؛ إنها حقيقة معمارية. يصبح الحدث حدّاً متيناً بين «ما حدث في العالم» و«ما يفعله العامل بشأنه». اكسر المستهلك في منتصف المعالجة فيبقى الحدث موجوداً لإعادة المحاولة. أضف ثلاثة أنواع أخرى من المستهلكين فلا يلاحظ المنتج. الأحداث هي الطريقة التي تتوقف بها عن امتلاك توقيت العمل.Try with AI
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
await ctx.step.run("fan-out", fan_out_per_customer_events, customers)
return {"customers_scheduled": len(customers)}
ثلاثة أشياء جديرة بالملاحظة:
-
الجدول صيغة cron قياسية فقط. يعني
0 9 * * *الساعة 09:00 بالتوقيت العالمي كل يوم؛ ويعني*/15 * * * *كل 15 دقيقة؛ ويعني0 9 * * 1أيام الاثنين عند 09:00. يقيّم Inngest cron بالتوقيت العالمي؛ وإذا احتجت منطقة زمنية مختلفة، فهي معامل للدالة، لا مفهوم مختلف. -
ما زالت الدالة تستخدم
ctx.step.run. سواء حُفّزت ب cron أو بحدث، يبقى شكل الدالة مطابقاً. تعمل الخطوات بالطريقة نفسها. وتعمل المتانة بالطريقة نفسها. ويعمل التحكم في التدفق بالطريقة نفسها. المحفّز هو فقط كيف تبدأ الدالة. -
مخرَج cron تشغيلٌ عادي لدالة Inngest. يظهر في لوحة التحكم، وله معرّف تشغيل، وله تتبّع، ويدعم إعادة التشغيل. وإذا فشل تشغيل cron صباح الاثنين عند الخطوة 3، فسيعمل cron الثلاثاء بصورة طبيعية، ويبقى فشل الاثنين متاحاً لإعادة التشغيل بعد إصلاح الخطأ.
ماذا يحدث إذا كانت خدمتك متوقفة عندما يطلق cron؟ هذا هو السؤال الذي يفرّق مجدولاً متيناً عن آخر هشّ. تُسجَّل تشغيلات cron في Inngest بمتانة لحظة إطلاق الجدول؛ فإذا كانت نقطة نهاية دالتك غير قابلة للوصول، يعيد Inngest المحاولة بتراجع حتى ينجح أو يبلغ سقف المحاولات. لا «يفوت» cron المُطلق عند 09:00 لأن نشرك كان جارياً عند 09:00؛ ينتظر التشغيل، وتنهي نشرك، فيكتمل التشغيل. لمحفزات cron في التطوير خصوصية واحدة تستحق معرفتها: لا يطلق خادم التطوير المحلي crons إلا وهو يعمل. أما الإنتاج فيشغّلها على بنية Inngest التحتية، وهي تعمل دائماً.
فحص سريع. ثلاث عبارات. ضع لكلٍّ True أو False. (a) إذا استغرقت دالة cron مدة 45 دقيقة وكانت مجدولة كل 15 دقيقة، فستكون ثلاث نسخ متزامنة قيد التشغيل في أي لحظة. (b) يمكنك استخدام
step.sleepداخل دالة حفّزها cron لتوزيع العمل على مدار اليوم. (c) يمكن استدعاء دالة حفّزها cron يدوياً من لوحة التحكم لغرض الاختبار.
الإجابات: (a) يعتمد على سياسة التزامن: افتراضياً، يضع Inngest التشغيلات المتداخلة في الطابور؛ فإذا ضبطت concurrency=1 تسلسلت؛ وإذا ضبطت concurrency=10 توازت. والافتراضي معقول. (b) صحيح، وهو نمط شائع ل«توزيع العمل اليومي على ساعات لتنعيم الحمل». (c) صحيح: تتيح لك لوحة Inngest استدعاء أي دالة عند الطلب للاختبار، بغضّ النظر عن محفّزها.Try with AI
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, use the MCP's `invoke_function` tool to
test it manually (instead of waiting for Monday). Confirm the audit
SQL is correct by using `grep_docs` to search Inngest's docs for
"step.run" examples.
المفهوم 3: محفزات webhook، عندما ينادي العالم الخارجي
سطح التحفيز الثاني هو HTTP. يريد نظام خارجي (Stripe، أو مزوّد بريدك، أو نموذج بوابة العملاء، أو webhook من GitHub) أن يستدعي عاملك. من دون Inngest، سيكون عليك: إنشاء نقطة نهاية HTTPS، وتحليل الحِمل، والتحقق من المصدر، والكتابة إلى طابور، وكتابة عامل يستهلك من الطابور، ومعالجة إعادات المحاولة، ومعالجة idempotency، وشحن القياسات. كل واحدة منها أسبوع من عمل البنية التحتية.
مع Inngest، تكون نقطة النهاية جاهزة. تضبط webhook في لوحة Inngest برابط مثل https://inn.gs/e/<your-key>، وتوجّه Stripe (أو غيره) إلى ذلك الرابط، و_يتحول حِملُ webhook إلى حدث في تدفق أحداثك_. وأي دالة لها محفّز باسم حدث مطابق تعمل الآن.
@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"]
customer_id = ctx.event.data["customer_id"]
# Look up which support ticket originated this refund
ticket = await ctx.step.run(
"find-ticket-for-refund", lookup_ticket_by_charge, charge_id,
)
# Wake the customer-support Worker with the full context
await ctx.step.run(
"notify-support-agent",
notify_support_agent_of_refund_failure,
ticket_id=ticket["id"], charge_id=charge_id,
)
return {"ticket": ticket["id"], "action": "notified"}
التدفق: يفشل Stripe في ردّ مبلغ ← يرسل Stripe طلب POST إلى رابط Inngest webhook ← ينشئ Inngest حدثاً باسم stripe/charge.refund.failed ← تعمل الدالة أعلاه (لأن اسم الحدث يطابق محفّزها) ← تستخدم الدالة الخطوات للعثور على التذكرة وإبلاغ وكيل الدعم. لا تكتب أنت أي سباكة HTTP. لا نقطة نهاية، ولا محلّل، ولا طابور، ولا مستهلك.
نمطان متجاوران يستحقان التسمية:
- خطافات ويب عامة بصيغة JSON. إذا لم يكن المصدر مزوّداً معروفاً، توجّه أي خدمة تصدر JSON إلى النوع نفسه من نقطة النهاية وتختار اسم الحدث. الأسماء ذات النطاق بشرطة مائلة (
vendor/event.subtype) هي العرف؛ لا يفرضها شيء، لكن لوحة التحكم ترتّب بوضوح حين تلتزم به. - تحويلات webhook. إذا كان الحِمل الوارد لا يطابق الشكل الذي تريده، يتيح لك Inngest تعريف دالة "transform" تعمل على الخادم عند الاستلام وتعيد تشكيل الحدث قبل دخوله تدفقَ أحداثك. يبقي هذا كود دالتك نظيفاً من حقول خاصة بالمزود.
توقّع. يطلق 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) يعني أن الدالة لا تُحفَّز أبداً، والعَرَض صامت. وتساعد لوحة Inngest في التقاط هذا: تظهر الأحداث غير المطابقة في تدفق منفصل تستطيع تدقيقه.Try with AI
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 ودلالات الحدث، الحدث نفسه يُطلق مرتين
لا تعمل Webhooks بنمط exactly-once. إنها at-least-once: يعيد المرسِل المحاولة إذا لم يحصل على إقرار. تسقط الشبكاتُ حزماً، وتعيد الخدماتُ التشغيل، وتنتهي مهلةُ نقطة نهايتك فيعيد المرسِل المحاولة رغم أنك نجحت فعلاً. من دون idempotency، يصل أي نظام webhooks في النهاية إلى فوترة مزدوجة، أو رسائل مزدوجة، أو ردّ أموال مزدوج لأحد ما. وهذا ليس قلقاً نظرياً؛ إنه أكثر أخطاء الإنتاج شيوعاً في أنظمة الأحداث.
طبقتا دفاع، وكلتاهما مدمجتان في 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}-{request_timestamp}", # idempotency key
),
])
إذا أُرسل حدث ثانٍ بالمعرّف id نفسه داخل نافذة إزالة التكرار (24 ساعة افتراضياً)، يُسقط Inngest النسخة المكررة. الحدث المنطقي نفسه، والمعرّف نفسه، وتشغيل دالة واحد فقط.
الطبقة 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", 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", write_audit_refund_issued,
order_id=order["id"], refund=refund,
)
return {"refund_id": refund["id"]}
إذا انهارت هذه الدالة أثناء الخطوة 3، تدخل إعادةُ المحاولة الخطوةَ 1 من جديد (وتحصل على بيانات الطلب المخزنة، بلا استدعاء قاعدة بيانات)، وتدخل الخطوةَ 2 من جديد (وتحصل على بيانات ردّ الأموال المخزنة، بلا استدعاء Stripe)، ثم تشغّل الخطوة 3 فعلياً، وتعيد. تُحمَّل بطاقةُ العميل مرة واحدة، حتى لو عملت الدالة ثلاث مرات. هذه هي الميزة القاتلة. وهي ما يجعل Inngest مختلفاً نوعياً عن طابور بحلقة إعادة محاولة.
يمنحك تذكّرُ Inngest إكمالَ خطوة exactly-once من منظور الدالة: بمجرد أن يسجّل step.run خطوةً ناجحة، فلن يعيد تنفيذها. لكن هناك نافذة ضيقة. إذا استدعى جسمُ خطوتك Stripe (فيحدث الأثر الجانبي على خوادم Stripe)، ثم انهار قبل أن يسجّل Inngest النتيجة، فستعيد المحاولةُ استدعاء Stripe. من منظور Inngest، «لم تكتمل» الخطوة. ومن منظور Stripe، وقع الخصم بالفعل. النمط الإنتاجي هو تذكّرُ خطوات Inngest مع مفاتيح idempotency على مستوى المزود: ترويسة Stripe الموسومة Idempotency-Key، وإعادة استخدام MessageID في Postmark، وعقد idempotency في خادم MCP الخاص بك. تعامل مع step.run ومفاتيح idempotency لدى المزود بوصفهما مكمّلين لا بديلين: يحفظ step.run المنطقَ الداخلي لدالتك exactly-once؛ ويحفظ مفتاح idempotency لدى المزود الأثرَ الجانبي الخارجي exactly-once.
فحص سريع. صحيح أم خطأ. (a) يجعل
step.runالخطوةَ idempotent فقط إذا كانت الدالة داخله idempotent أيضاً. (b) يُعامَل الحدث ذو المعرّف المكرر خارج نافذة إزالة التكرار كحدث جديد. (c) إذا فشلstep.runفي منتصف التنفيذ (يرمي كود الخطوة استثناءً)، يخزّن Inngest الفشلَ ويعيد الخطوة في المحاولة التالية من دون إعادة تشغيل الخطوات السابقة.
الإجابات: (a) خطأ: يجعل step.run استدعاءَ الخطوة idempotent (سيعمل على الأكثر مرة واحدة عند النجاح)، لكن إذا كانت الدالة داخله غير idempotent (مثل استدعاء Stripe)، فإن ضمان at-most-once هو بالضبط ما تريده. الفكرة كلها أنك لا تضطر إلى جعل استدعاء Stripe idempotent بنفسك. (b) صحيح: نافذة إزالة التكرار في Inngest هي 24 ساعة افتراضياً؛ والأحداث ذات المعرّف نفسه بعد تلك النافذة تُعامَل كأحداث جديدة. (c) صحيح: إعادة المحاولة التلقائية مذكَّرة؛ يعرف Inngest أن الخطوة 3 فشلت في المحاولة 1 ويعيد الخطوة 3 فقط في المحاولة 2. ولا تُعاد الخطوات السابقة الناجحة. (هذه هي إعادة المحاولة داخل التشغيل، لا زرّ Replay في لوحة التحكم، الذي هو تشغيل جديد، المفهوم 14.)Try with AI
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:
from datetime import date
async def fan_out_per_customer_events(
customers: list[str],
) -> int:
events = [
inngest.Event(
name="customer/health_check.requested",
data={"customer_id": cid},
id=f"daily-health-{cid}-{date.today().isoformat()}", # idempotency
)
for cid in customers
]
await inngest_client.send(events=events)
return len(events)
تُرسَل 5,000 حدث في استدعاء send واحد. ويُطلَق 5,000 تشغيل دالة، كلٌّ بمعرّف عميله الخاص، وكلٌّ معزول، وكلٌّ قابل لإعادة المحاولة مستقلاً. ويحدّ التحكم في التدفق (المفهوم 11) كم منها يعمل متزامناً حتى لا تُذيب واجهاتِك التحتية. وتعود دالة cron في ثوانٍ؛ ويعمل fan-out بأي معدل تسمح به سياسات التحكم في التدفق لدى Inngest.
تفويض الوكيل الفرعي حالة خاصة من fan-out. داخل تشغيل عامل، يمكنك استدعاء await inngest_client.send(...) لتفويض مهام فرعية إلى أنواع عمال أخرى. ولا تنتظر الأمُّ الأبناءَ إلا إذا استخدمت step.invoke صراحةً لتشغيلهم متزامنين وجمع نتائجهم.
توقّع. لديك ثلاث دوال كلها يحفّزها
customer/email.received: وكيل دعم العملاء الذي يصوغ رداً (15 ثانية)، وعدّاد تحليلات (50ms)، و«كاشف VIP» يفحص إن كان العميل عالي القيمة (200ms). عندما يصل بريد، كيف يبدو الكمون الظاهر للمستخدم لكلٍّ منها؟ ثلاثة خيارات: (a) تتراكم الثلاث إلى نحو 15 ثانية؛ (b) تعمل الثلاث بالتوازي، والكمون الكلي نحو 15 ثانية (الأبطأ)؛ (c) تعمل كلٌّ مستقلةً بلا أي كمون مشترك بتاتاً. الثقة 1-5.
الإجابة: (c). كل دالة تشغيلها الخاص، في فتحة عمليتها الخاصة. لا يحجب وكيلُ دعم العملاء عدّادَ التحليلات؛ ولا يحجب كاشفُ VIP الوكيلَ. من الخارج، الكمون لأي دالة بعينها هو وقت تلك الدالة وحدها. ولا تنتظر أي دالة دالةً شقيقة أبداً. ولهذا يتوسع fan-out: المستهلكون معزولون. إذا انهار الوكيل، لا يتأثر عدّاد التحليلات.Try with AI
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: ردود الفعل المنعكسة، ما يحدث عندما ينكسر شيء
توقظ الحواسُ العاملَ. وتجعل ردودُ الفعل المنعكسة العاملَ ينجو مما يأتي تالياً. يستدعي عاملٌ وكيلاً، فيستدعي الوكيلُ بضع أدوات، فتستدعي الأدواتُ قاعدةَ بيانات وواجهةَ دفع ونموذجاً: عدة استدعاءات خارجية في دورة واحدة، أيٌّ منها قد يفشل. من دون المتانة، فشلٌ عابر واحد في منتصف الدورة يعيد المسار كله من الأعلى. ردة الفعل المنعكسة تلقائية: تتصرف بسرعة، بلا حاجة إلى أن يقرر عقلُ الوكيل. وذلك ما يمنحك إياه التنفيذ المتين. المتانة هي الخاصية التي تقول: عندما يفشل شيء في منتصف التنفيذ، يبقى العمل المكتمل مكتملاً، ويستأنف التنفيذ من موضع الانكسار. يقدّم Inngest هذا ببدائية واحدة (step.run) وآلية تذكّر تحتها. يشرح الجزء 2 الاثنين، إضافة إلى المتغيرات الزمنية (step.sleep، وstep.wait_for_event)، ودلالات إعادة المحاولة، وبدائيات step.ai.
ملاحظة ضغط المرور الأول. إذا كنت تمسح سريعاً، فالمفهومان الحاملان هما 6 (
step.run) و7 (التذكّر). والمفاهيم 8-10 تبني عليهما. اقرأ 6 و7 بعناية؛ والبقية ستُقرأ سريعاً بمجرد أن يستقرّ ذانك في ذهنك.
المفهوم 6: step.run ونموذج الدالة المتينة
دالة Python عادية تعمل مرة واحدة، من الأعلى إلى الأسفل. وإذا انهارت في منتصفها، تبدأ من الأعلى. وإذا أجرت ثلاثة استدعاءات API قبل أن تنهار، فالمحاولة التالية تجري تلك الاستدعاءات الثلاثة من جديد، وتدفع مقابلها، وربما تخصم من أحدٍ مرتين، من جديد.
دالة Inngest متينة. كل عملية تريد لها نقطةَ تحقق تُلَفّ في step.run(name, fn, ...). تظل الدالة تعمل من الأعلى إلى الأسفل في كل محاولة، لكن الخطوات التي اكتملت بالفعل تعيد مخرجاتها المخزنة بدلاً من إعادة التنفيذ. تلحق الدالة بموضع الانكسار، ثم تواصل إلى الأمام.
@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)
response = await ctx.step.run(
"run-agent",
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", 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", post_slack_for_review, response=response,
)
return {"status": "drafted", "reviewer_notified": True}
خمس خطوات. كلٌّ لها نقطة تحقق مستقلة.
ما الذي تشتريه لك المتانة هنا، في ثلاثة سيناريوهات فشل:
-
السيناريو A: خطوة الوكيل ترمي انتهاء مهلة. من دون لفّ استدعاء الوكيل في
step.run، تعيد المحاولةُ التالية لهذه الدالة تحميلَ العميل، وتحميلَ المحادثة، وتعيد تشغيل الوكيل من الصفر، فتدفع مقابل رموز OpenAI مرتين لعمل أنجزه الوكيل جزئياً أصلاً. ومعstep.run، يُذكَّر تحميلُ العميل والمحادثة (لا تُعاد الخطوتان 1-2)؛ تُعاد الخطوة 3 وحدها. وتعالج إعاداتُ Inngest التلقائية أخطاءَ OpenAI العابرة من دون أن يعرف كودك. -
السيناريو B: عملية الدالة تُقتل بين الخطوة 3 والخطوة 4 (نشرٌ صدر، أو أُعيد تشغيل عقدة، أو نفدت ذاكرة الحاوية). من دون المتانة، يضيع ردُّ الوكيل ويظل بريد العميل بلا جواب حتى يلاحظ أحدٌ. ومع المتانة، تستأنف الدالة بعد إعادة التشغيل: تعيد الخطوات 1 و2 و3 مخرجاتها المخزنة في ميلي ثوانٍ، وتعمل الخطوة 4 فعلياً، وتعمل الخطوة 5 فعلياً، فيحصل العميل على الرد المصاغ.
-
السيناريو C: يعيد Slack رمز 503 على الخطوة 5. من دون
step.run، إما أن تضيّع العمل أو تكتب منطق إعادة-محاولة-وتراجع بيدك لاستدعاء Slack تحديداً. ومعstep.run، يعيد Inngest الخطوة 5 بتراجع أسّي حتى يتعافى Slack؛ وفي الأثناء تبقى الخطوات 1-4 مكتملة ولا تُعاد. الردُّ المصاغ موجود أصلاً في قاعدة البيانات؛ والتنبيه هو الشيء الوحيد المعلَّق.
لا تكتب أي حلقات إعادة محاولة، ولا أي فحوص «هل فعلت هذا أصلاً»، ولا أي آلات حالة. آلة الحالة هي تسلسلُ استدعاءات step.run. كل خطوة عقدة؛ وكل انتقال متين.
القاعدة الوحيدة ل step.run. الدالة الممرَّرة إلى step.run ينبغي أن تكون حتمية بالنظر إلى مدخلاتها: استدعاؤها مرتين بالوسائط نفسها ينبغي أن ينتج النتيجة نفسها.
- وذلك تلقائي للدوال النقية.
- وتلقائي لاستدعاءات API ال idempotent (مفتاح
idempotency_keyفي Stripe، وأدوات خادم MCP الخاص بك). - ويتطلب حذراً لأشياء مثل «أنشئ معرّفاً عشوائياً» أو «استدعِ نموذجاً بدرجة حرارة افتراضية» (قد تنتج إعادةُ المحاولة مخرجاً مختلفاً عن المحاولة الأصلية، وهذا يهمّ أحياناً).
عندما لا تكون العملية حتمية، اجعلها حتمية: مرّر بذرة، أو ولّد القيمة العشوائية مسبقاً خارج الخطوة، أو اقبل أن تختلف إعادة المحاولة عن الأصل (وغالباً لا بأس بذلك لرد وكيل).
فحص سريع. صحيح أم خطأ. (a) يُعاد تنفيذ جسم الدالة من الأعلى في كل إعادة محاولة، بما في ذلك كل عمليات الاستيراد وإسنادات المتغيرات خارج استدعاءات
step.run. (b) إذا استغرقت خطوة 30 ثانية لتكتمل، وانهارت الدالة عند الثانية 25، فتواصل إعادةُ المحاولة تلك الخطوة من الثانية 25. (c) تُخزَّن مخرجاتstep.runفي بنية Inngest التحتية، لا في تطبيقك.
الإجابات: (a) صحيح، ولهذا تبقي العمل داخل step.run. الكود خارج step.run يُعاد تشغيله في كل إعادة محاولة؛ والكود داخله يعمل مرة لكل محاولة ويُذكَّر عند النجاح. (b) خطأ: step.run هو الوحدة الذرّية؛ إذا قُوطعت خطوة، تعيد المحاولةُ تشغيلَ الخطوة كاملةً. وإذا كانت خطوتك طويلة جداً بحيث لا يُسمح بإعادة تشغيلها، فقسّمها إلى خطوات أصغر. (c) صحيح: مخزن مخرجات الخطوة جزء من Inngest، لا من قاعدة بياناتك. ولهذا تستطيع إعادة تشغيل التشغيلات حتى بعد أن يتغير مخطط قاعدة بياناتك.Try with AI
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 مخزن تذكّره: «هل ثمة نتيجة مخزنة للخطوة
load-customerفي هذا التشغيل؟» لا توجد. - تعمل الدالة
load_customer_by_id("c-4429"). وتعيد{"id": "c-4429", "tier": "pro", ...}. - يكتب Inngest تلك النتيجة في مخزن التذكّر، مفتاحها
(run_id, step_name="load-customer"). ثم يعيد النتيجة إلى كودك.
إذا انهارت الدالة بعد الخطوة 3 وأعاد Inngest المحاولة، ففي المحاولة الثانية يُعاد تشغيل جسم الدالة من الأعلى. وعندما يبلغ التنفيذ السطر نفسه، تحدث ثلاثة أشياء مختلفة:
- يفحص Inngest مخزن تذكّره: «هل ثمة نتيجة مخزنة للخطوة
load-customerفي هذا التشغيل؟» نعم، خُزّنت في المحاولة 1. - الدالة
load_customer_by_id("c-4429")لا تعمل. لا يحدث استدعاء قاعدة البيانات. - يعيد Inngest النتيجة المخزنة إلى كودك في ميلي ثوانٍ.
ولهذا تكون إعاداتُ المحاولة رخيصة: العمل المكلف مخزَّن أصلاً. ولهذا تكون المتانة صحيحة: العمل المكلف لا يحدث مرتين. ولهذا يكون «إعادةُ تشغيل جسم الدالة من الأعلى إلى الأسفل» مقبولاً رغم أنه يبدو مهدِراً: العمل داخل الخطوات لا يُعاد تشغيله فعلاً؛ بل كود التنسيق بين الخطوات وحده.
التضمين الذي يفاجئ المستخدمين الجدد. الكود خارج step.run يعمل في كل محاولة. إذا فعلت هذا:
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
# ANTI-PATTERN: this runs on every retry. 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 دولار للاستدعاء وأعادت الدالةُ المحاولة 5 مرات، فقد أنفقت للتو 0.50 دولار في جلب البيانات نفسها خمس مرات. الإصلاح أن تلفّ الشيء المكلف في خطوته الخاصة:
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؛ ولا تدفع إعاداتُ المحاولة مقابلها من جديد.
اسم الخطوة هو مفتاح التذكّر. ولهذا يجب أن تكون أسماء الخطوات فريدة داخل دالة. إذا كان لديك استدعاءان step.run("load-customer", ...) في الدالة نفسها، فسيعيد Inngest مخرجَ الأول المخزن لكلا الاستدعاءين. وهذا غالباً ليس ما تريده. وإذا كانت لديك حلقة تستدعي خطوة N مرة، فسمِّها بأسماء فريدة (step.run(f"load-customer-{i}", ...)) ليكون لكل تكرار فتحة تذكّر خاصة به.
توقّع. لدالتك ثلاث خطوات. الخطوة 1 (
load-customer) تكلّف 0.01 دولار في استدعاءات قاعدة البيانات وتستغرق 100ms. الخطوة 2 (run-agent) تكلّف 0.20 دولار في رموز OpenAI وتستغرق 12 ثانية. الخطوة 3 (save-draft) تكلّف 0.005 دولار في استدعاءات قاعدة البيانات وتستغرق 50ms. تفشل الخطوة 2 بنسبة 30% بسبب حدود معدل OpenAI؛ يعيد Inngest المحاولة بتراجع. ما فرقُ الكلفة بين (a) لفّ الثلاث فيstep.runو(b) لفّ الخطوة 2 وحدها فيstep.run؟ الثقة 1-5.
الإجابة: مع (a)، تكلّفك إعادةُ محاولة واحدة كلفةَ الخطوة 2 وحدها (0.20 دولار). يُذكَّر العميل وsave-draft؛ ولا يُعادان. ومع (b)، تكلّفك كل إعادة محاولة الخطوتين 1 و3 إضافةً إلى الخطوة 2: 0.215 دولار لكل إعادة محاولة. وعلى ألف بريد بمعدل إعادة محاولة 30%، ذلك فرقٌ يبلغ نحو 4.50 دولار من الهدر الصرف، إضافةً إلى التعقيد التشغيلي في معرفة ما الذي كُتب جزئياً عندما عملت الخطوة 3 مرتين. لُفّ كل ما لا تريد إعادة تنفيذه في step.run. ليس اختيارياً بمجرد أن تفهم الآلية.Try with AI
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"]}
ما الذي يحدث:
- تبلغ الدالةُ
wait_for_event. تعلّق. حوسبة صفرية مستهلكة. - ينظر إنسانٌ إلى تنبيه Slack، وينقر "Approve" في واجهة الإدارة لديك، فتستدعي واجهتُك
inngest_client.send(events=[Event(name="refund/approval.decided", data={"request_id": "...", "approved": True})]). - يطابق Inngest الحدثَ بالدالة المنتظِرة (يضمن
if_expأن أحداث هذا request_id وحدها تطابق) ويستأنف الدالة بالحدث بوصفه قيمةapprovalالمعادة. - تواصل الدالةُ إلى خطوة ردّ الأموال. ويحدث ردّ Stripe بعد أن وافق الإنسان.
step.sleep وstep.wait_for_event مهلٌ لا تدفع مقابلها. تبدو الدالة متزامنة في كودك («انتظر ثلاثة أيام، ثم أرسل البريد»)، لكن دلالات وقت التشغيل غير متزامنة ومتينة. وهذا أحد الأمرين اللذين يشتهر بهما Inngest (والآخر إعادات المحاولة المتينة). من دونه، البديل طابور مع آلة حالة مع قاعدة بيانات مع poller، وكنت ستكتب ألف سطر بدل ثلاثة.
فحص سريع. ثلاث عبارات. ضع لكلٍّ True أو False. (a) إذا ضُبط
step.sleepل 30 يوماً وأُعيد نشر خدمتك خمس مرات في تلك الأيام الثلاثين، فيستمر النومُ بلا انقطاع على خطة مدفوعة. (b) إذا انتهت مهلةstep.wait_for_event، ترمي الدالةُ استثناءً. (c) يمكن لاستدعاءينstep.wait_for_eventفي الدالة نفسها أن ينتظرا الحدث نفسه في آن.
الإجابات: (a) صحيح على خطة مدفوعة: تُخزَّن حالات النوم في بنية Inngest التحتية، لا في ذاكرة خدمتك، فلا تضيّعها إعاداتُ النشر. لاحظ سقف الطبقة: نومُ 30 يوماً مقبول على خطة مدفوعة لكنه يتجاوز سقفَ نوم سبعة أيام في خطة Hobby المجانية. (b) خطأ: عند انتهاء المهلة، يعيد wait_for_event القيمة None. يفحصها كودك ويقرر ما يفعل (رفض، أو تصعيد، أو موافقة افتراضية، أياً كانت السياسة). (c) صحيح، لكنه مريب: كلاهما سيُطلَق عندما يصل حدث مطابق. إذا كان للاستدعاءين if_exp مختلفان فلا بأس. وإذا كانا متطابقين فأنت على الأرجح أمام فرصة لإعادة الهيكلة.Try with AI
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 (لا تعد المحاولة أبداً)، أو أنواع استثناءات محددة لا ينبغي إعادتها.
@inngest_client.create_function(
fn_id="charge-customer",
trigger=inngest.TriggerEvent(event="order/checkout.completed"),
retries=2, # only retry twice; this involves Stripe; don't keep hammering
)
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 StripeCardDeclinedError as e:
# A declined card is not a transient failure. Don't retry.
# Mark the order as failed in our database and emit an event
# for the dunning flow.
await ctx.step.run(
"mark-failed", 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 صراحةً أن يتخطى إعاداتِ المحاولة لاستثناء مرميّ.
النمط 2: أخطاء على مستوى الخطوة مقابل مستوى الدالة. الخطوة التي ترمي تُعاد محاولتها. وبعد استنفاد إعادات المحاولة على مستوى الخطوة، تفشل الدالة. أحياناً تريد لدالة أن تنجو من خطوة فاشلة: تسجّل الفشل، وتعلّم العمل بأنه «جزئي»، وتواصل. لُفّ step.run في try/except. تظل الخطوة تنال إعاداتِ محاولتها؛ وإذا فشلت كلها، ينتشر الاستثناء إلى كتلة catch لديك، حيث تقرر ما تفعل.
النمط 3: dead-letter وإعادة التشغيل. عندما تفشل دالة تماماً، لا تختفي. تدخل عرض «التشغيلات الفاشلة» في لوحة Inngest، مع التتبّع الكامل، وكل مخرجات الخطوات، والاستثناء، وزرّ Replay. وبعد أن تشحن إصلاح خطأ، يمكنك إعادة تشغيل التشغيلات الفاشلة: كلٌّ يُعاد من الأعلى على الكود المصلَّح (تشغيل جديد، لا استئناف حافظ للتذكّر، وذلك التمييز هو المفهوم 14). هذا نمط «طابور الرسائل الميتة» من الطوابير التقليدية، إلا أنك لا تكتب معالِج الرسائل الميتة. تصلح الخطأ فقط وتعيد التشغيل، مبقياً الخطوات ذات الآثار الجانبية idempotent حتى لا يضاعف تشغيلٌ مُعاد الفعلَ.
توقّع. تستدعي دالتك Stripe في الخطوة 2 وخدمةَ بيانات عملائك في الخطوة 4. يعيد Stripe رمز 503 (الخدمة غير متاحة، عابر) في المحاولة الأولى للخطوة 2. تعيد الخطوة 2 المحاولة 4 مرات بتراجع أسّي (نحو 1s، و2s، و5s، و12s)؛ وفي المحاولة الرابعة، يعود Stripe، فينجح الخصم. الآن تعمل الخطوة 4، وخدمة البيانات متوقفة برمز 500. هل يعيد Inngest محاولةَ الدالة كلها، أم الخطوة 4 فقط؟ وكم مرة؟ الثقة 1-5.
الإجابة: الخطوة 4 فقط، وتنال ميزانية إعادة محاولتها الخاصة. لا تتشارك الخطوات إعاداتِ المحاولة. إعاداتُ الخطوة 2 الأربع مستقلة عن الخطوة 4. سيعيد Inngest محاولةَ الخطوة 4 (افتراضياً نحو 4 مرات)، فإذا عاد خادمُ MCP، تكتمل الخطوة 4، وتنجح الدالة. وخصمُ Stripe من الخطوة 2 لا يُعاد إصداره، لأن مخرج الخطوة 2 ذُكّر بعد إعادة محاولتها الناجحة. يُخصَم من العميل مرة واحدة بالضبط حتى لو أنفقت الدالةُ 20 ثانية عبر إعادات المحاولة.Try with AI
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، وكتابات الملفات، واستدعاءات الوكيل. ويشحن Inngest أيضاً بدائيات خطوة خاصة بالذكاء الاصطناعي تعالج الأنماط التي تميل إليها استدعاءاتُ LLM: إعادات المحاولة عند حدود المعدل، وقابلية الملاحظة للتعليمات والردود، و(اختيارياً) وكالة الاستدلال التي تقلّل كلفة حوسبة serverless.
ملاحظة مهمة عن 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. (محقَّق مقابل وثائق AI Inference حتى مايو 2026.)
step.run لاستدعاءات OpenAI في Python (النمط الموصى به). تجري دالتك استدعاءَ OpenAI داخل ctx.step.run("name", fn, ...). يتتبّع Inngest مدخلات الخطوة ومخرجاتها (الوسائط التي مرّرتها وما أُعيد)، ويعيد المحاولة عند الأعطال العابرة، ويذكّر النتيجة حتى لا تدفع إعاداتُ المحاولة للخطوات اللاحقة كلفةَ OpenAI من جديد. وتُسجَّل التعليمةُ والردُّ بوصفهما مدخل/مخرج الخطوة في لوحة التحكم:
from openai import AsyncOpenAI
oai = AsyncOpenAI()
async def call_openai_summary(thread_text: str) -> str:
"""A normal async function. Inngest doesn't care that this is an LLM call."""
response = await oai.chat.completions.create(
model="gpt-5",
messages=[
{"role": "system", "content": "Summarize this support thread in 3 sentences."},
{"role": "user", "content": thread_text},
],
)
return response.choices[0].message.content
@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: list = await ctx.step.run(
"load-thread", load_thread, ctx.event.data["thread_id"],
)
# The OpenAI call is wrapped in step.run. Inngest sees this as a step:
# the inputs (formatted thread text) are recorded, the output (summary
# string) is recorded, the call is memoized on success, and retries are
# automatic on transient failures.
summary: str = await ctx.step.run(
"openai-summary", call_openai_summary, format_thread(thread),
)
return {"summary": summary}
في لوحة التحكم، يُظهر هذا التشغيل تتبّعَ خطوات الدالة (load-thread متبوعةً ب openai-summary) مع مدخلات كل خطوة ومخرجاتها. إذا أعاد OpenAI رمز 429 (محدود المعدل)، يعيد Inngest محاولةَ openai-summary بتراجع تلقائياً: دلالات التذكّر نفسها من المفهوم 7، فلا تضاعف إعاداتُ المحاولة فوترةَ خطوة load-thread السابقة. ما لا تحصل عليه (مقارنةً ب step.ai.wrap في TypeScript): قياسات تلقائية خاصة ب LLM مثل عدد الرموز، واسم النموذج، والتتبّعات الخاصة بالمزود مفصَّلةً في عرض الذكاء الاصطناعي بلوحة التحكم. ولمعظم أحمال Python الإنتاجية، يغطي هذه الفجوةَ تتبّعُ الخطوة القياسي إضافةً إلى قياسات عميل OpenAI الخاص بك (مثل تتبّع OpenAI Agents SDK).
لأن step.run يسجّل مدخلات كل خطوة ومخرجاتها في مخزن الملاحظة لدى Inngest، فإن المحتوى الذي تمرّره عبر خطوة يُخزَّن ويظهر في لوحة التحكم. إذا تضمّنت تعليمتك بياناتٍ شخصية (أسماء، وبريد، وعناوين)، أو أسراراً (مفاتيح API، ورموز داخلية)، أو بياناتٍ تعاقدية أو مالية، أو محتوى مُنظَّماً (HIPAA، وبيانات بنطاق GDPR، وPCI)، فلا تمرّر المحتوى الخام إلى جسم الخطوة. أخفِه، أو جزّئه، أو لخّصه، أو مرّر مرجعاً (customer_id وticket_id، لا نصّ التذكرة كاملاً) وأعد تحميل المحتوى الحساس داخل جسم الخطوة من مخزنك المعتمد، حيث ضوابطُ الاحتفاظ والوصول تخصّك أنت لتضبطها. وتنطبق الانضباطيةُ نفسها على تتبّع OpenAI Agents SDK إن فعّلتَه. عامل تتبّعات الخطوة كما تعامل أي سجل إنتاج: مفيد افتراضياً، ومنظَّم بالسياسة.
step.ai.infer: أداة متخصصة لخفض كلفة serverless (مدعومة في Python). نادراً ما تلجأ إليها؛ ف step.run هو الافتراضي لكل استدعاء ذكاء اصطناعي في هذه الدورة. توجد step.ai.infer لحالة واحدة محددة: بدلاً من استدعاء OpenAI من عملية دالتك، تطلب من بنية Inngest التحتية أن تجري الاستدعاء، فبينما الطلب جارٍ تستطيع عمليةُ دالتك أن تُفرغ مواردها. على منصات serverless (Vercel، وCloudflare Workers، وAWS Lambda) التي تفوتر مقابل الوقت الجاري، يوفر هذا كلفةَ حوسبة أثناء الانتظار. وللاستدلالات الطويلة (Deep Research، ودفعات تضمين كبيرة) تكون الوفورات حقيقية. أما للاستدعاءات دون الثانية، فيضيف كموناً بلا فائدة تُذكر.
إن لجأت إليها يوماً، فاسحب التوقيع الدقيق من وثائق AI Inference لإصدارك المثبَّت: تعيش في فضاء الأسماء التجريبي inngest.experimental.ai ولم تُستخدَم في بناء هذه الدورة.
فحص سريع. صحيح أم خطأ. (a) في Python،
ctx.step.run("name", call_openai, ...)يجعل استدعاءَ OpenAI متيناً، ومُعاداً عند الأعطال العابرة، ومذكَّراً عند النجاح. (b)step.ai.inferشرطٌ صارم لاستخدام Inngest مع OpenAI Agents SDK في Python. (c) استبدالُstep.runبstep.ai.inferلاستدعاء OpenAI واحد يجعل تشغيلَ الدالة أرخص دائماً.
الإجابات: (a) صحيح: هذا هو نمط Python الموصى به. يدخل استدعاءُ OpenAI داخل جسم الخطوة؛ ويعامل Inngest الخطوةَ كلها بوصفها وحدةَ العمل. (b) خطأ: step.run يكفي لمعظم الحالات. step.ai.infer تحسينٌ لكلفة حوسبة serverless، لا شرط. ويستخدم تكاملُ OpenAI Agents SDK في المثال العملي step.run العادي. (c) خطأ: توفّر step.ai.infer مالاً فقط عندما (i) تكون على منصة serverless تفوتر مقابل الوقت الجاري و(ii) يكون الاستدعاء طويلاً بما يكفي ليغلب توفيرُ نقل الطلب العبءَ التنسيقي المضاف. وللاستدعاءات دون الثانية على خوادم دائمة التشغيل، يفوز step.run العادي.Try with AI
With my AI coding assistant: take a customer-support agent
invocation and produce TWO versions of the Inngest function that
calls it:
Version A: Wrap the Runner.run call in step.run (the recommended
Python pattern: durable, retried on transient failures, memoized;
you get the standard step trace).
Version B: For comparison, write a SEPARATE small Inngest function
that calls a single OpenAI completion via step.ai.infer (the
Python-supported step.ai primitive that offloads inference to
Inngest's infrastructure to save serverless compute cost).
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: التوازن والتعافي، نطاق الإنتاج
التوازن هو الطبقة الثالثة: يبقي العامل سليماً تحت الحمل، كما يبقي جسدُك نفسه ثابتاً حين تجهده. يمنع التزامنُ العاملَ من إذابة الأنظمة التحتية. ويبقيك الخنقُ بعيداً عن جدران حدود المعدل. وتمنع الأولويةُ والعدالةُ عميلاً ثرثاراً واحداً من تجويع الجميع. ويحوّل التجميعُ «10,000 حدث عند منتصف الليل» إلى «100 تشغيل دالة يمكن إدارتها». وتحوّل إعادةُ التشغيل «خطأ الأمس كلّفنا 200 تفاعل فاشل» إلى «أصلحناه؛ استؤنفت 200 محادثة». وتعلّق بواباتُ الموافقة البشرية الوكيلَ حتى يوافق إنسان. مفاهيم الجزء 3 الخمسة تمنحك سياسات الإنتاج التي تحوّل عاملاً يعمل إلى عامل تستطيع وضعه أمام عملاء يدفعون.
المفهوم 11: التزامن والخنق
التزامن هو أقصى عدد من تشغيلات دالة يمكن أن تُنفَّذ في آن. والخنق هو أقصى عدد من التشغيلات التي يمكن أن تبدأ في وحدة الزمن. وكلاهما يُضبط لكل دالة بسطر واحد لكلٍّ. وكلاهما أكثر فجوات الإنتاج شيوعاً عندما تنتقل الفِرَق من النموذج الأولي إلى النطاق.
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 من هذه الدوال تعمل في أي لحظة. ينتظر الحدثُ الحادي عشر في الطابور حتى تنتهي إحدى العشر. ويقول throttle=100/minute: على الأكثر 100 تشغيل جديد يبدأ في الدقيقة. ينتظر الحدثُ الحادي بعد المئة حتى لو كان ثمة فسحة تزامن.
لماذا يهمّ الاثنان عملياً. يحمي التزامنُ الأنظمةَ التحتية: إذا حادث عاملُ دعم العملاء لديك OpenAI وPostgres، فإن 1,000 تشغيل متزامن تعني 1,000 استدعاء OpenAI متزامن و1,000 اتصال Postgres متزامن. ستستنفد حدّ معدل OpenAI، أو تستنفد مجمع اتصالاتك، أو كليهما. ويحمي الخنقُ من الطفرات: إذا وصل 500 بريد عميل عند 9:00 صباحاً بالضبط، فلا تريد 500 دالة تبدأ في الثانية ذاتها؛ يُنعّم الخنقُ معدلَ البدء.
التزامن حسب المفتاح. حدّ concurrency واحد ينطبق على الدالة عالمياً. وثمة نمط أكثر إثارة هو التزامن حسب المفتاح: الحدّ بخاصية ما من خصائص الحدث.
@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
concurrency=[
inngest.Concurrency(limit=10), # global cap
inngest.Concurrency(limit=2, key="event.data.customer_id"), # per-customer cap
],
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
...
يقول هذا: على الأكثر 10 دوال تعمل عالمياً، و على الأكثر 2 لكل عميل في آن. إذا أرسل عميلٌ واحد 100 بريد في الدقيقة، فلا تُعالَج إلا 2 من بريده في آن؛ ويصطف ال 98 الباقون خلفه. وفي الأثناء، تتدفق بُرُد العملاء الآخرين بصورة طبيعية؛ ولا يحجبهم العميلُ الثرثار. هذه عدالة متعددة المستأجرين في سطرين من الكود. ويطوّر المفهوم 12 النمطَ أكثر.
فحص سريع. ثلاث عبارات، صحيح أم خطأ. (a) إذا ضبطت
concurrency=10ووصل 1,000 حدث دفعة واحدة، فيُسقَط 990 منها. (b) يقلّل الخنقُ وحدودُ التزامن كلاهما إجماليَّ الإنتاجية. (c) يتطلب التزامنُ حسب المفتاح مفتاحاً حتمياً من بيانات الحدث.
الإجابات: (a) خطأ: لا تُسقَط الأحداث؛ تصطف. طابور Inngest متين؛ ينتظر ال 990 حدثاً حتى تنفتح فتحات التزامن. (b) خطأ. يحدّ الخنقُ معدلَ البدء؛ ويحدّ التزامنُ التشغيلاتِ الجارية. لا يُسقط أيٌّ منهما عملاً؛ بل يشكّل كلاهما متى يُنفَّذ العمل. والإنتاجية على نافذة طويلة لا تتغير إذا كان متوسط حملك دون الحدود. والإنتاجية على ذروة تُشكَّل: تمتص الطابورُ الطفراتِ. (c) صحيح: يُقيَّم تعبيرُ المفتاح على بيانات الحدث؛ وعليه أن ينتج سلسلةً ثابتة للنطاق المنطقي نفسه (customer_id مناسب؛ current_timestamp ليس كذلك).Try with AI
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: الأولوية والعدالة، التوسع متعدد المستأجرين
تعمل حدودُ التزامن. ويضيف التزامنُ حسب المفتاح عدالةً أساسية. وتحتاج الأنظمةُ متعددةُ المستأجرين بمستوى إنتاجي إلى أكثر: أولويات (لا ينبغي أن ينتظر عملاءُ Enterprise خلف الهواة على الحوسبة نفسها) وجدولة الحصة العادلة (لا يستطيع مستأجر واحد احتكارَ النظام حتى ضمن حدّ تزامنه).
الأولوية. يقيّم Inngest تعبيرَ أولوية على كل حدث؛ والتشغيلات ذات الأولوية الأعلى تقفز الطابور أمام التشغيلات ذات الأولوية الأدنى.
@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
concurrency=[inngest.Concurrency(limit=10)],
priority=inngest.Priority(
# Enterprise tier = high priority; Pro = 0; Free = low priority
run="100 - (event.data.customer_tier_priority * 100)",
),
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
...
عندما يكون في طابور التزامن 50 تشغيلاً منتظِراً، تذهب تشغيلاتُ عملاء Enterprise أولاً، ثم Pro، ثم Free. وداخل الطبقة نفسها، ينطبق ترتيب FIFO. لا تتجاوز الأولويةُ حدودَ التزامن أو الخنق؛ بل تقرر فقط أيُّ التشغيلات المنتظِرة ينال الفتحةَ الحرة التالية. لا يزال عميلُ Enterprise ينتظر فتحةً تنفتح؛ لكنه ينال التالية.
جدولة الحصة العادلة. عندما يكون لديك مئاتُ المستأجرين يتنافسون على مجمع التزامن العالمي نفسه، لا يكفي FIFO مع الأولوية. لا يزال مستأجر واحد يرسل طفرةً قادراً على شغل معظم الفتحات لدقائق. تمنح جدولةُ الحصة العادلة، المنفَّذة عبر معامل key على التزامن بحجم مدروس، كلَّ مستأجر شريحةً مضمونة:
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.
الإجابة: إنه قرار متعدد المستويات. أولاً، يعني سقفُ كل عميل البالغ 2 أن على الأكثر 2 من أحداث كل عميل مؤهلة للتشغيل في آن. فيكون مجمعُ المرشحين: 2 من A، و2 من B، و2 من C: ستة تشغيلات مؤهلة فوراً. ثانياً، تقرر الأولويةُ أيُّ هذه الستة يملأ الفتحاتِ الأولى: يعمل اثنا B أولاً (Enterprise)، ثم اثنا A واثنا C (Free، بترتيب FIFO). فعند t=0: يعمل 2 من B، ثم يبدأ 2 من A، ثم يبدأ 2 من C. الإجمالي: 6 نشطة. وكلما انتهى أحدها، صار الحدثُ التالي في طابور عميله مؤهلاً وامتلأت الفتحةُ التالية بالأولوية. هذا نوعُ السياسة التي هي ميزة في Inngest ومجدولٌ من ألف سطر في كودك الخاص.Try with AI
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: التجميع، معالجة جماعية فعّالة الكلفة
بعض العمل مجمَّع بطبعه. لا تلخّص كل واحدة من 10,000 محادثة عميل على حدة؛ بل تستدعي LLM بدفعة من 50 في المرة. ولا تكتب 10,000 صف تدقيق واحداً واحداً؛ بل تستخدم COPY. ويتيح لك محفّزُ التجميع في 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 API بدفعة من 50 نصاً بدلاً من 50 استدعاءً لنصٍّ واحد، وهو أرخص كثيراً (تدفع لكل رمز، لكن عبء كل طلب اختفى) وأسرع (جولة API واحدة بدل 50).
التجميع هو الأداة الصحيحة عندما يكون العمل قابلاً للتجميع بطبعه (التضمينات، وكتابات قاعدة البيانات الجماعية، والبُرُد الجماعية) وتستطيع تحمّل كمونٍ بمقدار مهلتك قبل أن يحدث العمل. وهو الأداة الخاطئة عندما تتطلب كلُّ حدث استجابةً تفاعلية أو عندما يهمّ الترتيبُ عبر الأحداث بطرق غير متوقعة.
فحص سريع. صحيح أم خطأ. (a) لا تزال الدوالُ المجمَّعة تنال إعاداتِ محاولة وتذكّراً؛ تُذكَّر الدفعةُ ككل بمتانة. (b) إذا انتهت مهلةُ الدفعة بثلاثة أحداث متراكمة فقط، فلن تعمل الدالة حتى يصل ال 47 التاليون. (c) يمكنك دمجُ
batch_eventsمعconcurrencyلتحدّ كم دفعة تعمل بالتوازي.
الإجابات: (a) صحيح: الدفعةُ هي وحدةُ العمل؛ وتعيد إعاداتُ المحاولة الدفعةَ كاملةً بكل أحداثها ضمن النطاق. (b) خطأ: ذلك هو مغزى المهلة كله. بعد 30 ثانية تعمل الدالة بما تراكم، حتى لو كان حدثاً واحداً. (c) صحيح: هذا نمط الإنتاج. الدفعةُ مع التزامن معاً يحدّان حملك التحتي بشكل جميل.Try with AI
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: إعادة التشغيل والإلغاء الجماعي، تعافي الإنتاج
أحياناً يخطئ كل شيء دفعة واحدة. شحنت خطأً؛ ففشل ألفُ تشغيل في الساعات الست الأخيرة. أو كانت واجهتك التحتية متوقفة 30 دقيقة؛ فمات كلُّ ما حاول استدعاءها في تلك النافذة. أو اكتشفت خطأ منطق وتريد إعادة عمل يوم بعد إصلاحه.
أولاً، التمييز الذي يُعثِر الجميع. يمنحك Inngest طريقتين لإعادة تشغيل خطوة فاشلة، وتتصرفان مختلفاً:
- إعادة المحاولة التلقائية (داخل التشغيل نفسه). عندما ترمي خطوة، يعيد Inngest محاولةَ الدالة بتراجع، فيدخلها من الأعلى. تعود الخطواتُ المكتملة من التذكّر و_لا_ تُعاد؛ وتُعاد الخطوةُ الفاشلة وحدها. هذا هو الاستئناف الحافظ للتذكّر، الذي شاهدته في المكسب السريع، والذي يجعل خاصية «ال 0.20 دولار المنفقة عند الخطوة 3 لا تُنفَق من جديد» صحيحة. وهو تلقائي ويحدث داخل التشغيل الأصلي.
- إعادة التشغيل / Rerun (زرّ لوحة التحكم، عبر تشغيلات كثيرة). يبدأ هذا تشغيلاً جديداً تماماً من الأعلى بكودك المنشور حالياً، وكل خطوة تُنفَّذ من جديد من الصفر (ينال rerun معرّفَ تشغيل جديداً ويعيد الخطوة الأولى، لا استئناف القديم). فعملياً لا ينقذك تذكّرُ التشغيل القديم هنا. وهو للتعافي من حادثة، لا لتخطّي العمل المكتمل.
إبقاء هذين واضحين هو المفهوم كله. مردودُ التذكّر يعيش في إعادة المحاولة التلقائية؛ وReplay بداية جديدة.
بدائيتا تعافٍ متعاكستان. تقول إعادةُ التشغيل «هذا العمل فشل، أريده أن يعمل من جديد على الكود المصلَّح». ويقول الإلغاءُ الجماعي «صُفّ هذا العمل لكنني لم أعد أريد حدوثه». السطح نفسه في لوحة التحكم، والنية معاكسة. ويحتاج معظم الفِرَق كليهما في أول ثلاثة أشهر من تشغيل حركة حقيقية.
إعادة التشغيل هي بدائية التعافي. تبقى التشغيلات الفاشلة بتاريخ خطواتها الكامل، وحدث المدخل، واستثناء الخطوة الفاشلة. من لوحة التحكم تفتح عرض Functions، وترشّح إلى دالة لها تشغيلات فاشلة، وتختار نافذة زمنية ونمط فشل (أي رسالة خطأ محددة أو فقط «كل الإخفاقات»)، وتنقر Replay. ويجدول Inngest كلاً بوصفه تشغيلاً جديداً من الأعلى على أي كود منشور الآن.
ثلاثة أشياء عليك فهمُها عن إعادة التشغيل.
- تستخدم إعادةُ التشغيل كودَك المنشور حالياً. إذا نشرت إصلاحاً بين فشل التشغيلات وإعادة تشغيلها، تستخدم التشغيلاتُ المُعادة الكودَ الجديد. وهذا هو المغزى كله: خذ مجموعة تشغيلات ماتت على خطأ، اشحن الإصلاح، وأعد تشغيلها كلها دون لمس يدوي.
- تعيد إعادةُ التشغيل تنفيذَ كل خطوة؛ ولا تعيد استخدام تذكّر التشغيل القديم. التشغيلُ المُعاد تشغيلٌ جديد، فتعمل كل خطوة من جديد من الصفر على الكود المصلَّح. ومن حيث الكلفة، خطّط لكلفة الدالة كلها لكل تشغيل مُعاد، لا الخطوة الفاشلة وحدها. والشيء الذي يمنع إعادةَ التشغيل من إصدار أثر جانبي حقيقي ثانٍ (ردّ أموال مكرر، أو بريد مكرر) ليس التذكّر، بل مفتاح idempotency على ذلك الأثر الجانبي (المفهوم 4): تشتق مفتاحاً ثابتاً من الطلب (لردّ أموال، شيء مثل
(order_id, request_id)) فيعامل المزودُ التكرارَ بلا أثر. ويحذف العاملُ الأدنى في هذه الدورة ذلك المفتاح اختصاراً، فردّ أمواله يطابق على العميل ويكتب بلا شرط، فنسخةُ إنتاج تضيف واحداً قبل أن يتحرك أي مال حقيقي. يحمي التذكّرُ داخل التشغيل؛ ويحمي مفتاحُ idempotency عبر إعادات التشغيل. - إعادةُ التشغيل اختيارية. تجلس التشغيلات الفاشلة في لوحة التحكم حتى تتصرف بشأنها. لا تعيد المحاولة إلى الأبد؛ ولا تختفي. تنتظرك.
الإلغاء الجماعي هو العكس. أحياناً يكون لديك آلافُ التشغيلات المصطفّة أو النائمة التي لم تعد تريدها: حملة أُلغيت، أو عميل انقطع ولم تعد تريد إرسال بُرُد متابعة له، أو ميزة أُعيدت. من لوحة التحكم تختار دالة ونافذة زمنية أو مرشّح حدث، وتنقر Cancel. وتنتهي التشغيلاتُ المطابقة بنظافة: لا تستأنف استدعاءاتُ step.sleep وstep.wait_for_event، ولا تبدأ التشغيلاتُ المصطفّة، وتفحص التشغيلاتُ الجارية الإلغاءَ وتخرج عند حدّ الخطوة التالي. يحترم الإلغاءُ حدَّ الخطوة؛ ف step.run جارٍ يُنهي الخطوة التي هو فيها قبل أن ينتهي، فلا تحصل على خصوم Stripe نصف مكتملة أو كتابات قاعدة بيانات ممزَّقة.
إعادة التشغيل مقابل الإلغاء بوصفهما قراراً. عندما يخطئ شيء بمجموعة تشغيلات، اسأل سؤالاً واحداً: هل أريد لهذا العمل أن ينجح أم أريده ألا يحدث؟ إذا كان ينبغي للعمل أن ينجح (تعافٍ بإصلاح خطأ)، فأعد التشغيل. وإذا كان ينبغي ألا يحدث (حملة ملغاة، عميل منقطع، ميزة مُعادة)، فألغِ. وإذا لم تكن متأكداً (مثلاً، تتضمن التشغيلات الفاشلة بعضاً تريد استرداده وبعضاً ما كان ينبغي أن يُطلَق أصلاً)، فرشّح استعلام لوحة تحكمك أضيق ليأخذ كلُّ مجموعة فرعية المعالجةَ الصحيحة.
ثلاثة أنماط يتيحها هذا عملياً:
- تعافي «شحنّا خطأً». اعثر على التشغيلات الفاشلة في نافذة النشر السيئ، أصلح الخطأ، اشحن الإصلاح، أعد تشغيل الإخفاقات. تجربة العميل: لم يحصل بريده على رد لساعة لكنه حصل عليه أخيراً، دون أن تكتب أي كود تعافٍ.
- تراجع «الحملة ملغاة». سلسلة ترحيب تطلق ثلاثة بُرُد متابعة على 14 يوماً؛ ينقطع العميل في اليوم 4. لا تريد إرسال متابعتَي اليوم 7 واليوم 14. ألغِ جماعياً تشغيلاتِ
wait-for-eventوsleepالمطابقة. - إعادة تشغيل «ترحيل المخطط». غيّرت كيف يصوغ الوكيلُ الملخصات؛ تريد إعادة تلخيص تذاكر الأمس بالصيغة الجديدة. اعثر على تلك التشغيلات (الناجحة أو لا) وأعد تشغيلها؛ ولأن إعادة التشغيل تشغيلٌ جديد من الأعلى، يعيد الوكيلُ تشغيلَ كل خطوة على الكود الجديد، وهو بالضبط ما تريده هنا. أبقِ خطواتِك ذاتَ الآثار الجانبية idempotent حتى لا تضاعف إعادةُ تشغيلها الخصمَ أو الإرسال.
يجعل MCP خادم التطوير التعافيَ متاحاً دون مغادرة وكيلك البرمجي. أثناء التطوير تستطيع أن تطلب من الذكاء الاصطناعي استخدامَ get_run_status لفحص تشغيل فاشل، ثم استرداد العمل بإعادة إطلاق الحدث على الكود المصلَّح (امنحه معرّف حدث جديداً، إذ إن إعادة الإطلاق بالمعرّف نفسه تُزال تكرارها إلى لا-أثر بدلالات idempotency في المفهوم 4). وزرّ Rerun في لوحة التحكم هو المسار المكافئ بنقرة واحدة. في كلتا الحالتين تحصل على تشغيل جديد على الكود الحالي، لا استئناف حافظ للتذكّر.
فحص سريع. صحيح أم خطأ. (a) Replay في لوحة التحكم يعيد تشغيلَ العمل على الكود المنشور الجديد. (b) Replay في لوحة التحكم يعيد خطواتِ التشغيل الأصلي الناجحة من التذكّر ويعيد الفاشلةَ وحدها. (c) إعادةُ المحاولة التلقائية داخل تشغيل فاشل تعيد الخطواتِ المكتملة من التذكّر وتعيد الخطوةَ الفاشلة وحدها. (d) الإلغاءُ الجماعي لدالة جارية يُجهض
step.runالمنفَّذ حالياً في منتصف الخطوة لينتهي أسرع.
الإجابات: (a) صحيح: إعادةُ التشغيل تشغيلٌ جديد من الأعلى على أي كود منشور الآن، ولهذا هي أداةُ التعافي بإصلاح خطأ. (b) خطأ: هذا هو الفخّ. إعادةُ التشغيل تشغيلٌ جديد يعيد تنفيذَ كل خطوة من الأعلى، فلا ينتقل تذكّرُ التشغيل القديم. وما يمنع أثراً جانبياً مُعاداً من الإطلاق مرتين هو مفتاحُ idempotency، لا التذكّر. (c) صحيح: هذا هو المسار الحافظ للتذكّر، وهو الذي شاهدته في المكسب السريع. تجلس الخطوةُ المكتملة عند محاولة واحدة بينما تُعاد الخطوةُ الفاشلة. (d) خطأ: يحترم الإلغاءُ حدَّ الخطوة؛ ف step.run الحالي ينتهي (أو يفشل) قبل أن ينتهي التشغيل. يمنع هذا الكتاباتِ الممزَّقة.Try with AI
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 في وقت التشغيل
يقول الثابت 1 في Agent Factory إن الإنسان هو صاحب السلطة: النيةُ المؤلَّفة، لا حكمُ الوكيل المستقل، هي ما يجب على وقت التشغيل أن يحترمه في القرارات عالية المخاطر. هذا هو الموضع الوحيد الذي يعود فيه عقلٌ بشري إلى الحلقة. وفي كل مكان آخر يعمل الجهاز العصبي من تلقاء نفسه، بردة فعل منعكسة؛ وهنا يتوقف وينتظر شخصاً. يظهر هذا في الإنتاج بوصفه بوابات موافقة: يجري الوكيلُ التحليل، ويصوغ الفعل، لكنه لا ينفّذ الفعل حتى يوافق إنسان.
step.wait_for_event في Inngest (المفهوم 8) هو أنظفُ تعبير عن هذا على أي منصة اليوم. يجري الوكيلُ إلى نقطة القرار، يعلّق، وينتظر حدثَ موافقة. يراجع الإنسانُ (في Slack، أو في واجهة إدارة، أو في بريد) وينقر موافقة أو رفض. يُطلَق الحدث. وتستأنف الدالةُ بحكم الإنسان وتتصرف وفقه. هذا ما يعنيه spec-driven في وقت التشغيل: يفرض الجهازُ العصبي الخطة، أيُّ فعل يحتاج إنساناً، بأي ترتيب، بأي مهلة. لا يراقب استدلالَ الوكيل؛ بل يتحكم بما يُسمَح للوكيل بفعله.
@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)
analysis = await ctx.step.run(
"agent-investigates",
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",
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",
escalate_to_senior_reviewer,
request_id=request_id,
)
return {"status": "escalated_timeout"}
if not approval.data["approved"]:
await ctx.step.run(
"notify-rejected", 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", call_stripe_refund,
request_id=request_id, amount_cents=amount_cents,
)
await ctx.step.run(
"audit-approved-refund", 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 بدائيةٌ واحدة. يُقرأ كودُ الدالة من الأعلى إلى الأسفل، والبوابة في الخط. لا callback، ولا استعادةَ حالة، ولا توزيعَ if state == waiting_for_approval: .... يتولى وقتُ التشغيل آليةَ التعليق/الاستئناف؛ ويعبّر كودك عن السياسة.
تطوّر دورةٌ لاحقة الثابتَ 1 معمارياً: النية المؤلَّفة، ومسارات spec-driven، وطبقة مدير-العمال التي تقرر أيُّ بوابات تنطبق على أي أفعال. تمنحك هذه الدورة البدائية في وقت التشغيل. وعندما تصل تلك الطبقةُ المديرة، ستكون البوابةُ التي تنفّذها هي نمطَ wait_for_event هذا بالضبط، مؤلَّفاً على نطاق أسطول. ومعرفةُ البدائية الآن تعني أن النمط المعماري لاحقاً يُقرأ بوصفه «تأليفاً معقولاً» بدلاً من «سحر».
هذا هو حجرُ الأساس الذي تبنيه في القرار 5 من الجزء 4: موافقةُ ردّ الأموال، مجعولةً متينة. المفهومُ هنا هو الشكل؛ والمثالُ العملي يصله بأداة needs_approval حقيقية ويثبت أن ردّ الأموال يُطلَق مرة واحدة بالضبط.
توقّع. لديك بوابةُ HITL مضبوطة ب
timeout=timedelta(hours=24). يأتي طلبُ ردّ أموال عميل عند 17:00 يوم جمعة. لا يوجد إنسان متصل طوال عطلة نهاية الأسبوع. تُطلَق مهلةُ البوابة عند 17:00 يوم السبت. ويسجّل معالِجُ مهلتك ردَّ أموال محجوباً. يقرأ المراجعُ الطلبَ يوم الاثنين عند 9:00 صباحاً. اسلك الخط الزمني: كم تشغيل دالة كان نشطاً في عطلة نهاية الأسبوع؟ وكم حوسبة فوتر Inngest مقابلها؟ الثقة 1-5.
الإجابة: صفرُ تشغيلات دالة نشطة في عطلة نهاية الأسبوع. كانت الدالة معلَّقة: خزّن Inngest حالتها، وأخرج الدالة من الذاكرة، وانتظر إما الحدث أو المهلة. ولا يفوتر Inngest مقابل الوقت المعلَّق. وعندما جاء السبت 17:00 وأُطلقت المهلة، استأنفت الدالةُ بضع مئات الميلي ثانية التي استغرقها كتابةُ صف تدقيق ردّ الأموال المحجوب، ثم اكتملت. وكونُ المراجع لا ينظر حتى الاثنين لا يكلّف شيئاً من جانب العامل. واقتصادياتُ مسارات HITL على Inngest مختلفة جذرياً عن طوابير قائمة على polling تفوترك مقابل كل ثانية من polling «هل وُوفِق على هذا بعد؟».Try with AI
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: المثال العملي، عاملُ إنتاج لدعم العملاء
هنا تبني. أولاً العامل (تعليمة واحدة)، ثم الجهاز العصبي حوله، طبقة لكل تعليمة. توجّه وكيلك البرمجي بتعليمات إنجليزية قصيرة بسيطة فيكتب الكود؛ والمقاطع المعروضة أدناه هي الأسطر الحاملة القليلة لكل طبقة، لا الملفات. شُغّل التنفيذُ الكامل من البداية إلى النهاية مقابل خادم تطوير حي ونموذج حقيقي، فالأشكال التي تراها هي ما يعمل. وإذا بدا توقيع غير مألوف، يراجع وكيلك الوثائقَ الحالية.
الشكل: سبع تعليمات، على الأساس الذي هيّأته أصلاً.
- D0 يبني العامل نفسه، مستقلاً.
- D1 يجعل تشغيلَ الوكيل متيناً.
- D2 يتيح لحدث أن يوقظه.
- D3 يضيف cron يومياً يتفرّع.
- D4 يضيف تحكماً في التدفق.
- D5 هو حجر الأساس: بوابةُ موافقة بشرية متينة على ردود الأموال.
- D6 يثبت أن العامل ينجو من خطوة مكسورة: إعادةُ محاولة دون إعادة العمل المكتمل، ثم التعافي.
قبل أن تبدأ. بيئتك مهيّأة أصلاً من المكسب السريع: افتح مجلد
ai-agent-nervous-systemنفسه، مع مهارات Inngest وneon-postgresمثبَّتة، وOPENAI_API_KEYوDATABASE_URLفي.env، وجدولَيكcustomersوaudit_logمزوَّدين، وخوادم MCP الثلاثة كلها (Neon، وContext7، وinngest-dev) موصولة. تذكيران فقط:
- خادم التطوير يعمل. شغّله من جديد إن أغلقته:
npx inngest-cli@latest devفي طرفية خاصة به. لوحة التحكم عندhttp://127.0.0.1:8288. (عندما تنشر لاحقاً إلى Inngest Cloud، طبقةُ Hobby المجانية بصفر دولار بلا بطاقة ائتمان؛ سقوفها في الجزء 5.)- ملاحظة حالة أحرف واحدة لاستدعاءات MCP أدناه. أسماءُ أدوات خادم التطوير
snake_case(send_event، وget_run_status، وinvoke_function)، لكن معاملاتهاcamelCase(get_run_statusتأخذrunId، وinvoke_functionتأخذfunctionId). وSDK لغة Python بsnake_caseكله؛ ومعاملاتُ استدعاء MCP وحدهاcamelCase.
الموجز
تبني عامل دعم عملاء صغيراً وتمنحه جهازاً عصبياً لعامل إنتاج. يقرأ العامل عملاءه النموذجيين من جدول Neon customers (id، وemail، وtier)، ويصوغ رداً دافئاً على بريد وارد، ويستطيع إصدار ردّ أموال بموافقة بشرية فقط، ويكتب صفَّ تدقيق في جدول Neon audit_log لكل فعل من مجموعة صغيرة ثابتة: message_received، وmessage_sent، وrefund_issued، وrefund_blocked. ثم تضيف التعليماتُ السبع Inngest حوله: حدثٌ يوقظه، واستدعاءُ الوكيل يعمل بمتانة، وcron يومي يتفرّع فحصَ صحة لكل عميل مؤهل، وتحكمٌ في التدفق يحدّ التزامن والخنق، وردُّ الأموال يتوقف على بوابة بشرية متينة، ومسارُ إعادة تشغيل يسترد التشغيلات الفاشلة.
ملاحظة عن التعليمات التالية. كلٌّ مكتوبة كما تقولها فعلاً لوكيل برمجي: قصيرة، بسيطة، تثق به في معالجة التفصيل. تعمل ملصوقةً باردة، وأفضل إن طلبت أولاً من الوكيل أن يتموضع ("اقرأ المشروع وأخبرني بما تراه، ثم اسألني عن أي شيء غير واضح قبل أن تبدأ") كلما تكدّست الملفات. التعليماتُ هي الوجهة؛ والتموضعُ أولاً هو المدخل.
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بخمسة صفوف نموذجية أولاً إن كان فارغاً. أبقِه صغيراً؛ هو موجود ليُغلَّف، لا ليُشحَن. ثم شغّله على بريد نموذجي وأرني الرد.
يُنشئ: worker.py وdb.py (مشروع مسطّح، بلا تعشيش src/). يضيف D1 مضيفَ Inngest بوصفه الملف الثالث. يصل الوكيل إلى Postgres عبر DATABASE_URL، لا عبر خادم Neon MCP، الذي هو أداةُ وقت بنائك فقط.
بيانات البذر صغيرة بما يكفي لإبقائها على الصفحة، خمسة عملاء نموذجيين عبر ثلاث طبقات، يُدخلهم الوكيل في جدول customers:
[
{ "id": "cust_001", "email": "ada@example.com", "tier": "enterprise" },
{ "id": "cust_002", "email": "grace@example.com", "tier": "pro" },
{ "id": "cust_003", "email": "linus@example.com", "tier": "pro" },
{ "id": "cust_004", "email": "edsger@example.com", "tier": "standard" },
{ "id": "cust_005", "email": "alan@example.com", "tier": "standard" }
]
يكتب وكيلك ملفَّي Python قصيرين. يحمل db.py وصولَ Postgres: اتصال asyncpg صغير مجمَّع عبر DATABASE_URL، وقراءةَ load_customers()، ومساعِدَ كتابةِ تدقيق record() بمفردات مغلقة، فأي اسم فعل خارج المجموعة الرباعية يرمي، مما يحوّل خطأً مطبعياً إلى خطأ صاخب بدلاً من صف سيئ صامت. وworker.py هو SandboxAgent بأداتين تستدعيان db.py. سطر واحد منه حامل لبقية الدورة، مزيِّنُ أداة ردّ الأموال:
@function_tool(needs_approval=True)
def issue_refund(order_id: str, amount_cents: int, reason: str) -> str:
...
تجعل needs_approval=True الوكيلَ يتوقف بدلاً من إصدار ردّ الأموال: يعود التشغيلُ بردّ الأموال معلَّقاً ويقرر إنسان. إنها الخطّاف الذي يتعلّق به حجرُ أساس HITL كله (D5). (يبوّب هذا الأدنى كل ردّ أموال، مما يبقي حجر الأساس بسيطاً؛ وعاملُ إنتاج عادةً يبوّب فوق عتبة فقط، نمطُ فوق-100-دولار من المفهوم 15. والوصلُ مطابق في الحالتين.)
ملاحظة بنيوية واحدة تؤكدها في ما يكتبه الوكيل، لأن D5 يعتمد عليها: أبقِ build_agent() وrun_config() الخاص بصندوق الرمل دالتين منفصلتين. عندما يستأنف D5 تشغيلاً معلَّقاً يعيد بناء الوكيل إلى شكل الأداة نفسه ويعيد تمرير run_config() نفسه؛ والحالةُ المحفوظة لا تحمل جلسةَ صندوق الرمل، فعلى الاستئناف أن يوفّرها من جديد. افصلهما الآن فيصير حجرُ الأساس خطوةً صغيرة لاحقاً.
جاهز عندما: يعمل الوكيل على بريد نموذجي ويطبع رداً قصيراً، ويوجد صف جديد في جدول 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. أكّد أن تشغيلاً مكتملاً يُظهر خطوةَ الوكيل مذكَّرةً في لوحة التحكم.
يُنشئ: inngest_app.py (عميل Inngest في وضع التطوير، واستدعاءُ الوكيل في مساعِد واحد، ومضيفُ FastAPI يكتشفه خادمُ التطوير).
الشكل المهم هو step.run واحد يلفّ استدعاءَ الوكيل:
async def handle_customer_email(ctx: inngest.Context) -> dict:
email_text = ctx.event.data["email_text"]
outcome = await ctx.step.run("run-agent", functools.partial(_run_agent, email_text))
return {"replied": outcome["status"] == "done"}
اصطلاحان تؤكدهما في ما يكتبه الوكيل. لا يأخذ معالِجُ الخطوة وسائطَ خاصة به، فيربط functools.partial المتغيرَ email_text مسبقاً، وهكذا تمرّر بيانات إلى أي خطوة، وستراه على كل خطوة من هنا فصاعداً. ويستخدم مساعِدُ الوكيل Runner.run العادي، لا runner متدفقاً: إنه المسار الذي يُبنى عليه حجرُ أساس الموافقة البشرية (D5)، فاستخدامه من البداية يجعل D5 خطوةً صغيرة بدل إعادة كتابة. ويُبنى العميل ب is_production=False (علم وضع التطوير من المكسب السريع).
شغّله عمليتين، مضيفَ الدالة وخادمَ التطوير الذي يجده:
uv run uvicorn inngest_app:app --port 8000 --reload --log-level info # terminal 1: function host (your model key is sourced here; --reload picks up the D6 break/fix edits)
npx inngest-cli@latest dev -u http://127.0.0.1:8000/api/inngest # terminal 2: dev server, auto-discovers the host
جاهز عندما: تسرد لوحةُ التحكم handle-customer-email ويُظهر تشغيلٌ مكتمل خطوةَ run-agent. (توقظه على نحو سليم بحدث في D2؛ والآن، يكفي أن تكون الدالةُ قابلةً للاكتشاف.)
لماذا هذه هي الحركة الحاملة. استدعاءُ الوكيل هو الجزء المكلف: رموزُ النموذج، وعدةُ ثوانٍ. داخل step.run تُذكَّر نتيجتُه، فحين تفشل خطوةٌ لاحقة وتعيد الدالةُ المحاولة، لا يعمل الوكيلُ من جديد. ذلك اللفُّ الواحد هو الفرق بين عامل يضاعف الدفعَ ويضاعف الفعلَ في كل إعادة محاولة وعامل يفعل كل شيء مكلف مرة واحدة بالضبط.
D2: حفّزه على حدث
أين أنت: دالة متينة يحفّزها أصلاً customer/email.received (مزيِّن D1)، لكن بلا سجل تدقيق. يضيف هذا القرارُ صفَّ تدقيق على كل جانب من الوكيل؛ وبنهايته يقود حدثٌ حقيقي تشغيلاً بكتابة الصفّين.
أضف خطوةَ تدقيق قبل الوكيل وأخرى بعده، ثم أوقظ العاملَ بحدث حقيقي بدلاً من تشغيله بيدك. الصق هذا:
اجعل العاملَ يستيقظ على حدث
customer/email.receivedبدلاً من تشغيله بيدي. أضف خطوةَ تدقيق دخول قبل الوكيل وخطوةَ تدقيق رد بعده. أرسل حدثَ اختبار وأرني التشغيلَ يكتمل بكتابة صفَّي التدقيق.
يعدّل: inngest_app.py (تكتسب الدالةُ خطوةَ تدقيق على كل جانب من الوكيل).
الشكل استدعاءان آخران ل step.run حول خطوة الوكيل:
customer_id = ctx.event.data.get("customer_id") # bound from the event, alongside D1's email_text
await ctx.step.run("audit-received", functools.partial(
db.record, "message_received", customer_id=customer_id, detail=email_text[:80]))
outcome = await ctx.step.run("run-agent", functools.partial(_run_agent, email_text))
await ctx.step.run("audit-sent", functools.partial(
db.record, "message_sent", customer_id=customer_id, detail=(outcome["reply"] or "")[:80]))
يستخدم كل صف اسمَ فعل من المجموعة المغلقة: message_received داخلاً، وmessage_sent خارجاً، ويكتبه db.record في جدول Neon audit_log عبر DATABASE_URL. أرسل حدثَ الاختبار من الوكيل بأداة send_event في MCP خادم التطوير (name: "customer/email.received"، وكائن data ب email_text وcustomer_id). يقبل خادمُ التطوير أي حدث، فلا تضبط webhook للاختبار محلياً؛ وفي الإنتاج توجّه مزوّدَ بريدك إلى رابط Inngest webhook يعيد تشكيل حِمله إلى هذا الحدث، وهو إعداد لوحة تحكم، لا كود.
جاهز عندما: يكتمل التشغيلُ، ويُظهر التتبّعُ ثلاثَ خطوات بالترتيب (audit-received، وrun-agent، وaudit-sent)، ويحوي جدول Neon audit_log صفَّ message_received واحداً وصفَّ message_sent واحداً لذلك العميل.
لماذا خطوتا تدقيق، لا واحدة. كلٌّ هي step.run خاصة بها، فكلٌّ مذكَّرة مستقلةً. إذا فشلت خطوةُ الرد وأعادت الدالةُ المحاولة، لا يُكتَب صفُّ الدخول مرتين (إصابةُ تذكّر) ولا يعمل الوكيلُ مرتين (مذكَّر أيضاً). يبقى سجلُّ التدقيق exactly-once عبر إعادات المحاولة، الخاصيةُ التي سيثبتها D6.
D3: cron يومي يتفرّع
أين أنت: عامل يوقظه العالمُ بريداً واحداً في المرة. يضيف هذا القرارُ cron يومياً يتفرّع حدثاً واحداً لكل عميل مؤهل؛ وبنهايته ينال كلٌّ تشغيلَه المتين الخاص.
أضف عملاً مجدولاً: cron يومي يطلق حدثَ فحص صحة لكل عميل Pro وEnterprise، كلُّ حدث يحفّز تشغيلَه المتين الخاص. الصق هذا:
أضف cron يومياً يتفرّع حدثَ
customer/health_check.requestedواحداً لكل عميل Pro وEnterprise، كلٌّ مفتاحُه idempotency حتى لا يضاعف تشغيلُ cron مُعاد التسليم الإطلاقَ. كلُّ حدثِ ابن يحفّز تشغيلَه المتين الخاص الذي يكتب صفَّ تدقيق واحداً. استدعِ cron يدوياً وأرني تشغيلَ ابن واحداً لكل عميل مؤهل.
يُنشئ: cron أبٍ يتفرّع ومستهلكَ حدث يعالج كل ابن، كلاهما مسجَّل مع المضيف.
شكلان يحملان هذا القرار. المحفّز مزيِّنُ cron من سطر واحد، وfan-out هو N أحداث كلٌّ يحمل مفتاح idempotency:
@inngest_client.create_function(fn_id="daily-health-check", trigger=inngest.TriggerCron(cron="0 9 * * *"))
async def daily_health_check(ctx: inngest.Context) -> dict:
# ... select Pro/Enterprise customers, then:
events = [
inngest.Event(
name="customer/health_check.requested",
data={"customer_id": c["id"]},
id=f"health-{c['id']}-{ctx.event.id}", # idempotency key per (customer, cron run)
)
for c in eligible
]
await ctx.step.send_event("fan-out-health-checks", events)
مفتاحُ idempotency هو التفصيل الحامل: id=f"health-{customer}-{cron_run}" يعني أنه إذا سُلّم تشغيلُ cron نفسه مرتين (إعادة نشر، أو إعادة محاولة)، يُسقَط الحدثُ المكرر، فينال كلُّ عميل فحصاً واحداً بالضبط في اليوم. والمستهلكُ دالةٌ عادية يحفّزها حدث تكتب صفَّ تدقيق واحداً. استدعِ cron من الوكيل بأداة invoke_function في MCP (لا تنتظر 09:00 غداً). خصوصيةٌ تطوير واحدة: لا يطلق خادمُ التطوير crons إلا وهو يعمل؛ والإنتاج يشغّلها على بنية Inngest التحتية دائمة التشغيل.
جاهز عندما: يكتمل الأبُ في ثوانٍ وتُظهر لوحةُ التحكم تشغيلَ customer-health-check ابناً واحداً لكل عميل مؤهل، مع تخطّي عملاء طبقة standard على نحو صحيح.
لماذا fan-out، لا حلقة. لا يعالج الأبُ العملاءَ بنفسه؛ بل يرسل N أحداث ويعود. كلُّ ابن تشغيلٌ خاص به، معزول، قابل لإعادة المحاولة مستقلاً، محدود بتزامنه الخاص. وحلقةٌ داخل دالة واحدة كانت ستربطهم: عميلٌ بطيء واحد يحتجز البقية، وتعطّلٌ يفقد الدفعة كلها. fan-out هو كيف يصير استيقاظٌ مجدول واحد N تشغيلاً متيناً مستقلاً.
D4: التحكم في التدفق
أين أنت: عامل يعالج كل بريد لكنه سيطلقها كلها دفعةً واحدة تحت طفرة. يضيف هذا القرارُ ثلاثَ سياسات تحكم في التدفق؛ وبنهايته تصطف طفرةٌ من عشرين حدثاً تحت السقف بلا صفوف مُسقَطة أو مكررة.
عندما يصل خمسمئة بريد عند 9 صباحاً، لا ينبغي للعامل أن يطلق خمسمئة استدعاء نموذج دفعةً واحدة: ذلك يفجّر حدَّ المعدل ويجوّع الجميعَ خلف العميل الثرثار. أضف سقفَ تزامن عالمياً، وسقفاً لكل عميل، وخنقاً. الصق هذا:
أضف تحكماً في التدفق لمعالِج البريد: سقفَ تزامن عالمياً، ومفتاحَ تزامن لكل عميل حتى لا يجوّع عميلٌ ثرثار واحد البقيةَ، وخنقاً لحماية حدّ معدل OpenAI. أطلق طفرةً من عشرين حدثاً عبر خمسة عملاء وأرني أنها تصطف تحت السقف وتكتمل كلها بلا صفوف تدقيق مُسقَطة أو مكررة.
يعدّل: inngest_app.py (ثلاثة وسائط مزيِّن على دالة البريد).
هذه الوسائط الثلاثة هي الدرس، D4 كله يعيش فيها:
concurrency=[
inngest.Concurrency(limit=10), # global cap
inngest.Concurrency(limit=2, key="event.data.customer_id"), # per-customer cap
],
throttle=inngest.Throttle(limit=100, period=datetime.timedelta(minutes=1)),
ثلاثة مقابض، ثلاث وظائف. يحدّ limit=10 العالمي كم تشغيلاً يُنفَّذ في آن، حامياً سقفين حقيقيين: حدَّ معدل النموذج، وميزانيةَ اتصالات Neon لديك. شيئان يحدّان اتصالاتك، ويعملان على نطاقين مختلفين. داخل نسخة عامل واحدة، تتشارك كلُّ التشغيلات مجمعَ asyncpg واحداً، ف max_size للمجمع هو ما يبقي الاتصالات ثابتة مهما بلغ عددُ التشغيلات النشطة (طفرةٌ من عشرين تشغيلاً على مضيف واحد ما زالت تركب حفنةً من الاتصالات المجمَّعة). وعبر النسخ، لم يعد ذلك المجمع المحلي يساعد، فللنسخة الثانية مجمعها الخاص، فسقفُ التزامن هو ما يحدّ مجملَ التشغيلات، وبالتالي مجملَ الاتصالات، عبر الأسطول: عشر نسخ ب limit=10 لكلٍّ هي مئةُ تشغيل ونحو مئة اتصال، تحجّمها مقابل ميزانية Neon (تتيح الطبقةُ المجانية بضع مئات مجمَّعة). المجمعُ والسقف معاً هما الحماية: يحدّ المجمعُ نسخةً واحدة، ويحدّ السقفُ الأسطولَ. ومن دون أحدهما، تفتح طفرةُ خمسمئة بريد عبر نسخ غير مجمَّعة وغير محدودة اتصالاتٍ أكثر بكثير مما يقبله Neon. ويعني limit=2 لكل عميل المفتاحُ على event.data.customer_id أن طفرةَ عميل واحد تشغل فتحتين على الأكثر، فلا يجوّع فيضانٌ من حساب واحد البقيةَ أبداً. ويحدّ throttle كم تشغيلاً يبدأ في الدقيقة، منعّماً طفرةً إلى معدل ثابت. وتحمل دالةٌ سياستَي تزامن على الأكثر؛ وزوجُ العالمي-مع-لكل-مفتاح هو الشكل الشائع. أطلق الطفرة من الوكيل: عشرون حدثَ customer/email.received عبر خمسة عملاء عبر send_event.
جاهز عندما: تصطف الطفرةُ تحت السقف (يبقى عددُ الجاري عند 10 أو أقل، وعند 2 أو أقل لكل عميل)، وتكتمل كلُّ تشغيل، ويحوي جدول Neon audit_log عشرين صفَّ message_received وعشرين صفَّ message_sent بالضبط. لا صفوف مُسقَطة، ولا مكررة، ولا أخطاءَ حدّ اتصال Neon تحت الطفرة، فعلى هذا المضيف الواحد يبقي مجمعُ asyncpg الاتصالاتِ ثابتة (سترى حفنةً فقط مستخدَمة حتى مع الطفرة جارية)، والسقفُ هو ما سيبقيها ثابتة عبر النسخ متى توسّعت.
لماذا هذه سياسة، لا كود. لا شيء من هذا يعيش في جسم دالتك؛ إنه إعداد يفرضه وقتُ التشغيل. من دون السقوف، إما أن تُذيب طفرةٌ نظاماً تحتياً أو تتيح لمستأجر واحد احتكارَ العامل. وكتابةُ العدالة نفسها بيدك طابورٌ مع مجدول مع محدِّد معدل، مئاتُ الأسطر. وهنا ثلاثة وسائط مزيِّن.
D5: بوابة موافقة بشرية متينة على ردود الأموال (حجر الأساس)
أين أنت: عامل توقفُ ردّ أمواله (needs_approval=True في D0) عابرٌ، يعيش في العملية الجارية. يجعل هذا القرارُ ذلك التوقفَ متيناً؛ وبنهايته يعلّق التشغيلُ عند حوسبة صفرية، وينتظر حدثَ موافقة حقيقياً، ويستأنف ليصدر ردَّ الأموال مرة واحدة بالضبط.
ذلك التوقفُ العابر هو الفجوة: تعطّلٌ، أو نشرٌ، أو مراجعٌ يأخذ ظهيرته، فيضيع ردُّ الأموال المعلَّق. هذا حجرُ أساس الدورة كلها: اجعل التوقفَ متيناً، فيعلّق التشغيلُ عند حوسبة صفرية، وينتظر حدثَ موافقة حقيقياً ما دام الأمر يستغرق، ثم يستأنف تشغيلَ الوكيل نفسه بالضبط. الصق هذا:
موافقةُ ردّ الأموال حالياً توقفٌ داخل العملية يفقده تعطّلٌ أو مراجعٌ بطيء. اجعله متيناً: عندما يتوقف الوكيلُ عند ردّ الأموال، خزّن حالةَ تشغيله المسلسَلة بوصفها مخرجَ الخطوة، ثم علّق الدالةَ كلها على
step.wait_for_eventمنتظراً حدثَrefund/approval.decided(امنحه مهلةَ أربع ساعات وطابقه بهذا العميل). عندما يصل القرار، أعد ترطيبَ الحالة، طبّق موافقة أو رفضاً، واستأنف الوكيلَ فيُطلَق ردُّ الأموال مرة واحدة بالضبط. قُد ردَّ أموال، أرني التشغيلَ معلَّقاً ومنتظِراً، أرسل موافقة، وأرني صفَّ تدقيق ردّ أموال واحداً بالضبط. ثم افعلها من جديد برفض وأرني صفاً محجوباً ولا ردَّ أموال.
يعدّل: inngest_app.py (يتعلّم مساعِدا الوكيل التوقفَ والاستئناف؛ وتكتسب دالةُ البريد البوابة).
يكسب هذا القرارُ كوداً أكثر من غيره، لأن رقصةَ التعليق-والاستئناف هي الدرس. عندما يتوقف الوكيل، يسلسِل حالةَ تشغيله؛ وعندما يصل القرار، تعيد ترطيبَ تلك الحالة، تطبّق موافقة أو رفضاً، وتستأنف:
async def _run_agent(email_text: str) -> dict:
agent = worker.build_agent()
result = await Runner.run(agent, email_text, run_config=worker.run_config())
if result.interruptions: # the refund tool paused for approval
return {"status": "needs_approval", "state": result.to_state().to_string()}
return {"status": "done", "reply": result.final_output}
async def _resume_agent(state_str: str, approved: bool, rejection_message: str | None) -> dict:
agent = worker.build_agent()
state = await RunState.from_string(agent, state_str)
for item in state.get_interruptions():
if approved:
state.approve(item)
else:
state.reject(item, rejection_message=rejection_message or "Refund denied.")
db.record("refund_blocked", detail=f"args={item.arguments}")
result = await Runner.run(agent, state, run_config=worker.run_config())
return {"status": "resumed", "reply": result.final_output}
داخل دالة البريد، البوابة wait_for_event واحد في الخط حيث توقف الوكيل؛ والقرار يقود خطوةَ استئناف:
decision = await ctx.step.wait_for_event(
"await-refund-approval",
event="refund/approval.decided",
timeout=datetime.timedelta(hours=4),
if_exp=f"async.data.customer_id == '{customer_id}'",
)
# (decision is None on timeout -> write a refund_blocked row and return)
resumed = await ctx.step.run("resume-agent", functools.partial(
_resume_agent, outcome["state"], bool(decision.data.get("approved")), decision.data.get("rejection_message")))
اقرأه من الأعلى إلى الأسفل: البوابة استدعاءٌ واحد في الخط في دالة عادية لولا ذلك. لا callback، ولا توزيعَ آلة حالة، ولا تفرّعَ if status == waiting: عبر الاستدعاءات. يتولى وقتُ التشغيل التعليقَ والاستئناف؛ ويعبّر كودك عن السياسة. أربعة تفاصيل تستحق مكانها:
result.to_state().to_string()يسلسِل التشغيلَ المتوقف، فيصير مخرجَ خطوةrun-agent، فيُخزَّن بمتانة. وto_state()متزامنة؛ وto_string()تعيد السلسلةَ التي تخزّنها.RunState.from_string(agent, s)يُنتظَر (إنه coroutine) ويأخذ تلك السلسلة المخزنة مباشرة. ثم توافق أو ترفض عبرstate.get_interruptions()وتستدعيRunner.run(agent, state, ...)للاستئناف. (يمكن أن يترك استئنافٌ واحد موافقاتٍ معلَّقة، فالمساعِدُ الحقيقي يكرّر حتى لا يبقى شيء.)- يُعاد تمريرُ
run_config()نفسه عند الاستئناف، ويُعاد بناءُ الوكيل إلى شكل الأداة نفسه. لا تحمل الحالةُ المسلسَلة جلسةَ صندوق الرمل، فعلى الاستئناف أن يوفّرها من جديد. هذا التفصيلُ الواحد الذي، إن فات، يجعل التشغيلَ المُستأنَف يفشل. (فصل D0 بينbuild_agentوrun_configلهذا بالضبط.) if_expيطابق القرارَ بهذا العميل (async.data.customer_id == '...')، فموافقةٌ لعميل لا تستأنف تشغيلَ عميل آخر أبداً.
لقيادته من الوكيل: أرسل حدثَ customer/email.received يصف بريدُه ردَّ أموال، راقب التشغيلَ يعلّق عند await-refund-approval (تُظهره لوحةُ التحكم WAITING، بحالة تشغيل RUNNING لكن حوسبة صفرية)، ثم أرسل refund/approval.decided ب {"approved": true, "customer_id": "cust_001"} عبر send_event. افعلها من جديد ب {"approved": false}.
جاهز عندما: عند الموافقة، يستأنف التشغيلُ المعلَّق ويحوي جدول Neon audit_log صفَّ refund_issued واحداً بالضبط. وعند الرفض، يستأنف التشغيلُ، ويحوي التدقيقُ صفَّ refund_blocked و_لا_ refund_issued، ويشرح ردُّ الوكيل الرفضَ.
لماذا هذا حجرُ الأساس. كلُّ طبقة أخرى (الحواس، وردود الفعل المنعكسة، والتوازن) تبقي العاملَ صحيحاً أو سليماً من تلقاء نفسه. وهذه هي حيث يعود العقلُ البشري إلى الحلقة على فعل عالي المخاطر، بمتانة، ما دام الأمر يستغرق، عند كلفة صفرية أثناء الانتظار. ونسخةُ طابور-مع-قاعدة-بيانات-مع-poller من هذا مشروعٌ صغير. وهنا wait_for_event واحد واستئناف.
D6: أثبت أن المتانة تنجو من خطوة مكسورة
أين أنت: عامل كامل بكل طبقة ملفوفة. يثبت هذا القرارُ الخاصيةَ التي بررت كل ذلك؛ وبنهايته تكون قد شاهدت تشغيلاً مكسوراً يعيد محاولةَ خطوته الفاشلة مرات كثيرة بينما تعمل خطوةُ تدقيقه المكتملة مرة واحدة بالضبط، ثم تعافيتَ العملَ على تشغيل جديد.
الخاصيةُ الأخيرة المطلوب إثباتها هي التي بررت كل هذا، آليةُ التذكّر من المفهوم 7. فهمتها هناك؛ والآن أثبتها في عاملك الخاص. الصق هذا:
اكسر خطوةَ الوكيل عمداً فتفشل، أطلق حدثاً، وأرني Inngest يعيد محاولتها بينما تبقى خطوةُ التدقيق السابقة مذكَّرة، فيكتب التشغيلُ الفاشل صفَّ تدقيق دخوله مرة واحدة بالضبط عبر كل إعادات محاولة الوكيل. ثم أصلح الخطوة وتعافَ العمل، وأرني التعافيَ يكتمل.
اكسر خطوةَ الوكيل عمداً (ارمِ ValueError داخل _run_agent)، أطلق بضعة أحداث customer/email.received لعملاء مختلفين، واقرأ تتبّعَ كل تشغيل. هذا هو الإثبات، وهو داخل كل تشغيل فاشل: تُظهر audit-received محاولة واحدة مكتملة وتكتب صفها مرة واحدة؛ وتُظهر run-agent عدة Attempts وهي تعيد المحاولة بتراجع (يتخذ Inngest افتراضياً عدة محاولات) ثم تفشل؛ ولا تعمل audit-sent أبداً. خطوةُ التدقيق الجالسة عند محاولة واحدة بينما تتسلق خطوةُ الوكيل هي التذكّرُ من المفهوم 7، الآن مرئياً في عاملك الخاص: يكتب التشغيلُ الفاشل صفَّ message_received واحداً فقط مهما أعادت خطوةُ الوكيل المحاولة.
ثم اعكس الكسر (يعيد المضيفُ التحميل تلقائياً إن شغّلته ب --reload؛ وإلا فأعد تشغيله) وتعافَ العملَ بإعادة إطلاق الحدث على الكود المصلَّح (أو، لدفعة نشر سيئ حقيقية، زرّ Rerun في لوحة التحكم؛ كلاهما يبدأ تشغيلاً جديداً من الأعلى، مغطّى في المفهوم 14). هنا الجزء الذي يفاجئ الناس، وهو سلوك صحيح لا خطأ: التعافي تشغيلٌ جديد تماماً، فيشغّل audit-received من جديد ويكتب صفَّ message_received الخاص به. بعد كسر-ثم-تعافٍ، يكون لذلك العميل بحق صفّان من message_received، واحد من التشغيل الفاشل، وواحد من التعافي. التذكّرُ ضمانٌ داخل التشغيل؛ ولا يمتدّ أبداً عبر تشغيلين منفصلين.
جاهز عندما: في تتبّع التشغيل الفاشل، جلست audit-received عند محاولة واحدة وكتبت صفاً واحداً بينما راكمت run-agent عدة محاولات وفشلت، تلك المحاولةُ-الواحدة-رغم-N-إعادات هي التذكّر، مثبَتاً. ثم يكمل تشغيلُ التعافي run-agent وaudit-sent على الكود المصلَّح. استعلم جدول Neon audit_log في الكونسول (أو اطلب من وكيلك قراءته عبر أدوات Neon): عميلٌ كسرته-وتعافيته سيكون له صفّان من message_received (تشغيل فاشل إضافة إلى تعافٍ) وواحد من message_sent (التعافي وحده بلغ ذلك الحدّ)، وهو صحيح تماماً. التشخيصُ الحقيقي لكل تشغيل، لا لكل عميل: افتح تتبّعَ تشغيل واحد وأكّد أن audit-received تُظهر محاولة واحدة. إذا أظهر تتبّعُ تشغيل واحد خطوةَ الدخول تعمل مرتين، فذلك خطأ تذكّر (عادةً اسمُ خطوة غير فريد)؛ أما صفّان موزّعان عبر تشغيلين منفصلين فليس كذلك.
لماذا هذا هو الخط الفاصل. عاملٌ يفقد عملَ العميل على نشر سيئ هو مجرد وكيل تستدعيه. وعاملٌ يأخذ النشرَ السيئ نفسه، ويفشل بصوت عالٍ، ويعيد محاولةَ الخطوة المكسورة دون إعادة العمل الذي أنهاه (محاولاتُ خطوة الوكيل الكثيرة، لكنّ تدقيقَ الدخول مكتوبٌ مرة واحدة)، ويتعافى بنظافة على تشغيل جديد بعد الإصلاح، هو عاملُ إنتاج. الإثباتُ هو تتبّعُ التشغيل الفاشل نفسه، محاولةُ دخول واحدة مقابل محاولات وكيل كثيرة، لا عددُ صفوف عبر التشغيلات.
وجّه الجهازَ العصبي نفسه إلى عامل SandboxAgent الخاص بك بدلاً من الأدنى؛ والوصلُ مطابق. وموافقةُ step.wait_for_event هذه تحلّ محلَّ جدول حالة التشغيل المصنوع يدوياً من القرار 10 الاختياري في تلك الدورة: البوابةُ المتينة التي بنيتها للتو هي طبقةُ الاستمرار، فيمكنك حذفُ الجدول.
ما الذي حدث للتو
بنيت عامل دعم عملاء صغيراً ومنحته جهازاً عصبياً، طبقةً طبقة. لم تتغير داخلياتُ العامل بعد D0: SandboxAgent نفسه، والأداتان نفساهما، وسجلُّ تدقيق Neon Postgres نفسه. ما تغيّر هو كلُّ شيء حوله. صار يستيقظ على حدث customer/email.received وعلى cron يومي يتفرّع لكل عميل مؤهل، ويعمل بمتانة (استدعاءُ الوكيل داخل step.run)، ويحترم التحكمَ في التدفق (تزامن عالمي ولكل عميل، وخنق)، ويبوّب ردودَ الأموال على موافقة بشرية متينة (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 يوماً، ودعم مخصص |
يتراكم تسعيرُ الأحداث فوق ذلك: أول 1-5 ملايين حدث في اليوم مضمَّنة؛ وفوقها، يبدأ الفائضُ نحو 0.000050 دولار للحدث ويتناقص عند حجم أعلى. ويضيف Pro 50 دولاراً لكل مليون تنفيذ إضافي عندما تتجاوز سقفَ المليون.
سقوفُ طبقة Hobby المهمة هنا. سقفُ الخطوات الخمس المتزامنة يعني أنه حتى لو أعلنت concurrency=Concurrency(limit=10) في الكود، يبقيك سقفُ المنصة على مستوى الحساب عند 5. كودُك صحيح للإنتاج؛ والتزامنُ الملاحَظ على الطبقة المجانية هو 5. وstep.sleep وstep.sleep_until محدودان بالطبقة أيضاً: حتى سبعة أيام على خطة Hobby المجانية، وحتى سنة على الخطط المدفوعة (حدود استخدام Inngest).
كلفةُ الاستدلال تهيمن. يستخدم تشغيلُ دعم عملاء نموذجي بضعةَ آلاف إلى عشرة آلاف رمز نموذج لكل محادثة. اضرب سعرَك لكل رمز في رموزك لكل بريد في بُرُدك في اليوم فتحصل على السطر الذي يهمّ؛ ولمعظم العمال يقزّم كلَّ ما عداه. هذا ما تحسّنه. وكلُّ شيء آخر خطأُ تقريب. والرافعتان الأعلى قيمة: أبقِ بادئةَ تعليمة مخبَّأة ثابتة (فيفوتر النموذجُ الجزءَ المكرر بالسعر المخبَّأ الأرخص، لا السعر الكامل في كل استدعاء)، ووجّه الدوراتِ السهلة إلى نموذج أرخص.
ثلاثُ رافعات كلفة خاصة ب Inngest متى دخلت منطقةَ التحسين:
- لا تلفّ دوالاً نقية في
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 بتزامن عامل، ومحدِّداتُ معدل قائمة على 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 وSDKs متعددة اللغات أكثر من تجربة التطوير المحلي ل Inngest. وInngest أداةُ التعلم الأفضل (تجعل لوحةُ التحكم النموذجَ الذهني مرئياً)؛ وDapr أداةُ النطاق الأفضل متى بلغت سقوفَ طبقة Inngest أو احتجت نشراً أصلياً ل K8s متعدد اللغات.
وInngest أيضاً مفتوحُ المصدر (github.com/inngest/inngest؛ أضاف إصدارُ 1.0 دعمَ الاستضافة الذاتية في سبتمبر 2024) وقابلٌ للاستضافة الذاتية عبر Helm + KEDA. والمحاورُ التي تهمّ على النطاق هي الحوكمة، والدعم، والنضج: يحكم Inngest مزوّدٌ واحد بقصة استضافة ذاتية فتية؛ وDapr محكومٌ ب CNCF بسجلّ إنتاج أطول.
| مفهومُ هذه الدورة | بدائيةُ Inngest | نظيرُ إنتاج Dapr | ملاحظةٌ تعليمية |
|---|---|---|---|
| عملٌ مجدول | TriggerCron | Cron input binding / Dapr Scheduler | الفكرة نفسها: الوقت يوقظ العامل. وDapr عادةً يتطلب إعدادَ مكوّن. |
| دخولُ webhook/حدث | نقطةُ نهاية Inngest webhook ← حدث | نقطةُ نهاية HTTP، أو input bindings، أو دخولُ pub/sub | يخفي Inngest سباكةً أكثر؛ ويمنح Dapr تحكماً تحتياً. |
| أحداثٌ داخلية | inngest_client.send() | Dapr pub/sub | النموذجُ الذهني القائم على الأحداث نفسه؛ والوسيطُ قابل للتوصيل في Dapr. |
| Fan-out | حدثٌ واحد يحفّز دوالاً كثيرة | موضوع/حدث واحد تستهلكه خدماتٌ كثيرة | البنيةُ نفسها؛ ويستخدم Dapr تأليفَ وسيط/موضوع/مشترك. |
| خطواتٌ متينة | step.run() + التذكّر | Dapr Workflows + activities | غرضُ إنتاج مشابه، ونموذجُ مطوّر مختلف. |
| انتظارٌ بلا حوسبة | step.sleep() | مؤقتاتُ سير عمل متينة | كلاهما يتجنب إبقاء عملية مفتوحة أثناء الانتظار. |
| بوابةُ موافقة بشرية | step.wait_for_event() | أحداثُ/إشاراتُ سير عمل خارجية، وpub/sub، وactors | تعبيرُ Inngest أبسط؛ وDapr أكثر قابلية للتأليف. |
| إعاداتُ المحاولة | إعاداتُ محاولة دالة/خطوة | إعاداتُ محاولة سير عمل/نشاط + سياساتُ مرونة | يجعل Dapr المرونةَ سياسةَ وقت تشغيل أيضاً، لا سلوكَ سير عمل فقط. |
| Dead-letter / تشغيلات فاشلة | تشغيلاتُ Inngest الفاشلة + إعادة التشغيل | DLQ الوسيط + حالةُ سير العمل/إعادة التشغيل/أدوات يدوية | Inngest أكثر جاهزية هنا؛ وDapr أكثر أصالةً تحتية. |
| التحكمُ في التدفق | التزامن، والخنق، والأولوية، والتجميع | توسّعُ Kubernetes، وتزامنُ التطبيق، وضوابطُ الوسيط، وسياساتُ المرونة، وbulk pub/sub | يستطيع Dapr فعلَه، لكنه ليس وسيطَ مزيِّن واحداً. وInngest أكثف. |
| تنسيقٌ ذو حالة | wait_for_event، ومفاتيحُ الحدث، وحالةُ الخطوة | Actors + state store + workflows | Dapr Actors أقوى للهوية الطويلة الأمد/التنسيق ذي الحالة. |
| وقتُ تشغيل الوكيل | وكيلُك داخل دالة Inngest | DurableAgent / Dapr Agents v1.0 GA | يجعل Dapr Agents الوكيلَ صراحةً مدعوماً بسير عمل وقابلاً للاستئناف. |
هذا الجدولُ دليلُ ترجمة، لا ادعاءُ واجهات متطابقة. يعلّم Inngest نمطَ الإنتاج بتجربة مطوّر مدمجة: المحفزات، والخطوات، والانتظارات، وإعادة التشغيل، والتحكم في التدفق في سطح منتج واحد. وينفّذ Dapr بنيةَ الإنتاج نفسها عبر لبنات أنظمة موزّعة: bindings، وpub/sub، وworkflows، وactors، وحالة، ومرونة، وعمليات أصلية ل Kubernetes. تنتقل المفاهيمُ مباشرة؛ ويتغير أسلوبُ التنفيذ. محقَّق مقابل نظرة Dapr العامة على bindings ومفاهيم 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 تحقيقٌ واحد.
عاملٌ واحد يستيقظ على الأحداث، ويعمل بمتانة، ويبوّب على البشر هو أصغرُ وحدة من البنية التي تعلمها هذه الدورة. وتمدّد الدورةُ التالية ذلك العاملَ إلى قوة عمل: عمالٌ متعددون ينسّقهم مدير، قابلون للتوسع عند الطلب، توقظهم محفزاتٌ، تحكمهم spec. أساسُ 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 صباحاً؟» ← خنقٌ غائب (المفهوم 11). أضف
throttle=Throttle(limit=N, period=timedelta(minutes=1)). - «لماذا تجوّع طفراتُ عميل واحد عملاءَ آخرين؟» ← تزامنٌ لكل مفتاح غائب (المفهوم 12). أضف
Concurrency(limit=2, key="event.data.customer_id")ثانياً. - «لماذا أُطلقت بوابةُ HITL لديّ بصمت في عطلة نهاية الأسبوع؟» ← معالِجُ مهلة غائب يكتب إلى التدقيق (المفهوم 15). تفرّع على
approval is Noneواكتب صفَّ التدقيق صراحةً.
ابنِ البنيةَ قطعةً قطعة. ولهذا الجزءُ 4 سبعُ تعليمات، لا واحدة. ابنِ العامل (D0). لُفّ الوكيلَ في step.run (D1) وراقب ما يتغير حين تعطّل عمداً في منتصف التشغيل. أوقظه على حدث (D2). أضف fan-out ب cron (D3)، ثم التحكمَ في التدفق (D4) متى اصطدمت فعلاً بحدّ معدل، ثم بوابةَ الموافقة المتينة (D5) حين يحتاج فعلٌ عالي المخاطر إنساناً فعلاً. كلُّ طبقة تعلمها الخاص. ومجموعةً في إعادة كتابة كبيرة واحدة، هي جدار.
الانضباطُ الذي تعلّمه هذه الدورة (استيقظ على الأحداث، اعمل بمتانة، بوّب على البشر، أعد التشغيل عند الأخطاء) هو الثابتُ المعماري. أياً كانت المنصةُ التي تنفّذه، عقدُ الخصائص الأربع ذاك هو ما تلتزم به فعلاً. هذا رهانُ Lindy: تبني على الأجزاء التي دامت، الدوال العادية، وSQL، ولغة مكتوبة الأنواع، وناقل أحداث، لا غلافُ هذا الموسم. المنتجُ قابل للاستبدال؛ والانضباطُ لا.
المرجع السريع
فاصلٌ بين الدورة السردية والمرجع أثناء البناء. الأقسامُ أدناه معدّة للبحث، لا للقراءة من الأعلى إلى الأسفل. وخلاصةُ كل مفهوم من سطر واحد في ورقة الغش المطوية بالمقدمة؛ وهذا القسمُ هو التشخيصُ أثناء البناء، وشجرتا القرار، وتخطيطُ الملفات.
شجرةُ قرار: اختر سطحَ المحفّز
عندما يحدث شيء جديد في العالم، من أين يأتي الاستيقاظ؟
- أرسل لنا نظامٌ خارجي طلبَ HTTP. ← محفّزُ webhook. اضبط المصدرَ في لوحة Inngest؛ أعد تشكيلَ الحِمل عبر transform؛ استهلك الحدثَ الناتج.
- يقول جدولٌ إن الوقتَ حان. ← محفّزُ cron.
TriggerCron(cron="..."). استخدم UTC؛ وتُطلَق crons الإنتاج حتى وخدمتك في منتصف نشر. - أصدرت دالةُ Inngest أخرى حدثاً أثناء تشغيلها. ← محفّزُ حدث.
TriggerEvent(event="ns/name.subtype"). اشترك دالةً واحدة أو كثيرة في الاسم نفسه. - مستخدمٌ تفاعلي ينتظر استجابةً فورية. ← ليس محفّزَ Inngest. أبقِ الطلب/الاستجابة في نقطة نهاية الويب العادية لديك؛ وإذا تضمّنت الاستجابةُ عملاً ثقيلاً، أطلق حدثاً من داخل الطلب وعُد فوراً، تاركاً Inngest يعالج العملَ بلا تزامن.
شجرةُ قرار: اختر بدائيةَ الخطوة
بافتراض أن دالةً تعمل وتحتاج فعلَ شيء، أي استدعاء step.* تلجأ إليه؟
- استدعاءٌ ذو أثر جانبي (API، أو قاعدة بيانات، أو كتابة ملف، أو استدعاء وكيل). ←
ctx.step.run("name", fn, ...). الافتراضي. مذكَّر عند النجاح، مُعاد عند العطل العابر. - استدعاءُ OpenAI طويل على منصة serverless تفوتر مقابل الوقت الجاري. ←
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)
يعيش العملاءُ وسجلُّ التدقيق في قاعدة بيانات Neon لديك (مزوَّدة في المكسب السريع، ومزروعة في D0)، لا في ملفات محلية. ولا يتغير العامل (db.py، وworker.py) بعد D0. وكلُّ طبقة جهاز عصبي (D1 حتى D5) تعدّل inngest_app.py.
جدولُ تشخيص، العَرَض ← السبب الجذري ← المفهوم
| العَرَض | المشتبه الأول | المفهوم لإعادة قراءته |
|---|---|---|
| الدالةُ لا تُحفَّز حين يصل الحدثُ المتوقع | خطأٌ مطبعي في اسم الحدث، عدمُ تطابق فضاء الأسماء | C3 (webhooks)، وC5 (fan-out) |
| الدالةُ تُحفَّز مرتين للحدث المنطقي نفسه | مفتاحُ idempotency غائب | C4 (idempotency) |
| الدالةُ «فقدت عملاً» بعد نشر | كودٌ خارج step.run يؤدي العمل | C7 (التذكّر) |
| جدولُ cron لم يُطلَق عبر نشر | خادمُ التطوير المحلي فقط، والإنتاج يعمل على بنية Inngest | C2 (cron) |
| خُصم من العميل مرتين لردّ أموال واحد | استدعاءُ Stripe خارج step.run، أو اسمُ الخطوة غير فريد | C6 (step.run)، وC7 (التذكّر) |
| أخطاءُ حدّ معدل OpenAI أثناء ذروة 9 صباحاً | خنقٌ غائب | C11 (التزامن + الخنق) |
| طفراتُ عميل واحد تجوّع عملاءَ آخرين | تزامنٌ لكل مفتاح غائب | C12 (الأولوية + العدالة) |
| الدالةُ معلَّقة إلى الأبد، لا تستأنف أبداً | اسمُ الحدث في wait_for_event لا يطابق الحدثَ المُرسَل | C8 (wait_for_event)، وC15 (HITL) |
| مهلةُ HITL أُطلقت بصمت في عطلة نهاية الأسبوع | معالِجُ مهلة غائب يكتب إلى التدقيق | D5 (بوابةُ ردّ الأموال المتينة)، وC15 (HITL) |
| تشغيلاتُ الأمس الفاشلة اختفت من لوحة التحكم | تبقى التشغيلاتُ حتى تُعاد يدوياً أو بعد نافذة الاحتفاظ | C14 (إعادة التشغيل) |
| إعادةُ التشغيل أعادت خصمَ العملاء | إعادةُ التشغيل تشغيلٌ جديد يعيد تنفيذَ كل خطوة؛ والخصمُ بلا مفتاح idempotency | C4 (idempotency)، وC14 (إعادةُ التشغيل تشغيلٌ جديد) |
| تتبّعُ الدالة لا يُظهر تعليمةَ OpenAI | يُظهر تتبّعُ الخطوة مدخلات/مخرجات الدالة لكن لا قياسات تعليمة/رموز خاصة ب LLM | C10 (تستخدم Python step.run؛ القياساتُ الخاصة ب LLM تحتاج تتبّعَ عميل OpenAI الخاص بك؛ وتتبّعاتُ step.ai.wrap على مستوى التعليمة خاصة ب TypeScript) |
ملحق: نسبٌ اختياري وورقةُ غش ل Inngest
تقف هذه الدورة وحدها: يبني الجزءُ 4 العاملَ من الصفر، فلا شيء أدناه شرطٌ سابق. ملاحظتان قصيرتان للسياق.
A.1: إن كنت قادماً من دورة Digital FTE
تبني دورةُ من وكيل إلى موظف رقمي بدوام كامل عامل دعم عملاء أغنى: مهاراتٌ محمولة، ونظامُ سجل Postgres، وخادمُ MCP مخصص. إن أنجزتها، فلديك أصلاً عامل SandboxAgent جالس على القرص، ويمكنك تخطّي الأدنى في D0: وجّه الجهازَ العصبي (من D1 فصاعداً) إلى عاملك الخاص بدلاً منه. والوصلُ مطابق. إضافةٌ واحدة: بوابةُ ردّ الأموال المتينة التي تبنيها في D5 (step.wait_for_event) تحلّ محلَّ جدول حالة التشغيل المصنوع يدوياً من القرار 10 الاختياري في تلك الدورة، فيمكنك حذفُه. وإن لم تنجز تلك الدورة، فتجاهل كلَّ هذا؛ يمنحك 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>على المضيف ليتبع، لا رابطَ MCP وحده.
A.3: التحوّلان الصعبان فعلاً
أصعبُ شيء في هذه الدورة ليس صيغةَ Inngest. إنه التحوّلُ الذهني من الطلب إلى الحدث (المفهوم 1) ومن التنفيذ داخل العملية إلى التنفيذ المتين (المفهوم 6). الصيغةُ آلية بمجرد أن يستقرّ ذانك. أعد قراءةَ المفهومين 1 و6 أولاً إن بدا أيُّ شيء آخر أصعبَ مما ينبغي.