חזרה לבלוג

איך מודל שפה נתקע בלולאה

קרה לי באג של repetition loop באפליקציה: המודל נכנס ללופ וחזר על עצמו בלי סוף. נכנסתי עם קלוד לפרטים כדי להבין מה קרה ואיך מתגוננים מזה.

ai-engineering llm debugging gemini

קרה לי באג של repetition loop באפליקציה: המודל נכנס ללופ, נתקע על אותו משפט בעברית, וחזר על עצמו בלי סוף.

שמעתי פעם על התופעה הזאת, אבל הפעם הייתי צריכה להבין אותה לעומק: מה בדיוק קרה, למה זה קורה, ואיך נכון להגיב לזה במערכת אמיתית.

נכנסתי עם קלוד לפרטים וזה מה שיצא: מה קרה, למה מודלי שפה יכולים להיכנס ללולאות חזרתיות, איך יודעים אם זו תקלה חד־פעמית או דפוס חוזר, ואיזה שכבות הגנה אפשר לשים.

מה קרה

ספינר של 7 דקות. בערך 65,000 טוקנים של אותו משפט בעברית, שוב ושוב.

  • מזהה שאילתה: 63de2625, ספר אנציקלופדיה ilana5
  • מודל: gemini-3.1-flash-lite-preview
  • קלט: בערך 260,000 טוקנים של טקסט אנציקלופדי בעברית
  • פלט: אותו משפט, שמסתיים ב־ וחוזר אלפי פעמים

זה היה הסימפטום. כל מה שמופיע בהמשך הוא הלמה, הנתונים, והתגובה ההנדסית הנכונה.

איך מודלי שפה מייצרים טקסט

מודל שפה לא “כותב משפט”. הוא בוחר טוקן אחד בכל פעם.

בכל צעד הוא שואל, בפועל: בהינתן כל מה שכבר הופיע לפני, מה הדבר הבא? הוא דוגם מתוך התפלגות הסתברויות על אוצר המילים, מוסיף את התוצאה להקשר, וחוזר על הפעולה.

התוצאה החשובה: הפלט של המודל עצמו הופך להיות חלק מהקלט שלו עבור הטוקן הבא.

בתנאים רגילים זה בסדר גמור. ככה נוצרים פסקאות קוהרנטיות. אבל זה גם יוצר נקודת חולשה.

אם ההתפלגות מתכנסת בחדות סביב טוקן או ביטוי מסוים, כל פלט חדש מחזק את אותה התפלגות עבור הצעד הבא. זאת לולאת חזרתיות.

זה משוב חיובי. לא “חיובי” במובן של טוב, אלא במובן של פידבק שמגביר את הכיוון הקיים. המודל מוציא X X X, ההקשר החדש הופך את X לעוד יותר סביר, ההתפלגות מתחדדת, והמודל מתחפר.

שלושה תנאים שמגבירים לולאות

לא כל מודל, ולא כל קונפיגורציה, ייפלו ללולאה. הכשל הזה צריך כמה מגברים שפועלים יחד.

דקודינג עם אנטרופיה נמוכה

דקודינג הוא המדיניות שבוחרת איך להפוך הסתברויות לטקסט בפועל. דקודינג חמדני, למשל, תמיד לוקח את הטוקן הכי סביר. זה יציב וממוקד, אבל אם המודל מתחיל לחזור על עצמו, אין כמעט רעש שיעזור לו לברוח. ככל שהדקודינג קרוב יותר לחמדני, ההתפלגות חדה יותר ויש פחות לחץ יציאה.

מודלים חלשים יותר

מודלים קטנים או פחות מאומנים מייצרים התפלגויות חדות יותר ופחות מכוילות. הם לא ראו מספיק המשכים מגוונים כדי לפזר הסתברות בצורה יציבה בין המשכים סבירים. הם נופלים לאטרקטורים בקלות רבה יותר, וברגע שהם שם, נשארת פחות מסה הסתברותית על טוקני יציאה.

קונטקסט ארוך עם מבנה חוזר

כשהקלט הוא גוף טקסט גדול עם סגנון עקבי — אינדקס ספר, אנציקלופדיה, כותרות סעיפים שחוזרות על עצמן — הקשב נתפס על הדפוס. הקלט מלמד את המודל שחזרתיות היא דבר נורמלי.

המאמר הקלאסי

Holtzman et al., 2020 — “The Curious Case of Neural Text Degeneration.”

רפרנס יסודי למצב הכשל הזה. המאמר הציע nucleus sampling, או top-p, כתיקון. התיקון הזה הפך לסטנדרט ברוב ה־APIs המרכזיים של מודלי שפה.

