דלג לתוכן
23/30פרק 23 מתוך 30

בנו agent harness: הלולאה וחמש הדרכים לצאת ממנה

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

בעמוד הזה

נתחיל בחלק הישר, כי אף אחד אחר לא יאמר אותו: ״harness״ הוא ז׳רגון, לא תקן. אין מפרט, אין ועדה, אין הגדרת ייחוס. ארבעת המאמרים שהפרק הזה מצטט — ReAct,1 CoALA,2 SWE-bench ו-vLLM — אינם משתמשים במילה אפילו פעם אחת בתקצירים שלהם. המימוש המורד ביותר של הדבר הזה, חבילת ai של Vercel עם 89.4 מיליון הורדות בחודש, אינו משתמש בה גם הוא: המחרוזת harness מופיעה אפס פעמים ב-397 KB של הצהרות הטיפוסים שנשלחו בגרסה 7.0.93.3 המקום היחיד שבו המילה כן נושאת משקל אומר משהו אחר לגמרי. SWE-bench אומר ״harness״ חמש פעמים ב-README שלו, תמיד בתור evaluation harness — שלד containerised שמחיל patch ומריץ את הבדיקות — ומודול ה-Python שלו הוא ממש swebench.harness.run_evaluation.4

אז שני דברים שונים חולקים שם. evaluation harness מחזיק את ה-agent במקום ומדרג אותו. agent harness הוא התוכנית שמריצה את ה-agent: היא קוראת למודל, מבצעת את מה שהמודל מבקש, מחליטה מתי לעצור, ומחזיקה את המצב ביניהם. הפרק הזה בונה את השני, בפחות ממאתיים שורות TypeScript, בלי framework בכלל.

הלולאה עצמה היא חמש-עשרה שורות והיא עובדת בניסיון הראשון. כל מה שאחריה הוא דרך לצאת ממנה.

הצגת פרטים

מה הפרק הזה צריך מהקודמים.

  • פרק 14 בשביל הלקוח: deadlines, מיון סטטוסים, ביטול, idempotency keys, וטכניקת ה-mock provider שמשמשת כאן שוב.
  • פרק 16 בשביל החשבון: input tokens גדלים עם ריבוע השיחה, והתעריפים שבהמשך הם אלה שנקראו שם ב-6 בספטמבר 2026.
  • פרק 18 בשביל קטלוג הכלים: schema שהמודל רואה, endpoint שהוא לעולם לא רואה, והכלל ששגיאות הן context ולא exceptions.
  • פרק 22 בשביל הלולאה שהפרק הזה יורש, ובשביל שתי ההגדרות שפורסמו ל-״agent״ וסותרות זו את זו.

אין כאן tensors. זהו מרכז התלויות השני של הקורס: פרקים 24, 25, 29 ו-30 רצים על הקובץ שלמטה, ו-26 עד 28 נבנים על מה שהוא יכול להגיע אליו.

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

לכן התוכנית הראשונה היא ספק מתוסרט: endpoint בצורת chat completions API שהתשובה שלו היא פונקציה של אינדקס התור ושל מה שהכלים החזירו עד כה. הוא סופר tokens עם byte-pair encoder אמיתי, כך שהכסף בהמשך הוא חשבון ולא קישוט.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

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

הקטלוג הוא זה של פרק 18, ארבעה כלים על פני שלושה קבצים: list_files, read_file, delete_file — מסומן needsApproval — ו-scan_archive, שהוא איטי בכוונה.

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

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

כוונו אותו אל הספק המתוסרט והוא עושה בדיוק מה שנראה שהוא עושה:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

שלושה תורות, שתי הפעלות כלים, רבע סנט אמריקאי. שימו לב לשורה האחרונה: 204, 269, 342. כל תור שולח מחדש את כל מה שהיה לפניו, וזה החשבון הריבועי של פרק 16 מגיע למקום שבו אף אחד לא הקליד כלום. שאר הפרק הזה הוא מה שקורה כשהשורה הזו לא מפסיקה לגדול.

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

קישור למקטע: שבירה ראשונה: המשימה שלא נגמרת

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

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

תקרת תוריםקריאות מודלinput tokensעלות
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

