手写一个 ReAct,彻底搞懂 Agent 是怎么“思考”的
一、Agent 本质1.1一个对比直接看出区别讲 Agent 最好的方式不是上来就给定义而是用一个真实场景做对比。场景用户说“帮我分析一下上个月的销售数据找出问题给出建议”1.1.1 普通 ChatClient Function Calling用户 → 模型收到问题 → 模型判断要调用 getSalesData 工具 → 拿到数据 → 模型生成一段分析文字 → 返回整个过程一轮完成工具调用是固定的模型是被动响应的。说白了工具调用在这里就是个“取数据的手”拿到数据之后出结果整个流程是写死的。1.1.2. Agent用户 → Agent 收到任务 → 思考要完成这个任务我需要做哪些步骤 Step 1查上月销售数据 Step 2对比历史数据找异常 Step 3分析异常原因可能需要再查 Step 4生成报告 → 执行 Step 1调用 getSalesData(上月) → 观察结果发现华东区下滑 30% → 思考需要深入分析华东区调用 getRegionDetail(华东) → 观察结果发现某品类库存积压 → 思考还要看一下竞品价格是不是同期变了调用 getCompetitorPrice → 观察结果竞品同期降价 15% → 思考足够了开始生成报告 → 生成完整分析报告 → 任务完成注意到了吗这里的调用顺序不是人写死的——是模型自己根据每一步的观察结果动态决定的。发现华东区下滑之后它“想到”要查原因查了原因之后它“想到”要对比竞品。整个链条是 AI 自己推演出来的。这就是Agent和“Function Calling 套壳”的本质区别Agent 自己决定下一步做什么执行多少轮什么时候停止。1.2 Agent 三要素要让一个 AI 系统能够“自主完成任务”至少需要三件东西。1.2.1工具ToolsAgent能做的事查数据库、发邮件、调 API、操作文件……没有工具Agent 只会说话不会做事。工具的description质量直接决定 Agent 的能力上限。工具设计得烂模型再聪明也干不了啥。这个坑后面会专门讲。1.2.2记忆MemoryAgent 需要记住上下文短期记忆当前任务执行过程中每步的结果Step 1 查到了什么Step 2 发现了什么长期记忆跨任务的信息这个用户是 VIP、上次的偏好设置没有记忆Agent 每步执行完就忘了下一步完全不知道前面发生了什么没法连贯推理。1.2.3规划Planning面对复杂任务Agent 能把大任务拆成小步骤并决定每步该做什么。规划能力来自大模型本身的推理能力 合适的系统提示设计。不同模型的规划能力差异很大——qwen-max 在中文任务规划上表现不错但换成一些小模型任务稍微复杂一点就开始乱绕。所以 Agent 项目选模型别只看价格。1.3Agent Loop —— Agent 的运行模式所有 Agent 系统的核心都是一个循环。不管框架封装得多复杂底层都是这个东西while (任务未完成) { Think(当前状态 历史记忆) - 决定下一步 Action if (需要工具) { Act(调用工具) - 获得 Observation } else { Answer(给出最终答案) - break } }每次循环Agent 决定继续执行调用另一个工具进入下一轮任务完成给出最终答案退出循环无法完成遇到错误或信息不足退出并告知用户退出条件非常重要。模型有时候会“停不下来”——明明已经有足够信息了还要再查一轮。这不是模型笨是 System Prompt 没设计好没有告诉它什么时候该停。这个坑我们后面会详细讲。二、手写一个 ReAct彻底搞懂 Agent 是怎么“思考”的ReAct是目前最主流的 Agent 推理框架。几乎所有主流 Agent 框架——LangChain、Spring AI、LangChain4j——底层都是这套思路。你以后调用的 Spring AI Agent API底层跑的就是这个东西只不过框架帮你封装好了不用自己写循环。2.1 ReAct 是什么ReAct Reasoning Acting2022 年 Google 的一篇论文名字起得很直白——推理 行动。核心思路只有一句话让模型在行动之前先“说出”它的推理过程。光看定义可能感觉没什么特别。来对比一下传统 Function Calling用户问题 → 模型直接输出工具调用ReAct用户问题 → Thought我需要先查一下当前价格 → Action调用 getPrice 工具 → Observation苹果手机当前价格 5999 元 → Thought价格拿到了还需要查库存 → Action调用 getStock 工具 → Observation库存 23 台 → Thought信息齐了可以回答用户了 → Final Answer当前苹果手机售价 5999 元库存充足23 台为什么要先“思考”再行动准确率更高。就像人解题要打草稿一样强迫自己想清楚了再动笔出错的概率会降低。模型也一样让它先把推理写出来它会更认真地“想”。方便调试。你能看到模型为什么做这个决定。出了问题不用瞎猜直接看Thought就知道模型的逻辑是否正确从哪一步开始跑偏的。支持中途自我修正。Thought过程会让模型在执行中途“重新评估”——比如查完第一个数据发现情况不对模型可以在Thought里说“这个结果有点奇怪我需要再验证一下”然后去调另一个工具核实。这种自我修正能力在传统 Function Calling 里根本做不到。2.2 ReAct 的三个阶段每次循环包含三个阶段一个都不能少。2.2.1. Thought思考模型用自然语言描述当前的分析和下一步计划。这段文字不展示给用户是模型的内心独白。Thought: 用户想了解上海明天天气。我需要调用天气查询工具 参数是城市上海日期明天。Thought 阶段是最容易被忽视的但其实是整个 ReAct 能不能跑好的关键。我见过很多“坏的 Thought”——就是两三个字完全没有推理比如Thought: 查天气。这种 Thought 质量极差后面的 Action 当然也乱。System Prompt 设计得好不好很大程度上体现在 Thought 的质量上。2.2.2. Action行动模型输出要调用的工具名和参数。Action: getWeather Action Input: {city: 上海, date: tomorrow}关键点模型输出的是文本不是函数调用。整个 Action 就是一段字符串“框架”也就是我们自己或者 Spring AI负责解析这段文本然后找到对应的函数执行。理解了这个就能理解为什么Tool注解里的方法名和description那么重要——模型是靠这些信息“认识”工具、在文本里写出正确的工具名的。2.2.3. Observation观察工具执行后的返回结果作为下一轮Thought的输入。Observation: {city: 上海, date: 2024-01-16, weather: 晴, temp: 8~15°C}Observation会被加回消息历史模型在下一轮能“看到”这个结果并基于它继续推理。工具的返回值要精简。我吃过亏——有个工具直接把第三方 API 的原始 JSON 响应丢给模型里面七八十个字段模型在Thought里说“这个结果太复杂我看不懂”然后开始胡乱推理。后来把无关字段过滤掉只返回模型真正需要的几个字段问题就解决了。工具返回值的质量直接影响 Agent 的准确率。循环直到模型输出Final Answer表示任务完成。2.3手写最简 ReAct 实现不用任何框架纯 Spring AI 手写循环把底层搞清楚。再强调一遍为什么要手写Spring AI 帮我们封装了 Agent Loop一般来说直接用就够了。但如果你不知道这个循环是怎么跑起来的遇到问题——模型死循环、工具调用出错、循环中途卡住——就完全不知道从哪里入手排查。手写版是“学原理用的”不是“上生产用的”这一点要分清楚。package com.jichi.agent.service; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Map; Service public class SimpleReActAgent { private final ChatClient chatClient; // 工具注册表工具名 → 工具实现 // 模型输出 Action: getWeather就直接拿名字去这里找对应函数 private final MapString, AgentTool tools; public SimpleReActAgent(Qualifier(dashScopeChatModel) ChatModel chatModel) { this.chatClient ChatClient.builder(chatModel).build(); this.tools Map.of( getWeather, this::getWeather, getDate, this::getDate ); } /** * 执行 ReAct 循环 * * param userTask 用户任务描述 * param maxIterations 最大循环次数防止死循环 */ public String run(String userTask, int maxIterations) { ListMessage messages new ArrayList(); String systemPrompt buildSystemPrompt(); messages.add(new UserMessage(userTask)); for (int i 0; i maxIterations; i) { // 每轮都把完整消息历史传给模型 // 模型是无状态的它记得上下文靠的就是这个列表 String modelOutput chatClient.prompt() .system(systemPrompt) .messages(messages) .call() .content(); messages.add(new AssistantMessage(modelOutput)); System.out.println( 第 (i 1) 轮 ); System.out.println(modelOutput); // 检测是否输出了最终答案 if (modelOutput.contains(Final Answer:)) { return extractFinalAnswer(modelOutput); } // 解析模型想调用哪个工具、参数是什么 String toolName extractAction(modelOutput); String toolInput extractActionInput(modelOutput); if (toolName null) { return 模型输出格式异常无法继续执行; } AgentTool tool tools.get(toolName); String observation; if (tool null) { observation 工具 toolName 不存在请换一个; } else { observation tool.execute(toolInput); } System.out.println(Observation: observation); // 把观察结果加回消息历史下一轮模型就能看到这个结果 messages.add(new UserMessage(Observation: observation)); } return 超过最大迭代次数 maxIterations 任务未完成; } private String buildSystemPrompt() { return 你是一个智能助手按照以下格式严格输出每次只做一个动作 可用工具 - getWeather(input: JSON {city: 城市名, date: today/tomorrow})查询天气 - getDate(input: 无)获取今天的日期 输出格式严格遵守 Thought: [你的分析和下一步计划] Action: [工具名] Action Input: [工具参数JSON 格式] 收到 Observation 后继续思考直到可以回答为止 Thought: [分析观察结果] Final Answer: [给用户的最终回答] 注意 - 每次只输出一个 Action 或 Final Answer不要一次输出多个 - 工具名必须和上面列表完全一致 ; } // ---- 工具实现 ---- private String getWeather(String input) { // 真实项目里调用天气 API这里用 Mock return {city: 上海, weather: 晴, temp: 8~15°C, wind: 北风3级} ; } private String getDate(String input) { return java.time.LocalDate.now().toString(); } // ---- 解析工具 ---- private String extractFinalAnswer(String output) { int idx output.indexOf(Final Answer:); if (idx -1) return output; return output.substring(idx Final Answer:.length()).strip(); } private String extractAction(String output) { for (String line : output.split(\n)) { if (line.startsWith(Action:)) { return line.substring(Action:.length()).strip(); } } return null; } private String extractActionInput(String output) { for (String line : output.split(\n)) { if (line.startsWith(Action Input:)) { return line.substring(Action Input:.length()).strip(); } } return ; } FunctionalInterface interface AgentTool { String execute(String input); } }几个设计点解释一下为什么用MapString, AgentTool存工具这是“工具注册表”的思路。模型输出Action: getWeather直接拿工具名去 Map 里找对应实现O(1) 查找简单直接。这个模式和后面 Spring AI 的Tool注解注册机制是同一个思路——理解了这里Spring AI 的Tool注解背后在做什么也就清楚了框架帮你维护了这张表收到Action的时候自动反射调用。为什么每轮都传完整的messages模型是无状态的每次调用都是一次全新的请求它完全不知道之前发生了什么。Agent Loop 的“记忆”就体现在这个消息列表里——每轮执行完把新的消息加进去下一轮带上全部历史模型才能接着推理。消息列表越来越长Token 消耗也越来越多这是 Agent 比普通聊天贵的核心原因。任务越复杂、执行轮数越多费用越高。为什么 System Prompt 每轮都传System Prompt 是“规则书”——告诉模型可以用哪些工具、要用什么格式输出。每次新的模型调用都是独立的不传就不知道规则。Spring AI 里的systemText()也是同样的道理。暴露 HTTP 接口package com.jichi.agent.controller; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/agent/react) public class ReActController { private final SimpleReActAgent agent; public ReActController(SimpleReActAgent agent) { this.agent agent; } PostMapping public String run(RequestBody TaskRequest request) { return agent.run(request.task(), 10); } record TaskRequest(String task) {} }测试curl -X POST http://localhost:8080/api/agent/react \ -H Content-Type: application/json \ -d {task: 上海明天天气怎么样今天是几号}2.4运行效果发出请求后控制台输出大概是这样 第 1 轮 Thought: 用户问上海明天天气和今天日期我先查日期。 Action: getDate Action Input: {} Observation: 2025-03-15 第 2 轮 Thought: 今天是 2025-03-15明天是 2025-03-16。现在查上海天气。 Action: getWeather Action Input: {city: 上海, date: tomorrow} Observation: {weather: 晴, temp: 8~15°C} 第 3 轮 Thought: 两个信息都拿到了可以回答用户。 Final Answer: 今天是 2025 年 3 月 15 日。上海明天3 月 16 日天气晴气温 8~15°C适合出行。2.5循环退出控制——几个必须处理的点这是我觉得最容易翻车的地方每一个都是实际被坑过之后加上去的// 1. 最大迭代次数防死循环必须加不加早晚翻车 int maxIterations 10; // 简单任务设 5复杂设 10-15 // 2. Final Answer 检测必须 if (modelOutput.contains(Final Answer:)) { ... } // 3. 工具不存在时的处理必须 if (tool null) { observation 工具 toolName 不存在请换一个; } // 4. 模型输出格式不符合要求时的处理强烈建议 if (toolName null) { messages.add(new UserMessage(请按格式输出 Action 或 Final Answer)); }为什么maxIterations必须设我亲身经历某个工具一直返回错误结果模型在Thought里说“上次结果不对我再查一次”然后又得到同样的错误然后又说“再试一次”……无限循环。如果是收费 API这个坑非常贵而且模型自己不会停下来。工具返回值要精简如果工具返回了一个很长的原始 JSON带着几十个无关字段模型可能在Thought里说“这个结果太复杂我看不懂”然后开始发散。要么乱猜要么陷入“我需要再查一次”的循环。只保留模型真正需要的字段。2.6手写版的意义我让大家手写这个不是要你以后都手写 ReAct——后面直接用 Spring AI 的 Agent API一行顶这里好几十行。手写的意义只有一个看清楚框架帮我们做了什么以及什么地方可能出问题。知道底层是这个循环遇到问题——Agent 突然停了、工具没被调用、循环次数超了、Thought 质量越来越差——就知道从哪里入手而不是一头雾水地去刷 Stack Overflow。这个“知道发生了什么”的感觉在排查生产环境问题的时候非常值钱。更重要的是理解了 ReAct 之后后面讲到Plan-and-Execute、多 Agent 协作你会发现都是在这个基础上扩展的——大框架变了但核心的“思考-行动-观察”逻辑一直在。这是整个 Agent 模块的地基。