导读
Day 2 的工具调用有一个致命缺陷:只能调用一次。如果 LLM 想"先读文件→分析→再写文件",它做不到。
今天,我们构建 Agent 的核心引擎——ReAct 循环。这不是一个简单的 while 循环,而是一个状态机:
这就是 ReAct (Reasoning + Acting)——LLM 自主决定"想→做→看→再想"的循环,直到它认为任务完成。
学完这一天,你会理解:
- 为什么 Claude Code 的
queryLoop用while(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" }; // 安全阀
}
}
}
为什么用 AsyncGenerator(function*)? 因为调用方需要实时观察中间状态:
// 调用方可以实时处理每个事件
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 死循环?
参考回答:
三层防御:
MAX_ITERATIONS:硬性迭代上限(我们的做法)- Token 预算:累计消耗超限就停(Claude Code 的
budget_exceeded) - 重复检测:连续 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...)→