Skip to content

最小 Agent Loop

本课交付结果

前四课已经准备好任务、CLI、Provider 和 Action,但它们还只是彼此独立的零件。Agent 真正出现的时刻,是 Runtime 能把模型动作与环境观察连接成闭环:把任务交给模型,收到 tool_call,执行工具,把结果写回下一轮上下文,直到模型提出 finish 或系统因失败、预算、重复动作、取消而停止。

本课交付 runAgent(task, provider, tools, limits, signal)。它维护 observations 与步骤数,调用 Provider,处理 message/tool_call/finish,执行已注册工具,序列化工具结果,检测最大步骤与重复动作,尊重取消,并返回 completedfailedbudget_exceededcancelled 四种结构化状态。Starter 已实现多数停止条件,练习缺口是最关键的“工具观察回写”。

完成本课后,第 1 周检查点能够使用 Fake Model 调用 echo 工具,再读取观察并 finish。这个循环只有几十行,却包含 Agent Runtime 的基本语法:观察—决策—动作—新观察。后续 35 课会给它增加工具合同、Sandbox、编辑事务、状态、Trace、Evaluation 和扩展系统,但不会改变这条核心因果链。

岗位问题

“循环调用模型直到它说完成”是最常见也最危险的 Agent 伪代码。它没有最大步数,模型可以永远解释;没有重复检测,同一个工具调用会反复执行;工具失败可能被吞掉,模型继续基于虚假成功推理;用户取消可能要等模型返回后才生效;finish 也可能在没有测试证据时被无条件接受。

Agent Loop 的职责不是让模型尽可能自由,而是管理一个受预算约束的状态机。每一轮都要回答:运行是否已取消?还能否进入下一步?模型返回的动作是否合法?这个动作是否重复?工具是否存在?执行结果怎样进入历史?出现错误时状态是什么?这些问题必须由 Runtime 决定,不能依赖 Prompt 中一句“请不要循环”。

观察回写是闭环成立的关键。模型调用 read_file 后,如果下一轮消息中没有文件内容,它只能再次读取或凭空猜测;工具即使成功,环境状态也没有进入推理状态。反过来,如果 Runtime 只回写“成功”而没有结构化结果,模型无法使用证据。观察必须包含调用身份、工具名、成功状态与值或错误,同时经过大小限制和脱敏。

Loop 还承担停止原因的可解释性。最大步数、重复动作、未知工具、工具异常和取消不应都变成“任务失败”。结构化状态让 CLI 映射退出码,让 Trace 记录终态,让恢复层决定是否重试,让 Evaluation 区分能力不足与安全阻止。可靠 Agent 首先是可靠停止的系统。

前置检查

前置知识快照

你需要理解异步 while 循环、可判别联合、依赖注入和协作式取消。Provider 与 Tool 都通过接口传入,测试可用脚本和函数替身。JSON.stringify(action) 可作为最小重复指纹,但对象 key 顺序和无关 callId 会影响结果;总项目会进一步稳定化。AbortSignal 要在模型调用前和工具执行后检查,底层调用也应接收 signal。

先证明 Action Protocol:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 04
pnpm --dir bootcamps/coding-agent/labs/05-minimal-agent-loop/starter test

Starter 四项中有三项已经通过:最大步骤、重复动作、调用模型前取消。唯一失败项要求工具返回 { content: 'hello' } 后,第二次 Provider 请求的 observations 包含该结果并最终 finish。这个失败精准指向闭环缺失,而不是要求重写整个循环。

原理拆解

最小循环是一台有守卫条件的状态机:

mermaid
stateDiagram-v2
  [*] --> Preflight
  Preflight --> Cancelled: signal.aborted
  Preflight --> BudgetExceeded: steps >= maxSteps
  Preflight --> Model: 可继续
  Model --> BudgetExceeded: 重复动作超限
  Model --> Completed: finish
  Model --> Observe: message
  Model --> Failed: 未知工具
  Model --> Tool: tool_call
  Tool --> Observe: 执行成功并回写
  Tool --> Failed: 执行异常
  Observe --> Preflight
  Completed --> [*]
  Failed --> [*]
  BudgetExceeded --> [*]
  Cancelled --> [*]

