ข้ามไปยังเนื้อหา
23/30บทที่ 23 จาก 30

สร้าง 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 สร้างต่อจากสิ่งที่มันเอื้อมถึงได้

บทที่ 14 เขียนกับ provider จริงไม่ได้ เพราะคุณสั่งให้มันส่ง 429 ณ จังหวะที่เลือกเองไม่ได้ บทนี้มีปัญหาเดียวกันในรูปทรงต่างออกไป: คุณสั่งให้ model จริง runaway หรือขอ tool เดิมด้วยอาร์กิวเมนต์เดิมติดกันสองรอบ แบบตามสั่งและทำซ้ำได้ ไม่ได้

ดังนั้นโปรแกรมแรกคือ scripted provider: endpoint ที่มีรูปทรงเหมือน chat completions API โดยคำตอบเป็นฟังก์ชันของ turn index และสิ่งที่ tools ส่งกลับมาถึงตอนนั้น มันนับ 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);

มีสองบรรทัดที่แบกดีไซน์ไว้ 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 ซึ่งช้าโดยตั้งใจ

นี่คือไอเดียทั้งหมด ก่อนส่วนอื่นใดที่ทำให้มันรอดในโลกจริง

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

สาม turns, tool executions สองครั้ง, หนึ่งในสี่ของเซนต์สหรัฐ สังเกตบรรทัดสุดท้าย: 204, 269, 342 ทุก turn ส่งทุกอย่างก่อนหน้ากลับไปใหม่ ซึ่งคือบิลกำลังสองจากบทที่ 16 ที่มาถึงในที่ที่ไม่มีใครพิมพ์อะไรเพิ่ม ส่วนที่เหลือของบทนี้คือสิ่งที่จะเกิดเมื่อบรรทัดนั้นไม่หยุดโต

พังครั้งที่หนึ่ง: งานที่ไม่จบสักที

ลิงก์ไปยังส่วน: พังครั้งที่หนึ่ง: งานที่ไม่จบสักที

ชี้ลูปเดิมไปที่สคริปต์ runaway — model ที่ขอ tool ทุก turn และไม่เคย emit prose — แล้ว return ที่ทำเครื่องหมายไว้จะไม่ถูกเรียก ไม่มีทางออกอื่น โปรแกรมรันไปจนกว่า process จะตาย หรือบัตรเครดิตจะตาย

วิธีแก้คือบรรทัดเดียว เป็น control แรกที่วรรณกรรมแนะนำ,5 และสุดท้ายทุกคนก็เขียนมัน สิ่งที่แทบไม่มีใครทำคือวัดว่ามันคุ้มค่าแค่ไหน:

turn capmodel callsinput tokenscost
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

อ่านสองแถวสุดท้ายคู่กัน การเพิ่ม cap จาก 50 เป็น 100 ไม่ได้ทำให้ cost เพิ่มสองเท่า แต่มันคูณด้วย 3.7 input tokens เพิ่มจาก 88,649 เป็น 337,299 หรือ 3.8 เท่า เพราะ turn nn แบกทุก turn ก่อนหน้ามาด้วย และผลรวมคือ Θ(n2)\Theta(n^2) 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 ที่ใช้ราคาเดียวกันตลอดคอร์สนี้:

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

สคริปต์ runaway เดิม ไม่มี turn cap เลย งบสามระดับ:

budgetturns reachedactually spent
$0.019$0.010780
$0.0524$0.051790
$0.2052$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 endswho decidedwhat 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:

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 จบด้วย 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 errorturnstool runscostwhat the user got
throws it out of the loop11$0.000756stack trace
returns Error: the tool failed.21$0.001462“ฉันอ่านไฟล์ไม่ได้ จึงไม่ทราบ”
returns what actually happened43$0.003550“errors.log พูดถึง timeout”

แถวที่สาม cost เป็น 4.7 เท่าของแถวแรก และเป็นแถวเดียวที่ตอบคำถามได้ ส่วนแถวที่สองคือแถวที่น่าสนใจ เพราะนั่นคือสิ่งที่ codebase ส่วนใหญ่ทำจริง: error ถูก catch แล้ว, ลูปรอด, model ได้รับการบอกว่า มี บางอย่างล้มเหลว แต่ไม่ได้บอกว่า อะไร และมันยอมแพ้อย่างสุภาพ ความต่างระหว่างแถวสองกับแถวสามไม่ใช่ error handling มันคือประโยคที่เขียนให้ผู้อ่าน

ดังนั้น harness จึงปฏิบัติกับ tool ที่ thrown เป็น 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 เตือนอีกด้านไว้ด้วย และมันก็มีราคาเช่นกัน ชี้ลูปไปที่ tool ที่ fail ด้วยเหตุผลที่ไม่มี message ใดแก้ได้ — read ที่ process ไม่มีสิทธิ์ทำ — แล้ว model จะ 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=""

