跳至内容
23/30第 23 章,共 30 章

构建 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_evaluation4

所以,两个不同的东西共用一个名字。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 章建立在它能触达的内容之上。

第 14 章无法基于真实 provider 来写,因为你不能要求它在指定时刻返回 429。本章有同样的问题,只是形态不同:你不能要求真实模型按需、可复现地失控,或者连续两次请求完全相同的工具。

所以第一个程序是一个脚本化 provider:一个形状类似 chat completions API 的 endpoint,其回复取决于 turn 索引以及工具到目前为止返回的内容。它用真实的 byte-pair encoder 统计 token,因此下面的钱是算术,而不是装饰。

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 索引是从对话派生出来的,而不是保存在变量里,所以 provider 是无状态的,一次运行可以被杀掉再对它恢复。并且 recover 会在决定之前读取工具结果:一个会读取自身 transcript 的脚本化模型,是衡量 harness 是否给了它值得阅读内容的最低要求。

目录沿用第 18 章,三个文件里的四个工具:list_filesread_filedelete_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 });
  }
}

把它指向脚本化 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

三个 turn,两次工具执行,四分之一美分。注意最后一行:204、269、342。每个 turn 都会重新发送它之前的一切,这就是第 16 章的平方级账单,出现在一个根本没人输入任何东西的地方。本章其余部分讲的就是当这行数字不停增长时会发生什么。

把同一个循环指向 runaway 脚本——一个每个 turn 都请求工具、从不输出正文的模型——标记为 return 的分支就永远不会触发。没有其他出口。程序会一直运行,直到进程死掉,或者信用卡先死掉。

修复只需一行,这是文献最先推荐的控制手段,5 也是所有人最终都会写的东西。几乎没人做的是衡量它值多少钱:

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

把最后两行放在一起读。把上限从 50 翻到 100,并没有让成本翻倍;它让成本乘以 3.7。输入 token 从 88,649 增至 337,299,是 3.8 倍,因为第 nn 个 turn 会携带之前每一个 turn,总量是 Θ(n2)\Theta(n^2)。turn cap 不是线性旋钮。它调的是你最坏情况的平方根,所以把它从 20 提到 100,“只是为了安全”,是一个值得在做出之前先定价的决定。

turn cap 的问题在于,一个 turn 没有固定价格。上面短 transcript 的二十个 turn 花费 $0.038。带着 200 个工具目录、检索到的文档集和四十条历史消息的二十个 turn,成本会高出数百倍,而上限对此一无所知。operator 真正想限制的是账单。

所以循环会用第 16 章的 computeCost 结合那里读取到的费率来统计金额——本课程始终使用的模型价格是每百万输入 token $2.00、每百万输出 token $12.00:

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

同一个失控脚本,完全没有 turn cap,三个预算:

budgetturns reachedactually spent
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

有两点值得点名。第一,预算每次买到的是不同数量的 turn,这正是重点:它限制的是 operator 在意的东西,并让 turn 数落在 transcript 决定的位置。第二,每一行都会超支。 预算是 $0.010,却花了 $0.010780,因为检查发生在一个 turn 之前,而一个 turn 的价格直到结束后才知道。你无法精确限制支出;你只能把它限制在一个 turn 的成本以内。要在界面里说清楚,而不是假装可以,并且把检查放在调用之前,这样超支是一轮,而不是两轮。

到现在,循环有三个出口,剩下章节的形状也清楚了。生产运行只会以五种方式之一结束,而且它们不是彼此的变体:

how it endswho decidedwhat the caller should do
the model stopped askingthe modelread the answer
turn capyou, in advanceraise the cap, or accept a partial result
budget exhaustedyou, in advanceapprove more money, or accept a partial result
an error you cannot retrythe provider or a toolfix the deployment; Chapter 14's triage decides
a human interveneda personwait for a verdict, then resume

把这些折叠成一个 boolean,是这个文件里最常见的设计错误,并且会以一种很具体的方式变贵:五种里有三种是可恢复的,两种不是。一个触达 turn cap 的 agent 拥有有效 transcript、真实的部分结果和下一步;一个遇到 401 的 agent 没有这些。所以 harness 会把原因记录为数据:

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 章以一个没有数字的主张收尾:把工具的错误作为工具结果交还给模型,而不是抛出,模型通常会自我修复。这里给出数字。

一次失败,三种策略。脚本化模型猜了一个不存在的文件;工具抛出 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.000756a stack trace
returns Error: the tool failed.21$0.001462“我无法读取文件,所以我不知道。”
returns what actually happened43$0.003550“errors.log 提到了 timeout。”