每轮先做 preflight,避免在已取消或预算耗尽后调用模型。模型响应到达后步骤加一,再记录动作指纹。finish 直接形成完成提案;message 进入 observations 后继续;tool_call 查找工具、执行并写回观察。任何终态都返回当前步骤和观察,调用方不需要解析异常判断普通运行结论。

observations 是最小工作记忆

Lab 用字符串数组表示观察,简化了消息协议。message:... 表示模型自身消息,tool:工具名:JSON值 表示环境结果。测试关注第二轮请求确实包含 tool:read_file:{"content":"hello"}。这证明信息流闭合,而不是只证明工具函数被调用。

生产系统更适合结构化消息:assistant tool_call 与 role=tool 的结果共享 callId,Provider 适配器再转换为供应商格式。无论表示形式如何,不变量是工具结果必须在下一轮模型上下文中可用,且原始值经过序列化、截断与脱敏。观察不是日志副本,而是模型后续决策的输入。

历史会持续增长,因此后续需要 Context Budget、摘要和持久状态。本课只保留小数组,目的是先证明正确因果顺序。不要在最小循环里提前加入复杂记忆框架,否则工具结果是否回写这个基本问题会被掩盖。

预算是 Runtime 权力的上限

最大步骤数限制模型轮次,最大重复动作限制局部循环。总项目还会限制工具调用数、输入输出 token、持续时间和成本。预算必须在 Runtime 计数,因为模型无法被信任自我约束。Prompt 可以告诉模型剩余预算,真正阻止调用的仍是 LoopGuard。

计数时机要明确。进入模型前检查已有 steps 是否达到上限;收到一个有效响应后 steps 加一。这样 maxSteps=2 恰好允许两次模型决策,第三次调用不会发生。工具调用可单独计数,因为 message 和 finish 虽消耗模型步骤,却没有环境副作用。

重复动作检测的语义

模型可能重复同一 message 或同一 tool_call。Lab 用 JSON.stringify(action) 作为指纹,连续相同次数超过阈值即停止。总项目对对象 key 排序,并忽略 tool_call 的 callId,使模型仅换 id 也不能绕过重复检测。只检测连续重复是保守起点;更复杂的 A-B-A-B 循环需要历史窗口或失败指纹。

重复不一定总是错误。分页读取可能连续调用同一工具但 input 不同,轮询也可能合理。因此指纹至少包含动作类型、工具名和稳定 input,阈值由任务预算决定。检测的目标不是阻止相似动作,而是阻止没有新观察却重复同一提案。

错误与恢复的第一版选择

本课工具抛错后立即返回 failed,未知工具也立即失败。这让错误边界明确。第 19、20 课会加入 Failure Taxonomy 与 Recovery Loop,区分可重试超时、测试失败、权限拒绝和取消。恢复只能建立在正确失败之上;若最小循环吞掉错误或伪造观察,后续重试只会放大问题。

模型异常同理。总项目捕获 AbortError 映射 cancelled,其他模型错误映射 failed;Trace 写入失败甚至会形成专门终态。错误分类逐步扩展,但始终返回结构化结果而非让 CLI 猜异常文字。

完成是系统结论,不是模型台词

最小循环收到 finish 就返回 completed,是为了先跑通协议;生产 Agent 必须在 finish 和 completed 之间加入验证门。模型只能表达“我认为任务已完成”,系统还要检查允许路径、工作区 Diff、测试结果、未解决错误和预算证据。若缺少证明,应把拒绝原因作为新观察回写,允许模型在剩余预算内补救,而不是直接接受摘要。

验证门还需要防止陈旧证据。模型先运行测试成功,随后又编辑文件,早先测试不能证明新状态;因此测试结果要绑定工作区版本或 Diff hash。完成时检查最近一次成功测试发生在最后一次编辑之后。类似地,git diff 审查必须针对当前工作树,不能复用上轮结果。

这说明终态不是一个布尔值,而是一组带原因的状态。completed 表示目标证据满足;failed 表示当前路径出现不可恢复错误;blocked 表示需要权限或人类输入;budget_exceeded 表示安全上限触发;cancelled 表示用户撤销。不同状态对应不同 UI、退出码和恢复动作,不能都包装成“Agent 停了”。

工具结果如何安全进入上下文