สิบเอ็ด 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 คือสิ่งที่คั่นระหว่างคุณกับกรณีที่สอง ซึ่งเป็นพื้นขั้นต่ำ ไม่ใช่วิธีแก้

คราวนี้เป็นความล้มเหลวที่คนส่วนใหญ่คิดว่าเกิดไม่ได้ Models ทำซ้ำตัวเองได้ ปล่อยให้ลูปใด ๆ รันนานพอ แล้วคุณจะเห็น tool เดิมกับ arguments เดิมเป๊ะในสอง turns ติดกัน

วัดเทียบกับ baseline ของงานเดียวกันที่ไม่มีการ repeat:

turnstool runscost
งานนั้น ไม่มี repeat21$0.001396
งานเดียวกัน call หนึ่งถูก repeat32$0.002446
repeat พร้อม result cache บน read-only tools31$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 ที่สองจะไม่เกิดขึ้นอย่างเงียบ ๆ:

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 บอกว่านี่คือสอง 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 จัดการส่วนที่เหลือ

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 list ไฟล์ แล้วขอ delete ไฟล์หนึ่งที่ task ไม่เคยพูดถึง ไม่มีอะไรในลูปจนถึงตอนนี้หยุดมันได้

tool ที่ทำเครื่องหมาย needsApproval ไม่ fail และไม่ proceed มัน หยุด run และคืน control พร้อมทุกอย่างที่คนต้องใช้ตัดสินใจ:

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

นั่นคือ mechanism ทั้งหมด และเหตุผลที่มันเป็น return แทน callback อยู่ในหัวข้อถัดไป: ระหว่าง stop กับ verdict, process อาจไม่อยู่แล้วก็ได้

แต่ก่อนอื่น มีการวัดที่ไม่มีใครคาด การ reject ไม่ใช่การไม่มี result — transcript มี slot ที่ keyed ด้วย tool_call_id และต้องมีบางอย่างใส่เข้าไป รัน rejection เดิมสองครั้ง โดยเปลี่ยนแค่ว่าบางอย่างนั้นพูดว่าอะไร:

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 และใน run ที่สอง user ถูกบอกว่ามันถูกลบแล้ว permission system ทำงานสมบูรณ์แบบ; report เป็นคำโกหก มันคือ mechanism เดียวกับตาราง tool-error ที่มาถึงจุดที่สำคัญกว่ามาก — human ปฏิเสธ, action ถูก block อย่างถูกต้อง และ summary ของ agent ขัดกับความจริง เพราะ refusal ไม่เคยถูกเขียนลงในที่ที่ model อ่าน กฎที่ได้จึงสั้น: ไม่ว่า code ของคุณตัดสินอะไรเกี่ยวกับ tool call ให้เขียนการตัดสินใจนั้นลง transcript เป็นคำพูด บทที่ 30 จะกลับมาหาเรื่องนี้จากฝั่ง security ซึ่งมันคือความต่างระหว่าง audit trail กับนิยาย

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 กลายเป็นเรื่องบรรทัดเดียว:

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 ไม่ใช่การ save แต่คือสิ่งที่เกิดตอนกลับเข้ามา และคำตอบแบบ naive ทำให้คุณถูกคิดเงินซ้ำ ถ้า process ตายหลังจาก model ขอ tool แต่ก่อน result ถูกเขียน resume ที่เริ่มด้วยการเรียก model อีกครั้งจ่ายเงินให้ turn ที่มันมีอยู่แล้ว — และถ้ามันเริ่มด้วยการ re-run tools มันจะ perform 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 drain pending ก่อน และถาม model ก็ต่อเมื่อไม่มีอะไร outstanding แล้ว Resume กลายเป็น code path เดียวกับแบบปกติ และ approval ก็เช่นกัน — approved call เป็นเพียง pending call ที่ตอนนี้ได้รับอนุญาตให้รัน ฆ่า process กลาง task แล้ว restart มัน:

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

tool executions สองครั้งข้ามสอง processes สำหรับ task ที่ต้องใช้สองครั้ง และ final cost เหมือนกับ run ที่ไม่เคย crash cost สะสมข้าม restart เพราะมันอยู่ใน state ไม่ได้อยู่ในตัวแปร

พังครั้งที่เจ็ด: ความเงียบสามนาที

ลิงก์ไปยังส่วน: พังครั้งที่เจ็ด: ความเงียบสามนาที

