【SpringAI】第七弹:从 CoT 到 ReAct,拆解 OpenManus 分层架构下的自主规划智能体源码
1. 从 CoT 到 ReAct我在 SpringAI 里复现自主规划智能体踩过的坑很多同学第一次接触 AI 智能体是从“深度思考”按钮开始的模型先输出一段推理再给答案看起来像人一样先想后说。这背后其实就是 CoTChain of Thought思维链。但 CoT 只解决了“想”的问题没解决“做”的问题。真正让智能体具备自主规划能力的是 ReActReasoning Acting模式思考下一步、调用工具、观察结果、再思考循环推进直到任务完成。OpenManus 就是把这套机制用分层架构落地的一个典型开源项目它的 BaseAgent → ReActAgent → ToolCallAgent → Manus 四层继承链把“步骤循环”“思考-行动”“工具选择与执行”拆得清清楚楚。这篇内容面向已经在用 SpringAI 做应用、想给自己的项目加上自主规划能力的开发者。我会先讲清楚 CoT 和 ReAct 在源码层面到底怎么落地再给出可复制的分层模块骨架配置最后用一次端到端任务验证整个链路是否跑通。如果你之前只写过 ChatClient 的单轮对话跟着做能直接复现一个能自己选工具、自己判断结束的智能体。过程中涉及模型调用和工具编排我会用 TaoToken 作为统一接入层来演示这样你不用在多个模型供应商之间来回切换配置。2. TaoToken 前置为什么智能体项目需要一个统一接入层自主规划智能体的一个特点是一次任务里会发起很多次模型请求。ReAct 循环每走一步就要问一次模型“下一步该用哪个工具、传什么参数”。一个稍微复杂的任务跑下来十几二十次调用很正常。如果每次调用都直连不同厂商的 SDK密钥管理、计费口径、模型切换会变得很碎。我试过在一个 Agent 项目里同时接三家模型光是适配不同的请求格式和工具调用返回结构就花了大半天。TaoToken 在这里的角色是统一接入层它提供 OpenAI 兼容的接口SpringAI 里只要把 base-url 指过去就能用同一套 ChatClient 代码调用不同模型。对智能体项目来说这意味着工具调用的返回结构、流式输出、function call 字段都是统一的ReActAgent 里解析 tool_calls 的逻辑不用为每个厂商写分支。你需要先拿到一个 API Key。访问控制台创建密钥https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsole创建完成后在 API Keys 页面可以查看和管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys接口地址统一用https://taotoken.net/api注意这个地址不带任何查询参数。模型对话调试可以在模型对话页直接试https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat如果你打算长期跑编码类或 Agent 类任务调用量会比较大可以了解下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan接入文档在这里SpringAI 的配置细节可以对照看https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc注意API Key 不要硬编码进源码提交到仓库用环境变量或配置中心注入。智能体项目里工具执行可能涉及文件、终端操作密钥泄露的风险比普通应用更高。3. 可复制配置SpringAI 分层智能体骨架3.1 依赖与模型接入配置先建一个 Spring Boot 项目引入 SpringAI 的 OpenAI starter。pom 里关键依赖如下dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M5/version /dependencyapplication.yml 里把模型指向 TaoToken 的兼容接口spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.3temperature 调低一点是因为 ReAct 循环里模型要稳定地输出工具调用决策太随机会导致同一任务每次走的路径差异很大调试起来很痛苦。3.2 状态枚举与 BaseAgent先定义智能体状态控制执行流程public enum AgentState { IDLE, RUNNING, FINISHED, ERROR }BaseAgent 负责“一步步循环做事”的框架。核心是 run 方法里的 while 循环以及把 step 声明为抽象方法交给子类Slf4j public abstract class BaseAgent { private String name; private String systemPrompt; private String nextStepPrompt; private AgentState state AgentState.IDLE; private int maxSteps 10; private int currentStep 0; private ChatClient chatClient; private ListMessage messageList new ArrayList(); public String run(String userPrompt) { if (state ! AgentState.IDLE) { throw new RuntimeException(Cannot run agent from state: state); } state AgentState.RUNNING; messageList.add(new UserMessage(userPrompt)); ListString results new ArrayList(); try { for (int i 0; i maxSteps state ! AgentState.FINISHED; i) { currentStep i 1; log.info(Executing step {}/{}, currentStep, maxSteps); String stepResult step(); results.add(Step currentStep : stepResult); } if (currentStep maxSteps) { state AgentState.FINISHED; results.add(Terminated: Reached max steps ( maxSteps )); } return String.join(\n, results); } catch (Exception e) { state AgentState.ERROR; log.error(Error executing agent, e); return 执行错误: e.getMessage(); } finally { cleanup(); } } public abstract String step(); protected void cleanup {} }这里有两个设计点值得注意chatClient 由外部传入而不是写死方便替换模型messageList 自己维护因为我们要手动控制工具调用时的上下文追加不能让框架托管。3.3 ReActAgent把 step 拆成 think 和 actReActAgent 继承 BaseAgent把“一步”明确拆成“先想后做”public abstract class ReActAgent extends BaseAgent { public abstract boolean think(); public abstract String act(); Override public String step() { try { boolean shouldAct think(); if (!shouldAct) { return 思考完成 - 无需行动; } return act(); } catch (Exception e) { return 步骤执行失败: e.getMessage(); } } }think 返回 boolean表示“这一步是否需要调用工具”。如果模型判断任务已经完成或者只需要输出一段文字就返回 false循环继续但这一步不执行工具。3.4 ToolCallAgent工具选择与执行这是最关键的一层。它要实现两件事think 里让模型从工具列表里选工具act 里真正执行并把结果写回上下文。Slf4j public class ToolCallAgent extends ReActAgent { private final ToolCallback[] availableTools; private ChatResponse toolCallChatResponse; private final ToolCallingManager toolCallingManager; private final ChatOptions chatOptions; public ToolCallAgent(ToolCallback[] availableTools) { this.availableTools availableTools; this.toolCallingManager ToolCallingManager.builder().build(); this.chatOptions OpenAiChatOptions.builder() .internalToolExecutionEnabled(false) .build(); } Override public boolean think() { if (getNextStepPrompt() ! null !getNextStepPrompt().isEmpty()) { getMessageList().add(new UserMessage(getNextStepPrompt())); } Prompt prompt new Prompt(getMessageList(), chatOptions); ChatResponse response getChatClient().prompt(prompt) .system(getSystemPrompt()) .tools(availableTools) .call() .chatResponse(); this.toolCallChatResponse response; AssistantMessage assistantMessage response.getResult().getOutput(); ListAssistantMessage.ToolCall toolCalls assistantMessage.getToolCalls(); log.info({} 选择了 {} 个工具, getName(), toolCalls.size()); if (toolCalls.isEmpty()) { getMessageList().add(assistantMessage); return false; } return true; } Override public String act() { if (!toolCallChatResponse.hasToolCalls()) { return 没有工具调用; } Prompt prompt new Prompt(getMessageList(), chatOptions); ToolExecutionResult result toolCallingManager.executeToolCalls(prompt, toolCallChatResponse); setMessageList(result.conversationHistory()); ToolResponseMessage toolMsg (ToolResponseMessage) CollUtil.getLast(result.conversationHistory()); String output toolMsg.getResponses().stream() .map(r - 工具 r.name() 结果: r.responseData()) .collect(Collectors.joining(\n)); boolean terminated toolMsg.getResponses().stream() .anyMatch(r - doTerminate.equals(r.name())); if (terminated) { setState(AgentState.FINISHED); } return output; } }关键点在于internalToolExecutionEnabled(false)。默认情况下 SpringAI 会自动帮你执行工具并把结果塞回上下文但那样 think 和 act 就混在一起了ReAct 的循环边界不清晰。关掉托管后think 只负责拿到 tool_callsact 负责执行职责分明。3.5 终止工具与 Manus 实例终止工具让模型自己决定任务何时结束public class TerminateTool { Tool(description Terminate the interaction when the task is done or cannot proceed.) public String doTerminate() { return 任务结束; } }注册到工具列表后Manus 实例继承 ToolCallAgent配置系统提示词和最大步数Component public class MyManus extends ToolCallAgent { public MyManus(ToolCallback[] allTools, ChatModel chatModel) { super(allTools); setName(myManus); setSystemPrompt(你是一个全能助手可以调用工具完成复杂任务。); setNextStepPrompt(根据用户需求选择最合适的工具。复杂任务可分步解决。 完成后调用 terminate 工具结束。); setMaxSteps(20); setChatClient(ChatClient.builder(chatModel) .defaultAdvisors(new SimpleLoggerAdvisor()) .build()); } }4. 验证请求一次端到端任务跑通写个测试让智能体完成一个需要多步工具调用的任务SpringBootTest class MyManusTest { Resource private MyManus myManus; Test void runTask() { String prompt 帮我查一下北京今天天气然后根据天气给出一句出行建议。; String answer myManus.run(prompt); System.out.println(answer); Assertions.assertNotNull(answer); } }以 Debug 模式运行观察日志里的循环过程。正常的话你会看到类似这样的输出Executing step 1/20 myManus 选择了 1 个工具 工具 getWeather 结果: 北京今天晴气温 18-26 度 Executing step 2/20 myManus 选择了 1 个工具 工具 doTerminate 结果: 任务结束第一步模型判断需要查天气调用天气工具第二步拿到结果后生成建议并调用终止工具。整个链路走通说明 think 选工具、act 执行、结果回写上下文、终止判断都正常。如果你想先单独验证模型和工具调用的返回结构可以在模型对话页手动发一条带工具定义的请求看返回的 tool_calls 字段是否符合预期https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat5. 本篇常见错排查5.1 工具调用返回为空最常见的原因是internalToolExecutionEnabled没关掉或者用的模型不支持 function call。先确认 chatOptions 里设了 false再确认模型本身支持工具调用。如果返回的 assistantMessage 里 toolCalls 一直是空列表可以在模型对话页用同样的工具定义测一下排除是模型侧的问题。5.2 消息上下文重复追加act 里执行完工具后result.conversationHistory()已经包含了助手消息和工具返回结果。如果你在 think 里又把 assistantMessage 加了一遍上下文里就会出现重复的助手消息模型下一轮会困惑。记住think 里只有“不调用工具”时才把 assistantMessage 加进 messageList调用工具时不要加交给 act 的 conversationHistory 统一处理。5.3 陷入无限循环模型可能反复调用同一个工具、拿到相同结果、继续调用。OpenManus 的做法是检测重复响应统计最近几条助手消息里是否有内容完全相同的超过阈值就注入一段提示词引导换策略。你可以在 BaseAgent 的循环里加一个 isStuck 检查protected boolean isStuck() { ListMessage messages getMessageList(); if (messages.size() 3) return false; String last messages.get(messages.size() - 1).getContent(); long dup messages.stream() .filter(m - m instanceof AssistantMessage) .filter(m - last ! null last.equals(m.getContent())) .count(); return dup 2; }检测到卡住后往 nextStepPrompt 里追加“观察到重复响应请换一种策略”下一轮 think 就会带上这个提示。5.4 最大步数设置不合理maxSteps 太小复杂任务跑一半就被截断太大遇到循环会烧掉大量 token。建议从 15 到 20 起步观察几个典型任务的实际步数再调整。如果任务经常在第十几步才完成说明工具粒度可能太细考虑合并一些操作。5.5 工具执行超时或异常没被捕获工具里涉及网络请求或文件操作时一定要加超时和异常处理。act 方法里如果工具抛异常没被捕获整个循环会中断。可以在 executeToolCalls 外层包一层 try-catch把异常信息作为工具结果返回给模型让模型决定是重试还是换工具。6. 继续深入的方向跑通基础链路后有几个方向可以继续优化。一是给智能体加上交互能力参考 OpenManus 的 AskHuman 工具在模型拿不准时弹出输入框让用户补充信息而不是瞎猜。二是接入 MCP 协议把远程服务的工具动态注册到 availableTools 里这样智能体的能力边界可以随时扩展。三是做工作流编排把多个专业智能体串起来比如一个负责规划、一个负责执行、一个负责校验用条件路由连接。如果你打算长期跑这类 Agent 任务调用量会比普通对话高不少可以看下 Coding Plan 的额度方案https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan接入过程中遇到工具调用格式、上下文管理的问题可以对照接入文档排查https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc最后提醒一句自主规划智能体虽然能处理复杂任务但 token 消耗和不确定性都比单轮对话高。生产环境里建议加上步数上限、循环检测和人工确认环节别让它无约束地跑下去。