Chuyển đến nội dung
23/30Chương 23 trên 30

Xây dựng Agent Harness: Vòng lặp và năm lối thoát

Một vòng lặp 15 dòng chạy ngay lần đầu, rồi cố ý bị phá 7 lần, bắt đầu bằng ca runaway tốn gấp 77 lần bản giới hạn chặt.

Trên trang này

Bắt đầu bằng phần thật thà, vì chẳng mấy ai nói điều này: “harness” là biệt ngữ, không phải một chuẩn. Không có đặc tả, không có ủy ban, không có định nghĩa tham chiếu. Bốn bài báo mà chương này trích dẫn — ReAct,1 CoALA,2 SWE-bench và vLLM — không dùng từ đó lần nào trong phần tóm tắt của chúng. Triển khai được tải nhiều nhất của thứ này, package ai của Vercel với 89,4 triệu lượt tải mỗi tháng, cũng không dùng nó: chuỗi harness xuất hiện đúng 0 lần trong 397 KB khai báo kiểu đi kèm phiên bản 7.0.93.3 Nơi duy nhất từ này thực sự gánh nghĩa lại mang một nghĩa hoàn toàn khác. SWE-bench nói “harness” năm lần trong README, luôn là evaluation harness — bộ khung container hóa để áp một bản vá và chạy kiểm thử — và module Python của nó đúng nghĩa là swebench.harness.run_evaluation.4

Vậy là hai thứ khác nhau dùng chung một tên. Evaluation harness giữ agent đứng yên và chấm điểm nó. Một agent harness là chương trình chạy agent: nó gọi model, thực thi những gì model yêu cầu, quyết định khi nào dừng, và giữ trạng thái ở giữa. Chương này xây dựng loại thứ hai, dưới hai trăm dòng TypeScript, hoàn toàn không dùng framework.

Bản thân vòng lặp chỉ có mười lăm dòng và chạy được ngay lần đầu. Mọi thứ sau đó đều là một cách để rời khỏi nó.

Hiện chi tiết

Chương này cần gì từ các chương trước.

  • Chương 14 cho client: deadline, phân loại trạng thái, hủy, idempotency key, và kỹ thuật mock provider được dùng lại ở đây.
  • Chương 16 cho phần số học: input tokens tăng theo bình phương của cuộc hội thoại, và các mức giá dùng bên dưới là những mức đã đọc ở đó vào ngày 6 tháng 9 năm 2026.
  • Chương 18 cho catalogue công cụ: một schema mà model thấy, một endpoint nó không bao giờ thấy, và quy tắc rằng lỗi là context chứ không phải exception.
  • Chương 22 cho vòng lặp mà chương này kế thừa, và cho hai định nghĩa đã xuất bản về “agent” nhưng bất đồng với nhau.

Không có tensor ở đây. Đây là hub phụ thuộc thứ hai của khóa học: Chương 24, 25, 29 và 30 chạy trên file bên dưới, còn 26 đến 28 xây trên những gì nó có thể chạm tới.

Chương 14 không thể viết dựa trên một provider thật, vì bạn không thể yêu cầu nó trả về 429 đúng vào khoảnh khắc mình chọn. Chương này gặp cùng vấn đề ở một hình dạng khác: bạn không thể yêu cầu một model thật chạy mất kiểm soát, hoặc yêu cầu cùng một công cụ với cùng tham số hai lần liên tiếp, theo ý muốn và có thể tái lập.

Vì vậy chương trình đầu tiên là một scripted provider: một endpoint có hình dạng của chat completions API, trong đó phản hồi là hàm của chỉ số lượt và của những gì các công cụ đã trả về cho đến lúc đó. Nó đếm token bằng một bộ mã hóa byte-pair thật, nên phần tiền bên dưới là số học chứ không phải trang trí.

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

