1. 三条链路到底在选什么:Function-Calling、MCP、Skill 的边界与 Spring-AI 落地场景
Function-Calling、MCP、Skill 这三个词经常被放在同一张对比表里,但它们根本不在一个层级。我在实际项目里踩过的最大坑,就是一开始把三者当成"三选一"的方案,结果架构越写越乱。先把定位说清楚:Function-Calling 是大模型原生能力,属于协议层,模型输出一段结构化 JSON,告诉应用"我要调用哪个函数、入参是什么";Skill 是业务抽象层,是 Spring-AI 这类框架里的封装概念,一个 Skill 等于工具元数据加执行逻辑加异常处理加鉴权切面;MCP 是 Model Context Protocol,属于传输协议层,解决的是 Agent 应用和外部工具服务之间的远程标准化调用。
能做什么?Function-Calling 让模型具备"决定调不调、调哪个、传什么参"的能力,但执行动作由应用侧完成。适合谁?适合工具数量少、都在本进程内的场景。MCP 能做什么?它把工具发现、远程执行、结果回传从 LLM 上下文里剥离出来,Client 按需从 Server 拉取 tools/list,不用一次性把所有 schema 塞进 Prompt。适合谁?适合工具几十上百、需要独立部署、多套 Agent 共用同一批工具服务的大型系统。Skill 则是无论你用不用 MCP 都建议做的一层封装,隔离 Agent 和底层实现。
为什么这个选型在 Spring-AI 宿主下特别值得聊?因为 Spring-AI 同时提供了 Function-Calling 的@Tool注解体系、Skill 风格的业务封装惯例,以及 MCP Client 的接入能力。三条链路可以在同一个工程里共存,但代价完全不同。我实测下来,工具数量在 15 个以内时,纯 Function-Calling 加 Skill 封装的组合开发速度最快;一旦工具膨胀到 20 个以上,Prompt 里的工具 schema 会让 Token 成本明显上涨、推理变慢,这时候就该考虑把重型工具下沉成 MCP-Server。
本文要交付的是可复制的东西:一份application.yml、三段工具注册代码、三条链路各自的验证请求与预期返回,以及一张按场景做决策的对照表。所有链路统一用 TaoToken 的 Key 跑通,这样你不用为每个模型供应商单独配一套凭证,切换模型只改一个 model 字段。下面从环境准备开始,一步步把三条链路都跑起来。
2. 用 TaoToken 统一 Key 做前置准备:Base URL、API Key 与模型 ID 三件套
在动手写 Spring-AI 代码之前,先把凭证和依赖理顺。TaoToken 在这里扮演的角色是统一入口:你只需要一个 API Key、一个 Base URL,就能在 Function-Calling、MCP、Skill 三条链路里调用同一个模型能力,不用为每个链路单独申请供应商账号。这对做选型对比特别友好,因为变量被控制住了,差异只来自架构本身。
先拿 Key。打开控制台页面,登录后进入 API Keys 管理,创建一个新 Key 并复制保存。注意 Key 只在创建时完整显示一次,关掉页面就看不到了。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Keys 页面在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。如果你还没决定用哪个模型,可以先去模型对话页面手动试几条 prompt,确认模型对工具调用的支持程度,地址是 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。
三件套里最容易搞错的是 Base URL。TaoToken 的 API 端点是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI 兼容协议的 base-url 使用。很多同学把控制台地址误填进去,结果报 404。模型 ID 则取决于你选的模型,比如gpt-4o、claude-3-5-sonnet这类标识,具体以模型对话页面展示的为准。
Spring-AI 侧的依赖需要引三块:核心 starter、OpenAI 兼容的模型适配、以及 MCP Client 的 starter。Maven 里大致是这样:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-client-spring-boot-starter</artifactId> </dependency>版本号建议统一用 Spring-AI 的 BOM 管理,避免各模块版本错位。MCP 相关的 starter 在 1.0 之后才稳定,如果你用的是早期 milestone 版本,包名和配置项会有差异,这点后面排障章节会展开。
环境变量层面,我习惯把 Key 放在系统环境变量里而不是硬编码进 yml,这样本地和 CI 可以复用同一份配置。设置方式:
export TAOTOKEN_API_KEY="sk-你的key"Windows 下用setx TAOTOKEN_API_KEY "sk-你的key",设置完要重开终端才生效。这一步做完,前置准备就齐了:一个 Key、一个 Base URL、一个模型 ID,加上三块依赖。接下来进入配置环节,把三条链路都接到同一个模型上。
3. 可复制配置:application.yml 与三条链路的工具注册代码
这一节是全文的核心,所有片段都可以直接复制进工程。先看application.yml,我把三条链路的配置放在一起,用注释标出各自的作用域:
spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o temperature: 0.2 mcp: client: enabled: true name: spring-ai-mcp-client version: 1.0.0 type: SYNC request-timeout: 30s stdio: connections: filesystem: command: npx args: - "-y" - "@modelcontextprotocol/server-filesystem" - "/data/workspace"注意base-url写的是https://taotoken.net/api,不带尾斜杠,Spring-AI 会自动拼接/v1/chat/completions。api-key用占位符引用环境变量,避免明文入库。MCP 部分先配了一个 stdio 类型的 filesystem server 作为示例,实际项目里你可以换成 SSE 类型连远程服务。
接下来是 Function-Calling 链路的工具注册。Spring-AI 用@Tool注解把普通 Java 方法暴露成模型可调用的函数:
@Component public class OrderTools { @Tool(description = "根据订单号查询订单状态,返回状态码和描述") public OrderStatus queryOrder(@ToolParam(description = "订单号") String orderId) { // 实际业务里查数据库 return new OrderStatus(orderId, "PAID", "已支付"); } @Tool(description = "取消指定订单,仅未发货订单可取消") public CancelResult cancelOrder(@ToolParam(description = "订单号") String orderId) { return new CancelResult(orderId, true, "取消成功"); } }注册到 ChatClient 时,把工具实例传进去即可:
ChatClient client = ChatClient.builder(chatModel) .defaultTools(new OrderTools()) .build();Skill 链路的封装思路不同,它不依赖注解,而是把一组能力包成一个业务单元,对外暴露统一的FunctionCallback。下面是一个 Skill 的骨架:
@Component public class OrderQuerySkill implements FunctionCallback { private final OrderService orderService; public OrderQuerySkill(OrderService orderService) { this.orderService = orderService; } @Override public String getName() { return "order_query_skill"; } @Override public String getDescription() { return "订单查询技能,封装参数校验、异常捕获与结果格式化"; } @Override public String getInputTypeSchema() { return """ { "type": "object", "properties": { "orderId": {"type": "string", "description": "订单号"} }, "required": ["orderId"] } """; } @Override public String call(String input) { try { String orderId = JsonParser.parse(input).get("orderId").asText(); if (orderId == null || orderId.isBlank()) { return "{\"error\":\"订单号不能为空\"}"; } OrderStatus status = orderService.query(orderId); return JsonWriter.write(status); } catch (Exception e) { return "{\"error\":\"" + e.getMessage() + "\"}"; } } }Skill 的价值就在这段call方法里:参数校验、异常捕获、结果格式化全在这一层做完,上层 Agent 拿到的永远是干净的结构化结果。MCP 链路的注册则交给 starter 自动完成,你只需要在 yml 里配好 server 连接,Spring-AI 会启动时拉取 tools/list 并注册成可调用的工具。三条链路的配置和代码到这里就齐了,下一节验证它们是否真的跑通。
4. 验证请求与预期返回:三条链路各跑一次确认成功
配置写完不验证等于没写。这一节给三条链路各一个最小验证请求,以及你应该看到的返回形态。先确认应用能正常启动,日志里应该出现 MCP client 初始化成功的记录,以及工具注册数量。
Function-Calling 链路的验证,用一个会触发工具调用的 prompt:
String reply = client.prompt() .user("帮我查一下订单 A10086 的状态") .call() .content(); System.out.println(reply);预期返回里,模型不会直接编造订单状态,而是先输出一个工具调用意图,Spring-AI 拦截后执行queryOrder,再把结果回填给模型,最终你看到的自然语言回复类似"订单 A10086 当前状态为已支付"。如果你在日志里打开 debug 级别,能看到ToolCall的入参 JSON 和工具返回。这一步成功的关键标志是:模型没有幻觉出订单状态,而是真实调用了你的 Java 方法。
Skill 链路的验证方式略有不同,因为 Skill 是手动注册的 FunctionCallback:
ChatClient skillClient = ChatClient.builder(chatModel) .defaultFunctions("order_query_skill") .build(); String reply = skillClient.prompt() .user("订单 A10086 现在什么情况") .call() .content();预期返回和 Function-Calling 类似,但区别在于:即使orderService.query抛异常,Skill 的call方法也会捕获并返回结构化错误,模型收到的是{"error":"..."}而不是一个 500。这正是 Skill 封装的价值,线上不会因为一个工具异常导致整条对话链路崩掉。
MCP 链路的验证要确认工具是从远程 server 拉取的。启动后先看日志里有没有tools/list的返回,然后发一个会用到 filesystem 工具的请求:
String reply = client.prompt() .user("列出 /data/workspace 目录下的所有文件") .call() .content();预期返回是模型调用 MCP filesystem server 的list_directory工具,返回文件列表。这里的关键验证点是:工具 schema 不在你的 Prompt 里,而是 Client 启动时从 Server 动态拉取的。你可以在 MCP server 侧加日志,确认收到了tools/call请求。三条链路都跑通后,你手里就有了一份可对比的基线,接下来看常见报错怎么排。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 对照
排障这节按真实报错来组织,每条都给出定位思路。第一个高频错误是 401 Unauthorized,返回体里通常带invalid_api_key。原因基本是 Key 没读到或者读错了。检查顺序:环境变量是否在当前 shell 生效(echo $TAOTOKEN_API_KEY)、yml 里的占位符拼写是否一致、Key 是否被复制时带了空格。还有一种隐蔽情况是 Key 创建后没保存,控制台里只剩前缀,这种情况只能重新创建一个。
第二个是local proxy failed或连接超时类错误。这类报错通常出现在 MCP stdio 连接上,因为 stdio server 是通过子进程启动的。检查command和args是否可执行,比如npx是否在 PATH 里、@modelcontextprotocol/server-filesystem是否能正常下载。如果公司网络对 npm 源有限制,换成内网镜像或者提前把包装好。SSE 类型的 MCP server 则要检查 URL 是否可达、端口是否放通。
第三个是reading choices相关的解析错误,完整形态类似Cannot deserialize value of type ... from Object value (token 'JsonToken.START_OBJECT')或者reading choices字段失败。这通常意味着返回体结构和 Spring-AI 期望的不一致。最常见的原因是 base-url 配错了,比如把控制台地址填进去,返回的是 HTML 而不是 JSON。确认base-url是https://taotoken.net/api,且没有多余路径。另一个原因是模型 ID 写错,供应商返回了错误结构。
第四个是 OAuth 相关报错,出现在 MCP 远程 server 需要鉴权的场景。报错里会带401加WWW-Authenticate头。MCP 的鉴权配置在 client 侧,需要在 yml 里补上对应的 token 或者 OAuth 配置项。如果你用的是 stdio server,一般不走 OAuth,出现这个报错说明连错了 server 类型。
排查时有个通用技巧:把 Spring-AI 的日志级别调到 DEBUG,logging.level.org.springframework.ai=DEBUG,这样工具调用、请求体、返回体都会打出来,比盲猜快得多。另外,三条链路共用同一个 Key 和 Base URL,如果只有某一条报错,问题基本在该链路的配置或代码,而不是凭证本身。这个隔离思路能帮你快速缩小范围。
6. 按场景做落地决策:从 Function-Calling 起步,按痛点引入 MCP
选型不是一次性拍板,而是跟着痛点走。我的建议是起步阶段用 Function-Calling 加 Skill 封装,不要过早引入 MCP。判断标准很具体:工具数量在 15 个以内、工具都属于本业务域、希望同进程部署、工具集合相对固定,这套组合的开发速度和运维成本都是最优的。绝大多数业务 Agent 项目,比如业务系统内嵌的智能助手,优先这套。
什么时候该引入 MCP?满足任意一条就该考虑:工具数量膨胀到几十上百、工具集合需要运行时动态插拔、工具需要独立部署和独立权限管控、多套 Agent 要共用同一批工具服务、需要工具执行流式输出和会话隔离。代价也很明确:多维护一个 MCP-Server 服务,调试链路变长,故障点变多。所以引入的时机应该是痛点已经真实出现,而不是提前预防。
迁移路径可以很平滑。你先把外部重型工具下沉成 MCP-Server,然后在 Spring-AI 侧实现 MCP Client,再把它包装成一个 Skill。这样上层 Agent 的代码几乎不用改动,因为 Agent 看到的还是 Skill 接口。这个"Skill 作为门面、底层可切换"的设计,是我实测下来最省心的做法。
最后强调一个反面模式:绝对不要裸用 Function-Calling 而不做 Skill 封装。参数校验缺失、异常满天飞、结果格式混乱,线上维护成本极高。Skill 这层封装无论你用不用 MCP 都值得做,它隔离的是 Agent 和底层实现,让底层从本地方法换成远程 MCP 调用时,上层无感。
如果你准备长期做编码类 Agent 或者多工具编排,可以了解下 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。把三条链路都跑一遍,你自然就知道自己的项目该停在哪一档。