5 Skill-Building · פרק 5 מתוך 8

Observability ו-Failure Recovery — לראות, לתפוס כשלים, ולעצור לולאות

עד עכשיו בנית harness שמריץ tools, מנהל context ומאכף שערים. אבל כשהסוכן "נתקע" — אתה עיוור. בפרק הזה נוסיף ל-harness עיניים (tracing) ורשת ביטחון (circuit breaker + recovery), כדי שתדע בדיוק איפה, על איזה tool, וכמה זה עלה — ותוכל לעצור לולאה לפני שהיא שורפת את ה-budget.

חוט הפרויקט 🧵

קודם (פרק 4 — Tools, MCP ו-Governance): נתת ל-harness שכבת tools אמינה — structured JSON output עם retry דטרמיניסטי, חיבור MCP חי, ו-policy gates שחוסמים פעולות הרסניות. ה-tools מאובטחים, אבל הריצה עצמה עדיין קופסה שחורה.

כאן (פרק 5): נוסיף שתי שכבות שהופכות את הקופסה השחורה לזכוכית — observability (tracing מלא של כל turn ו-tool call עם Langfuse) ו-failure recovery (זיהוי לולאות עם circuit breaker, failure capture מובנה, ומדיניות retry / fallback / escalation). מכאן והלאה אתה רואה הכל ולא מדמם budget בשקט.

אחר כך (פרק 6 — Orchestration רב-סוכני): ברגע שיש לך כמה סוכנים מקבילים, ה-tracing הזה הופך מ"נחמד שיש" לתנאי הכרחי: בלי trace לכל subagent אי אפשר לדבג למה אחד מהם נכשל או שרף תקציב. את ה-circuit-breaker וה-cost attribution שנבנה כאן נחבר שם לכל subagent בנפרד.

מה תשיג/י בפרק הזה

מה צריך/ה לדעת לפני

לפני שמתחילים — checklist טכני קצר (להימנע מ"התקנתי הכל ועדיין 401"):

מה תבנה/י בפרק (Deliverables)

  1. harness עם tracing חי ב-Langfuse — כל turn ו-tool call נראים כ-span, עם latency ו-token cost לכל אחד, מקובצים תחת trace אחד לכל ריצה.
  2. circuit-breaker — רכיב שמזהה קריאת tool חוזרת (אותו tool + אותו input) 3 פעמים → אזהרה, 6 פעמים → עצירה קשה עם דיווח מובנה, במקום לדמם budget עד max-turns.
  3. failure-recovery policy — מנגנון שתופס כל כשל, רושם רשומה מובנית (input/error/context/turn), ומחליט דטרמיניסטית: retry / fallback / escalate / stop.

למה harness בלי observability הוא קופסה שחורה

theoryobservabilitymotivation

נתחיל מתרחיש שכל מי שבנה loop ביתי מכיר. אתה מריץ task, רואה את ה-CPU עובד, רואה כמה הודעות נדפסות ב-terminal, ואז — שקט. הסוכן "חושב". עוברות שתי דקות. עוד דקה. אתה מסתכל על החשבון ב-Console ורואה שנשרפו 40,000 tokens. על מה? אין לך מושג. הסוכן נתקע על איזה tool? קרא לאותו endpoint שוב ושוב? נכנס ל-compaction באמצע? אתה לא יודע, כי הלולאה שלך לא מספרת לך כלום.

זו לא בעיה תיאורטית. אחד מ-beginner_mistakes המתועדים במחקר של 2026 הוא בדיוק זה: הסתמכות על frameworks כבדים שמגיעים עם "אפס observability ו-agents שלולפים 6+ פעמים על קריאת tool אחת" (מתוך course.research.json, gotchas). שים לב לצירוף: אפס observability ולולאה של 6+ מופיעים יחד — לא במקרה. כשאתה לא רואה את הלולאה, אתה לא יודע שהיא קורית עד שמגיע החשבון.

Observability (נראוּת) היא היכולת לענות על השאלה "מה באמת קרה בריצה הזו?" בלי לנחש. במערכות תוכנה רגילות זה מורכב משלושה רכיבים: logs (מה קרה), metrics (כמה, כמה מהר, כמה עלה) ו-traces (באיזה סדר, מה הוביל למה). ב-harness לסוכן, ה-trace הוא הרכיב הקריטי, כי ריצת agent היא רצף של turns ו-tool calls שמובילים זה לזה — ובלי לראות את הרצף, אתה לא יכול להבין למה הסוכן הגיע למקום שהוא הגיע אליו.

טעות נפוצה: לדבג לפי print-ים

הפיתוי הראשון הוא לזרוק print() בכל מקום בלולאה. זה עובד לסוכן אחד, בריצה אחת, ב-terminal אחד. ברגע שיש לך כמה turns, tool calls מקוננים, או — בפרק 6 — כמה subagents מקבילים, ה-print-ים מתערבבים זה בזה לכדי דייסה שאי אפשר לקרוא. tracing הוא תנאי, לא תוספת: הוא נותן לך מבנה היררכי (trace → span → span) שאפשר לסנן, למיין ולתחקר — בדיוק מה ש-print-ים לא נותנים. דוגמה קונקרטית: ב-Aden Hive (מתוך course.research.json, features) ריצות מרובות של subagents שולחות logs לאותו terminal בו-זמנית — ובלי trace hierarchy, שורה A של subagent-1 ושורה B של subagent-2 מסתדרות לפי זמן אורולוגיה, לא לפי לוגיקה. trace hierarchy שומר כל subagent בתת-עץ נפרד, ואתה יכול לפתוח כל אחד בנפרד.

הנקודה המעשית: observability היא לא "אופציה לפרודקשן" — היא תנאי כדי בכלל לבנות את שאר ה-harness. אי אפשר לבנות recovery חכם בלי לראות מה נכשל. אי אפשר לשלוט בעלות subagents (פרק 6) בלי לייחס tokens לכל אחד. אי אפשר למדוד אם Dreaming (פרק 7) באמת שיפר משהו בלי baseline. לכן זה הפרק שבא לפני ה-orchestration: קודם עיניים, אחר כך צוות.

שווה לעצור על ההבדל בין שלושת רכיבי ה-observability, כי מפתחים מבלבלים אותם. logs הם רשומות שטוחות — "קרה X בזמן T". הם טובים לחיפוש טקסטואלי אבל גרועים בהבנת רצף. metrics הם מספרים מצטברים — "50 ריצות, ממוצע 12 turns, עלות חציונית $0.30". הם מצוינים למגמות אבל לא אומרים לך מה קרה בריצה ספציפית. traces הם הרכיב שמחבר: הם מראים את הרצף ההיררכי של ריצה אחת — turn הוביל ל-tool call שהחזיר שגיאה שהוביל ל-turn נוסף. בעולם ה-LLM, שבו כל החלטה של הסוכן נשענת על מה שקרה לפניה, ה-trace הוא הרכיב שבלעדיו אתה פשוט לא מבין למה הסוכן עשה מה שעשה. הקורס מתמקד ב-traces כי הם הרכיב הייחודי לסוכנים — logs ו-metrics אתה כבר יודע מכל מערכת תוכנה אחרת.

יש כאן גם שאלה כלכלית, לא רק הנדסית. במחקר של הקורס מצוין במפורש שמפתח ישראלי צריך לתקצב ריצות agent עתירות-tokens תוך התחשבות בשער ILS/USD וב-VAT (מתוך course.research.json, local_market_notes). כשריצה בודדת יכולה לשרוף עשרות אלפי tokens, ולולאה לא-מזוהה יכולה להכפיל את זה פי כמה — ההבדל בין "יש לי observability" ל"אין לי" הוא הבדל ישיר בחשבון בסוף החודש. observability היא לא הוצאה, היא חיסכון.

