1. 三个概念的真实定位:别被缩写绕晕了
Function Calling、MCP、Skill 这三个词放在一起,很多人第一反应是“这不都是让大模型干活的东西吗”。我刚开始接触的时候也这么想,结果越看越糊涂——有的文章说 MCP 是协议,有的说 Skill 是能力封装,还有的把 Function Calling 当成万能钥匙。后来在 Spring AI 里实际跑通几个项目之后,我才算把这三者的边界摸清楚。
先说结论:Function Calling 是模型层面的能力,MCP 是系统层面的协议,Skill 是应用层面的封装。它们不在一个抽象层级上,所以不存在“谁替代谁”的问题。打个比方,Function Calling 像是人的手能抓东西,MCP 像是规定了“抓东西”这个动作怎么描述、怎么传递,Skill 则是把“抓杯子”“抓笔”“抓钥匙”打包成一套可复用的动作库。
这个定位很重要,因为很多团队在选型时犯的错就是拿 Function Calling 硬扛所有场景,结果代码里到处是 if-else 判断模型该调哪个函数,维护成本高得离谱。也有人一上来就上 MCP,但业务逻辑还没稳定,协议层反而成了累赘。我的经验是:先想清楚你的 Agent 要解决什么问题,再决定用哪一层。
1.1 为什么这三个概念总被混在一起
根本原因是它们都服务于同一个目标——让大模型从“只会聊天”变成“能干活”。在 ChatGPT 刚火的那阵子,大家发现模型可以输出 JSON 格式的调用指令,于是 Function Calling 成了第一个被广泛使用的方案。后来 Anthropic 推出 MCP,试图标准化“模型怎么发现和调用外部工具”这件事。再后来各种 Agent 框架开始提 Skill,强调“把一组相关能力打包成一个可复用的单元”。
从时间线看,Function Calling 最早,MCP 居中,Skill 最晚。但它们在技术栈里是叠加关系,不是替代关系。一个完整的 Agent 系统可能同时用到三者:用 Skill 定义业务能力,用 MCP 暴露给模型,用 Function Calling 完成实际调用。
1.2 一张表看清三者的核心差异
| 维度 | Function Calling | MCP | Skill |
|---|---|---|---|
| 抽象层级 | 模型 API 层 | 系统协议层 | 应用封装层 |
| 核心作用 | 让模型输出结构化调用指令 | 标准化工具发现与调用 | 打包可复用业务能力 |
| 典型实现 | OpenAI tools 参数 | JSON-RPC over stdio/SSE | 框架自定义的 Skill 接口 |
| 适用场景 | 简单工具调用 | 多工具、多服务集成 | 复杂业务流程封装 |
| Java 生态 | Spring AI FunctionCallback | Spring AI MCP Client | 自定义 Skill Bean |
这张表是我在给团队做内部分享时整理的,后来发现对新人理解帮助很大。关键是要明白:Function Calling 解决的是“模型怎么表达要调什么”,MCP 解决的是“工具怎么被找到和调用”,Skill 解决的是“业务能力怎么组织和复用”。
2. Function Calling 在 Java 里到底怎么落地
Function Calling 的原理不复杂:你在调用模型时传入一组工具描述,模型根据用户输入决定是否调用某个工具,并输出对应的参数。你的代码拿到这个输出后执行实际函数,再把结果传回模型,模型生成最终回复。
听起来简单,但实际写起来有几个坑。我在 Spring AI 里踩过的第一个坑就是工具描述写得太随意,导致模型经常选错工具。比如有两个工具都叫“查询信息”,一个查订单一个查物流,模型根本分不清。后来我把描述改成“根据订单号查询订单状态和金额”和“根据运单号查询物流轨迹和签收状态”,准确率立刻上去了。
2.1 Spring AI 中 FunctionCallback 的写法
Spring AI 提供了FunctionCallback接口,你可以用@Bean的方式注册工具。下面是一个查询天气的示例:
@Configuration public class WeatherFunctionConfig { @Bean @Description("根据城市名称查询当前天气,返回温度和天气状况") public FunctionCallback weatherFunction() { return FunctionCallback.builder() .function("getWeather", (city) -> { // 实际调用天气 API return weatherService.query(city); }) .inputType(String.class) .build(); } }注意@Description注解里的内容会直接传给模型,所以描述要写得像给同事交代任务一样清楚。我试过把描述写成“查询天气”,模型经常在用户问“今天热不热”的时候不调用工具,改成“根据城市名称查询当前天气,返回温度和天气状况”之后,命中率明显提升。
2.2 参数校验和异常处理不能省
模型输出的参数不一定合法。我遇到过模型把“北京”写成“北京市朝阳区”,也遇到过模型传空字符串。所以在函数内部一定要做参数校验,并且把错误信息以模型能理解的方式返回。
public String getWeather(String city) { if (city == null || city.trim().isEmpty()) { return "错误:城市名称不能为空,请提供有效的城市名"; } try { WeatherResult result = weatherService.query(city.trim()); return String.format("%s当前温度%d摄氏度,%s", city, result.getTemp(), result.getCondition()); } catch (Exception e) { return "查询失败:" + e.getMessage() + ",请稍后重试"; } }把错误信息返回给模型而不是直接抛异常,模型可以根据错误信息调整参数重新调用。这个技巧在实际项目里非常有用,能显著减少“模型调了但没调对”的情况。
2.3 多工具场景下的选择策略
当工具有十几个的时候,模型选错的概率会上升。我的经验是给工具分组,每组不超过 5 个,然后在系统提示里说明“当前场景下你应该使用哪组工具”。Spring AI 支持在ChatClient里动态设置工具列表,你可以根据用户意图先做一次粗分类,再传入对应的工具子集。
public String chat(String userInput) { List<FunctionCallback> tools; if (userInput.contains("订单") || userInput.contains("物流")) { tools = orderTools; } else if (userInput.contains("天气") || userInput.contains("温度")) { tools = weatherTools; } else { tools = generalTools; } return chatClient.prompt() .user(userInput) .functions(tools.toArray(new FunctionCallback[0])) .call() .content(); }这个做法虽然土,但实测下来比让模型从 20 个工具里自己选要稳得多。工具数量超过 10 个之后,模型的选择准确率会明显下降,这是我在三个项目里反复验证过的。
3. MCP 协议:为什么需要它,Java 怎么接
MCP 全称 Model Context Protocol,核心目标是让工具提供方和使用方解耦。在没有 MCP 之前,每个 Agent 框架都有自己的工具注册方式,你写好的工具换个框架就得重写。MCP 定义了一套标准的 JSON-RPC 接口,工具提供方实现 MCP Server,Agent 框架实现 MCP Client,两边通过标准协议通信。
我第一次接触 MCP 是在一个多团队协作的项目里。我们团队负责 Agent 编排,另一个团队负责数据查询服务。如果没有 MCP,他们得把接口文档给我们,我们手动封装成 FunctionCallback。有了 MCP,他们直接暴露一个 MCP Server,我们这边配置一下就能用,联调时间从两天缩短到两小时。
3.1 MCP 的核心概念:Server、Client、Transport
MCP 的架构很清晰:MCP Server 暴露工具和资源,MCP Client 连接 Server 并获取工具列表,Transport 负责底层通信。Transport 支持 stdio 和 SSE 两种方式,stdio 适合本地进程,SSE 适合远程服务。
在 Spring AI 里,你可以用McpClient来连接 MCP Server:
@Configuration public class McpConfig { @Bean public McpClient mcpClient() { return McpClient.builder() .transport(new StdioTransport("npx", "-y", "@modelcontextprotocol/server-filesystem")) .build(); } @Bean public List<FunctionCallback> mcpTools(McpClient mcpClient) { return mcpClient.listTools().stream() .map(tool -> FunctionCallback.builder() .function(tool.getName(), args -> mcpClient.callTool(tool.getName(), args)) .inputType(Map.class) .build()) .collect(Collectors.toList()); } }这段代码做了两件事:连接 MCP Server,然后把 Server 暴露的工具转换成 Spring AI 的 FunctionCallback。转换这一步是关键,它让 MCP 工具和本地工具在使用上没有任何区别。
3.2 MCP 相比裸 Function Calling 的优势
最大的优势是工具的动态发现。裸 Function Calling 需要你在代码里硬编码工具列表,每次加工具都要改代码重新部署。MCP 的listTools是运行时调用的,Server 加了新工具,Client 下次连接就能看到。
第二个优势是跨语言。MCP Server 可以用任何语言实现,Python 写的工具、Go 写的工具、Java 写的工具,只要实现 MCP 协议,Java 的 Agent 都能调。这在混合技术栈的团队里非常实用。
第三个优势是权限和审计。MCP Server 可以统一控制哪些工具对哪些 Client 可见,也可以记录所有调用日志。裸 Function Calling 要做到这些得自己造轮子。
3.3 Java 接入 MCP 的实操步骤
第一步,选一个 MCP Server 实现。官方提供了 filesystem、fetch、sqlite 等基础 Server,社区也有大量第三方 Server。我建议先从 filesystem Server 开始练手,因为它不需要额外配置。
第二步,在 Spring AI 项目里添加 MCP 依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-client-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>第三步,配置 MCP Server 连接信息:
spring: ai: mcp: client: stdio: servers: filesystem: command: npx args: - "-y" - "@modelcontextprotocol/server-filesystem" - "/path/to/allowed/directory"第四步,在 ChatClient 里启用 MCP 工具:
@Bean public ChatClient chatClient(ChatClient.Builder builder, McpClient mcpClient) { return builder .defaultFunctions(mcpClient.listTools().stream() .map(this::toFunctionCallback) .toArray(FunctionCallback[]::new)) .build(); }注意:MCP Server 的启动命令和参数因 Server 而异,配置前一定要看对应 Server 的文档。我见过有人把
npx写成npm,排查了半天才发现是命令错了。
4. Skill:把业务能力打包成可复用的单元
Skill 这个词在不同框架里含义不一样。在 Claude 的语境里,Skill 是一组指令和资源的集合;在 Agent 框架里,Skill 通常指一个可复用的能力单元。我理解的 Skill 是介于 Function Calling 和完整 Agent 之间的中间层:它比单个函数复杂,包含多个步骤和决策逻辑;但比完整 Agent 轻量,不需要独立的规划能力。
举个例子:一个“订单退款”Skill 可能包含查询订单、判断是否可退、计算退款金额、发起退款、通知用户五个步骤。如果只用 Function Calling,你得定义五个函数然后让模型自己编排。用 Skill 的话,你把这五个步骤封装成一个refundOrder方法,模型只需要决定“要不要退款”,具体怎么退由 Skill 内部处理。
4.1 Skill 的设计原则:高内聚、低耦合
设计 Skill 的第一原则是一个 Skill 只做一件事。我见过有人把“用户管理”做成一个 Skill,里面包含注册、登录、改密码、查权限十几个方法,结果模型根本不知道该调哪个。正确的做法是拆成“用户注册”“用户登录”“密码修改”三个独立 Skill。
第二原则是Skill 的输入输出要简单。输入最好是几个基本类型参数,输出最好是一段自然语言描述。这样模型容易理解,也容易在多个 Skill 之间传递数据。
@Component @Description("处理订单退款,输入订单号和退款原因,返回退款结果") public class RefundOrderSkill implements Skill { @Override public String execute(Map<String, Object> params) { String orderId = (String) params.get("orderId"); String reason = (String) params.get("reason"); Order order = orderService.getById(orderId); if (!order.canRefund()) { return "订单" + orderId + "当前状态不支持退款"; } RefundResult result = refundService.refund(order, reason); return String.format("订单%s退款%s,退款金额%.2f元,预计%d个工作日到账", orderId, result.isSuccess() ? "成功" : "失败", result.getAmount(), result.getDays()); } }4.2 Skill 与 Function Calling 的配合方式
Skill 最终还是要通过 Function Calling 暴露给模型。区别在于Skill 内部可以调用多个 Function,也可以调用其他 Skill。这样形成层次结构:模型调用 Skill,Skill 调用 Function,Function 调用实际服务。
在 Spring AI 里,你可以把 Skill 包装成 FunctionCallback:
@Bean public FunctionCallback refundOrderFunction(RefundOrderSkill skill) { return FunctionCallback.builder() .function("refundOrder", skill::execute) .inputType(Map.class) .build(); }这样模型看到的是一个refundOrder工具,但实际执行的是整个退款流程。模型不需要知道退款有几个步骤,只需要知道“有这个能力”。
4.3 Skill 的版本管理和灰度发布
Skill 作为业务能力的封装,会随着业务变化而迭代。我的做法是给每个 Skill 加版本号,在描述里注明版本,这样模型调用时可以看到当前可用版本。灰度发布时,可以同时注册新旧两个版本的 Skill,通过配置控制哪个版本对哪些用户可见。
@Bean @Description("处理订单退款 v2,支持部分退款和优惠券退回") public FunctionCallback refundOrderV2(RefundOrderSkillV2 skill) { return FunctionCallback.builder() .function("refundOrderV2", skill::execute) .inputType(Map.class) .build(); }这个做法在业务频繁变更的场景下特别有用,新版本上线后先让内部用户试用,确认没问题再全量。
5. 三者协同的完整 Java 落地流程
前面分别讲了三个概念,现在把它们串起来。一个完整的 Agent 系统通常是这样的:Skill 层定义业务能力,MCP 层负责工具暴露和发现,Function Calling 层完成模型与实际执行的对接。
5.1 项目结构规划
我建议按职责分层:
src/main/java/com/example/agent/ ├── skill/ # Skill 定义 │ ├── OrderSkill.java │ ├── WeatherSkill.java │ └── RefundSkill.java ├── mcp/ # MCP Client 配置 │ └── McpConfig.java ├── function/ # FunctionCallback 注册 │ └── FunctionConfig.java └── agent/ # Agent 编排 └── AgentService.java这样分层的好处是每层可以独立测试。Skill 层用单元测试,MCP 层用集成测试,Function 层用 Mock 测试。
5.2 完整调用链路示例
假设用户说“帮我查一下订单 12345 能不能退款”。调用链路是这样的:
- AgentService 收到用户输入,调用 ChatClient
- ChatClient 把用户输入和可用工具列表传给模型
- 模型判断需要调用
queryOrder工具,输出调用指令 - Spring AI 执行
queryOrderFunctionCallback - FunctionCallback 内部调用 OrderSkill
- OrderSkill 查询数据库返回订单信息
- 模型根据订单信息判断“可以退款”,调用
refundOrder工具 - RefundOrderSkill 执行退款流程
- 模型生成最终回复“订单 12345 已退款成功,金额 99 元”
整个链路里,模型只负责决策“做什么”,Skill 负责“怎么做”。这个分工让模型的能力边界很清晰,也让业务逻辑的维护变得简单。
5.3 性能优化:减少不必要的模型调用
每次 Function Calling 都是一次模型调用,有延迟和成本。我的优化经验是在 Skill 内部做缓存,对于查询类操作,相同参数在短时间内直接返回缓存结果。
@Component public class OrderSkill implements Skill { private final Cache<String, Order> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(1000) .build(); public Order queryOrder(String orderId) { return cache.get(orderId, id -> orderService.getById(id)); } }另外,对于确定性的多步操作,可以在 Skill 内部直接完成,不需要模型逐步决策。比如退款流程,模型只需要决定“退款”,具体步骤由 Skill 内部顺序执行,不需要模型每一步都参与。
6. 常见问题与排查技巧实录
6.1 模型不调用工具怎么办
这是最常见的问题。排查顺序是:先看工具描述是否清楚,再看工具数量是否过多,最后看系统提示是否引导了模型使用工具。
我遇到过一个案例:模型死活不调用天气工具,检查发现工具描述写的是“获取天气数据”,模型理解成“这是一个数据接口,不是我能调用的工具”。改成“根据城市名称查询当前天气,返回温度和天气状况”之后立刻正常了。描述里要有动词,要说明输入输出。
6.2 MCP Server 连接失败怎么排查
MCP 连接失败通常有三个原因:命令不对、路径不对、权限不够。排查步骤:
- 在终端手动执行 MCP Server 启动命令,确认能正常启动
- 检查配置里的路径是否存在,是否有读写权限
- 查看 Spring AI 的日志,MCP Client 会打印连接过程和错误信息
提示:stdio 类型的 MCP Server 启动失败时,错误信息可能不会直接显示在应用日志里。可以在配置里加上
spring.ai.mcp.client.stdio.servers.xxx.log-events=true来查看详细日志。
6.3 Skill 执行超时怎么处理
Skill 内部可能调用多个外部服务,任何一个慢都会导致整体超时。我的做法是给每个外部调用设置独立超时,并且在 Skill 层面设置总超时。
public String execute(Map<String, Object> params) { CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { // Skill 内部逻辑 return doExecute(params); }); try { return future.get(10, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); return "操作超时,请稍后重试"; } }超时后返回给模型的信息要明确,模型可以根据这个信息决定是重试还是告知用户。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型不调用工具 | 描述不清、工具太多 | 检查工具描述和数量 | 优化描述,分组传入 |
| 模型调错工具 | 工具名称相似 | 查看模型输出的工具名 | 重命名工具,加前缀区分 |
| MCP 连接失败 | 命令/路径/权限问题 | 手动执行启动命令 | 修正配置,检查权限 |
| Skill 超时 | 外部服务慢 | 查看各步骤耗时 | 加超时和降级逻辑 |
| 参数格式错误 | 模型输出不稳定 | 打印模型原始输出 | 加参数校验和错误提示 |
这张表是我从三个项目的 issue 记录里整理出来的,覆盖了 80% 以上的常见问题。
7. 我踩过的坑和给你的建议
第一个坑是过早引入 MCP。有个项目一开始就上了 MCP,结果业务逻辑还没稳定,工具接口天天变,MCP Server 跟着改,反而比直接写 FunctionCallback 还慢。后来我们把 MCP 撤了,等业务稳定后再加回来。MCP 适合工具接口稳定的场景,业务探索期直接用 Function Calling 更快。
第二个坑是Skill 粒度太粗。有个“客服 Skill”包含了查订单、改地址、退款、投诉四个功能,模型经常在用户说“我要退款”的时候调用了整个 Skill,然后 Skill 内部不知道该执行哪个分支。后来拆成四个独立 Skill,问题解决。一个 Skill 只做一件事,这是铁律。
第三个坑是忽略模型输出格式的稳定性。不同模型、不同版本的输出格式可能有差异。我在代码里加了严格的 JSON 解析和容错处理,确保格式异常时不会直接崩溃。永远不要假设模型输出是完美的。
如果让我给刚接触这三个概念的 Java 开发者一个建议,那就是:先用 Function Calling 把最小闭环跑通,遇到工具管理问题再考虑 MCP,遇到业务复用问题再考虑 Skill。不要一上来就追求“架构完整”,能跑通的简单方案比跑不通的完美方案有价值得多。