קראו את שתי השורות האחרונות יחד. הכפלת התקרה מ-50 ל-100 לא הכפילה את העלות; היא הגדילה אותה פי 3.7. input tokens עלו מ-88,649 ל-337,299, פקטור של 3.8, כי תור nn נושא איתו את כל התורים הקודמים והסכום הוא Θ(n2)\Theta(n^2). תקרת תורים אינה חוגה ליניארית. היא חוגה על השורש הריבועי של המקרה הגרוע ביותר שלכם, ולכן להעלות אותה מ-20 ל-100 ״רק ליתר ביטחון״ זו החלטה שכדאי לתמחר לפני שמקבלים אותה.

שבירה שנייה: תקרה על תורים אינה תקרה על כסף

קישור למקטע: שבירה שנייה: תקרה על תורים אינה תקרה על כסף

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

אז הלולאה סופרת כסף, באמצעות computeCost של פרק 16 מול התעריפים שנקראו שם — $2.00 למיליון input tokens ו-$12.00 למיליון output, עבור המודל שמתומחר לאורך הקורס הזה:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

אותו סקריפט בורח משליטה, בלי תקרת תורים בכלל, שלושה תקציבים:

תקציבתורים שהושגוהוצאה בפועל
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

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

חמש דרכים לצאת מהלולאה, לא אחת

קישור למקטע: חמש דרכים לצאת מהלולאה, לא אחת

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

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

כיווץ כל אלה ל-boolean אחד הוא טעות התכנון הנפוצה ביותר בקובץ הזה, והיא יקרה באופן מסוים: שלוש מתוך החמש הן ניתנות לחידוש ושתיים לא. agent שפגע בתקרת התורים שלו מחזיק תמליל תקף, תוצאה חלקית אמיתית וצעד הבא; agent שקיבל 401 לא מחזיק אף אחד מאלה. לכן ה-harness מתעד את הסיבה כנתונים:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

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

כשל אחד, שלוש מדינויות. המודל המתוסרט מנחש קובץ שלא קיים; הכלי זורק no such file: timeout.log. Call list_files to see what exists.

מה ה-harness עושה עם השגיאהתוריםהרצות כליעלותמה המשתמש קיבל
זורק אותה מחוץ ללולאה11$0.000756stack trace
מחזיר Error: the tool failed.21$0.001462״לא הצלחתי לקרוא את הקובץ, אז איני יודע.״
מחזיר מה באמת קרה43$0.003550״errors.log מזכיר timeout.״

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

לכן ה-harness מתייחס לכלי שנזרק כאל נתונים, והופך את הניסוח למדיניות:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

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

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

אחת-עשרה הפעלות זהות של קריאה שאינה יכולה להצליח, פי 5.2 מהעלות של הריצה שהתאוששה מכשל שניתן לתיקון, ושום דבר בסוף. שגיאות הן context; שגיאה קבועה היא context שמרעיל את שאר הריצה. ההבחנה היא מיון הסטטוסים של פרק 14, שכבה אחת למעלה: שגיאה שהמודל יכול לפעול לפיה חוזרת לתמליל, ושגיאה שהוא לא יכול צריכה לעצור את הריצה עם סיבה. תקרת התורים היא מה שעומד ביניכם לבין המקרה השני היום, וזה רצפה ולא תיקון.

שבירה רביעית: אותה קריאה, פעמיים

קישור למקטע: שבירה רביעית: אותה קריאה, פעמיים

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

נמדד מול baseline של אותה משימה בלי החזרה:

תוריםהרצות כליעלות
המשימה, בלי חזרה21$0.001396
אותה משימה, קריאה אחת חוזרת32$0.002446
חוזרת, עם result cache על כלים לקריאה בלבד31$0.002446

הקריאה המשוכפלת עלתה $0.001050 נוספים, עלייה של 75%, והנה החלק שמפתיע אנשים: cache של התוצאה לא החזיר כלום מזה. Deduplication חסך את ביצוע הכלי ולא את התור, כי עד שהקוד שלכם שם לב לחזרה, כבר שילמו למודל על כך שביקש. החיסכון אמיתי כשהכלי איטי, מוגבל בקצב, או מחויב לפי קריאה — והוא אפס בשורת החיוב שגדלה.

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

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

