第 2 章 · Agent Loop 与任务规划

04 · 退出条件与失败恢复

Agent Loop 不能无限跑下去。这一节实现三种退出条件、失败后的自动重试策略,以及结构化的执行统计。

循环不能永远跑

第 1 章用了一个硬编码的 MAX_TOOL_ROUNDS = 5 来防止无限循环。问题很明显:5 次对简单任务可能太多,对复杂任务又不够。

这一节做三件事:

  1. 定义更合理的退出条件
  2. 让循环知道"什么时候该停"
  3. 把执行过程的信息记录下来

三种退出条件

Agent Loop 在以下三种情况下停止:

1. 模型给出最终回答

这是最正常的退出方式。模型经过若干轮 Reason-Act-Observe 后,认为已经可以回答用户的问题了,于是不再请求工具调用,直接给出文本回复。

if (!response.toolCalls || response.toolCalls.length === 0) {
  return { answer: response.content, todos: [...todoManager.getAll()], stats };
}

模型的 toolCalls 为空 = "我不想再调用工具了" = "我准备好回答了"。

2. 所有计划步骤完成

如果模型创建了计划,而且每个步骤都标记为 completed,说明任务已经做完。这时如果模型没有给出最终回答(可能还想继续调工具),可以提前退出,避免多余调用。

if (todoManager.allCompleted()) {
  if (!response.toolCalls || response.toolCalls.length === 0) {
    return { answer: response.content, todos: [...todoManager.getAll()], stats };
  }
}

注意这里的逻辑:即使所有步骤都完成了,也不强制退出。如果模型还想调工具(比如想再确认一下某个细节),让它继续。只有在模型同时给出文本回答时才退出。这避免了过早截断模型的自我验证行为。

3. 达到迭代上限

不管任务多复杂,都不能无限制地跑下去。每多一次迭代就多一次模型调用,意味着更多的 token 消耗和等待时间。

const DEFAULT_MAX_ITERATIONS = 15;

for (let i = 0; i < maxIterations; i++) {
  // ...
}

stats.hitLimit = true;
return {
  answer: response.content || "未能完成任务:达到最大迭代次数。",
  todos: [...todoManager.getAll()],
  stats,
};

默认上限设为 15。这个值的取舍:

  • 太小(如 5):复杂任务做不完
  • 太大(如 50):失败的任务浪费大量资源
  • 15 是一个平衡点,足够大多数多步骤任务使用

同时通过 options.maxIterations 参数允许覆盖,测试时可以用很小的值验证边界行为。

执行统计:让过程可观测

退出条件决定了"什么时候停",但用户还需要知道"停的时候发生了什么"。这就是 AgentStats 的作用:

interface AgentStats {
  modelCalls: number;     // 调了几次模型
  toolCallCount: number;  // 执行了几次工具
  hitLimit: boolean;      // 是不是因为到上限停的
}

在循环中跟踪这些数据:

const stats = { modelCalls: 0, toolCallCount: 0, hitLimit: false };

// 每次调用模型后
stats.modelCalls++;

// 每次执行工具后
stats.toolCallCount++;

最终通过 AgentResult 返回给调用方:

interface AgentResult {
  answer: string;    // 最终回复
  todos: TodoItem[]; // 执行计划
  stats: AgentStats; // 执行统计
}

main.ts 中展示给用户:

[模型调用: 4次 | 工具调用: 3次]

如果 stats.hitLimit 为 true,额外提示:

[模型调用: 16次 | 工具调用: 15次 | ⚠ 达到迭代上限]

失败恢复:Repair Loop

退出条件解决的是"正常完成"和"超时停止"的情况。还有一种情况需要处理:工具执行失败

比如模型搜索了一个不存在的文件名,search 工具返回"未找到"。或者读文件时路径拼错了,read_file 工具返回"文件不存在"。

在代码里,工具失败不需要特殊处理——错误信息作为工具结果原样返回给模型:

const result = await tool.execute(toolCall.arguments, state);
stats.toolCallCount++;

/** 不管成功还是失败,都把结果追加到对话历史 */
allMessages.push({
  role: "tool_result",
  toolCallId: toolCall.id,
  result,
});

模型在下一轮 Reason 时看到这个错误信息,可以自行决定下一步。它会怎么选?取决于具体的失败原因:

失败原因模型可能的反应
搜索无结果换一个关键词再搜
文件不存在换一个可能的路径再试
搜索结果太多缩小搜索范围
工具调用参数格式错误修正参数后重新调用

随着后续章节加入更多工具(第三章的 glob、第四章的权限系统),模型能选择的修复策略也会更丰富。当前这一课的重点是理解 Repair Loop 的机制本身。

这就是 Repair Loop 的核心:不做预设的错误处理逻辑,而是把错误信息交给模型,让模型自己判断如何修复。

这种设计的优势是灵活性——你不需要提前枚举所有可能的失败原因和对应的修复策略。模型作为一个通用推理引擎,能处理预料之外的情况。

升级后的完整循环

把所有内容组合起来,完整的 agent 循环:

export async function runAgent(
  state: AgentState,
  model: Model,
  tools: Tool[],
  options?: { maxIterations?: number },
): Promise<AgentResult> {
  const maxIterations = options?.maxIterations ?? 15;
  const todoManager = new TodoManager();
  const allTools = [
    ...tools,
    createTodosTool(todoManager),
    updateTodoTool(todoManager),
  ];
  const stats = { modelCalls: 0, toolCallCount: 0, hitLimit: false };

  const systemMessage = { /* 升级后的系统提示词 */ };
  const allMessages = [systemMessage, ...state.messages];

  let response = await model.chat(allMessages, allTools);
  stats.modelCalls++;

  for (let i = 0; i < maxIterations; i++) {
    // 退出条件 1:模型给出最终回答
    if (!response.toolCalls || response.toolCalls.length === 0) {
      return { answer: response.content, todos: [...todoManager.getAll()], stats };
    }

    // 执行工具,收集结果(失败也是结果)
    for (const toolCall of response.toolCalls) {
      const tool = allTools.find((t) => t.name === toolCall.name);
      const result = tool
        ? await tool.execute(toolCall.arguments, state)
        : `错误:未知工具 ${toolCall.name}`;
      stats.toolCallCount++;
      allMessages.push({ role: "tool_result", toolCallId: toolCall.id, result });
    }

    // 再调用模型
    response = await model.chat(allMessages, allTools);
    stats.modelCalls++;

    // 退出条件 2:所有计划步骤完成
    if (todoManager.allCompleted() && !response.toolCalls?.length) {
      return { answer: response.content, todos: [...todoManager.getAll()], stats };
    }
  }

  // 退出条件 3:达到迭代上限
  stats.hitLimit = true;
  return {
    answer: response.content || "未能完成任务:达到最大迭代次数。",
    todos: [...todoManager.getAll()],
    stats,
  };
}

这一节做了什么

  • 定义了三种退出条件:正常完成、计划完成、达到上限
  • 实现了 AgentStatsAgentResult 来跟踪执行过程
  • 说明了 Repair Loop 的原理:错误信息交给模型,让模型自行调整策略
  • 组合出了完整的升级版 agent 循环

下一节用一个完整的多步骤任务实战,看看升级后的 agent 表现如何。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page