JSON.stringify(value) 是最小实现,但真实工具结果可能包含超大文件、循环对象、二进制、绝对路径或秘密。Tool Contract 应返回可序列化结构;Registry 对大小设上限;敏感字段在写 Trace 和发模型前脱敏;超长内容存入 artifact,只把摘要和引用放入上下文。否则一次读取构建产物就可能耗尽 token 或泄露凭证。

错误结果也需要进入观察,而不是只形成异常。模型若知道“文件不存在”可以改用搜索工具,知道“权限需要确认”可以进入 blocked,知道“测试失败”可以读取具体断言。最小 Lab选择工具异常立即 failed,是为了保持控制流清楚;后续恢复层会把可恢复错误结构化回写,同时保留尝试次数和失败指纹。

序列化必须稳定。对象 key 顺序、时间戳和临时路径若每轮变化,会让重复检测认为观察总是不同,也让快照报告抖动。工具结果应排序关键字段、规范化路径、去除无关时间数据。确定性 Fake 与稳定 Tool Result 一起,才能让整条 Agent 轨迹可复验。

时间预算、背压与外部进程

最大步骤限制模型轮次,却不能阻止某个工具永远挂起。Provider 和 Tool 都要接收 signal,执行器还要设置各自超时并终止子进程。LoopGuard 的 maxDuration 在每轮前检查总时长;底层超时保护单次调用。两层缺一不可:只有总时长时,正在挂起的 await 没机会回到 Guard;只有单次超时时,许多短调用仍可无限累积。

工具输出还会产生背压。子进程 stdout 若无限缓存,内存可能先耗尽;文件读取若直接返回整仓库,模型上下文会爆炸。执行层应流式收集并截断,结果注明 truncated;模型看到摘要后可请求更窄范围。Loop 不负责具体截断算法,但必须把预算信息保留给模型和 Trace。

并发工具调用会进一步改变状态机。多个调用同时写工作区可能冲突,结果回写顺序也不稳定。第 1 周坚持一次只执行一个 tool_call,得到确定因果链。未来 Subagent 或并发工具需要显式调度器、隔离工作区、结果排序和取消传播,而不是把 Promise.all 塞进当前分支。

从内存循环到可恢复运行

Lab 的 observations、steps 和重复指纹只存在内存,进程崩溃就丢失。长任务需要把状态转换追加到 JSONL 或数据库,在副作用前后保存 checkpoint,恢复时从最后已验证状态继续。第 22、23 课会实现 Task State 与 Resume。

可恢复并不等于简单重放整个循环。文件写入、命令执行和外部 API 可能已经成功,只是进程在记录结果前崩溃。恢复必须有幂等键、前置条件和副作用核查。callId、工具输入 hash 与工作区版本会帮助判断动作是否已提交。最小循环先保证每次转换清晰,才有可能在未来持久化它。

把每轮视为事件也有利于 Trace:run_started、model_completed、tool_started、tool_completed、run_finished 按 sequence 排列。观察用于模型,Trace 用于人类与评测,两者可以共享事实但用途不同。不要把整个 Trace 原样塞回模型,否则诊断元数据会挤占上下文并暴露内部信息。

代码实验

打开本课 Lab

失败实现

下面的循环执行了工具,却没有把值交回模型:

ts
while (true) {
  const action = await provider.complete({ task, observations });
  if (action.type === "finish") return completed(action.summary);
  if (action.type === "tool_call") {
    await tools[action.tool]?.execute(action.input);
  }
}

模型下一轮看到的 observations 没变,很可能再次调用同一工具。它也没有预算、取消、未知工具或错误终态。

正确实现把工具执行视为产生新观察的事务:

ts
const tool = tools[action.tool];
if (tool === undefined) {
  return { status: "failed", summary: `未知工具:${action.tool}`, steps, observations };
}
try {
  const value = await tool.execute(action.input, signal);
  observations.push(`tool:${action.tool}:${JSON.stringify(value)}`);
} catch (error) {
  return {
    status: "failed",
    summary: `工具 ${action.tool} 失败:${error instanceof Error ? error.message : String(error)}`,
    steps,
    observations,
  };
}

测试不只检查 execute 调用,还检查下一轮请求:

