پرش به محتوا
23/30فصل 23 از 30

ساخت یک agent harness: حلقه و پنج راه خروج از آن

حلقه‌ای پانزده‌خطی که بار اول کار می‌کند، سپس عمداً هفت بار می‌شکند؛ از runawayای که 77 برابر نسخه محدود هزینه داشت.

در این صفحه

از بخش صادقانه شروع کنیم، چون هیچ‌کس دیگری آن را نمی‌گوید: «harness» اصطلاح تخصصی است، نه یک استاندارد. نه specificationای وجود دارد، نه کمیته‌ای، نه تعریف مرجع. چهار مقاله‌ای که این فصل به آن‌ها ارجاع می‌دهد — ReAct،1 CoALA،2 SWE-bench و vLLM — در چکیده‌هایشان حتی یک بار هم از این واژه استفاده نمی‌کنند. پر‌دانلودترین پیاده‌سازیِ این چیز، پکیج ai از Vercel با 89.4 میلیون دانلود در ماه، آن را به کار نمی‌برد: رشته harness در 397 KB declarationهای type که نسخه 7.0.93 منتشر کرده، صفر بار ظاهر می‌شود.3 تنها جایی که این واژه واقعاً نقش سازه‌ای دارد، معنایی کاملاً متفاوت می‌دهد. SWE-bench در README خود پنج بار می‌گوید «harness»، همیشه به‌صورت evaluation harness — داربست containerشده‌ای که patch را اعمال می‌کند و testها را اجرا می‌کند — و ماژول Python آن واقعاً swebench.harness.run_evaluation است.4

پس دو چیز متفاوت یک نام مشترک دارند. یک evaluation harness، agent را ثابت نگه می‌دارد و امتیازش می‌دهد. یک agent harness برنامه‌ای است که agent را اجرا می‌کند: مدل را فراخوانی می‌کند، آنچه مدل درخواست کرده اجرا می‌کند، تصمیم می‌گیرد چه زمانی متوقف شود، و state میان این‌ها را نگه می‌دارد. این فصل دومی را در کمتر از دویست خط TypeScript می‌سازد، بدون هیچ frameworkای.

خود حلقه پانزده خط است و در اولین تلاش کار می‌کند. هر چیزی بعد از آن، راهی برای خروج از آن است.

نمایش جزئیات

این فصل از فصل‌های قبلی چه لازم دارد.

  • فصل 14 برای client: deadlineها، triage وضعیت، cancellation، idempotency keyها، و تکنیک mock provider که اینجا هم دوباره استفاده می‌شود.
  • فصل 16 برای حساب‌وکتاب: input tokenها با مربع مکالمه رشد می‌کنند، و نرخ‌های استفاده‌شده در ادامه همان‌هایی هستند که آنجا در 6 سپتامبر 2026 خوانده شدند.
  • فصل 18 برای کاتالوگ ابزار: schemaای که مدل می‌بیند، endpointای که هرگز نمی‌بیند، و این قاعده که خطاها context هستند نه exception.
  • فصل 22 برای حلقه‌ای که این یکی از آن ارث می‌برد، و برای دو تعریف منتشرشده از «agent» که با هم توافق ندارند.

اینجا خبری از tensor نیست. این دومین هاب وابستگی دوره است: فصل‌های 24، 25، 29 و 30 روی فایل زیر اجرا می‌شوند، و 26 تا 28 بر اساس چیزهایی ساخته می‌شوند که این فایل می‌تواند به آن‌ها برسد.

ارائه‌دهنده‌ای که می‌توانید script کنید

لینک به بخش: ارائه‌دهنده‌ای که می‌توانید script کنید

فصل 14 را نمی‌شد علیه یک ارائه‌دهنده واقعی نوشت، چون نمی‌توانید در لحظه‌ای که انتخاب کرده‌اید از آن یک 429 بخواهید. این فصل همان مشکل را در شکلی متفاوت دارد: نمی‌توانید از یک مدل واقعی بخواهید runaway شود، یا دو بار پشت سر هم دقیقاً همان ابزار را درخواست کند، آن هم on demand و قابل بازتولید.

پس برنامه اول یک scripted provider است: endpointای با شکل یک chat completions API که پاسخ آن تابعی از index نوبت و چیزهایی است که ابزارها تا آن لحظه برگردانده‌اند. tokenها را با یک 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);