第三行成本是第一行的 4.7 倍,并且是唯一回答了问题的一行。第二行才有意思,因为这是大多数代码库实际会做的事:错误被捕获了,循环活了下来,模型被告知东西失败了,但没有被告知是什么,于是它礼貌地放弃了。第二行和第三行的差别不是错误处理。它是给读者写的一句话。

因此,harness 会把抛出的工具当作数据,并把措辞变成一项策略:

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 章也警告过另一面,而它也有价格。把循环指向一个因任何消息都修不好的原因而失败的工具——进程无权执行的读取——模型会永远重试:

TEXT
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 上出现相同工具、相同参数。

以没有重复调用的同一任务为基线来衡量:

turnstool runscost
the task, no repeat21$0.001396
the same task, one call repeated32$0.002446
repeated, with a result cache on read-only tools31$0.002446

重复调用额外花了 $0.001050,增长 75%,而让人意外的是:缓存结果一点也没有追回这笔成本。去重省下的是工具执行,不是 turn,因为等你的代码注意到重复时,模型已经因为提出请求而收过钱了。当工具很慢、有速率限制,或者按调用计费时,这个节省是真实的——但在增长的那条账目上,它是零。

还有更糟的版本。把同一个缓存应用到会写入的工具,第二次调用就会静默地没有发生:

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

哪一个是正确的?都不是,而且可知地不是。协议说这是两个调用:它们携带两个不同的 tool_call_id 值。参数说它们可能是一个调用。一个靠比较参数字符串来决定的 harness,总有一天会吞掉两个相同但有意发生的收费操作中的第二个——而第 14 章已经说过,唯一能诚实解决这个问题的机制,是由知道这个操作是什么的那一层为每个逻辑操作生成幂等性 key。在工具携带它之前,可辩护的默认行为就是上面的只读门:缓存读取,执行写入,并让写入自身的幂等性处理其余部分。

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 脚本会列出文件,然后请求删除一个任务从未提到的文件。到目前为止,循环里没有任何东西会阻止它。

标记为 needsApproval 的工具不会失败,也不会继续。它会停止运行并交还控制权,并带上一个人做决定所需的一切:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

这就是整个机制,而它是一个 return 而不是 callback 的原因在下一节:从停止到给出裁决之间,进程可能已经不存在了。

但先看一个没人预料到的测量。拒绝不是没有结果——transcript 中有一个由 tool_call_id 键控的槽位,里面必须放点东西。同一个拒绝运行两次,只改变放进去的那句话:

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

两次运行里都没有删除任何东西,而第二次却告诉用户已经删除了。 权限系统完美工作;报告是谎言。它和工具错误表是同一种机制,只是出现在重要得多的地方——人类说了不,操作被正确阻止,而 agent 的摘要与现实相矛盾,因为拒绝从未以模型可读的形式写下来。由此得到的规则很短:无论你的代码如何决定一个工具调用,都要把这个决定用文字写进 transcript。 第 30 章 会从安全角度回到这一点,在那里,这是审计轨迹和虚构故事之间的差别。

一次 approval 需要几分钟或几小时。一次 deploy 需要几秒。如果运行存在于 HTTP 请求内部的局部变量里,每次重启都是一次丢失的运行,每次 approval 都是一场竞态。

所以运行不是闭包。它是一个普通的可序列化对象——messages、turn count、cost、status、interruption、已批准 call id 列表——循环则是作用于它的纯函数。这个单一约束让持久化变成一行的事情:

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

正确性问题不是保存。问题是回来时会发生什么,而朴素答案会让你重复付费。如果进程在模型请求工具之后、结果写入之前死掉,那么从再次调用模型开始的恢复,会为一个它已经拥有的 turn 付费;如果从重新运行工具开始,它就会把写操作执行两次。

修复方法是让循环从询问 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));    
}

每次迭代都会先排空 pending,只有在没有 outstanding 时才询问模型。恢复会变成与正常运行相同的代码路径,approval 也是如此——已批准的调用只是一个现在允许运行的 pending 调用。任务中途杀掉进程再重启:

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

一个需要两个工具执行的任务,在两个进程中总共执行了两次工具,最终成本与从未崩溃的运行完全相同。成本能跨重启累积,是因为它在状态里,而不是在变量里。

scan_archive 在这里耗时三秒,代表生产中耗时三分钟的工具。它运行时缺少两件事:用户不知道任何事情正在发生,Stop 按钮也不起作用。

两者是同一个修复,而且是第 14 章的 AbortSignal 再向下推进一层。signal 不只用于 fetch——它被传进工具,写得好的工具会尊重它:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

从点击到停止只用了两毫秒,因为工具内部的 sleep 监听的是 fetch 所用的同一个 signal。如果你只把它传进 fetch,同一个 Stop 按钮会等待三秒——也就是工具的长度——然后运行在它原本要取消的工作已经完成之后才“取消”。没有一路接到底的取消,就是一个说着正确词语的 spinner。