Hai dòng mang toàn bộ thiết kế. Chỉ số lượt được suy ra từ cuộc hội thoại, không giữ trong một biến, nên provider là stateless và một lần chạy có thể bị giết rồi tiếp tục lại với nó. Và recover đọc kết quả công cụ trước khi quyết định: một scripted model đọc chính transcript của nó là mức tối thiểu cần có để đo xem harness có đưa cho nó thứ gì đáng đọc hay không.

Catalogue là của Chương 18, bốn công cụ trên ba file: list_files, read_file, delete_file — được đánh dấu needsApproval — và scan_archive, vốn chậm có chủ ý.

Đây là toàn bộ ý tưởng, trước mọi phần giúp nó có thể sống sót.

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

Trỏ nó vào scripted provider và nó làm đúng những gì trông như nó sẽ làm:

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

Ba lượt, hai lần thực thi công cụ, một phần tư xu Mỹ. Hãy chú ý dòng cuối: 204, 269, 342. Mỗi lượt gửi lại mọi thứ trước đó, chính là hóa đơn bậc hai của Chương 16 xuất hiện ở một nơi không ai gõ thêm gì. Phần còn lại của chương này là những gì xảy ra khi dòng đó không ngừng tăng.

Phá lần một: tác vụ không bao giờ kết thúc

Liên kết đến mục: Phá lần một: tác vụ không bao giờ kết thúc

Trỏ cùng vòng lặp vào script runaway — một model yêu cầu công cụ ở mọi lượt và không bao giờ phát ra văn xuôi — và return được đánh dấu sẽ không bao giờ chạy. Không có lối thoát nào khác. Chương trình chạy cho đến khi process chết hoặc thẻ tín dụng chết.

Cách sửa là một dòng, đó là kiểm soát đầu tiên mà tài liệu khuyến nghị,5 và cuối cùng ai cũng viết. Điều gần như không ai làm là đo xem nó đáng giá bao nhiêu:

giới hạn lượtlượt gọi modelinput tokenschi phí
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Đọc hai hàng cuối cùng cùng nhau. Tăng gấp đôi giới hạn từ 50 lên 100 không làm chi phí tăng gấp đôi; nó nhân chi phí lên 3,7 lần. Input tokens đi từ 88.649 lên 337.299, hệ số 3,8, vì lượt nn mang theo mọi lượt trước đó và tổng là Θ(n2)\Theta(n^2). Giới hạn lượt không phải một núm chỉnh tuyến tính. Nó là một núm chỉnh trên căn bậc hai của trường hợp xấu nhất, đó là lý do nâng nó từ 20 lên 100 “cho chắc” là một quyết định đáng định giá trước khi bạn làm.

Phá lần hai: giới hạn lượt không phải giới hạn tiền

Liên kết đến mục: Phá lần hai: giới hạn lượt không phải giới hạn tiền

Vấn đề với giới hạn lượt là một lượt không có giá cố định. Hai mươi lượt trên một transcript ngắn tốn $0.038 như trên. Hai mươi lượt với catalogue 200 công cụ, một tập tài liệu được truy xuất và bốn mươi tin nhắn lịch sử có thể tốn gấp hàng trăm lần, và giới hạn không biết điều đó. Thứ operator muốn chặn là hóa đơn.

Vì vậy vòng lặp đếm tiền, dùng computeCost của Chương 16 với các mức giá đã đọc ở đó — $2.00 cho mỗi triệu input tokens và $12.00 cho mỗi triệu output, cho model được định giá xuyên suốt khóa học này:

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

Cùng script runaway, không giới hạn lượt nào cả, ba ngân sách:

ngân sáchlượt đạt tớithực tế đã tiêu
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Có hai điều đáng gọi tên. Thứ nhất, ngân sách mua được một số lượt khác nhau mỗi lần, và đó chính là mục đích: nó chặn thứ operator quan tâm, rồi để số lượt rơi vào nơi transcript đặt nó. Thứ hai, hàng nào cũng vượt mức. Ngân sách là $0.010 và đã tiêu $0.010780, vì kiểm tra chạy trước một lượt còn giá của lượt đó chỉ biết sau khi nó kết thúc. Bạn không thể chặn chi tiêu chính xác tuyệt đối; bạn có thể chặn nó trong phạm vi chi phí của một lượt. Hãy nói vậy trong giao diện thay vì giả vờ, và đặt kiểm tra trước lời gọi để phần vượt là một lượt chứ không phải hai.

