从零手写 ReAct Agent 主循环 —— AI 代码审查助手的核心引擎

Gabrielle Lv5

为什么手写而不是用 LangChain?

在开始之前,先回答一个会被反复问到的问题:市面上有 LangChain、Vercel AI SDK、CrewAI 这么多 Agent 框架,为什么要自己手写?

我的回答是:如果你只是想把 Agent 跑起来,用框架。如果你想理解 Agent 每一行代码的执行逻辑,手写。

LangChain 封装了太多细节。用它做项目,面试时一定会被追问”你知不知道底层怎么做的?”——而框架使用者能讲清楚底层机制的概率极低。

我的代码审查 Agent 选了一条更累的路:基于 ReAct 论文(Yao et al., 2022),用 TypeScript 从零实现完整的 Thought → Action → Observation 循环。每个退出条件、每次错误重试、每条消息是怎么拼进 prompt 的——我都能对着代码讲清楚。

ReAct 是什么?

ReAct = Reasoning + Acting。核心思想很简单:让 LLM 在推理过程中交替进行”思考”和”行动”。

1
2
3
4
5
6
7
8
9
10
Thought: 我需要读取 package.json 来了解项目依赖
Action: readFile("package.json")
Observation: { "dependencies": { "react": "^19.0.0", "next": "^15.0.0" } }

Thought: 项目使用 React 19 + Next.js 15,我应该检查是否有 React 19 的废弃 API
Action: searchCode("ReactDOM.render|findDOMNode|string refs")
Observation: src/components/legacy.tsx 中发现了 findDOMNode 使用

Thought: 找到了问题。准备给出审查结论。
Final Answer: 发现一个兼容性问题和改进建议...

LLM 不只是输出文本——它可以”调用工具”获取信息,然后用工具返回的结果继续推理。这个循环一直持续到 LLM 认为任务完成,输出最终答案。

主循环实现

核心在 react-runtime.ts 中。整个 Agent 的生命周期由这个 while 循环控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// react-runtime.ts — ReAct 主循环(简化版)
export async function* runReActLoop(
messages: ChatMessage[],
registry: ToolRegistry,
modelClient: ModelClient,
options: ReActOptions = {},
): AsyncGenerator<ReActEvent> {
const {
maxSteps = 10,
maxParseFailures = 2,
contextManager,
} = options;

let step = 0;
let parseFailures = 0;

while (step < maxSteps) {
step++;

// 1. 构建 prompt:系统提示 + 工具列表 + 代码上下文 + 对话历史
const systemPrompt = buildSystemPrompt({
tools: registry.getAllDefs(),
workspace: contextManager?.getWorkspaceContext(),
});

// 2. 调用 LLM(流式或非流式,由 ModelClient 抹平差异)
yield { type: 'step', step, phase: 'thinking' };
const response = await modelClient.chat({
system: systemPrompt,
messages: [...messages],
tools: registry.getAllDefs(),
});

// 3. 解析响应:tool_use 还是 text/final?
for (const block of response.content) {
if (block.type === 'tool_use') {
yield { type: 'tool_call', step, tool: block.name, input: block.input };

// 4. 执行工具 → 追加 observation
const result = await registry.execute(block.name, block.input);
yield { type: 'observation', step, result };

messages.push({
role: 'assistant',
content: [{ type: 'tool_use', ...block }],
});
messages.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: block.id, content: result.output }],
});
} else if (block.type === 'text') {
// 5. 最终答案 → 退出循环
yield { type: 'final', step, content: block.text };
return;
}
}

// 6. 上下文压缩(如果 token 用量逼近上限)
if (contextManager) {
await contextManager.maybeCompress(messages);
}
}

// 7. Final Poke:达到 maxSteps 但 LLM 还在犹豫?强制要求输出结论
yield { type: 'final_poke', step };
const lastResponse = await modelClient.chat({
system: 'You have reached the maximum number of steps. Please provide your final answer now.',
messages,
});
yield { type: 'final', step, content: lastResponse.text };
}