دو خط طراحی را حمل می‌کنند. index نوبت از مکالمه مشتق می‌شود، نه اینکه در یک متغیر نگه داشته شود، پس ارائه‌دهنده stateless است و یک run را می‌توان کشت و دوباره علیه آن ادامه داد. و recover قبل از تصمیم گرفتن نتایج ابزار را می‌خواند: یک مدل scriptشده که transcript خودش را می‌خواند، حداقل چیزی است که برای اندازه‌گیری اینکه آیا 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 });
  }
}

آن را به scripted provider وصل کنید و دقیقاً همان کاری را می‌کند که از ظاهرش پیداست:

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 است که جایی می‌رسد که هیچ‌کس چیزی تایپ نکرده. باقی این فصل درباره اتفاقی است که می‌افتد وقتی آن خط از رشد کردن نمی‌ایستد.

شکست اول: taskای که هرگز تمام نمی‌شود

لینک به بخش: شکست اول: taskای که هرگز تمام نمی‌شود

همان حلقه را به script runaway وصل کنید — مدلی که در هر نوبت یک ابزار می‌خواهد و هرگز نثر تولید نمی‌کند — و return علامت‌خورده هرگز اجرا نمی‌شود. خروجی دیگری وجود ندارد. برنامه اجرا می‌شود تا process بمیرد یا کارت اعتباری.

راه‌حل یک خط است، نخستین کنترلی است که ادبیات توصیه می‌کند،5 و همه بالاخره آن را می‌نویسند. کاری که تقریباً هیچ‌کس نمی‌کند، اندازه‌گیری ارزش آن است:

سقف نوبتفراخوانی‌های مدلinput tokenهاهزینه
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

دو ردیف آخر را با هم بخوانید. دو برابر کردن سقف از 50 به 100 هزینه را دو برابر نکرد؛ آن را 3.7 برابر کرد. input tokenها از 88,649 به 337,299 رسیدند، یعنی ضریب 3.8، چون نوبت nn هر نوبت قبلی را با خود حمل می‌کند و مجموع Θ(n2)\Theta(n^2) است. سقف نوبت یک پیچ خطی نیست. پیچی روی ریشه دوم بدترین حالت شماست؛ برای همین بالا بردنش از 20 به 100 «برای اطمینان» تصمیمی است که پیش از گرفتنش باید قیمت‌گذاری شود.

شکست دوم: سقف نوبت سقف پول نیست

لینک به بخش: شکست دوم: سقف نوبت سقف پول نیست

مشکل سقف نوبت این است که یک نوبت قیمت ثابت ندارد. بیست نوبت روی transcript کوتاه، بالا $0.038 هزینه داشت. بیست نوبت با یک کاتالوگ 200 ابزاری، مجموعه‌ای از سندهای retrievalشده و چهل پیام history، صدها برابر آن هزینه دارد، و سقف از آن خبر ندارد. چیزی که operator می‌خواهد محدود کند، قبض است.

پس حلقه پول را می‌شمارد، با استفاده از computeCost فصل 16 و نرخ‌هایی که آنجا خوانده شدند — $2.00 برای هر میلیون input token و $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);

همان script runaway، بدون هیچ سقف نوبتی، سه بودجه:

بودجهنوبت‌های رسیدهمقدار واقعی خرج‌شده
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

دو چیز ارزش نام‌گذاری دارند. اول، بودجه هر بار تعداد متفاوتی نوبت می‌خرد، که دقیقاً نکته همین است: همان چیزی را محدود می‌کند که operator برایش اهمیت قائل است، و اجازه می‌دهد تعداد نوبت‌ها جایی بیفتد که transcript آن را می‌گذارد. دوم، هر ردیف overshoot دارد. بودجه $0.010 بود و $0.010780 خرج شد، چون check قبل از یک نوبت اجرا می‌شود و قیمت یک نوبت تا وقتی تمام نشود معلوم نیست. نمی‌توانید خرج را دقیقاً محدود کنید؛ می‌توانید آن را تا فاصله هزینه یک نوبت محدود کنید. این را در interface بگویید نه اینکه وانمود کنید، و check را قبل از call بگذارید تا overshoot یک نوبت باشد نه دو نوبت.

پنج راه برای خروج از حلقه، نه یکی

لینک به بخش: پنج راه برای خروج از حلقه، نه یکی

تا اینجا حلقه سه خروجی دارد، و شکل باقی فصل معلوم شده است. یک run تولیدی دقیقاً به یکی از پنج روش تمام می‌شود، و این‌ها variationهای یکدیگر نیستند:

چگونه تمام می‌شودچه کسی تصمیم گرفتcaller باید چه کند
مدل دیگر چیزی نخواستمدلپاسخ را بخواند
سقف نوبتشما، از قبلسقف را بالا ببرد، یا نتیجه جزئی را بپذیرد
بودجه تمام شدشما، از قبلپول بیشتری تأیید کند، یا نتیجه جزئی را بپذیرد
خطایی که نمی‌توانید retry کنیدارائه‌دهنده یا یک ابزارdeployment را درست کند؛ triage فصل 14 تصمیم می‌گیرد
انسان مداخله کردیک نفرمنتظر verdict بماند، سپس resume کند

فشرده کردن این‌ها در یک boolean رایج‌ترین اشتباه طراحی در این فایل است، و هزینه‌اش به شکلی مشخص بالا می‌رود: سه تا از پنج مورد resumable هستند و دو تا نیستند. agentای که به سقف نوبتش رسیده transcript معتبر، نتیجه جزئی واقعی و next step دارد؛ agentای که 401 گرفته هیچ‌کدام را ندارد. بنابراین harness دلیل را به‌صورت data ثبت می‌کند:

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 };

شکست سوم: یک ابزار fail می‌شود

لینک به بخش: شکست سوم: یک ابزار fail می‌شود

فصل 18 با ادعایی بدون عدد تمام شد: خطای یک ابزار را به‌عنوان نتیجه ابزار به مدل برگردانید، نه اینکه آن را raise کنید، و مدل معمولاً خودش را اصلاح می‌کند. این عددش است.

یک failure، سه policy. مدل scriptشده نام فایلی را حدس می‌زند که وجود ندارد؛ ابزار no such file: timeout.log. Call list_files to see what exists. throw می‌کند

harness با خطا چه می‌کندنوبت‌هااجرای ابزارهزینهکاربر چه گرفت
آن را از حلقه بیرون throw می‌کند11$0.000756یک stack trace
Error: the tool failed. برمی‌گرداند21$0.001462«نتوانستم فایل را بخوانم، پس نمی‌دانم.»
چیزی را برمی‌گرداند که واقعاً اتفاق افتاد43$0.003550«errors.log به یک timeout اشاره می‌کند.»

ردیف سوم 4.7 برابر ردیف اول هزینه دارد و تنها ردیفی است که به سؤال جواب می‌دهد. و ردیف دوم جالب است، چون همان کاری است که بیشتر codebaseها واقعاً انجام می‌دهند: خطا catch شد، حلقه زنده ماند، به مدل گفته شد که چیزی fail شده اما نه چه چیزی، و مدل مؤدبانه دست کشید. تفاوت ردیف‌های دو و سه error handling نیست. جمله‌ای است که برای یک خواننده نوشته شده.

پس harness با یک ابزار throwشده مثل data رفتار می‌کند، و wording را policy می‌سازد:

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 درباره سوی دیگر هم هشدار داده بود، و آن هم قیمت دارد. حلقه را به ابزاری وصل کنید که به دلیلی fail می‌شود که هیچ پیامی نمی‌تواند درستش کند — خواندنی که process اجازه انجامش را ندارد — و مدل آن را برای همیشه retry می‌کند:

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

یازده اجرای یکسان از callای که نمی‌تواند موفق شود، 5.2 برابر هزینه runای که از یک خطای قابل اصلاح recover کرد، و در پایان هیچ چیز. خطاها context هستند؛ خطای دائمی contextای است که باقی run را مسموم می‌کند. این تمایز همان triage وضعیت فصل 14 است که یک لایه بالاتر آمده: خطایی که مدل می‌تواند بر اساس آن عمل کند به transcript برمی‌گردد، و خطایی که نمی‌تواند باید run را با یک دلیل متوقف کند. امروز سقف نوبت چیزی است که بین شما و حالت دوم ایستاده، و این کف است نه fix.

شکست چهارم: همان call، دوبار

لینک به بخش: شکست چهارم: همان call، دوبار

حالا failureای که بیشتر مردم فکر می‌کنند نمی‌تواند اتفاق بیفتد. مدل‌ها خودشان را تکرار می‌کنند. هر حلقه‌ای را آن‌قدر طولانی اجرا کنید و خواهید دید که ابزار یکسان با argumentهای یکسان در دو نوبت پیاپی می‌آید.

در مقایسه با baseline همان task بدون تکرار:

نوبت‌هااجرای ابزارهزینه
task، بدون تکرار21$0.001396
همان task، یک call تکراری32$0.002446
تکراری، با result cache روی ابزارهای read-only31$0.002446

