
1. 为什么我要用 SpringAI 复刻一个 Manus从 ReAct 到工具调用的真实动机Manus 火起来那阵子我盯着它的任务规划链路看了很久用户丢一句“帮我做一份中秋家宴的采购清单并生成 PDF”它就能自己拆步骤、搜资料、写文件、最后交付。这套东西听起来玄拆开看其实就是ReAct 推理循环 工具调用 会话记忆三件套。而 SpringAI 恰好把这三件套都封装成了开箱即用的组件所以用 Java 复刻一个简化版 Manus比想象中简单得多。这篇文章要解决的核心问题是怎么用 SpringAI 搭出一个具备 ReAct 推理与工具调用能力的 Agent让它能自主规划任务、调用工具、观察结果、继续推理直到任务闭环。适合谁看有 Spring Boot 基础、想从“会调大模型 API”进阶到“能写 Agent”的 Java 开发者。如果你之前只写过chatClient.prompt().user(你好).call()这种一次性问答那这篇就是你的下一步。我会按真实开发顺序走一遍先讲清楚 ReAct 和 Agent Loop 到底在循环什么再给出可复制的 SpringAI 配置片段然后注册一组实用工具文件读写、联网搜索、网页抓取、终端执行、PDF 生成最后跑一轮端到端任务验证把“搜索→抓取→写文件→生成 PDF”这条链路完整跑通。中间踩过的坑比如user.dir()写错导致文件路径诡异、工具返回内容太长把上下文灌爆、withProxyToolCalls(true)忘了开导致框架和手动控制打架我都会标出来。先说结论一个能用的 Manus 类 Agent核心代码量其实不大难的是把 ReAct 循环的每一步控制权拿捏清楚——什么时候让模型思考、什么时候执行工具、什么时候把结果回传、什么时候终止。SpringAI 的ToolCallingManager和ChatOptions给了你手动接管这个循环的能力这正是复刻 Manus 的关键入口。2. ReAct 与 Agent LoopSpringAI 里到底循环的是什么2.1 ReAct 不是新概念是“思考-行动-观察”的固定节奏ReAct 的全称是 Reasoning Acting核心就三步循环Reason推理下一步该干嘛→ Act调用工具执行→ Observe拿到工具返回结果→ 再推理。它模仿的是人解决问题的节奏你不会一上来就动手而是先想“我现在缺什么信息”然后去查查到结果再决定下一步。在 SpringAI 里这个循环的载体是ChatClient的一次call()。但注意一次call()默认只完成一轮“模型回复”如果模型决定调用工具SpringAI 内置的机制会自动帮你执行工具、把结果回传、再调一次模型——这就是所谓的“托管式工具调用”。托管很省事但你想复刻 Manus 那种“我能看到每一步在干嘛、能干预、能记录”的效果就得把手动控制打开。2.2 Agent Loop没有用户输入也要自己转起来普通聊天是“用户问一句模型答一句”就结束。Agent Loop 不一样模型答完之后如果任务没完成它要自己继续执行下一步形成一个自主循环直到任务完成或达到最大步数。这就是BaseAgent里那个for (int i 0; i maxSteps state ! FINISHED; i)循环的意义。我实测下来maxSteps设太小比如 3会导致复杂任务半途而废设太大比如 50又可能让模型在死循环里烧 token。20 是一个比较稳的默认值配合terminate工具让模型自己决定何时收手。2.3 SpringAI 手动控制工具调用的关键开关这是整篇文章最容易被忽略、但最关键的配置this.chatOptions DashScopeChatOptions.builder() .withProxyToolCalls(true) .build();withProxyToolCalls(true)的含义是禁用 SpringAI 内置的自动工具执行改由我自己接管。如果不加这个框架会在模型返回tool_calls后自动执行工具并再次调用模型你的think()和act()就形同虚设ReAct 循环的每一步你都看不到。加上它之后模型返回的ChatResponse里会带着工具调用请求你可以在act()里自己决定执行哪个工具、怎么执行、结果怎么处理。注意不同模型供应商的ChatOptions实现类名不同DashScope 用DashScopeChatOptionsOpenAI 用OpenAIChatOptions但withProxyToolCalls这个语义是通用的。如果你用的是其他供应商去对应 Options 类里找proxyToolCalls字段。2.4 三层继承结构BaseAgent → ReActAgent → ToolCallAgent复刻 Manus 的类结构我建议照搬 OpenManus 的分层思路这样职责清晰、好扩展层级类名职责第一层BaseAgent状态管理、消息上下文、多步骤执行循环、step()抽象第二层ReActAgent把step()拆成think()act()两个抽象方法第三层ToolCallAgent实现think()解析工具调用请求和act()执行工具第四层LearningCompanyManus具体实例注入 ChatClient、工具列表、系统提示词BaseAgent的run()方法里维护了一个messageList每轮把用户消息、模型回复、工具结果都追加进去这就是 Agent 的“短期记忆”。state字段控制执行流程IDLE才能启动RUNNING表示执行中FINISHED表示正常结束ERROR表示异常。这个状态机设计能防止 Agent 被重复启动或在错误状态下继续跑。3. 可复制的 SpringAI 配置从依赖到 ChatClient 的完整片段3.1 Maven 依赖一个 starter 搞定大模型接入dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter-dashscope/artifactId /dependency这个 starter 会自动装配DashScopeChatModel和DashScopeEmbeddingModel你只需要在配置文件里填 API Key 和模型名。如果你用的是 OpenAI 或 Anthropic换成对应的 starter 即可SpringAI 的抽象层保证了上层代码几乎不用改。3.2 application.yml模型与向量库配置server: port: 8123 servlet: context-path: /api spring: application: name: spring-ai-manus ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen3-max embedding: options: model: text-embedding-v4 dimensions: 1024这里有个小细节api-key我用了环境变量占位符避免把 Key 硬编码进代码库。你在本地跑的时候可以在 IDE 的运行配置里加DASHSCOPE_API_KEYsk-xxx或者直接写死不推荐提交到 Git。3.3 ChatClient 与 ChatMemory 的 Bean 配置Configuration public class AgentConfig { Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build(), new SimpleLoggerAdvisor() ) .build(); } }MessageChatMemoryAdvisor负责把历史消息自动注入到每次请求里SimpleLoggerAdvisor负责打印请求和响应日志方便调试。生产环境建议把InMemoryChatMemory换成基于 Redis 或数据库的实现否则重启就丢记忆。3.4 工具注册一个 Bean 集中管理所有工具Configuration public class ToolRegistration { Value(${search-api.api-key}) private String searchApiKey; Bean public ToolCallback[] allTools() { FileOperationTool fileOperationTool new FileOperationTool(); WebSearchTool webSearchTool new WebSearchTool(searchApiKey); WebScrapingTool webScrapingTool new WebScrapingTool(); ResourceDownloadTool resourceDownloadTool new ResourceDownloadTool(); TerminalOperationTool terminalOperationTool new TerminalOperationTool(); PDFGenerationTool pdfGenerationTool new PDFGenerationTool(); return ToolCallbacks.from( fileOperationTool, webSearchTool, webScrapingTool, resourceDownloadTool, terminalOperationTool, pdfGenerationTool ); } }ToolCallbacks.from(...)会把每个带Tool注解的方法适配成统一的ToolCallback协议模型看到的是工具名、描述、参数 Schema执行时框架负责把 JSON 参数转成 Java 参数。这个集中注册的写法好处是新增工具只改一处坏处是所有工具对所有对话都可见——如果你想让不同 Agent 用不同工具集可以拆成多个 Bean按需注入。3.5 文件路径常量别再用user.dir()public interface FileConstant { String FILE_SAVE_DIR System.getProperty(user.dir) /tmp; }System.getProperty(user.dir)返回的是 JVM 启动时的工作目录通常是项目根目录。我踩过的坑是写成了user.dir()多了括号结果返回 null文件全写到系统根目录去了。所有工具的文件操作都约束在FILE_SAVE_DIR下面配合子目录/file、/download、/pdf做隔离避免模型乱写文件。4. 工具调用实战注册、执行、观察的完整链路4.1 工具定义Tool注解的写法与参数描述以文件操作为例public class FileOperationTool { private final String FILE_DIR FileConstant.FILE_SAVE_DIR /file; Tool(description Read content from a file) public String readFile( ToolParam(description Name of the file to read) String fileName) { String filePath FILE_DIR / fileName; try { return FileUtil.readUtf8String(filePath); } catch (Exception e) { return Error reading file: e.getMessage(); } } Tool(description Write content to a file) public String writeFile( ToolParam(description Name of the file to write) String fileName, ToolParam(description Content to write to the file) String content) { String filePath FILE_DIR / fileName; try { FileUtil.mkdir(FILE_DIR); FileUtil.writeUtf8String(content, filePath); return File written successfully to: filePath; } catch (Exception e) { return Error writing to file: e.getMessage(); } } }Tool的description是给模型看的写得越清楚模型越知道什么时候该调它。ToolParam的description同理模型靠它理解参数含义。我试过把 description 写成中文DashScope 的 qwen3-max 也能正确理解但英文描述在跨模型时更稳。4.2 联网搜索工具返回精简结果别把原始 JSON 全塞给模型public class WebSearchTool { private static final String SEARCH_API_URL https://www.searchapi.io/api/v1/search; private final String apiKey; public WebSearchTool(String apiKey) { this.apiKey apiKey; } Tool(description Search for information from Baidu Search Engine) public String searchWeb( ToolParam(description Search query keyword) String query) { MapString, Object paramMap new HashMap(); paramMap.put(q, query); paramMap.put(api_key, apiKey); paramMap.put(engine, baidu); try { String response HttpUtil.get(SEARCH_API_URL, paramMap); JSONObject jsonObject JSONUtil.parseObj(response); JSONArray organicResults jsonObject.getJSONArray(organic_results); JSONArray compact new JSONArray(); int limit Math.min(5, organicResults.size()); for (int i 0; i limit; i) { JSONObject item organicResults.getJSONObject(i); JSONObject obj new JSONObject(); obj.put(title, item.getStr(title)); obj.put(link, item.getStr(link)); obj.put(snippet, item.getStr(snippet)); compact.add(obj); } return compact.toStringPretty(); } catch (Exception e) { return Error searching Baidu: e.getMessage(); } } }这里的关键优化是只返回 title、link、snippet 三个字段且最多 5 条。原始搜索结果 JSON 动辄几千 token全塞给模型不仅贵还容易把上下文窗口挤爆导致模型“忘记”前面的推理。精简之后模型能快速抓住要点继续下一步。4.3 网页抓取与 PDF 生成两个高频工具网页抓取用 Jsouppublic class WebScrapingTool { Tool(description Scrape the content of a web page) public String scrapeWebPage( ToolParam(description URL of the web page to scrape) String url) { try { Document doc Jsoup.connect(url).get(); return doc.html(); } catch (IOException e) { return Error scraping web page: e.getMessage(); } } }PDF 生成用 iText注意中文字体要单独引入public class PDFGenerationTool { Tool(description Generate a PDF file with given content) public String generatePDF( ToolParam(description Name of the file to save the generated PDF) String fileName, ToolParam(description Content to be included in the PDF) String content) { String fileDir FileConstant.FILE_SAVE_DIR /pdf; String filePath fileDir / fileName; try { FileUtil.mkdir(fileDir); try (PdfWriter writer new PdfWriter(filePath); PdfDocument pdf new PdfDocument(writer); Document document new Document(pdf)) { PdfFont font PdfFontFactory.createFont(STSongStd-Light, UniGB-UCS2-H); document.setFont(font); document.add(new Paragraph(content)); } return PDF generated successfully to: filePath; } catch (IOException e) { return Error generating PDF: e.getMessage(); } } }STSongStd-Light是 iText 内置的中文字体配合UniGB-UCS2-H编码中文不会乱码。如果你要生成更复杂的 PDF表格、图片可以继续扩展这个方法。4.4 ToolCallAgent 的 think 与 act手动接管工具执行EqualsAndHashCode(callSuper true) Data 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) { super(); this.availableTools availableTools; this.toolCallingManager ToolCallingManager.builder().build(); this.chatOptions DashScopeChatOptions.builder() .withProxyToolCalls(true) .build(); } Override public boolean think() { if (StringUtil.isNotBlank(getNextStepPrompt())) { getUserMessageList().add(new UserMessage(getNextStepPrompt())); } Prompt prompt new Prompt(getMessageList(), chatOptions); this.toolCallChatResponse getChatClient().prompt(prompt) .toolCallbacks(availableTools) .call() .chatResponse(); AssistantMessage assistantMessage toolCallChatResponse.getResult().getOutput(); getMessageList().add(assistantMessage); ListAssistantMessage.ToolCall toolCalls assistantMessage.getToolCalls(); if (toolCalls.isEmpty()) { return false; } log.info({} 选择了 {} 个工具, getName(), toolCalls.size()); return true; } Override public String act() { if (!toolCallChatResponse.hasToolCalls()) { return 没有工具调用; } Prompt prompt new Prompt(getMessageList(), chatOptions); ToolExecutionResult toolExecutionResult toolCallingManager.executeToolCalls(prompt, toolCallChatResponse); setMessageList(toolExecutionResult.conversationHistory()); ToolResponseMessage toolResponseMessage (ToolResponseMessage) CollUtil.getLast(toolExecutionResult.conversationHistory()); String results toolResponseMessage.getResponses().stream() .map(response - 工具 response.name() 完成了任务结果为 response.responseData()) .collect(Collectors.joining(\n)); if (toolResponseMessage.getResponses().stream() .anyMatch(response - response.name().equals(terminate))) { setState(AgentState.FINISHED); } return results; } }think()负责把当前消息列表 工具列表发给模型拿到模型回复后检查有没有tool_calls。有就返回 true进入act()没有就返回 false表示这轮不需要行动step()会返回“思考完成 - 无需行动”。act()用ToolCallingManager.executeToolCalls()执行模型请求的工具把结果追加到消息列表然后检查是否调用了terminate工具——如果调了就把状态设为FINISHED循环结束。4.5 端到端验证一轮完整任务跑通写个测试方法让 Agent 完成“搜索中秋家宴菜谱 → 抓取一个页面 → 保存为文件 → 生成 PDF”SpringBootTest class ManusAgentTest { Resource private LearningCompanyManus manus; Test void testEndToEndTask() { String result manus.run(帮我搜索中秋家宴的菜谱抓取其中一个网页的内容保存为文件然后生成一份 PDF 指南); System.out.println(result); Assertions.assertNotNull(result); } }跑起来之后控制台会打印每一步的日志Executing step 1/20、选择了 2 个工具、工具 webSearch 完成了任务……最后在tmp/pdf/目录下能看到生成的 PDF 文件。这就是一个完整的 Agent 闭环。5. 常见报错排查401、local proxy failed、reading choices、OAuth5.1 401 UnauthorizedKey 没配对或环境变量没生效最常见的报错是401 Unauthorized: {error:{message:Invalid API key,type:invalid_request_error}}排查顺序先确认application.yml里的api-key有没有写错再确认环境变量DASHSCOPE_API_KEY有没有在 IDE 运行配置里设置。如果你用的是 TaoToken 这类聚合服务Base URL 要指向https://taotoken.net/apiKey 用 TaoToken 控制台生成的Model ID 填qwen3-max或你实际要用的模型名。三件套Base URL Key Model ID缺一不可。5.2 local proxy failed网络层问题java.net.ConnectException: local proxy failed这个报错通常出现在你配置了 HTTP 代理但代理不可用的时候。检查application.yml或 JVM 参数里有没有-Dhttp.proxyHost之类的设置有就删掉。SpringAI 的 DashScope starter 默认直连不需要额外代理配置。5.3 reading choices响应格式解析失败com.alibaba.fastjson.JSONException: reading choices这个报错说明模型返回的 JSON 结构和你预期的对不上。常见原因是你用的模型不支持工具调用或者withProxyToolCalls(true)没开导致框架尝试解析一个不存在的choices字段。确认你用的模型如 qwen3-max支持 function calling并且chatOptions里开了proxyToolCalls。5.4 OAuth 相关报错认证方式不匹配OAuth authentication failed: invalid_client如果你用的是需要 OAuth 的服务比如某些企业版 API要确认 client_id、client_secret、token endpoint 都配对。TaoToken 的 API Key 方式是 Bearer Token不需要 OAuth 流程直接在 Header 里带Authorization: Bearer sk-xxx即可。5.5 工具调用不触发模型没“看到”工具如果 Agent 一直不调工具只输出自然语言检查两点一是toolCallbacks(availableTools)有没有真的传进去二是工具的description是不是太模糊。我试过把Tool(description search)写成这样模型根本不知道什么时候该用改成Search for information from Baidu Search Engine之后触发率明显提升。5.6 文件路径错误user.dir()的坑Error writing to file: /tmp/file/xxx.txt (No such file or directory)如果你看到文件被写到系统根目录的/tmp而不是项目下的tmp八成是System.getProperty(user.dir())写错了。正确写法是System.getProperty(user.dir)没有括号。这个坑我踩过排查了半小时才发现。6. 把 Agent 接进真实业务TaoToken 接入与长期编码方案6.1 为什么需要 TaoToken 这类聚合服务自己写 Agent 最烦的不是代码是模型管理今天想用 qwen3-max明天想试 Claude后天要对比 GPT——每换一个供应商就要改配置、换 SDK、调参数。TaoToken 这类聚合服务把多家模型统一到一个 API 端点Base URL 固定为https://taotoken.net/apiKey 在控制台生成Model ID 按需切换。对 Agent 开发来说这意味着你的ChatClient配置几乎不用动只改 Model ID 就能换模型。6.2 在 SpringAI 里接入 TaoToken 的配置片段spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: qwen3-max如果你用的是 OpenAI 兼容协议SpringAI 的spring-ai-openai-spring-boot-starter可以直接指向 TaoToken 的 Base URL。Key 从 TaoToken 控制台的 API Keys 页面生成Model ID 填你实际要用的模型名。这样配置之后你的 Agent 代码完全不用改换模型只改model字段。6.3 长期编码与 Agent 场景Coding Plan 更划算如果你打算长期跑 Agent 任务比如每天自动生成报告、批量处理文件按 token 计费可能不划算。TaoToken 的 Coding Plan 提供包月套餐适合高频调用场景。接入方式不变只是计费模式从按量变成包月。对于需要长时间运行、多轮工具调用的 Agent 来说这个方案能显著降低成本。6.4 验证模型是否接通一次简单的对话测试配置好之后先跑一个最简单的对话验证SpringBootTest class TaoTokenConnectTest { Resource private ChatClient chatClient; Test void testConnect() { String content chatClient.prompt() .user(你好请回复连接成功) .call() .content(); System.out.println(content); Assertions.assertNotNull(content); } }如果控制台打印出“连接成功”说明 Base URL、Key、Model ID 三件套都对了。接下来就可以把ChatClient注入到你的 Agent 里跑完整的 ReAct 循环。6.5 从 Demo 到生产三个必须加的硬约束第一权限与范围文件操作限制在FILE_SAVE_DIR下终端命令加白名单下载域名加白名单。第二资源与成本抓取内容做长度截断比如最多 5000 字符下载文件限制大小终端执行加超时。第三可观测性记录每次工具调用的名称、参数、耗时、结果摘要方便排查问题。这三条加上之后你的 Agent 才算从“能跑”变成“能用”。6.6 下一步把 Agent 封装成 HTTP 接口最后一步把LearningCompanyManus封装成一个 REST 接口让前端或其他服务能调用RestController RequestMapping(/agent) public class AgentController { Resource private LearningCompanyManus manus; GetMapping(/chat) public String chat(RequestParam String message) { return manus.run(message); } }启动服务后访问http://localhost:8123/api/agent/chat?message帮我生成一份学习计划PDF就能看到 Agent 自主完成搜索、写文件、生成 PDF 的全过程。到这一步你的简化版 Manus 就算真正跑通了。