从零构建 Coding Agent
English

02. 最小 Agent Loop

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

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

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

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

  • 模型响应是一个 turn。
  • 一个 turn 可以包含零个或多个 tool call。
  • tool call 执行完必须形成 tool result 消息。
  • tool result 进入下一轮上下文。
  • 只有 stop reason 表示完成时,任务才结束。

最小结构

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

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

    if (assistant.stopReason !== "toolUse") {
      return messages;
    }

    for (const block of assistant.content) {
      if (block.type !== "toolCall") {
        continue;
      }
      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 事件、恢复和调试都会失去统一依据。

工具错误要回喂模型

工具失败不等于任务失败。假设模型调用 read_file 时写错路径,运行时应该返回:

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

模型下一轮可能会改用正确路径。这个闭环是 Agent 和普通脚本的差异之一。脚本遇错通常退出;Agent 遇错应该把可修正信息放回上下文,让模型继续尝试。

当然,不是所有错误都应该回喂后继续。权限拒绝、用户取消、危险命令被拦截,通常需要把错误写入日志并结束或等待用户确认。判断标准是:模型是否能靠更多推理修复这个错误。如果不能,就不要让它无限重试。

步数上限与终止

最小 loop 必须有 maxTurns。没有上限的 Agent 可能因为工具失败、模型重复、压缩缺失或 prompt 误导而无限运行。上限不是用户体验的最终方案,但它是运行时安全阀。产品层可以把“超过上限”渲染成用户可理解的状态:当前任务没有收敛,最后一次操作是什么,建议用户如何继续。

中止也不能简单地让异常飞出所有层。更好的做法是把 abort 变成一条带 stopReason: "aborted" 的 assistant 消息,写入日志,并让 UI 知道这次 turn 是被用户中断,而不是模型失败。

检查点

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

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

验收标准:

  • assistant 的 tool call 会被执行。
  • tool result 会进入下一次模型请求。
  • 未知工具不会让进程崩溃,而是形成错误 tool result。
  • 超过 turn 上限时有明确错误。
  • 所有消息按发生顺序保留。