ts
it("writes a tool result into the next model observation and finishes", async () => {
  const requests: Array<{ observations: readonly string[] }> = [];
  const result = await runAgent(
    "读取 README",
    scripted([
      { type: "tool_call", tool: "read_file", input: { path: "README.md" } },
      { type: "finish", summary: "读取完成" },
    ], requests),
    { read_file: { execute: async () => ({ content: "hello" }) } },
    { maxSteps: 5, maxRepeatedActions: 2 },
  );
  expect(result).toMatchObject({ status: "completed", steps: 2 });
  expect(requests[1]?.observations[0])
    .toContain('tool:read_file:{"content":"hello"}');
});

关键实现讲解

observations 由 Loop 自己创建,传给 Provider 时复制为新数组,避免 Provider 修改内部历史。返回结果将它暴露为 readonly。若观察值含对象,生产实现还要控制深拷贝或序列化边界;Lab 在写入时已转成字符串。

while (true) 本身不是问题,没有守卫的无限循环才是问题。每条路径必须要么继续并产生新状态,要么返回终态。message 会追加观察,tool_call 会追加工具结果,finish 返回;预算和取消在下一轮前拦截。代码审查时可以逐分支检查是否满足这条规则。

重复指纹在步骤增加后计算,所以触发重复停止的动作也计入 steps。测试预期 maxRepeatedActions=1 时第二次同 message 返回 budget_exceeded、steps=2。明确计数语义避免报告“只执行一步”却实际调用模型两次。

取消在调用 Provider 前检查,确保已取消 signal 不产生外部调用;signal 还传给 Provider 与 Tool,让进行中的异步操作协作停止。工具返回后再次检查可避免取消发生在执行末尾却进入下一轮。取消不是异常泄漏,而是正常终态。

未知工具不能跳过或当作 message。如果模型提出系统未注册能力,继续推理会让它误以为动作发生。立即 failed 提供真实反馈;未来恢复层可以把错误回写模型并允许重选,但仍不能伪造成功。

总项目把消息历史改为标准 role,对工具调用和结果使用 callId,并通过 Tool Registry 归一成功/失败。LoopGuard 独立维护步骤、工具调用、token、时间和重复指纹。拆出 Guard 让预算规则可单测,也让 Agent Loop 聚焦状态转换。

逐分支审查循环正确性

审查 Agent Loop 时,可以为每个 continuereturn 写状态表。继续分支必须证明产生了新观察或状态,否则可能空转;返回分支必须包含可解释 status、summary 和计数;副作用分支必须有调用前守卫、结果记录和失败路径。再检查每个 await 前后是否需要取消与预算判断。

这种机械审查比阅读 Prompt 更可靠。Prompt 会变化,模型行为会变化,状态机守卫仍由代码和测试控制。可靠 Agent 的“自主”来自在有限状态内选择下一步,而不是没有边界地循环。

当每次继续和停止都能由状态与证据解释时,循环才从演示脚本变成可以维护、测试和审计的 Runtime 核心。

这也是后续所有工程化能力共同依附的主轴。

运行与验证

bash
pnpm --dir bootcamps/coding-agent/labs/05-minimal-agent-loop/starter test
pnpm --dir bootcamps/coding-agent/labs/05-minimal-agent-loop/solution test
pnpm --filter @coding-agent/agent-core test
pnpm --dir bootcamps/coding-agent/checkpoints/week-01-minimal-agent smoke

真实运行输出

Starter 一项失败、三项通过,证明练习缺口集中在观察回写。Solution 摘要:

text
✓ ../tests/lab.test.ts (4 tests)
Test Files  1 passed (1)
Tests       4 passed (4)

第 1 周累计 smoke 输出:

text
第 1 周检查点通过:最小 Agent 完成 echo 后结束
{"week":1,"ok":true,"summary":"最小 Agent 完成 echo 后结束","evidence":["echo:hello"]}

这条证据说明任务、Fake Model、Action、工具和观察已组成闭环。它没有访问公网,也没有真实模型随机性。重复运行应得到相同 summary 与 evidence。

验证时可故意删除 observations.push,第一项测试必须失败;去掉 maxSteps,预算测试可能挂起,因此测试 Provider用随机 message 保证不触发重复检测;把取消检查移到模型调用后,complete 的调用次数断言应失败。门禁能抓住这些变化,才证明停止条件真实有效。

常见失败与排查

故障案例 1

