تصور کنید برنامهای نوشتهاید که منتظر پاسخ یک مدل هوش مصنوعی است، اما در لحظاتی که مدل دقیقاً کلمه «null» را میفرستد، خروجی شما بهطور کامل غیب میشود. این کابوس تکرارشونده برای توسعهدهندگانی بود که از نسخه ۰.۳.۱ کتابخانه stream_struct استفاده میکردند. «استریمهایی که بدون ارسال حتی یک فریم به پایان میرسند»؛ این توصیف دقیق مشکلی بود که توسعهدهندگان با آن دستوپنجه نرم میکردند.
یک خطای منطقی بحرانی باعث میشد هرگاه یک مدل هوش مصنوعی زاینده (Generative AI) — شبیه به نویسندهای که کلمات را یکییکی روی کاغذ مینویسد و نه یکباره — مقدار null را در سطح اول خروجی JSON برگرداند، کل استریم حذف شود. این اتفاق باعث شکست خروجیهای ساختیافته در پاسخهای اختیاری ابزارها (Optional Tool Responses)، نتایجی که nullable هستند، یا پاسخهایی میشد که صراحتاً «بدون مقدار» (No Value) بودند.
سازوکار استریمینگ چگونه است؟
استریم کردن JSON ذاتاً حساس است زیرا تجزیهکننده (Parser) باید توکنهای ناقص را مدیریت کند. نقطه ورود این فرآیند متد streamPartialJson است که یک جریان از رشتهها (Stream<String>) از دلتاها را میگیرد و آنها را به جریانی از مقادیر JSON تجزیهشده (Stream<Object?>) تبدیل میکند.