call تکراری $0.001050 اضافه هزینه داشت، یعنی افزایش 75٪، و اینجاست که مردم غافلگیر می‌شوند: caching نتیجه هیچ‌کدام از آن را برنگرداند. Deduplication اجرای ابزار را ذخیره کرد نه نوبت را، چون تا وقتی کد شما متوجه تکرار شود، پول مدل برای درخواستش پرداخت شده است. این صرفه‌جویی وقتی ابزار کند، rate-limited، یا per-call billed باشد واقعی است — و روی line itemای که رشد کرده صفر است.

نسخه بدتری هم هست. همان cache را روی ابزاری که می‌نویسد اعمال کنید، و call دوم بی‌صدا اتفاق نمی‌افتد:

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"]

کدام‌یک درست است؟ هیچ‌کدام، و دانستنی هم نیست. protocol می‌گوید این‌ها دو call هستند: دو مقدار متفاوت tool_call_id دارند. argumentها می‌گویند شاید یکی باشند. harnessای که با مقایسه رشته argumentها تصمیم می‌گیرد، یک روز دومین مورد از دو charge یکسان و عمدی را می‌بلعد — و فصل 14 قبلاً تنها مکانیزمی را نام برده که صادقانه این را حل می‌کند: idempotency keyای که برای هر عملیات منطقی، توسط لایه‌ای تولید می‌شود که می‌داند آن عملیات چیست. تا وقتی ابزار یکی داشته باشد، پیش‌فرض قابل دفاع همان gate read-only بالاست: readها را cache کنید، writeها را اجرا کنید، و بگذارید idempotency خود write بقیه را مدیریت کند.

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;
}

شکست پنجم: چیزی را حذف می‌کند

لینک به بخش: شکست پنجم: چیزی را حذف می‌کند

script destructive فایل‌ها را فهرست می‌کند و بعد درخواست حذف فایلی را می‌دهد که task هرگز ذکر نکرده. هیچ‌چیز در حلقه تا اینجا جلویش را نمی‌گیرد.

ابزاری که با needsApproval علامت خورده، نه fail می‌شود و نه ادامه می‌دهد. run را متوقف می‌کند و کنترل را برمی‌گرداند، با هر چیزی که یک انسان برای تصمیم گرفتن لازم دارد:

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، بخش بعدی است: بین توقف و verdict، ممکن است process دیگر وجود نداشته باشد.

اما اول، اندازه‌گیری‌ای که هیچ‌کس انتظارش را ندارد. رد کردن، نبودنِ نتیجه نیست — transcript یک slot دارد که با tool_call_id کلید خورده و باید چیزی داخلش برود. همان رد را دوبار اجرا کنید، فقط چیزی را که آن slot می‌گوید تغییر دهید:

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."

در هیچ‌کدام از دو run چیزی حذف نشد، و در دومی به کاربر گفته می‌شود که حذف شده است. سیستم permission بی‌نقص کار کرد؛ گزارش دروغ است. همان مکانیزم جدول خطای ابزار است، اما جایی ظاهر می‌شود که خیلی مهم‌تر است — یک انسان نه گفت، action درست block شد، و summary agent واقعیت را نقض می‌کند چون امتناع هرگز در جایی که مدل می‌خواند نوشته نشد. قاعده‌ای که از این بیرون می‌آید کوتاه است: هر تصمیمی که کد شما درباره یک tool call می‌گیرد، آن تصمیم را با کلمات در transcript بنویسید. فصل 30 از سمت امنیت به این برمی‌گردد، جایی که تفاوت میان audit trail و داستان‌پردازی است.

یک approval چند دقیقه یا چند ساعت طول می‌کشد. یک deploy چند ثانیه. اگر run در یک متغیر محلی داخل یک HTTP request زندگی کند، هر restart یک run ازدست‌رفته است و هر approval یک race.

پس run یک closure نیست. یک object ساده serialisable است — پیام‌ها، تعداد نوبت، هزینه، status، interruption، فهرست call idهای تأییدشده — و حلقه یک pure function روی آن است. همین constraint واحد است که 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"));

سؤال correctness ذخیره کردن نیست. این است که در مسیر برگشت چه اتفاقی می‌افتد، و پاسخ naive به شما دوباره charge می‌زند. اگر process بعد از اینکه مدل ابزار خواست اما قبل از نوشته شدن نتیجه مرده باشد، resumeای که با دوباره فراخوانی مدل شروع شود، پول نوبتی را می‌دهد که قبلاً دارد — و اگر با دوباره اجرای ابزارها شروع شود، یک write را دوبار انجام می‌دهد.

راه‌حل این است که حلقه در ابتدا از transcript بپرسد چه چیزی outstanding است:

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));    
}