זה לא פתרון מלא, כמו שהבאג הזה הדגים, אבל זאת הסיבה ש־greedy decoding במערכות אמיתיות נחשב היום בחירה מסוכנת.

nucleus sampling, או top-p, בפשטות

במקום לקחת תמיד את הטוקן הכי סביר, משאירים את קבוצת הטוקנים הקטנה ביותר שסכום ההסתברויות שלה מגיע ל־p — בדרך כלל 0.9 — ודוגמים מתוכה באקראיות.

ה־nucleus הוא הגרעין בעל ההסתברות הגבוהה בתוך ההתפלגות.

זה נותן למודל מספיק אקראיות כדי לברוח מאטרקטורים חלשים, ועדיין שומר את רוב המשקל על המשכים סבירים.

איפה משתמשים בזה:

  • ברירת מחדל נפוצה ב־Anthropic, OpenAI ו־Google Vertex
  • בדרך כלל לא מגדירים את זה ידנית, כי זה כבר פעיל
  • משנים את זה רק כשבאמת רוצים אופי פלט אחר: נמוך יותר = ממוקד יותר וגם רגיש יותר ללולאות; גבוה יותר = מגוון יותר

הלקח מהבאג הזה: גם top-p כברירת מחדל לא בהכרח יציל מודל חלש כשהתפלגות הקלט כבר חדה מאוד.

top-p מקטין את הסיכון. הוא לא מעלים אותו כשהמגברים פועלים במלוא הכוח.

מה גרם לתקרית הספציפית הזאת

כל שלושת המגברים הופיעו יחד.

מודל חלש.
gemini-3.1-flash-lite-preview — שכבת העלות הזולה ביותר, שנבחרה בכוונה מטעמי עלות. התפלגות חדה ופחות מכוילת מאשר מודלים חזקים יותר.

קלט ארוך וחזרתי.
260,000 טוקנים של טקסט אנציקלופדי בעברית. גוף גדול, מבנה סעיפים עקבי. קלט מגוון יותר היה שומר את ההתפלגות שטוחה יותר.

טמפרטורת ברירת מחדל.
לא הוגדרה טמפרטורה נמוכה במיוחד, אבל תחת מודל חלש, עם הקשר שכבר מייצר שיא חד, ברירות המחדל לא הספיקו כדי למנוע מהאטרקטור להיווצר.

הסימן המסגיר: כל משפט שחזר על עצמו הסתיים בשלוש נקודות בעברית.

ברגע שהמודל הוציא משפט אחד שמסתיים ב־..., התפלגות האימון נתנה משקל גבוה ל”עוד משפט דומה שמסתיים ב־...” כהמשך. משם זה הזין את עצמו.

מה הנתונים הראו

לפני שהחלטתי אם זאת תקלה חד־פעמית, הסתכלתי על נתוני האפליקציה עצמם: 30 יום של encyclopedia_queries.

SELECT
  percentile_cont(0.50) WITHIN GROUP (ORDER BY output_tokens) AS p50,
  percentile_cont(0.90) WITHIN GROUP (ORDER BY output_tokens) AS p90,
  percentile_cont(0.99) WITHIN GROUP (ORDER BY output_tokens) AS p99
FROM encyclopedia_queries
WHERE created_at > now() - interval '30 days'
  AND output_tokens IS NOT NULL;

התוצאה:

אחוזוןטוקניםמשמעות
p50592חצי מהשאילתות החזירו פחות מזה. התשובה ה”טיפוסית”.
p901,07490% מהשאילתות היו מתחת לזה. הגבול של “ארוך אבל נורמלי”.
p9935,73799% מהשאילתות היו מתחת לזה. רק האחוז העליון מגיע לשם.

בהתפלגות בריאה, p99 אמור להיות בערך פי 2–3 מ־p90. כאן הוא פי 33.

זה לא “תשובה ארוכה”. זה זנב מזוהם: כמה לולאות חזרתיות משלושים הימים האחרונים יושבות בקצה ומנפחות את p99.

כלומר, לא שכל הזמן יש תשובות בעייתיות. להפך: זה נדיר. אבל החריגים כל כך קיצוניים שהם כבר לא נראים כמו תשובות רגילות. הם נראים כמו מצב כשל.

חוזר, לא חד־פעמי

הרגישות של Flash-Lite ללולאות על הקשרים ארוכים בעברית היא חולשה שצריך להתייחס אליה כאל סיכון מערכת אמיתי.

לפי הנתונים, הקצב הוא שבועות עד חודשים, לא ימים. זה יקרה שוב.

שכבות ההגנה הן התגובה הנכונה לכשל חוזר, לא פוסט־מורטם על אירוע מוזר אחד.