עשה/י עכשיו (60 שניות)

פתח/י את ה-loop שכתבת בפרק 2. ספור/י כמה print() או logging calls יש בו. עכשיו ענה/י על שתי שאלות בלי להריץ אותו מחדש: (1) בריצה האחרונה, כמה tokens עלה ה-turn הכי יקר? (2) על איזה tool הסוכן בילה הכי הרבה זמן? אם אתה לא יכול לענות — זה הפרק שלך.

לפני שצוללים ל-Langfuse — נעצור שנייה על המושגים. trace (עקבה) זה כמו "תיק רפואי של ריצה אחת": לא סתם "מה קרה", אלא רצף כרונולוגי של אירועים עם זמני התחלה וסיום, תוצאות, ועלויות. span זה אירוע אחד בתוך הריצה — turn (קריאה למודל) או tool call. generation זה span מיוחד לקריאת LLM, שרושם בנוסף גם token usage. בעולם הסוכנים, כל ריצה = trace אחד, וה-spans מתקבצים תחתיו בעץ. Langfuse היא המערכת שמייצרת, שומרת, ומציגה את העץ הזה בממשק גרפי — בדיוק מה שאתה צריך כדי לענות על שתי השאלות למעלה.

Langfuse כשכבת tracing — trace, span, ומה לרשום

practicaltracinglangfuse

Langfuse הוא פלטפורמת observability open-source שנבנתה ספציפית ל-LLM applications. בניגוד לכלי tracing כלליים, הוא מבין את המודלים של עולם הסוכנים: trace לכל ריצה, span לכל turn או tool call, וייחוס token cost לכל קריאה למודל. במחקר של הקורס הוא מסומן כשכבת ה-tracing המומלצת ל-harness (מתוך course.research.json, suggested_chapters: "Observability and Failure Recovery" → "Langfuse tracing"). יש לו tier חינמי ל-self-hosting וגם SaaS — לצרכי הקורס שניהם מספיקים.

שני מושגי היסוד:

מושגמה זהב-harness שלנו
traceיחידת עבודה אחת מקצה לקצה — "ריצה" שלמההרצת task אחת של הסוכן: מ-goal ועד תוצאה סופית או עצירה
spanפעולה בודדת בתוך trace, עם זמן התחלה/סיום ומטא-דאטהturn אחד (קריאה למודל) או tool call בודד
nested spanspan בתוך span — היררכיהtool call שרץ בתוך turn מסוים מקונן תחתיו
generationspan מיוחד לקריאת LLM, עם input/output/token usageהקריאה ל-Claude Agent SDK בכל turn — כאן נרשם ה-token cost

החיבור ל-Claude Agent SDK פשוט להפתיע. שלושה משתני סביבה ו-decorator, או — לשליטה מלאה — קריאות ידניות לכל span. נתחיל מהגרסה הידנית כי היא מלמדת מה באמת קורה:

# pip install langfuse
import os
from langfuse import Langfuse

# מפתחות מ-Langfuse Console (יש tier חינמי)
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-..."
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-..."
os.environ["LANGFUSE_HOST"] = "https://cloud.langfuse.com"  # או self-hosted

langfuse = Langfuse()

def run_agent(goal: str):
    # trace אחד לכל ריצה — כל ה-spans יתקבצו תחתיו
    trace = langfuse.trace(name="agent-run", input={"goal": goal},
                           metadata={"harness_version": "0.5"})
    messages = build_initial_messages(goal)

    for turn in range(MAX_TURNS):
        # span לכל turn = קריאה למודל. generation רושם token usage
        gen = trace.generation(name=f"turn-{turn}", model="claude-sonnet-4-6",
                               input=messages)
        response = call_model(messages)          # הקריאה ל-Claude Agent SDK
        gen.end(output=response.content,
                usage={"input": response.usage.input_tokens,
                       "output": response.usage.output_tokens})

        tool_calls = extract_tool_use(response)
        if not tool_calls:
            trace.update(output=response.content)  # stop condition: יש תשובה סופית
            break

        for call in tool_calls:
            # span מקונן לכל tool call — כאן נראה latency אמיתי
            span = trace.span(name=f"tool:{call.name}", input=call.input)
            result = execute_tool(call)            # ה-tool execution מ-ch2/ch4
            span.end(output=result, metadata={"error": result.is_error})
            messages.append(tool_result_block(call, result))

    langfuse.flush()   # חשוב: לשלוח את ה-spans לפני שהתהליך מסתיים

שים לב לשלושה דברים. ראשית, ה-trace נוצר פעם אחת לכל ריצה — כל מה שקורה בלולאה מתקבץ תחתיו, וזה מה שנותן לך את התמונה ההיררכית במקום ערימת logs. שנית, trace.generation() הוא span מיוחד שיודע לרשום token usage — וזה מה שיאפשר את ה-cost attribution בסעיף הבא. שלישית, langfuse.flush() בסוף הוא לא קישוט: ה-SDK שולח spans באצווה ברקע, ואם התהליך מת לפני ה-flush, איבדת את ה-trace. ב-harness שרץ ב-CI או ב-cron, שכחת flush = ריצה שלמה שנעלמה.

Framework: מה לרשום ב-span — ואם X אז Y

אל תרשום "הכל" — זה מנפח עלויות אחסון ומקשה לקרוא. כלל ההחלטה:

עשה/י עכשיו (45 שניות)

פתח/י את הקוד של ה-loop שלך וסמן/י בעיניים שלוש נקודות: (1) איפה נכנס ה-goal — שם ייכנס trace(). (2) איפה הקריאה למודל — שם ייכנס generation(). (3) איפה execute_tool() — שם ייכנס span(). לא צריך לכתוב כלום עדיין; רק לזהות את שלוש נקודות-העיגון. ברגע שתראה/י אותן, ה-tracing הוא חיווט של שלוש שורות.

תרגיל 1: לחבר tracing ולראות trace ראשון

מטרה: להפוך את ה-loop מפרק 2 משקוף לזכוכית — trace אחד עם spans נראים.

דוגמת כלים מינימלית (אם אין לך עדיין — אלה שני tools שעובדים מספיק טוב לתרגיל):

# tools.py — read_file / write_file מינימליים לתרגיל
import pathlib

def read_file(path: str) -> str:
    return pathlib.Path(path).read_text(encoding="utf-8")

def write_file(path: str, content: str) -> str:
    pathlib.Path(path).write_text(content, encoding="utf-8")
    return f"wrote {len(content)} chars to {path}"