هر iteration اول pending را drain می‌کند و فقط وقتی هیچ outstandingای وجود ندارد از مدل می‌پرسد. Resume به همان code path معمول تبدیل می‌شود، و approval هم همین‌طور — یک call تأییدشده فقط pending callای است که حالا اجازه اجرا دارد. process را وسط task بکشید و دوباره راه‌اندازی کنید:

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

دو اجرای ابزار در دو process برای taskای که به دو اجرا نیاز دارد، و هزینه نهایی با runای که هرگز crash نکرد یکسان است. هزینه از میان restart جمع می‌شود چون در state بود، نه در یک متغیر.

scan_archive اینجا سه ثانیه طول می‌کشد و جای ابزاری می‌نشیند که در production سه دقیقه طول می‌کشد. وقتی اجرا می‌شود دو چیز کم است: کاربر هیچ ایده‌ای ندارد چیزی در حال انجام است، و دکمه Stop هیچ کاری نمی‌کند.

هر دو یک fix دارند، و آن 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"

دو میلی‌ثانیه از click تا stop، چون sleep داخل ابزار به همان signal گوش می‌دهد که fetch گوش می‌دهد. آن را فقط به fetch thread کنید و همان دکمه Stop سه ثانیه منتظر می‌ماند — طول ابزار — و run بعد از اینکه کاری که قرار بود cancel شود تمام شده، «cancel» می‌شود. Cancellationای که تا پایین‌ترین لایه plumbing نشده، spinnerای است که کلمه درست را می‌گوید.

harness برای هر event یک خط emit می‌کند، و vocabulary آن آن‌قدر کوچک است که می‌شود حفظش کرد: 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 دارد، پس runای که سه process و دو روز را طی می‌کند یک query است. هر خط turn token countهای خودش و هزینه جاری را دارد، پس «چرا این run چهل دلار هزینه داشت» بعد از واقعه قابل پاسخ است، نه اینکه فقط در theory قابل بازتولید باشد. و run_stopped دلیل را حمل می‌کند، یعنی fieldای که یک ticket پشتیبانی را به پاسخ یک‌خطی تبدیل می‌کند: agentای که به budget رسید و agentای که crash کرد از بیرون یکسان به نظر می‌رسند و پاسخ‌های مخالف لازم دارند.

فصل 13 time to first token را روی سخت‌افزاری که مالک آن هستید اندازه گرفت. فصل 14 آن را از طریق socket اندازه گرفت. یک agent آن را ضرب می‌کند، و multiplier عددی است که هیچ‌کس انتخاب نکرده:

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

همان task سه‌نوبتی، با تغییر فقط latency ارائه‌دهنده:

latency ارائه‌دهنده در هر نوبتwall clock، 3 نوبت
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

خود harness به یک run سه‌نوبتی پانزده میلی‌ثانیه اضافه می‌کند. هر چیز دیگر NN است ضربدر عددی که کنترلش نمی‌کنید — داخل serving schedulerای تنظیم شده که request شما را با requestهای غریبه‌ها batch می‌کند6 — و NN را مدل انتخاب می‌کند. برای همین streaming فصل 14 اینجا مهم‌تر از chat است و کمتر کمک می‌کند: می‌توانید نوبت آخر را stream کنید، و چهار نوبت قبل از آن سکوت‌اند مگر اینکه harness progress emit کند. این کل استدلال برای event tool_progress بالاست — در یک agent، واحد صادقانه feedback، token نیست؛ step است.

همان harness، با یک مدل واقعی پشت port

لینک به بخش: همان harness، با یک مدل واقعی پشت port

همه چیز بالا علیه scripted provider اجرا شد، که harness را ثابت می‌کند و درباره مدل‌ها هیچ چیز ثابت نمی‌کند. پس یک خط را عوض کنید — seam فصل 14، LLM_BASE_URL — و همان کد را به یک Qwen2.5-0.5B-Instruct محلی با همان چهار ابزار وصل کنید. شش task روی همان سه فایل:

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

سه finding، و سومی دلیل وجود این بخش است.

تک‌تک taskها دقیقاً در دو نوبت تمام شدند. سقف نوبت هرگز اجرا نشد، budget هرگز اجرا نشد، و تنها خروجی حلقه تولید نثر توسط مدل بود. یک مدل نیم‌میلیاردپارامتری iterate نمی‌کند؛ در نفس دومش جواب می‌دهد، چه چیزی را که لازم دارد داشته باشد چه نداشته باشد. تعداد نوبت property مدل است، نه حلقه شما.

