十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring AI alibaba 智能体的 ChatClient 配 TaoToken:让 Open Manus 的 think/act 循环跑通

Spring AI alibaba 智能体的 ChatClient 配 TaoToken:让 Open Manus 的 think/act 循环跑通 Spring AI alibaba 智能体写到最后一步卡住我的不是 Open Manus 那套 BaseAgent / ReActAgent / ToolCallAgent 分层而是 ChatClient 的通道think 和 act 循环要么第一轮就返回 false要么工具名对不上。TaoToken 正是在这个位置接进来的——去 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建一把 Key把 Base URL 填成 https://taotoken.net/api日志里的「思考 / 选择工具 / 执行工具」才一轮一轮跑出来。这篇不重新讲一遍 Spring AI 怎么用而是把 Open Manus 风格的四层智能体照着写完之后单独把 ChatClient 这一环拆开看它在 BaseAgent 里只是一个字段在 ToolCallAgent 里却是真正发请求的地方。原来的写法里BaseAgent 持有 DashScopeChatModelToolCallAgent 里塞 DashScopeChatOptions.withProxyToolCalls(true)测试前还要去某个控制台复制 Key——任何一环不对run() 的循环都走不到第二轮。下面按原文的章节顺序从 BaseAgent 的属性开始一直到 test() 的三轮日志把通道换成 TaoToken 之后每一步该长什么样写清楚。1. Open Manus 四层骨架能照抄ChatClient 通道得先定下来1.1 BaseAgent / ReActAgent / ToolCallAgent / Manus 各自管什么Open Manus 源码里 app/agent 目录的分层是这套 Java 实现的蓝本。BaseAgent 负责最小可用的一套状态名字、系统提示词、下一步提示词、当前状态、步数、记忆列表以及对外的 run() 循环和留给子类实现的 step()。ReActAgent 把 step() 填上转手去问 think()再决定要不要走 act()。ToolCallAgent 是真正接触模型和工具的一层think() 里把工具列表发给大模型act() 里把模型选出来的工具交给自己手里的 ToolCallingManager 执行。Manus 只是最上面那层装配工负责起名字、写提示词、把模型和工具一起塞进构造函数。这四层如果只做代码搬运十分钟能搭完。难的是装配完以后第一次 run() 到底能不能转起来而转不转得动几乎全押在 ChatClient 上。BaseAgent 只声明了一个private ChatClient chatClient;字段看起来无足轻重可 think() 里.call().chatResponse()发出的是真实网络请求模型不认识工具、Key 不对、Base URL 拼错都会以「think 返回 false」这种非常隐蔽的形式暴露出来。1.2 DashScope Key 与 Base URL 为什么容易卡住验证原来的配置里模型走 DashScopeKey 从另一套控制台申请Base URL 也是 DashScope 自己的 endpoint。写完四层之后你会发现一个尴尬的局面run() 的第一轮日志只有一句「本次无需使用工具」第二轮根本没发生。此时你分不清到底是 think() 的逻辑写错了、工具描述写得不清楚还是这条通道本身没把 tool_calls 带回来。把 ChatClient 接成 TaoToken 的兼容通道之后问题被收敛成两个可排查的点Key 是不是从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建的那把Base URL 是不是写成了 https://taotoken.net/api末尾不带 /v1。剩下的 think/act 逻辑本身可以慢慢调。这也是「先定通道再调循环」这个顺序的由来通道不稳循环里的每一步都是雾里看花。2. BaseAgentChatClient 字段和 AgentState 一起写在基类里2.1 AgentState 枚举与 memoryMessages 的定位先把状态枚举定死后面 act() 里判断任务是否结束时要用到它。IDLE、RUNNING、FINISHED、ERROR 四个值够了不要为了「看起来完整」多加状态多出来的状态最后都会变成没人处理的死分支。public enum AgentState { IDLE, RUNNING, FINISHED, ERROR }BaseAgent 的字段里有两个是后面调通道时最容易被忽略的chatClient和memoryMessages。memoryMessages 是智能体自己维护的上下文不是 Spring AI 的 ChatMemory这意味着每一轮 think() 发出去的 Prompt 里装的是这份列表加上当轮的 nextStepPrompt。通道换掉之后这份列表的结构不用改但你要清楚它最终会被序列化成 messages 数组发给模型。Data Slf4j public abstract class BaseAgent { private String name; private String systemPrompt; private String nextStepPrompt; private AgentState agentState AgentState.IDLE; private Integer currentStep 1; private Integer maxStep 10; private ChatClient chatClient; private final ListMessage memoryMessages new ArrayList(); public abstract String step(); public void clearUp() { log.info(清理资源); } }2.2 run 方法里 step 被调用的那条主线run() 的结构不复杂关键是别把异常吞掉。原来那种「出错就 return 一个字符串」的写法会让 think() 里的 401 变成一个看起来像业务结果的返回值非常难查。这里让异常先落到 agentState ERROR再把错误信息带出去。public String run(String text) { if (agentState ! AgentState.IDLE) { throw new IllegalStateException(当前代理非空闲状态); } if (StrUtil.isBlank(text)) { throw new IllegalArgumentException(用户输入不能为空); } agentState AgentState.RUNNING; memoryMessages.add(new UserMessage(text)); ListString records new ArrayList(); try { while (currentStep maxStep agentState ! AgentState.FINISHED) { String stepResult step(); records.add(stepResult); log.info(第 {} 轮结果{}, currentStep, stepResult); currentStep; } if (currentStep maxStep agentState ! AgentState.FINISHED) { agentState AgentState.FINISHED; records.add(达到最大步数 maxStep 停止); } return String.join(\n, records); } catch (Exception e) { agentState AgentState.ERROR; log.error(执行出错, e); return 执行出错 e.getMessage(); } finally { clearUp(); } }3. ReActAgent 的 step 与 ToolCallAgent 的 think第一次真实请求在这里发出3.1 step 先问 think再决定要不要 actReActAgent 只做一件事把 step() 补上然后声明 think() 和 act() 两个抽象方法。这里没有多余逻辑think() 返回 false 就直接结束这一轮连工具都不碰。EqualsAndHashCode(callSuper true) Data Slf4j public abstract class ReActAgent extends BaseAgent { Override public String step() { try { if (!think()) { return 思考完成无需行动; } return act(); } catch (Exception e) { log.error(步骤执行失败, e); return 步骤执行失败 e.getMessage(); } } public abstract boolean think(); public abstract String act(); }真正排查 think/act 循环时你会反复看的就是这句不需要行动。它一出现通常意味着模型这一轮没有返回 tool_calls而不是你的 act() 有问题。3.2 think 里 ChatClient 怎么带上工具列表ToolCallAgent 的构造方法里要准备好三样东西可用工具数组、ToolCallingManager、以及关掉框架自动执行工具的 ChatOptions。原文这里用的是DashScopeChatOptions.builder().withProxyToolCalls(true).build()换成兼容通道之后这个选项类型要跟着换否则编译期就会报类型不匹配。EqualsAndHashCode(callSuper true) Data Slf4j public class ToolCallAgent extends ReActAgent { private ToolCallback[] availableTools; private ChatResponse toolCallChatResponse; private ToolCallingManager toolCallingManager; private ChatOptions chatOptions; public ToolCallAgent(ToolCallback[] availableTools, String modelId) { super(); this.availableTools availableTools; this.toolCallingManager ToolCallingManager.builder().build(); this.chatOptions OpenAiChatOptions.builder() .model(modelId) .internalToolExecutionEnabled(false) .build(); } }internalToolExecutionEnabled(false)这一行的意义和原来withProxyToolCalls(true)是一样的告诉框架别自己动手把模型返回的 tool_calls 原样交给我由 act() 里的 ToolCallingManager 手动执行。这样 Open Manus 那套「think 只做选择、act 才真正执行」的分工才立得住。think() 里发请求的方式不用改还是 Prompt 加工具列表Override public boolean think() { if (StrUtil.isNotBlank(getNextStepPrompt())) { getMemoryMessages().add(new UserMessage(getNextStepPrompt())); } Prompt prompt new Prompt(getMemoryMessages(), chatOptions); try { this.toolCallChatResponse getChatClient() .prompt(prompt) .system(getSystemPrompt()) .tools(availableTools) .call() .chatResponse(); AssistantMessage assistantMessage toolCallChatResponse.getResult().getOutput(); String text assistantMessage.getText(); ListAssistantMessage.ToolCall toolCalls assistantMessage.getToolCalls(); log.info({} 的思考{}, getName(), text); log.info({} 选择了 {} 个工具, getName(), toolCalls.size()); toolCalls.forEach(tc - log.info(工具名称 {}工具参数 {}, tc.name(), tc.arguments())); if (CollUtil.isEmpty(toolCalls)) { getMemoryMessages().add(assistantMessage); return false; } return true; } catch (Exception e) { log.error({} 的思考过程出错, getName(), e); getMemoryMessages().add(new AssistantMessage(处理时遇到错误 e.getMessage())); return false; } }4. ToolCallAgent 的 act让 scrapeWebPage 和 generatePDF 真正执行4.1 executeToolCalls 返回的 conversationHistoryact() 比 think() 短但有两个细节容易写错。第一Prompt 要用最新的 memoryMessages 构造不能沿用 think() 里那一份第二执行完要把 conversationHistory 整体写回 memoryMessages而不是只追加最后一条。Spring AI 的 ToolCallingManager 已经把 assistant 的 tool_calls 消息和 tool 的响应消息都拼进了 history你自己再拼一次会重复。Override public String act() { if (!toolCallChatResponse.hasToolCalls()) { return 没有工具调用; } Prompt prompt new Prompt(getMemoryMessages(), chatOptions); ToolExecutionResult result toolCallingManager.executeToolCalls(prompt, toolCallChatResponse); setMemoryMessages(result.conversationHistory()); ToolResponseMessage last (ToolResponseMessage) CollUtil.getLast(result.conversationHistory()); String resp last.getResponses().stream() .map(r - 工具 r.name() 完成结果 r.responseData()) .collect(Collectors.joining(\n)); log.info(resp); return resp; }4.2 doTerminate 命中后把状态置为 FINISHED任务何时结束靠的是模型主动调用终止工具。TerminateTool 的描述写清楚一点模型才会在该收手的时候收手public class TerminateTool { Tool(description Terminate the interaction when the request is met, or when the assistant cannot proceed further. Call this tool after all tasks are finished. ) public String doTerminate() { return 任务结束; } }act() 里对应加一段判断命中就把 agentState 置为 FINISHEDrun() 的 while 条件下一轮就会退出boolean terminated last.getResponses().stream() .anyMatch(r - doTerminate.equals(r.name())); if (terminated) { setAgentState(AgentState.FINISHED); }5. Manus 初始化ChatClient.builder 接到 TaoToken 兼容通道5.1 先从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建 Key原文第六节在测试前要配置 DashScope 的 Key这一步现在整个前移打开 TaoToken 注册账号在控制台创建一把 API Key顺手在模型广场里确认一下准备用哪个模型 ID。Key 不要写死在代码里用环境变量或者配置中心注入写进仓库的那把迟早要换。模型 ID 以模型广场当时的列表为准不要凭记忆写一个带日期后缀的名字。配置阶段只需要记住一对地址给人点的页面是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end填进工具的 Base URL 是 https://taotoken.net/api。两者不能混尤其不要把带查询参数的落地页地址塞进 base-url那样请求会直接打到网页路由上。5.2 application.yml 与代码两种接法最省事的是走配置文件Spring AI 的 OpenAI starter 会自动装配一个 ChatModelspring: ai: openai: base-url: https://taotoken.net/api api-key: YOUR_API_KEY chat: options: model: YOUR_MODEL_IDBase URL 只写到 https://taotoken.net/api 为止末尾不要手工补 /v1路径拼接交给 SDK。如果你更习惯显式构造也可以在配置类里自己 new 一个Bean public ChatModel chatModel( Value(${spring.ai.openai.base-url}) String baseUrl, Value(${spring.ai.openai.api-key}) String apiKey, Value(${spring.ai.openai.chat.options.model}) String model) { OpenAiApi api OpenAiApi.builder() .baseUrl(baseUrl) .apiKey(apiKey) .build(); return OpenAiChatModel.builder() .openAiApi(api) .defaultOptions(OpenAiChatOptions.builder().model(model).build()) .build(); }Manus 的构造函数不用大改只是把注入的模型换成上面这个再把 modelId 一起传给父类Component public class RagdollCatManus extends ToolCallAgent { public RagdollCatManus(ToolCallback[] availableTools, ChatModel chatModel, Value(${spring.ai.openai.chat.options.model}) String modelId) { super(availableTools, modelId); setName(RagdollCatManus); setSystemPrompt( You are a Java agent that plans, calls tools and reports results. ); setNextStepPrompt( Pick the most suitable tool for the current step, explain the result after each call, and call doTerminate when everything is done. ); ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new MyLogAdvisor()) .build(); setChatClient(chatClient); } }5.3 ChatOptions 从 DashScope 换成 OpenAI 兼容类型这一步最容易漏。原文 ToolCallAgent 的构造函数里写的是 DashScopeChatOptionsthink() 和 act() 里的chatOptions字段也是这个类型。通道换成 TaoToken 之后ChatModel 变成了 OpenAI 兼容实现继续传 DashScopeChatOptions 会直接编译失败或者更糟——运行时被忽略。把字段类型统一改成ChatOptions实现用OpenAiChatOptions工具手动的开关换成internalToolExecutionEnabled(false)think() 里.tools(availableTools)的行为就与原来一致了。6. test() 三轮日志怎么读出错又该怎么改6.1 第一轮到第三轮的日志特征测试类不用改还是调用 run() 并断言结果非空SpringBootTest class RagdollCatManusTest { Resource private RagdollCatManus ragdollCatManus; Test void test() { String result ragdollCatManus.run(帮我生成一份 Java 学习路线以 PDF 格式输出); Assertions.assertNotNull(result); } }通道配好之后日志应该是三段明显的节奏。第一轮MyLogAdvisor 打出请求紧接着 think 里出现「选择了 1 个工具」工具名是 scrapeWebPage参数里带一个 urlact 执行完工具返回抓取结果。第二轮模型基于上一轮的网页内容做总结这次选的是 generatePDF参数里能看到 content 和 fileName执行完得到本地文件路径。第三轮模型判断任务已完成选择 doTerminate参数为空act 里命中终止条件状态切到 FINISHEDrun() 退出循环并打一行「清理资源」。这三段日志和通道之间是有对应关系的think 里的「选择了 N 个工具」说明模型的 tool_calls 被正确解析act 里的工具返回说明 ToolCallingManager 拿到了完整 history。如果第一轮就直接「无需行动」问题多半不在你的 ReActAgent 分层。6.2 三类高频报错与对应改法第一类是 Key 相关。日志里出现 401 或 invalid api key先确认YOUR_API_KEY是不是从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建的那把再确认它有没有被引号、空格或换行污染。第二类是路径相关。请求打到 404基本上就是 base-url 后面多写了 /v1把 https://taotoken.net/api/v1 改回 https://taotoken.net/api 即可注意别把 UTM 参数也一起带进去。第三类是工具名相关。模型返回的 doTerminate 和 Tool 所在方法名大小写不一致时act() 里的终止判断永远为 false循环会一直跑到 maxStep 才停把方法名和描述里的写法对齐就好。还有一类不算报错但很磨人think 每轮都返回「选择了 0 个工具」。常见原因是模型 ID 填了一个不支持工具调用的型号或者 availableTools 数组是空的。前者去模型广场换一个支持 function calling 的模型后者检查 Manus 的构造函数有没有把工具真正注册进去。6.3 回到控制台核对这一次调用三轮日志跑通之后建议回控制台看一次用量把这次 test() 消耗的 token 和时间对一遍能确认请求确实经过了通道而不是被本地缓存或某个 mock 吞掉。这一步对长期跑自动化的项目很有意义因为你后面会加更多工具、更长提示词用量曲线比日志更早暴露异常。7. 跑通之后把这条通道固定在项目里7.1 用同一把 Key 在模型对话里发一条消息配置保存之后先去 TaoToken 模型对话 用同一把 Key 发一条普通消息确认模型 ID 和 Base URL 没写错。这一步排除的是「配置本身对不对」和智能体逻辑无关出结果很快。如果模型对话能回test() 却还是「选择了 0 个工具」那就把注意力放回 think() 和工具描述上。7.2 长期跑智能体就看 Coding Plan 和 API Keys如果这个 Open Manus 风格的 Java 智能体只是学习用默认额度基本够但要把它接进日常任务跑批量的 scrapeWebPage 或 generatePDF建议先看看 Coding Plan 的套餐是否合适再在 控制台 API Keys 里为不同环境各建一把 Key。做到这一步BaseAgent 里的那个 chatClient 字段才算真正稳定下来分层是骨架通道是血液两者都对了think/act 的循环才会有下一轮。
返回列表