从0打造编码Agent 岔路 阅读 ≈ 20 分钟 页内实验 ×1
A1 循环:模型只会吐文字,它怎么改你的文件?什么时候停? 岔路课

为什么不用框架自带的自动多步?

为什么自己写循环
很多 SDK 自带「自动多步」:给它工具和一个步数,它替你转圈。OpenCode 用的就是这样一个 SDK,却偏偏不开这个开关,而是自己写 while(true)——因为它要在两次模型调用之间做十来件事:读取压缩后的历史、检查是否该压缩、注入提醒、按权限重算工具、组装 system prompt。Codex 更进一步,把循环拆成了四层。
❓

本课一问:第 2 课自己写了一个 while。可很多 SDK 已经自带「自动多步」:给它工具清单和最大步数,它自己请求、执行工具、再请求,直到模型不再调工具。为什么生产 harness 还要自己写这个循环?

这一课先不碰:选哪家 SDK、要不要自建模型接入层(A8);子 agent 的编排(A7)。

从已有出发

第 2 课的循环在每一圈都做同一件事:把 messages[] 整份发出去、执行工具、追加结果。真实的 harness 在「追加结果」和「下一次发出去」之间,还要做很多别的事。

为什么这里不用贯穿任务的 trace:这一课的问题出在「循环由谁来写」,trace 里看不出来,只能读源码;贯穿任务在本课项目 miniagent 里的样子,就是下面表格里「只有第 ③ 层」的那一种。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

先自己设计:把多步交给框架

最省事的做法:用 SDK 的自动多步(比如 Vercel AI SDK 的 streamText 配 stopWhen),工具的 execute 函数交给 SDK 调,一次调用就跑完整个任务。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案用框架的自动多步:一次调用跑完所有步
OpenCode · llm.ts:调 streamText,但没有 stopWhen opencode@v1.18.32:packages/opencode/src/session/llm.ts:276-353需联网 MIT
276 // Default runtime path: AI SDK owns provider execution and tool dispatch;
277 // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.
278 return {
279 type: "ai-sdk" as const,
280 result: streamText({
281 onError(error) {
282 bridge.fork(
283 Effect.logError("stream error", {
284 providerID: input.model.providerID,
285 modelID: input.model.id,
286 "session.id": input.sessionID,
287 small: (input.small ?? false).toString(),
288 agent: input.agent.name,
289 mode: input.agent.mode,
290 error,
291 }),
292 )
293 },
294 // Copilot returns the authoritative billed amount only in provider-specific response fields.
295 includeRawChunks: input.model.providerID.includes("github-copilot"),
296 async experimental_repairToolCall(failed) {
297 const lower = failed.toolCall.toolName.toLowerCase()
298 if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
299 return {
300 ...failed.toolCall,
301 toolName: lower,
302 }
303 }
304 return {
305 ...failed.toolCall,
306 input: JSON.stringify({
307 tool: failed.toolCall.toolName,
308 error: failed.error.message,
309 }),
310 toolName: "invalid",
311 }
312 },
313 temperature: prepared.params.temperature,
314 topP: prepared.params.topP,
315 topK: prepared.params.topK,
316 providerOptions: ProviderTransform.providerOptions(input.model, prepared.params.options),
317 activeTools: Object.keys(prepared.tools).filter((x) => x !== "invalid"),
318 tools: prepared.tools,
319 toolChoice: input.toolChoice,
320 maxOutputTokens: prepared.params.maxOutputTokens,
321 abortSignal: input.abort,
322 headers: prepared.headers,
323 maxRetries: input.retries ?? 0,
324 messages: prepared.messages,
325 model: wrapLanguageModel({
326 model: language,
327 middleware: [
328 {
329 specificationVersion: "v3" as const,
330 async transformParams(args) {
331 if (args.type === "stream") {
332 // @ts-expect-error
333 args.params.prompt = ProviderTransform.message(
334 args.params.prompt,
335 input.model,
336 prepared.messageTransformOptions,
337 )
338 }
339 return args.params
340 },
341 },
342 ],
343 }),
344 experimental_telemetry: {
345 isEnabled: cfg.experimental?.openTelemetry,
346 functionId: "session.llm",
347 tracer: telemetryTracer,
348 metadata: {
349 userId: cfg.username ?? "unknown",
350 sessionId: input.sessionID,
351 },
352 },
353 }),
👆 带下划线的行可以点:点哪行,讲哪行。
🎯

试三件事:① 在右边的源码里点 ⑧:工具清单是每一步现算的——框架的自动多步通常在开头拿一次工具清单,整个过程不变;② 点 ④ 和 ⑤:压缩只能插在两次调用之间,这是 A5 的前提;③ 回想第 3 课 OpenCode 的退出条件:它是在 ② 这里、由 OpenCode 自己判的,不是 SDK 判的。

改进:循环分几层

教学蓝本只有一层循环。Codex 把它拆成了四层,每一层回答一个不同的问题:

层在哪(pin 8cabf5a)它转一圈是什么在这一层能做的事
① 提交层session/handlers.rs:706 submission_loop收到一个提交(用户消息、中断、配置变更…)中断、切换设置、启动新任务
② 任务层tasks/regular.rs:73-90跑一个 turn;turn 结束后如果还有待处理的用户输入,再跑一个把「做完之后的追加」接成新的 turn(岔路 B1)
③ turn 层session/turn.rs:278-558一次采样 + 执行它要的工具;这就是第 2 课的那个循环退出判定、压缩、Stop hook、预算提醒
④ 流层session/turn.rs:2215-2740(try_run_sampling_request)一个流式事件边收边执行工具、流错误重试(第 3 课的截断重发)
Codex · tasks/regular.rs:第二层循环 codex@8cabf5a:codex-rs/core/src/tasks/regular.rs:73-90需联网 Apache-2.0 最新版已变化 · 勘误表 X9
73 let mut next_input = input;
74 let mut prewarmed_client_session = prewarmed_client_session;
75 loop {
76 let last_agent_message = run_turn(
77 Arc::clone(&sess),
78 Arc::clone(&ctx),
79 next_input,
80 prewarmed_client_session.take(),
81 cancellation_token.child_token(),
82 )
83 .instrument(run_turn_span.clone())
84 .await?;
85 if !sess.input_queue.has_pending_input(&sess.active_turn).await {
86 return Ok(last_agent_message);
87 }
88 next_input = Vec::new();
89 }
90 }
👆 带下划线的行可以点:点哪行,讲哪行。

OpenCode 的 while(true) 大致对应第 ③ 层,它的 ① ② 由 HTTP 服务与会话管理承担;本课项目 miniagent 只有第 ③ 层——一次性命令行工具,不需要其余几层。

⚠
常见误解:「用了框架的 maxSteps / stopWhen,就等于写好了 harness。」

框架的自动多步替你转圈,也就拿走了「两次调用之间」这个位置。上面那十来件事——压缩、提醒注入、按权限重算工具、退出的双重校验、截断重发——都要在那里做。OpenCode 用着同一个 SDK,仍然选择每次只让它跑一步。至于它这样选的原因,源码里没有注释说明,上面是根据代码结构的归纳 。

🔍 那框架的自动多步什么时候合适?

任务短、工具少、不需要压缩和权限中断时,它省事而且不容易写错——比如 A0 讲的「一段代码就够」的 workflow 里的一个步骤。一旦需要在调用之间改写上下文(A5)、中途等用户批准(A3)、按步数或预算收尾(第 4 课),就得把循环拿回自己手里。OpenCode 自己也在试验另一条路:它的 v2(packages/core)把 runner 拆成了独立模块,但截至 2026-09-26 还没有启用,课程不把它当现行设计讲。

证据三问

问本课的回答
① 它解决什么问题?在两次模型调用之间保留一个 harness 能插手的位置
② 为什么可能有效?压缩、提醒、权限、退出判定都依赖「上一轮的结果」和「下一轮要发什么」,只能放在两次调用之间
③ 实验上呢?没有找到「框架自动多步 vs 自写循环」的对照实验;这一课的依据全部是源码

演进地图:你在这里

模型只会吐文字,怎么动手、什么时候停:每一代都被上一代的某个真数字逼出来
  1. 解决了不需要任何协议:模型本来就会写命令。
    卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法 sed -i 在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。
  2. 第一代 工具调用协议:模型写结构化的调用,harness 执行后回填 tools 参数 + tool_calls + role: tool
    解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以 role: tool 回填,模型能看见后果。
    卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。
  3. 朴素 固定调用 3 轮 for _ in range(3)
    解决了能连续走几步了,而且一定会停。
    卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
  4. 第二代 循环到模型不再调工具为止 CoreCoder、learn-claude-code s01
    解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。
    卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
  5. 第三代 双重校验 + 截断防护 + 可枚举的退出原因 OpenCode、Pi、Hermes;本课项目 v2
    解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。
    卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以 dropped_tool_call 停;而且它不回答「要不要强制停」。
  6. 第四代 上限与预算:硬上限 / 收尾提示 / 不设上限靠压缩兜底 / 预算加总结调用 CoreCoder 50 · OpenCode steps · Codex 不设 · Hermes 预算
    解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。
    卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
  7. 岔路 双层循环:干活时插话、做完后追加 Pi steering / follow-up;Codex 任务层
    解决了用户不必等整次运行结束才能纠偏。
    卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
  8. 岔路 不用框架的自动多步,自己驱动循环 OpenCode 调 streamText 不传 stopWhen 你在这里
    解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。
    卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
  9. 岔路 独立评估器决定能不能停 learn-claude-code s17 · Hermes goals · Codex Stop hook
    解决了「模型说做完了」交给另一个没有工具的模型核对。
    卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
数字口径:本课项目 miniagent 的真实运行(Qwen3-8B-MLX-4bit,2026-09-26;单一模型、单一任务,只说明现象会发生,不估计频率);「截在第 N 轮」的验收结果是重放那次运行前 N 轮的 edit 后实跑测试得到的;插话一行来自 8 个终止谓词在同一条构造事件序列上的判定(a01-termination.json)。

一句话

📌
框架的自动多步替你转圈,也就拿走了「两次调用之间」这个位置;生产 harness 自己写循环,就是为了在那里做事。

回主线

那它什么时候停?「没有工具调用就停」够不够? → 第 3 课

你可能会问