איזה מהם נכון? אף אחד, באופן שניתן לדעת. הפרוטוקול אומר שאלה שתי קריאות: הן נושאות שני ערכי tool_call_id שונים. הארגומנטים אומרים שאולי הן אחת. harness שמחליט לפי השוואת מחרוזות ארגומנטים יבלע יום אחד את השנייה מבין שתי חיובים זהים ומכוונים — ופרק 14 כבר נתן שם למנגנון היחיד שפותר זאת בכנות, שהוא idempotency key שנוצר לכל פעולה לוגית על ידי השכבה שיודעת מה הפעולה היא. עד שהכלי נושא אחד, ברירת המחדל שניתן להגן עליה היא שער הקריאה-בלבד שלמעלה: cache לקריאות, הרצת כתיבות, ולתת ל-idempotency של הכתיבה עצמה לטפל בשאר.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

שבירה חמישית: הוא מוחק משהו

קישור למקטע: שבירה חמישית: הוא מוחק משהו

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

כלי שמסומן needsApproval לא נכשל ולא ממשיך. הוא עוצר את הריצה ומחזיר שליטה, עם כל מה שאדם צריך כדי להחליט:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

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

אבל קודם, המדידה שאף אחד לא מצפה לה. דחייה אינה היעדר תוצאה — לתמליל יש slot שממופה לפי tool_call_id ומשהו חייב להיכנס אליו. הריצו אותה דחייה פעמיים, כשמשנים רק מה אותו משהו אומר:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

שום דבר לא נמחק באף אחת מהריצות, ובשנייה אומרים למשתמש שהוא נמחק. מערכת ההרשאות עבדה מושלם; הדיווח הוא שקר. זה אותו מנגנון כמו טבלת שגיאות הכלים, שמגיע למקום הרבה יותר חשוב — אדם אמר לא, הפעולה נחסמה נכון, והסיכום של ה-agent סותר את המציאות כי הסירוב מעולם לא נכתב במקום שבו המודל קורא. הכלל שיוצא מזה קצר: כל מה שהקוד שלכם מחליט לגבי tool call, כתבו את ההחלטה לתמליל במילים. פרק 30 חוזר לזה מצד האבטחה, שם זה ההבדל בין audit trail לבדיה.

אישור לוקח דקות או שעות. deploy לוקח שניות. אם הריצה חיה במשתנה מקומי בתוך HTTP request, כל restart הוא ריצה אבודה וכל אישור הוא מרוץ.

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

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

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

התיקון הוא לגרום ללולאה להתחיל בשאלה לתמליל מה עדיין פתוח:

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

כל איטרציה מנקזת קודם את pending ורק כשאין שום דבר פתוח שואלת את המודל. Resume הופך לאותו נתיב קוד כמו הנתיב הרגיל, וכך גם approval — קריאה שאושרה היא פשוט pending call שעכשיו מותר להריץ. הרגו את התהליך באמצע משימה והפעילו אותו מחדש:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

שתי הפעלות כלי על פני שני תהליכים עבור משימה שצריכה שתיים, והעלות הסופית זהה לריצה שמעולם לא קרסה. העלות מצטברת על פני ה-restart כי היא הייתה במצב, לא במשתנה.

שבירה שביעית: שלוש דקות של שקט

קישור למקטע: שבירה שביעית: שלוש דקות של שקט

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

שניהם אותו תיקון, והוא AbortSignal של פרק 14 שנדחף שכבה אחת עמוק יותר. ה-signal אינו רק בשביל ה-fetch — הוא מועבר לתוך הכלי, וכלי שכתוב היטב מכבד אותו:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

שתי מילישניות מהקליק עד העצירה, כי ה-sleep בתוך הכלי מאזין לאותו signal שה-fetch מאזין לו. העבירו אותו רק לתוך fetch וכפתור עצירה זהה מחכה שלוש שניות — אורך הכלי — והריצה ״מתבטלת״ אחרי שהעבודה שהיא ביטלה כבר הסתיימה. ביטול שלא מחווט עד הסוף הוא spinner שאומר את המילה הנכונה.

ה-harness פולט שורה אחת לכל אירוע, ואוצר המילים קטן מספיק כדי לזכור בעל פה: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

שלוש תכונות הופכות את זה ל-trace ולא logging. כל שורה נושאת את run id, כך שריצה שנפרסת על פני שלושה תהליכים ושני ימים היא שאילתה אחת. כל שורת turn נושאת את ספירות ה-token שלה ואת העלות הרצה, כך ש-״למה הריצה הזו עלתה ארבעים דולר״ היא שאלה שאפשר לענות עליה בדיעבד במקום משהו שאפשר לשחזר רק בתיאוריה. ו-run_stopped נושא את הסיבה, שהיא השדה שהופך ticket תמיכה לתשובה בשורה אחת: agent שנעצר בתקציב ו-agent שקרס נראים זהים מבחוץ וצריכים תגובות הפוכות.