Đến đây vòng lặp có ba lối thoát, và hình dạng phần còn lại của chương đã hiện rõ. Một lần chạy production kết thúc chính xác theo một trong năm cách, và chúng không phải các biến thể của nhau:

kết thúc như thế nàoai quyết địnhcaller nên làm gì
model ngừng yêu cầumodelđọc câu trả lời
giới hạn lượtbạn, từ trướctăng giới hạn, hoặc chấp nhận kết quả một phần
hết ngân sáchbạn, từ trướcphê duyệt thêm tiền, hoặc chấp nhận kết quả một phần
một lỗi không thể retryprovider hoặc công cụsửa deployment; phân loại của Chương 14 quyết định
con người can thiệpmột ngườichờ phán quyết, rồi tiếp tục

Gộp những điều này thành một boolean là lỗi thiết kế phổ biến nhất trong file này, và nó đắt theo một cách cụ thể: ba trong năm trường hợp có thể tiếp tục còn hai thì không. Một agent chạm giới hạn lượt có transcript hợp lệ, một kết quả một phần thật và bước tiếp theo; một agent gặp 401 không có thứ nào trong số đó. Vì vậy harness ghi lại lý do như dữ liệu:

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

Chương 18 kết thúc bằng một mệnh đề chưa có con số: đưa lỗi của công cụ trở lại cho model như một kết quả công cụ thay vì raise nó, và model thường tự sửa. Đây là con số.

Một lỗi, ba chính sách. Scripted model đoán một file không tồn tại; công cụ throw no such file: timeout.log. Call list_files to see what exists.

harness làm gì với lỗilượtlần chạy công cụchi phíngười dùng nhận được gì
ném nó ra khỏi vòng lặp11$0.000756một stack trace
trả về Error: the tool failed.21$0.001462“Tôi không thể đọc file, nên tôi không biết.”
trả về điều thực sự đã xảy ra43$0.003550“errors.log nhắc đến một timeout.”

Hàng thứ ba tốn gấp 4,7 lần hàng đầu tiên và là hàng duy nhất trả lời được câu hỏi. Và hàng thứ hai mới là hàng thú vị, vì đó là điều hầu hết codebase thực sự làm: lỗi được bắt, vòng lặp sống sót, model được báo rằng thứ gì đó thất bại chứ không phải thứ gì, và nó lịch sự bỏ cuộc. Khác biệt giữa hàng hai và hàng ba không phải error handling. Đó là một câu được viết cho một người đọc.

Vì vậy harness xem một công cụ bị throw như dữ liệu, và biến cách diễn đạt thành một chính sách:

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

Chương 18 cũng đã cảnh báo về mặt còn lại, và nó cũng có giá. Trỏ vòng lặp vào một công cụ thất bại vì một lý do không thông điệp nào sửa được — một lần đọc mà process không được phép thực hiện — và model retry nó mãi:

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

Mười một lần thực thi y hệt nhau của một lời gọi không thể thành công, tốn gấp 5,2 lần lần chạy đã phục hồi từ lỗi có thể sửa, và không có gì ở cuối. Lỗi là context; lỗi vĩnh viễn là context đầu độc phần còn lại của lần chạy. Sự phân biệt này là phân loại trạng thái của Chương 14 được đưa lên một lớp: lỗi mà model có thể hành động dựa trên đó được đưa lại vào transcript, còn lỗi mà nó không thể hành động nên dừng lần chạy với một lý do. Giới hạn lượt là thứ đang đứng giữa bạn và trường hợp thứ hai hôm nay, tức là một mức sàn chứ không phải bản sửa.

