یک خط جدید داخل رشته JSON که هر چند روز یک مقاله را از بین میبرد
پایپلاین تولید مقاله گاهی با «JSON پیدا نشد» شکست میخورد. علت یک newline واقعی داخل رشته JSON بود که لاگ ناقص آن را پنهان میکرد.
پایپلاین تولید خودکار مقالههای ما از یک سرویس هوش مصنوعی میخواهد کل مقاله را بهشکل یک شیء JSON برگرداند. بیشتر وقتها همهچیز خوب پیش میرفت. گاهی هم کار با خطای «JSON مقاله پیدا نشد» متوقف میشد و مقاله از بین میرفت. این یادداشت روایت ردگیری همین خطاست: دو حدس اشتباه، یک لاگ که ما را گمراه میکرد و علتی که بعد از دیدن پاسخ کامل مدل کاملاً ساده بود.
ماجرا و علامتها
علامت فقط یک پیام بود: «JSON مقاله پیدا نشد». خطا همیشه رخ نمیداد و الگوی روشنی هم نداشت. چند روز همهچیز سالم کار میکرد و بعد یک اجرا شکست میخورد. نتیجه هر بار این بود که پایپلاین مقالهای منتشر نمیکرد و کل خروجی آن اجرا دور ریخته میشد.
دیدن علت در این وضع سخت بود، چون لاگ ما از پاسخ مدل فقط ۶۰۰ کاراکتر اول را نگه میداشت. این تصمیم را زمانی گرفته بودیم که میخواستیم لاگها کوچک بمانند. در همهی موارد خراب، همین ۶۰۰ کاراکتر کاملاً سالم به نظر میرسید: آکولاد باز، کلیدهای درست، عنوان و توضیح معمولی. هر بار که لاگ را باز میکردیم، این سؤال بیپاسخ میماند که پس چرا پارسر شکست خورده است.
نکات کلیدی
- خطا متناوب بود و هر بار یک مقاله کامل از دست میرفت.
- پیام خطا فقط میگفت JSON پیدا نشد و علت را نشان نمیداد.
- لاگ فقط ۶۰۰ کاراکتر اول پاسخ را نگه میداشت و آن بخش همیشه سالم بود.
ردگیری علت: دو حدس اشتباه
حدس اول ما بلاکهای کد markdown بود. مدلها گاهی JSON را داخل سه بکتیک میپیچند و اگر استخراجکننده این حالت را درست مدیریت نکند، پارسر چیزی پیدا نمیکند. کد استخراج را بازبینی کردیم و حالتهای مختلف بلاک کد را در آن پوشش دادیم. اما شکستها ادامه پیدا کرد.
حدس دوم قطع شدن پاسخ بود. مقاله بلند است و اگر خروجی وسط راه بریده شود، JSON ناقص میماند و آکولاد پایانی ندارد. این توضیح منطقی به نظر میرسید، اما مدرکی برایش نداشتیم. ما فقط ابتدای پاسخ را میدیدیم و نه انتهایش را. این نکته بعدها مهم شد: هر دو حدس بر پایهی چیزی بود که لاگ ما نشان نمیداد.
تا اینجا اصلاحی انجام داده بودیم که چیزی را درست نمیکرد، چون علت را نمیدانستیم. تصمیم بعدی ساده بود: بهجای حدس زدن، پاسخهای خراب را کامل ذخیره کنیم و منتظر بمانیم تا یکی دیگر رخ دهد. وقتی چنین پاسخی ذخیره شد، دو حدس قبلی هر دو رد شدند. پاسخ نه در بلاک کد بود و نه بریده شده بود. JSON تا آخر کامل بود و آکولاد پایانی را هم داشت.
علت اصلی: کاراکتر کنترلی خام داخل رشته
با مقایسهی پاسخ کامل و پیام خطا معلوم شد مدل گاهی داخل رشتهی بدنهی HTML مقاله یک newline واقعی گذاشته است. یعنی بهجای دو کاراکتر بکاسلش و n، خود کاراکتر خط جدید (کد U+000A) وسط رشته آمده بود. چشم انسان این را عادی میبیند، چون HTML چندخطی طبیعی است. اما JSON این را نمیپذیرد.
مشخصات JSON (RFC 8259، بخش ۷) میگوید همهی کاراکترهای یونیکد را میشود داخل کوتیشن گذاشت، بهجز کوتیشن، بکاسلش و کاراکترهای کنترلی U+0000 تا U+001F که حتماً باید escape شوند. گرامر همان بخش هم همین را نشان میدهد: بازهی مجاز کاراکترهای escapeنشده از U+0020 شروع میشود. پس newline خام، تب خام و بقیهی کاراکترهای زیر U+0020 داخل رشتهی JSON نامعتبرند. مرورگرها و موتورهای جاوااسکریپت هم همین را با خطایی مثل «bad control character in string literal» گزارش میکنند. در مستندات MDN این پیام در فهرست خطاهای JSON.parse آمده است.
نکتهی مهم این است که JSON.parse همه یا هیچ است. یک کاراکتر خام در وسط یک رشتهی بلند باعث میشود کل سند رد شود و هیچ بخشی از آن بهدست نیاید. پیام «JSON پیدا نشد» هم در کد خودمان ساخته شده بود و فقط میگفت پارس ناموفق بوده، نه اینکه چرا. چون این اتفاق فقط وقتی میافتاد که مدل برای آن مقاله یک خط جدید خام میگذاشت، خطا متناوب به نظر میرسید.
نکات کلیدی
- خطای پارس به خاطر محتوای رشته بود، نه ساختار کلی JSON.
- newline خام داخل رشته طبق RFC 8259 نامعتبر است؛ باید بهصورت \n نوشته شود.
- چون JSON.parse همه یا هیچ است، یک کاراکتر مشکلدار کل مقاله را از بین میبرد.
راهحل: اول strict، بعد ترمیم هدفمند
راهحل ما دو مرحله دارد. ابتدا همان JSON.parse معمولی را امتحان میکنیم. اگر موفق شد، چیزی تغییر نمیدهیم. فقط در صورت شکست، متن را کاراکتر به کاراکتر میخوانیم و تنها داخل رشتهها کاراکترهای کنترلی را escape میکنیم، سپس دوباره پارس میکنیم. چون بیرون از رشتهها دست نمیزنیم، فاصله و خط جدید بین کلیدها دستنخورده میماند.
یک replace ساده روی کل متن کار نمیکند، چون newline بیرون رشتهها کاملاً مجاز است و خراب کردنشان ساختار را بههم میریزد. به همین دلیل باید وضعیت «داخل رشته هستیم یا نه» و وضعیت «کاراکتر قبلی بکاسلش بود یا نه» را نگه داشت. نمونهی عمومی زیر همین منطق را نشان میدهد:
const NAMED = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
function escapeControlCharsInStrings(src) {
let out = '';
let inString = false;
let escaped = false;
for (const ch of src) {
if (!inString) {
if (ch === '"') inString = true;
out += ch;
continue;
}
if (escaped) { escaped = false; out += ch; continue; }
if (ch === '\\') { escaped = true; out += ch; continue; }
if (ch === '"') { inString = false; out += ch; continue; }
const code = ch.charCodeAt(0);
if (code < 0x20) {
out += NAMED[ch] ?? '\\u' + code.toString(16).padStart(4, '0');
} else {
out += ch;
}
}
return out;
}
function parseModelJson(raw) {
try {
return JSON.parse(raw); // مسیر عادی: strict
} catch (strictError) {
console.warn('strict parse failed, trying repair:', strictError.message);
return JSON.parse(escapeControlCharsInStrings(raw));
}
}نکات کلیدی
- اول strict؛ ترمیم فقط وقتی لازم است که پارس عادی شکست بخورد.
- فقط کاراکترهای داخل رشته escape میشوند، نه کل متن.
- اگر ترمیم هم شکست خورد، خطا باید با پاسخ کامل لاگ شود.
درسها و چکلیست
بخش بزرگی از زمان ما صرف حدسهایی شد که لاگ نمیتوانست آنها را تأیید یا رد کند. لاگی که فقط ابتدای پاسخ را نگه میدارد، دقیقاً جاهایی را پنهان میکند که خطا در آنهاست. با ذخیرهی پاسخ کامل، علت در همان اولین نمونه معلوم شد.
درس دوم دربارهی خروجی مدل است. حتی وقتی در پرامپت مینویسید «JSON معتبر برگردان»، خروجی یک متن تولیدشده است و باید مثل ورودی غیرقابل اعتماد با آن رفتار کرد. پارسر باید با خرابیهای قابل پیشبینی کنار بیاید، اما نه با تغییر بیصدا: هر بار که مسیر ترمیم استفاده میشود باید در لاگ دیده شود تا بدانید مدل چقدر از قالب خارج میشود.
چکلیستی که از این ماجرا نگه داشتیم:
۱. پاسخ خام مدل را وقتی پارس شکست میخورد کامل ذخیره کنید، نه فقط ابتدای آن را.
۲. پیام خطای اصلی پارسر (و موقعیت خطا اگر موجود است) را در لاگ بنویسید، نه فقط پیام عمومی خودتان.
۳. قبل از اصلاح، فرضیه را با دادهی واقعی بسنجید؛ بازبینی استخراجکنندهای که مشکلی نداشت وقت ما را گرفت.
۴. پارس strict را حفظ کنید و ترمیم را فقط بهعنوان مسیر جایگزین بگذارید.
۵. ترمیم را محدود به داخل رشتهها کنید و روی کل متن replace نزنید.
۶. استفاده از مسیر ترمیم را شمارش یا لاگ کنید تا تغییر رفتار مدل دیده شود.
جمعبندی
علت شکستهای متناوب ما یک newline خام داخل رشتهی بدنهی HTML بود که طبق RFC 8259 در JSON نامعتبر است و JSON.parse بهخاطر آن کل مقاله را رد میکرد. لاگ ۶۰۰ کاراکتری این را پنهان کرده بود و دو حدس اول ما، یعنی بلاک markdown و قطع شدن پاسخ، هر دو غلط بودند. راهحل کوتاه است: پارس strict و در صورت شکست، escape کردن کاراکترهای کنترلی فقط داخل رشتهها. درس اصلی اما دربارهی لاگ است: خروجی مدل را کامل ذخیره کنید و برای خطا با اندازهگیری تصمیم بگیرید، نه حدس.
منابع
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Formatrfc-editor.org
- MDN: SyntaxError: JSON.parse: bad parsingdeveloper.mozilla.org