פלטים מובנים ו-Tool Use
הצעד שהופך "צ'אט" ל"מוצר": לגרום ל-LLM להחזיר JSON תקין ולהפעיל כלים. בלי זה, אי אפשר לתכנת מעל מודל שפה.
מה סכמה מבטיחה, ומה לא
זו הנקודה שקובעת אם המערכת שלכם תעבוד או רק תיראה כאילו: אכיפת סכמה מבטיחה צורה, אף פעם לא אמת.
JSON תקין שעובר ולידציה מלאה יכול להכיל מספר שהומצא, תאריך שלא נאמר בשום מקום, וקטגוריה שנבחרה כי היא נשמעה סבירה. הסכמה בדקה שיש שדה amount ושהוא מספר. היא לא בדקה שזה הסכום הנכון, ואין לה שום דרך לבדוק.
הטעות המסוכנת נובעת בדיוק מכאן: מרגע שהפלט מובנה הוא מרגיש אמין, ומפסיקים להסתכל עליו. המעבר מטקסט חופשי ל-JSON העלה את הביטחון בלי לשנות את הדיוק, וזה השילוב שמייצר תקלות שקטות.
מה סכמה כן פותרת, וזה לא מעט: קריסות פענוח, שדות חסרים, טיפוסים לא צפויים, ערכים מחוץ לרשימה מותרת, ותשובות שמתחילות ב"בטח, הנה ה-JSON". כל אלה היו רוב תקלות האינטגרציה לפני שהיה מנגנון אכיפה, והם נעלמו. מה שנשאר — נכונות התוכן — צריך מנגנון נפרד.
סכמה תקינה היא תנאי הכרחי לפלט נכון, ולא ראיה לכך שהוא נכון.
וההשלכה הארגונית חשובה לא פחות מהטכנית: מרגע שהפלט מובנה, קל מאוד לחבר אותו לשלב הבא — לטבלה, למערכת CRM, לזרימת אוטומציה. המבנה מוריד את החיכוך, ובדיוק בגלל זה הוא מגדיל את הנזק של ערך שגוי, שעכשיו זורם קדימה בלי שאיש קורא אותו בדרך.
שלוש רמות של אכיפה
"לבקש JSON" הוא לא דבר אחד אלא שלושה, והם שונים לחלוטין באמינות:
- לבקש יפה בפרומפט. "החזר JSON בלבד". עובד רוב הזמן, ונכשל בדיוק כשלא מצפים — טקסט לפני, הסבר אחרי, גדר markdown עוטפת. מתאים לניסוי, לא לייצור.
- מצב JSON. הספק מבטיח שהפלט יהיה JSON שניתן לפענוח. זה פותר את הגדר ואת ההקדמה, אבל לא מבטיח שהשדות שלך יהיו שם — יכול לחזור אובייקט תקין עם מבנה אחר לגמרי.
- אכיפת סכמה בזמן הפענוח. הספק מגביל את הטוקנים האפשריים כך שהתוצאה תואמת סכמה נתונה. זו הרמה היחידה שבה המבנה מובטח ולא מקווה.
ההבדל בין השנייה לשלישית הוא מה שרוב הצוותים מפספסים. מצב JSON מעביר את הבעיה משלב הפענוח לשלב הגישה לשדה — במקום JSONDecodeError מקבלים KeyError, וזה לא שיפור. אם הספק תומך באכיפת סכמה, אין סיבה להסתפק במצב JSON.
ומה עדיין נשבר גם עם אכיפה
אכיפת סכמה מסירה את רוב הכשלים, ולא את כולם. שלושה מקרים ממשיכים להופיע בייצור, וכדאי לטפל בהם מראש ולא בהפתעה.
קטיעה באמצע. אם תקרת הטוקנים נחצתה לפני שהאובייקט נסגר, מה שחוזר הוא JSON חתוך — שעדיין נכשל בפענוח. זה קורה בדיוק במסמכים הארוכים, כלומר במקרים החשובים. סכמה עם שדות טקסט ארוכים דורשת תקרה נדיבה, ובדיקה מפורשת של סיבת הסיום ולא רק של התוכן.
סירוב. כשהמודל מסרב לענות, התשובה לא תמיד תואמת את הסכמה — או שהיא תואמת אותה עם שדות ריקים, שזה מבלבל יותר. שדה סטטוס מפורש בסכמה, שהמודל יכול לסמן בו שלא ביצע, הופך את המקרה הזה לצפוי.
הזרמה. פלט שמוזרם טוקן־טוקן אינו JSON תקין עד שהוא נגמר. אפשר להזרים ואפשר לאכוף, אבל אי אפשר להסתמך על תקינות באמצע — הוולידציה רצה על התוצאה המלאה.
איך מעצבים סכמה שהמודל מצליח למלא
סכמה אינה רק חוזה טכני — היא גם הנחיה. המודל רואה את שמות השדות ואת מבנה האובייקט, והם משפיעים על מה שייכנס פנימה. ארבעה כללים שמשנים את שיעור הדיוק:
- שטוח עדיף על מקונן. אובייקט בעומק ארבע רמות מייצר יותר שגיאות מאובייקט אחד עם שמונה שדות. אם אפשר לשטח — שטחו.
- רשימה סגורה במקום מחרוזת.
enumעם חמישה ערכים אפשריים מונע פיזית קטגוריה שהומצאה. מחרוזת חופשית מזמינה אותה. - שמות שאומרים מה נדרש.
customer_full_nameברור מ-name, ו-amount_ilsמונע את השאלה אם זה בשקלים או באגורות. שם שדה הוא הנחיה קצרה. - הכל נדרש, וריק הוא ערך חוקי. שדות אופציונליים גורמים למודל להשמיט, והקוד מתמודד עם שני מצבים במקום אחד. עדיף לדרוש הכל ולאפשר
null.
ושדה תיאור לכל שדה, כשהספק תומך בזה, שווה יותר משנראה: "date": {"type": "string", "description": "בפורמט YYYY-MM-DD"} חוסך את רוב תקלות הפורמט לפני שהן קורות.
כמה שדות זה יותר מדי
אין מספר מדויק, ויש תופעה שחוזרת: ככל שהסכמה גדולה יותר, כך הדיוק בשדה הבודד יורד. סכמה עם שלושים שדות מבקשת מהמודל לבצע שלושים חילוצים בקריאה אחת, והשדות שבסוף הרשימה סובלים יותר מאלה שבתחילתה.
כשמגיעים לשם, עדיף לפצל. שתי קריאות עם סכמה ממוקדת מדויקות יותר מקריאה אחת עם סכמה ענקית, ולעתים קרובות גם זולות יותר, כי אפשר להריץ את השנייה רק כשהראשונה מצאה משהו.
שני דפוסי פיצול שעובדים: לפי סוג — קריאה אחת לפרטי הלקוח, אחרת לפרטי ההזמנה, כשהן לא תלויות זו בזו; ולפי ודאות — קריאה ראשונה מסווגת את סוג המסמך, ורק אז נטענת הסכמה המתאימה לסוג. הדפוס השני חוסך גם בהקשר, כי אין צורך לשלוח סכמה של כל הסוגים בכל פעם.
השדה החשוב ביותר הוא זה שמאפשר לא לדעת
אם יש שינוי אחד שמשתלם יותר מכל השאר, הוא לתת למודל דרך לגיטימית לומר שאין לו תשובה.
סכמה שדורשת "amount": number בלי אפשרות ל-null מייצרת מצב בלתי אפשרי כשהסכום לא מופיע במסמך: המודל חייב להחזיר מספר, אז הוא יחזיר מספר. לא מתוך "הזיה" במובן המסתורי, אלא כי המבנה שהגדרתם לא הותיר ברירה אחרת.
{
"amount_ils": {"type": ["number", "null"]},
"amount_found": {"type": "boolean"},
"confidence": {"type": "string",
"enum": ["high", "low"]}
}
שלושת השדות האלה יחד משנים את התנהגות המערכת: מה שקודם היה מספר שגוי שנכנס למסד הנתונים בשקט הופך לרשומה מסומנת שמישהו יכול לבדוק. ההבדל בין מערכת שאפשר לתקן למערכת שאי אפשר הוא בדיוק כאן.
ותוספת שמשתלמת במיוחד בחילוץ ממסמכים: שדה ציטוט. לבקש מהמודל להחזיר לצד כל ערך את הקטע המדויק מהמקור שממנו נגזר. אפשר לבדוק בקוד שהציטוט אכן מופיע בטקסט המקורי — ואם לא, הערך חשוד. זו בדיקה מכנית זולה שתופסת בדיוק את סוג הטעות שסכמה לא תופסת.
ולידציה שהיא מעבר לסכמה
אחרי שהמבנה אומת, מתחילה השכבה שבאמת מגנה על המערכת. ארבע בדיקות שכדאי שיהיו בקוד:
- טווחים הגיוניים. סכום שלילי, תאריך בעוד שנתיים, ציון 150. הסכמה אישרה כי הטיפוס נכון.
- עקביות פנימית. אם
amount_foundהואfalseאבל יש ערך ב-amount_ils— משהו לא מסתדר. - התאמה למקור. ערך שמופיע בפלט וכלל לא מופיע בקלט. כאן שדה הציטוט עושה את העבודה.
- התאמה לנתונים שלכם. מזהה לקוח שלא קיים במערכת, שם עיר שאינו ברשימה. בדיקה מול מסד הנתונים תופסת את מה ששום סכמה לא יכולה.
ומה עושים עם כישלון: מפרידים בין תיקון לבין דחייה. בעיית מבנה — לנסות שוב. בעיית תוכן שנתפסה בבדיקה — לא לנסות שוב, אלא להעביר לבדיקה אנושית או להחזיר תשובה שאומרת שאין מידע מספיק. ניסיון חוזר על בעיית תוכן מייצר בדרך כלל את אותה טעות בניסוח אחר.
איפה הוולידציה גרה
שאלה שנשמעת טכנית ומכריעה בפועל: הוולידציה שייכת לקוד, ולא לפרומפט ולא לביטוי בתוך כלי האוטומציה.
"ודא שהסכום סביר" בהנחיית המערכת היא בקשה, ובקשה נכשלת בשקט. בדיקה בתוך ביטוי של n8n או Make עובדת, אבל היא לא ניתנת לבדיקה אוטומטית ולא נראית בביקורת קוד — ובדרך כלל מישהו עוקף אותה חצי שנה אחרי.
הדפוס שמחזיק לאורך זמן הוא פונקציית ולידציה אחת שכל הקריאות עוברות דרכה, עם בדיקות ביחידה משלה. כשמתגלה סוג טעות חדש, מוסיפים לה בדיקה ומוסיפים מקרה בדיקה — והמערכת נעשית עמידה יותר במקום לצבור תיקונים מפוזרים.
ונקודה אחרונה בעלת ערך מעשי: מה שנכשל בוולידציה נשמר, לא נזרק. אוסף הכשלים הוא בדיוק מערך הבדיקה שיידרש בפעם הבאה שתשנו את הסכמה.
ניסיון חוזר שעושה טוב ולא רק עוד קריאה
הדפוס הנפוץ — לתפוס שגיאה ולקרוא שוב עם אותה בקשה בדיוק — הוא בזבוז. אותה קלט, אותה הנחיה, סיכוי דומה לאותה תוצאה.
מה שהופך ניסיון חוזר ליעיל הוא להחזיר למודל את השגיאה עצמה:
def extract(text, max_tries=2):
err = None
for attempt in range(max_tries + 1):
msg = build_prompt(text, previous_error=err)
raw = call_model(msg) # עם אכיפת סכמה
try:
return validate(json.loads(raw))
except ValidationError as e:
err = str(e) # נכנס לניסיון הבא
return {"ok": False,
"reason": "failed validation",
"last_error": err}
שלושה פרטים בקוד הזה חשובים יותר מהמבנה שלו. תקרה קשיחה — שניים או שלושה ניסיונות, לא לולאה פתוחה, אחרת קלט בעייתי אחד שורף תקציב. כישלון שמחזיר תשובה ולא זורק חריגה, כדי שהשלב הבא ידע להתמודד. ותיעוד של כל כישלון עם הקלט — אם אותו סוג שגיאה חוזר, הבעיה בסכמה או בהנחיה, לא במודל, ואף כמות ניסיונות לא תפתור אותה.
אותו קלט, אותה תשובה?
ציפייה סבירה שמאכזבת רבים: אותו מסמך שנשלח פעמיים יכול לחזור עם ערכים מעט שונים. הסכמה מבטיחה שהמבנה יהיה זהה, לא שהתוכן יהיה.
שתי דרכים לצמצם את הפער. טמפרטורה נמוכה למשימות חילוץ — אין כאן שום ערך ביצירתיות, ואפשר לרדת עד לערך המינימלי שהספק מאפשר. וסכמה מהודקת יותר: כל שדה שהפכתם מטקסט חופשי לרשימה סגורה הוא שדה שהפסיק להשתנות בין הרצות.
מה שלא עובד הוא לצפות לזהות מוחלטת ולבנות עליה. אם המערכת שלכם דורשת שאותו קלט ייתן בדיוק אותה תוצאה — שמרו את התוצאה, אל תריצו שוב. מטמון על הקלט פותר את זה לגמרי, וגם חוסך כסף.
עברית: מבנה באנגלית, ערכים בעברית
זו נקודה שחוזרת בכל מערכת עברית ושווה להחליט בה פעם אחת. שמות השדות והערכים ברשימות הסגורות באנגלית; התוכן שנכנס לשדות בעברית.
הסיבה מעשית: המודלים ראו כמות עצומה של סכמות JSON באנגלית ומעט מאוד בעברית, ושמות שדה עבריים מעלים את שיעור השגיאות בלי שום תמורה. אותו דבר ל-enum: ["high","medium","low"] יציב יותר מאשר ["גבוה","בינוני","נמוך"], והתרגום לתצוגה נעשה ממילא בקוד.
ושלוש מלכודות שמופיעות דווקא בעברית:
- מספרים וטקסט מעורבים. "כ-1,200 ש״ח" צריך להגיע כ-
1200בשדה מספרי ולא כמחרוזת. הדרישה לטיפוס מספרי בסכמה עושה את רוב העבודה. - תאריכים בניסוח יחסי. "מחרתיים", "בשבוע שעבר", "אחרי החג". תאריך מוחלט דורש נקודת ייחוס — מסרו את התאריך הנוכחי בהנחיה במפורש, אחרת המודל מנחש ממתי לספור.
- טלפונים וכיווניות. מספר עם קידומת בתוך משפט עברי עלול לחזור בסדר שנראה שונה. נרמול טלפונים, סכומים ותאריכים שייך לקוד שלכם אחרי הפענוח, לא למודל.
דוגמה מלאה: הזמנה מהודעת וואטסאפ
כדי לחבר את הכל, הנה משימה אמיתית שחוזרת הרבה בעסקים בישראל: הודעה חופשית בעברית שצריכה להפוך לרשומה. הקלט נראה בערך כך: "היי, אפשר בבקשה 3 קרטונים של המוצר הקטן, למשלוח ליום ראשון הבא לרחוב הרצל 12 ראשל״צ. תודה!"
הסכמה שמתאימה לזה נראית כך — שימו לב שכל שדה שעלול להיעדר מותר להיות null, ושיש שדה נימוק לפני שדות המסקנה:
{
"type": "object",
"properties": {
"notes": {"type": "string"},
"quantity": {"type": ["integer", "null"]},
"unit": {"type": ["string", "null"],
"enum": ["carton", "unit", "pallet", null]},
"product_raw":{"type": ["string", "null"],
"description": "כפי שנכתב בהודעה"},
"address_raw":{"type": ["string", "null"]},
"delivery_hint": {"type": ["string", "null"],
"description": "ניסוח התאריך כפי שנאמר"},
"needs_review": {"type": "boolean"}
},
"required": ["notes", "quantity", "unit", "product_raw",
"address_raw", "delivery_hint", "needs_review"],
"additionalProperties": false
}
שלוש החלטות בסכמה הזו הן מה שהופך אותה לעובדת. שדות ה-raw — המודל מחזיר את מה שנכתב, והתאמה למוצר במלאי או נרמול כתובת נעשים בקוד מול הנתונים שלכם. delivery_hint ולא delivery_date — "יום ראשון הבא" הוא ניסוח יחסי, והפיכתו לתאריך דורשת את התאריך הנוכחי ולוח חגים, כלומר קוד. וneeds_review — הדרך של המודל לומר שמשהו לא היה ברור, במקום לנחש בשקט.
מה שקורה אחרי הפענוח הוא לא פחות חשוב: product_raw מותאם לקטלוג, וכשאין התאמה חד-משמעית הרשומה מסומנת לבדיקה; quantity נבדק מול טווח סביר; והכתובת עוברת נרמול. המודל עשה את מה שהוא טוב בו — להבין כוונה מטקסט חופשי — והקוד עשה את מה שהוא טוב בו.
כשהמודל בוחר פעולה ולא רק מחזיר נתונים
קריאה לכלי היא אותו מנגנון בדיוק: המודל מייצר אובייקט שתואם סכמה. ההבדל אינו טכני אלא בתוצאה — כאן הפלט לא נכנס למסד נתונים אלא מפעיל פעולה.
לכן כל מה שנאמר עד כאן חל, ועוד שכבה מעליו: הסכמה אישרה שהפרמטרים תקינים, לא שכדאי לבצע את הפעולה. {"action": "refund", "amount_ils": 4500} הוא JSON תקין מושלם, וגם החלטה שאולי לא הייתה צריכה להתקבל.
שני כללים שמפרידים בין מערכת בטוחה ללא: ההרשאה נבדקת בקוד לפני הביצוע ולא בהנחיה, ופעולות שלא ניתן לבטל עוברות דרך אישור אנושי. ההרחבה המלאה, כולל מה עושים עם קריאה כפולה ועם שגיאות, נמצאת בTool Use.
מה זה עולה
אכיפת סכמה אינה חינם, ושווה לדעת במה משלמים.
הסכמה נשלחת בכל קריאה. סכמה מפורטת עם תיאור לכל שדה היא תוספת קבועה להקשר. בדרך כלל היא משתלמת — פחות ניסיונות חוזרים — אבל בסכמות ענקיות זה מצטבר.
יש אילוץ על הפלט. המודל מוגבל במה שהוא יכול לייצר, ובמשימות שדורשות הסבר או שיקול דעת זה עלול לפגוע באיכות. הפתרון המקובל: שדה נימוק בתוך הסכמה, לפני שדות המסקנה, שנותן מקום לחשיבה בלי לוותר על המבנה. סדר השדות משנה כאן — נימוק שמופיע אחרי המסקנה הוא הצדקה בדיעבד.
וסכמה נוקשה מדי שוברת מקרי קצה. enum של חמש קטגוריות מתנהג רע כשמגיע מקרה שישי. שדה other לצד רשימה סגורה הוא שסתום זול שמונע סיווג שגוי בכוח.
שלושה מספרים שכדאי לעקוב אחריהם
"זה עובד" אינו מדד. שלושה מספרים מפרידים בין בעיות שונות לגמרי ומכוונים את התיקון למקום הנכון:
- שיעור כשל מבני. כמה תשובות לא עברו פענוח או סכמה. עם אכיפה זה אמור להיות אפסי — ואם לא, החשוד הוא קטיעה בגלל תקרת טוקנים.
- שיעור כשל בוולידציה העסקית. עבר סכמה ונפל על טווח, עקביות או התאמה לנתונים. זה המספר שמעיד על איכות אמיתית, והוא היחיד שהרבה מערכות לא מודדות בכלל.
- שיעור
nullו-needs_review. אם הוא אפס, כנראה שהמודל לא באמת מודה שהוא לא יודע. אם הוא גבוה מאוד, הסכמה מבקשת מידע שלרוב אינו קיים בקלט.
ומעל שלושתם, ההרגל שמונע נסיגות: מערך של כמה עשרות קלטים אמיתיים עם הפלט הנכון מסומן, שרץ אחרי כל שינוי בסכמה או בהנחיה. אותה גישה שמפורטת בEvals, וכאן היא זולה במיוחד — הפלט מובנה, אז ההשוואה אוטומטית לחלוטין.
מתי טקסט חופשי הוא התשובה הנכונה
- כשהפלט הולך ישירות לעיני אדם. תשובה בצ׳אט, טיוטת מייל, הסבר. מבנה כאן רק מפריע.
- כשהמשימה יצירתית. כתיבה, רעיונות, ניסוח. אילוץ מבני פוגע ישירות בתוצר.
- כשהמבנה לא ידוע מראש. אם אתם עדיין מגלים אילו שדות רלוונטיים, סכמה מוקדמת נועלת החלטה שטרם התקבלה.
- כשקוד רגיל מספיק. חילוץ מפורמט קבוע נעשה בביטוי רגולרי בוודאות, בלי מודל ובלי עלות.
ויש דרך ביניים שעובדת טוב: תשובה חופשית למשתמש, וקריאה נפרדת ומובנית למערכת. שתי קריאות במקום פשרה שפוגעת בשתי המטרות.
מעבר ממערכת שכבר רצה
אם כבר יש לכם זרימה שמנתחת טקסט חופשי ועובדת "בסדר", אין צורך לכתוב אותה מחדש. סדר המעבר שמפחית סיכון הוא כזה:
- לאסוף פלטים אמיתיים. כמה עשרות תשובות מהמערכת הקיימת, כולל אלה שיצאו שגויות. זה מערך הבדיקה, וגם מה שיגלה אילו שדות באמת נחוצים.
- להגדיר סכמה על מה שכבר קיים, לא על מה שאולי יידרש בעתיד. שדות נוספים אפשר להוסיף; שדה שנולד מיותר נשאר מיותר.
- להריץ במקביל. הזרימה הישנה ממשיכה לפעול, החדשה רצה לצדה וכותבת ללוג. משווים, ורק כשהפער ברור לטובת החדשה — מחליפים.
- להוסיף ולידציה עסקית לפני ההחלפה ולא אחריה, אחרת המעבר מייצר ביטחון מוגזם בדיוק בשלב הרגיש.
המעבר הזה כמעט תמיד מגלה משהו על המערכת הישנה: שדות שאיש לא השתמש בהם, ותקלות שקרו מזמן ואיש לא ידע עליהן, כי בטקסט חופשי לא היה למה להשוות.
טעויות שחוזרות
- להניח שמבנה תקין הוא תוכן נכון. הטעות היחידה שמייצרת תקלות שקטות.
- סכמה בלי אפשרות ל-null. מאלצת את המודל להמציא ערך כשאין.
- מחרוזת חופשית במקום רשימה סגורה. קטגוריה שנשמעת סבירה תופיע במוקדם או במאוחר.
- שדות אופציונליים. מכפילים את מספר המצבים שהקוד צריך לטפל בהם.
- ניסיון חוזר בלי להחזיר את השגיאה. אותה קריאה, אותה תוצאה, פעמיים המחיר.
- לולאת ניסיונות בלי תקרה. קלט בעייתי אחד שורף תקציב.
- שמות שדה בעברית. מעלה שגיאות בלי תמורה.
- נימוק אחרי המסקנה. מקבלים הצדקה, לא חשיבה.
- נרמול שמוטל על המודל. טלפונים, סכומים ותאריכים שייכים לקוד.
- סכמה נוקשה בלי שסתום. מקרה קצה נדחס לקטגוריה הלא נכונה.