Giờ đến lỗi mà đa số cho rằng không thể xảy ra. Model tự lặp lại. Hãy để bất kỳ vòng lặp nào chạy đủ lâu và bạn sẽ thấy cùng một công cụ với cùng tham số ở hai lượt liên tiếp.

Đo so với baseline của cùng tác vụ nhưng không lặp:

lượtlần chạy công cụchi phí
tác vụ, không lặp21$0.001396
cùng tác vụ, một lời gọi bị lặp32$0.002446
bị lặp, có cache kết quả trên công cụ chỉ đọc31$0.002446

Lời gọi trùng lặp tốn thêm $0.001050, tăng 75 %, và đây là phần khiến nhiều người ngạc nhiên: cache kết quả không thu hồi được đồng nào trong đó. Deduplication tiết kiệm lần thực thi công cụ chứ không tiết kiệm lượt, vì vào lúc code của bạn nhận ra sự lặp lại thì model đã được trả tiền để hỏi rồi. Khoản tiết kiệm là thật khi công cụ chậm, bị rate-limit, hoặc tính tiền theo lượt gọi — và bằng 0 trên dòng chi phí đã tăng.

Có một phiên bản tệ hơn. Áp cùng cache đó cho một công cụ ghi, và lời gọi thứ hai âm thầm không xảy ra:

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

Cái nào trong đó đúng? Không cái nào, nếu có thể biết. Protocol nói đây là hai lời gọi: chúng mang hai giá trị tool_call_id khác nhau. Tham số nói chúng có thể là một. Một harness quyết định bằng cách so sánh chuỗi tham số rồi sẽ có ngày nuốt mất lần thứ hai trong hai khoản tính phí giống hệt nhưng có chủ ý — và Chương 14 đã gọi tên cơ chế duy nhất giải quyết chuyện này một cách trung thực, đó là idempotency key được tạo cho mỗi thao tác logic bởi lớp biết thao tác đó là gì. Cho đến khi công cụ mang một key như vậy, mặc định có thể bảo vệ là cổng chỉ đọc ở trên: cache các lượt đọc, thực thi các lượt ghi, và để idempotency của chính lượt ghi xử lý phần còn lại.

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 liệt kê file rồi yêu cầu xóa một file mà tác vụ chưa bao giờ nhắc tới. Không có gì trong vòng lặp cho đến lúc này sẽ ngăn nó.

Một công cụ được đánh dấu needsApproval không thất bại và không tiếp tục. Nó dừng lần chạy và trả quyền kiểm soát, cùng mọi thứ một người cần để quyết định:

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

Đó là toàn bộ cơ chế, và lý do nó là một return chứ không phải callback nằm ở phần tiếp theo: giữa lúc dừng và lúc có phán quyết, process có thể không còn tồn tại nữa.

Nhưng trước hết là phép đo mà không ai ngờ. Một lần từ chối không phải là sự vắng mặt của kết quả — transcript có một slot keyed by tool_call_id và phải có thứ gì đó đi vào đó. Chạy cùng một lần từ chối hai lần, chỉ thay đổi thứ đó nói gì:

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

Không có gì bị xóa trong cả hai lần chạy, và ở lần thứ hai người dùng được báo là đã xóa. Hệ thống quyền hoạt động hoàn hảo; báo cáo là lời nói dối. Đó là cùng cơ chế như bảng lỗi công cụ, nhưng xuất hiện ở nơi quan trọng hơn nhiều — một con người đã nói không, hành động đã bị chặn đúng cách, và tóm tắt của agent mâu thuẫn với thực tế vì sự từ chối chưa bao giờ được viết xuống nơi model đọc. Quy tắc rút ra rất ngắn: bất kể code của bạn quyết định gì về một tool call, hãy viết quyết định đó vào transcript bằng lời. Chương 30 quay lại chuyện này từ phía bảo mật, nơi nó là khác biệt giữa audit trail và hư cấu.

