从零构建 Coding Agent
English

02. 最小 Agent Loop

理解了工具协议之后,Agent 的核心就是 loop。它不是 while true 地让模型说话,而是由模型返回的信号驱动状态转移。模型说需要工具,运行时执行工具;模型说完成,运行时结束;模型超长或被中止,运行时把状态记录下来,让上层决定怎么处理。

失败模式:把工具调用当成普通文本

最常见的错误是只处理 assistant 文本,忽略 tool call。这样模型会认真地说“我需要读取文件”,但系统什么也不做。第二种错误是执行一次工具就结束,忘了把结果发回模型。此时模型永远没有机会基于观察继续推理。第三种错误是工具失败时抛异常退出,导致模型无法自我修正。

Agent loop 的责任是把这些边界固定下来:

  • 模型响应是一个 turn。
  • 一个 turn 可以包含零个或多个 tool call。
  • tool call 执行完必须形成 tool result 消息。
  • tool result 进入下一轮上下文。
  • 没有更多 tool call、也没有排队输入时,任务才结束。

注意最后一条的措辞。教学实现常写成“stopReason 不是 toolUse 就结束”,但成熟系统的判断依据是“这条 assistant 消息里有没有 tool call”。两者大多数时候等价,但模型偶尔会返回 stopReason: "stop" 却仍带着 tool call。以内容为准的 loop 会照常执行这些工具;以 stop reason 为准的 loop 会把它们悄悄丢掉,并且留下一条“有 tool call 却没有 tool result”的非法上下文,下一次请求直接被 provider 拒绝。防御性原则:stop reason 用于解释状态,tool call 的存在与否决定控制流。

最小结构

先定义模型边界和工具边界:

type Message = UserMessage | AssistantMessage | ToolResultMessage;

type ModelClient = {
  complete(input: { messages: Message[]; tools: ToolDefinition[] }): Promise<AssistantMessage>;
};

type ToolDefinition = {
  name: string;
  description: string;
  parameters: unknown;
};

type ToolRuntime = {
  definition: ToolDefinition;
  execute(input: unknown, signal: AbortSignal): Promise<ToolResultMessage>;
};

然后实现 loop。教学版可以先串行执行工具,后面再讲并行和文件写队列:

async function runAgent(input: {
  model: ModelClient;
  tools: ToolRuntime[];
  messages: Message[];
  signal: AbortSignal;
  maxTurns: number;
}): Promise<Message[]> {
  const messages = [...input.messages];
  const toolsByName = new Map(input.tools.map((tool) => [tool.definition.name, tool]));

  for (let turn = 0; turn < input.maxTurns; turn += 1) {
    const assistant = await input.model.complete({
      messages,
      tools: input.tools.map((tool) => tool.definition),
    });
    messages.push(assistant);

    const toolCalls = assistant.content.filter(
      (block): block is ToolCallBlock => block.type === "toolCall",
    );
    if (toolCalls.length === 0) {
      return messages;
    }

    for (const block of toolCalls) {
      const tool = toolsByName.get(block.name);
      if (!tool) {
        messages.push({
          role: "toolResult",
          toolCallId: block.id,
          toolName: block.name,
          isError: true,
          content: [{ type: "text", text: `Unknown tool: ${block.name}` }],
        });
        continue;
      }
      const result = await tool.execute(block.input, input.signal);
      messages.push({ ...result, toolCallId: block.id, toolName: block.name });
    }
  }

  throw new Error(`Agent exceeded ${input.maxTurns} turns`);
}

这段代码的重点不是完整性,而是控制流:assistant 消息先入历史,再执行工具,再把 tool result 入历史,再回到模型。这个顺序不能随意调换。否则会话日志、UI 事件、恢复和调试都会失去统一依据。

所有失败都汇入同一条出口

工具执行有五种典型的失败路径:工具名不存在、参数校验失败、执行中抛异常、被权限钩子阻止、执行中收到 abort。成熟系统会让这五条路径汇入同一个出口——构造一条 isError: true 的 tool result。这不是代码洁癖,而是协议要求:上一章说过,每个 tool call 必须有对应结果消息。任何一条失败路径“逃逸”成未捕获异常,都会留下一个没有结果的 tool call,污染整个会话。

判断错误应该回喂还是终止,标准是:模型是否能靠更多推理修复这个错误。路径写错、参数缺字段、oldText 不匹配,回喂后模型通常能修正;权限拒绝、用户取消、provider 认证失败,回喂只会让模型无限重试。前者写进 tool result,后者写进日志并停止或等待用户。

工具失败的反馈要可行动。假设模型调用 read_file 时写错路径,运行时应该返回:

toolResult(read_file, isError=true):
  File not found: src/maths.ts. Did you mean src/math.ts?

模型下一轮多半会改用正确路径。这个闭环是 Agent 和普通脚本的差异之一:脚本遇错退出,Agent 遇错把可修正信息放回上下文。

中止的正确姿势

用户按下停止时,AbortSignal 会传进 provider 请求和正在执行的工具。这里有一个容易做错的细节:中止不等于立刻返回。已经开始执行的工具需要走完清理逻辑(杀掉子进程、关闭文件句柄),并且各自形成一条“Operation aborted”的错误 tool result;尚未开始的 tool call 可以不执行,但如果这条 assistant 消息要留在会话里继续使用,投影上下文时就必须为它们补上占位结果。

更好的做法是把 abort 变成一条带 stopReason: "aborted" 的 assistant 消息(或在现有部分消息上标记),写入日志,并让 UI 知道这次 turn 是被用户中断,而不是模型失败。异常一路裸奔到顶层的 loop,恢复会话时没有任何办法解释“上次停在哪里”。

Turn 与 run 的分界

教学实现里 loop 就是全部,但值得提前建立一对概念:一个 turn 是“一次模型响应加上它触发的一批工具执行”;一个 run 是“从用户输入开始,到没有更多工具调用、也没有排队输入为止的若干个 turn”。这个分界后面会变得重要——UI 在每个 turn 结束时刷新,插话在 turn 边界注入,压缩在 turn 边界切分,统计按 run 聚合。现在先在代码里保留清晰的 turn 边界,第六、七章会用到它。

步数上限与终止

最小 loop 必须有 maxTurns。没有上限的 Agent 可能因为工具失败、模型重复、压缩缺失或 prompt 误导而无限运行。值得说明的是,成熟系统往往不用硬编码上限,而是用可插拔的“是否继续”钩子(比如每个 turn 后询问上层要不要停),加上完善的可观测性让用户随时看到 Agent 在做什么、随时可以中止。但那是在事件流、日志和 UI 都齐备之后的选择。在那之前,maxTurns 是最便宜的安全阀,产品层可以把“超过上限”渲染成用户可理解的状态:当前任务没有收敛,最后一次操作是什么,建议用户如何继续。

检查点

完成本章后,你应该有一个可以跑通这段脚本的 Agent:

user: 列出工作区文件
assistant: toolCall list_files {}
toolResult: README.md, src/index.ts
assistant: 工作区包含 README.md 和 src/index.ts。

验收标准:

  • assistant 的 tool call 会被执行,即使 stopReason 不是 toolUse
  • tool result 会进入下一次模型请求。
  • 未知工具、参数校验失败和工具异常都形成错误 tool result,进程不崩溃。
  • abort 后,每个已开始的 tool call 都有结果消息,会话状态可解释。
  • 超过 turn 上限时有明确错误。
  • 所有消息按发生顺序保留。