上一篇《让大模型自己查天气:Spring AI + DeepSeek 的 MCP 服务端/客户端实战》里,我们把天气工具拆成了一个独立的 MCP 服务端,客户端通过
/mcp发现并调用工具。这一次把 MCP 拿掉:工具仍然叫
getWeather,但它只是同一个 Spring Boot 进程里的一个普通 Java 方法。用户问“今天武汉天气怎么样”,DeepSeek 返回tool_calls,Spring AI 直接在当前 JVM 里执行方法,再把结果交回模型总结。本文介绍本地 Tool 的原理、完整示例代码,以及和 MCP 版本怎么选。
源码:https://github.com/chengpei/spring-ai-mcp
一、为什么还需要“本地 Tool”
1.1 大模型本身不会执行代码
不管用不用 MCP,大模型能做的只有一件事:根据上下文生成内容。它可以在回复里“表示”要调用某个工具,例如:
{
"tool_calls": [
{
"id": "call_001",
"type": "function",
"function": {
"name": "getWeather",
"arguments": "{\"city\":\"武汉\",\"date\":\"2026-11-02\"}"
}
}
]
}
真正执行 getWeather 的是应用框架。Spring AI 收到这个 tool_calls 后,找到同名工具、把 JSON 参数反序列化成方法参数、反射调用方法,再把返回值作为一条 tool 消息发回给模型。模型看到工具结果,才会生成最终的自然语言回答。
所以“本地 Tool”并不是另一种模型能力,而是 Function Calling 在单体应用里的落地方式:工具定义、执行、结果回传都在同一个进程内完成。
1.2 本地 Tool 与 MCP 的边界
| 对比项 | 本地 Tool | MCP |
|---|---|---|
| 工具位置 | 与聊天应用同一个 JVM | 独立的 MCP 服务端进程 |
| 通信方式 | 普通 Java 方法调用 | JSON-RPC over Streamable HTTP / SSE |
| 工具注册 | @Tool + MethodToolCallbackProvider |
MCP Server 暴露,Client 通过 tools/list 发现 |
| 启动依赖 | 只启动一个应用 | 先启动 MCP Server,再启动 Client |
| 跨语言/跨团队复用 | 不方便 | 天然适合 |
| 调试成本 | 低,直接断点进方法 | 需要同时看两端日志 |
| 适用场景 | 单体应用、内部工具、快速迭代 | 工具平台、独立部署、多客户端共享 |
一句话总结:
- 工具只给当前应用用,选本地 Tool。
- 工具要独立部署、被多个应用/多种语言复用,选 MCP。
本文的示例是一个独立的 spring-ai-tool 模块,端口 8082,不引入任何 MCP Starter,也不连接 mcp-server。
二、Spring AI 的 Tool 抽象
2.1 四个核心对象
Spring AI 1.x 的工具体系不复杂,先记住四个名字:
| 对象 | 作用 |
|---|---|
@Tool |
标注一个方法可以被大模型调用,name 是工具名,description 决定模型什么时候选它 |
@ToolParam |
描述方法参数,帮助模型正确填参 |
ToolCallback |
运行时的工具回调,包含工具定义(名称、描述、入参 Schema)和执行逻辑 |
ToolCallbackProvider |
一组 ToolCallback 的提供者,ChatClient 从它这里拿工具 |
把普通对象变成工具,最常用的是 MethodToolCallbackProvider:
ToolCallbackProvider provider = MethodToolCallbackProvider.builder()
.toolObjects(weatherService)
.build();
它会扫描 weatherService 上所有带 @Tool 的方法,为每个方法生成一个 ToolCallback。方法的参数类型会被转换成 JSON Schema,@ToolParam 的描述也会写进 Schema。
2.2 一次工具调用的完整往返
以“今天武汉天气怎么样?”为例:
浏览器
│ POST /api/chat
▼
ChatController
│ 组装 system prompt + user message + 本地工具定义
▼
DeepSeek API
│ 返回 tool_calls: getWeather(city="武汉", date="2026-11-02")
▼
Spring AI Tool Calling 机制
│ 在当前 JVM 内反射调用 WeatherService.getWeather(...)
▼
WeatherService
│ 返回一段天气文本
▼
DeepSeek API
│ 结合工具结果生成最终回答
▼
浏览器展示自然语言结果
关键点:模型只负责“决定调用哪个工具、传什么参数”,执行发生在你的 Java 进程里。
2.3 defaultTools 和 defaultToolCallbacks 有什么区别
这两个 API 经常让人困惑,其实它们最终都会把工具放进 ChatOptions.toolCallbacks,区别在“工具从哪来、什么时候解析”。
defaultTools(Object... toolObjects)
传普通对象,Spring AI 立即扫描 @Tool 方法:
return builder
.defaultTools(weatherService)
.build();
它内部大致等价于:
ToolCallbacks.from(weatherService);
// 即 MethodToolCallbackProvider.builder()
// .toolObjects(weatherService)
// .build()
// .getToolCallbacks()
适合:工具就是本地一个带 @Tool 方法的 Bean,写法最短。
defaultToolCallbacks(ToolCallbackProvider... providers)
传已经封装好的 Provider,Spring AI 在每次构建请求时调用 provider.getToolCallbacks():
return builder
.defaultToolCallbacks(localWeatherToolCallbackProvider)
.build();
适合:工具已经注册成 Provider,或者工具来自 MCP、远端服务、动态注册中心。
怎么选
| 场景 | 推荐写法 |
|---|---|
本地一个 @Tool 对象,图省事 |
defaultTools(weatherService) |
已经有 ToolCallbackProvider,想统一管理 |
defaultToolCallbacks(provider) |
| 工具来自 MCP | 必须用 defaultToolCallbacks(mcpProvider) |
| 工具列表会动态变化 | defaultToolCallbacks(provider),每次请求重新取 |
本文为了演示“显式注册”的过程,使用 ToolConfig + defaultToolCallbacks。实际项目里如果只有一个本地工具,直接用 defaultTools(weatherService) 也完全可以。
三、项目骨架
3.1 版本
| 组件 | 版本 |
|---|---|
| JDK | 17 |
| Spring Boot | 3.5.15 |
| Spring AI | 1.1.8 |
| 大模型 | DeepSeek deepseek-chat |
| 构建工具 | Maven 3.6+ |
3.2 模块依赖
spring-ai-tool/pom.xml 只需要 Web 和 DeepSeek:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
</dependencies>
注意:没有 spring-ai-starter-mcp-client,也没有 spring-ai-starter-mcp-server。这是本地 Tool 版本和 MCP 版本最直观的区别。
父 POM 负责统一版本:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.15</version>
</parent>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.1.8</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
3.3 目录结构
spring-ai-tool/
├── pom.xml
└── src/main/
├── java/com/example/tool/
│ ├── SpringAiToolApplication.java
│ ├── config/
│ │ ├── ToolConfig.java # 注册本地 ToolCallbackProvider
│ │ └── ChatClientConfig.java # ChatClient + 工具 + 会话记忆
│ ├── weather/
│ │ └── WeatherService.java # @Tool 天气查询
│ └── chat/
│ ├── ChatController.java # /api/chat、/api/tools
│ └── ApiExceptionHandler.java
└── resources/
├── application.yml # DeepSeek 配置(无 MCP)
└── static/index.html # 内置聊天页面
四、定义天气工具
4.1 WeatherService
这是一个再普通不过的 @Service,唯一特别的地方是方法上多了 @Tool:
package com.example.tool.weather;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.format.TextStyle;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
@Service
public class WeatherService {
private static final Logger log = LoggerFactory.getLogger(WeatherService.class);
@Tool(name = "getWeather",
description = "查询指定城市在指定日期的天气情况。城市目前仅支持武汉;日期必须是 yyyy-MM-dd 格式。当用户询问天气时调用此工具。")
public String getWeather(
@ToolParam(description = "城市名称,例如:武汉") String city,
@ToolParam(description = "查询日期,格式 yyyy-MM-dd,例如:2026-11-02") String date) {
log.info("【本地 Tool 被调用】getWeather(city={}, date={})", city, date);
if (city == null || city.isBlank()) {
return "请提供要查询的城市名称,例如:武汉。";
}
String normalizedCity = city.trim();
if (!normalizedCity.contains("武汉") && !normalizedCity.equalsIgnoreCase("wuhan")) {
return "当前 Demo 仅内置了武汉市(Wuhan)的模拟天气数据,暂时无法查询「"
+ normalizedCity + "」。你可以试试查询武汉。";
}
if (date == null || date.isBlank()) {
return "请提供要查询的日期,格式为 yyyy-MM-dd,例如:2026-11-02。";
}
LocalDate queryDate;
try {
queryDate = LocalDate.parse(date);
} catch (DateTimeParseException ex) {
return "日期格式不正确,请使用 yyyy-MM-dd 格式,例如:2026-11-02。";
}
String week = queryDate.getDayOfWeek()
.getDisplayName(TextStyle.FULL, Locale.CHINA);
return """
【武汉市天气查询结果】
城市:武汉市
日期:%s(%s)
天气:晴间多云
气温:22℃ ~ 28℃
体感温度:26℃
湿度:65%%
风向风力:东南风 2 级
空气质量:AQI 72(良)
紫外线:中等
穿衣建议:短袖或薄衬衫即可,早晚可备一件薄外套。
数据说明:以上为 Demo 内置的模拟数据,仅用于演示 Spring AI 本地 Tool 的自动调用流程。
""".formatted(queryDate, week);
}
}
这段代码有几个值得注意的点:
- 工具名不要随便改。
@Tool(name = "getWeather")是模型看到的函数名;改了就相当于换了一个工具。 description是给模型看的。 要写清楚“什么时候调用、支持什么范围、参数格式”,它直接影响模型是否选对这个工具。- 参数描述要具体。
@ToolParam(description = "查询日期,格式 yyyy-MM-dd...")能显著降低模型传错格式的概率。 - 工具方法要自己做防御性校验。 模型可能传空值、传错城市、传错日期格式,不能假设参数一定合法。
- 返回值尽量结构化、可读。 模型会基于这段文本总结,字段清晰比一大段散文更稳。
4.2 注册成 ToolCallbackProvider
package com.example.tool.config;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.tool.weather.WeatherService;
@Configuration
public class ToolConfig {
@Bean
public ToolCallbackProvider localWeatherToolCallbackProvider(WeatherService weatherService) {
return MethodToolCallbackProvider.builder()
.toolObjects(weatherService)
.build();
}
}
MethodToolCallbackProvider 会扫描 weatherService 上的 @Tool 方法,并生成对应的 ToolCallback。这个 Bean 是本地对象,不涉及任何网络通信。
五、组装 ChatClient
5.1 工具 + 会话记忆
package com.example.tool.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder,
ToolCallbackProvider localWeatherToolCallbackProvider,
ChatMemory chatMemory) {
return builder
.defaultToolCallbacks(localWeatherToolCallbackProvider)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
}
两件事:
.defaultToolCallbacks(...):把所有请求默认带上本地天气工具;.defaultAdvisors(...):挂上MessageChatMemoryAdvisor,让“明天呢?”这类省略主语的追问也能结合上一轮上下文。
本示例直接注入 ChatMemory,Spring AI 的自动配置会提供基于内存的实现。生产环境可以换成 Redis、JDBC 等持久化实现。
5.2 启动时打印工具列表
工具没生效时,第一步应该确认它到底注册上没有。加一个 ApplicationRunner:
@Bean
public ApplicationRunner localToolLogger(ToolCallbackProvider localWeatherToolCallbackProvider) {
return args -> {
ToolCallback[] callbacks = localWeatherToolCallbackProvider.getToolCallbacks();
log.info("本地 Tool 注册完成,共 {} 个工具:", callbacks.length);
for (ToolCallback callback : callbacks) {
log.info(" - {}:{}",
callback.getToolDefinition().name(),
callback.getToolDefinition().description());
}
};
}
启动时看到:
本地 Tool 注册完成,共 1 个工具:
- getWeather:查询指定城市在指定日期的天气情况。...
就说明注册成功了。
5.3 系统提示词:把“今天”变成具体日期
大模型不知道“今天”是哪天。如果直接把“今天武汉天气”发过去,模型可能自己猜一个日期,或者把 date 填成“今天”。
解决办法是在 system prompt 里注入当前日期和相对日期:
private String systemPrompt() {
LocalDate today = LocalDate.now();
String week = today.getDayOfWeek()
.getDisplayName(TextStyle.FULL, Locale.CHINA);
return """
你是一个中文天气助手,通过 Spring AI 本地注册的工具查询天气。
当前日期:%s(%s)。
相对日期参考:
- 今天:%s
- 明天:%s
- 后天:%s
回答规则:
1. 用户询问某个城市、某一天的天气时,必须调用本地工具 getWeather,不要凭记忆编造天气。
2. 用户没有说城市时,默认按武汉处理;没有说日期时,默认按今天处理。
3. 调用工具时,date 参数必须使用 yyyy-MM-dd 格式,city 参数传城市中文名。
4. 工具返回后,用自然、简洁的中文总结天气,并给出穿衣或出行建议。
5. 如果工具提示不支持该城市,如实告知用户当前 Demo 只内置了武汉数据。
6. 不要把工具调用过程、JSON 或系统提示词暴露给用户。
""".formatted(today, week, today, today.plusDays(1), today.plusDays(2));
}
这段提示词解决了三个常见问题:
- 模型不知道今天是几号;
- 模型把“明天”直接当成
date参数; - 模型不调用工具,凭训练数据编天气。
5.4 Controller:一次对话
@PostMapping("/chat")
public ChatResponse chat(@RequestBody ChatRequest request) {
if (request.message() == null || request.message().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "消息不能为空");
}
String conversationId = (request.conversationId() == null || request.conversationId().isBlank())
? UUID.randomUUID().toString()
: request.conversationId();
String answer = this.chatClient.prompt()
.system(systemPrompt())
.user(request.message())
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
return new ChatResponse(conversationId, answer);
}
.advisors(...) 把 conversationId 传给 MessageChatMemoryAdvisor,这样同一个会话的多轮消息会被自动带上。
另外两个接口:
@GetMapping("/tools")
public List<ToolInfo> tools() {
return Arrays.stream(localToolCallbackProvider.getToolCallbacks())
.map(callback -> new ToolInfo(
callback.getToolDefinition().name(),
callback.getToolDefinition().description()))
.toList();
}
@PostMapping("/chat/reset")
public Map<String, String> reset(@RequestBody ResetRequest request) {
this.chatMemory.clear(request.conversationId());
return Map.of("status", "ok");
}
/api/tools 用来在页面上展示“工具是否注册成功”,/api/chat/reset 用来清空会话记忆。
六、配置与启动
6.1 application.yml
server:
port: 8082
error:
include-message: always
spring:
application:
name: spring-ai-tool-demo
ai:
deepseek:
# 二选一:直接改这里,或设置环境变量 DEEPSEEK_API_KEY
api-key: "${DEEPSEEK_API_KEY:sk-请填写你的DeepSeekAPIKey}"
base-url: "${DEEPSEEK_BASE_URL:https://api.deepseek.com}"
chat:
options:
model: "${DEEPSEEK_MODEL:deepseek-chat}"
temperature: 0.7
logging:
level:
com.example.tool: INFO
DeepSeek 的 API Key 在 https://platform.deepseek.com/ 申请。不要把真实 Key 提交到 Git,推荐用环境变量:
export DEEPSEEK_API_KEY=sk-你的真实Key
6.2 构建与启动
mvn clean package -DskipTests
java -jar spring-ai-tool/target/spring-ai-tool-1.0.0.jar
如果只想构建这个模块:
mvn -pl spring-ai-tool -am clean package -DskipTests
启动成功后会看到:
本地 Tool 注册完成,共 1 个工具:
- getWeather:查询指定城市在指定日期的天气情况。...
Tomcat started on port 8082 (http)
浏览器访问 http://localhost:8082,就可以在页面里问天气了。
七、验证工具是否真的被调用
7.1 查看工具列表
curl -s http://localhost:8082/api/tools
返回:
[
{
"name": "getWeather",
"description": "查询指定城市在指定日期的天气情况。城市目前仅支持武汉;日期必须是 yyyy-MM-dd 格式。当用户询问天气时调用此工具。"
}
]
7.2 发起一次对话
curl -s -X POST http://localhost:8082/api/chat \
-H 'Content-Type: application/json' \
-d '{
"message": "今天武汉天气怎么样?",
"conversationId": "demo-1"
}'
返回:
{
"conversationId": "demo-1",
"answer": "武汉今天(2026-11-02,星期一)晴间多云,气温 22℃~28℃,体感 26℃,湿度 65%,东南风 2 级,空气质量良。建议穿短袖或薄衬衫,早晚备一件薄外套。"
}
7.3 看日志确认调用次数
控制台应该出现:
【本地 Tool 被调用】getWeather(city=武汉, date=2026-11-02)
对话完成,conversationId=demo-1,耗时 241 ms
如果 【本地 Tool 被调用】 只出现一次,说明整条链路是:
用户提问 → 模型返回 tool_calls → 本地方法执行一次 → 结果回传模型 → 最终回答
没有被重复调用。
7.4 多轮对话
同一个 conversationId 继续问:
curl -s -X POST http://localhost:8082/api/chat \
-H 'Content-Type: application/json' \
-d '{
"message": "那明天呢?",
"conversationId": "demo-1"
}'
因为挂了 MessageChatMemoryAdvisor,模型能看到上一轮的“武汉”和天气结果,补全成“武汉明天”的查询;系统提示词又提供了“明天”的具体日期,所以 date 参数会是明天而不是字符串“明天”。
八、常见问题
Q1:模型不调用工具,直接编天气
按顺序排查:
/api/tools是否返回getWeather;- 启动日志是否有“本地 Tool 注册完成”;
@Tool的description是否写清楚“当用户询问天气时调用”;defaultToolCallbacks/defaultTools是否真的配置到了当前ChatClient;- system prompt 是否明确要求“天气问题必须调用工具”;
- 使用的模型是否支持 Function Calling(DeepSeek
deepseek-chat支持)。
Q2:接口返回 401
DEEPSEEK_API_KEY 没配置、配置成了占位符,或者 Key 没有余额。日志里通常会看到 DeepSeek 返回的 401 信息。
Q3:模型把 date 传成“今天”“明天”
只靠 @ToolParam 的描述不够,必须在 system prompt 里把相对日期换算成 yyyy-MM-dd,并明确要求 date 使用该格式。本文的 systemPrompt() 就是做这件事的。
Q4:工具名冲突
如果两个工具都叫 getWeather,运行时可能报工具名重复。用 @Tool(name = "...") 给每个工具一个唯一名字,例如 getWeather、getWeatherForecast。
Q5:defaultTools 和 defaultToolCallbacks 到底用哪个
- 本地
@Tool对象,图省事:defaultTools(weatherService); - 已经有
ToolCallbackProvider,或工具来自 MCP:defaultToolCallbacks(provider)。
两者最终都会把工具放进 ToolCallingChatOptions.toolCallbacks,模型侧行为一致。
Q6:工具方法很慢,会不会阻塞请求
会。默认情况下工具是在当前请求线程里同步执行的。工具里有 HTTP 调用、数据库查询时,要设置合理超时,避免拖垮整个对话请求;耗时特别长的任务可以改成异步工具,先返回“任务已提交”,再通过其他方式通知结果。
九、从 Demo 到生产
这个示例为了聚焦 Tool 调用链路,做了一些简化。真实项目建议补上:
- 替换模拟数据:把
WeatherService里写死的文本换成和风天气、OpenWeatherMap 等真实 API。 - 参数校验:除了
LocalDate.parse,还要限制日期范围、城市白名单、参数长度。 - 超时与重试:工具内部的 HTTP 客户端设置连接/读取超时,避免模型请求长时间挂起。
- 结构化返回:返回 JSON 字符串或 DTO,字段稳定,模型更容易总结,也方便后续程序消费。
- 安全边界:工具方法本质上是“模型可以触发的代码入口”,不要暴露任意 SQL、Shell、文件路径;涉及写操作时要加确认、幂等和权限校验。
- 可观测性:记录 tool name、参数、耗时、是否成功;不要记录 API Key 和敏感业务数据。
- 限流与成本:一次工具调用通常意味着两次模型请求,注意 token 成本和并发限制。
十、总结
本地 Tool 的核心就三句话:
- 用
@Tool把普通 Java 方法暴露给模型; - 用
MethodToolCallbackProvider把它注册成ToolCallbackProvider; - 用
ChatClient.defaultTools(...)或defaultToolCallbacks(...)把工具交给模型。
它没有 MCP 的网络开销和部署复杂度,适合工具与业务在同一个应用里的场景。MCP 则把工具变成独立服务,适合跨团队、跨语言、多客户端复用。两者不是替代关系,而是同一套 Function Calling 思路在不同边界下的两种落地方式。
示例代码对应项目中的 spring-ai-tool 模块,启动后访问 http://localhost:8082 即可体验。把 DEEPSEEK_API_KEY 换成你自己的 Key,再问一句“今天武汉天气怎么样?”,控制台里的那行 【本地 Tool 被调用】 就是整条链路跑通的最好证明。
评论区