صفی که هر روز بیصدا یک تلاش را میسوزاند
کلیدواژه تکراری سر صف مجله خودکار ماند و هر روز یک تلاش را هدر داد؛ لاگ فقط «JSON نامعتبر» میگفت. ردگیری، علت و راهحل.
مجله خودکار ما از یک صف کلیدواژه میخواند. اولین تلاش هر روز، کلیدواژهی سر صف را برمیداشت، از سرویس هوش مصنوعی مقاله میخواست و نتیجه را منتشر میکرد. مدتی همهچیز عادی بود. بعد متوجه شدیم یک هفته است که هر روز یک تلاش بینتیجه تمام میشود و یک روز هم هیچ مقالهای منتشر نشده. خطا هر بار فقط یک خط بود: «JSON نامعتبر». این یادداشت دربارهی همین خط است و اینکه چرا برای فهمیدن ماجرا کافی نبود.
ماجرا و علامتها
علامت اصلی، الگوی تکراری لاگها بود: اولین تلاش هر روز با «JSON نامعتبر» تمام میشد. تلاشهای بعدی همان روز، که کلیدواژههای دیگری را برمیداشتند، معمولاً موفق بودند. یعنی سیستم کاملاً از کار نیفتاده بود و فقط یک تلاش در روز هدر میرفت. همین باعث شد مدتی مشکل را جدی نگیریم.
در یکی از روزها وضعیت بدتر شد و هیچ مقالهای منتشر نشد. آن روز عملاً هیچ تلاشی به نتیجه نرسید. این تنها روزی بود که اثر مشکل از «یک تلاش کمتر» به «خروجی صفر» رسید و توجه ما را جلب کرد.
نکتهی مهم این بود که از بیرون چیزی خراب به نظر نمیرسید. سرویس بالا بود، کلید و دسترسی مشکلی نداشت و اسکریپت هم کرش نمیکرد. فقط یک تلاش در روز بیصدا از بین میرفت.
نکات کلیدی
- اولین تلاش هر روز با «JSON نامعتبر» شکست میخورد
- تلاشهای بعدی همان روز معمولاً موفق بودند
- یک روز هیچ مقالهای منتشر نشد
- لاگ فقط علامت خطا داشت، نه محتوای پاسخ
ردگیری علت
اولین فرضیهی ما همان چیزی بود که پیام خطا القا میکرد: مدل JSON خراب تولید میکند. ما قبلاً با خروجیهای مشکلدار مدلها برخورد کرده بودیم، از جمله مشکلی که در مقالهی خط جدید داخل رشته JSON نوشتیم. پس اول به سراغ پارسر و پاکسازی خروجی رفتیم. اما این فرضیه با الگوی شکست نمیخواند، چون خرابی ساختاری معمولاً تصادفی است و روی همهی تلاشها پخش میشود، نه فقط روی اولین تلاش هر روز.
فرضیهی دوم زمانبندی بود: شاید اولین تلاش روز با چیزی مثل بالا آمدن دیرهنگام یک وابستگی یا محدودیت نرخ برخورد میکند. این هم جواب نداد. خطا همیشه از مرحلهی پارس میآمد، نه از مرحلهی درخواست.
نقطهی عطف وقتی بود که پرسیدیم چرا فقط اولین تلاش؟ جواب در منطق خود صف بود: اولین تلاش همیشه سر صف را میخواند. پس شاید مشکل از «زمان» نیست و از «کدام کلیدواژه» است. با این نگاه، پاسخ خام همان تلاش را دستی دیدیم. به جای JSON، چند جمله نثر بود که میگفت این موضوع قبلاً منتشر شده و تکراری است.
یعنی مدل درست تشخیص داده بود. ایراد از او نبود. ایراد از این بود که ما برای این حالت راهی در نظر نگرفته بودیم و لاگ هم هیچ سرنخی از آن نمیداد.
علت اصلی و توضیح فنی
آخرین کلیدواژهی صف قبلاً منتشر شده بود. وقتی به مدل داده میشد، پاسخ درست او این بود که «این تکراری است» و این را به نثر مینوشت، چون قالب JSON جایی برای گفتن «نمیتوانم تولید کنم» نداشت. اسکریپت انتظار JSON داشت، پارس شکست میخورد و فقط «JSON نامعتبر» لاگ میشد.
مشکل واقعی از اینجا شروع میشد که کلیدواژه فقط با موفقیت از صف مصرف میشد. شکست آن را برنمیداشت و جایی هم ثبت نمیکرد که قبلاً شکست خورده. پس فردا اولین تلاش دوباره همان کلیدواژه را برمیداشت و دوباره همان نتیجه را میگرفت. این الگوی کلاسیک «پیام سمی» (poison pill) است. مستندات Amazon SQS آن را پیامی تعریف میکند که دریافت میشود اما پردازش نمیشود. راهحل استاندارد هم این است که بعد از تعداد مشخصی شکست، پیام از جریان اصلی کنار گذاشته شود، یعنی صف نامههای مردود (dead-letter queue).
ما SQS نداشتیم و صف ما یک ساختار ساده بود، اما اصل یکی است. هر صفی که آیتم را فقط در موفقیت حذف میکند و برای شکست هیچ شمارنده یا مسیر خروجی ندارد، با اولین آیتمی که هیچوقت نمیتواند موفق شود، گیر میکند و هر بار همان را از نو امتحان میکند.
دو چیز در کنار هم کار را بیصدا کرد. اول، شکست ماندگار بود چون وابسته به محتوای آیتم بود، نه به شانس. دوم، لاگ فقط میگفت «چه شد» و نه «چه دیدیم». اگر ابتدای پاسخ لاگ میشد، با یک نگاه معلوم بود که مدل به فارسی میگوید این مقاله تکراری است.
یک نکتهی جانبی هم دارد: راههای بهتری برای گرفتن خروجی ساختاریافته از مدل وجود دارد. مستندات Anthropic برای خروجی JSON با تضمین انطباق با اسکیما، استفاده از Structured Outputs را توصیه میکند. اما حتی با آن، سؤال طراحی باقی میماند که پاسخ «این درخواست قابل انجام نیست» در قالب چطور بیان شود. اگر قالب این حالت را نداشته باشد، باید آن را بهعنوان یک نتیجهی معتبر در طراحی خروجی گنجاند.
راهحل
سه تغییر دادیم. اول، در لاگ خطای پارس، ابتدای پاسخ خام را هم مینویسیم. دوم، آیتمی که دوبار پشت سر هم شکست بخورد از صف حذف میشود. سوم، تکراری بودن کلیدواژه را پیش از افزودن به صف بررسی میکنیم تا از اول چنین آیتمی وارد صف نشود.
نمونهی ساده و عمومی از منطق اول و دوم (نامها و ساختار ساختگی است):
const MAX_FAILURES = 2;
async function runDailyAttempt(queue) {
const item = queue.peek();
if (!item) return;
const raw = await callModel(item.keyword);
let article;
try {
article = JSON.parse(raw);
} catch (err) {
// خود پاسخ را لاگ کن، نه فقط علامت خطا
console.error('invalid JSON', {
keyword: item.keyword,
head: raw.slice(0, 200),
});
item.failures = (item.failures ?? 0) + 1;
if (item.failures >= MAX_FAILURES) {
queue.remove(item.id);
queue.addToRejected(item, 'unparseable response');
} else {
queue.update(item);
}
return;
}
await publish(article);
queue.remove(item.id);
}
function enqueue(queue, published, keyword) {
if (published.has(keyword) || queue.has(keyword)) return false;
queue.add({ keyword, failures: 0 });
return true;
}نکات کلیدی
- ابتدای پاسخ غیرقابلپارس لاگ میشود
- آیتم بعد از دو شکست پشت سر هم از صف کنار میرود
- تکراری بودن پیش از ورود به صف بررسی میشود
درسها و چکلیست
مهمترین درس این است که لاگ باید خود پاسخ را نشان دهد، نه فقط علامت خطا. عبارت «JSON نامعتبر» ما را به سمت فرضیهی خراب بودن خروجی برد، در حالی که خروجی کاملاً سالم و درست بود و فقط در قالبی که انتظار داشتیم نبود. با دیدن ۲۰۰ کاراکتر اول، ماجرا در چند دقیقه روشن میشد.
درس دوم دربارهی طراحی صف است. اگر یک سیستم خودکار فقط در موفقیت پیش میرود، باید برای شکست هم تعریف روشنی داشته باشد. در غیر این صورت یک آیتم بد میتواند ظرفیت کار را بیصدا مصرف کند. کاری که برای این نوع اتوماسیونها در خدمات اتوماسیون هوش مصنوعی انجام میدهیم همین جزئیات است. چیزی که هر روز اجرا میشود باید در شکست هم قابل فهم باشد. دربارهی اینکه چرا به خروجی مدل نباید بیقیدوشرط اعتماد کرد، در ساخت ایجنت هوش مصنوعی متصل به دیتابیس هم نوشتهایم.
چکلیستی که از این اتفاق برای خودمان نگه داشتیم:
نکات کلیدی
- در لاگ خطای پارس، ابتدای پاسخ خام را بنویس
- برای هر آیتم صف شمارندهی شکست و حد مشخص داشته باش
- آیتمهای مردود را به جای حذف بیصدا، جایی ثبت کن تا بررسی شوند
- پیش از ورود به صف، تکراری بودن را بررسی کن
- در قالب خروجی برای «درخواست قابل انجام نیست» جا بگذار
- اگر چند روز پشت سر هم یک تلاش شکست میخورد، آن را الگو بدان، نه اتفاق
جمعبندی
مدل در این ماجرا درست عمل کرد و سیستم ما نبود که آمادگی جواب او را داشت. صفی که شکست را نمیشمارد، با اولین آیتم ناموفق هر روز همان تلاش را از نو میسوزاند، و لاگی که فقط میگوید «نامعتبر» جلوی دیدن علت را میگیرد. شمارش شکست، کنار گذاشتن آیتم و ثبت خود پاسخ، هزینهی کمی دارند و از چنین هدررفتی بیصدا جلوگیری میکنند.
منابع
- Capturing problematic messages in Amazon SQSdocs.aws.amazon.com
- Increase output consistency (Claude Platform Docs)platform.claude.com