Một phê duyệt mất vài phút hoặc vài giờ. Một deploy mất vài giây. Nếu lần chạy sống trong một biến cục bộ bên trong HTTP request, mỗi lần restart là một lần chạy bị mất và mỗi phê duyệt là một cuộc đua.

Vì vậy lần chạy không phải closure. Nó là một đối tượng plain có thể serialize — messages, số lượt, chi phí, trạng thái, interruption, danh sách call id đã được phê duyệt — và vòng lặp là một hàm thuần trên nó. Ràng buộc duy nhất đó biến persistence thành mối bận tâm một dòng:

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

Câu hỏi đúng sai không phải là lưu. Nó là điều xảy ra trên đường quay lại, và câu trả lời ngây thơ sẽ khiến bạn bị tính tiền hai lần. Nếu process chết sau khi model yêu cầu công cụ nhưng trước khi kết quả được ghi, một resume bắt đầu bằng cách gọi lại model sẽ trả tiền cho một lượt mà nó đã có — và nếu nó bắt đầu bằng cách chạy lại công cụ, nó thực hiện một lượt ghi hai lần.

Cách sửa là để vòng lặp bắt đầu bằng cách hỏi transcript xem còn gì 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));    
}

Mỗi iteration drain pending trước và chỉ hỏi model khi không còn gì outstanding. Resume trở thành cùng code path với đường bình thường, và approval cũng vậy — một lời gọi được phê duyệt đơn giản là một pending call giờ đã được phép chạy. Giết process giữa tác vụ rồi khởi động lại:

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

Hai lần thực thi công cụ qua hai process cho một tác vụ cần đúng hai lần, và chi phí cuối cùng giống hệt lần chạy chưa từng crash. Chi phí tích lũy qua restart vì nó nằm trong state, không nằm trong một biến.

scan_archive mất ba giây ở đây và đại diện cho công cụ mất ba phút trong production. Khi nó chạy, thiếu hai thứ: người dùng không biết có chuyện gì đang diễn ra, và nút Stop không làm gì cả.

Cả hai có cùng cách sửa, và đó là AbortSignal của Chương 14 được đẩy sâu thêm một lớp. Signal không chỉ dành cho fetch — nó được truyền vào công cụ, và một công cụ được viết tốt sẽ tôn trọng nó:

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"

Hai mili giây từ cú click đến lúc dừng, vì sleep bên trong công cụ lắng nghe cùng signal mà fetch lắng nghe. Chỉ luồn nó vào fetch thôi thì cùng nút Stop đó phải chờ ba giây — độ dài của công cụ — và lần chạy “hủy” sau khi công việc mà nó định hủy đã hoàn thành. Cancellation không được nối dây xuống tận cùng chỉ là một spinner nói đúng từ.

Harness phát ra một dòng cho mỗi event, và vốn từ đủ nhỏ để nhớ thuộc lòng: 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}

Ba thuộc tính khiến đây là trace chứ không phải logging. Mỗi dòng mang run id, nên một lần chạy trải qua ba process và hai ngày vẫn là một query. Mỗi dòng turn mang token count riêng của nó và chi phí đang chạy, nên câu hỏi “vì sao lần chạy này tốn bốn mươi đô” có thể trả lời sau sự kiện thay vì chỉ tái lập được trên lý thuyết. Và run_stopped mang lý do, tức là field biến một ticket hỗ trợ thành câu trả lời một dòng: một agent dừng vì ngân sách và một agent crash nhìn từ bên ngoài giống hệt nhau nhưng cần phản ứng trái ngược.

Chương 13 đã đo time to first token trên phần cứng bạn sở hữu. Chương 14 đo nó qua socket. Một agent nhân nó lên, và hệ số nhân là một con số không ai chọn:

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

Cùng tác vụ ba lượt, chỉ thay đổi latency của provider:

latency provider mỗi lượtthời gian thực, 3 lượt
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