میانگین هر نوبت 6,908 میلی‌ثانیه بود، پس جدول latency بالا اسباب‌بازی نیست: در این اندازه، یک run فرضی هشت‌نوبتی تقریباً یک دقیقه wall clock است با هیچ چیز روی صفحه.

و جواب‌ها غلط‌اند. بزرگ‌ترین فایل errors.log است؛ مدل فایل‌ها را فهرست کرد، هرگز آن‌ها را نخواند، و بااین‌حال یکی را نام برد. task اول نام فایلی را حدس زد، به او گفته شد وجود ندارد، و نتیجه‌گیری کرد. harness در هر شش run بی‌نقص اجرا شد. harness یک agent را قابل حاکمیت می‌کند، نه درست — فصل 29 راه فهمیدن اینکه کدام است را نشان می‌دهد، و فصل 30 نشان می‌دهد وقتی هیچ‌کس این را نفهمیده باشد چه هزینه‌ای دارد.

subagentها، اینجا نام‌گذاری می‌شوند و بعداً charge می‌شوند

لینک به بخش: subagentها، اینجا نام‌گذاری می‌شوند و بعداً charge می‌شوند

یک ابزار در کاتالوگ می‌تواند پشت خودش run دیگری داشته باشد. interface همان interface فصل 18 است — یک schema و یک endpoint — و یک agent کامل می‌تواند پشت آن جا بگیرد چون آن interface باریک است:

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";
  },
};

سه چیز همین حالا در آن ده خط درست است و هر سه پیامد تصمیم‌هایی هستند که بالا گرفته شدند: child window خودش را دارد، پس transcript والد به‌جای هر چیزی که child خوانده، یک summary می‌گیرد؛ limitهای خودش را دارد، پس child runaway نمی‌تواند budget والد را خرج کند؛ و signal را به ارث می‌برد، پس یک Stop کل tree را cancel می‌کند. اینکه چرا window تمیز نکته اصلی است نه side effect، موضوع فصل 24 است؛ پنج pattern orchestration — prompt chaining، routing، parallelisation، orchestrator-workers، evaluator-optimiser — و handoff در فصل 25 هستند.

frameworkها کجا هستند، و چرا این دوره از هیچ‌کدام استفاده نکرد

لینک به بخش: frameworkها کجا هستند، و چرا این دوره از هیچ‌کدام استفاده نکرد

هیچ‌چیز بالا نباید به‌عنوان استدلالی علیه libraryها خوانده شود. اندازه‌گیری‌شده در 7 سپتامبر 2026، برای ماه منتهی به 29 اوت:7

packageدانلودهای آن ماهچه چیزی به شما می‌دهد
ai (Vercel AI SDK)89,385,860ToolLoopAgent، stopWhen، approval ابزار، step hookها
@anthropic-ai/claude-agent-sdk41,558,352Claude Code harness به‌عنوان library: حلقه، sessionها، hookها، permissionها، subagentها8
@langchain/langgraph12,812,815حلقه به‌عنوان state graph صریح
langchain11,359,058chainها، agentها، integrationها
@openai/agents6,093,155agentها، handoffها، guardrailها
@mastra/core5,914,502agentها، workflowها، memory

دلیل اینکه این دوره حلقه را دستی می‌نویسد به‌جای اینکه یکی از آن‌ها را آموزش دهد، اعلام می‌شود نه تلویحی، و قابل اندازه‌گیری است. در دوازده ماه تا 7 سپتامبر 2026، ai 945 نسخه منتشر کرد و از major 5 به major 7 رفت، و class agent آن هنوز به‌صورت Experimental_Agent export می‌شود؛ langchain در همان بازه 132 نسخه منتشر کرد؛ @openai/agents 83 نسخه منتشر کرد و پانزده ماه پس از اولین release هنوز روی 0.x است.7 فصلی که علیه هرکدام از آن APIها نوشته شود ظرف یک فصل stale می‌شود، و این فصل به سی‌وسه زبان منتشر می‌شود، پس هر re-edition هزینه کل translation را دارد. چیزی که زیر همه آن‌هاست حرکت نمی‌کند: یک حلقه، یک stopping rule، یک کاتالوگ، یک executor، مقداری state.

و reference implementation در بخش مهم با این فصل موافق است. در ai نسخه 7.0.93، خروجی حلقه یک عدد نیست — stopWhen است، فهرستی از predicateها، که step count فقط یکی از آن‌هاست: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

Stopping در پرکاربردترین پیاده‌سازی این حلقه جمع است، به همان دلیلی که در صد و نود و شش خط بالا جمع است.

بعد از این به کجا می‌رویم

لینک به بخش: بعد از این به کجا می‌رویم