מה הקשר של הקאש לזה

אין קשר ישיר. הקאש חף מפשע.

Prompt caching מספק פרומפט ארוך בזול. זה מתואם עם שאילתות הקשר ארוך, ושאילתות הקשר ארוך מתואמות עם לולאות. אבל הקאש עצמו לא נוגע בהתפלגות הפלט של המודל.

אותה לולאה הייתה יכולה לקרות גם בלי caching. פשוט היה עולה יותר להגיע לשם.

שלוש שכבות הגנה

אין תיקון יחיד שמעלים לולאות. התשובה הנכונה היא הגנה בשכבות: להניח כמה הגנות זולות, ולקבל שלכל אחת יש מצב כשל קטן שהשכבה הבאה מכסה.

שכבה 1: תקרת טוקנים בצד הספק

להגדיר maxOutputTokens בכל קריאת מודל. תקרה קשיחה: ברגע שהמודל מגיע אליה, הסטרים נעצר בלי קשר למה שהוא עושה.

זה לא מזהה את הלולאה. המודל עדיין בלולאה כשחותכים אותו. אבל זה מגביל את הנזק: ספינר של 7 דקות הופך לספינר של 30 שניות.

שורת קוד אחת. חינם להוסיף, ותופס הכל.

שכבה 2: גלאי rolling buffer בצד השרת

בזמן שהטוקנים זורמים החוצה, מחזיקים באפר מתגלגל של הטקסט האחרון. אם אותו חלון קצר מופיע N פעמים בתוך M התווים האחרונים, מבטלים את הסטרים.

זה תופס לולאה אמיתית בתוך בערך שנייה, במקום בערך 30 שניות של שכבה 1.

הגלאי צריך להיות מספיק אדיש לתוכן כדי לא לייצר false positives על חזרתיות לגיטימית: רשימות ממוספרות, פזמונים, קוד עם מבנה חוזר. הכיוון של גודל החלון והסף הוא הסיפור העיקרי.

ואפשר גם לעשות משהו טוב יותר משגיאה: לעצור את הסטרים, לבצע retry פנימי עם קונפיגורציה בטוחה יותר, ולהחזיר למשתמש תשובה חדשה אם ה־retry מצליח. אבל צריך לעצב את זה בזהירות סביב עלות, קוואטה, ולוגים ל־ops.

שכבה 3: גלאי “זה נראה שבור” בפרונטאנד

אם התשובה המוצגת מכילה אותו משפט יותר מ־3 פעמים, מחליפים את ההודעה בכפתור ניסיון מחדש.

זה לא מונע את התשובה הרעה. זה מתקן את חוויית המשתמש למי שכבר רואה אותה. שכבת גיבוי אם שכבות 1 ו־2 מפספסות.

האם שכבה 2 מספיקה לבד?

ברוב המקרים כן, אבל עדיין רוצים את שכבה 1.

שכבה 1 היא שורת קוד אחת, והיא תופסת שני מקרים ששכבה 2 יכולה לפספס:

  • לולאות נסחפות — כל חזרה קצת שונה: X1 Y1 X2 Y2 X3. גלאי n-gram מדויק עלול לפספס.
  • גלאי עם באג — הגלאי עצמו מפספס לולאה אמיתית, או מזהה בטעות תוכן אמיתי כלולאה.

תקרת הטוקנים אדישה לתוכן. היא לא יכולה להכשיל תשובה אמיתית, כי תשובות אמיתיות לא מגיעות לתקרה: p90 הוא בערך אלף, והתקרה היא 3,000.

זה הופך אותה לשכבת גיבוי אמינה במיוחד. אין לה מצב כשל על פלט תקין, רק על פלט שנכנס ללולאה. לכן מעלים את שתיהן.

התיקון האמיתי

כל שלוש השכבות הן בקרת נזקים. תיקון האיכות הוא לשדרג את מודל שכבת התשובה.

מודלים טובים יותר לא מעלימים לולאות. הם מעבירים אותן מ”כל כמה שבועות” ל”נדיר מאוד”.

הטריידאוף הוא עלות. להריץ גם router וגם answer על מודל זול היה החלטה מכוונת. זה יכול להיות נכון מוצרית, אבל אז צריך להתייחס ללולאות כאל סיכון הנדסי ולשים להן גבולות.

השורה התחתונה: repetition loop הוא לא רק “המודל התחרפן”. זה כשל מערכתי שנוצר מהמפגש בין דקודינג, מודל חלש, הקשר ארוך וחזרתי, וחוסר בתקרות קשיחות. הפתרון הוא לא לקוות שזה לא יקרה שוב, אלא לבנות את המערכת כאילו זה יקרה שוב.