# רישום הכלים ל-Claude Agent SDK — input_schema חייב להיות תקין JSON Schema
TOOLS = [
    {"name": "read_file", "description": "קורא קובץ טקסט מהדיסק",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"}},
                      "required": ["path"]}},
    {"name": "write_file", "description": "כותב טקסט לקובץ בדיסק",
     "input_schema": {"type": "object",
                      "properties": {"path": {"type": "string"},
                                     "content": {"type": "string"}},
                      "required": ["path", "content"]}},
]
  1. הירשם/י ל-Langfuse (cloud.langfuse.com, tier חינמי) וצור/י project. העתק/י את PUBLIC_KEY ו-SECRET_KEY. למה הצעד הזה חשוב: בלי project לא קיים יעד ל-spans — ה-SDK ישלח אותם לאוויר ותריץ הכל לשווא. פלט צפוי: ב-Settings → API Keys מופיעים pk-lf-xxx ו-sk-lf-xxx (שתי מחרוזות ארוכות).
  2. התקן/י: pip install langfuse. הגדר/י את שלושת משתני הסביבה. למה הצעד הזה חשוב: בלי env vars ה-SDK יעלה אבל ייכשל ב-AuthenticationError רק בעת שליחת span — קשה לדבג. פלט צפוי: python -c "import langfuse; print(langfuse.__version__)" מדפיס גרסה; env | grep LANGFUSE מציג 3 שורות.
  3. עטוף/י את הלולאה שלך לפי הדוגמה למעלה: trace() בכניסה, generation() לכל turn, span() לכל tool call. למה הצעד הזה חשוב: זו הליבה — שלוש קריאות בלבד הופכות את הלולאה מקופסה שחורה לזכוכית. פלט צפוי: הקוד רץ בלי ImportError/TypeError; ב-trace רואים 3 סוגי spans שונים.
  4. הרץ/י task פשוט שדורש 2-3 tool calls (למשל: "קרא קובץ X, סכם אותו, וכתוב את הסיכום לקובץ Y"). למה הצעד הזה חשוב: 2-3 tool calls = מספיק כדי לראות עץ spans מקונן, אבל לא כל כך הרבה שהתמונה מתערבבת. פלט צפוי: הסוכן מסיים את ה-task; בלוג רואים 3 נקודות-עיגון (trace/generation/span) שעברו.
  5. אל תשכח/י langfuse.flush() בסוף. פתח/י את ה-Trace View ב-Langfuse. למה הצעד הזה חשוב: flush() דוחף את ה-spans לשרת — בלעדיו, תהליך שמסתיים מהר מאבד trace שלם. פלט צפוי: בטרמינל — ✓ Trace flushed: trace_id=tr_...; ב-Tracing tab ב-Langfuse — trace חדש עם spans מקוננים.

פלט נראה לעין: ב-Langfuse Trace View אתה רואה trace אחד ("agent-run") עם עץ spans — turn-0, turn-1, tool:read_file מקונן תחת turn-0, tool:write_file מקונן תחת turn-1. כל span מציג duration בms ו-token count. הפלט ב-terminal נראה כך: ✓ Trace flushed: trace_id=tr_a1b2c3 · 3 spans · total_tokens=1240 · 2 tool_calls. צלם/י מסך של ה-Trace View — זה ה-baseline שכל שאר הפרק נבנה עליו.

Token cost attribution ו-Latency profiling

practicalcostlatency

עכשיו שיש לך spans עם token usage, אתה יכול לענות על השאלה ששרפה לך 40,000 tokens בלי הסבר: איזה שלב היה יקר, ולמה? זה נקרא token cost attribution — ייחוס עלות ה-tokens לכל turn ו-tool call ספציפי, במקום מספר אחד גדול בסוף.

הקשר לפרק 3 ישיר: שם למדת למדוד input/output/cache tokens מתוך תגובת ה-SDK. כאן אתה לוקח את אותם מספרים ורושם אותם לכל span בנפרד. הסכימה הזו:

# ייחוס עלות לכל turn — מתבסס על token accounting מ-ch3
PRICE_PER_MTOK = {"input": 3.00, "output": 15.00, "cache_read": 0.30}  # USD/million, דוגמה מייצגת

def cost_of_turn(usage) -> float:
    return (usage.input_tokens   * PRICE_PER_MTOK["input"]
          + usage.output_tokens  * PRICE_PER_MTOK["output"]
          + usage.cache_read     * PRICE_PER_MTOK["cache_read"]) / 1_000_000

# בכל turn, מצרפים את העלות ל-span כ-metadata
gen.end(output=response.content,
        usage={"input": u.input_tokens, "output": u.output_tokens},
        metadata={"cost_usd": cost_of_turn(u),
                  "cache_hit_ratio": u.cache_read / max(u.input_tokens, 1)})

טעות נפוצה: לקבע מחירים בקוד

המחירים ב-PRICE_PER_MTOK למעלה הם דוגמה מייצגת, לא ציטוט מחיר רשמי. מחירי tokens משתנים, ומפתח ישראלי צריך גם לקחת בחשבון שער ILS/USD ו-VAT (מתוך course.research.json, local_market_notes). אל תקבע/י את המספרים האלה בקוד כ"אמת" — שלוף/י מ-config שאפשר לעדכן, ואמת/י את המחיר העדכני מול דף התמחור הרשמי לפני שאתה מציג עלות למישהו. בנוסף, החל מ-15 ביוני 2026, מנויי Claude מקבלים ה"Agent SDK credits" נפרדים — כלומר עלות agent run לא נספרת מהמכסה האינטראקטיבית. הפרדה זו משפיעה על אופן ייחוס עלות ב-cost_attribution אם אתה משתמש ב-subscription plan (מתוך course.research.json, key_2026_updates).

Latency profiling הוא הצד השני של אותו מטבע. לכל span כבר יש זמן התחלה וסיום, אז ה-latency "חינם" — אבל הערך הוא בפילוח. שאלת המפתח: האם ה-latency הוא network או CPU? tool שקורא ל-API חיצוני איטי בגלל הרשת; tool שמריץ עיבוד כבד מקומית איטי בגלל ה-CPU. ההבחנה משנה את הפתרון:

תסמין ב-traceסיבה סבירהפתרון (חלקו מפרק 4)
span של tool עם latency גבוה, אבל מעט CPUnetwork — קריאה ל-API חיצוני איטיparallel tool calls + caching (ch4)
כמה tool calls עצמאיים בטור, סך הזמן = הסכוםהרצה סדרתית של קריאות שאינן תלויותהרץ/י במקביל — ה-latency יורד לזמן של האיטי ביותר
turn (generation) ארוך עם הרבה input tokenscontext מנופח — היסטוריה גדולה מדיcontext management + distillation (ch3)
אותו tool call חוזר עם אותו inputאין caching — או לולאה (הסעיף הבא!)tool result caching (ch4) / circuit breaker

בוא נראה מספרים, כי כאן ה-cost attribution מוכיח את עצמו. נניח ריצה של 8 turns שעלתה $0.48 בסך הכל. בלי attribution, זה כל מה שאתה יודע. עם attribution, ה-trace מראה ש-turn 5 לבדו עלה $0.21 — כמעט חצי מהריצה. למה? כי בדיוק לפניו tool החזיר 40KB של JSON ישר ל-context (זוכר/ת את context budget מפרק 3?), וה-turn הבא נשא את כל ה-40KB כ-input tokens. בלי attribution היית מחפש את הבעיה בכל מקום; עם attribution, ה-trace מצביע ישר על ה-turn האשם ועל ה-tool שלפניו. התיקון — להחזיר reference במקום ה-JSON המלא (ch3) — חוסך את ה-$0.21 בכל ריצה עתידית. זה ההבדל בין ניחוש לניתוח.

שים/י לב גם ל-cache_hit_ratio שרשמנו ל-span. ה-Claude Agent SDK תומך ב-prompt caching — חלקים יציבים מההיסטוריה (system prompt, הוראות קבועות) נקראים מ-cache במחיר מוזל משמעותית. cache hit ratio נמוך לאורך זמן הוא דגל אדום: כנראה ה-system prompt או סדר ההודעות משתנים בכל turn ושוברים את ה-cache. ה-trace חושף את זה — ושיפור ה-cache hit ratio הוא לרוב הדרך הזולה ביותר להוריד עלות, בלי לגעת בלוגיקה.

Framework: לאתר בעיית עלות בתוך 3 דקות — אם X אז Y

כשמגיע חשבון גבוה ולא מובן, ה-trace נותן תשובה שיטתית:

השורה האחרונה בטבלת ה-latency היא הגשר לסעיף הבא. כש-tool call חוזר עם אותו input, יש שתי אפשרויות: או ששכחת caching (בזבוז), או שהסוכן נתקע בלולאה (כשל). ה-trace מראה לך את התסמין; עכשיו נבנה את המנגנון שמבחין ועוצר.

עשה/י עכשיו (90 שניות)

