Administrator
发布于 2025-03-02 / 1076 阅读
7

Spring AI 1.x 深入:Model Context Protocol 集成

三十多个工具方法散在各处

去年底我们把内部运维助手接上了大模型,能查订单、查库存、重启任务。当时图快,工具方法用 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约 480380ms96%
23约 2100620ms91%
41约 39001050ms78%

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 集成》里同一个坑绊住,回来翻这篇,能省半小时。

参考