我用 Spring AI + MCP 把公司内部系统接给了 AI,过程没想象中顺利

起因是一句抱怨

我们运维群里每天都有类似的对话:

「帮我看下 202610120831 这个工单现在到哪一步了?」 「稍等,我登一下后台。」

我数过,这种"帮我看下 XXX"的问题,一天能有二十几条。每次都要人工登系统、输入单号、截图、贴回群里。烦,但又不算什么大事,所以一直没人管。

Spring AI 1.0 GA 之后我一直想找个真实场景练手,就把这个当成了切入点:做一个 AI 助手,运维在群里问,它自己去查。

第一个决策:Function Calling 还是 MCP

最直接的做法是 Function Calling。把查工单的方法注册成一个 function,用户问,模型决定要不要调,调完把结果塞回去让它总结。

这个方案能跑通,我一开始也是这么干的。写了个 demo,三十行代码就能让模型查工单状态。

但问题是:这套东西和我的 AI 应用绑死了。

我如果换个模型厂商(比如从这家换到那家),或者换一个前端(比如从群机器人换成内部管理后台),Function 的定义、参数 schema、调用协议,全都得重来一遍。

这时候 MCP(Model Context Protocol)就看出价值了。它的本质是把"能力"和"使用能力的客户端"解耦------你写一个 MCP Server 暴露工具,任何支持 MCP 的客户端(Claude Desktop、Cursor、你自己的 Spring AI 应用)都能连上来用。

用一句话概括我的理解:

Function Calling 是「我在我的应用里定义了一个函数」;MCP 是「我对外发布了一个服务,谁都能调」。

对我们这种"内部系统多、想接 AI 的地方也多"的场景,MCP 明显更划算。于是我把查工单、查订单、查配置这三个能力,统一封成了一个 MCP Server。

搭起来的骨架

技术栈:Spring Boot 3.5 + Spring AI 1.1 + spring-ai-starter-mcp-server(JDK 21)。

MCP Server 侧的核心就是定义工具。Spring AI 提供了两种写法,一是低层的 McpServerFeatures.SyncToolSpecification,二是直接用 @Tool 注解。我用了后者,写起来清爽:

java 复制代码
@Component
public class TicketTools {

    private final TicketFacade ticketFacade;

    public TicketTools(TicketFacade ticketFacade) {
        this.ticketFacade = ticketFacade;
    }

    @Tool(description = "根据工单号查询工单当前状态和处理人。工单号是12位纯数字字符串,例如 202610120831")
    public TicketStatusVO queryTicketStatus(@ToolParam(description = "工单号") String ticketNo) {
        return ticketFacade.queryStatus(ticketNo);
    }
}

然后配置一下:

yaml 复制代码
spring:
  ai:
    mcp:
      server:
        name: internal-tools
        version: 1.0.0
        protocol: STREAMABLE

客户端(也就是我的 AI 应用)侧:

yaml 复制代码
spring:
  ai:
    mcp:
      client:
        streamable-http:
          connections:
            internal-tools:
              url: http://127.0.0.1:8088/mcp

跑起来,在 AI 应用的 ChatClient 上把 MCP 客户端接进去,模型就能自动发现这些工具并调用了。整个过程不到 200 行。

搭完之后我挺得意的,觉得这事到此结束了。

然后坑就来了。

坑一:工具描述写得敷衍,模型根本不用它

第一版我给 @Tool 的描述写的是:

java 复制代码
@Tool(description = "查询工单")

结果测试的时候,我问「202610120831 现在什么状态」,模型直接回我:

我无法查询工单信息,请您登录后台查看。

它压根没调工具。

折腾了半天才想明白:模型看到的是一个没有任何上下文的工具清单,description 就是它唯一的判断依据。你写"查询工单",它不知道输入长什么样、不知道什么时候该用、不知道这个工具和其他工具的区别。

改完之后是这样的:

java 复制代码
@Tool(description = """
        根据工单号查询工单的当前状态、当前处理节点和当前处理人。
        适用场景:用户询问某个工单"到哪一步了""谁在处理""什么状态"。
        不适用:用户想修改工单内容,请使用 updateTicket 工具。
        工单号格式:12 位纯数字字符串。
        """)

就这一处改动,识别率从大概三成提到了九成以上。

这个坑的教训是:写 tool description 跟写 prompt 是一回事,甚至比写 prompt 还重要。 描述里要说清楚三件事------它干什么、什么时候用、什么时候别用。

坑二:工具返回的结果把上下文撑爆了

第二个坑更隐蔽。

我们的工单详情接口返回的是一个完整的 VO,里面包含工单基本信息、流转记录、附件列表、操作日志......一个工单序列化出来 JSON 有三万多字符。

第一次调通之后我发现,问第二个问题的时候模型就开始胡言乱语了,或者说"我记不住之前的内容"。

原因很简单:那个三万多字符的 JSON 全被塞进了对话上下文。上下文窗口是会被消耗的,一个工单就吃掉一大半。

而且更要命的是,模型并不需要那么多字段。 它只需要知道状态、节点、处理人。

解决方式是在 MCP 工具这一层做裁剪,返回一个专门给模型看的精简结构:

java 复制代码
@Tool(description = "...")
public TicketBriefVO queryTicketStatus(@ToolParam(...) String ticketNo) {
    TicketStatusVO full = ticketFacade.queryStatus(ticketNo);
    // 只把模型需要的字段摘出来,别把整个 VO 丢出去
    return TicketBriefVO.builder()
            .ticketNo(full.getTicketNo())
            .status(full.getStatusDesc())
            .currentNode(full.getCurrentNodeName())
            .handler(full.getHandlerName())
            .updatedAt(full.getUpdateTime())
            .build();
}

改完之后单个工具返回从三万多字符降到了两百字符左右,上下文压力一下就没了。

这里我有个很实在的建议:MCP 工具应该返回"给模型看的数据",不是"给前端看的数据"。 这两者的目标完全不同。前端要字段全、要能渲染;模型要信息密度高、要短。硬把业务 VO 直接丢给模型,一定会出问题。

坑三:权限,一个很现实的问题

这是我最开始没考虑到的。

MCP Server 把工具暴露出去之后,理论上任何连上来的客户端都能调。我的 AI 应用接的是公司内网,看起来安全,但万一有人在本地起一个 Cursor 连上我的 Server 呢?

后来我加了三层防护:

  1. 传输层鉴权:MCP 的 Streamable HTTP 模式支持带 header,我在 Server 上校验了一个内部 token。
  2. 工具白名单:只暴露只读工具(查询类),写操作(改工单、删配置)一律不开。
  3. 数据脱敏:工具返回前把手机号、身份证这类字段打过码。

第 2 条我觉得是底线。AI 调工具失败顶多是回答不出来,AI 顺手套用了一个写操作,那是生产事故。 我宁可多花点时间做审批流,也不愿意让模型有权限直接写库。

坑四:超时和重试

还有个不算坑但要注意的点。

模型调工具的时候,它不知道你的工具要跑多久。我们的工单查询在高峰期偶尔会慢到两秒,模型那边可能会提前放弃。

解决办法是在 MCP 工具内部自己控制超时,不要让它无限等:

java 复制代码
@Tool(description = "...")
public TicketBriefVO queryTicketStatus(String ticketNo) {
    try {
        return CompletableFuture
                .supplyAsync(() -> doQuery(ticketNo), executor)
                .orTimeout(800, TimeUnit.MILLISECONDS)
                .get();
    } catch (TimeoutException e) {
        // 返回一个"查不到"的结构,让模型自己决定怎么说,而不是抛异常
        return TicketBriefVO.notFound(ticketNo, "查询超时,请稍后重试");
    }
}

注意这里我没有直接抛异常。因为抛异常的话,模型拿到的是一坨 stack trace,它会更懵。返回一个明确的"查不到"结构,模型反而能处理得体面一点------它会跟用户说"系统繁忙,稍等再试",这比抛一堆红字好看多了。

现在的效果和我的看法

上线之后,运维群里那种"帮我看下 XXX"的问题,差不多七成能被机器人直接答掉。剩下三成,要么是问题本身太模糊("刚才那个单子"),要么是涉及写操作需要人工。

回到技术本身,我对 MCP 的判断是这样的:

MCP 的价值不在「让 AI 更聪明」,而在「让系统更容易被接」。 它是给内部系统用的,不是给最终用户用的。你把自己的业务能力封成一个 MCP Server,之后不管是接 Claude、接自家模型、还是接一个刚出的新客户端,都不用改一行业务代码。

如果你现在手里有一堆内部接口,想让它们被 AI 用起来,我建议直接上 MCP,别在 Function Calling 上折腾太久。但前提是先把权限和脱敏这两件事想清楚,这俩比能不能跑通重要得多。

2026 年这个方向上其实还在快速演进,MCP 的认证规范、工具发现、多 Server 编排这些都还在变。我的原则是:接口层跟着标准走,业务层自己说了算。 这样标准怎么变,都不会伤到根子。


下一篇写 Spring Cloud Gateway 统一加解密,一个 DataBuffer 只能读一次的问题,卡了我整整两天。

相关推荐
m0_587383001 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
geovindu1 小时前
rust: Chain of Responsibility Pattern (续)
开发语言·后端·设计模式·rust·责任链模式·行为模式
诚心呈意共享外卖配送系统1 小时前
多平台订单聚合系统的技术架构:接 API 只是开始
java·开发语言·架构·系统架构·创业创新
Java爱好狂.2 小时前
Java小白如何三天速通redis?
java·数据库·redis·架构师·分布式锁·java面试·java程序员
大风起兮云飞扬丶2 小时前
Java——多线程
java
郝学胜-神的一滴2 小时前
Web安全入门:读懂Web世界里的攻防
前端·网络·后端·安全·web安全·hibernate
天若有情6732 小时前
从零搭建校园二手交易系统:Vue2 + SpringBoot 4.x 全栈实战
java·spring boot·后端
lexiangqicheng2 小时前
1,自定义表单通俗理解-PC设计APP渲染
java·前端·javascript
南极企鹅2 小时前
生产环境gc文件分析
java·jvm·算法