Day 3: 核心引擎 —— Agent 状态机

6 阅读1分钟

导读

Day 2 的工具调用有一个致命缺陷:只能调用一次。如果 LLM 想"先读文件→分析→再写文件",它做不到。

今天,我们构建 Agent 的核心引擎——ReAct 循环。这不是一个简单的 while 循环,而是一个状态机

这就是 ReAct (Reasoning + Acting)——LLM 自主决定"想→做→看→再想"的循环,直到它认为任务完成。

学完这一天,你会理解:

  • 为什么 Claude Code 的 queryLoopwhile(true) + AsyncGenerator
  • 读操作并发 vs 写操作串行的调度策略
  • MAX_ITERATIONS 安全阀防止死循环
  • 结构化退出理由(不是 break,而是 { reason } 对象)

3.1 核心概念

从 Function Calling 到 ReAct

对比

Day 2: 单次调用

Day 3: ReAct 循环

工具调用次数

1 次

不限(LLM 自主决定)

退出条件

调完就结束

LLM 返回 end_turn 时退出

能力

"帮我读这个文件"

"帮我分析这个项目的代码质量"

对应产品

ChatGPT 的 Actions

Claude Code / Cursor

Claude Code 的 queryLoop 是什么?

打开 claude-code-source/src/query.ts(1730 行),核心就是这个结构:

// Claude Code 简化版
async function* queryLoop(params): AsyncGenerator<StreamEvent, Terminal> {
  while (true) {
    const response = await callAPI(messages);

    if (response.stopReason === "end_turn") {
      return { reason: "end_turn" }; // 结构化退出
    }

    if (response.stopReason === "tool_use") {
      yield* runTools(toolUseBlocks); // 执行工具
      continue; // 继续循环
    }

    if (turnCount > MAX_TURNS) {
      return { reason: "max_turns" }; // 安全阀
    }
  }
}

为什么用 AsyncGeneratorfunction*)? 因为调用方需要实时观察中间状态:

// 调用方可以实时处理每个事件
for await (const event of queryLoop(params)) {
  if (event.type === "tool_start") showSpinner(event.toolName);
  if (event.type === "tool_result") hideSpinner();
  if (event.type === "text_delta") appendToUI(event.text);
}

普通函数只能在全部执行完后返回——中间过程对调用方是黑箱。Generator 让每一步都"可观测"。

3.2 实现 Agent Loop

核心循环

const MAX_ITERATIONS = 20; // 安全阀,防止 LLM 陷入死循环

type LoopResult = {
  reason: "end_turn" | "max_iterations" | "error";
  content?: string;
};

async function agentLoop(
  messages: OpenAI.Chat.ChatCompletionMessageParam[],
): Promise<LoopResult> {
  let iterations = 0;

  while (true) {
    iterations++;

    // 安全阀
    if (iterations > MAX_ITERATIONS) {
      console.log(`⚠️ 达到最大迭代次数 (${MAX_ITERATIONS}),强制退出`);
      return { reason: "max_iterations" };
    }

    const response = await client.chat.completions.create({
      model: MODEL,
      messages,
      tools: toolDefinitions,
    });

    const choice = response.choices[0];
    if (!choice) return { reason: "error" };

    const message = choice.message;

    // 情况 1:LLM 直接回复(任务完成)
    if (choice.finish_reason === "stop" || !message.tool_calls?.length) {
      return { reason: "end_turn", content: message.content || "" };
    }

    // 情况 2:LLM 要调用工具 → 执行 → 继续循环
    messages.push({
      role: "assistant",
      content: message.content,
      tool_calls: message.tool_calls,
    });

    // 工具调度(读写分离)
    await executeToolCalls(message.tool_calls, messages);

    // continue → 回到循环顶部,让 LLM 看到工具结果后继续思考
  }
}

3.3 读写分离调度

Claude Code 的 toolOrchestration.ts 有一个精妙设计:读操作可以并发,写操作必须串行

/** 工具是否只读 */
function isReadOnlyTool(name: string): boolean {
  const readOnlyTools = new Set(["readFile", "listFiles", "searchCode"]);
  return readOnlyTools.has(name);
}

