从零手写 ReAct Agent 主循环 —— AI 代码审查助手的核心引擎
为什么手写而不是用 LangChain? 在开始之前,先回答一个会被反复问到的问题:市面上有 LangChain、Vercel AI SDK、CrewAI 这么多 Agent 框架,为什么要自己手写?
我的回答是:如果你只是想把 Agent 跑起来,用框架。如果你想理解 Agent 每一行代码的执行逻辑,手写。
LangChain 封装了太多细节。用它做项目,面试时一定会被追问”你知不知道底层怎么做的?”——而框架使用者能讲清楚底层机制的概率极低。
我的代码审查 Agent 选了一条更累的路:基于 ReAct 论文(Yao et al., 2022),用 TypeScript 从零实现完整的 Thought → Action → Observation 循环。每个退出条件、每次错误重试、每条消息是怎么拼进 prompt 的——我都能对着代码讲清楚。
ReAct 是什么? ReAct = Re asoning + A cting。核心思想很简单:让 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 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++; const systemPrompt = buildSystemPrompt ({ tools : registry.getAllDefs (), workspace : contextManager?.getWorkspaceContext (), }); yield { type : 'step' , step, phase : 'thinking' }; const response = await modelClient.chat ({ system : systemPrompt, messages : [...messages], tools : registry.getAllDefs (), }); for (const block of response.content ) { if (block.type === 'tool_use' ) { yield { type : 'tool_call' , step, tool : block.name , input : block.input }; 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' ) { yield { type : 'final' , step, content : block.text }; return ; } } if (contextManager) { await contextManager.maybeCompress (messages); } } 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 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 的每一个边界条件:
LLM 的输出不可靠——需要 parse 容错
Agent 可能陷入死循环——需要 maxSteps + Final Poke
长对话会撑爆 context window——需要渐进压缩(下一篇讲)
工具调用可能被滥用——需要安全护栏(第三篇讲)
下一篇:三层上下文压缩:82% token 削减背后的设计 。