קח/י את ה-trace מתרגיל 1. מיין/י את ה-spans לפי משך זמן (Langfuse מאפשר). מה ה-span הכי איטי? עכשיו הסתכל/י על ה-metadata: זה network (tool חיצוני) או CPU (עיבוד מקומי)? רשום/י לעצמך — בריצה אמיתית התשובה הזו היא ההבדל בין "להוסיף caching" ל"לפרק את ה-tool". אם הspan האיטי הוא generation (turn), ולא tool call — זה דגל שה-context מנופח, לא שה-tool איטי.

Loop detection — ה-failure הקלאסי

theorypracticalrecovery

הגענו ל-failure הכי נפוץ, הכי יקר, והכי קל לפספס: הסוכן קורא לאותו tool, עם אותו input, שוב ושוב. במחקר של הקורס זה מתואר כ"agents looping 6+ times on a single tool call" (מתוך course.research.json, gotchas) — וזה לא מקרה קצה, זו ההתנהגות הברירת-מחדל של סוכן שנתקל בכשל שהוא לא "מבין".

הדינמיקה כך: הסוכן קורא ל-search("X"), מקבל שגיאה או תוצאה ריקה, "חושב" שאולי הוא טעה, וקורא שוב ל-search("X") — בדיוק אותו דבר. שוב שגיאה. שוב קריאה. כל סבב כזה הוא turn מלא: קריאה למודל (input = כל ההיסטוריה שגדלה!), עיבוד, קריאת tool. הלולאה הזו לא רק לא מתקדמת — היא מאיצה, כי כל סבב מנפח את ה-context (פרק 3) ומקרב אותך ל-compaction. סוכן בלולאה הוא סוכן שמדמם budget ומאבד היסטוריה בו-זמנית.

דוגמה קונקרטית: נניח שה-search tool מחזיר {"results": []} כשאין תוצאות. הסוכן ראה "רשימה ריקה" ומנחש שבקשתו לא מספיק טובה. הוא מנסח מחדש — אבל את אותה שאלה בדיוק עם מילים שונות מעט. תוצאה: 6 קריאות שונות קלות שכולן חוזרות על {"results": []}. כל קריאה מוסיפה עוד turn להיסטוריה. turn 1: 800 input tokens. turn 7: 4,200 input tokens — כי ההיסטוריה של ששת הקריאות הקודמות נמצאת שם. העלות מצטברת לא ליניארית.

טעות נפוצה: לסמוך רק על max-turns כהגנה מלולאות

max-turns מפרק 2 הוא בלם חשוב — אבל הוא תופס את הקצה, לא את הלולאה. אם max-turns=30 והסוכן נתקע בלולאה ב-turn 4, הוא ישרוף 26 turns מיותרים לפני שהבלם נכנס — וכל turn יקר יותר מקודמו כי ההיסטוריה תפחה. circuit-breaker על קריאה חוזרת תופס את הלולאה כבר ב-turn 6-7, וזול בהרבה (מתוך course.research.json, common_mistakes לפרק זה). max-turns ו-circuit-breaker עובדים יחד: האחד תופס ריצה ארוכה לגיטימית שחרגה, השני תופס לולאה פתולוגית מוקדם.

איך מזהים לולאה? המפתח הוא signature — חתימה שמזהה "אותה קריאה". הנאיבי הוא להשוות שם tool בלבד, אבל זה תופס false positives (סוכן שקורא ל-read_file על 5 קבצים שונים זה לגיטימי!). הנכון הוא חתימה של שם ה-tool + ה-input המנורמל:

import hashlib, json

def tool_signature(call) -> str:
    # נרמול: אותו tool + אותו input = אותה חתימה
    canonical = json.dumps(call.input, sort_keys=True, ensure_ascii=False)
    raw = f"{call.name}::{canonical}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]

נרמול ה-input (sort_keys=True) חשוב: {"q":"X","lang":"he"} ו-{"lang":"he","q":"X"} הם אותה קריאה לוגית, וצריכים אותה חתימה. עם החתימה ביד, ה-circuit breaker הוא פשוט מונה.

יש כאן עדינות אחת ששווה לדעת: לולאה לא תמיד מדויקת לחלוטין. לפעמים הסוכן קורא ל-search("מחיר Claude"), נכשל, ואז קורא ל-search("מחיר Claude API") — input קצת שונה, אבל אותה לולאה לוגית. החתימה המדויקת לא תתפוס את זה, כי ה-input השתנה במילימטר. לרוב ה-harnesses, חתימה מדויקת מספיקה (היא תופסת את 90% מהמקרים — הסוכן בדרך כלל חוזר על בדיוק אותו דבר). אבל אם אתה רואה ב-trace לולאות "כמעט-זהות" שחומקות, אפשר לשדרג ל-fuzzy signature — למשל לחשב חתימה על שם ה-tool בלבד עם סף גבוה יותר (8-10 קריאות לאותו tool גם עם input משתנה). התחל/י מהמדויק; שדרג/י רק אם ה-trace מראה שאתה צריך.

Circuit breaker בקוד — לספור, להזהיר, לעצור

practicalcircuit-breakercode

ה-circuit breaker (מפסק) הוא דפוס שמושאל מהנדסת מערכות: כשרכיב נכשל שוב ושוב, "מנתקים" אותו במקום להמשיך לקרוא לו. ב-harness שלנו: סופרים כמה פעמים אותה חתימה הופיעה, ומגיבים בשני ספים — אזהרה רכה (3), עצירה קשה (6). שני הספים מכוונים: 3 כדי לתפוס מוקדם בלי false positive (לפעמים שתי קריאות זהות לגיטימיות), 6 כי זה הסף ש-course.research.json מזהה כ"לולאה" (6+ קריאות).

from collections import Counter

class CircuitBreaker:
    def __init__(self, warn_at=3, trip_at=6):
        self.counts = Counter()
        self.warn_at, self.trip_at = warn_at, trip_at

    def check(self, call, trace=None):
        sig = tool_signature(call)
        self.counts[sig] += 1
        n = self.counts[sig]

        if n == self.warn_at:
            # אזהרה רכה: מזריקים למודל הודעה שהקריאה חוזרת
            if trace: trace.event(name="loop-warning",
                                  metadata={"tool": call.name, "count": n})
            return ("warn", f"קראת ל-{call.name} עם אותו input {n} פעמים. "
                            f"נסה גישה אחרת או החזר תשובה סופית.")

        if n >= self.trip_at:
            # עצירה קשה: לא מריצים את ה-tool, עוצרים את הריצה
            if trace: trace.event(name="circuit-tripped", level="ERROR",
                                  metadata={"tool": call.name, "count": n})
            raise CircuitTripped(tool=call.name, signature=sig, count=n)

        return ("ok", None)

שים לב להבדל בין שני הספים — זה לב העניין. ב-3 קריאות אנחנו לא עוצרים, אלא מזריקים feedback למודל: "אתה חוזר על עצמך, נסה אחרת". לפעמים זה מספיק כדי לשבור את הלולאה — המודל לא ידע שהוא תקוע. זו גישה של self-healing רכה. רק ב-6 קריאות, כשברור שהמודל לא מצליח לצאת לבד, אנחנו עושים hard-stop — זורקים CircuitTripped ועוצרים. ההבחנה הזו בין self-healing ל-hard-stop תחזור עוד מעט כעיקרון.

החיבור ללולאה: ה-check() נכנס לפני execute_tool() בלולאה מפרק 2 — בדיוק במקום שבו בפרק 4 שמת את ה-policy gate. למעשה, circuit-breaker הוא סוג של gate: gate על תדירות במקום על תוכן.

for call in tool_calls:
    status, feedback = breaker.check(call, trace=trace)   # ה-gate החדש
    if status == "warn":
        messages.append(system_nudge(feedback))           # מזריקים feedback
        continue                                          # לא מריצים את ה-tool הפעם
    span = trace.span(name=f"tool:{call.name}", input=call.input)
    result = execute_tool(call)
    span.end(output=result, metadata={"error": result.is_error})
    messages.append(tool_result_block(call, result))