harness 每个事件发出一行,词汇小到可以背下来:turntool_starttool_progresstool_resultapproval_requiredrun_stopped

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

有三个属性让它成为 trace,而不是 logging。每一行都带有 run id,所以一个跨越三个进程、两天时间的运行就是一次查询。每一行 turn 都带有自己的 token 计数和滚动成本,所以“为什么这次运行花了四十美元”可以事后回答,而不是只在理论上可复现。并且 run_stopped 带有原因,这个字段会把支持工单变成一行答案:一个因预算停止的 agent 和一个崩溃的 agent 从外面看完全相同,但需要相反的响应。

第 13 章 测量了你自有硬件上的首 token 时间。第 14 章通过 socket 测量了它。agent 会把它相乘,而乘数是一个没人选择的数字:

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

同一个三 turn 任务,只改变 provider 的 latency:

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

harness 本身为一次三 turn 运行贡献十五毫秒。除此之外的一切都是 NN 乘以一个你无法控制的数字——它在 serving scheduler 内部设定,而这个 scheduler 正在把你的请求与陌生人的请求一起 batching6——并且 NN 由模型选择。这就是为什么第 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,使用同样四个工具。基于同样三个文件的六个任务:

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

三个发现,而第三个正是本节存在的原因。

每一个任务都恰好在两个 turn 内完成。 turn cap 从未触发,预算从未触发,循环唯一的出口是模型生成正文。一个 5 亿参数模型不会迭代;无论它是否拥有所需信息,它都会在第二口气时作答。turn 数是模型的属性,不是你的循环的属性。

平均 turn 用了 6,908 毫秒,所以上面的 latency 表不是玩具:在这个规模下,一个假设的八 turn 运行,几乎就是屏幕上什么都没有的一分钟 wall clock。

而答案是错的。 最大的文件是 errors.log;模型列出了文件,从未读取它们,却还是随便点名了一个。第一个任务猜了一个文件名,被告知它不存在,然后就下结论。harness 在所有六次运行中都 flawless 地执行了。harness 让 agent 可治理,而不是正确——第 29 章 会讲你如何分辨是哪一种,第 30 章会讲没人这样做时要付出什么代价。

目录中的一个工具背后可以有另一次运行。接口沿用第 18 章——一个 schema 和一个 endpoint——而一个完整 agent 能放在它背后,是因为这个接口很窄:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

这十行里已经有三件事是正确的,而三者都是上面决策的结果:child 有自己的窗口,所以 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

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,815loop 作为显式状态图
langchain11,359,058chains、agents、integrations
@openai/agents6,093,155agents、handoffs、guardrails
@mastra/core5,914,502agents、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

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

在这个循环最常用的实现中,停止是复数,原因和上面一百九十六行里停止是复数相同。

现在你有了一个 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。

  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 的交错,也正是本循环所实现的内容,并且指出 acting 让模型能够“handle exceptions”——这正是上面工具错误表衡量的东西。

  2. 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 章的三存储表。

  3. 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,同时以 ToolLoopAgentExperimental_Agent 导出;declare function isStepCount(stepCount: number)——以 stepCountIs 导出——在上文逐字引用;type StopCondition 省略了第二个 type parameter(RUNTIME_CONTEXT extends Context = Context),这是摘录中唯一的省略,stopWhen?: Arrayable<StopCondition<...>>generateTextstreamText 上的形状也是如此。同一个文件声明了 toolApprovalToolApprovalStatusprepareSteprepairToolCall,也就是说 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)。摘要把 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 保持静止并给它评分的脚手架,而不是运行它的循环。

  5. 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 章完整引用了它的定义。

  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 会把你的请求与陌生人的请求一起 batching,并管理第 13 章的 KV cache。它值得知道,恰恰因为它不是你的:你的 harness 所乘上的 latency 是在它里面设定的,你对自己循环做再多工作也移动不了它。

  7. 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:ai 945(最新 7.0.93,于 2026-09-04,major versions 5、6 和 7 都出现在该窗口内),langchain 132(最新 1.5.10,于 2026-08-20),@openai/agents 83(最新 0.17.0,于 2026-08-19,首次发布 2025-06-03)。 2

  8. Claude Agent SDK(@anthropic-ai/claude-agent-sdk)是打包成 library 的 Claude Code harness——agent loop、内置 file 和 shell tools、context management、sessions、hooks、permissions 和 subagents——文档见 code.claude.com/docs/en/agent-sdk。它是最接近于本章手写构建的每个机制的公开说明,值得与你自己的实现并读,尤其是它命名了而本章只粗略指向的部分。

准备好让 LIA 替你选模型了吗?

所有 AI 模型都在一处——今天就免费开始。