حالا یک harness دارید: یک حلقه، یک کاتالوگ، یک executor، پنج راه خروج، یک run persisted، signalای که به ابزارها می‌رسد، و traceای که روی هر خط run id دارد. فصل‌های 24، 25، 29 و 30 روی این فایل ساخته می‌شوند، و 26 تا 28 روی چیزهایی که این فایل می‌تواند به آن‌ها برسد.

یک مشکل باقی مانده، و اندازه‌گیری‌های بالا تمام مدت به آن اشاره کرده‌اند. دوباره به جدول runaway نگاه کنید: 3,431 input token در هشت نوبت، 337,299 در صد نوبت. به run کارآمد نگاه کنید: 204، 269، 342. هر نوبت کل transcript را دوباره می‌فرستد، پس context یک agent با history خودش پر می‌شود — و مدل در استفاده از انتهای دور یک window بلند بدتر از انتهای نزدیک آن است، برای همین agent خوب در نوبت پنج در نوبت چهل سردرگم می‌شود.

سقف نوبت این را fix نمی‌کند. فقط جلوی شما را می‌گیرد که پول بدهید و تماشایش کنید. چیزی که fix می‌کند، تصمیم گرفتن در تک‌تک نوبت‌هاست که کدام tokenها شایسته window هستند: چه چیزی compact شود، چه چیزی به noteای منتقل شود که agent بتواند fetch کند، چه چیزی به subagent با window تمیز سپرده شود، و کدام تعریف‌های ابزار ارزش مالیات دائمی‌شان را دارند. فصل 24 اندازه می‌گیرد window واقعاً کجا می‌رود — و نکته غافلگیرکننده این است که آنجا مکالمه نیست.


هر عددی در این فصل از دو server توصیف‌شده بالا بیرون آمد، روی Node 22 از طریق loopback interface: scripted providerای که tokenها را با encoding o200k_base می‌شمرد، و Qwen/Qwen2.5-0.5B-Instruct پشت endpointای با همان شکل، greedy decoding، روی CPU. هزینه‌ها از token countهای اندازه‌گیری‌شده با نرخ‌هایی محاسبه شده‌اند که فصل 16 در 6 سپتامبر 2026 خواند — $2.00 برای هر میلیون input token و $12.00 برای هر میلیون output — و هیچ requestی در این فصل به endpoint پولی نرفت. جواب‌های مدل محلی، جواب‌های یک مدل کوچک‌اند؛ آن‌ها را به‌عنوان evidence درباره حلقه بخوانید، که در هر دو حالت یکسان است، نه به‌عنوان 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 traceها و actionهایی که حلقه پیاده‌سازی می‌کند، و منبع این مشاهده که acting به مدل اجازه می‌دهد «exceptionها را handle کند» — دقیقاً همان چیزی که جدول خطای ابزار بالا اندازه می‌گیرد.

  2. Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). treatment formal کاری که حلقه بالا informal انجام می‌دهد: componentهای memory ماژولار، action space ساختاریافته که memory داخلی و محیط‌های خارجی را در بر می‌گیرد، و «یک process generalized decision-making برای انتخاب actionها». آن را برای vocabularyای بخوانید که اصطلاح صنعتی کم دارد — مخصوصاً جداسازی working، episodic، semantic و procedural memory، که سایه عملی‌اش جدول سه-store فصل 24 است.

  3. ai (Vercel AI SDK) نسخه 7.0.93، منتشرشده در 4 سپتامبر 2026؛ declarationهای type از cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts در 7 سپتامبر 2026 خوانده شدند. فایل 397 KB شامل صفر occurrence از رشته harness است. class agent برابر declare class ToolLoopAgent است، هم به‌صورت ToolLoopAgent و هم به‌صورت Experimental_Agent export شده؛ declare function isStepCount(stepCount: number) — exportشده به‌صورت stepCountIs — بالا verbatim نقل شده؛ type StopCondition بدون type parameter دومش (RUNTIME_CONTEXT extends Context = Context) نشان داده شده، که تنها elision در excerpt است، همان‌طور که شکل stopWhen?: Arrayable<StopCondition<...>> روی generateText و streamText نیز چنین است. همان فایل toolApproval، ToolApprovalStatus، prepareStep و repairToolCall را declare می‌کند، یعنی reference implementation مستقلانه به approval gateها، آماده‌سازی per-step و error repair رسیده است. 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). چکیده، artefact را یک «evaluation framework» شامل 2,294 مسئله می‌نامد و هرگز از واژه «harness» استفاده نمی‌کند؛ README خود پروژه (github.com/SWE-bench/SWE-bench، خوانده‌شده در 7 سپتامبر 2026) پنج بار از آن استفاده می‌کند، همیشه به‌صورت «evaluation harness»، و entry point برابر python -m swebench.harness.run_evaluation است. این معنای دیگر واژه است: scaffoldای که agent را ثابت نگه می‌دارد و امتیازش می‌دهد، نه حلقه‌ای که آن را اجرا می‌کند.

  5. Anthropic, Building effective agents, 19 دسامبر 2024، anthropic.com/engineering/building-effective-agents، خوانده‌شده در 7 سپتامبر 2026. augmented model به‌عنوان building block، agent به‌عنوان LLMای که «با استفاده از ابزارها بر اساس feedback محیطی در یک حلقه» کار می‌کند، و توصیه stopping conditionهایی «مانند حداکثر تعداد iterationها» برای حفظ کنترل. فصل 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ای که request شما را با requestهای غریبه‌ها batch می‌کند و KV cache فصل 13 را مدیریت می‌کند. دقیقاً چون مال شما نیست، دانستن اینکه وجود دارد ارزش دارد: latencyای که harness شما ضرب می‌کند داخل آن تعیین می‌شود، و هیچ مقدار کار روی حلقه شما آن را جابه‌جا نمی‌کند.

  7. شمار دانلودهای npm registry، api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>، یک window صریح به‌جای window rolling last-month، و release historyها از registry.npmjs.org/<package>؛ هر دو در 7 سپتامبر 2026 query شدند. release countها تعداد versionهای منتشرشده در دوازده ماه تا آن تاریخ هستند، شامل canary buildها: ai 945 (آخرین 7.0.93 در 2026-09-04، با major versionهای 5، 6 و 7 که همگی داخل window ظاهر شده‌اند)، 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 است که به‌صورت library بسته‌بندی شده — حلقه agent، ابزارهای built-in فایل و shell، context management، sessionها، hookها، permissionها و subagentها — و در code.claude.com/docs/en/agent-sdk مستند شده. این نزدیک‌ترین چیز به یک روایت منتشرشده از هر مکانیزمی است که این فصل دستی می‌سازد، و خواندنش کنار implementation خودتان برای بخش‌هایی که نام می‌برد و این فصل فقط به آن‌ها اشاره می‌کند ارزش دارد.


