Spring AI alibaba 智能体的 ChatClient 配 TaoToken:让 Open Manus 的 think/act 循环跑通
2026/9/17 0:31:12 网站建设 项目流程

Spring AI alibaba 智能体写到最后一步,卡住我的不是 Open Manus 那套 BaseAgent / ReActAgent / ToolCallAgent 分层,而是 ChatClient 的通道:think 和 act 循环要么第一轮就返回 false,要么工具名对不上。TaoToken 正是在这个位置接进来的——去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建一把 Key,把 Base URL 填成 https://taotoken.net/api,日志里的「思考 / 选择工具 / 执行工具」才一轮一轮跑出来。

这篇不重新讲一遍 Spring AI 怎么用,而是把 Open Manus 风格的四层智能体照着写完之后,单独把 ChatClient 这一环拆开看:它在 BaseAgent 里只是一个字段,在 ToolCallAgent 里却是真正发请求的地方。原来的写法里,BaseAgent 持有 DashScopeChatModel,ToolCallAgent 里塞 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 为什么容易卡住验证

原来的配置里,模型走 DashScope,Key 从另一套控制台申请,Base URL 也是 DashScope 自己的 endpoint。写完四层之后你会发现一个尴尬的局面:run() 的第一轮日志只有一句「本次无需使用工具」,第二轮根本没发生。此时你分不清到底是 think() 的逻辑写错了、工具描述写得不清楚,还是这条通道本身没把 tool_calls 带回来。

把 ChatClient 接成 TaoToken 的兼容通道之后,问题被收敛成两个可排查的点:Key 是不是从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建的那把,Base URL 是不是写成了 https://taotoken.net/api(末尾不带 /v1)。剩下的 think/act 逻辑本身可以慢慢调。这也是「先定通道,再调循环」这个顺序的由来:通道不稳,循环里的每一步都是雾里看花。

2. BaseAgent:ChatClient 字段和 AgentState 一起写在基类里

2.1 AgentState 枚举与 memoryMessages 的定位

先把状态枚举定死,后面 act() 里判断任务是否结束时要用到它。IDLE、RUNNING、FINISHED、ERROR 四个值够了,不要为了「看起来完整」多加状态,多出来的状态最后都会变成没人处理的死分支。

public enum AgentState { IDLE, RUNNING, FINISHED, ERROR }

BaseAgent 的字段里,有两个是后面调通道时最容易被忽略的:chatClientmemoryMessages。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 List<Message> 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)); List<String> 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,再决定要不要 act

ReActAgent 只做一件事:把 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(); List<AssistantMessage.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 返回的 conversationHistory

act() 比 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 置为 FINISHED,run() 的 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_source=taotoken_aicg_blog_end 创建 Key

原文第六节在测试前要配置 DashScope 的 Key,这一步现在整个前移:打开 TaoToken 注册账号,在控制台创建一把 API Key,顺手在模型广场里确认一下准备用哪个模型 ID。Key 不要写死在代码里,用环境变量或者配置中心注入,写进仓库的那把迟早要换。模型 ID 以模型广场当时的列表为准,不要凭记忆写一个带日期后缀的名字。

配置阶段只需要记住一对地址:给人点的页面是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end,填进工具的 Base URL 是 https://taotoken.net/api。两者不能混,尤其不要把带查询参数的落地页地址塞进 base-url,那样请求会直接打到网页路由上。

5.2 application.yml 与代码两种接法

最省事的是走配置文件,Spring AI 的 OpenAI starter 会自动装配一个 ChatModel:

spring: ai: openai: base-url: https://taotoken.net/api api-key: YOUR_API_KEY chat: options: model: YOUR_MODEL_ID

Base 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 的构造函数里写的是 DashScopeChatOptions,think() 和 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,参数里带一个 url;act 执行完,工具返回抓取结果。第二轮,模型基于上一轮的网页内容做总结,这次选的是 generatePDF,参数里能看到 content 和 fileName;执行完得到本地文件路径。第三轮,模型判断任务已完成,选择 doTerminate,参数为空,act 里命中终止条件,状态切到 FINISHED,run() 退出循环并打一行「清理资源」。

这三段日志和通道之间是有对应关系的:think 里的「选择了 N 个工具」说明模型的 tool_calls 被正确解析;act 里的工具返回说明 ToolCallingManager 拿到了完整 history。如果第一轮就直接「无需行动」,问题多半不在你的 ReActAgent 分层。

6.2 三类高频报错与对应改法

第一类是 Key 相关。日志里出现 401 或 invalid api key,先确认YOUR_API_KEY是不是从 https://taotoken.net/?utm_source=taotoken_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 的循环才会有下一轮。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询