פרק 13 מדד time to first token על חומרה שבבעלותכם. פרק 14 מדד אותו דרך socket. agent מכפיל אותו, והמכפיל הוא מספר שאף אחד לא בחר:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

אותה משימה בת שלושה תורים, כשמשנים רק את ה-latency של הספק:

latency של הספק לכל תורזמן שעון, 3 תורים
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

ה-harness עצמו תורם חמש-עשרה מילישניות לריצה בת שלושה תורים. כל השאר הוא NN מוכפל במספר שאינכם שולטים בו — נקבע בתוך serving scheduler שמאגד את הבקשה שלכם עם בקשות של זרים6 — ו-NN נבחר על ידי המודל. לכן ה-streaming של פרק 14 חשוב כאן יותר מאשר בצ׳אט ועוזר פחות: אפשר להזרים את התור האחרון, וארבעת התורים שלפניו הם שקט אלא אם ה-harness פולט התקדמות. זו גם כל הטענה בעד אירוע tool_progress למעלה — ב-agent, יחידת המשוב הכנה אינה ה-token, אלא הצעד.

אותו harness, מודל אמיתי מאחורי הפורט

קישור למקטע: אותו harness, מודל אמיתי מאחורי הפורט

כל מה שלמעלה רץ מול ספק מתוסרט, מה שמוכיח את ה-harness ולא מוכיח כלום על מודלים. אז שנו שורה אחת — התפר מפרק 14, LLM_BASE_URL — וכוונו את אותו קוד בדיוק אל Qwen2.5-0.5B-Instruct מקומי עם אותם ארבעה כלים. שש משימות על אותם שלושה קבצים:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

שלושה ממצאים, והשלישי הוא הסיבה שהסעיף הזה קיים.

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

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

והתשובות שגויות. הקובץ הגדול ביותר הוא errors.log; המודל הציג את הקבצים, מעולם לא קרא אותם, ובכל זאת נקב בשם אחד. המשימה הראשונה ניחשה שם קובץ, נאמר לה שהוא לא קיים, והיא הסיקה מסקנה. ה-harness פעל ללא דופי בכל שש הריצות. harness הופך agent ל-governable, לא לנכון — פרק 29 הוא הדרך לגלות מי מהם, ופרק 30 הוא מה שזה עולה כשאף אחד לא עשה זאת.

Subagents, נקראים כאן ומחויבים אחר כך

קישור למקטע: Subagents, נקראים כאן ומחויבים אחר כך

כלי אחד בקטלוג יכול להריץ ריצה אחרת מאחוריו. הממשק הוא של פרק 18 — schema ו-endpoint — ו-agent שלם נכנס מאחוריו כי הממשק הזה צר:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

שלושה דברים כבר נכונים בעשר השורות האלה, ושלושתם תוצאות של החלטות שהתקבלו למעלה: לילד יש חלון משלו, כך שהתמליל של ההורה מקבל סיכום ולא את כל מה שהילד קרא; יש לו גבולות משלו, כך שילד שבורח משליטה לא יכול לבזבז את התקציב של ההורה; והוא יורש את ה-signal, כך שעצירה אחת מבטלת את כל העץ. למה חלון נקי הוא הנקודה ולא תופעת לוואי נמצא ב-פרק 24; חמש תבניות ה-orchestration — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — וה-handoff נמצאים ב-פרק 25.

איפה ה-frameworks, ולמה הקורס הזה לא השתמש באחד

קישור למקטע: איפה ה-frameworks, ולמה הקורס הזה לא השתמש באחד

אין לקרוא שום דבר מלמעלה כטיעון נגד ספריות. נמדד ב-7 בספטמבר 2026, עבור החודש שהסתיים ב-29 באוגוסט:7

packageהורדות באותו חודשמה הוא נותן לכם
ai (Vercel AI SDK)89,385,860ToolLoopAgent, stopWhen, tool approval, step hooks
@anthropic-ai/claude-agent-sdk41,558,352ה-Claude Code harness כספרייה: לולאה, sessions, hooks, הרשאות, subagents8
@langchain/langgraph12,812,815הלולאה כ-state graph מפורש
langchain11,359,058chains, agents, integrations
@openai/agents6,093,155agents, handoffs, guardrails
@mastra/core5,914,502agents, workflows, memory

