构建 agent harness:循环与五种退出方式
一个首次即能运行的十五行循环,再故意破坏七次:从失控运行开始,其成本是严格限额版本的 77 倍。
本页内容
先说最诚实的部分,因为别人不会这么说:“harness”是行话,不是标准。 没有规范,没有委员会,也没有参考定义。本章引用的四篇论文——ReAct、1 CoALA、2 SWE-bench 和 vLLM——在摘要里一次都没有使用这个词。这个东西下载量最高的实现,Vercel 的 ai package,每月下载量 8940 万,也没有使用它:在 7.0.93 版本随附的 397 KB 类型声明中,字符串 harness 出现了零次。3 这个词唯一真正承担重量的地方,意思完全不同。SWE-bench 在 README 中五次提到“harness”,但始终是 evaluation harness——一个容器化脚手架,用来应用补丁并运行测试——它的 Python 模块字面上就是 swebench.harness.run_evaluation。4
所以,两个不同的东西共用一个名字。evaluation harness 让 agent 保持静止并给它评分。agent harness 则是运行 agent 的程序:它调用模型,执行模型请求的内容,决定何时停止,并在其间保存状态。本章会用不到两百行 TypeScript 构建第二种东西,不使用任何框架。
循环本身只有十五行,并且第一次就能跑通。后面的一切,都是离开它的方法。
查看详情
本章需要前文提供的内容。
- 第 14 章 提供客户端:截止时间、status triage、取消、幂等性 key,以及这里再次使用的 mock provider 技术。
- 第 16 章 提供算术:输入 token 会随对话的平方增长,下面使用的费率也是该章在 2026 年 9 月 6 日读取到的费率。
- 第 18 章 提供工具目录:模型能看到的 schema、它永远看不到的 endpoint,以及错误是 context 而不是异常这一规则。
- 第 22 章 提供本章继承的循环,以及两个彼此矛盾的“agent”公开定义。
这里没有张量。这是本课程的第二个依赖枢纽:第 24、25、29 和 30 章运行在下面这个文件之上,而第 26 到 28 章建立在它能触达的内容之上。
一个可以编写脚本的 provider
链接到此部分:一个可以编写脚本的 provider第 14 章无法基于真实 provider 来写,因为你不能要求它在指定时刻返回 429。本章有同样的问题,只是形态不同:你不能要求真实模型按需、可复现地失控,或者连续两次请求完全相同的工具。
所以第一个程序是一个脚本化 provider:一个形状类似 chat completions API 的 endpoint,其回复取决于 turn 索引以及工具到目前为止返回的内容。它用真实的 byte-pair encoder 统计 token,因此下面的钱是算术,而不是装饰。
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 索引是从对话派生出来的,而不是保存在变量里,所以 provider 是无状态的,一次运行可以被杀掉再对它恢复。并且 recover 会在决定之前读取工具结果:一个会读取自身 transcript 的脚本化模型,是衡量 harness 是否给了它值得阅读内容的最低要求。
目录沿用第 18 章,三个文件里的四个工具: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 });
}
}把它指向脚本化 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三个 turn,两次工具执行,四分之一美分。注意最后一行:204、269、342。每个 turn 都会重新发送它之前的一切,这就是第 16 章的平方级账单,出现在一个根本没人输入任何东西的地方。本章其余部分讲的就是当这行数字不停增长时会发生什么。
破坏一:永不结束的任务
链接到此部分:破坏一:永不结束的任务把同一个循环指向 runaway 脚本——一个每个 turn 都请求工具、从不输出正文的模型——标记为 return 的分支就永远不会触发。没有其他出口。程序会一直运行,直到进程死掉,或者信用卡先死掉。
修复只需一行,这是文献最先推荐的控制手段,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 |
把最后两行放在一起读。把上限从 50 翻到 100,并没有让成本翻倍;它让成本乘以 3.7。输入 token 从 88,649 增至 337,299,是 3.8 倍,因为第 个 turn 会携带之前每一个 turn,总量是 。turn cap 不是线性旋钮。它调的是你最坏情况的平方根,所以把它从 20 提到 100,“只是为了安全”,是一个值得在做出之前先定价的决定。
破坏二:turn 上限不是金额上限
链接到此部分:破坏二:turn 上限不是金额上限turn cap 的问题在于,一个 turn 没有固定价格。上面短 transcript 的二十个 turn 花费 $0.038。带着 200 个工具目录、检索到的文档集和四十条历史消息的二十个 turn,成本会高出数百倍,而上限对此一无所知。operator 真正想限制的是账单。
所以循环会用第 16 章的 computeCost 结合那里读取到的费率来统计金额——本课程始终使用的模型价格是每百万输入 token $2.00、每百万输出 token $12.00:
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);同一个失控脚本,完全没有 turn cap,三个预算:
| budget | turns reached | actually spent |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
有两点值得点名。第一,预算每次买到的是不同数量的 turn,这正是重点:它限制的是 operator 在意的东西,并让 turn 数落在 transcript 决定的位置。第二,每一行都会超支。 预算是 $0.010,却花了 $0.010780,因为检查发生在一个 turn 之前,而一个 turn 的价格直到结束后才知道。你无法精确限制支出;你只能把它限制在一个 turn 的成本以内。要在界面里说清楚,而不是假装可以,并且把检查放在调用之前,这样超支是一轮,而不是两轮。
离开循环有五种方式,不是一种
链接到此部分:离开循环有五种方式,不是一种到现在,循环有三个出口,剩下章节的形状也清楚了。生产运行只会以五种方式之一结束,而且它们不是彼此的变体:
| how it ends | who decided | what the caller should do |
|---|---|---|
| the model stopped asking | the model | read the answer |
| turn cap | you, in advance | raise the cap, or accept a partial result |
| budget exhausted | you, in advance | approve more money, or accept a partial result |
| an error you cannot retry | the provider or a tool | fix the deployment; Chapter 14's triage decides |
| a human intervened | a person | wait for a verdict, then resume |
把这些折叠成一个 boolean,是这个文件里最常见的设计错误,并且会以一种很具体的方式变贵:五种里有三种是可恢复的,两种不是。一个触达 turn cap 的 agent 拥有有效 transcript、真实的部分结果和下一步;一个遇到 401 的 agent 没有这些。所以 harness 会把原因记录为数据:
export type RunStatus =
| "running" | "completed" | "failed"
| "max_turns_exceeded" | "budget_exceeded" | "interrupted";
export type Interruption =
| { type: "approval"; callId: string; toolName: string; args: unknown }
| { type: "max_turns" } | { type: "max_budget" }
| { type: "cancelled"; reason: string };破坏三:工具失败
链接到此部分:破坏三:工具失败第 18 章以一个没有数字的主张收尾:把工具的错误作为工具结果交还给模型,而不是抛出,模型通常会自我修复。这里给出数字。
一次失败,三种策略。脚本化模型猜了一个不存在的文件;工具抛出 no such file: timeout.log. Call list_files to see what exists.
| 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 | a stack trace |
returns Error: the tool failed. | 2 | 1 | $0.001462 | “我无法读取文件,所以我不知道。” |
| returns what actually happened | 4 | 3 | $0.003550 | “errors.log 提到了 timeout。” |
第三行成本是第一行的 4.7 倍,并且是唯一回答了问题的一行。第二行才有意思,因为这是大多数代码库实际会做的事:错误被捕获了,循环活了下来,模型被告知有东西失败了,但没有被告知是什么,于是它礼貌地放弃了。第二行和第三行的差别不是错误处理。它是给读者写的一句话。
因此,harness 会把抛出的工具当作数据,并把措辞变成一项策略:
} 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 章也警告过另一面,而它也有价格。把循环指向一个因任何消息都修不好的原因而失败的工具——进程无权执行的读取——模型会永远重试:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""对一个不可能成功的调用执行了十一次相同操作,成本是从可修复失败中恢复那次运行的 5.2 倍,最后什么也没有。错误是 context;永久性错误是会毒化后续整次运行的 context。 这个区分就是第 14 章的 status triage 上移一层:模型可以采取行动的错误回到 transcript,模型不能处理的错误应该带着原因停止运行。今天站在你和第二种情况之间的是 turn cap,它只是地板,不是修复。
破坏四:同一个调用,出现两次
链接到此部分:破坏四:同一个调用,出现两次现在是大多数人以为不可能发生的失败。模型会重复自己。让任何循环跑得足够久,你都会看到两个连续 turn 上出现相同工具、相同参数。
以没有重复调用的同一任务为基线来衡量:
| turns | tool runs | cost | |
|---|---|---|---|
| the task, no repeat | 2 | 1 | $0.001396 |
| the same task, one call repeated | 3 | 2 | $0.002446 |
| repeated, with a result cache on read-only tools | 3 | 1 | $0.002446 |
重复调用额外花了 $0.001050,增长 75%,而让人意外的是:缓存结果一点也没有追回这笔成本。去重省下的是工具执行,不是 turn,因为等你的代码注意到重复时,模型已经因为提出请求而收过钱了。当工具很慢、有速率限制,或者按调用计费时,这个节省是真实的——但在增长的那条账目上,它是零。
还有更糟的版本。把同一个缓存应用到会写入的工具,第二次调用就会静默地没有发生:
naive cache on every tool 3 turns, 1 tool run, files deleted: ["access.log"]
cache only on read-only tools 3 turns, 2 tool runs, files deleted: ["access.log","access.log"]哪一个是正确的?都不是,而且可知地不是。协议说这是两个调用:它们携带两个不同的 tool_call_id 值。参数说它们可能是一个调用。一个靠比较参数字符串来决定的 harness,总有一天会吞掉两个相同但有意发生的收费操作中的第二个——而第 14 章已经说过,唯一能诚实解决这个问题的机制,是由知道这个操作是什么的那一层为每个逻辑操作生成幂等性 key。在工具携带它之前,可辩护的默认行为就是上面的只读门:缓存读取,执行写入,并让写入自身的幂等性处理其余部分。
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {
state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
continue;
}破坏五:它删除了某个东西
链接到此部分:破坏五:它删除了某个东西destructive 脚本会列出文件,然后请求删除一个任务从未提到的文件。到目前为止,循环里没有任何东西会阻止它。
标记为 needsApproval 的工具不会失败,也不会继续。它会停止运行并交还控制权,并带上一个人做决定所需的一切:
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."这就是整个机制,而它是一个 return 而不是 callback 的原因在下一节:从停止到给出裁决之间,进程可能已经不存在了。
但先看一个没人预料到的测量。拒绝不是没有结果——transcript 中有一个由 tool_call_id 键控的槽位,里面必须放点东西。同一个拒绝运行两次,只改变放进去的那句话:
rejected with a reason deleted=[] the agent then told the user:
"I did not delete anything: you declined the deletion."
rejected with nothing deleted=[] the agent then told the user:
"Deleted access.log to free space."两次运行里都没有删除任何东西,而第二次却告诉用户已经删除了。 权限系统完美工作;报告是谎言。它和工具错误表是同一种机制,只是出现在重要得多的地方——人类说了不,操作被正确阻止,而 agent 的摘要与现实相矛盾,因为拒绝从未以模型可读的形式写下来。由此得到的规则很短:无论你的代码如何决定一个工具调用,都要把这个决定用文字写进 transcript。 第 30 章 会从安全角度回到这一点,在那里,这是审计轨迹和虚构故事之间的差别。
破坏六:进程死掉了
链接到此部分:破坏六:进程死掉了一次 approval 需要几分钟或几小时。一次 deploy 需要几秒。如果运行存在于 HTTP 请求内部的局部变量里,每次重启都是一次丢失的运行,每次 approval 都是一场竞态。
所以运行不是闭包。它是一个普通的可序列化对象——messages、turn count、cost、status、interruption、已批准 call id 列表——循环则是作用于它的纯函数。这个单一约束让持久化变成一行的事情:
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"));正确性问题不是保存。问题是回来时会发生什么,而朴素答案会让你重复付费。如果进程在模型请求工具之后、结果写入之前死掉,那么从再次调用模型开始的恢复,会为一个它已经拥有的 turn 付费;如果从重新运行工具开始,它就会把写操作执行两次。
修复方法是让循环从询问 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));
}每次迭代都会先排空 pending,只有在没有 outstanding 时才询问模型。恢复会变成与正常运行相同的代码路径,approval 也是如此——已批准的调用只是一个现在允许运行的 pending 调用。任务中途杀掉进程再重启:
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一个需要两个工具执行的任务,在两个进程中总共执行了两次工具,最终成本与从未崩溃的运行完全相同。成本能跨重启累积,是因为它在状态里,而不是在变量里。
破坏七:三分钟的沉默
链接到此部分:破坏七:三分钟的沉默scan_archive 在这里耗时三秒,代表生产中耗时三分钟的工具。它运行时缺少两件事:用户不知道任何事情正在发生,Stop 按钮也不起作用。
两者是同一个修复,而且是第 14 章的 AbortSignal 再向下推进一层。signal 不只用于 fetch——它被传进工具,写得好的工具会尊重它:
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"从点击到停止只用了两毫秒,因为工具内部的 sleep 监听的是 fetch 所用的同一个 signal。如果你只把它传进 fetch,同一个 Stop 按钮会等待三秒——也就是工具的长度——然后运行在它原本要取消的工作已经完成之后才“取消”。没有一路接到底的取消,就是一个说着正确词语的 spinner。
trace,以及为什么它不是 log
链接到此部分:trace,以及为什么它不是 logharness 每个事件发出一行,词汇小到可以背下来: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}有三个属性让它成为 trace,而不是 logging。每一行都带有 run id,所以一个跨越三个进程、两天时间的运行就是一次查询。每一行 turn 都带有自己的 token 计数和滚动成本,所以“为什么这次运行花了四十美元”可以事后回答,而不是只在理论上可复现。并且 run_stopped 带有原因,这个字段会把支持工单变成一行答案:一个因预算停止的 agent 和一个崩溃的 agent 从外面看完全相同,但需要相反的响应。
latency 的算术
链接到此部分:latency 的算术第 13 章 测量了你自有硬件上的首 token 时间。第 14 章通过 socket 测量了它。agent 会把它相乘,而乘数是一个没人选择的数字:
同一个三 turn 任务,只改变 provider 的 latency:
| provider latency per turn | wall clock, 3 turns |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
harness 本身为一次三 turn 运行贡献十五毫秒。除此之外的一切都是 乘以一个你无法控制的数字——它在 serving scheduler 内部设定,而这个 scheduler 正在把你的请求与陌生人的请求一起 batching6——并且 由模型选择。这就是为什么第 14 章的 streaming 在这里比在 chat 里更重要、却帮助更少:你可以 stream 最后一个 turn,而它之前的四个 turn 都是沉默,除非 harness 发出进度。这也是上面 tool_progress 事件的全部理由——在 agent 中,诚实的反馈单位不是 token,而是步骤。
同一个 harness,端口后面是真实模型
链接到此部分:同一个 harness,端口后面是真实模型上面所有内容都运行在脚本化 provider 上,这证明了 harness,但没有证明任何模型。所以改一行——第 14 章的 seam,LLM_BASE_URL——把同一份代码指向本地 Qwen2.5-0.5B-Instruct,使用同样四个工具。基于同样三个文件的六个任务:
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三个发现,而第三个正是本节存在的原因。
每一个任务都恰好在两个 turn 内完成。 turn cap 从未触发,预算从未触发,循环唯一的出口是模型生成正文。一个 5 亿参数模型不会迭代;无论它是否拥有所需信息,它都会在第二口气时作答。turn 数是模型的属性,不是你的循环的属性。
平均 turn 用了 6,908 毫秒,所以上面的 latency 表不是玩具:在这个规模下,一个假设的八 turn 运行,几乎就是屏幕上什么都没有的一分钟 wall clock。
而答案是错的。 最大的文件是 errors.log;模型列出了文件,从未读取它们,却还是随便点名了一个。第一个任务猜了一个文件名,被告知它不存在,然后就下结论。harness 在所有六次运行中都 flawless 地执行了。harness 让 agent 可治理,而不是正确——第 29 章 会讲你如何分辨是哪一种,第 30 章会讲没人这样做时要付出什么代价。
subagents,在这里命名,之后计费
链接到此部分:subagents,在这里命名,之后计费目录中的一个工具背后可以有另一次运行。接口沿用第 18 章——一个 schema 和一个 endpoint——而一个完整 agent 能放在它背后,是因为这个接口很窄:
const research: Tool = {
name: "research",
description: "Investigate one question and return a short summary.",
parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
readOnly: true,
async run(args, ctx) {
const child = newRun(RESEARCH_SYSTEM, args.question); // its own transcript
const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
return out.output ?? "no result";
},
};这十行里已经有三件事是正确的,而三者都是上面决策的结果:child 有自己的窗口,所以 parent 的 transcript 收到的是摘要,而不是 child 读过的一切;它有自己的限制,所以失控的 child 不能花掉 parent 的预算;它继承 signal,所以一个 Stop 会取消整棵树。为什么干净窗口是重点而不是副作用,见第 24 章;五种 orchestration pattern——prompt chaining、routing、parallelisation、orchestrator-workers、evaluator-optimiser——以及 handoff,见第 25 章。
框架在哪里,以及为什么本课程没有使用框架
链接到此部分:框架在哪里,以及为什么本课程没有使用框架上面的任何内容都不应被理解为反对 library。按 2026 年 9 月 7 日测量,截至 8 月 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 | loop 作为显式状态图 |
langchain | 11,359,058 | chains、agents、integrations |
@openai/agents | 6,093,155 | agents、handoffs、guardrails |
@mastra/core | 5,914,502 | agents、workflows、memory |
本课程手写循环而不是教授其中某一个的原因,是明确说出的,不是暗示出来的,而且可以衡量。截至 2026 年 9 月 7 日的十二个月里,ai 发布了 945 个版本,并从 major 5 迁移到 major 7,它的 agent class 仍以 Experimental_Agent 导出;langchain 在同一窗口发布了 132 个版本;@openai/agents 发布了 83 个版本,在首次发布十五个月后仍停留在 0.x。7 针对这些 API 之一写成的章节,一季之内就会过时,而本章会发布成三十三种语言,所以每次重编都会让整套翻译一起付费。它们底下的东西不会动:一个 loop、一条 stopping rule、一个 catalogue、一个 executor、一些 state。
而 reference implementation 在关键部分同意本章。在 ai 7.0.93 版本中,循环的出口不是一个数字——它是 stopWhen,一组 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在这个循环最常用的实现中,停止是复数,原因和上面一百九十六行里停止是复数相同。
接下来去哪里
链接到此部分:接下来去哪里现在你有了一个 harness:一个 loop、一个 catalogue、一个 executor、五种退出方式、一个持久化运行、一个能到达工具的 signal,以及每行都带 run id 的 trace。第 24、25、29 和 30 章会建立在这个文件之上,第 26 到 28 章会建立在它能触达的内容之上。
它还剩一个问题,而上面的测量一路都在指向它。再看一次失控表:八个 turn 时 3,431 个输入 token,一百个 turn 时 337,299。再看那次能工作的运行:204、269、342。每个 turn 都重新发送整个 transcript,所以 agent 的 context 会被自己的历史填满——并且模型使用长窗口远端内容的能力不如近端,这就是为什么第五个 turn 还好的 agent,到第四十个 turn 会变得困惑。
turn cap 修不好这个。它只是阻止你继续付费观看它发生。真正修复它的是在每一个 turn 上决定哪些 token 配得上窗口:要压缩什么,要把什么移到 agent 可抓取的 note 里,要把什么交给拥有干净窗口的 subagent,以及哪些工具定义值得承担永久税。第 24 章会测量窗口实际去了哪里——令人意外的是,它去的并不是对话。
来源与方法
链接到此部分:来源与方法本章中的每个数字都来自上面描述的两台服务器,在 Node 22 上通过 loopback interface 运行:一个用 o200k_base encoding 统计 token 的脚本化 provider,以及一个位于相同形状 endpoint 后面的 Qwen/Qwen2.5-0.5B-Instruct,CPU 上 greedy decoding。成本根据测得 token 数按第 16 章在 2026 年 9 月 6 日读取到的费率计算——每百万输入 token $2.00、每百万输出 token $12.00——本章没有任何请求发往付费 endpoint。本地模型的答案是小模型的答案;请把它们看作关于循环的证据,无论哪种方式,循环都是同一个,而不是关于当前模型表现的 benchmark。
参考资料
链接到此部分:参考资料-
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 的交错,也正是本循环所实现的内容,并且指出 acting 让模型能够“handle exceptions”——这正是上面工具错误表衡量的东西。 ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023)。它对上面循环非正式完成的事情做了 formal treatment:模块化 memory components、跨越 internal memory 和 external environments 的 structured action space,以及“a generalized decision-making process to choose actions”。读它是为了补上 industry term 缺失的 vocabulary——尤其是 working、episodic、semantic 和 procedural memory 的分离,其实践影子就是第 24 章的三存储表。 ↩
-
ai(Vercel AI SDK) 版本 7.0.93,发布于 2026 年 9 月 4 日;类型声明于 2026 年 9 月 7 日从cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts读取。这个 397 KB 文件包含零次字符串harness。agent class 是declare class ToolLoopAgent,同时以ToolLoopAgent和Experimental_Agent导出;declare function isStepCount(stepCount: number)——以stepCountIs导出——在上文逐字引用;type StopCondition省略了第二个 type parameter(RUNTIME_CONTEXT extends Context = Context),这是摘录中唯一的省略,stopWhen?: Arrayable<StopCondition<...>>在generateText和streamText上的形状也是如此。同一个文件声明了toolApproval、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)。摘要把 artefact 称为一个包含 2,294 个问题的“evaluation framework”,且从未使用“harness”一词;项目自己的 README(
github.com/SWE-bench/SWE-bench,读取于 2026 年 9 月 7 日)使用了五次,每次都是“evaluation harness”,入口点是python -m swebench.harness.run_evaluation。这是这个词的另一种含义:一个让 agent 保持静止并给它评分的脚手架,而不是运行它的循环。 ↩ -
Anthropic, Building effective agents, 2024 年 12 月 19 日,
anthropic.com/engineering/building-effective-agents,读取于 2026 年 9 月 7 日。augmented model 是 building block,agent 是一个“using tools based on environmental feedback in a loop”的 LLM,并建议使用“such as a maximum number of iterations”这样的 stopping conditions 来保持控制。第 22 章完整引用了它的定义。 ↩ -
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 会把你的请求与陌生人的请求一起 batching,并管理第 13 章的 KV cache。它值得知道,恰恰因为它不是你的:你的 harness 所乘上的 latency 是在它里面设定的,你对自己循环做再多工作也移动不了它。 ↩
-
npm registry 下载量,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>,使用显式窗口而不是滚动的last-month窗口,release histories 来自registry.npmjs.org/<package>;两者均查询于 2026 年 9 月 7 日。release count 是截至该日期十二个月内发布的版本数量,包括 canary builds:ai945(最新 7.0.93,于 2026-09-04,major versions 5、6 和 7 都出现在该窗口内),langchain132(最新 1.5.10,于 2026-08-20),@openai/agents83(最新 0.17.0,于 2026-08-19,首次发布 2025-06-03)。 ↩ ↩2 -
Claude Agent SDK(
@anthropic-ai/claude-agent-sdk)是打包成 library 的 Claude Code harness——agent loop、内置 file 和 shell tools、context management、sessions、hooks、permissions 和 subagents——文档见code.claude.com/docs/en/agent-sdk。它是最接近于本章手写构建的每个机制的公开说明,值得与你自己的实现并读,尤其是它命名了而本章只粗略指向的部分。 ↩