症状:read_file 成功后,模型再次请求同一路径,直到重复预算耗尽。根因:工具结果没有写入下一轮观察,或 Provider 请求使用了旧数组快照。定位:记录每轮发给 Provider 的 observations,确认工具值出现且顺序正确。修复:工具成功后先序列化并追加观察,再进入下一轮。

故障案例 2

症状:模型持续输出不同措辞的 message,Agent 永不停止且没有工具调用。根因:只检测重复动作,没有最大步骤;不同文本绕过重复指纹。定位:检查 steps 是否每次模型响应后增加、preflight 是否在调用前执行。修复:最大步骤是强制总上限,重复检测只是附加局部保护。

故障案例 3

症状:用户已取消,Provider 仍被调用一次,可能产生费用。根因:取消只传给底层,却没有在循环入口检查;部分 Provider 不会同步观察 signal。定位:用已 abort 的 controller 和 spy Provider,断言 complete 调用次数为零。修复:preflight 先检查 signal,同时继续向下传递以取消进行中操作。

另一个问题是工具异常导致整个 Promise reject,CLI 只能显示栈。应在工具边界捕获并形成 failed;真正编程错误是否继续抛出,可由更高级错误分类决定,但普通工具失败必须可观察。

课后作业

第一项作业是给 AgentRunResulttoolCalls,每次真正执行前增加;工具不存在不计入,执行失败计入。为 maxToolCalls 写边界测试,说明与 maxSteps 的区别。

第二项作业是改用结构化 observation 联合,包含 message 与 tool_result,tool_result 有 tool、callId、ok、value/error。Provider 适配器负责序列化。证明错误结果不会被当成功值。

第三项作业是设计 finish gate:只有最近一次 run_tests 成功且 Diff 在允许路径内才接受 finish,否则回写“验证不足”。写出状态机与三个拒绝用例,不需要实现完整测试工具。

知识检查:为什么工具被调用不等于闭环完成?最大步骤和重复动作分别防什么?取消检查应出现在哪些边界?未知工具为什么不能跳过?finish 为什么最终还需要系统证据?

验收 Rubric

维度通过标准常见扣分
闭环工具值进入下一轮模型观察只执行不回写
停止finish、失败、预算、重复、取消均有终态只依赖模型自停
预算调用前检查、响应后准确计步off-by-one 或无总上限
工具边界未知工具与异常形成真实失败静默跳过或伪造成功
取消调用前零副作用,signal 继续下传取消后仍调用 Provider
证据Solution 四项、agent-core 与周 smoke 通过只检查 summary

总项目增量

总项目包:@coding-agent/agent-core

总项目路径:packages/agent-core/src/agent-loop.tspackages/agent-core/src/loop-guard.ts

总项目测试:packages/agent-core/tests/agent-loop.test.ts

总项目验证命令:pnpm --filter @coding-agent/agent-core test

本课把 agent-loop 能力加入 week-01-minimal-agent。总项目使用统一 Provider、Tool Registry、角色消息、LoopGuard 和可选 Trace,仍保持同一核心序列。第一周至此得到一个能离线调用工具并结束的最小 Agent,而不是只有聊天输出的脚本。

延伸阅读

方案对比与工程取舍

直接 while 循环代码少、控制流清楚,适合核心状态有限的 Runtime;图执行框架能表达分支、持久化和人工中断,却增加抽象与序列化约束。无论使用哪种形式,都必须显式定义状态、守卫、终态和证据,图框架不会自动带来可靠性。

失败即停最容易推理,但可用性有限;自动重试提高成功率,却可能造成重复副作用和成本风暴。本课选择失败即停,后续在 Failure Taxonomy、Checkpoint 和 Recovery Loop 中基于幂等性与预算逐步开放恢复。先有正确失败,再有安全恢复。

下一课衔接

最小 Loop 目前用对象字典查工具,对工具输入、权限和结果没有统一合同。下一课将定义 Tool Contract 与 Schema,把每个工具描述为名字、说明、输入验证、权限和结构化结果。Agent Loop 已会提出和执行动作,接下来要保证所有环境能力以同一种安全语言进入 Registry。

建议复盘第 1 周完整链路:任务合同稳定目标,CLI 建立入口,Provider 隔离模型,Action Protocol 收缩输出,Agent Loop 管理时间。任何一层绕过前一层,系统都会重新依赖猜测。

从零实现 Mini Code Agent Runtime