הסיבה שהקורס הזה כותב את הלולאה ביד במקום ללמד אחד מהם מוצהרת ולא נרמזת, והיא מדידה. בשנים-עשר החודשים עד 7 בספטמבר 2026, ai פרסם 945 גרסאות ועבר מ-major 5 ל-major 7, ומחלקת ה-agent שלו עדיין מיוצאת כ-Experimental_Agent; langchain פרסם 132 גרסאות באותו חלון; @openai/agents פרסם 83 ועדיין נמצא ב-0.x, חמישה-עשר חודשים אחרי השחרור הראשון שלו.7 פרק שנכתב מול כל אחד מה-APIs האלה מתיישן בתוך עונה, והפרק הזה מתפרסם בשלושים ושלוש שפות, כך שכל מהדורה מחדש עולה לכל התרגום. מה שמתחת לכולם לא זז: לולאה, כלל עצירה, קטלוג, executor, קצת state.

ומימוש הייחוס מסכים עם הפרק הזה לגבי החלק החשוב. בגרסה 7.0.93 של ai היציאה מהלולאה אינה מספר — היא stopWhen, רשימת predicates, שספירת צעדים היא רק אחת מהן:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

עצירה היא ברבים במימוש הנפוץ ביותר של הלולאה הזו, מאותה סיבה שהיא ברבים במאה תשעים ושש השורות שלמעלה.

עכשיו יש לכם harness: לולאה, קטלוג, executor, חמש דרכים לצאת, ריצה שנשמרת, signal שמגיע לכלים, ו-trace עם run id על כל שורה. פרקים 24, 25, 29 ו-30 נבנים על הקובץ הזה, ו-26 עד 28 על מה שהוא יכול להגיע אליו.

נשארה לו בעיה אחת, והמדידות שלמעלה הצביעו עליה כל הדרך. הביטו שוב בטבלת הבריחה משליטה: 3,431 input tokens בשמונה תורים, 337,299 במאה. הביטו בריצה שעבדה: 204, 269, 342. כל תור שולח מחדש את כל התמליל, כך שה-context של agent מתמלא בהיסטוריה של עצמו — והמודל גרוע יותר בשימוש בקצה הרחוק של חלון ארוך מאשר בקצה הקרוב, ולכן agent טוב בתור חמש הוא מבולבל בתור ארבעים.

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