scan_archive ใช้เวลาสามวินาทีที่นี่ และแทน tool ที่ใช้เวลาสามนาทีใน production มีสองสิ่งหายไปขณะมันรัน: user ไม่รู้เลยว่ามีอะไรเกิดขึ้น และปุ่ม Stop ไม่ทำอะไร

ทั้งสองอย่างแก้ด้วยวิธีเดียวกัน และมันคือ AbortSignal จากบทที่ 14 ที่ดันลงไปลึกอีกชั้น signal ไม่ได้มีไว้สำหรับ fetch เท่านั้น — มันถูกส่ง เข้าไปใน tool และ tool ที่เขียนดีจะเคารพมัน:

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"

สอง milliseconds จาก click ถึง stop เพราะ sleep ภายใน tool ฟัง signal เดียวกับ fetch ถ้าสอดมันเข้าไปแค่ fetch ปุ่ม Stop เดียวกันจะรอสามวินาที — ความยาวของ tool — และ run “cancel” หลังจากงานที่มันกำลัง cancel เสร็จไปแล้ว Cancellation ที่ไม่ได้เดินท่อลงไปจนสุดคือ spinner ที่พูดคำถูกต้อง

harness emit หนึ่งบรรทัดต่อ event และ 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}

สาม 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 ดูเหมือนกันจากภายนอก และต้องตอบสนองตรงข้ามกัน

บทที่ 13 วัด time to first token บน hardware ที่คุณเป็นเจ้าของ บทที่ 14 วัดผ่าน socket agent คูณมัน และตัวคูณคือจำนวนที่ไม่มีใครเลือก:

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

งานสาม turns เดิม เปลี่ยนแค่ latency ของ provider:

provider latency per turnwall clock, 3 turns
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

harness เองมีส่วนเพิ่มสิบห้า milliseconds ต่อ run สาม turns ทุกอย่างที่เหลือคือ NN คูณด้วยจำนวนที่คุณควบคุมไม่ได้ — ตั้งอยู่ใน serving scheduler ที่กำลัง batch request ของคุณกับ request ของคนแปลกหน้า6 — และ NN ถูกเลือกโดย model นี่คือเหตุผลที่ streaming ในบทที่ 14 สำคัญกว่าที่นี่มากกว่าใน chat และช่วยได้น้อยกว่า: คุณ stream turn สุดท้ายได้ และสี่ turns ก่อนหน้าคือความเงียบ เว้นแต่ harness จะ emit progress นี่คือ argument ทั้งหมดสำหรับ event tool_progress ด้านบนเช่นกัน — ใน agent หน่วย feedback ที่ซื่อสัตย์ไม่ใช่ token แต่คือ step

ทุกอย่างด้านบนรันกับ scripted provider ซึ่งพิสูจน์ harness และไม่พิสูจน์อะไรเกี่ยวกับ models ดังนั้นเปลี่ยนหนึ่งบรรทัด — seam จากบทที่ 14, LLM_BASE_URL — แล้วชี้ code เดิมไปที่ Qwen2.5-0.5B-Instruct บนเครื่อง local พร้อม tools สี่ตัวเดิม หก tasks เหนือสามไฟล์เดิม:

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

มีสาม 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 นั้นแคบ:

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

มีสามสิ่งที่ถูกต้องอยู่แล้วในสิบเส้นนั้น และทั้งสามเป็นผลตามมาจาก 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

packagedownloads that monthwhat it gives you
ai (Vercel AI SDK)89,385,860ToolLoopAgent, stopWhen, tool approval, step hooks
@anthropic-ai/claude-agent-sdk41,558,352Claude Code harness ในรูป library: loop, sessions, hooks, permissions, subagents8
@langchain/langgraph12,812,815ลูปในฐานะ state graph ที่ explicit
langchain11,359,058chains, agents, integrations
@openai/agents6,093,155agents, handoffs, guardrails
@mastra/core5,914,502agents, 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

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

การหยุดเป็นพหูพจน์ใน 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