/** 分区:连续读操作并发,写操作单独执行 */
async function executeToolCalls(
  toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[],
  messages: OpenAI.Chat.ChatCompletionMessageParam[],
) {
  // Step 1: 分区
  const batches: { concurrent: boolean; calls: typeof toolCalls }[] = [];
  for (const call of toolCalls) {
    const extracted = extractToolCall(call);
    if (!extracted) continue;
    const isReadOnly = isReadOnlyTool(extracted.name);

    const lastBatch = batches[batches.length - 1];
    if (lastBatch?.concurrent && isReadOnly) {
      lastBatch.calls.push(call);
    } else {
      batches.push({ concurrent: isReadOnly, calls: [call] });
    }
  }

  // Step 2: 按分区执行
  for (const batch of batches) {
    if (batch.concurrent) {
      // 读操作:并发执行 🚀
      console.log(`⚡ 并发执行 ${batch.calls.length} 个读操作`);
      const results = await Promise.all(
        batch.calls.map((call) => executeSingleTool(call)),
      );
      results.forEach(({ callId, result }) => {
        messages.push({ role: "tool", tool_call_id: callId, content: result });
      });
    } else {
      // 写操作:串行执行 🔒
      for (const call of batch.calls) {
        const { callId, result } = await executeSingleTool(call);
        messages.push({ role: "tool", tool_call_id: callId, content: result });
      }
    }
  }
}

为什么这样设计?

场景:LLM 返回 [readFile(A), readFile(B), writeFile(C)]

不分区(全串行):       读A(200ms) → 读B(200ms) → 写C(100ms) = 500ms
读写分区(读并发):     [读A + 读B](200ms) → 写C(100ms)      = 300ms  🚀
全并发(危险!):       三个都同时跑?→ 读到的可能是写之前的内容 ❌

3.4 结构化退出理由

Day 2 的循环用 break 退出——你不知道为什么退了。Claude Code 用结构化返回类型

type Terminal = {
  reason: "end_turn" | "max_turns" | "budget_exceeded" | "abort";
};

这让调用方可以做精确的后处理:

const result = await agentLoop(messages);

switch (result.reason) {
  case "end_turn":
    console.log("✅ 任务完成");
    break;
  case "max_iterations":
    console.log("⚠️ 迭代过多,可能需要拆分任务");
    break;
  case "error":
    console.log("❌ 执行出错");
    break;
}

3.5 完整运行示例

You: 帮我分析当前目录下的 package.json,然后创建一个 summary.md 概述这个项目

🔧 [迭代 1] 调用工具: readFile({"filePath":"package.json"})
📄 读取成功 (245 字符)

🔧 [迭代 2] 调用工具: writeFile({"filePath":"summary.md","content":"# 项目概述\n..."})
✅ 写入成功

AI: 我已经分析了 package.json 并创建了 summary.md。该项目使用 Bun 运行时...

注意 LLM 自主决定了"先读→再写"的两步策略,无需人工干预。

3.6 常见问题

Q: ReAct 和 Plan-Execute 有什么区别?

参考回答

ReAct:     想→做→看→想→做→看→...(每步都让 LLM 重新思考)
Plan-Execute:先让 LLM 做全局计划 → 然后按计划逐步执行

优劣:
  ReAct 更灵活(能根据中间结果调整),但 LLM 调用次数更多
  Plan-Execute 更高效,但计划可能过期(中间状态变了计划就不准了)
  Claude Code 实际上两者结合——有 Plan Mode + ReAct 执行

Q: 怎么防止 Agent 死循环?

参考回答

三层防御:

  1. MAX_ITERATIONS:硬性迭代上限(我们的做法)
  2. Token 预算:累计消耗超限就停(Claude Code 的 budget_exceeded
  3. 重复检测:连续 N 次调用相同工具相同参数 → 可能是死循环

Q: 为什么用 AsyncGenerator 而不是回调函数?

参考回答

回调地狱 vs 拉模型。Generator 让消费方用 for await 按需拉取事件,而不是被动接收。它天然支持背压(consumer 来不及处理就不 yield)、可取消(.return())、可组合(yield* 委托)。Claude Code 的 queryLoop 返回 AsyncGenerator<StreamEvent, Terminal>——中间事件是 StreamEvent,最终值是 Terminal。

3.7 本日回顾

✅ 今天是整个课程的核心:
  - Agent Loop = while(true) + LLM 自主判断退出
  - 读写分离调度 = 读并发 + 写串行
  - 结构化退出 = { reason } 而不是裸 break
  - 安全阀 = MAX_ITERATIONS 防死循环

🏗️ Day 3 的代码就是 Day 4-7 的地基。
  后续所有能力(规划、记忆、多代理)都是在这个循环上叠加的。

🔗 Day 4 预告:
  给 Agent 加上"先想后做"的规划能力——自动生成任务清单

上一篇:← Day 2: 工具工程化

下一篇:Day 4: 给 Agent 一个大脑 —— 结构化规划 (ing...)→