כל מספר בפרק הזה הגיע משני השרתים שתוארו למעלה, על Node 22 דרך ממשק loopback: ספק מתוסרט שסופר tokens עם קידוד o200k_base, ו-Qwen/Qwen2.5-0.5B-Instruct מאחורי endpoint באותה צורה, greedy decoding, על CPU. העלויות מחושבות מספירות token שנמדדו לפי התעריפים שפרק 16 קרא ב-6 בספטמבר 2026 — $2.00 למיליון input tokens ו-$12.00 למיליון output — ואף בקשה בפרק הזה לא נשלחה ל-endpoint בתשלום. התשובות של המודל המקומי הן תשובות של מודל קטן; קראו אותן כראיה על הלולאה, שזהה בכל מקרה, ולא כ-benchmark למה שמודלים נוכחיים עושים.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. and Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). השזירה של reasoning traces ופעולות שהלולאה מממשת, ומקור התצפית שפעולה מאפשרת למודל ״לטפל בחריגות״ — וזה בדיוק מה שטבלת שגיאות הכלים למעלה מודדת.

  2. Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). הטיפול הפורמלי במה שהלולאה למעלה עושה באופן לא פורמלי: רכיבי זיכרון מודולריים, מרחב פעולה מובנה שנפרש על זיכרון פנימי וסביבות חיצוניות, ו-״תהליך קבלת החלטות מוכלל לבחירת פעולות״. קראו אותו בשביל אוצר המילים שחסר למונח התעשייתי — בפרט ההפרדה בין working, episodic, semantic ו-procedural memory, שהצל המעשי שלה הוא טבלת שלושת המאגרים בפרק 24.

  3. ai (Vercel AI SDK) גרסה 7.0.93, פורסמה ב-4 בספטמבר 2026; הצהרות הטיפוסים נקראו מתוך cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts ב-7 בספטמבר 2026. הקובץ בן 397 KB מכיל אפס מופעים של המחרוזת harness. מחלקת ה-agent היא declare class ToolLoopAgent, מיוצאת גם כ-ToolLoopAgent וגם כ-Experimental_Agent; declare function isStepCount(stepCount: number) — מיוצאת כ-stepCountIs — מצוטטת מילה במילה למעלה; type StopCondition מוצגת בלי פרמטר הטיפוס השני שלה (RUNTIME_CONTEXT extends Context = Context), שהוא ההשמטה היחידה בקטע, כמו גם הצורה של stopWhen?: Arrayable<StopCondition<...>> על generateText ו-streamText. אותו קובץ מצהיר על toolApproval, ToolApprovalStatus, prepareStep ו-repairToolCall, כלומר מימוש הייחוס הגיע באופן עצמאי ל-approval gates, הכנה לכל צעד ותיקון שגיאות. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. and Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). התקציר קורא לארטיפקט ״evaluation framework״ של 2,294 בעיות ולעולם אינו משתמש במילה ״harness״; ה-README של הפרויקט עצמו (github.com/SWE-bench/SWE-bench, נקרא ב-7 בספטמבר 2026) משתמש בה חמש פעמים, תמיד בתור ״evaluation harness״, ונקודת הכניסה היא python -m swebench.harness.run_evaluation. זו המשמעות האחרת של המילה: שלד שמחזיק את ה-agent במקום ומדרג אותו, לא הלולאה שמריצה אותו.

  5. Anthropic, Building effective agents, 19 בדצמבר 2024, anthropic.com/engineering/building-effective-agents, נקרא ב-7 בספטמבר 2026. המודל המוגבר כאבן הבניין, ה-agent כ-LLM ״שמשתמש בכלים על בסיס משוב סביבתי בלולאה״, וההמלצה על תנאי עצירה ״כגון מספר איטרציות מרבי״ כדי לשמור על שליטה. פרק 22 מצטט את ההגדרה שלו במלואה.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. and Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). הלולאה האחרת — serving scheduler שמאגד את הבקשה שלכם עם בקשות של זרים ומנהל את KV cache של פרק 13. כדאי לדעת שהוא קיים בדיוק כי הוא לא שלכם: ה-latency שה-harness שלכם מכפיל נקבע בתוכו, ושום עבודה על הלולאה שלכם לא מזיזה אותו.

  7. ספירות הורדות מרשם npm, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, חלון מפורש ולא חלון last-month מתגלגל, והיסטוריות שחרור מתוך registry.npmjs.org/<package>; שניהם תושאלו ב-7 בספטמבר 2026. ספירות השחרור הן מספר הגרסאות שפורסמו בשנים-עשר החודשים עד תאריך זה, כולל canary builds: ai 945 (האחרונה 7.0.93 ב-2026-09-04, עם גרסאות major 5, 6 ו-7 כולן בתוך החלון), langchain 132 (האחרונה 1.5.10 ב-2026-08-20), @openai/agents 83 (האחרונה 0.17.0 ב-2026-08-19, פורסמה לראשונה ב-2025-06-03). 2

  8. Claude Agent SDK (@anthropic-ai/claude-agent-sdk) הוא Claude Code harness ארוז כספרייה — לולאת agent, כלי קבצים ו-shell מובנים, context management, sessions, hooks, הרשאות ו-subagents — מתועד ב-code.claude.com/docs/en/agent-sdk. זה הדבר הקרוב ביותר לתיאור שפורסם של כל מנגנון שהפרק הזה בונה ביד, ושווה לקרוא אותו לצד המימוש שלכם עבור החלקים שהוא נותן להם שם והפרק הזה רק מרמז עליהם.


נוצר על ידי

David Vicente Campos

מייסד NeuraLIA Labs ושותף-מייסד MyRealFood

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

עוד על המחבר

פורסם על ידי NeuraLIA Labs.

פוסטים חדשים ישירות לתיבת הדואר

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

תוכן הקורס

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev10 דקות קריאה

מודל ה-AI Jev נבנה להחלטות, לא לפרוזה

Jev של TypeSafe AI מושך תשומת לב כי הוא מתייחס לאינטליגנציית תוכנה כאל בעיית הסתברות: לבחור את ההסתעפות הנכונה, להצמיד ביטחון, ולהימנע מתשלום ל-LLM כדי שיכתוב טקסט כשהקוד צריך החלטה.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering10 דקות קריאה

הנדסת הקשר לסוכני AI ארוכי־טווח

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

מוכנים לתת ל-LIA לבחור?

בנו עם כל מודלי ה-AI במקום אחד — התחילו בחינם עוד היום.