三十多个工具方法散在各处
去年底我们把内部运维助手接上了大模型,能查订单、查库存、重启任务。当时图快,工具方法用 Spring AI 的 @Tool 直接写在各个业务服务里,谁需要谁加。到 2025 年 2 月一数,散在 6 个服务里总共 37 个工具方法,问题就来了:网关那边的对话服务要用库存工具,得把 InventoryService 整个依赖拷一份;工具描述改了,另外五个服务不知道;测试环境想 mock 一个工具,得改代码重启。
同事在周会上问了一句:"能不能把这些工具独立部署,谁要用谁连上去?" 这句话正好戳中 MCP 要解决的问题。
MCP 到底解决什么
Model Context Protocol 是 Anthropic 2024 年 11 月开源的一个协议,讲白了就是给"大模型调工具"定了一套传输和发现的标准。它有三个角色:
- MCP Server:暴露工具、资源、提示词模板的一方,独立进程;
- MCP Client:连上一个或多个 Server,把工具列表喂给模型;
- Host:真正跟用户对话的应用,比如我们的运维助手。
关键点在"发现":Client 连上 Server 后会调 tools/list 拿到全部工具的 JSON Schema,模型侧不需要任何硬编码。以前我们是把工具签名写死在 prompt 或者 Java 接口里,现在这层变成了运行时协商。
需要说明的是,我写这篇时 Spring AI 刚发到 1.0.0-M6(2025 年 2 月),MCP 相关模块还在 milestone,API 名字跟 GA 之后会有出入。生产上我们只把只读类工具放到 MCP Server,写操作还留在服务内部,这是刻意的风险控制。
把库存服务改造成 MCP Server
先加依赖,Spring AI 的 milestone 仓库要单独配:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0-M6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
工具方法直接写在 Spring Bean 上,这是我觉得 Spring AI 做得最顺手的地方——不用额外写适配层,业务 Service 加个注解就行:
@Service
public class InventoryTool {
private final InventoryRepository repo;
@Tool(description = "按 SKU 编码查询当前可用库存数量。"
+ "仅当用户提供了明确的 SKU 编码时调用,不要用它做模糊搜索。")
public StockDTO queryStock(
@ToolParam(description = "SKU 编码,形如 SKU-20250113-001") String sku) {
return repo.findAvailable(sku)
.orElseThrow(() -> new SkuNotFoundException(sku));
}
}
然后注册一个 ToolCallbackProvider,它负责扫描 Bean 上的 @Tool 方法:
@Bean
public ToolCallbackProvider inventoryTools(InventoryTool inventoryTool) {
return MethodToolCallbackProvider.builder()
.toolObjects(inventoryTool)
.build();
}
配置走 SSE 传输(stdio 适合本地进程,跨网络部署必须用 SSE):
spring.ai.mcp.server.name=inventory-mcp
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.sse-message-endpoint=/mcp/message
工具描述决定调用成功率
这里踩了最狠的一个坑。我第一版描述只写了"查询库存",Qwen2.5-72B 在用户问"上海仓还剩多少货"时也去调,参数填了个仓库名,直接抛类型转换异常。后来我们把描述当成接口契约来写——明确前提条件、参数格式、不适用边界,改完之后工具误调用率从 17% 降到 4%。
一条经验:描述里写清楚"什么时候不要调用",比写清楚"什么时候调用"更有效。
Client 侧:多 Server 聚合
对话服务加 client starter,配置里列出所有要连的 Server:
spring.ai.mcp.client.sse.connections.inventory.url=http://inventory-svc:8080
spring.ai.mcp.client.sse.connections.ops.url=http://ops-svc:8080
spring.ai.mcp.client.sse.connections.bi.url=http://bi-svc:8080
Spring Boot 自动配置会把所有 Server 的工具收集到一个 ToolCallbackProvider 里,直接挂到 ChatClient:
@Bean
ChatClient opsChatClient(ChatModel chatModel, ToolCallbackProvider mcpTools) {
return ChatClient.builder(chatModel)
.defaultToolCallbacks(mcpTools)
.build();
}
启动时日志会打出每个 Server 连上了多少工具:
Received tools/list notification: server=inventory-mcp, tools=6
Received tools/list notification: server=ops-mcp, tools=11
Registered 23 MCP tools from 3 servers in 412ms
工具数量暴涨的副作用
工具一多,prompt 里的工具描述就长。我们实测:
| 工具数 | 工具描述占用 token | 首 token 延迟 | 选对工具的比例 |
|---|---|---|---|
| 6 | 约 480 | 380ms | 96% |
| 23 | 约 2100 | 620ms | 91% |
| 41 | 约 3900 | 1050ms | 78% |
41 个工具时准确率掉得肉眼可见,模型开始挑名字像的工具乱调。解决办法是按意图先做一层路由——用一个轻量模型(我们用的 Qwen2.5-7B,本地部署)先判断用户意图属于哪个域,只把该域的工具喂给主模型。加路由后首 token 延迟回到 430ms,准确率回到 94%。MCP 解决了工具发现的工程问题,但没解决模型的选择困难症。
生产上要注意的几件事
- 超时要分层设:MCP 调用是同步 HTTP,默认的
RestClient超时是无限。我们给每个 Server 单独设了 3s 连接、10s 读取,超时的工具直接返回错误文本给模型,让它换个思路,而不是把整个对话拖死; - Server 挂了不等于助手挂了:Client 启动时会尝试连接所有 Server,某个 Server 不可用会导致整个
ToolCallbackProvider初始化失败。我们改成了启动容错,连不上的 Server 记 warn 日志跳过,运行时再重试; - 版本对齐很痛:M6 里 MCP Java SDK 用的是 0.8.1,我们自己引了 0.9.0 想用新特性,结果
tools/list的响应结构不兼容,调了半天才发现是依赖冲突,最后用mvn dependency:tree定位,强制锁回 0.8.1; - 权限别指望协议:MCP 现在没有成熟的鉴权规范,我们在网关层加了 tool 白名单,不同角色的用户能看到不同工具子集,这点必须在 Host 侧做。
就写到这。如果哪天你也被《Spring AI 1.x 深入:Model Context Protocol 集成》里同一个坑绊住,回来翻这篇,能省半小时。