目 录CONTENT

文章目录

让大模型自己查天气:Spring AI + DeepSeek 的本地Tool

成培培
2026-11-02 / 0 评论 / 0 点赞 / 5 阅读 / 0 字

上一篇《让大模型自己查天气: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);
    }
}

这段代码有几个值得注意的点:

  1. 工具名不要随便改。 @Tool(name = "getWeather") 是模型看到的函数名;改了就相当于换了一个工具。
  2. description 是给模型看的。 要写清楚“什么时候调用、支持什么范围、参数格式”,它直接影响模型是否选对这个工具。
  3. 参数描述要具体。 @ToolParam(description = "查询日期,格式 yyyy-MM-dd...") 能显著降低模型传错格式的概率。
  4. 工具方法要自己做防御性校验。 模型可能传空值、传错城市、传错日期格式,不能假设参数一定合法。
  5. 返回值尽量结构化、可读。 模型会基于这段文本总结,字段清晰比一大段散文更稳。

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:模型不调用工具,直接编天气

按顺序排查:

  1. /api/tools 是否返回 getWeather;
  2. 启动日志是否有“本地 Tool 注册完成”;
  3. @Tool 的 description 是否写清楚“当用户询问天气时调用”;
  4. defaultToolCallbacks / defaultTools 是否真的配置到了当前 ChatClient;
  5. system prompt 是否明确要求“天气问题必须调用工具”;
  6. 使用的模型是否支持 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 调用链路,做了一些简化。真实项目建议补上:

  1. 替换模拟数据:把 WeatherService 里写死的文本换成和风天气、OpenWeatherMap 等真实 API。
  2. 参数校验:除了 LocalDate.parse,还要限制日期范围、城市白名单、参数长度。
  3. 超时与重试:工具内部的 HTTP 客户端设置连接/读取超时,避免模型请求长时间挂起。
  4. 结构化返回:返回 JSON 字符串或 DTO,字段稳定,模型更容易总结,也方便后续程序消费。
  5. 安全边界:工具方法本质上是“模型可以触发的代码入口”,不要暴露任意 SQL、Shell、文件路径;涉及写操作时要加确认、幂等和权限校验。
  6. 可观测性:记录 tool name、参数、耗时、是否成功;不要记录 API Key 和敏感业务数据。
  7. 限流与成本:一次工具调用通常意味着两次模型请求,注意 token 成本和并发限制。

十、总结

本地 Tool 的核心就三句话:

  1. 用 @Tool 把普通 Java 方法暴露给模型;
  2. 用 MethodToolCallbackProvider 把它注册成 ToolCallbackProvider;
  3. 用 ChatClient.defaultTools(...) 或 defaultToolCallbacks(...) 把工具交给模型。

它没有 MCP 的网络开销和部署复杂度,适合工具与业务在同一个应用里的场景。MCP 则把工具变成独立服务,适合跨团队、跨语言、多客户端复用。两者不是替代关系,而是同一套 Function Calling 思路在不同边界下的两种落地方式。

示例代码对应项目中的 spring-ai-tool 模块,启动后访问 http://localhost:8082 即可体验。把 DEEPSEEK_API_KEY 换成你自己的 Key,再问一句“今天武汉天气怎么样?”,控制台里的那行 【本地 Tool 被调用】 就是整条链路跑通的最好证明。

0

评论区