ตัวเลขทุกตัวในบทนี้มาจาก 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 ทำอะไร

  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 และ actions ที่ลูป implement และเป็นแหล่งของข้อสังเกตว่าการ acting ทำให้ model “handle exceptions” ได้ — ซึ่งตรงกับสิ่งที่ตาราง tool-error ด้านบนวัด

  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: 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

  3. 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 ไฟล์เดียวกัน declare toolApproval, ToolApprovalStatus, prepareStep และ repairToolCall กล่าวคือ reference implementation ไปถึง approval gates, per-step preparation และ 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). 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 ไว้และให้คะแนนมัน ไม่ใช่ลูปที่รันมัน

  5. 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 นิยามของมันครบถ้วน

  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 ที่ batch request ของคุณกับ request ของคนแปลกหน้า และจัดการ KV cache ของบทที่ 13 มันควรรู้ว่ามีอยู่พอดีเพราะมันไม่ใช่ของคุณ: latency ที่ harness ของคุณคูณถูกตั้งอยู่ข้างในนั้น และไม่ว่าคุณทำงานกับลูปของคุณมากแค่ไหนก็ไม่ขยับมัน

  7. download counts จาก npm registry, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, เป็น window ที่ explicit ไม่ใช่ rolling last-month และ release histories จาก registry.npmjs.org/<package>; query ทั้งคู่เมื่อ 7 กันยายน 2026 release counts คือจำนวน versions ที่ publish ในสิบสองเดือนถึงวันนั้น รวม canary builds: ai 945 (latest 7.0.93 เมื่อ 2026-09-04 โดย major versions 5, 6 และ 7 ปรากฏอยู่ใน window ทั้งหมด), langchain 132 (latest 1.5.10 เมื่อ 2026-08-20), @openai/agents 83 (latest 0.17.0 เมื่อ 2026-08-19, publish ครั้งแรก 2025-06-03) 2

  8. 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 ของคุณเองสำหรับส่วนที่มันตั้งชื่อไว้ แต่บทนี้เพียงชี้ผ่าน


สร้างโดย

David Vicente Campos

ผู้ก่อตั้ง NeuraLIA Labs และผู้ร่วมก่อตั้ง MyRealFood

ผมเป็นวิศวกรคอมพิวเตอร์ที่จบจากมหาวิทยาลัยเลออน ผมร่วมก่อตั้ง MyRealFood ที่ที่ผมในฐานะ CTO ได้สร้างแอปซึ่งผู้คนหลายล้านคนใช้เพื่อกินให้ดีขึ้น และผมก่อตั้ง NeuraLIA Labs ที่ที่ผมสร้างผลิตภัณฑ์ AI ที่นี่ผมเขียนถึงสิ่งที่ผมต้องทำความเข้าใจระหว่างทาง ในแบบที่ผมเคยหวังว่าจะมีใครสักคนอธิบายให้ผมฟัง

เพิ่มเติมเกี่ยวกับผู้เขียน

เผยแพร่โดย NeuraLIA Labs

รับโพสต์ใหม่ในกล่องจดหมาย

ข่าว AI คู่มือ และอัปเดตผลิตภัณฑ์ — อีเมลสั้น ๆ เมื่อเรามีสิ่งที่คุ้มเวลาของคุณ

ชอบแบบข้อความมากกว่าไหม รับเนื้อหาเดียวกันได้ที่นี่:คอมมูนิตี้ WhatsApp (เปิดในแท็บใหม่)ช่อง Telegram (เปิดในแท็บใหม่)

ดัชนีคอร์ส

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jevอ่าน 5 นาที

โมเดล AI Jev สร้างมาเพื่อการตัดสินใจ ไม่ใช่การเขียนความเรียง

Jev ของ TypeSafe AI กำลังได้รับความสนใจ เพราะมองความฉลาดของซอฟต์แวร์เป็นปัญหาความน่าจะเป็น: เลือกกิ่งที่ถูกต้อง แนบความมั่นใจ และหลีกเลี่ยงการจ่ายเงินให้ LLM เขียนข้อความเมื่อโค้ดต้องการการตัดสินใจ

Abstract legal research workspace with documents, search nodes and governance controls.
openaiอ่าน 4 นาที

Astra for Law ของ OpenAI คือระบบ AI ด้านกฎหมาย ไม่ใช่โมเดลใหม่

การเปิดตัวด้านกฎหมายของ OpenAI ไม่ได้เน้นโมเดลฐานรากใหม่เท่ากับระบบที่ล้อมรอบโมเดลนั้น: การค้นคืนเฉพาะโดเมน เครื่องมือที่เชื่อถือได้ สิทธิ์ เบนช์มาร์ก และเส้นทางการตรวจทาน

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineeringอ่าน 4 นาที

วิศวกรรมบริบทสำหรับเอเจนต์ AI ที่ทำงานระยะยาว

เอเจนต์ที่ทำงานต่อเนื่องไม่ได้ล้มเหลวเพียงเพราะหน้าต่างบริบทเล็กเกินไป แต่ล้มเหลวเมื่อไฟล์ ผลลัพธ์จากเครื่องมือ และประวัติที่ค้างเก่าบดบังงานที่เอเจนต์ควรทำให้เสร็จ

พร้อมให้ LIA เลือกโมเดลให้แล้วหรือยัง?

สร้างงานด้วยโมเดล AI ทุกตัวในที่เดียว เริ่มฟรีวันนี้