几个关键设计决策:

为什么用 AsyncGenerator 而不是返回 Promise? Agent 的回答可能长达几十秒。AsyncGenerator 让前端能逐条 SSE 推送 step / tool_call / observation / final 事件——用户能看到 Agent 正在”读文件”、”搜索代码”,而不是对着一个 loading 动画干等。

maxSteps = 10 是什么意思? 防止 Agent 陷入无限循环。如果 10 步内 LLM 还没给出最终答案,触发 Final Poke——强制要求 LLM 停止工具调用,立刻输出结论。

parseFailures > 2 退出? LLM 的输出不是百分百可靠的。如果连续两次解析失败(比如 tool_use 的 JSON 格式错误),直接退出比继续死循环更安全。

多架构切换:不只有一个 ReAct

除了 ReAct,我还实现了另外两种 Agent 架构,通过统一的 orchestrate.ts 入口调度:

1
2
3
4
5
6
7
8
9
10
11
12
            ┌──────────────────┐
│ orchestrate.ts │
│ 统一调度入口 │
└──────┬───────────┘
┌─────────────┼──────────────┐
│ │ │
┌────┴────┐ ┌────┴─────┐ ┌─────┴──────┐
│ ReAct │ │Plan&Exec │ │ Reflection │
│ 逐步推理 │ │规划→执行 │ │执行→评估→反思│
│ maxSteps │ │用户确认 │ │ maxRounds 3│
│ = 10 │ │再执行 │ │ │
└─────────┘ └─────────┘ └────────────┘
  • ReAct:适合”直接审查一段代码”——Agent 读文件、搜索、分析,中间不需要人类干预。
  • Plan & Execute:适合复杂任务——Agent 先生成审查计划,用户确认后再执行。减少跑偏风险。
  • Reflection:Actor 先输出审查结果 → Evaluator 评估质量 → Reflector 改进。多一轮自我校验,适合需要高质量输出的场景。

面试被问”三者怎么选”时的回答:ReAct 是默认策略,Plan&Execute 适合复杂多文件审查,Reflection 适合对质量要求极高的场景。三种架构共享同一套工具和模型客户端,切换只需改一个参数。

ModelClient:抹平多模型差异

DeepSeek 和 Claude 的 API 不完全一样。Claude 支持 client.messages.stream() 原生流式,DeepSeek 通过 Anthropic 兼容协议接入但流式行为不稳定(带 tools 参数时超时)。

我用一个 ModelClient 接口 + 工厂模式抹平了差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// model-client.ts — 接口定义
export interface ModelClient {
chat(request: ChatRequest): Promise<ChatResponse>;
chatStream?(request: ChatRequest): AsyncGenerator<ChatResponse>;
}

// 工厂函数:根据环境变量选择后端
export function createModelClient(): ModelClient {
const provider = process.env.LLM_PROVIDER || 'deepseek';
switch (provider) {
case 'claude':
return new ClaudeModelClient();
case 'deepseek':
default:
return new DeepSeekModelClient();
}
}

对外暴露的是统一接口,内部各自实现细节。面试被问”怎么支持多模型”时,一句话就能讲清。

小结

手写 ReAct 循环不是炫技。它让我理解了 Agent 的每一个边界条件:

  1. LLM 的输出不可靠——需要 parse 容错
  2. Agent 可能陷入死循环——需要 maxSteps + Final Poke
  3. 长对话会撑爆 context window——需要渐进压缩(下一篇讲)
  4. 工具调用可能被滥用——需要安全护栏(第三篇讲)

下一篇:三层上下文压缩:82% token 削减背后的设计

  • Title: 从零手写 ReAct Agent 主循环 —— AI 代码审查助手的核心引擎
  • Author: Gabrielle
  • Created at : 2026-08-11 10:00:00
  • Updated at : 2026-08-10 23:09:23
  • Link: https://zoella-w.github.io/2026/08/11/100-agent-react-architecture/
  • License: This work is licensed under CC BY-NC-SA 4.0.