תרגיל 2: לבנות circuit breaker ולתפוס לולאה אמיתית

מטרה: לראות את ה-breaker עוצר לולאה — ואת ההבדל בין אזהרה לעצירה.

  1. ממש/י את tool_signature() ואת מחלקת CircuitBreaker מהקוד למעלה. פלט צפוי: import עובר; tool_signature({"q":"X","lang":"he"}) מחזיר מחרוזת hash בת 16 תווים.
  2. צור/י לולאה מלאכותית: tool שתמיד מחזיר שגיאה (למשל search שמחזיר "no results" תמיד), ו-prompt שמעודד את הסוכן לחפש שוב. פלט צפוי: בלוג רואים שהסוכן קורא ל-search ברצף (3+ פעמים לפני שה-breaker מתערב).
  3. חבר/י את ה-breaker.check() ללולאה לפני ה-execute, לפי הקוד. פלט צפוי: הקוד רץ; הדפסות ה-debug מראות את שני הספים (3 ו-6) בזמן אמת.
  4. הרץ/י. צפה/י: אזהרה ב-3, feedback למודל, ועצירה קשה ב-6 עם CircuitTripped. פלט צפוי: warn בדיוק בקריאה ה-3; CircuitTripped בדיוק בקריאה ה-6 (לא ב-5, לא ב-7).
  5. פתח/י את Langfuse: ה-events loop-warning ו-circuit-tripped צריכים להופיע מסומנים על ה-trace. פלט צפוי: ה-trace מסומן ב-ERROR (אדום); שני ה-events נראים בציר הזמן של ה-spans.

פלט נראה לעין: ב-terminal הריצה מדפיסה ⚠ loop-warning: search · count=3 · injecting feedback ב-turn 3, ואז עוצרת ב-turn 6 עם ✗ CircuitTripped(tool="search", count=6, sig="a3f1b2c4d5e6f7a8"). ב-Langfuse ה-trace מסומן ב-ERROR (אדום) עם שני ה-events גלויים בציר הזמן. השווה/י את tokens_burned שנכתב ב-FailureRecord מול ריצה בלי breaker עם max-turns=30 — ה-circuit-breaker צריך לחסוך לפחות 60%-70% מהעלות.

Failure capture — לתפוס כשל כמו אזרח מהשורה הראשונה

theorypracticalfailure-capture

circuit breaker עוצר לולאה — אבל מה עושים עם הכשל? כאן נכנס failure capture: במקום לזרוק exception ש"נעלם" ל-stderr, תופסים כל כשל כרשומה מובנית. זה לא קישוט — זו תשתית. במחקר של הקורס, זהו דפוס מרכזי של Aden Hive, ה-harness הרב-סוכני open-source (~10.5k stars ב-GitHub), שמתואר עם "failure capture" כיכולת מובנית (מתוך course.research.json, features). ב-Aden Hive, failure הוא first-class citizen — אזרח מהשורה הראשונה — לא תקלה שמטאטאים מתחת לשטיח.

למה מבנה ולא טקסט? כי רשומה מובנית אפשר לתחקר, לספור, ולהזין בחזרה למערכת. וזה הגשר הישיר לפרק 7: ה-failures שאתה תופס כאן הם חומר הגלם של Dreaming — תהליך ה-self-improvement האסינכרוני של Claude Managed Agents שמשפר זיכרון על בסיס transcripts (מתוך course.research.json, features, "Claude Dreaming"). בלי failure capture מובנה, אין מה ל-Dreaming לעבד.

דוגמה קונקרטית לפני הקוד. נניח שהסוכן קורא ל-search("מחיר מניית אפל") ומקבל timeout. במקום שייזרק exception וייעלם ל-stderr, capture_failure בונה רשומה אחת: failure_type="transient", tool_name="search", error_message="ConnectionError: timed out after 30s", input_snapshot={"q": "מחיר מניית אפל"}, turn_number=4, token_cost_so_far=0.07. חמשת השדות האלה (לא סיפור, לא traceback) נשמרים גם ב-failure_store (לתחקור ול-Dreaming) וגם כ-event ב-Langfuse (לראייה מיידית). הרשומה המינימלית שמייצרת את זה:

from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone

@dataclass
class FailureRecord:
    failure_type: str          # loop / tool_error / schema_violation / policy_block / timeout
    tool_name: str | None
    error_message: str
    input_snapshot: dict       # מה הסוכן ניסה לעשות
    context_summary: str       # תקציר state — לא כל ההיסטוריה (זוכר/ת context budget מ-ch3)
    turn_number: int
    token_cost_so_far: float   # מ-cost attribution — כמה כבר נשרף
    timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())

def capture_failure(exc, call, turn, trace, cost_tracker) -> FailureRecord:
    rec = FailureRecord(
        failure_type=classify_failure(exc),
        tool_name=getattr(call, "name", None),
        error_message=str(exc),
        input_snapshot=getattr(call, "input", {}),
        context_summary=summarize_recent_messages(messages, last_n=3),  # קצר!
        turn_number=turn,
        token_cost_so_far=cost_tracker.total(),
    )
    # נרשם גם ל-trace (לראות) וגם ל-store (לזכור — ch7)
    if trace: trace.event(name="failure-captured", metadata=asdict(rec))
    failure_store.append(asdict(rec))
    return rec

שני פרטים שמבדילים failure capture טוב מ-except: pass. ראשית, context_summary הוא תקציר של 3 ההודעות האחרונות, לא כל ההיסטוריה — אם תשמור את כל ה-context בכל רשומת כשל, תנפח את ה-store ותשלם פעמיים (פעם על הריצה, פעם על האחסון). שנית, token_cost_so_far מגיע מ-cost attribution של הסעיף הקודם — וזה מה שיאפשר לך אחר כך לשאול "כמה עולים לי הכשלים מסוג X?".

עשה/י עכשיו (60 שניות)

ענה/י על שאלה אחת לפני שמממשים: ב-harness שלך, אילו סוגי כשלים אתה כבר מטפל בהם (ולו עם except:)? רשום/י 2-3 מהם. עכשיו בדוק/י: כל אחד מהם — מה הוא מחזיר? exception שנזרק? הודעת לוג? ערך ברירת מחדל? אם התשובה היא "אני לא בטוח" — זו בדיוק הבעיה. classify_failure מחייב ידיעה ברורה מה הסוגים.

Framework: סיווג כשל — אם X אז Y

הסיווג (classify_failure) קובע את ה-recovery. ההיגיון הדטרמיניסטי:

מדיניות recovery — retry, fallback, escalate, stop

theorypracticalrecovery-policy

עכשיו מחברים הכל למדיניות recovery אחת: בהינתן רשומת כשל מסווגת, מה עושים? יש ארבע תגובות אפשריות, וההבחנה ביניהן היא ההבדל בין harness שמתאושש ל-harness שמדמם.

תגובהמתימה זה עושהסכנה אם טועים
retrytransient (network, 5xx, timeout)מנסה שוב, עם backoff מעריכי, עד N פעמיםאם הכשל לא transient — זו לולאה ששורפת budget
retry + feedbackschema violationמחזיר למודל את השגיאה, מבקש תיקון (ch4)אם המודל לא יכול לתקן — לולאה. הגבל/י ל-2-3 ניסיונות
fallbacktool/מודל נכשל שוב ושובמסלול חלופי: tool אחר, prompt פשוט יותרfallback שגם נכשל = שני כשלים. הגדר/י fallback אחד ברור
escalate / stoploop, policy, permanentעוצר, קורא לאדם או נכשל בנקיון עם דיווחescalation על כל דבר = אין אוטונומיה. שמור/י לכשלים אמיתיים

