پرش به محتوای اصلی
پرش به محتوای مقاله

درون به‌روزرسانی stream_struct؛ جداسازی عدم آمادگی داده از null واقعی

·۱ مرداد ۱۴۰۵۴ دقیقه مطالعه۳ بازدید
سطح بالای null حل‌شده از streamPartialJson حذف شد (stream_struct 0.3.2)
سطح بالای null حل‌شده از streamPartialJson حذف شد (stream_struct 0.3.2)
اشتراک‌گذاری
واقعاً چه چیز جدید است؟

معرفی نوع داده داخلی `parsePartialJsonResult` برای تفکیک وضعیت Boolean (وجود مقدار) از مقدار واقعی (که می‌تواند null باشد)، تا از حذف تصادفی داده‌های null در سطح اول استریم جلوگیری شود.

تصور کنید برنامه‌ای نوشته‌اید که منتظر پاسخ یک مدل هوش مصنوعی است، اما در لحظاتی که مدل دقیقاً کلمه «null» را می‌فرستد، خروجی شما به‌طور کامل غیب می‌شود. این کابوس تکرار‌شونده برای توسعه‌دهندگانی بود که از نسخه ۰.۳.۱ کتابخانه stream_struct استفاده می‌کردند. «استریم‌هایی که بدون ارسال حتی یک فریم به پایان می‌رسند»؛ این توصیف دقیق مشکلی بود که توسعه‌دهندگان با آن دست‌وپنجه نرم می‌کردند.

یک خطای منطقی بحرانی باعث می‌شد هرگاه یک مدل هوش مصنوعی زاینده (Generative AI) — شبیه به نویسنده‌ای که کلمات را یکی‌یکی روی کاغذ می‌نویسد و نه یک‌باره — مقدار null را در سطح اول خروجی JSON برگرداند، کل استریم حذف شود. این اتفاق باعث شکست خروجی‌های ساخت‌یافته در پاسخ‌های اختیاری ابزارها (Optional Tool Responses)، نتایجی که nullable هستند، یا پاسخ‌هایی می‌شد که صراحتاً «بدون مقدار» (No Value) بودند.

سازوکار استریمینگ چگونه است؟

استریم کردن JSON ذاتاً حساس است زیرا تجزیه‌کننده (Parser) باید توکن‌های ناقص را مدیریت کند. نقطه ورود این فرآیند متد streamPartialJson است که یک جریان از رشته‌ها (Stream<String>) از دلتاها را می‌گیرد و آن‌ها را به جریانی از مقادیر JSON تجزیه‌شده (Stream<Object?>) تبدیل می‌کند.

سطح بالای حل‌شده null از streamPartialJson حذف شد (stream_struct 0.3.2)

این سیستم با جمع‌آوری تکه‌ها (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 در مدل‌های زبانی مراجعه کنید.

چرا این موضوع مهم است؟

این اصلاح از طریق حذف تداخل میان وضعیت «در حال پردازش» و «مقدار تهی»، پایداری خروجی‌های ساختاریافته را تضمین می‌کند. اعتبار این راهکار از طریق تست‌های رگرسیون در نسخه ۰.۳.۲ تأیید شده است.

تأثیر برای ایران

این به‌روزرسانی برای توسعه‌دهندگان ایرانی که از فریم‌ورک‌های دارت در پیاده‌سازی رابط‌های استریمینگ AI استفاده می‌کنند، ضروری است تا از باگ‌های ناپایدار در خروجی‌ها پیشگیری کنند.

·نگاه ما
تحریریه دات‌هوش

جایگزینی مقدار sentinel با یک Record برای تفکیک وضعیت از داده، راهکاری کلاسیک اما حیاتی در مهندسی نرم‌افزار است که در سیستم‌های استریمینگ هوش مصنوعی نادیده گرفته می‌شود. این باگ نشان می‌دهد که حتی در ساده‌ترین تبدیل‌های نوع (Type Casting)، تکیه بر مقادیر null برای مدیریت وضعیت‌های موقت (Pending States) می‌تواند منجر به حذف داده‌های واقعی شود. انتقال به الگوهای Option-type در لایه‌های زیرساختی، پیش‌شرطی برای رسیدن به قابلیت اطمینان در عامل‌های هوش مصنوعی است.

منابع

این گزارش با خط‌لولهٔ خودکار دات‌هوش از منابع معتبر جهانی تدوین و زیر نظر تحریریه منتشر شده است. روش کار ما

گفتگو

پنج‌شنبه‌های هوش‌محور

بسته‌ی هفتگی دات‌هوش

۵ خبر، ۲ ابزار، ۱ پرامپت در هر شماره. به‌زودی راه‌اندازی می‌شود — هر پنج‌شنبه صبح.

خبر کلیدی
ابزار کاربردی
پرامپت حرفه‌ای
تحلیل پژوهش
به‌زودی
زاویه‌ی ایرانی
به‌زودی
تمرین این هفته
به‌زودی

راهنماهای دات‌هوش

راهنماهای کاربردیِ دات‌هوش برای کار با هوش مصنوعی — از همین‌جا شروع کنید:

دات‌هوش

راهنمای فارسی هوش مصنوعی — با نگاه به ایران

اخبار روزانه، معرفی ابزارها و مدل‌ها، و آموزشِ کار با هوش مصنوعی؛ همیشه با این پرسش که از ایران چه چیزی کار می‌کند و چه چیزی نه.