สร้าง agent harness: ลูปและห้าทางออกของมัน
ลูป 15 บรรทัดที่รันผ่านตั้งแต่ครั้งแรก แล้วจงใจทำให้พัง 7 แบบ เริ่มจาก runaway ที่แพงกว่าแบบจำกัดแน่น 77 เท่า
ในหน้านี้
เริ่มจากส่วนที่ตรงไปตรงมาก่อน เพราะคงไม่มีใครพูด: “harness” เป็นศัพท์วงใน ไม่ใช่มาตรฐาน ไม่มีสเปก ไม่มีคณะกรรมการ ไม่มีนิยามอ้างอิง งานวิจัยสี่ฉบับที่บทนี้อ้างถึง — ReAct,1 CoALA,2 SWE-bench และ vLLM — ไม่ได้ใช้คำนี้เลยแม้แต่ครั้งเดียวในบทคัดย่อ implementation ที่มีคนดาวน์โหลดมากที่สุดของสิ่งนี้ แพ็กเกจ ai ของ Vercel ที่มียอดดาวน์โหลด 89.4 ล้านครั้งต่อเดือน ก็ไม่ได้ใช้คำนี้เช่นกัน: สตริง harness ปรากฏเป็นศูนย์ครั้งใน type declarations ขนาด 397 KB ที่มากับเวอร์ชัน 7.0.933 ที่เดียวที่คำนี้ รับน้ำหนักความหมายจริง กลับหมายถึงอีกอย่างโดยสิ้นเชิง SWE-bench พูดคำว่า “harness” ห้าครั้งใน README และหมายถึง evaluation harness เสมอ — โครง containerised ที่นำ patch ไปใช้และรันเทสต์ — และโมดูล Python ของมันชื่อ swebench.harness.run_evaluation ตรงตัว4
ดังนั้นของสองอย่างที่ต่างกันจึงใช้ชื่อเดียวกัน evaluation harness ตรึง agent ไว้และให้คะแนนมัน ส่วน agent harness คือโปรแกรมที่รัน agent: มันเรียก model, execute สิ่งที่ model ขอ, ตัดสินใจว่าจะหยุดเมื่อไร และเก็บ state ระหว่างนั้น บทนี้จะสร้างอย่างที่สอง ภายใน TypeScript ไม่ถึงสองร้อยบรรทัด โดยไม่ใช้ framework เลย
ตัวลูปเองมี 15 บรรทัด และทำงานได้ตั้งแต่ลองครั้งแรก ทุกอย่างหลังจากนั้นคือวิธีออกจากมัน
แสดงรายละเอียด
บทนี้ต้องใช้สิ่งใดจากบทก่อนหน้า
- บทที่ 14 สำหรับ client: deadlines, การ triage สถานะ, cancellation, idempotency keys และเทคนิค mock provider ที่ใช้ซ้ำที่นี่
- บทที่ 16 สำหรับเลขคำนวณ: input tokens โตตามกำลังสองของ conversation และ rate ที่ใช้ด้านล่างคือ rate ที่อ่านไว้ในบทนั้นเมื่อ 6 กันยายน 2026
- บทที่ 18 สำหรับ tool catalogue: schema ที่ model เห็น, endpoint ที่มันไม่เคยเห็น และกฎว่า errors คือ context ไม่ใช่ exceptions
- บทที่ 22 สำหรับลูปที่บทนี้รับช่วงมา และสำหรับนิยาม “agent” ที่เผยแพร่แล้วสองนิยามซึ่งไม่เห็นตรงกัน
ไม่มี tensor ที่นี่ นี่คือ dependency hub แห่งที่สองของคอร์ส: บทที่ 24, 25, 29 และ 30 รันบนไฟล์ด้านล่าง และบทที่ 26 ถึง 28 สร้างต่อจากสิ่งที่มันเอื้อมถึงได้
Provider ที่คุณสคริปต์ได้
ลิงก์ไปยังส่วน: Provider ที่คุณสคริปต์ได้บทที่ 14 เขียนกับ provider จริงไม่ได้ เพราะคุณสั่งให้มันส่ง 429 ณ จังหวะที่เลือกเองไม่ได้ บทนี้มีปัญหาเดียวกันในรูปทรงต่างออกไป: คุณสั่งให้ model จริง runaway หรือขอ tool เดิมด้วยอาร์กิวเมนต์เดิมติดกันสองรอบ แบบตามสั่งและทำซ้ำได้ ไม่ได้
ดังนั้นโปรแกรมแรกคือ scripted provider: endpoint ที่มีรูปทรงเหมือน chat completions API โดยคำตอบเป็นฟังก์ชันของ turn index และสิ่งที่ tools ส่งกลับมาถึงตอนนั้น มันนับ tokens ด้วย byte-pair encoder จริง ดังนั้นเงินด้านล่างคือเลขคำนวณ ไม่ใช่ของประดับ
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);มีสองบรรทัดที่แบกดีไซน์ไว้ turn index derived จาก conversation ไม่ได้เก็บไว้ในตัวแปร ดังนั้น provider จึง stateless และ run หนึ่งถูกฆ่าแล้ว resume กลับมาหามันได้ และ recover อ่าน tool results ก่อนตัดสินใจ: scripted model ที่อ่าน transcript ของตัวเองคือขั้นต่ำที่ต้องมีเพื่อวัดว่า harness ให้สิ่งที่คุ้มค่าแก่การอ่านกับมันหรือไม่
catalogue คือของบทที่ 18 มี tools สี่ตัวในสามไฟล์: list_files, read_file, delete_file — ทำเครื่องหมาย needsApproval — และ scan_archive ซึ่งช้าโดยตั้งใจ
ลูปที่ทำงานได้
ลิงก์ไปยังส่วน: ลูปที่ทำงานได้นี่คือไอเดียทั้งหมด ก่อนส่วนอื่นใดที่ทำให้มันรอดในโลกจริง
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 แล้วมันก็ทำสิ่งที่หน้าตามันบอกว่าจะทำพอดี:
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สาม turns, tool executions สองครั้ง, หนึ่งในสี่ของเซนต์สหรัฐ สังเกตบรรทัดสุดท้าย: 204, 269, 342 ทุก turn ส่งทุกอย่างก่อนหน้ากลับไปใหม่ ซึ่งคือบิลกำลังสองจากบทที่ 16 ที่มาถึงในที่ที่ไม่มีใครพิมพ์อะไรเพิ่ม ส่วนที่เหลือของบทนี้คือสิ่งที่จะเกิดเมื่อบรรทัดนั้นไม่หยุดโต
พังครั้งที่หนึ่ง: งานที่ไม่จบสักที
ลิงก์ไปยังส่วน: พังครั้งที่หนึ่ง: งานที่ไม่จบสักทีชี้ลูปเดิมไปที่สคริปต์ runaway — model ที่ขอ tool ทุก turn และไม่เคย emit prose — แล้ว return ที่ทำเครื่องหมายไว้จะไม่ถูกเรียก ไม่มีทางออกอื่น โปรแกรมรันไปจนกว่า process จะตาย หรือบัตรเครดิตจะตาย
วิธีแก้คือบรรทัดเดียว เป็น control แรกที่วรรณกรรมแนะนำ,5 และสุดท้ายทุกคนก็เขียนมัน สิ่งที่แทบไม่มีใครทำคือวัดว่ามันคุ้มค่าแค่ไหน:
| turn cap | model calls | input tokens | cost |
|---|---|---|---|
| 8 | 8 | 3,431 | $0.009070 |
| 20 | 20 | 16,259 | $0.038038 |
| 50 | 50 | 88,649 | $0.191098 |
| 100 | 100 | 337,299 | $0.702198 |
อ่านสองแถวสุดท้ายคู่กัน การเพิ่ม cap จาก 50 เป็น 100 ไม่ได้ทำให้ cost เพิ่มสองเท่า แต่มันคูณด้วย 3.7 input tokens เพิ่มจาก 88,649 เป็น 337,299 หรือ 3.8 เท่า เพราะ turn แบกทุก turn ก่อนหน้ามาด้วย และผลรวมคือ turn cap ไม่ใช่ปุ่มหมุนเชิงเส้น มันเป็นปุ่มหมุนบน รากที่สอง ของ worst case ของคุณ ซึ่งเป็นเหตุผลว่าทำไมการเพิ่มจาก 20 เป็น 100 “เผื่อไว้ให้ปลอดภัย” จึงเป็นการตัดสินใจที่ควรตีราคาเสียก่อน
พังครั้งที่สอง: cap จำนวน turns ไม่ใช่ cap เงิน
ลิงก์ไปยังส่วน: พังครั้งที่สอง: cap จำนวน turns ไม่ใช่ cap เงินปัญหาของ turn cap คือ turn ไม่มีราคาคงที่ ยี่สิบ turns บน transcript สั้น ๆ cost $0.038 ตามด้านบน ยี่สิบ turns ที่มี tool catalogue 200 รายการ, ชุดเอกสารที่ retrieve มา และประวัติ 40 messages cost ได้มากกว่านั้นหลายร้อยเท่า และ cap ไม่รู้เรื่องนี้ สิ่งที่ operator ต้องการจำกัดคือบิล
ดังนั้นลูปจึงนับเงิน โดยใช้ computeCost จากบทที่ 16 กับ rate ที่อ่านไว้ — $2.00 ต่อ input tokens หนึ่งล้าน และ $12.00 ต่อ output หนึ่งล้าน สำหรับ model ที่ใช้ราคาเดียวกันตลอดคอร์สนี้:
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);สคริปต์ runaway เดิม ไม่มี turn cap เลย งบสามระดับ:
| budget | turns reached | actually spent |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
มีสองเรื่องที่ควรเรียกชื่อ เรื่องแรก budget ซื้อจำนวน turns ที่ ต่างกัน ในแต่ละครั้ง ซึ่งนี่แหละคือประเด็น: มันจำกัดสิ่งที่ operator สนใจ และปล่อยให้จำนวน turns ตกไปตาม transcript เรื่องที่สอง ทุกแถวจ่ายเกิน budget คือ $0.010 แต่ใช้ไป $0.010780 เพราะการเช็กเกิดก่อน turn และราคาของ turn ยังไม่รู้จนกว่ามันจะจบ คุณจำกัด spend ให้เป๊ะไม่ได้ คุณจำกัดได้ภายใน cost ของหนึ่ง turn จงพูดแบบนั้นใน interface แทนการเสแสร้ง และวาง check ไว้ก่อน call เพื่อให้จ่ายเกินหนึ่ง turn ไม่ใช่สอง
ห้าทางออกจากลูป ไม่ใช่ทางเดียว
ลิงก์ไปยังส่วน: ห้าทางออกจากลูป ไม่ใช่ทางเดียวถึงตอนนี้ลูปมีทางออกสามทางแล้ว และรูปทรงของบทที่เหลือเริ่มชัด production run จบด้วยหนึ่งในห้าวิธีพอดี และพวกมันไม่ใช่ variation ของกันและกัน:
| how it ends | who decided | what the caller should do |
|---|---|---|
| model หยุดขอ | model | อ่านคำตอบ |
| turn cap | คุณ ล่วงหน้า | เพิ่ม cap หรือยอมรับ partial result |
| budget exhausted | คุณ ล่วงหน้า | อนุมัติเงินเพิ่ม หรือยอมรับ partial result |
| error ที่ retry ไม่ได้ | provider หรือ tool | แก้ deployment; triage ของบทที่ 14 เป็นตัวตัดสิน |
| human intervened | คน | รอคำตัดสิน แล้ว resume |
การยุบทั้งหมดนี้ให้เหลือ boolean เดียวคือ design mistake ที่พบบ่อยที่สุดในไฟล์นี้ และมันแพงในแบบเฉพาะ: สามในห้าอย่าง resumable และอีกสองอย่างไม่ใช่ agent ที่ชน turn cap ยังมี transcript ที่ valid, partial result จริง และ next step; agent ที่เจอ 401 ไม่มีสิ่งเหล่านั้นเลย ดังนั้น harness จึงบันทึก reason เป็น data:
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 };พังครั้งที่สาม: tool ล้มเหลว
ลิงก์ไปยังส่วน: พังครั้งที่สาม: tool ล้มเหลวบทที่ 18 จบด้วย claim ที่ไม่มีตัวเลข: ส่ง error ของ tool กลับให้ model เป็น tool result แทนที่จะ raise แล้ว model มักแก้ตัวเองได้ นี่คือตัวเลข
ความล้มเหลวหนึ่งครั้ง policy สามแบบ scripted model เดาชื่อไฟล์ที่ไม่มีอยู่; tool throws no such file: timeout.log. Call list_files to see what exists.
| what the harness does with the error | turns | tool runs | cost | what the user got |
|---|---|---|---|---|
| throws it out of the loop | 1 | 1 | $0.000756 | stack trace |
returns Error: the tool failed. | 2 | 1 | $0.001462 | “ฉันอ่านไฟล์ไม่ได้ จึงไม่ทราบ” |
| returns what actually happened | 4 | 3 | $0.003550 | “errors.log พูดถึง timeout” |
แถวที่สาม cost เป็น 4.7 เท่าของแถวแรก และเป็นแถวเดียวที่ตอบคำถามได้ ส่วนแถวที่สองคือแถวที่น่าสนใจ เพราะนั่นคือสิ่งที่ codebase ส่วนใหญ่ทำจริง: error ถูก catch แล้ว, ลูปรอด, model ได้รับการบอกว่า มี บางอย่างล้มเหลว แต่ไม่ได้บอกว่า อะไร และมันยอมแพ้อย่างสุภาพ ความต่างระหว่างแถวสองกับแถวสามไม่ใช่ error handling มันคือประโยคที่เขียนให้ผู้อ่าน
ดังนั้น harness จึงปฏิบัติกับ tool ที่ thrown เป็น data และทำให้ wording เป็น policy:
} 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 เตือนอีกด้านไว้ด้วย และมันก็มีราคาเช่นกัน ชี้ลูปไปที่ tool ที่ fail ด้วยเหตุผลที่ไม่มี message ใดแก้ได้ — read ที่ process ไม่มีสิทธิ์ทำ — แล้ว model จะ retry มันตลอดไป:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""สิบเอ็ด executions ที่เหมือนกันของ call ที่ไม่มีทางสำเร็จ, 5.2 เท่าของ cost ของ run ที่ recover จาก error ที่แก้ได้ และไม่มีอะไรปลายทาง Errors คือ context; permanent error คือ context ที่ทำให้ run ที่เหลือเป็นพิษ ความแตกต่างนี้คือ status triage ของบทที่ 14 ที่ย้ายขึ้นมาหนึ่งชั้น: error ที่ model ลงมือแก้ได้กลับเข้า transcript และ error ที่มันแก้ไม่ได้ควรหยุด run พร้อม reason วันนี้ turn cap คือสิ่งที่คั่นระหว่างคุณกับกรณีที่สอง ซึ่งเป็นพื้นขั้นต่ำ ไม่ใช่วิธีแก้
พังครั้งที่สี่: call เดิม สองครั้ง
ลิงก์ไปยังส่วน: พังครั้งที่สี่: call เดิม สองครั้งคราวนี้เป็นความล้มเหลวที่คนส่วนใหญ่คิดว่าเกิดไม่ได้ Models ทำซ้ำตัวเองได้ ปล่อยให้ลูปใด ๆ รันนานพอ แล้วคุณจะเห็น tool เดิมกับ arguments เดิมเป๊ะในสอง turns ติดกัน
วัดเทียบกับ baseline ของงานเดียวกันที่ไม่มีการ repeat:
| turns | tool runs | cost | |
|---|---|---|---|
| งานนั้น ไม่มี repeat | 2 | 1 | $0.001396 |
| งานเดียวกัน call หนึ่งถูก repeat | 3 | 2 | $0.002446 |
| repeat พร้อม result cache บน read-only tools | 3 | 1 | $0.002446 |
call ที่ซ้ำ cost เพิ่ม $0.001050 หรือเพิ่มขึ้น 75 % และนี่คือส่วนที่ทำให้คนแปลกใจ: caching result กู้คืนได้ ศูนย์ deduplication ประหยัด tool execution ไม่ได้ประหยัด turn เพราะเมื่อ code ของคุณสังเกตว่า repeat model ก็ได้รับเงินไปแล้วสำหรับการถามนั้น การประหยัดมีจริงเมื่อ tool ช้า, rate-limited หรือคิดเงินต่อ call — และเป็นศูนย์ใน line item ที่โตขึ้น
มีเวอร์ชันที่แย่กว่า ใช้ cache เดียวกันกับ tool ที่เขียนข้อมูล แล้ว call ที่สองจะไม่เกิดขึ้นอย่างเงียบ ๆ:
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 บอกว่านี่คือสอง calls: มันถือค่า tool_call_id คนละค่า arguments บอกว่ามันอาจเป็นอันเดียวกัน harness ที่ตัดสินด้วยการเทียบสตริง arguments วันหนึ่งจะกลืนรายการ charge ที่ตั้งใจให้เกิดเป็นครั้งที่สองแต่เหมือนกันทุกอย่าง — และบทที่ 14 ตั้งชื่อ mechanism เดียวที่แก้เรื่องนี้อย่างซื่อสัตย์ไว้แล้ว นั่นคือ idempotency key ที่สร้างต่อ logical operation โดยเลเยอร์ที่รู้ว่า operation นั้น คืออะไร จนกว่า tool จะพก key นั้นมา default ที่ป้องกันตัวเองได้คือ gate read-only ด้านบน: cache reads, execute writes และปล่อยให้ idempotency ของ write จัดการส่วนที่เหลือ
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 list ไฟล์ แล้วขอ delete ไฟล์หนึ่งที่ task ไม่เคยพูดถึง ไม่มีอะไรในลูปจนถึงตอนนี้หยุดมันได้
tool ที่ทำเครื่องหมาย needsApproval ไม่ fail และไม่ proceed มัน หยุด run และคืน control พร้อมทุกอย่างที่คนต้องใช้ตัดสินใจ:
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) });
}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."นั่นคือ mechanism ทั้งหมด และเหตุผลที่มันเป็น return แทน callback อยู่ในหัวข้อถัดไป: ระหว่าง stop กับ verdict, process อาจไม่อยู่แล้วก็ได้
แต่ก่อนอื่น มีการวัดที่ไม่มีใครคาด การ reject ไม่ใช่การไม่มี result — transcript มี slot ที่ keyed ด้วย tool_call_id และต้องมีบางอย่างใส่เข้าไป รัน rejection เดิมสองครั้ง โดยเปลี่ยนแค่ว่าบางอย่างนั้นพูดว่าอะไร:
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 และใน run ที่สอง user ถูกบอกว่ามันถูกลบแล้ว permission system ทำงานสมบูรณ์แบบ; report เป็นคำโกหก มันคือ mechanism เดียวกับตาราง tool-error ที่มาถึงจุดที่สำคัญกว่ามาก — human ปฏิเสธ, action ถูก block อย่างถูกต้อง และ summary ของ agent ขัดกับความจริง เพราะ refusal ไม่เคยถูกเขียนลงในที่ที่ model อ่าน กฎที่ได้จึงสั้น: ไม่ว่า code ของคุณตัดสินอะไรเกี่ยวกับ tool call ให้เขียนการตัดสินใจนั้นลง transcript เป็นคำพูด บทที่ 30 จะกลับมาหาเรื่องนี้จากฝั่ง security ซึ่งมันคือความต่างระหว่าง audit trail กับนิยาย
พังครั้งที่หก: process ตาย
ลิงก์ไปยังส่วน: พังครั้งที่หก: process ตายapproval ใช้เวลาหลายนาทีหรือหลายชั่วโมง deploy ใช้เวลาไม่กี่วินาที ถ้า run อยู่ใน local variable ภายใน HTTP request ทุก restart คือ run ที่หายไป และทุก approval คือ race
ดังนั้น run ไม่ใช่ closure มันคือ plain serialisable object — messages, turn count, cost, status, interruption, รายการ approved call ids — และลูปคือ pure function เหนือมัน constraint เดียวนี้ทำให้ persistence กลายเป็นเรื่องบรรทัดเดียว:
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 ไม่ใช่การ save แต่คือสิ่งที่เกิดตอนกลับเข้ามา และคำตอบแบบ naive ทำให้คุณถูกคิดเงินซ้ำ ถ้า process ตายหลังจาก model ขอ tool แต่ก่อน result ถูกเขียน resume ที่เริ่มด้วยการเรียก model อีกครั้งจ่ายเงินให้ turn ที่มันมีอยู่แล้ว — และถ้ามันเริ่มด้วยการ re-run tools มันจะ perform write สองครั้ง
วิธีแก้คือทำให้ลูปเริ่มด้วยการถาม transcript ว่ามีอะไร outstanding:
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 drain pending ก่อน และถาม model ก็ต่อเมื่อไม่มีอะไร outstanding แล้ว Resume กลายเป็น code path เดียวกับแบบปกติ และ approval ก็เช่นกัน — approved call เป็นเพียง pending call ที่ตอนนี้ได้รับอนุญาตให้รัน ฆ่า process กลาง task แล้ว restart มัน:
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.logtool executions สองครั้งข้ามสอง processes สำหรับ task ที่ต้องใช้สองครั้ง และ final cost เหมือนกับ run ที่ไม่เคย crash cost สะสมข้าม restart เพราะมันอยู่ใน state ไม่ได้อยู่ในตัวแปร
พังครั้งที่เจ็ด: ความเงียบสามนาที
ลิงก์ไปยังส่วน: พังครั้งที่เจ็ด: ความเงียบสามนาทีscan_archive ใช้เวลาสามวินาทีที่นี่ และแทน tool ที่ใช้เวลาสามนาทีใน production มีสองสิ่งหายไปขณะมันรัน: user ไม่รู้เลยว่ามีอะไรเกิดขึ้น และปุ่ม Stop ไม่ทำอะไร
ทั้งสองอย่างแก้ด้วยวิธีเดียวกัน และมันคือ AbortSignal จากบทที่ 14 ที่ดันลงไปลึกอีกชั้น signal ไม่ได้มีไว้สำหรับ fetch เท่านั้น — มันถูกส่ง เข้าไปใน tool และ tool ที่เขียนดีจะเคารพมัน:
result = await tool.run(JSON.parse(c.function.arguments), {
signal,
progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});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"สอง milliseconds จาก click ถึง stop เพราะ sleep ภายใน tool ฟัง signal เดียวกับ fetch ถ้าสอดมันเข้าไปแค่ fetch ปุ่ม Stop เดียวกันจะรอสามวินาที — ความยาวของ tool — และ run “cancel” หลังจากงานที่มันกำลัง cancel เสร็จไปแล้ว Cancellation ที่ไม่ได้เดินท่อลงไปจนสุดคือ spinner ที่พูดคำถูกต้อง
Trace และเหตุผลที่มันไม่ใช่ log
ลิงก์ไปยังส่วน: Trace และเหตุผลที่มันไม่ใช่ logharness emit หนึ่งบรรทัดต่อ event และ vocabulary เล็กพอให้จำได้: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped
{"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}สาม properties ทำให้สิ่งนี้เป็น trace ไม่ใช่ logging ทุกบรรทัดมี run id ดังนั้น run ที่กินสาม processes และสองวันคือ query เดียว ทุกบรรทัด turn มี token counts ของตัวเองและ running cost ดังนั้น “ทำไม run นี้ cost สี่สิบดอลลาร์” ตอบย้อนหลังได้ แทนที่จะ reproducible ได้แค่ในทฤษฎี และ run_stopped มี reason ซึ่งเป็น field ที่เปลี่ยน support ticket ให้เป็นคำตอบบรรทัดเดียว: agent ที่หยุดเพราะ budget กับ agent ที่ crash ดูเหมือนกันจากภายนอก และต้องตอบสนองตรงข้ามกัน
เลขคำนวณของ latency
ลิงก์ไปยังส่วน: เลขคำนวณของ latencyบทที่ 13 วัด time to first token บน hardware ที่คุณเป็นเจ้าของ บทที่ 14 วัดผ่าน socket agent คูณมัน และตัวคูณคือจำนวนที่ไม่มีใครเลือก:
งานสาม turns เดิม เปลี่ยนแค่ latency ของ provider:
| provider latency per turn | wall clock, 3 turns |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
harness เองมีส่วนเพิ่มสิบห้า milliseconds ต่อ run สาม turns ทุกอย่างที่เหลือคือ คูณด้วยจำนวนที่คุณควบคุมไม่ได้ — ตั้งอยู่ใน serving scheduler ที่กำลัง batch request ของคุณกับ request ของคนแปลกหน้า6 — และ ถูกเลือกโดย model นี่คือเหตุผลที่ streaming ในบทที่ 14 สำคัญกว่าที่นี่มากกว่าใน chat และช่วยได้น้อยกว่า: คุณ stream turn สุดท้ายได้ และสี่ turns ก่อนหน้าคือความเงียบ เว้นแต่ harness จะ emit progress นี่คือ argument ทั้งหมดสำหรับ event tool_progress ด้านบนเช่นกัน — ใน agent หน่วย feedback ที่ซื่อสัตย์ไม่ใช่ token แต่คือ step
harness เดิม แต่มี model จริงอยู่หลัง port
ลิงก์ไปยังส่วน: harness เดิม แต่มี model จริงอยู่หลัง portทุกอย่างด้านบนรันกับ scripted provider ซึ่งพิสูจน์ harness และไม่พิสูจน์อะไรเกี่ยวกับ models ดังนั้นเปลี่ยนหนึ่งบรรทัด — seam จากบทที่ 14, LLM_BASE_URL — แล้วชี้ code เดิมไปที่ Qwen2.5-0.5B-Instruct บนเครื่อง local พร้อม tools สี่ตัวเดิม หก tasks เหนือสามไฟล์เดิม:
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มีสาม findings และข้อที่สามคือเหตุผลที่หัวข้อนี้มีอยู่
ทุก task จบในสอง turns พอดี turn cap ไม่เคยทำงาน budget ไม่เคยทำงาน และทางออกเดียวของลูปคือ model ผลิต prose model ขนาดครึ่งพันล้านพารามิเตอร์ไม่ iterate; มันตอบในลมหายใจที่สอง ไม่ว่ามันมีสิ่งที่ต้องใช้หรือไม่ จำนวน turns เป็น property ของ model ไม่ใช่ของลูปคุณ
mean turn ใช้เวลา 6,908 milliseconds ดังนั้นตาราง latency ด้านบนไม่ใช่ของเล่น: ที่ขนาดนี้ run สมมติแปด turns คือ wall clock เกือบหนึ่งนาทีโดยไม่มีอะไรบนหน้าจอ
และคำตอบผิด ไฟล์ที่ใหญ่ที่สุดคือ errors.log; model list ไฟล์ ไม่เคยอ่านมัน แล้วก็เลือกชื่อหนึ่งขึ้นมาอยู่ดี task แรกเดาชื่อไฟล์ ถูกบอกว่าไม่มีอยู่ แล้วสรุปจบ harness execute ได้อย่าง flawless ในทั้งหก runs harness ทำให้ agent governable ไม่ได้ทำให้ถูกต้อง — บทที่ 29 คือวิธีหาว่าเป็นอย่างไหน และบทที่ 30 คือราคาที่ต้องจ่ายเมื่อไม่มีใครทำ
Subagents ตั้งชื่อไว้ที่นี่ แล้วค่อยคิดเงินทีหลัง
ลิงก์ไปยังส่วน: Subagents ตั้งชื่อไว้ที่นี่ แล้วค่อยคิดเงินทีหลังหนึ่ง tool ใน catalogue สามารถมีอีก run อยู่ข้างหลังได้ interface คือของบทที่ 18 — schema และ endpoint — และ agent ทั้งตัว fit อยู่ข้างหลังมันได้ เพราะ interface นั้นแคบ:
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";
},
};มีสามสิ่งที่ถูกต้องอยู่แล้วในสิบเส้นนั้น และทั้งสามเป็นผลตามมาจาก decisions ด้านบน: child มี window ของตัวเอง ดังนั้น transcript ของ parent ได้รับ summary แทนที่จะได้ทุกอย่างที่ child อ่าน; มันมี limits ของตัวเอง ดังนั้น child ที่ runaway ใช้ budget ของ parent ไม่ได้; และมัน inherit signal ดังนั้น Stop หนึ่งครั้ง cancel ทั้ง tree ทำไม window ที่สะอาดคือจุดสำคัญ ไม่ใช่ side effect อยู่ใน บทที่ 24; orchestration patterns ห้าแบบ — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — และ handoff อยู่ใน บทที่ 25
Frameworks อยู่ไหน และทำไมคอร์สนี้ไม่ใช้
ลิงก์ไปยังส่วน: Frameworks อยู่ไหน และทำไมคอร์สนี้ไม่ใช้ทั้งหมดข้างบนไม่ควรถูกอ่านว่าเป็น argument ต่อต้าน libraries วัดเมื่อ 7 กันยายน 2026 สำหรับเดือนที่สิ้นสุด 29 สิงหาคม:7
| package | downloads that month | what it gives you |
|---|---|---|
ai (Vercel AI SDK) | 89,385,860 | ToolLoopAgent, stopWhen, tool approval, step hooks |
@anthropic-ai/claude-agent-sdk | 41,558,352 | Claude Code harness ในรูป library: loop, sessions, hooks, permissions, subagents8 |
@langchain/langgraph | 12,812,815 | ลูปในฐานะ state graph ที่ explicit |
langchain | 11,359,058 | chains, agents, integrations |
@openai/agents | 6,093,155 | agents, handoffs, guardrails |
@mastra/core | 5,914,502 | agents, workflows, memory |
เหตุผลที่คอร์สนี้เขียนลูปเองแทนที่จะสอนสักตัวหนึ่งถูกประกาศตรง ๆ ไม่ได้ปล่อยให้เดา และมันวัดได้ ในสิบสองเดือนถึง 7 กันยายน 2026, ai publish 945 versions และขยับจาก major 5 ไป major 7 และ agent class ของมันยัง export เป็น Experimental_Agent; langchain publish 132 versions ในช่วงเดียวกัน; @openai/agents publish 83 และยังอยู่บน 0.x สิบห้าเดือนหลัง release แรก7 บทที่เขียนผูกกับ API ใดในนั้นจะ stale ภายในหนึ่งฤดูกาล และบทนี้ publish ในสามสิบสามภาษา ดังนั้นทุก re-edition cost ทั้ง translation สิ่งที่อยู่ข้างใต้ทั้งหมดไม่ขยับ: ลูป, stopping rule, catalogue, executor, state บางส่วน
และ reference implementation ก็เห็นตรงกับบทนี้ในส่วนที่สำคัญ ใน ai เวอร์ชัน 7.0.93 exit ของลูปไม่ใช่ตัวเลข — มันคือ stopWhen, list ของ predicates ซึ่ง step count เป็นเพียงหนึ่งในนั้น:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsการหยุดเป็นพหูพจน์ใน implementation ที่ใช้กันมากที่สุดของลูปนี้ ด้วยเหตุผลเดียวกับที่มันเป็นพหูพจน์ในหนึ่งร้อยเก้าสิบหกบรรทัดด้านบน
ต่อจากนี้ไปที่ไหน
ลิงก์ไปยังส่วน: ต่อจากนี้ไปที่ไหนตอนนี้คุณมี harness แล้ว: ลูป, catalogue, executor, ห้าทางออก, run ที่ persist แล้ว, signal ที่ไปถึง tools และ trace ที่มี run id ในทุกบรรทัด บทที่ 24, 25, 29 และ 30 สร้างต่อจากไฟล์นี้ และบทที่ 26 ถึง 28 สร้างต่อจากสิ่งที่มันเอื้อมถึงได้
มันเหลือปัญหาเดียว และการวัดด้านบนชี้ไปที่มันมาตลอด ดูตาราง runaway อีกครั้ง: 3,431 input tokens ที่แปด turns, 337,299 ที่หนึ่งร้อย ดู run ที่ทำงานได้: 204, 269, 342 ทุก turn ส่ง transcript ทั้งหมดซ้ำ ดังนั้น context ของ agent จึงเต็มไปด้วย history ของตัวเอง — และ model ใช้ปลายไกลของ window ยาวได้แย่กว่าปลายใกล้ นี่คือเหตุผลที่ agent ที่ดีใน turn ห้า กลายเป็น agent ที่สับสนใน turn สี่สิบ
turn cap แก้เรื่องนั้นไม่ได้ มันแค่หยุดไม่ให้คุณจ่ายเงินเพื่อดูมันเกิดขึ้น สิ่งที่แก้คือการตัดสินใจในทุก ๆ turn ว่า tokens ใดคู่ควรกับ window: อะไรควร compact, อะไรควรถูกย้ายออกไปเป็น note ที่ agent fetch ได้, อะไรควร hand ให้ subagent ที่มี window สะอาด และ tool definitions ใดคุ้มกับภาษีถาวรของมัน บทที่ 24 วัดว่า window ไปอยู่ที่ไหนจริง ๆ — และสิ่งที่น่าประหลาดใจคือมันไม่ใช่ conversation
Sources and method
ลิงก์ไปยังส่วน: Sources and methodตัวเลขทุกตัวในบทนี้มาจาก servers สองตัวที่อธิบายด้านบน บน Node 22 ผ่าน loopback interface: scripted provider ที่นับ tokens ด้วย encoding o200k_base และ Qwen/Qwen2.5-0.5B-Instruct หลัง endpoint ที่มี shape เดียวกัน, greedy decoding, บน CPU Costs คำนวณจาก token counts ที่วัดจริงตาม rates ที่บทที่ 16 อ่านเมื่อ 6 กันยายน 2026 — $2.00 ต่อ input tokens หนึ่งล้าน และ $12.00 ต่อ output หนึ่งล้าน — และไม่มี request ใดในบทนี้ถูกส่งไปยัง paid endpoint คำตอบของ local model คือคำตอบของ small model; ให้อ่านมันเป็นหลักฐานเกี่ยวกับลูป ซึ่งเหมือนกันทั้งสองทาง ไม่ใช่ benchmark ว่า current models ทำอะไร
รายการอ้างอิง
ลิงก์ไปยังส่วน: รายการอ้างอิง-
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 และ actions ที่ลูป implement และเป็นแหล่งของข้อสังเกตว่าการ acting ทำให้ model “handle exceptions” ได้ — ซึ่งตรงกับสิ่งที่ตาราง tool-error ด้านบนวัด ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). treatment แบบ formal ของสิ่งที่ลูปด้านบนทำอย่าง informal: modular memory components, structured action space ที่ครอบคลุม internal memory และ external environments และ “a generalized decision-making process to choose actions” อ่านเพื่อ vocabulary ที่คำศัพท์ในวงการยังขาด — โดยเฉพาะการแยก working, episodic, semantic และ procedural memory ซึ่งเงาในทางปฏิบัติคือตาราง three-store ของบทที่ 24 ↩
-
ai(Vercel AI SDK) เวอร์ชัน 7.0.93, publish 4 กันยายน 2026; อ่าน type declarations จากcdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsเมื่อ 7 กันยายน 2026 ไฟล์ขนาด 397 KB มีสตริงharnessปรากฏเป็นศูนย์ครั้ง agent class คือdeclare class ToolLoopAgent, export ทั้งเป็นToolLoopAgentและเป็นExperimental_Agent;declare function isStepCount(stepCount: number)— export เป็นstepCountIs— ถูก quote verbatim ด้านบน;type StopConditionแสดงโดยไม่มี type parameter ตัวที่สอง (RUNTIME_CONTEXT extends Context = Context) ซึ่งเป็นการตัดออกเพียงจุดเดียวใน excerpt เช่นเดียวกับ shape ของstopWhen?: Arrayable<StopCondition<...>>บนgenerateTextและstreamTextไฟล์เดียวกัน declaretoolApproval,ToolApprovalStatus,prepareStepและrepairToolCallกล่าวคือ reference implementation ไปถึง approval gates, per-step preparation และ error repair อย่างอิสระเช่นกัน ↩ ↩2 -
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). abstract เรียก artefact นี้ว่า “evaluation framework” ของ 2,294 ปัญหา และไม่เคยใช้คำว่า “harness”; README ของ project เอง (
github.com/SWE-bench/SWE-bench, อ่าน 7 กันยายน 2026) ใช้มันห้าครั้ง และหมายถึง “evaluation harness” เสมอ และ entry point คือpython -m swebench.harness.run_evaluationนั่นคือความหมายอีกด้านของคำนี้: scaffold ที่ตรึง agent ไว้และให้คะแนนมัน ไม่ใช่ลูปที่รันมัน ↩ -
Anthropic, Building effective agents, 19 ธันวาคม 2024,
anthropic.com/engineering/building-effective-agents, อ่าน 7 กันยายน 2026 augmented model ในฐานะ building block, agent ในฐานะ LLM “using tools based on environmental feedback in a loop” และคำแนะนำเรื่อง stopping conditions “such as a maximum number of iterations” เพื่อรักษา control บทที่ 22 quote นิยามของมันครบถ้วน ↩ -
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 ที่ batch request ของคุณกับ request ของคนแปลกหน้า และจัดการ KV cache ของบทที่ 13 มันควรรู้ว่ามีอยู่พอดีเพราะมันไม่ใช่ของคุณ: latency ที่ harness ของคุณคูณถูกตั้งอยู่ข้างในนั้น และไม่ว่าคุณทำงานกับลูปของคุณมากแค่ไหนก็ไม่ขยับมัน ↩
-
download counts จาก npm registry,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, เป็น window ที่ explicit ไม่ใช่ rollinglast-monthและ release histories จากregistry.npmjs.org/<package>; query ทั้งคู่เมื่อ 7 กันยายน 2026 release counts คือจำนวน versions ที่ publish ในสิบสองเดือนถึงวันนั้น รวม canary builds:ai945 (latest 7.0.93 เมื่อ 2026-09-04 โดย major versions 5, 6 และ 7 ปรากฏอยู่ใน window ทั้งหมด),langchain132 (latest 1.5.10 เมื่อ 2026-08-20),@openai/agents83 (latest 0.17.0 เมื่อ 2026-08-19, publish ครั้งแรก 2025-06-03) ↩ ↩2 -
Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) คือ Claude Code harness ที่ packaged เป็น library — agent loop, built-in file และ shell tools, context management, sessions, hooks, permissions และ subagents — document อยู่ที่code.claude.com/docs/en/agent-sdkมันคือสิ่งที่ใกล้เคียงที่สุดกับ account ที่เผยแพร่แล้วของแต่ละ mechanism ที่บทนี้สร้างเอง และคุ้มค่าแก่การอ่านคู่กับ implementation ของคุณเองสำหรับส่วนที่มันตั้งชื่อไว้ แต่บทนี้เพียงชี้ผ่าน ↩