最近团队在做一个内部的智能运维助手,代码还没写多少,光是给同事讲清楚 Function Calling、MCP、Skill 这三个词就开了三次会。很多 Java 工程师对它们都是「听说过但分不清」的状态:知道 MCP 是协议,也知道 Function Calling 能让模型调用函数,但真正动手写代码的时候,往往不知道该把逻辑放在哪一层。这篇文章一次性把三者的关系讲透,并给出从 Spring Boot 项目开始的 Java 落地全流程,适合正在用 Java 做 AI 应用、或者准备 Agent 相关岗位面试的工程师。读完你至少能做到:别人问你「项目里 Function Calling 和 MCP 到底怎么配合」,你能直接给出清晰答案。
1. 先给结论:三者根本不在同一个维度
1.1 Function Calling:模型的一种「召唤术」
Function Calling(也叫 Tool Calling、Tool Use)本质上是模型侧的一种输出能力。模型并不真的去执行函数,它只是在生成回复时,从你提供的函数声明列表里挑一个,然后输出一段结构化的 JSON:函数名加参数。框架拿到这段 JSON 后,才在本地真正调用对应的 Java 方法,再把返回值塞回给模型,让模型基于真实结果生成最终回答。
这个过程很多人理解反了,以为模型「会调用函数」。实际上模型做的还是文本生成,只是这次生成的内容受限于你给的 JSON Schema,专门输出「我要调哪个函数、参数是什么」。真正干活的是你写在 Java 方法里的业务逻辑。这也是我为什么一直强调:Function Calling 的工程质量,很大程度取决于你把这些方法声明得清不清楚。
1.2 MCP:工具接入的「标准化插座」
MCP(Model Context Protocol)是 Anthropic 在 2024 年底开源的一个应用层软件协议,注意是软件协议,别和硬件协议混为一谈。它和 HTTP、gRPC 属于同一层级的产物,解决的是 AI 应用怎么和外部工具对话的问题。在 MCP 之前,每接一个系统就要写一套自定义接口,鉴权方式各异、参数格式各异,AI 应用根本没法动态发现工具。MCP 定义了一套标准:AI 应用作为 Host,通过 Client 去连接 Server,Server 暴露出三类原语——Tools(可执行动作)、Resources(可读取的数据上下文)、Prompts(可复用的提示词模板)。
打个比方,Function Calling 是「手」,MCP 是「插座标准」。手再灵活,如果插座乱七八糟,插什么都要转接头,效率也很低。MCP 的价值就是让工具接入这件事标准化,让 AI 应用和工具服务之间可以即插即用。
1.3 Skill:面向任务的「员工岗位手册」
Skill 是最容易混淆的概念,因为各家平台叫法不一样:Claude 的 Skill、OpenAI 的社区技能包、阿里云百炼里的应用编排,本质上都是同一个东西。一个 Skill 是面向某个具体任务的封装,通常包含三部分:任务目标与执行提示词、完成任务需要的工具清单、执行步骤和规则。说白了,就是把「一名老员工做一件事的经验」打包成代码,让模型拿到就能照着做。
Skill 不是协议,也不是模型能力,它是工程层面的封装。同样是查库存、算优惠、发通知,单独看都是工具;但组合成「处理退货退款」的完整流程,附带标准的处理步骤和话术,就是一个 Skill。它解决的是「让 Agent 像熟手一样干活」的问题。
1.4 一句话关系总结
Function Calling 是模型的能力,MCP 是工具接入的协议,Skill 是工程层的封装。三者不是三选一的关系,而是层层叠加:MCP Server 暴露出来的工具,最终要靠 Function Calling 机制被模型选中;Skill 则负责决定「什么时候、按什么流程、用哪批工具」。
2. 三者怎么协作:从一次真实调用看完整链路
2.1 一个能说明问题的例子:运维工单助手
我用团队正在做的运维工单助手来拆解。用户问:「我的工单 T20250615001 现在到哪一步了?」
程序要做的事是:把候选工具发送给模型。这些工具里,有的是本地用 @Tool 注解写的queryTicketStatus,有的是通过 MCP 接入的告警查询、钉钉通知等远程工具。模型看到用户问题后,判定应该使用queryTicketStatus,于是返回一个结构化调用请求,参数是{"ticketNo": "T20250615001"}。
紧接着 Spring AI 拦截到这个请求,反射调用 Java 方法,拿到真实工单状态,再把结果作为一条工具消息回传给模型。模型基于这个结果生成自然语言回复:「您的工单 T20250615001 当前状态是‘处理中’,负责人是张三,预计明天下午完成。」
这条链路里,Function Calling 承担的是「模型决策和参数翻译」;MCP 如果在场,负责的是「让外部系统的工具以标准方式被发现和调用」;Skill 如果封装了这个工单查询场景,负责的是「提前把所需工具和提示词打包好,甚至规定先查工单再查负责人」的流程。
2.2 用「翻译、插座、工作手册」理解层级关系
给团队讲的时候我用了三个类比,反馈很好。
Function Calling 是同声传译。用户说中文,模型听得懂,但它不能直接操作你的系统,需要翻译成「调用哪个函数、传什么参数」的结构化指令,代码才能执行。MCP 是 USB-C 接口标准。以前每个设备一个充电口,现在统一了,只要是 USB-C 接口的鼠标、键盘、显示器都能插到电脑上。MCP 就是 AI 应用和工具之间的 USB-C。Skill 是新员工的工作手册。手册里写了这个岗位遇到什么情况该干什么、先做什么后做什么、哪些话术不能乱说。新员工(模型)照着手册做事,就能稳定输出合格的结果。
这三个东西是互补的,不是替代的。一个 Skill 里可以既包含本地 @Tool,也包含通过 MCP 接进来的远程工具,它们一起组成模型可用工具的全集。
2.3 面试和方案选型时怎么组织答案
面试被问「三者是什么关系」,先讲维度不同,再用一个实际请求例子把链路串起来,最后补一句「Function Calling 负责模型决策,MCP 负责工具标准化接入,Skill 负责任务级封装和流程复用」,基本就是满分答案。
技术方案选型时就反过来思考:工具数量少(1 到 3 个)直接用 Function Calling 就够了,上 MCP 反而增加运维成本;工具数量多、系统分散、要跨团队甚至跨语言复用,就上 MCP 统一接入;需要把「懂行的做事方法」沉淀下来、让非技术用户也能触发复杂任务,就上 Skill 层做编排。
3. Java 落地第一步:把 Function Calling 用 @Tool 跑通
3.1 技术选型:为什么优先选 Spring AI
Java 生态里做 AI Agent,目前主流有两条路:Spring AI 和 LangChain4j。我更推荐 Spring AI,原因很直接:Spring Boot 集成度最高,一个.defaultTools()就能把工具注册给模型,@Tool注解直接声明函数,后续 MCP 的 Client 和 Server 也都有官方 Starter。你要是本来就用 Spring 写业务代码,学习成本几乎为零。LangChain4j 也很好,思路类似,核心是@Tool加AiServices,怎么选看团队技术栈和习惯。
3.2 前置准备与依赖配置
环境要求不复杂:JDK 17 以上,Spring Boot 3.4 以上,一个支持 Function Calling 的模型 API。示例里我用 DeepSeek,它兼容 OpenAI 接口,所以用spring-ai-starter-model-openai这个依赖即可。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency>spring: ai: openai: base-url: ${LLM_BASE_URL:https://api.deepseek.com} api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.2temperature 为什么要调低?工具调用属于确定性任务,温度越高模型越容易在参数生成上「发挥」,填错参数导致调用失败的概率也越高,我在实际项目里一般固定 0.2 以下。
3.3 用 @Tool 注解写一个 Java 工具类
定义一个工单查询组件,核心是@Tool注解和@ToolParam。这两个注解的说明文字不是可有可无的注释,它们会被拼进模型看到的工具描述里,直接影响模型选工具和填参数的正确率。
@Component public class TicketTools { private final TicketService ticketService; public TicketTools(TicketService ticketService) { this.ticketService = ticketService; } @Tool(name = "queryTicketStatus", description = "根据工单号查询工单当前处理状态,工单号格式类似 T20250615001") public String queryTicketStatus( @ToolParam(description = "工单号,例如 T20250615001") String ticketNo) { Ticket ticket = ticketService.findByNo(ticketNo); if (ticket == null) { return "没有找到工单:" + ticketNo; } return String.format("工单 %s 当前状态:%s,处理人:%s,预计完成时间:%s", ticket.getNo(), ticket.getStatus(), ticket.getAssignee(), ticket.getEta()); } @Tool(name = "assignTicket", description = "将指定工单指派给某个处理人,仅在用户可以明确给出处理人姓名时调用") public String assignTicket( @ToolParam(description = "工单号") String ticketNo, @ToolParam(description = "处理人姓名") String assignee) { return ticketService.assign(ticketNo, assignee); } }两个参数细节我再强调一下。第一,工单号这种 Long 型数据尽量用 String,因为很多模型端或中间层是按 JavaScript 数字处理 JSON 的,超过 2 的 53 次方就会丢精度。第二,日期参数也建议用 String 并在 description 里写清格式,比如「格式为 yyyy-MM-dd」,否则模型生成Date对象的 JSON 时经常会踩序列化的坑。
3.4 注册工具并完成一次对话
用 ChatClient 把这些工具注册进模型。关键点有两个:.defaultSystem(...)里写明助手的行为边界,比如「工单信息一律以工具返回结果为准,不要凭空编造」;.defaultTools(ticketTools)把工具类丢进去,Spring AI 会自动扫描里面的@Tool方法。
@Configuration public class ChatConfig { @Bean ChatClient chatClient(ChatModel chatModel, TicketTools ticketTools) { return ChatClient.builder(chatModel) .defaultSystem("你是内部 IT 运维助手。回答问题前必须优先通过工具查询真实数据,工单信息一律以 queryTicketStatus 返回结果为准。") .defaultTools(ticketTools) .build(); } }调用:
@Service public class AssistService { private final ChatClient chatClient; public AssistService(ChatClient chatClient) { this.chatClient = chatClient; } public String ask(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }跑起来之后,日志里能看到一个很关键的现象:模型先返回function_call,框架执行完工具后再把结果回传,模型才生成最终答案。第一次看到这个链路会很兴奋,但要注意,模型能不能选对工具、填对参数,一半靠工具描述质量,一半靠系统提示词写得干不干净。工具描述写得模棱两可,后面会有一堆坑等着你,这些问题我在第 6 章统一讲。
4. Java 落地进阶:工具多了,用 MCP 标准接入
4.1 什么情况下才值得上 MCP
团队刚开始只有两三个工具,没必要上 MCP。但当出现这些信号时就值得了:业务系统有十几个,每个都想给 Agent 暴露能力;工具要被多个应用甚至多种语言复用;希望 Agent 能动态发现工具列表而不用改代码。MCP 的客户端发现机制加上标准工具描述,能帮你省掉大量手工适配。
我自己踩过对比最明显的场景是接钉钉通知和查询告警:如果不用 MCP,每个都要写独立的 HTTP 客户端、鉴权、超时、错误处理,代码重复且难维护;换成统一的 MCP Server 之后,每个工具的接入变成一行配置。
4.2 客户端接入:Spring Boot 里挂载远程 MCP 工具
先引入依赖spring-ai-starter-mcp-client。然后有两种传输方式,一种是 stdio,通过 Java 进程拉起子进程(比如 npx 启动一个 Node 写的 MCP Server),适合本地开发和单机部署;另一种是 HTTP/SSE,连接一个独立部署的 MCP Server 地址,适合生产环境。
stdio 方式的配置大致长这样:
spring: ai: mcp: client: stdio: servers: - command: npx args: - -y - mcp-server-sqlite - --db-path - /opt/data/ops.db enabled: true - command: node args: - /opt/servers/ding-talk/dist/index.js env: DING_WEBHOOK: ${DING_WEBHOOK} enabled: true配置好之后,Spring AI 会自动连接这些 Server,并把其中声明的工具收集到应用上下文里。在 Java 代码里把它们和本地工具一起注册给 ChatClient 即可。以 Spring AI 1.0 为例,大致是这个写法:
@Bean ChatClient chatClient(ChatModel chatModel, TicketTools ticketTools, McpClientOperations mcpClientOperations) { ToolCallback[] mcpCallbacks = McpToolUtils.getToolCallbacks(mcpClientOperations); return ChatClient.builder(chatModel) .defaultSystem("你是内部 IT 运维助手,回答前优先查询真实数据。") .defaultTools(ticketTools, mcpCallbacks) .build(); }不同小版本的 API 可能略有差异,但思路是一样的:MCP Client 负责连接并发现工具,框架负责把工具转成模型可识别的声明,最终交给 Function Calling 机制去触发。
4.3 服务端输出:把 Java 方法发布成标准 MCP 工具
MCP 的价值不只是接别人的工具,也可以把团队自己的工具发布出去,让别的应用调用。做法和你写本地 Function Calling 差不多,引入spring-ai-starter-mcp-server后,同样是@Tool注解,方法会被注册成标准的 MCP 工具。
同一个@Tool方法,既可以作为本地 Function Calling 被自己的 Agent 调用,也可以作为 MCP Server 暴露给外部系统调用。这点是很多人在概念上容易卡住的地方:Function Calling 和 MCP 不是两套互斥的工具体系,而是同一批工具的不同暴露路径。本地调用省去网络开销,MCP 暴露换来跨系统复用,按场景选。
4.4 安全边界不能省
接 MCP 之后最需要重视的是安全边界。不要让 Agent 直连数据库或生产核心系统,正确的做法是单独跑一个 MCP Server,暴露最小操作集:比如只允许查询、不允许删除,只在特定时间窗口允许修改。鉴权一定要在 MCP Server 端做,Agent 拿到的只是一个受限工具集。工具返回的数据也要在 Server 侧做脱敏,尤其是涉及用户手机号、内部地址这类字段,别让模型把不该说的信息带出来。
5. Java 落地终极层:用 Skill 把散装工具变成能力包
5.1 Skill 层的最小设计
Skill 没有统一协议标准,所以 Java 里自己定义一个清晰的模型即可。我习惯用一个不可变类或 record,包含名称、描述、说明、工具列表四个字段。
public record Skill( String name, String description, String instructions, Object... tools ) { public String toSystemMessage() { return """ [技能 %s] %s """.formatted(name, instructions); } }调用的时候,把这个 Skill 的instructions拼进系统提示词,把它的tools注册进 Function Calling 的候选集合。这样模型看到的上下文就是「你正在做什么事、按什么步骤做、你可以用这些工具」,比把几十个工具一次性全丢给它要清晰得多。
如果系统里有多个 Skill,可以让模型来分配:把每个 Skill 的description变成一个轻量的路由工具,模型判断用户意图后返回该用哪个 Skill,代码再根据返回值加载对应 Skill 的提示词和工具。这本质上就是 Agent 的「技能路由」。
5.2 做一个能用的 Skill:周报助手
举个例子。运维团队最烦写周报,我封装了一个weekly-reportSkill:
Skill weeklyReportSkill = new Skill( "weekly-report", "生成周报。当用户提到周报、本周总结、工作汇报时使用。", """ 你是职场周报助手,严格按以下步骤执行: 1. 调用 queryClosedTickets 查询本周已关闭的工单数据; 2. 将工单归类为「系统变更」「故障处理」「日常运维」三类; 3. 按下述三部分输出 Markdown 格式周报: - 本周完成:列出各类别的主要工单,每项一句话说明; - 风险与问题:只写仍处于未关闭状态且标记为 high 的工单; - 下周计划:基于当前待办工单生成 3-5 条计划; 4. 全文不超过 300 字,不要出现工单号以外的内部编码。 """, ticketTools );这个 Skill 里的 instructions 写得越像给新员工的入门文档,模型执行得越稳。里面包含触发规则、执行步骤、输出模板、边界约束,基本上把「老员工写周报」的经验全部固化了。用户只需要说一句「帮我写本周周报」,模型就会自动走完这套流程。
5.3 Skill 与工作流的边界
Skill 适合的是「目标明确、路径相对固定」的任务。如果流程里有多个分支、需要人工审批、涉及定时任务或状态流转,那更合适的是工作流引擎或状态机,不要让模型自由发挥。我的经验是:单轮任务用 Skill 封装,多步骤强管控流程用工作流编排,两者可以嵌套——一个工作流节点内部照样可以调一个 Skill。
Skill 层沉淀的门槛其实不高,关键在于持续迭代。我建议每个 Skill 都记录触发场景、失败案例和最新调整原因,隔一个月再看,你会发现自己团队的业务知识正在慢慢变成可运行的资产。这也是 Skill 和普通工具函数最大的区别:工具是死的,Skill 是带经验和流程的。
6. 常见问题与排错实录:这些坑我基本都踩过
6.1 模型就是不调工具,或者乱调工具
这是出现频率最高的问题。通常有四个原因:工具 description 描述模糊,模型不知道什么时候该用;temperature 太高,参数生成不稳定;参数类型太复杂,模型生成出来的 JSON 反复反序列化失败;候选工具太多,模型被干扰。
我的处理清单:第一,把工具描述写清楚,包含「什么场景用、用什么格式传参、传错了会怎样」,最好给一个参照示例。第二,temperature 调到 0.2 以下。第三,参数类型尽量用 String 和数字,枚举值写进 description。第四,用 Skill 做工具分组,按用户意图只加载相关一组工具,减小模型选择范围。第五,在工具方法内部做参数兜底和异常捕获,工具调用失败时返回一句清晰的错误说明,模型会根据说明自行修正。
6.2 工具调用死循环,轮数失控
典型场景是工具返回了结果,但模型认为结果不够,又调用一次,循环好几轮,最后上下文被撑爆。我不是很建议让框架完全自动跑完所有工具调用轮次,应该有上限控制。我的做法是在调用层包一个计数器,超过 5 次就强制收尾,返回兜底提示「这个问题比较复杂,建议转人工处理」。对运维助手这种场景,稳定比炫技重要,宁可让模型说「我不确定」,也不能让它无限重试。
6.3 工具返回内容太大,直接把上下文撑爆
有一次让工具查全量告警列表,返回了几百条记录,加上模型端的理解成本,一轮对话直接超出上下文窗口。后来在工具方法里做了两点改造:返回前做摘要裁剪,只保留核心字段;生成类似「共 120 条,仅展示前 5 条,如需查看更多请传入分页参数」的字符串;同时给工具加上分页参数。这样既保证了返回内容可控,也给模型提供了进一步查询的入口。
6.4 MCP Server 连接不稳定
标准连接问题集中在四类:stdio 方式下 Java 进程找不到npx或node命令,环境变量 PATH 不对;子进程退出后没有正确清理,产生僵尸进程;HTTP/SSE 方式下网络超时配置太短,工具响应稍慢就被判定失败;鉴权 token 过期,服务器直接拒连。
排查时先看 Java 日志里的 stderr,MCP Server 的报错信息基本都带。其次确认 transport 类型是否匹配:配了 stdio 却连远程地址,肯定不通。最后给 HTTP 方式预留足够超时,不要默认用 3 秒这种浏览器级配置。
| 问题 | 可能原因 | 排查方向 |
|---|---|---|
| MCP 工具列表为空 | Server 未启动成功或配置错误 | 查看 Server 日志与 stderr |
| 调用超时 | 网络或超时配置太短 | 调大超时时间,测试 Server 响应延迟 |
| 鉴权失败 | token 过期或配置错误 | 检查 Server 端鉴权配置 |
| 子进程残留 | stdio 方式进程未清理 | 检查 Java 进程与子进程生命周期 |
6.5 Java 侧序列化和类型踩坑
参数类型建议全部用简单类型。Long 超过 JavaScript 安全整数范围会丢精度,统一用 String;金额用 BigDecimal 时也要转成字符串并注明单位;枚举参数直接让模型填字符串,在方法内部做映射。另外@ToolParam的 description 里要写清楚格式,尤其日期时间字段,模型对 ISO 8601 的掌握程度远没有你想象得那么可靠。
6.6 模型不支持 Function Calling 时的降级方案
如果因为模型选型受限,某个模型不支持 Function Calling,也有降级办法:让模型输出一段固定结构的 JSON,代码手工解析后路由到对应方法。类似这样:
你只能输出 JSON,格式为: {"tool": "queryTicketStatus", "args": {"ticketNo": "T20250615001"}}代码用 Jackson 解析再反射调用。这个方法能解一时之急,但不建议长期用——文本解析的失败率明显更高,响应也不稳定。有条件还是优先用原生支持 Function Calling 的模型。
我自己把这一整套流程在运维助手项目里跑通之后,最大感触是:不要一上来就追求 MCP 加 Skill 的大而全架构。正确的顺序是先把 Function Calling 跑通,用最简单的工具类解决最痛的场景;等工具数量开始失控、协作团队开始变多,再引入 MCP 统一接入;最后随着业务经验积累,把稳定的任务逐步沉淀成 Skill。这套顺序能让每一步都有明确的收益支撑,也让团队在概念上始终清楚:Function Calling 是模型能力,MCP 是接入协议,Skill 是业务沉淀。别把三者混为一谈,Agent 代码就不会写成一团乱麻。