این سیستم با جمعآوری تکهها (Deltas) در یک بافر (Buffer) کار میکند. بعد از هر تکه، تلاش میکند تا بافر را تجزیه کند. وقتی بافر با موفقیت تجزیه شود، مقدار را ارسال (Emit) میکند. این سیستم تکههایی را که باعث میشوند بافر غیرقابل تجزیه باقی بماند — مانند یک توکن نیمهکاره یا یک شیء ناتمام — یا مواردی که مقدار تجزیهشده آنها با آخرین مقدار ارسال شده یکسان است، نادیده میگیرد. این سازوکار به شنونده (Listener) اجازه میدهد تا رشد شیء را در زمان واقعی و همگام با رسیدن دلتاها مشاهده کند. همانطور که در تحلیلهای پیشین ما درباره امنیت مدلهای بازمتن دیدیم، مدیریت دقیق دادههای ورودی و خروجی در لایههای پاییندستی، کلید پایداری سیستمهای عاملمحور است.
باگ «نقش دوگانه»
به نقل از یک پست فنی در وبسایت dev.to که در ۲۳ ژوئیه ۲۰۲۶ منتشر شد، ریشه این باگ در اختصاص یک «نقش دوگانه» به مقدار null بود. حلقه استریمینگ بر اساس یک تابع کمکی صادرشده به نام parsePartialJson(String buffer) کار میکرد که خروجی آن از نوع Object? بود.
این تابع در دو وضعیت کاملاً متفاوت، مقدار null را برمیگرداند:
۱. داده هنوز قابل رمزگشایی نیست: بافر شامل یک توکن ناقص است، مثلاً رشته «nu» یا یک شیء نیمهکاره.
۲. بافر دقیقاً به مقدار literal null در JSON تبدیل شده است: در این حالت، null یک مقدار واقعی، نهایی و رمزگشاییشده است.
به دلیل اینکه حلقه استریمینگ از یک بررسی ساده استفاده میکرد: final value = parsePartialJson(buffer.toString()); if (value == null) continue; هر دو حالت را یکی میدید. سیستم یک مقدار null رمزگشاییشده را به عنوان وضعیت «هنوز آماده نیست» (Not Ready) تلقی میکرد. در نتیجه، اگر پاسخ مورد نظر مدل دقیقاً null میبود، کتابخانه آن را نادیده میگرفت و هیچ چیزی ارسال نمیکرد.
بازتولید خطا و اثرات تودرتو
برای درک بهتر، تصور کنید یک مدل عبارت null را در دو تکه ارسال کند: ابتدا «nu» و سپس «ll».
- در نسخه ۰.۳.۱، تکه اول («nu») تجزیه نمیشود و به درستی نادیده گرفته میشود.
- تکه دوم بافر را به «null» میرساند که تجزیه شده و مقدار
nullرا برمیگرداند. - حلقه این مقدار را به عنوان «عدم آمادگی» میخواند و دوباره آن را نادیده میگیرد.
- نتیجه: دریافت یک لیست خالی
[]در حالی که انتظار فریم[null]میرفت.
جالب است که طبق بررسی مستندات، این باگ فقط روی مقادیر سطح اول (Top-level) اثر میگذاشت. مقادیر null تودرتو — مانند یک مقدار null داخل یک شیء یا آرایه JSON — هرگز حذف نمیشدند. برای مثال، اگر مدل عبارت {"a": و سپس null} را استریم کند، فرآیند به این شکل پیش میرود:
- بافر ناقص
{"a":به عنوان یک Map خالی{}تجزیه میشود که غیر-null است و ارسال میگردد. - تکه دوم شیء را به
{"a": null}کامل میکند که باز هم یک Map غیر-null است و ارسال میشود.
از آنجا که ظرفِ حاوی (Container)، به مقداری غیر-null تجزیه میشود، مقدار null داخلی «همسفر» شده و ارسال میشود. برای رخ دادن باگ، لازم بود خودِ مقدار سطح اول null باشد.
راهحل در نسخه ۰.۳.۲
برای حل این مشکل، نسخه ۰.۳.۲ یک نوع داده داخلی به نام parsePartialJsonResult معرفی کرد. این رکورد (Record)، «وجود مقدار» را از «خودِ مقدار» جدا میکند:
- hasValue (bool): اگر هنوز چیزی قابل تجزیه نباشد
falseو در صورت وجود مقدارtrueاست. - value (Object?): مقدار رمزگشایی شده که اکنون میتواند با خیال راحت
nullباشد، به شرطی کهhasValueبرابر true باشد.
منطق کد در مسیر lib/src/streaming.dart تغییر کرد تا بهجای if (value == null) continue; از عبارت if (!result.hasValue) continue; استفاده کند. این تغییر تضمین میکند که یک null رمزگشایی شده، از فیلتر عبور کرده، از مرحله تشخیص تغییرات (Change Detection) از طریق jsonEncode رد شود و در نهایت به دستور yield برسد.
جزئیات پیادهسازی
- پایداری API: قرارداد public تابع
parsePartialJsonبرای حفظ سازگاری تغییر نکرد. نوع جدیدparsePartialJsonResultوPartialJsonResultبا تگ@internalعلامتگذاری شدهاند و صادر نمیشوند. - نسخه و وصله: چون سطح عمومی API تغییر نکرد، نسخه ۰.۳.۲ یک آپدیت وصله (Patch Bump) نسبت به ۰.۳.۱ محسوب میشود.
- تست: تستهای رگرسیون در نسخه ۰.۳.۲ تمامی موارد را پوشش میدهند: nullهای سطح اول که در یک تکه میرسند، nullهای سطح اول در دو تکه، رشد عادی اشیاء و بافرهایی که هرگز تکمیل نمیشوند.
این اصلاح، یک فرض بنیادی در مورد مدیریت نشانگرها (Sentinels) را تغییر داد. استفاده از الگوی Record یا Option-type از «برخورد نشانگر» (Sentinel Collision) جلوگیری میکند؛ وضعیتی که در آن یک مقدار بازگشتی مشروع، دقیقاً مشابه وضعیت در حال انتظار سیستم است. برای توسعهدهندگان، این یعنی خروجیهای ساختیافته هوش مصنوعی اکنون در موارد مرزی (Edge Cases) که «بدون مقدار» پاسخ درست است، قابل اعتماد هستند. اگر از stream_struct در محیط عملیاتی استفاده میکنید، فوراً به نسخه ۰.۳.۲ بهروزرسانی کنید.
گام بعدی شما
- اگر از کتابخانه stream_struct در محیط عملیاتی استفاده میکنید، فوراً به نسخه ۰.۳.۲ بهروزرسانی کنید.
- در پیادهسازیهای مشابه، از ارسال مقادیر null به عنوان نشانگر «وضعیت در حال انتظار» پرهیز کنید.
- تستهای واحد را برای مقادیر null در سطح اول (Top-level) در تمامی پارسرهای استریم خود اضافه کنید.
اما داستان بهینهسازی حافظه در این کتابخانه حتی پیچیدهتر است — به تحلیل ما درباره مدیریت KV Cache در مدلهای زبانی مراجعه کنید.




گفتگو