
1. 从零跑通 Java SpringAI 智能体为什么模型入口要先统一很多同学在写 Java SpringAI 智能体的时候卡住的地方往往不是BaseAgent的抽象设计而是第一步——模型调用根本连不上。你照着示例把ChatClient建出来结果一跑就抛401或者报Connection refused再或者返回体里choices是空的。代码逻辑明明没问题问题出在模型入口这一层没有统一。这篇要解决的就是这件事用 TaoToken 作为统一的 Key 和 API 通道把 SpringAI 的ChatModel接进来然后在这个基础上把智能体的核心类——AgentState、BaseAgent、ReActAgent、ToolCallAgent、Manus——一层层落地最后启动项目验证对话和工具回调是不是真的生效。适合谁看已经会写 Spring Boot想用 Java 做智能体但被模型接入卡住的开发者或者你已经有一版代码但think()里拿不到toolCalls想搞清楚withProxyToolCalls(true)到底在干什么的人。我试过把模型入口散落在各个类里后来发现一旦要换模型或者换通道改起来非常痛苦。统一到一个 Base URL 一个 Key 之后ChatModel只建一次后面所有 Agent 共用这才是能维护的写法。TaoToken 在这里的角色很简单它提供一个兼容 OpenAI 协议的 API 入口SpringAI 的OpenAiChatModel可以直接指向它。你不需要改智能体的任何业务代码只需要把base-url、api-key、model三个值配对。官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址后面不加 UTM 参数配置里填的就是这个纯地址。下面按“配置 → 核心类 → 验证 → 排障”的顺序走每一段都能直接复制。2. TaoToken 前置准备拿到统一 Key 并配好 SpringAI 依赖在写智能体代码之前先把模型通道打通。这一步做完你后面调试ToolCallAgent的时候才不会把“模型连不上”和“工具调用逻辑写错”混在一起。2.1 申请 Key 与确认 Base URL进入控制台创建 API Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建完之后你会拿到一串以sk-开头的 Key。这个 Key 就是后面application.yml里要填的值。Base URL 用 https://taotoken.net/api 不要在后面拼/v1之外的路径SpringAI 的 OpenAI starter 会自己补/chat/completions。如果你手动加了/v1/chat/completions反而会变成双路径报 404。模型 ID 这块你在模型对话页面可以先试一下哪个模型响应正常地址是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。选一个支持工具调用function calling的模型因为ToolCallAgent依赖toolCalls字段如果模型不支持think()里永远拿到空列表智能体就不会执行act()。2.2 Maven 依赖SpringAI 的版本迭代比较快这里用 1.0.0 系列的 starter。pom.xml里加这几个dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyHutool 是为了StrUtil和CollUtilBaseAgent和ToolCallAgent里都用到了。Lombok 负责Data、Slf4j。2.3 application.yml 配置这是最关键的一段路径和字段名要和 SpringAI 的约定一致spring: ai: openai: base-url: https://taotoken.net/api api-key: sk-你的TaoToken密钥 chat: options: model: 你选定的模型ID temperature: 0.7注意base-url写的是https://taotoken.net/apiSpringAI 会在后面自动拼/v1/chat/completions。如果你填成https://taotoken.net/api/v1就会变成/api/v1/v1/chat/completions直接 404。这个坑我踩过报错信息是404 Not Found但日志里不会明确告诉你路径重复了得自己看请求 URL。配好之后Spring 容器里会自动有一个OpenAiChatModelBean类型是ChatModel。后面Manus的构造函数直接注入它就行。2.4 先单独验证模型通道在写智能体之前建议先写一个最小的 Controller 验证通道通不通RestController public class PingController { private final ChatModel chatModel; public PingController(ChatModel chatModel) { this.chatModel chatModel; } GetMapping(/ping) public String ping() { return chatModel.call(用一句话介绍你自己); } }启动后访问http://localhost:8080/ping如果返回一句正常的模型回复说明 Key、Base URL、模型 ID 三件套都对了。如果这里就报 401先别往下写智能体回到 2.1 检查 Key 是否复制完整、有没有多余空格。这一步通过之后模型入口就算统一好了。接下来所有 Agent 共用这一个ChatModel不再各自建连接。3. 可复制配置智能体核心类与工具调用代码实现这一章是全文的技术主体。按分层顺序来状态枚举 → 基础抽象类 → ReAct 行为抽象 → 工具调用实现 → 具体实例。每一层都能单独编译通过你可以边写边跑。3.1 AgentState 与 BaseAgent先定义状态枚举控制智能体的生命周期public enum AgentState { IDLE, RUNNING, FINISHED, ERROR }BaseAgent封装通用属性和主循环。这里的关键是run()方法里的状态检查——如果当前不是IDLE直接抛异常避免同一个 Agent 实例被并发调用导致上下文错乱。Data Slf4j public abstract class BaseAgent { private String name; private String prompt; private String nextStepPrompt; private AgentState state AgentState.IDLE; private int currentStep 0; private int maxStep 10; private ChatClient chatClient; private ListMessage messageList new ArrayList(); public String run(String userPrompt) { if (this.state ! AgentState.IDLE) { throw new RuntimeException(Agent is not idle); } if (StrUtil.isBlank(userPrompt)) { throw new RuntimeException(User prompt is empty); } this.state AgentState.RUNNING; messageList.add(new UserMessage(userPrompt)); ListString resultList new ArrayList(); try { for (int i 0; i maxStep state ! AgentState.FINISHED; i) { int stepNumber i 1; currentStep stepNumber; log.info(Step: {}/{}, stepNumber, maxStep); String stepResult step(); resultList.add(Step stepNumber : stepResult); } if (currentStep maxStep) { this.state AgentState.FINISHED; } return StrUtil.join(\n, resultList); } catch (Exception e) { this.state AgentState.ERROR; log.error(Agent error, e); return Error: e.getMessage(); } finally { cleanup(); } } public abstract String step(); public void cleanup() { } }messageList是自主维护的上下文不依赖 SpringAI 内置的会话记忆。这一点很重要因为后面ToolCallAgent要禁用内置工具调用自己管理消息历史。3.2 ReActAgent思考与行动分离ReActAgent把step()拆成think()和act()两个抽象方法形成“先决策、后执行”的闭环EqualsAndHashCode(callSuper true) Data Slf4j 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()返回true表示需要调用工具返回false表示本轮结束。这个布尔值是整个智能体流程的开关。3.3 ToolCallAgent工具调用核心这是最核心的一段。注意构造函数里DashScopeChatOptions.builder().withProxyToolCalls(true)这一行——它的作用是禁用 SpringAI 内置的工具调用执行逻辑改由我们自己通过ToolCallingManager来执行。为什么要这么做因为内置逻辑会把工具调用和消息记录绑死我们想自己控制messageList的写入时机尤其是think()阶段拿到toolCalls后先不记录助手消息等act()执行完再统一更新上下文。EqualsAndHashCode(callSuper true) Data Slf4j public class ToolCallAgent extends ReActAgent { private final ToolCallback[] tools; private ChatResponse toolCallChatResponse; private final ToolCallingManager toolCallingManager; private final ChatOptions chatOptions; public ToolCallAgent(ToolCallback[] tools) { super(); this.tools tools; this.toolCallingManager ToolCallingManager.builder().build(); this.chatOptions DashScopeChatOptions.builder() .withProxyToolCalls(true) .build(); } Override public boolean think() { if (StrUtil.isNotBlank(getNextStepPrompt())) { getMessageList().add(new UserMessage(getNextStepPrompt())); } ListMessage messageList getMessageList(); Prompt prompt new Prompt(messageList, this.chatOptions); try { ChatResponse chatResponse getChatClient().prompt(prompt) .system(getPrompt()) .tools(tools) .call() .chatResponse(); this.toolCallChatResponse chatResponse; AssistantMessage assistantMessage chatResponse.getResult().getOutput(); ListAssistantMessage.ToolCall toolCalls assistantMessage.getToolCalls(); String result assistantMessage.getText(); log.info(getName() 思考: result); String collect toolCalls.stream() .map(tc - String.format(工具调用: %s参数: %s, tc.name(), tc.arguments())) .collect(Collectors.joining(\n)); log.info(getName() 工具调用: collect); if (toolCalls.isEmpty()) { getMessageList().add(assistantMessage); return false; } else { return true; } } catch (Exception e) { log.error(getName() 错误: e.getMessage()); getMessageList().add(new AssistantMessage(发生错误 e.getMessage())); return false; } } Override public String act() { if (!toolCallChatResponse.hasToolCalls()) { return 没有工具; } Prompt prompt new Prompt(getMessageList(), this.chatOptions); ToolExecutionResult toolExecutionResult toolCallingManager.executeToolCalls(prompt, toolCallChatResponse); setMessageList(toolExecutionResult.conversationHistory()); ToolResponseMessage toolResponseMessage (ToolResponseMessage) CollUtil .getLast(toolExecutionResult.conversationHistory()); String result toolResponseMessage.getResponses().stream() .map(r - 工具 r.name() 返回: r.responseData()) .collect(Collectors.joining(\n)); boolean doTerminate toolResponseMessage.getResponses().stream() .anyMatch(r - r.name().equals(doTerminate)); if (doTerminate) { this.setState(AgentState.FINISHED); } log.info(getName() 工具调用结果: result); return result; } }这里有个细节think()里如果toolCalls不为空不把assistantMessage加进messageList。原因是executeToolCalls内部会根据ChatResponse自动把助手消息和工具响应消息一起写进conversationHistory如果你提前加了一次上下文里就会出现重复的助手消息模型下一轮会困惑。3.4 工具定义与 Manus 实例先定义一个最简单的工具用来验证回调是否生效Component public class TimeTool implements ToolCallback { Override public ToolDefinition getToolDefinition() { return ToolDefinition.builder() .name(getCurrentTime) .description(获取当前系统时间) .inputSchema( {type:object,properties:{},required:[]} ) .build(); } Override public String call(String toolInput) { return LocalDateTime.now().toString(); } }然后写Manus把所有工具注入进去Component public class Manus extends ToolCallAgent { public Manus(ToolCallback[] allTools, ChatModel chatModel) { super(allTools); this.setName(yuManus); this.setPrompt( You are YuManus, an all-capable AI assistant. You have various tools at your disposal. ); this.setNextStepPrompt( Based on user needs, select the most appropriate tool. If you want to stop, use the terminate tool. ); this.setMaxStep(20); ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new MyLoggerAdvisor()) .build(); this.setChatClient(chatClient); } }MyLoggerAdvisor是一个简单的日志 Advisor实现CallAroundAdvisor接口在请求前后打印日志即可。它的作用是让你在控制台看到每次模型调用的耗时和返回摘要排查toolCalls为空时非常有用。到这里三件套就齐了Base URL 是https://taotoken.net/apiKey 是sk-开头那串Model ID 是你 2.1 里选定的。这三个值只在application.yml里出现一次所有 Agent 共用。4. 验证请求启动后确认对话与工具回调生效代码写完启动项目。这一章给你具体的验证动作不是“连上后就能用”这种空话。4.1 验证普通对话写一个 Controller 暴露ManusRestController RequestMapping(/agent) public class AgentController { private final Manus manus; public AgentController(Manus manus) { this.manus manus; } GetMapping(/chat) public String chat(RequestParam String q) { return manus.run(q); } }启动后访问http://localhost:8080/agent/chat?q你好介绍一下你自己。预期结果是返回一段模型回复控制台打印Step: 1/20和思考:...。如果这一步正常说明ChatClient和 TaoToken 通道是通的。4.2 验证工具回调访问http://localhost:8080/agent/chat?q现在几点了。预期行为控制台先打印思考:后面跟一段文本然后打印工具调用: 工具调用: getCurrentTime参数: {}。接着打印工具调用结果: 工具getCurrentTime返回:2025-...。最后返回结果里包含时间字符串。如果你看到工具调用:后面是空的说明模型没有返回toolCalls。这时候先确认你选的模型支持 function calling再确认think()里.tools(tools)有没有加上。tools数组是通过构造函数注入的Spring 会自动收集所有ToolCallback类型的 Bean前提是你的工具类加了Component。4.3 验证终止逻辑act()里检测到doTerminate工具会设置FINISHED。你可以加一个TerminateTool在nextStepPrompt里告诉模型“任务完成时调用 terminate”。然后发一个简单问题观察控制台是否在某一轮之后不再打印新的Step。如果maxStep是 20 但实际只跑了 2 步就停了说明终止逻辑生效了。4.4 观察上下文增长在think()里加一行log.info(当前消息数: {}, getMessageList().size())。正常情况每轮工具调用后消息数会增加 2助手消息 工具响应消息。如果发现消息数不增长说明setMessageList(toolExecutionResult.conversationHistory())没有正确执行检查act()里toolCallChatResponse.hasToolCalls()是否为 true。5. 本篇常见错排查401、toolCalls 为空与路径重复这一章对照真实报错来。你遇到问题时先在这里找对应条目。5.1 401 Unauthorized报错原文通常是401 Unauthorized: Incorrect API key provided。原因有三个Key 复制时带了空格Key 已经失效application.yml里api-key字段名写错。检查方式是打开 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 重新复制一次粘贴到 yml 时注意不要带换行。如果用的是环境变量确认SPRING_AI_OPENAI_API_KEY有没有被系统里其他值覆盖。5.2 404 Not Found 或路径重复报错原文404 Not Found日志里请求 URL 是https://taotoken.net/api/v1/v1/chat/completions。这是base-url填成了https://taotoken.net/api/v1导致的。改成https://taotoken.net/api即可。SpringAI 的 OpenAI starter 默认会补/v1/chat/completions你只需要给到/api这一层。5.3 toolCalls 为空act() 不执行控制台打印工具调用:后面空白think()返回 false智能体直接结束。原因可能是模型不支持 function callingtools数组为空工具类没加ComponentwithProxyToolCalls(true)没生效。先确认工具 Bean 被扫描到可以在启动日志里搜ToolCallback。再确认模型 ID 是支持工具调用的版本。如果都正常把think()里的chatResponse完整打印出来看getResult().getOutput().getToolCalls()到底返回了什么。5.4 reading choices 相关报错报错原文类似Cannot read field choices because ... is null。这通常发生在返回体解析阶段说明 API 返回的不是标准 OpenAI 格式。检查base-url是否指向了正确的入口以及模型 ID 是否拼写正确。如果模型 ID 不存在有些网关会返回错误结构SpringAI 解析时就会在choices字段上抛空指针。5.5 OAuth 或 local proxy failed如果你在日志里看到OAuth或local proxy failed说明请求没有走到 TaoToken 的 API 入口而是被本地某个代理拦截了。检查你的系统代理设置或者application.yml里有没有多余的proxy配置。SpringAI 默认不走代理如果环境变量里有HTTP_PROXY需要排除taotoken.net域名。5.6 工具执行后上下文重复表现是模型第二轮开始胡言乱语或者重复调用同一个工具。原因是think()里把assistantMessage加进了messageList而executeToolCalls又加了一次。按 3.3 的写法toolCalls非空时不加助手消息交给executeToolCalls统一处理。6. 语义一致 CTA把统一 Key 用到长期编码与 Agent 场景代码跑通之后你手里这套BaseAgent → ReActAgent → ToolCallAgent → Manus的结构是可以复用的。新增一个智能体只需要继承ToolCallAgent换一套prompt和nextStepPrompt注入不同的工具数组就行。模型入口始终是application.yml里那一个 Base URL 和一个 Key。如果你打算把这个智能体用到长期编码或者多轮 Agent 任务上可以看一下 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合需要持续调用模型、跑多步工具链的场景不用每次单独申请额度。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有针对 SpringAI 和其他框架的配置说明遇到字段名不确定的时候可以对照。模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 用来快速试哪个模型对工具调用的支持更稳。最后给一个实用技巧把maxStep设成 20 之后如果某次任务跑了 18 步还没结束大概率是nextStepPrompt里没有明确告诉模型什么时候该停。在提示词里写清楚“任务完成后必须调用 terminate 工具”比单纯调大maxStep更有效。这个是我在调ToolCallAgent时改了好几次才稳定的点。