تهیه‌شده توسط

David Vicente Campos

بنیان‌گذار NeuraLIA Labs و هم‌بنیان‌گذار MyRealFood

من مهندس کامپیوتر و فارغ‌التحصیل دانشگاه لئون هستم. هم‌بنیان‌گذار MyRealFood بودم، جایی که به‌عنوان مدیر ارشد فناوری اپلیکیشنی را ساختم که میلیون‌ها نفر برای سالم‌تر غذا خوردن از آن استفاده کرده‌اند، و NeuraLIA Labs را بنیان‌گذاری کردم؛ جایی که محصولات هوش مصنوعی می‌سازم. اینجا از چیزهایی می‌نویسم که در طول مسیر باید می‌فهمیدم، همان‌طور که دوست داشتم کسی برایم توضیح می‌داد.

بیشتر درباره نویسنده

منتشرشده توسط NeuraLIA Labs.

پست‌های جدید را در ایمیل خود دریافت کنید

اخبار AI، راهنماها و به‌روزرسانی‌های محصول — هر وقت چیزی ارزشمند منتشر کنیم، یک ایمیل کوتاه می‌فرستیم.

فهرست دوره

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev12 دقیقه مطالعه

مدل هوش مصنوعی Jev برای تصمیم ساخته شده، نه نثر

Jev از TypeSafe AI توجه‌ها را جلب کرده چون هوشمندی نرم‌افزار را مسئله‌ای احتمالاتی می‌بیند: شاخه درست را انتخاب کنید، میزان اطمینان را کنار آن بگذارید، و وقتی کد به یک تصمیم نیاز دارد برای نوشتن متن به یک LLM پول ندهید.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering13 دقیقه مطالعه

مهندسی کانتکست برای عامل‌های AI بلندافق

عامل‌های طولانی‌اجرا فقط به‌خاطر کوچک بودن پنجره شکست نمی‌خورند. وقتی فایل‌ها، خروجی ابزارها و تاریخچهٔ کهنه وظیفه‌ای را که عامل قرار بود تمام کند کنار می‌زنند، شکست رخ می‌دهد.

آماده‌اید انتخاب مدل را به LIA بسپارید؟

با همه مدل‌های هوش مصنوعی در یک جا بسازید — همین امروز رایگان شروع کنید.