Bản thân harness đóng góp mười lăm mili giây cho một lần chạy ba lượt. Mọi thứ còn lại là NN nhân với một con số bạn không kiểm soát — được đặt bên trong serving scheduler đang batching request của bạn với request của người lạ6 — và NN do model chọn. Đây là lý do streaming của Chương 14 quan trọng ở đây hơn trong chat nhưng giúp ít hơn: bạn có thể stream lượt cuối, còn bốn lượt trước đó là im lặng trừ khi harness phát tiến trình. Đó cũng là toàn bộ lập luận cho event tool_progress ở trên — trong một agent, đơn vị phản hồi trung thực không phải token, mà là bước.

Mọi thứ bên trên chạy với scripted provider, chứng minh harness và chẳng chứng minh gì về model. Vì vậy đổi một dòng — đường nối từ Chương 14, LLM_BASE_URL — và trỏ cùng code đó vào một Qwen2.5-0.5B-Instruct cục bộ với cùng bốn công cụ. Sáu tác vụ trên cùng ba file:

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

Ba phát hiện, và phát hiện thứ ba là lý do phần này tồn tại.

Mọi tác vụ đều kết thúc chính xác trong hai lượt. Giới hạn lượt chưa bao giờ kích hoạt, ngân sách chưa bao giờ kích hoạt, và lối thoát duy nhất của vòng lặp là model tạo ra văn xuôi. Một model nửa tỷ tham số không iterate; nó trả lời ở hơi thở thứ hai dù có thứ nó cần hay không. Số lượt là thuộc tính của model, không phải của vòng lặp của bạn.

Lượt trung bình mất 6.908 mili giây, nên bảng latency ở trên không phải đồ chơi: ở kích thước này, một lần chạy giả định tám lượt gần như là một phút thời gian thực mà không có gì trên màn hình.

Và câu trả lời sai. File lớn nhất là errors.log; model đã liệt kê các file, không bao giờ đọc chúng, rồi vẫn nêu tên một file. Tác vụ đầu tiên đoán một tên file, được báo là nó không tồn tại, rồi kết luận. Harness thực thi hoàn hảo trong cả sáu lần chạy. Harness khiến agent có thể quản trị, không khiến nó đúng — Chương 29 là cách bạn tìm ra cái nào, và Chương 30 là cái giá khi không ai làm điều đó.

Subagents, được gọi tên ở đây và tính tiền sau

Liên kết đến mục: Subagents, được gọi tên ở đây và tính tiền sau

Một công cụ trong catalogue có thể có một lần chạy khác phía sau nó. Interface là của Chương 18 — một schema và một endpoint — và cả một agent nằm gọn phía sau nó vì interface đó hẹp:

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

Ba điều đã đúng trong mười dòng đó và cả ba đều là hệ quả của các quyết định bên trên: child có window riêng, nên transcript của parent nhận một tóm tắt thay vì mọi thứ child đã đọc; nó có giới hạn riêng, nên một child runaway không thể tiêu ngân sách của parent; và nó kế thừa signal, nên một Stop hủy cả cây. Vì sao một window sạch là điểm chính chứ không phải tác dụng phụ nằm ở Chương 24; năm pattern orchestration — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — và handoff nằm ở Chương 25.

Framework nằm ở đâu, và vì sao khóa học này không dùng framework nào

Liên kết đến mục: Framework nằm ở đâu, và vì sao khóa học này không dùng framework nào

Không điều gì ở trên nên được đọc như một lập luận chống lại thư viện. Đo vào ngày 7 tháng 9 năm 2026, cho tháng kết thúc ngày 29 tháng 8:7

packagelượt tải trong tháng đónó cho bạn gì
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, tool approval, step hooks
@anthropic-ai/claude-agent-sdk41.558.352Claude Code harness dưới dạng thư viện: loop, sessions, hooks, permissions, subagents8
@langchain/langgraph12.812.815vòng lặp như một state graph rõ ràng
langchain11.359.058chains, agents, integrations
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, workflows, memory

