حُرّاس Vercel AI SDK: سدّ ثغرة المطابقة المقسومة
أمثلة الوسيط في AI SDK لم تكن تجتاز المصرِّف، وتُرك wrapStream دون تنفيذ. ما الذي أبلغنا عنه، وما دُمج في 22 سبتمبر 2026، والقاعدة.
خمسة أمثلة، ولا واحد منها يجتاز المصرِّف
يوثّق AI SDK وسيطَ النماذج اللغوية في صفحة واحدة، وكانت تلك الصفحة تحمل خمسة أمثلة عملية. وبنسخها إلى مشروع يعمل على ai@7.0.107 و@ai-sdk/provider@4.0.17، أخفق كل واحد منها على النحو نفسه:
error TS2741: Property 'specificationVersion' is missing in type
'{ wrapGenerate: ... }' but required in type 'LanguageModelV4Middleware'.
وspecificationVersion: 'v4' حقل مطلوب غير قابل للتعديل في نوع الوسيط، ولم تذكره الصفحة في أي موضع — لا في المثال الأول، ولا في ملاحظة، ولا عَرَضًا. ولم يكن الخلل خفيًّا: فكل مثال على الصفحة أخفق على الخاصية الناقصة نفسها، مهما كان ما يعرضه كل واحد منها. فالقارئ الذي يتبع الصفحة يحصل على هذا الخطأ في أول وسيط يكتبه، ولا يجد في الصفحة ما يفسّره.
النصف الذي تُرك تعليقًا
أما المشكلة الثانية فأكبر، وقد كانت الصفحة صريحة بشأنها. فمثال الحماية ينفّذ wrapGenerate، ويُجري .replace() على النص بعد اكتماله، ويترك نصف البثّ تعليقًا، وهذا نصّه كما التُقط بالضبط:
here you would implement the guardrail logic for streaming // Note: streaming guardrails are difficult to implement, because you do not know the full content of the stream until it's finished.
الملاحظة صحيحة، وهي تذكر الصعوبة كلّها بوضوح. لكنها لا تقول ما الذي يحدث للقارئ الذي يقرؤها دعوةً. فالخطوة البديهية أن ينقل .replace() من wrapGenerate ويضعها في تحويل يمرّ على أجزاء البثّ. والنتيجة تبدو حارسًا عاملًا، وتنجو من تجربة يدوية بطلب قصير، وهي خاطئة من وجهين منفصلين.
الوجه الأول: المطابقة ليست هناك أصلًا لتُلتقط
يُخرج النموذج نصّه دفعاتٍ مقسومة على حدود الرموز لا على المعنى. وأي قيمة تطول بما يستحقّ الحجب تتوزّع عادةً على عدّة دفعات منها.
delta 1: "...your card is 4111 11"
delta 2: "11 1111 1111, charge it."
و.replace() على الدفعة الأولى لا تجد رقم بطاقة، وعلى الثانية لا تجد رقم بطاقة. وتذهب الدفعتان إلى العميل، فيصلهما المتصفّح، ويظهر الرقم على الشاشة. وبأحجام الدفعات التي يرسلها المزوّدون فعلًا، ليست هذه حالة طرفية — بل هي الحالة الغالبة في معظم القيم التي تستحقّ الترشيح.
الوجه الثاني: السرّ المحجوب نصفه
والإصلاح الذي يلجأ إليه الناس هو الاحتفاظ بنافذة من النص الأخير، وفحصها، وإخراج ما يسقط من مؤخّرتها. هذا يمرّ في المراجعة، ويفشل أسوأ من الأول، لأنه ينتج مخرجات تبدو معالَجة.
بعض الأنماط تتوقّف عن النمو، وكثير منها لا يتوقّف. فالنمط الذي يطابق سرًّا متغيّر الطول يطابق بمجرّد أن يطول النص بما يكفي، ثم يظلّ يطابق كلّما وصلت محارف أخرى. وإن وقعت نقطة الإخراج داخل قيمة ما زالت تنمو — وهذا ما يقع كثيرًا مع نافذة ثابتة — حجب المرشّح ما بين يديه وبثّ الباقي مكشوفًا:
out: "your key is <REDACTED>uvwxyz123456"
فنصف السرّ في السجلّ، إلى جوار علامة تقول للقارئ إنه أُزيل. التسريب الظاهر عطل، والتسريب الذي يرتدي علامة حجب عطلٌ وطمأنينةٌ كاذبة معًا، والجزء الثاني هو ما يجعله مكلفًا: لا أحد يعود لينظر.
القاعدة
لا تتصرّف بناءً على مطابقة يمكن أن تنمو، ولا تُخرج نصًّا لم تفحصه في صورته النهائية.
وللشطرين وزن. الأول يمنع حجب قيمة قبل أن تعرف أين تنتهي، والثاني يمنع إخراج نصّ لم تره إلا مقطّعًا. وعمليًّا يعني ذلك الاحتجاز من بداية أي مطابقة تتجاوز نقطة الإخراج، لا من موضع ثابت خلفها. فالموضع الثابت تخمين لأطوال الأسرار، أما بداية المطابقة فواقعة في النص الذي بين يديك.
الخاصية مكتوبةً اختبارًا
القاعدة تعليمة تصميم، وهذه صورتها القابلة للفحص آليًّا:
لأي مدخل وأي تقسيم، يجب أن يطابق الناتج المبثوث — بايتًا ببايت — ترشيحَ النص كاملًا دفعة واحدة.
أعد تشغيل المدخل نفسه بكل حجم دفعة — محرف واحد، ثم اثنان، ثم ثلاثة، حتى النص كلّه — وقارن كل تشغيلة بنتيجة المرور الواحد. فيتحوّل الإخفاقان أعلاه إلى اختلاف، ولا يحتاج أحد إلى الاستدلال على أي حدّ كان الحدّ المشؤوم. وهو قصير، ولا يعنيه كيف بُني المرشّح. وللصنف الذي يكشفه اسم وكتابة أطول: بيانات شخصية مقسومة بين أجزاء البثّ.
ما الذي دُمج
كان البلاغ vercel/ai#21209، «docs: guardrails example leaves wrapStream unimplemented» (مثال الحماية ترك wrapStream دون تنفيذ)، رُفع في 20 سبتمبر 2026. وأُغلق في 22 سبتمبر بعد تسعة تعليقات. وفي 28 سبتمبر 2026 كان المستودع يحمل 27,011 نجمة. ودُمجت ثلاثة طلبات دمج بفارق ستّ عشرة ثانية بينها:
- #21210 على main — ملف واحد، +92/-7
- #21211 على release-v6.0 — ملف واحد، +92/-7
- #21212 على release-v5.0 — ملفان، +151/-21
أي +335/-35 على ثلاثة فروع. ويحمل البلاغ الوسوم merged-main وmerged-v5.0 وmerged-v6.0 وdocumentation-fix، من بين غيرها. وحتى 28 سبتمبر 2026، تحمل صفحة الوسيط الحيّة specificationVersion: 'v4'.
ومثال wrapStream الذي حطّ لا يعتمد على أي مكتبة. يحتفظ بمخزن لكل مُعرّف نصّي — إذ يمكن للبثّ أن يحمل عدّة كتل نصّية في آن واحد، وهذا بالضبط ما وُجد من أجله المُعرّف على كل جزء، والمخزن الواحد المشترك ينسب النص إلى الكتلة الخطأ. ثم يحسب نقطة حسم، ويمشي على المطابقات في المخزن، وحين تتجاوز مطابقةٌ نقطةَ الحسم يحتجز من m.index لا من نقطة الحسم. ولا يقطع داخل زوج بديل أبدًا، فلا يصل الرمز التعبيري نصفين مكسورين. وهو، إذا قُرئ كلّه، القاعدةُ أعلاه مكتوبةً كودًا بدل أن تُوصف في تعليق.
الإفصاح جاء أولًا
وثمّة تفصيل يزن أكثر من الفرق في الأسطر. فقد افتُتح البلاغ بهذا، قبل وصف المشكلة أصلًا: «إفصاح: أنا أشرف على مكتبة برخصة MIT تُجري الحجب على البثّ، فلي مصلحة هنا. والاقتراح أدناه قائم بذاته عن قصد — لا يعتمد عليها إطلاقًا، ويمكنك وضعه في التوثيق كما هو.»
والمكتبة هي llm-stream-guardrails، وقد نُشرت على npm في 21 سبتمبر 2026 — في اليوم التالي لرفع البلاغ. فالبلاغ جاء أولًا، وما اقترحه رقعةٌ يستطيع المشرف أن يأخذها دون أن يأخذ معها شيئًا آخر. وهذا هو الترتيب الوحيد الذي يجوز فيه لمن يشرف على بديل أن يرفع بلاغًا على مشروع بهذا الحجم: أعلن المصلحة في الأعلى، ثم اجعل الاقتراح جيّدًا بما يكفي ليصمد بعد إعلانها.
إن كنت تكتب واحدًا من هذه
خذ القاعدة والشرط الثابت، ولا تحتاج إلى شيء آخر في هذه الصفحة. كلاهما يعمل بلا أي اعتماد على مكتبة، والمثال الموجود الآن في توثيق AI SDK تنفيذ كامل لهما. وثمّة مكتبات أخرى تعالج حدود الأجزاء باستطلاع محدود وتؤدّي عملها — فالسؤال ليس أي حزمة تقف على سطر الاستيراد، بل هل يصمد الناتج إن أُعيد تشغيله بكل حجم جزء. وإن لم يصمد، لم تعد الحزمة تعني شيئًا.
ويحسن ذكر الحدود صراحةً، لأن مرشّح البثّ يغري بثقة أكبر مما يستحقّ. فهو يطابق صيغًا لا معانيَ: يلتقط رقم بطاقة ولا يلتقط التاريخ الطبي لعميل مكتوبًا نثرًا. ويعمل بعد أن يقرّر النموذج ما يقوله، فهو خط أخير لا خط أول. ولا يجعل أي مرشّح في أي مكتبة منظومةً ممتثلة لشيء؛ فالامتثال صفة المنظومة ومن يشغّلها. وما تكسبه من القاعدة أضيق من ذلك، ويستحقّ أن يُقتنى وحده: ناتج واحد سواء وصل قطعة واحدة أو أربعمئة.
أسئلة يطرحها المهندسون
كيف أُصلح الخطأ «error TS2741: Property 'specificationVersion' is missing»؟
أضِف specificationVersion: 'v4' إلى كائن الوسيط. فهو حقل مطلوب غير قابل للتعديل في LanguageModelV4Middleware، والكائن الذي لا يحمل غير wrapGenerate أو wrapStream لا يجتاز فحص الأنواع. ولم تذكر صفحة الوسيط في AI SDK هذا الحقل قطّ، ولذلك أخفقت أمثلتها الخمسة كلّها مع ai@7.0.107 و@ai-sdk/provider@4.0.17. وقد صُحّحت الصفحة في 22 سبتمبر 2026 وصارت تعرض الحقل. وإن نسخت مثالًا قبل ذلك التاريخ، فهذا السطر الواحد هو الإصلاح كلّه.
لماذا لم تكن أمثلة الوسيط في AI SDK تجتاز المصرِّف؟
أخفق كل مثال على الصفحة على الخاصية الناقصة نفسها، مهما كان ما يعرضه كل واحد منها: فـspecificationVersion حقل مطلوب في نوع الوسيط، ولم تذكره الصفحة في أي موضع — لا في المثال الأول، ولا في ملاحظة. كان الإخفاق هو السطر الواحد نفسه ناقصًا من كل كائن. وقد صحّحت ثلاثة طلبات دمج، دُمجت في 22 سبتمبر 2026، الصفحةَ على الفرع الأساسي وعلى فرعي الإصدار معًا.
هل أنقل .replace() من wrapGenerate إلى wrapStream؟
تستطيع، ولن تفعل ما يبدو أنها تفعله. فـ.replace() المطبّقة على كل دفعة لا ترى غير تلك الدفعة، والقيمة التي تستحقّ الحجب تتوزّع عادةً على عدّة دفعات. تصل «4111 11» ثم «11 1111 1111» — فلا يطابق أيٌّ من النصفين نمط البطاقة، ويذهب كلاهما إلى العميل، ويصلهما المتصفّح على الشاشة. والخطوة التي تبدو إعادة ترتيب صغيرة هي التي تنتج مرشّحًا ينجح في التجربة اليدوية ويسرّب في التشغيل.
الحجب في البثّ عندي لا يطابق شيئًا أبدًا. ما الذي يفوتني؟
النص الذي تطابق عليه لم يكن وحدةً قطّ. فالمزوّدون يقطعون الدفعات على حدود الرموز لا على المعنى، ولذلك يقع رقم البطاقة أو عنوان البريد أو مفتاح API على حدّ دفعتين أو أكثر في العادة، فلا يطابق الفحصُ على الدفعة الواحدة أيًّا من النصفين. والحلّ ليس نمطًا أفضل بل حالةً محفوظة: احتفظ بمخزن يمتدّ عبر الدفعات، واحتجز كل نصّ يمكن لمطابقة أن تنمو إليه، بدل فحص كل دفعة كأنها مستند مكتمل.
هل يكفي أن أخزّن الردّ كاملًا ثم أُرشّحه في النهاية؟
هو صحيح، وهو أبسط ما يحقّق الشرط الثابت. وهو أيضًا تخلٍّ عن السبب الوحيد للبثّ: يصير زمن أول رمز هو زمن التوليد الكامل، وتنمو الذاكرة مع طول الكتلة. مقايضة مقبولة في الردود القصيرة، مؤلمة في الطويلة. أما الاحتجاز من بداية المطابقة المفتوحة فيُبقي التدفّق جاريًا ويعطي الناتج نفسه، بثمن مزيد من الكود داخل التحويل.
ما حجم الاحتجاز المناسب — 32 أم 64 أم 128 محرفًا؟
لا أحدها بذاته. فأيّ N ثابت يمكن أن تهزمه مطابقة أطول منه، وحين يحدث ذلك يسقط صامتًا، لأن الناتج يظلّ يحمل علامة الحجب. والمقدار المحتجز يجب أن يأتي من النص لا من ثابت: احتجِز من الموضع الذي تبدأ عنده مطابقة ما زالت مفتوحة، وأخرِج كل ما قبله. وإن كان لا بدّ من سقف للمخزن، فقرّر سلفًا ما يحدث عند بلوغه، واجعل الجواب «ارفض» لا «أخرِج وتوكّل».
لماذا يظهر في ناتجي علامة حجب يتبعها نصف مفتاح API؟
لأن نقطة الإخراج وقعت داخل قيمة ما زالت تنمو. فالأسرار متغيّرة الطول تطابق بمجرّد أن تطول بما يكفي، والمرشّح الذي يمسك نافذة ثابتة يحجب المحارف التي بين يديه ويبثّ الباقي دون مساس. والناتج يُقرأ هكذا: <REDACTED>uvwxyz123456 — نصف المفتاح، إلى جوار علامة تقول إنه أُزيل. وهو أسوأ من غياب المرشّح، لأن التسريب الظاهر يُبلَّغ عنه والتسريب المعنون لا يُبلَّغ.
لماذا مخزن لكل مُعرّف نصّي بدل مخزن واحد مشترك؟
لأن البثّ الواحد قد يحمل عدّة كتل نصّية في آن، وهذا بالضبط ما وُجد من أجله المُعرّف على كل جزء. والمخزن المشترك يخلطها، فيُفحص نصّ كتلة ويُحتجز ويُخرج ضمن كتلة أخرى — حجب في غير موضعه هنا وترتيب خاطئ هناك. والمثال المدموج في توثيق AI SDK يفهرس مخزنه بمُعرّف النص لهذا السبب بالذات.
كيف أختبر حارسًا يعمل على البثّ؟
تأكيد واحد، يجري على كل حالة وبكل حجم جزء: يجب أن يساوي تجميعُ الناتج المبثوث، بايتًا ببايت، ترشيحَ النص كاملًا دفعة واحدة. أعد تشغيل المدخل نفسه بمحرف واحد لكل دفعة، ثم اثنين، ثم ثلاثة، حتى النص كلّه، وقارن كل تشغيلة بنتيجة المرور الواحد. فتظهر المطابقة المقسومة والقيمة المحجوبة نصفها كلتاهما على هيئة اختلاف، ولا يخمّن أحد أي حدّ هو الخطر. وهو قطعة قصيرة من كود الاختبار.
أي فروع AI SDK نالت مثال wrapStream المصحَّح؟
ثلاثة فروع، في اليوم نفسه. حطّ طلب الدمج #21210 على main، و#21211 على release-v6.0، و#21212 على release-v5.0، ودُمجت كلّها في 22 سبتمبر 2026 بفارق ستّ عشرة ثانية بينها — ملف واحد في الأولين، وملفان على فرع v5، و+335/-35 في المجموع. وحتى 28 سبتمبر 2026، يحمل توثيق الوسيط الحيّ specificationVersion: 'v4'.
هل أحتاج إلى مكتبة لحجب البيانات في البثّ؟
لا. فالمثال الموجود الآن في توثيق AI SDK قائم بذاته ولا يعتمد على شيء، والقاعدة التي ينفّذها قصيرة بما يكفي لتكتبها بنفسك: لا تتصرّف بناءً على مطابقة يمكن أن تنمو، ولا تُخرج نصًّا لم تفحصه في صورته النهائية. وثمّة مكتبات عدّة تعالج حدود الأجزاء معالجة صحيحة باستطلاع محدود. وما يهمّ ليس الاسم على سطر الاستيراد، بل هل يصمد الناتج إن أُعيد تشغيله بكل حجم جزء.
هل هذا عطل خاص بـ Vercel AI SDK وحدها؟
لا. فالحزمة لم تشحن مرشّحًا معطوبًا — شحنت نقصًا في التوثيق، إذ تُرك نصف الحارس الخاص بالبثّ تعليقًا. أما الإخفاق الذي يغري به فقد شحنته مشاريع لا رابط بينها، وبأكثر من لغة، وهذا ما يجعله صنفًا من العلل لا غلطةَ فريق واحد. وعلى هذا الموقع كتابة منفصلة تغطّي تلك الحالات، والاسم الذي أُعطي للصنف، وصيغة الاستشهاد به.
هل يكسر التخزين ثم القطع الرموزَ التعبيرية؟
يكسرها إن قطعت بسذاجة. فالمحرف الواقع خارج المستوى الأساسي يُخزَّن في وحدتي ترميز، والقطع بينهما يُخرج نصف محرف لا يستطيع أي مستهلك لاحق إصلاحه. والمثال المدموج يفحص هذه الحالة ويُزحزح نقطة قطعه بدل أن يشقّ زوجًا بديلًا. والعناية نفسها تلزم في أي شيء تبنيه: نقطة الإخراج موضع في سلسلة نصّية، وليس كل موضع في السلسلة صالحًا للتوقّف عنده.
لماذا بدأ البلاغ بإفصاح؟
لأنه صحيح، ولأنه يغيّر كيف يُقرأ الاقتراح. فمن يشرف على مكتبة برخصة MIT تُجري الحجب على البثّ كان يبلّغ عن نقص تسدّه مكتبته هو. وقد كُتب الاقتراح قائمًا بذاته تحديدًا كي لا تعني تلك المصلحة شيئًا — لا اعتماد، ولا شيء يُثبَّت، الصقه في التوثيق كما هو. وإعلان المصلحة أولًا هو ما يجعل بقية البلاغ صالحًا للاستعمال عند من لا يدينون لك بشيء.