הלב של ה-retry gate הדטרמיניסטי הוא ההבחנה transient מול permanent. retry על כשל transient = חכם (הרשת חזרה, ההמשך עובד). retry על כשל permanent = בדיוק הלולאה ששורפת budget (מתוך course.research.json, common_mistakes לפרק זה: "לעשות retry על כל כשל באופן עיוור"). ההבחנה היא דטרמיניסטית — מבוססת סוג השגיאה, לא שיפוט של LLM:

import time

def recover(rec: FailureRecord, attempt: int, max_retries=3):
    # retry gate דטרמיניסטי — לא LLM שיפוטי, רק לוגיקה על סוג הכשל
    if rec.failure_type == "transient" and attempt < max_retries:
        time.sleep(2 ** attempt)          # exponential backoff: 1s, 2s, 4s
        return Action.RETRY

    if rec.failure_type == "schema" and attempt < 3:
        return Action.RETRY_WITH_FEEDBACK  # ה-pattern מ-ch4

    if rec.failure_type in ("transient", "schema"):
        # מיצינו retries — ננסה מסלול חלופי לפני שמוותרים
        return Action.FALLBACK

    if rec.failure_type in ("loop", "permanent"):
        return Action.ESCALATE             # retry כאן = לולאה. לא עושים

    if rec.failure_type == "policy":
        return Action.HARD_STOP            # gate חסם — לא עוקפים. לעולם

    return Action.ESCALATE                  # ברירת מחדל: כשלא בטוחים, קוראים לאדם

שים/י לב ל2 ** attemptexponential backoff. אם הרשת נפלה, להציף אותה ב-retries מיידיים רק מחמיר. backoff מעריכי (1s, 2s, 4s) נותן למערכת להתאושש ומפזר עומס. זה סטנדרט בכל retry של רשת.

שים/י לב גם לברירת המחדל בשורה האחרונה: return Action.ESCALATE כשלא מזהים את סוג הכשל. זה עקרון הנדסי חשוב — כשלא בטוח, escalate לאדם. harness שממשיך לפעול עם כשל לא-מזוהה עשוי לעשות נזק שקשה לזהות. כשלא יודעים — מבקשים עזרה, לא ממשיכים בעיוורון.

טעות נפוצה: retry עיוור על כל כשל

הקוד הכי מסוכן הוא for _ in range(5): try: do() except: continue. נראה תמים, אבל אם הכשל לוגי (4xx, bad request, אותו input שתמיד ייכשל) — אתה בדיוק בנית את הלולאה שאת ה-circuit-breaker בנינו כדי לעצור. retry בלי סיווג הוא לא recovery, הוא לולאה עם איפור. הסיווג מ-classify_failure הוא מה שהופך retry מסכנה לכלי. דגל אדום נוסף: retry בלי backoff. ל-API שנפל, פגיעה ב-rate limit, או שרת עמוס — retries מיידיים לא עוזרים, הם מחמירים את הבעיה ועלולים לגרום ל-429 (Too Many Requests) שמוסיף עוד סוג כשל חדש. (מתוך course.research.json, common_mistakes לפרק זה.)

Escalation, self-healing מול hard-stop

theoryescalationhuman-in-the-loop

הגענו לשאלה הפילוסופית-מעשית של הפרק: מתי ה-harness מתקן את עצמו, ומתי הוא חייב לעצור ולקרוא לאדם? ההבחנה בין self-healing ל-hard-stop היא לא טכנית בלבד — היא החלטה על כמה אוטונומיה אתה נותן.

self-healing (ריפוי עצמי) זה כשה-harness פותר את הבעיה בלי אדם: schema retry שמתקן output לא-תקין, transient retry שמחכה לרשת, loop-warning שמזריק feedback והמודל מתאושש. hard-stop זה כשה-harness עוצר בכוונה: policy violation (gate חסם — לעולם לא עוקפים), loop שלא נשבר, או כשל permanent חוזר. ביניהם יושב escalation — עצירה שקוראת לאדם להחליט, במקום להיכשל בשקט.

ה-escalation מתחבר ישירות ל-human-approval gate מפרק 4. שם בנית gate שמשהה את הריצה עד אישור אנושי בנקודה רגישה (לפני שליחת email). אותו מנגנון בדיוק משמש כאן ל-recovery — רק שהטריגר שונה: בפרק 4 הטריגר היה "פעולה רגישה", כאן הטריגר הוא "כשל שה-harness לא יודע לפתור לבד". זה לא מקרה — גם governance וגם recovery מתכנסים לאותה תשתית של "השהה והמתן לאדם". תשתית זו כדאי לממש פעם אחת כ-abstraction נפרד ולהשתמש בה בשני המקרים.

def escalate(rec: FailureRecord, channel):
    # אותו מנגנון human-in-the-loop מ-ch4, טריגר שונה: כשל במקום פעולה רגישה
    channel.notify(
        title=f"🛑 Harness escalation: {rec.failure_type}",
        body=f"tool={rec.tool_name} · turn={rec.turn_number} · "
             f"cost_so_far=${rec.token_cost_so_far:.2f}\n"
             f"error: {rec.error_message}",
        actions=["retry_manual", "skip_step", "abort_run"],
    )
    # הריצה מושהית עד שאדם בוחר — לא נכשלת בשקט, לא ממשיכה לבד
    return channel.wait_for_decision(timeout_hours=4)

שים/י לב לשדה cost_so_far בגוף ה-escalation. כשאדם מקבל הודעה "הסוכן נתקע", ידיעת העלות שכבר נשרפה משנה את ההחלטה. "כשל ב-turn 2, עלות $0.02 עד כה" → אולי נסה שוב. "כשל ב-turn 22, עלות $1.40 עד כה" → כנראה עדיף לבטל ולנסח מחדש. ה-cost attribution מהסעיף הראשון הוא זה שמאפשר את ה-escalation הזה להיות מושכל — לא רק "משהו נכשל" אלא "משהו נכשל וכבר עלה X".

שיקולי אבטחה כשחושפים עלות לבני אדם

ה-escalation שולח לערוץ חיצוני (Slack / email / PagerDuty) שילוב של cost_so_far, error_message, ו-input_snapshot. זה חושף שלושה סיכונים שכדאי לסגור לפני הפרודקשן: (1) מידע פיננסי — סכומי USD/ILS בפנאל שלא כולם צריכים לראות; הגבל/י את ה-channel לבעלי תפקיד "budget owner". (2) PII דלףinput_snapshot עלול להכיל email/שם/כתובת של משתמש; השתמש/י ב-summarize_recent_messages() או mask לפני שליחה. (3) סודות ב-error_message — traceback שלם לפעמים כולל token שדלף או URL חתום; חתוך/י ל-200 תווים ראשונים ולא תשלח/י traceback גולמי. כלל אצבע: מה שלא היית שולח ב-Slack ללקוח — אל תשלח/י ב-escalation.

Framework: self-heal, escalate, או hard-stop — אם X אז Y

תרגיל 3: לחבר failure capture + recovery policy מלאה