Lý do khóa học này viết vòng lặp bằng tay thay vì dạy một trong số chúng được tuyên bố thẳng chứ không ngầm hiểu, và nó đo được. Trong mười hai tháng tính đến ngày 7 tháng 9 năm 2026, ai phát hành 945 phiên bản và đi từ major 5 lên major 7, và class agent của nó vẫn được export là Experimental_Agent; langchain phát hành 132 phiên bản trong cùng khung; @openai/agents phát hành 83 và vẫn ở 0.x, mười lăm tháng sau lần phát hành đầu tiên.7 Một chương viết dựa trên bất kỳ API nào trong số đó sẽ lỗi thời trong một mùa, và chương này được xuất bản bằng ba mươi ba ngôn ngữ, nên mỗi lần tái bản tốn công của toàn bộ bản dịch. Thứ nằm dưới tất cả chúng không thay đổi: một vòng lặp, một quy tắc dừng, một catalogue, một executor, một ít state.

Và triển khai tham chiếu đồng ý với chương này về phần quan trọng. Trong ai phiên bản 7.0.93, lối thoát của vòng lặp không phải một con số — nó là stopWhen, một danh sách predicate, trong đó step count chỉ là một: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 là số nhiều trong triển khai được dùng nhiều nhất của vòng lặp này, vì cùng lý do nó là số nhiều trong một trăm chín mươi sáu dòng bên trên.

Giờ bạn đã có một harness: một vòng lặp, một catalogue, một executor, năm lối thoát, một lần chạy được persisted, một signal đi tới công cụ, và một trace có run id trên mọi dòng. Chương 24, 25, 29 và 30 xây trên file này, còn 26 đến 28 trên những gì nó có thể chạm tới.

Nó còn lại một vấn đề, và các phép đo bên trên đã chỉ vào nó suốt từ đầu. Nhìn lại bảng runaway một lần nữa: 3.431 input tokens ở tám lượt, 337.299 ở một trăm. Nhìn lần chạy hoạt động: 204, 269, 342. Mỗi lượt gửi lại toàn bộ transcript, nên context của agent đầy lên bằng chính lịch sử của nó — và model dùng phần xa của một window dài kém hơn phần gần, đó là lý do một agent tốt ở lượt năm trở thành một agent bối rối ở lượt bốn mươi.

Giới hạn lượt không sửa điều đó. Nó chỉ ngăn bạn trả tiền để xem điều đó xảy ra. Thứ sửa được là quyết định, ở từng lượt một, token nào xứng đáng vào window: compact cái gì, chuyển cái gì ra một note để agent có thể fetch, giao cái gì cho subagent với một window sạch, và tool definitions nào xứng đáng với khoản thuế vĩnh viễn của chúng. Chương 24 đo window thực sự đi đâu — và điều bất ngờ là nó không phải cuộc hội thoại.


