1. 从“能调通”到“能管住”:Java 后端的多模型调用困局
如果你正在用 Spring Boot 写业务系统,最近又被要求“加个 AI 能力”,大概率会经历这么一段心路:先拿 Spring AI 的 ChatClient 调通一个模型,跑起来挺爽;接着产品说“再支持一下另一个模型做对比”,于是你复制一份配置;再后来测试环境、预发环境、生产环境各一套 Key,模型供应商还换了两家——配置文件里的api-key开始满天飞,谁也不敢删,谁也不知道哪个还在用。
这就是 Java 团队做 AI 集成时最真实的痛点:调用链路能跑通,但多模型 Key 和 Base URL 管不住。Spring AI 2.0 把 MCP(Model Context Protocol)能力合并进核心之后,Java 侧终于可以像写普通 Service 一样声明工具、暴露资源,甚至让模型在对话里直接调用你后端的业务方法。但工具一多、模型一多,问题就从“怎么写注解”变成了“怎么统一出口”。
我试过在一个 Spring Boot 3.3 的项目里同时接三家模型,结果光是application.yml就写了四套spring.ai.openai.*配置,切换模型要改代码、重启服务,测试同学想验证一个 MCP 工具调用还得找我要 Key。后来把出口统一到 TaoToken 的 API 通道上,Base URL 和 Key 收敛成一份,模型 ID 通过配置项切换,才算把这条链路理顺。
这篇就按“本地跑通端到端”的目标来写:从依赖版本、application.yml配置,到 MCP 工具声明、一次真实的工具调用验证,再到几个我踩过的报错。适合已经在用 Spring Boot、想给项目加 AI 能力、又不想把 Key 管理搞成一团乱麻的 Java 后端。
2. 前置准备:Spring AI 2.0 与 TaoToken 通道怎么接
先说清楚这一节要解决什么:让你的 Spring Boot 项目有一个统一的模型出口,后面不管加多少个模型、多少个 MCP 工具,都走同一个 Base URL 和同一把 Key。
2.1 版本对齐,别在 M 版本上翻车
Spring AI 2.0 目前处于里程碑阶段,MCP 注解和 MCP Apps 的 metadata 支持是在 2.0.0-M3 之后才逐步稳定的。如果你用 M2 或更早,@McpServer、@ToolMetadata这些注解会直接编译不过。建议直接对齐到 2.0.0-M4 及以上。
Maven 里需要引入 Spring AI 的 BOM 和 starter:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>2.0.0-M4</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> </dependency> </dependencies>注意spring-ai-starter-mcp-server这个 starter,M4 之后 MCP Server 能力已经并入核心,不需要再单独引老版本的spring-ai-mcp。
2.2 为什么用 TaoToken 做统一出口
Spring AI 默认的 OpenAI starter 会把请求打到官方地址,但实际项目里你往往需要:
- 一个 Key 覆盖多个模型,不用为每个供应商单独申请;
- Base URL 可配置,本地、测试、生产用不同通道;
- 模型 ID 通过配置切换,不改代码。
TaoToken 提供的就是这样一个兼容 OpenAI 协议的 API 通道。你拿到一把 Key,配好 Base URL,Spring AI 的 OpenAI starter 就能直接指向它。模型侧通过model参数指定,比如对话模型、代码模型各用各的 ID,但出口是同一个。
需要提前准备的东西:
- 一个 TaoToken 账号,在控制台创建 API Key;
- 记下 Base URL:
https://taotoken.net/api; - 确认你要用的模型 ID(在模型列表里能看到)。
控制台入口在这里,创建 Key 的时候建议按环境分,比如spring-ai-dev、spring-ai-prod,方便后面排查是谁在调:
API Key 管理:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=springai_mcp_java
如果你还没决定用哪个模型,可以先在模型对话页面试几条 prompt,确认返回格式和延迟符合预期,再写进配置:
模型对话体验:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=springai_mcp_java
2.3 项目结构建议
我习惯把 AI 相关的东西单独放一个包,避免和业务代码混在一起:
com.example.demo ├── ai │ ├── config // ChatClient、MCP 配置 │ ├── tool // MCP 工具声明 │ └── resource // MCP Apps 的 UI 资源 ├── service // 业务 Service,被工具调用 └── DemoApplication.java这样后面加工具、改模型,改动范围可控。MCP 工具本质上就是 Spring Bean,@Tool标注的方法会被框架扫描并注册到 MCP Server 上,模型在对话中决定要不要调用。
3. 可复制配置:application.yml 与 MCP 工具声明
这一节是全文的核心,所有片段都可以直接复制到你的项目里改。
3.1 application.yml 里的 Base URL 与 Key
Spring AI 的 OpenAI starter 读取spring.ai.openai前缀。把base-url指向 TaoToken 的 API 地址,api-key用环境变量注入,避免硬编码进 Git:
spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: ${TAOTOKEN_CHAT_MODEL:gpt-4o-mini} temperature: 0.7 embedding: options: model: ${TAOTOKEN_EMBED_MODEL:text-embedding-3-small} mcp: server: name: demo-mcp-server version: 1.0.0 protocol: STREAMABLE几个关键点:
base-url结尾不要带/v1,Spring AI 的 OpenAI 客户端会自己拼路径。如果你写成https://taotoken.net/api/v1,请求会变成/api/v1/v1/chat/completions,直接 404。
api-key用${TAOTOKEN_API_KEY}占位,本地开发在 IDE 的 Run Configuration 里配环境变量,生产环境用 K8s Secret 或配置中心注入。这样 Key 不会进代码仓库。
model也做成可覆盖的,默认给一个便宜的对话模型,需要换模型时改环境变量即可,不用动 yml。
mcp.server.protocol用STREAMABLE,这是 MCP 的流式传输协议,Spring AI 2.0 里对它的支持比较完整。如果你用老版本的 SSE,部分客户端会连不上。
3.2 声明一个 MCP 工具
下面这个例子模拟“查询订单状态”的工具,业务 Service 先写好:
@Service public class OrderService { public OrderStatus queryStatus(String orderId) { // 真实项目里查数据库 return new OrderStatus(orderId, "SHIPPED", LocalDateTime.now()); } }然后声明 MCP 工具。注意@McpServer标在类上,@Tool标在方法上,description会作为工具描述发给模型,写清楚一点,模型才知道什么时候调:
@Component @McpServer public class OrderMcpTools { private final OrderService orderService; public OrderMcpTools(OrderService orderService) { this.orderService = orderService; } @Tool(name = "queryOrderStatus", description = "根据订单号查询订单当前状态,返回状态码和更新时间") public OrderStatus queryOrderStatus( @ToolParam(description = "订单号,例如 ORD-20240501-001") String orderId) { return orderService.queryStatus(orderId); } }@ToolParam的 description 同样重要,模型靠它理解参数含义。参数名和类型也要清晰,别用String a这种。
3.3 配置 ChatClient 并注册工具
Spring AI 2.0 里 ChatClient 通过 Builder 构建,工具可以全局注册,也可以在单次调用时挂载:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder, OrderMcpTools orderMcpTools) { return builder .defaultSystem("你是一个电商后台助手,可以查询订单状态。") .defaultTools(orderMcpTools) .build(); } }defaultTools会把工具注册到每次对话里,模型根据用户问题决定是否调用。如果你工具很多,建议按场景拆分多个 ChatClient,避免一次塞太多工具导致模型选择困难。
3.4 如果要用 MCP Apps 返回 UI
MCP Apps 允许工具返回交互式界面。在 Spring AI 2.0 里通过@ToolMetadata指定 UI 资源:
@Tool(name = "showOrderDashboard", description = "展示订单看板") @ToolMetadata(ui = @UiResource(uri = "ui://order-dashboard")) public String showOrderDashboard() { return "dashboard-opened"; } @Resource(uri = "ui://order-dashboard") public String orderDashboardHtml() { return """ <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> </head> <body> <canvas id="orderChart"></canvas> <script> // 通过 MCP 调用工具拿数据后渲染 </script> </body> </html> """; }资源 URI 必须用ui://协议,写成http://或文件路径都不会被识别。如果 UI 里要加载外部 CDN,记得配 CSP,否则 sandboxed iframe 会拦掉:
@ToolMetadata( ui = @UiResource( uri = "ui://order-dashboard", csp = @Csp( scriptSrc = {"'self'", "https://cdn.jsdelivr.net"}, styleSrc = {"'self'", "'unsafe-inline'"} ) ) )CSP 这块是踩坑重灾区,后面第 5 节会展开。
4. 验证请求:跑通一次端到端 MCP 工具调用
配置写完,怎么确认真的通了?分两步:先验证模型通道,再验证 MCP 工具调用。
4.1 先用一个 REST 接口验证模型通道
写一个最简单的 Controller,确认 Base URL 和 Key 没问题:
@RestController @RequestMapping("/ai") public class AiController { private final ChatClient chatClient; public AiController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/ping") public String ping(@RequestParam String q) { return chatClient.prompt() .user(q) .call() .content(); } }启动项目,用 curl 打一下:
curl "http://localhost:8080/ai/ping?q=你好,用一句话介绍你自己"如果返回一段正常的模型回复,说明base-url、api-key、model三件套都对。如果报 401,先检查环境变量有没有注入成功;如果报连接超时,检查base-url是不是写成了带/v1的地址。
4.2 验证 MCP 工具调用
工具调用的验证要稍微绕一点,因为模型是否调用工具取决于它的判断。最稳的办法是给一个明确会触发工具的 prompt:
curl "http://localhost:8080/ai/ping?q=帮我查一下订单 ORD-20240501-001 现在什么状态"预期结果是模型返回类似“订单 ORD-20240501-001 当前状态为 SHIPPED,更新时间……”的内容。这背后发生的事是:
- Spring AI 把
queryOrderStatus的工具描述发给模型; - 模型判断需要调用工具,返回 tool_call;
- Spring AI 执行你的 Java 方法,拿到
OrderStatus; - 把结果回传给模型,模型生成自然语言回复。
如果你想确认工具真的被调用了,在OrderService.queryStatus里打一行日志:
public OrderStatus queryStatus(String orderId) { log.info("MCP tool invoked, orderId={}", orderId); return new OrderStatus(orderId, "SHIPPED", LocalDateTime.now()); }看到日志输出,就说明端到端链路通了。
4.3 用 MCP Inspector 单独验证 Server
如果你想把 MCP Server 和模型解耦开验证,可以用 MCP Inspector 这类客户端工具直连你的 Server。Spring AI 2.0 的 MCP Server 默认暴露 streamable 端点,启动后日志里会打印监听地址。用 Inspector 连上后,能看到注册的工具列表,直接调用queryOrderStatus,不经过模型。
这一步的好处是:当工具调用失败时,你能快速判断是模型没选对工具,还是工具本身执行报错。
4.4 切换模型验证统一出口
前面说过,TaoToken 的价值在于一个出口覆盖多个模型。验证方式很简单,改环境变量重启:
export TAOTOKEN_CHAT_MODEL=另一个模型ID再打一次/ai/ping,如果返回正常,说明模型切换不需要改任何代码和 Base URL。这就是统一出口的意义——Key 和地址收敛,模型 ID 变成配置项。
5. 本篇常见报错排查:401、local proxy failed 与 choices 解析
这一节按真实报错来,都是我或身边同事遇到过的。
5.1 401 Unauthorized
最常见的 401 有两种原因。一是环境变量没生效,${TAOTOKEN_API_KEY}解析成了空字符串,请求头里Authorization: Bearer后面是空的。排查方法是在启动日志里打印一下配置(注意别把完整 Key 打出来):
@PostConstruct public void checkConfig() { log.info("base-url configured: {}", baseUrl); log.info("api-key present: {}", apiKey != null && !apiKey.isBlank()); }二是 Key 本身失效或被删。去控制台确认 Key 状态,必要时重新生成一把。
5.2 local proxy failed 或连接被拒
这个报错通常出现在你本地配了某些网络工具,或者公司网络有出口限制。Spring AI 的 HTTP 客户端会走 JVM 的代理设置,如果代理配置不对,就会报local proxy failed或Connection refused。
排查顺序:
- 检查 JVM 启动参数里有没有
-Dhttp.proxyHost之类的设置; - 检查系统环境变量
HTTP_PROXY、HTTPS_PROXY; - 用 curl 直接打
https://taotoken.net/api看能不能通。
如果 curl 能通但 Java 不通,基本就是 JVM 代理配置的问题,清掉相关参数即可。
5.3 解析 choices 失败 / reading choices 报错
完整报错类似Error while extracting response for type ... reading choices。这通常意味着返回的 JSON 结构和 Spring AI 期望的不一致。可能原因:
base-url写错,请求打到了某个返回 HTML 的地址,解析自然失败;- 模型 ID 不存在,通道返回了错误结构;
- 请求路径被重复拼接,比如
/api/v1/v1/chat/completions。
排查方法:打开 Spring AI 的 debug 日志,看实际请求的 URL 和返回体:
logging: level: org.springframework.ai: DEBUG org.springframework.web.client: DEBUG看到实际 URL 后,对照base-url配置,基本一眼能定位。
5.4 MCP 工具没被调用
模型回复了文字,但没有触发工具。先确认工具描述是否清晰,description太模糊模型不会选。其次确认defaultTools有没有真的注册上,可以在启动日志里看 MCP Server 注册的工具列表。
还有一个容易忽略的点:如果你用的是流式调用(.stream()),部分版本对工具调用的支持不完整,先用.call()验证。
5.5 OAuth 相关报错
如果你在 MCP Server 上开了 OAuth 保护,客户端连接时会报 OAuth 相关错误。本地验证阶段建议先关掉鉴权,把链路跑通再加。Spring AI 2.0 的 MCP Server 配置里可以显式关闭:
mcp: server: auth: enabled: false生产环境再按需开启,并配好 token 校验。
5.6 三件套对照表
出现任何连接类问题,先对照这张表检查:
| 配置项 | 正确值 | 常见错误 |
|---|---|---|
| Base URL | https://taotoken.net/api | 多写/v1、写成首页地址 |
| API Key | 控制台生成的 Key,环境变量注入 | 硬编码、Key 失效、变量名拼错 |
| Model ID | 模型列表里的准确 ID | 拼写错误、用了不存在的模型 |
Base URL、Key、Model ID 这三件套对齐,90% 的连接问题都能解决。
6. 把链路收进配置:长期编码与 Agent 场景的下一步
本地跑通只是第一步。真正上项目之后,你会遇到更多场景:多个环境共用一套代码、团队里每个人都要本地调试、CI 里要跑集成测试。这时候统一出口的价值会更明显——Base URL 和 Key 收敛成环境变量,模型 ID 按环境覆盖,代码里不出现任何供应商相关的硬编码。
如果你的项目开始往 Agent 方向走,比如让模型连续调用多个工具完成一个任务,建议把 Coding Plan 这类长期方案纳入考虑,按用量规划比临时申请 Key 更可控:
Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=springai_mcp_java
接入文档里有各语言客户端的完整示例,Java 侧如果遇到 starter 版本兼容问题,可以对照文档里的依赖矩阵排查:
接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=springai_mcp_java
最后给一个实用建议:把application.yml里的模型 ID 全部做成环境变量,本地用便宜的模型调试,生产用能力更强的模型。这样团队里新人拉下代码,配好 Key 就能跑,不用问任何人“这个模型 ID 填什么”。链路跑通之后,剩下的就是往工具里加业务逻辑了。