מטרה: harness שתופס כשל, מסווג, ומחליט retry/fallback/escalate — אוטומטית.

  1. ממש/י את FailureRecord, capture_failure, classify_failure ו-recover מהקוד למעלה. פלט צפוי: import עובר; classify_failure(TimeoutError()) מחזיר "transient"; classify_failure(ValidationError(...)) מחזיר "schema".
  2. עטוף/י את ה-execute_tool() בלולאה ב-try/except שקורא ל-capture_failure בכשל. פלט צפוי: הקוד רץ; כל כשל נכנס ל-capture_failure ול-failure_store (אפשר לוודא בהדפסת len(failure_store)).
  3. צור/י שלושה תרחישי כשל: (א) tool עם timeout אקראי (transient), (ב) tool שמחזיר JSON שבור (schema), (ג) ה-circuit-tripped מתרגיל 2 (loop). פלט צפוי: שלוש ריצות נפרדות, אחת לכל תרחיש; classify_failure מחזיר את הסוג הנכון בכל אחת.
  4. הרץ/י כל אחד וצפה/י ב-recover() מחליט נכון: retry עם backoff על (א), retry+feedback על (ב), escalate על (ג). פלט צפוי: recover() מחזיר Action.RETRY / Action.RETRY_WITH_FEEDBACK / Action.ESCALATE בהתאמה — בדיוק אחד לכל תרחיש.
  5. פתח/י את failure_store: צריכות להיות שלוש רשומות מובנות, כל אחת עם type, cost ו-turn. פלט צפוי: len(failure_store) == 3; כל רשומה היא dict עם מפתחות failure_type, tool_name, error_message, turn_number, token_cost_so_far (לא מחרוזת חופשית).

פלט נראה לעין: בלוג ה-terminal מופיעות שלוש שורות — [recovery] transient → RETRY (backoff 1s, attempt 1/3), [recovery] schema → RETRY_WITH_FEEDBACK · injecting validation error, [recovery] loop → ESCALATE · cost_so_far=$0.18. קובץ failure_store.json מכיל שלוש רשומות JSON מובנות, כל אחת עם שדות failure_type, tool_name, error_message, turn_number, ו-token_cost_so_far. זה ה-recovery policy עובד מקצה לקצה.

מדידת אמינות — איך יודעים שה-recovery עובד

practicalmetricsreliability

בנית tracing, circuit-breaker, failure capture ו-recovery policy. אבל איך אתה יודע שזה עובד? כאן חוזרים ל-observability — לא כדי לדבג ריצה בודדת, אלא כדי למדוד אמינות לאורך זמן. שלוש מטריקות הן המינימום:

מטריקהמה היא מודדתמאיפה (מה-trace)יעד בריא
completion rate% ריצות שהשלימו בלי escalate/hard-stoptrace.output קיים מול trace שנעצרגבוה ועולה לאורך זמן
ממוצע retries לריצהכמה ניסיונות חוזרים בממוצעספירת events "retry" / מספר tracesנמוך. עלייה = משהו נשבר
עלות לכשלכמה tokens נשרפו לפני שכשל נתפסtoken_cost_so_far ברשומותנמוך — circuit-breaker מוקדם מוריד את זה
loop rate% ריצות שהפעילו circuit-breakerevents "circuit-tripped"שואף לאפס. גבוה = בעיה ב-prompt/tools

הערך האמיתי הוא במגמה, לא בנקודה. completion rate שעולה מ-70% ל-90% אחרי ששיפרת tool מסוים — זה ה-recovery עובד. עלות-לכשל שיורדת אחרי שהזזת circuit-breaker מ-6 ל-5 — זה חיסכון מדיד. בלי ה-tracing מהתחלת הפרק, אף אחת מהמטריקות האלה לא קיימת; עם ה-tracing, הן בחינם — רק צריך לאסוף ולחשב.

איך אוספים את המטריקות בפועל? Langfuse מאפשר לבנות dashboard מ-traces קיימים. הגישה הפשוטה: סכם traces לפי שדות שכבר רשמת. לאסוף completion rate, שאל: "כמה traces מסתיימים עם trace.output מול כאלה שמסתיימים עם event של circuit-tripped או failure-captured?" הנתון הזה נמצא ב-Langfuse ללא כתיבת קוד נוסף — כי רשמת את ה-events הנכונים בתרגילים 1-3.

Framework: לתחקר loop rate גבוה — אם X אז Y

loop rate מעל 10% הוא דגל אדום. הגישה השיטתית לתחקור:

עשה/י עכשיו (2 דקות)

קח/י את ה-traces שצברת בתרגילים 1-3. חשב/י ידנית: כמה ריצות השלימו? כמה נעצרו? מה העלות הממוצעת לכשל? זה ה-baseline שלך. ב-Langfuse אפשר לבנות dashboard שעושה את זה אוטומטית — אבל גם החישוב הידני הזה כבר אומר לך אם ה-harness שלך אמין או מדמם. רשום/י את שלושת המספרים — completion rate, ממוצע retries, ו-loop rate — ב-comment בראש קובץ ה-harness. זה ה-baseline שתשווה אליו בפרק 6.

שגרת עבודה: ה-loop של observability + recovery

כשאתה בונה או מתחזק harness, השגרה היא לא "פעם בסוף" אלא לולאה קבועה:

  1. כל ריצה → trace. אף ריצה לא רצה בלי trace. flush בסוף, תמיד.
  2. כל כשל → רשומה מובנית. לא except: pass. failure capture עם סיווג.
  3. כל לולאה → circuit-breaker. חתימה, מונה, אזהרה ב-3, עצירה ב-6.
  4. שבועית → סקירת מטריקות. completion rate, ממוצע retries, loop rate. מחפש/ת מגמות, לא נקודות.
  5. loop rate עולה? → חקור/י ב-Langfuse. סנן/י לפי event "circuit-tripped", מצא/י את ה-tool/prompt האשם, תקן/י את השורש — לא את התסמין.

בדיקה עצמית

checkreview

בדוק/י את עצמך — 5 שאלות

  1. מבנה Langfuse: מה ההבדל בין trace, span ו-generation, ולמה דווקא generation הוא זה שמאפשר cost attribution?
  2. circuit-breaker מול max-turns: שניהם עוצרים ריצות. למה לא להסתפק ב-max-turns? תן/י מספר — בכמה turns circuit-breaker תופס לולאה לעומת max-turns=30?
  3. signature: למה חתימת הלולאה מנרמלת את ה-input (sort_keys) ולא משווה רק את שם ה-tool? תן/י דוגמה ל-false positive שזה מונע.
  4. retry gate: מתי retry הוא חכם ומתי הוא בדיוק הלולאה ששורפת budget? איך ה-classify_failure מבדיל דטרמיניסטית בין השניים?
  5. escalation: איך ה-escalation בפרק הזה מתחבר ל-human-approval gate מפרק 4 — מה זהה ומה שונה ביניהם? למה כדאי לממש את שניהם כ-abstraction אחד?

Just One Thing

אם תיקח/י דבר אחד מהפרק: harness בלי trace הוא ניחוש, ו-retry בלי סיווג הוא לולאה עם איפור. קודם תן ל-harness עיניים (trace לכל ריצה), אחר כך רשת ביטחון (circuit-breaker שעוצר לולאה ב-6, ו-recovery שמבדיל transient מ-permanent). שתי השכבות האלה הן מה שהופך סוכן "שלפעמים עובד" לסוכן שאתה יכול לסמוך עליו — ולדבג אותו כשלא.

מילון מונחים