Mọi con số trong chương này đến từ hai server được mô tả ở trên, trên Node 22 qua loopback interface: một scripted provider đếm token bằng encoding o200k_base, và Qwen/Qwen2.5-0.5B-Instruct phía sau một endpoint cùng hình dạng, greedy decoding, trên CPU. Chi phí được tính từ token count đo được ở các mức giá Chương 16 đọc vào ngày 6 tháng 9 năm 2026 — $2.00 cho mỗi triệu input tokens và $12.00 cho mỗi triệu output — và không request nào trong chương này đi tới paid endpoint. Câu trả lời của local model là câu trả lời của một model nhỏ; hãy đọc chúng như bằng chứng về vòng lặp, vốn giống hệt theo cả hai cách, chứ không phải benchmark về điều các model hiện tại làm.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. và Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Sự đan xen giữa reasoning traces và actions mà vòng lặp triển khai, và nguồn của quan sát rằng acting cho phép model “handle exceptions” — chính là điều bảng lỗi công cụ bên trên đo.

  2. Sumers, T. R., Yao, S., Narasimhan, K. và Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Cách xử lý hình thức cho điều vòng lặp bên trên làm một cách phi hình thức: các thành phần memory module, một structured action space trải rộng qua internal memory và external environments, và “một generalized decision-making process to choose actions”. Hãy đọc nó để có vốn từ mà thuật ngữ ngành đang thiếu — đặc biệt là sự tách bạch giữa working, episodic, semantic và procedural memory, bóng thực tiễn của nó là bảng ba store ở Chương 24.

  3. ai (Vercel AI SDK) phiên bản 7.0.93, xuất bản ngày 4 tháng 9 năm 2026; khai báo kiểu đọc từ cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts ngày 7 tháng 9 năm 2026. File 397 KB chứa 0 lần xuất hiện của chuỗi harness. Class agent là declare class ToolLoopAgent, được export cả dưới dạng ToolLoopAgent lẫn Experimental_Agent; declare function isStepCount(stepCount: number) — export là stepCountIs — được trích nguyên văn ở trên; type StopCondition được hiển thị không có type parameter thứ hai (RUNTIME_CONTEXT extends Context = Context), đây là phần lược duy nhất trong đoạn trích, cũng như shape của stopWhen?: Arrayable<StopCondition<...>> trên generateTextstreamText. Cùng file đó khai báo toolApproval, ToolApprovalStatus, prepareSteprepairToolCall, tức là triển khai tham chiếu đã độc lập đi tới approval gates, per-step preparation và error repair. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. và Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Phần tóm tắt gọi artifact này là “evaluation framework” gồm 2.294 vấn đề và không bao giờ dùng từ “harness”; README của chính dự án (github.com/SWE-bench/SWE-bench, đọc ngày 7 tháng 9 năm 2026) dùng từ này năm lần, luôn là “evaluation harness”, và entry point là python -m swebench.harness.run_evaluation. Đó là nghĩa còn lại của từ này: một scaffold giữ agent đứng yên và chấm điểm nó, không phải vòng lặp chạy nó.

  5. Anthropic, Building effective agents, 19 tháng 12 năm 2024, anthropic.com/engineering/building-effective-agents, đọc ngày 7 tháng 9 năm 2026. Augmented model như building block, agent như một LLM “using tools based on environmental feedback in a loop”, và khuyến nghị stopping conditions “such as a maximum number of iterations” để duy trì kiểm soát. Chương 22 trích đầy đủ định nghĩa của họ.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. và Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Vòng lặp còn lại — serving scheduler batching request của bạn với request của người lạ và quản lý KV cache của Chương 13. Đáng biết nó tồn tại chính vì nó không thuộc về bạn: latency mà harness của bạn nhân lên được đặt bên trong nó, và không lượng công sức nào trên vòng lặp của bạn dịch chuyển được nó.

  7. Số lượt tải từ npm registry, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, một window rõ ràng thay vì window rolling last-month, và lịch sử phát hành từ registry.npmjs.org/<package>; cả hai được query ngày 7 tháng 9 năm 2026. Release count là số phiên bản được xuất bản trong mười hai tháng tính đến ngày đó, bao gồm canary builds: ai 945 (mới nhất 7.0.93 ngày 2026-09-04, với major version 5, 6 và 7 đều xuất hiện trong window), langchain 132 (mới nhất 1.5.10 ngày 2026-08-20), @openai/agents 83 (mới nhất 0.17.0 ngày 2026-08-19, xuất bản lần đầu 2025-06-03). 2

  8. Claude Agent SDK (@anthropic-ai/claude-agent-sdk) là Claude Code harness được đóng gói thành thư viện — agent loop, file và shell tools tích hợp, context management, sessions, hooks, permissions và subagents — được document tại code.claude.com/docs/en/agent-sdk. Nó là thứ gần nhất với một bản mô tả đã xuất bản về từng cơ chế chương này xây thủ công, và đáng đọc cạnh implementation của chính bạn cho những phần nó gọi tên còn chương này chỉ lướt qua.

Sẵn sàng để LIA chọn giúp bạn chưa?

Xây dựng cùng mọi mô hình AI ở một nơi — bắt đầu miễn phí ngay hôm nay.