Observability (נראוּת)
היכולת לענות על "מה באמת קרה בריצה?" בלי לנחש — דרך logs, metrics ו-traces. ב-harness לסוכן, ה-trace הוא הרכיב הקריטי, כי הוא שומר את הרצף ההיררכי של ריצה שלמה. logs מספרים "מה קרה"; traces מספרים "מה הוביל למה".
Langfuse
פלטפורמת observability open-source ל-LLM applications, עם tier חינמי (SaaS ו-self-hosted). מספקת trace/span/generation ו-token cost attribution. שכבת ה-tracing המומלצת בקורס — מבינה מודלים של עולם הסוכנים מהקופסה, בניגוד לכלי tracing כלליים שדורשים configuration ידני של כל שלב.
trace
יחידת עבודה אחת מקצה לקצה — ב-harness שלנו, ריצת task אחת מלאה מהזנת ה-goal עד תוצאה סופית (או עצירה). כל ה-spans מתקבצים תחתיה. trace אחד = שאלה אחת ל-Langfuse "מה קרה בריצה הזו?"
span
פעולה בודדת בתוך trace, עם זמן התחלה/סיום ומטא-דאטה חופשי. ב-harness: turn (קריאה למודל) או tool call. spans יכולים להיות מקוננים — tool call הוא span בתוך span של turn.
generation
span מיוחד לקריאת LLM, שרושם input messages, output content ו-token usage (input/output/cache). כאן נרשם ה-token cost לכל turn. בלי generation, אין cost attribution — רק event logs שטוחים.
token cost attribution
ייחוס עלות ה-tokens לכל turn ו-tool call ספציפי, במקום מספר כולל בסוף הריצה. מאפשר לזהות איזה שלב יקר ולמה — כגון turn שנשא 40KB של JSON מ-tool שלפניו.
latency profiling
פילוח זמני הריצה לכל span, והבחנה אם ה-latency נובע מ-network (API חיצוני) או מ-CPU (עיבוד מקומי). הבחנה זו קריטית כי הפתרון שונה — caching לרשת, אופטימיזציה או פיצול ל-CPU.
cache hit ratio
יחס ה-tokens שנקראו מ-prompt cache לעומת סך ה-input tokens. יחס נמוך (<30%) לאורך זמן מעיד שה-system prompt או מבנה ההודעות משתנים ושוברים את ה-cache — ומייקרים כל turn ללא צורך.
loop detection
זיהוי שהסוכן קורא לאותו tool עם אותו input שוב ושוב (6+ פעמים) — ה-failure הקלאסי שמדמם budget ומנפח context בו-זמנית. ה-detection מבוסס tool_signature, לא השוואת שם בלבד.
tool signature (חתימה)
hash קצר (16 תווים) של שם ה-tool + ה-input המנורמל (sort_keys=True), שמזהה "אותה קריאה לוגית" לצורך זיהוי לולאה. נרמול מפתחות מונע false positives ממה שנראה כ-input שונה אבל לוגית זהה.
circuit breaker (מפסק)
רכיב שסופר כמה פעמים אותה tool_signature הופיעה. אזהרה (warn) בסף נמוך (3) — מזריק feedback למודל; עצירה קשה (trip) בסף גבוה (6) — זורק CircuitTripped. תופס לולאה מוקדם וזול בהרבה ממה שמאפשר max-turns=30 לעשות.
failure capture
תפיסת כל כשל כרשומה מובנית (FailureRecord) עם type, input, error, context קצר, turn, ו-cost. דפוס מרכזי ב-Aden Hive (~10.5k stars); חומר הגלם ל-Dreaming (פרק 7). הפוך לאזרח מהשורה הראשונה — לא exception שנעלם.
classify_failure
פונקציה דטרמיניסטית שממפה exception לסוג כשל (transient/schema/loop/policy/permanent) בהתבסס על קוד הסטטוס או סוג ה-exception. הפלט שלה קובע מה recover() יעשה — retry, feedback, fallback, escalate, או hard-stop.
retry gate
החלטה דטרמיניסטית אם לנסות שוב, מבוססת פלט classify_failure ומספר ה-attempt. transient כן (חכם), schema כן עם feedback (מוגבל), permanent/loop לא (זו הלולאה). תמיד עם exponential backoff.
exponential backoff
השהיה גדלה בין ניסיונות חוזרים (1s, 2s, 4s — כלומר 2^attempt שניות). נותן למערכת להתאושש ומפזר עומס. retries מיידיים על API שנפל מחמירים ומסיכנים ב-rate limit (429). סטנדרט בכל retry של רשת.
fallback strategy
מסלול חלופי כש-tool/מודל נכשל לאחר מיצוי retries — tool אחר, prompt פשוט יותר, תוצאה חלקית עם הסבר. צריך להיות מוגדר מראש, לא אד-הוק. fallback שגם נכשל = escalate, לא fallback שני.
escalation to human
עצירת הריצה וקריאה לאדם להחליט (retry_manual / skip_step / abort_run), במקום להיכשל בשקט. ה-notification כולל turn, cost_so_far ו-error — כי האדם צריך context כדי להחליט. מתחבר ל-human-approval gate מ-ch4, אותו מנגנון עם טריגר שונה (כשל במקום פעולה רגישה).
self-healing מול hard-stop
self-healing — ה-harness פותר לבד (schema/transient retry, loop-warning feedback). hard-stop — עוצר בכוונה (policy violation, circuit-tripped לאחר 6). ביניהם escalation — עצירה עם קריאה לאדם. ההבחנה נקבעת מ-classify_failure, לא מ-LLM שיפוטי.
completion rate
אחוז הריצות שהשלימו תוצאה (trace.output קיים) מול כלל הריצות. מטריקת הבריאות הראשית של ה-harness. completion rate שעולה לאחר שיפור tool/prompt = ראיה שהתיקון עבד.
loop rate
אחוז הריצות שהפעילו circuit-breaker (event circuit-tripped). צריך לשאוף לאפס. loop rate גבוה מחייב תחקור שיטתי — איזה tool, איזה prompt, איזה תנאי — לפני כל תיקון.

טעויות נפוצות — סיכום

סיכום הפרק והגשר קדימה

  1. observability היא תנאי, לא תוספת. harness בלי trace הוא קופסה שחורה — אתה לא יודע איפה הסוכן נתקע, על איזה tool, או כמה זה עלה.
  2. Langfuse נותן trace/span/generation. trace לכל ריצה, span לכל turn ו-tool call, generation שרושם token usage — והכל בחיבור פשוט ל-Claude Agent SDK (זכור/י flush()).
  3. cost attribution + latency profiling הופכים "נשרפו 40K tokens" ל"ה-turn הזה היה יקר כי ה-context תפח, וה-tool הזה איטי כי הוא network". הפתרון שונה לכל אחד — ורק attribution מראה לך מה לתקן.
  4. circuit-breaker תופס לולאות מוקדם: חתימה (tool+input מנורמל), אזהרה ב-3 (feedback), עצירה ב-6 (hard-stop) — זול בהרבה מ-max-turns=30.
  5. failure capture מובנה (Aden Hive pattern) הופך כל כשל לרשומה שאפשר לתחקר, למנות, ולהזין ל-Dreaming (פרק 7) — לא exception שנעלם ל-stderr.
  6. recovery policy דטרמיניסטית: retry על transient (עם exponential backoff), retry+feedback על schema, fallback או escalate על loop/permanent, hard-stop על policy. ההבחנה transient/permanent היא הכל — ומבוססת classify_failure, לא LLM שיפוטי.
  7. self-healing מול hard-stop ו-escalation מתחברים ל-human-approval gate מ-ch4 — אותו מנגנון, טריגר שונה. כדאי לממש פעם אחת כ-abstraction ולשתף בין שניהם.

הגשר לפרק 6 — Orchestration רב-סוכני: כל מה שבנינו כאן הוא תנאי הכרחי לצוות סוכנים. ברגע שיש כמה subagents מקבילים, בלי trace לכל אחד אי אפשר לדבג למה אחד נכשל, ובלי cost attribution לכל subagent — ה-Parallel Subagent Cost Explosion קורה בשקט (מתוך course.research.json, gotchas: "Each spawned subagent consumes a completely separate session"). את ה-circuit-breaker נחבר לכל subagent בנפרד, ואת ה-tracing נהפוך להיררכי: trace אחד לצוות, span לכל subagent. בפרק הבא נלמד לפצל subagents עם max-turn limits ו-budget caps, להחליט ephemeral מול durable, ולהריץ Claude Agent Teams מקבילים שמתקשרים בזמן אמת — הכל מעל ה-observability שבנינו עכשיו.

צ'קליסט סיום פרק