10. 工具调用与联网搜索

5 min

AI 模型本身只能根据训练数据回答问题,无法获取实时信息、执行计算或操作外部系统。工具调用(Tool Calling) 让模型可以在对话过程中请求调用外部函数,由框架执行后将结果返回给模型,从而大幅扩展 AI 的能力边界。

接下来实现三个工具:获取当前时间、数学计算、联网搜索,并将它们集成到现有的聊天系统中。

1. 工具调用原理

工具调用原理

2. 定义工具

Spring AI 使用 @Tool 注解定义工具,非常简洁。每个工具就是一个普通的 Java 方法。

2.1 时间工具

src/main/java/com/albertstack/aichat/tool/DateTimeTool.java

java
package com.albertstack.aichat.tool;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

@Component
public class DateTimeTool {

    @Tool(description = "获取指定时区的当前日期和时间。如果用户没有指定时区,默认使用 Asia/Shanghai")
    public String getCurrentDateTime(
            @ToolParam(description = "时区 ID,例如 Asia/Shanghai、America/New_York", required = false)
            String timezone) {
        ZoneId zoneId = timezone != null ? ZoneId.of(timezone) : ZoneId.of("Asia/Shanghai");
        return LocalDateTime.now(zoneId)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss (EEEE)"));
    }
}

2.2 数学计算工具

src/main/java/com/albertstack/aichat/tool/MathTool.java

java
package com.albertstack.aichat.tool;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

@Component
public class MathTool {

    @Tool(description = "计算数学表达式。支持加减乘除、幂运算、取余等。当需要精确计算时使用此工具")
    public String calculate(
            @ToolParam(description = "数学表达式,例如 (3 + 5) * 2、2^10、100 % 7")
            String expression) {
        try {
            // 使用 JavaScript 引擎计算表达式(安全沙箱)
            Object result = new javax.script.ScriptEngineManager()
                    .getEngineByName("js")
                    .eval(expression);
            return expression + " = " + result;
        } catch (Exception e) {
            return "计算失败: " + e.getMessage();
        }
    }
}

2.3 联网搜索工具

联网搜索是最实用的工具。我们使用 Tavily,一个专为 AI 设计的搜索 API,支持全网搜索并返回结构化结果。

application.yaml 中添加 Tavily 配置:

yaml
ai:
  tool:
    tavily:
      api-key: your-tavily-api-key

src/main/java/com/albertstack/aichat/tool/WebSearchTool.java

java
package com.albertstack.aichat.tool;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

import java.util.List;
import java.util.Map;

@Component
public class WebSearchTool {

    private final WebClient webClient;
    private final String apiKey;

    public WebSearchTool(@Value("${ai.tool.tavily.api-key}") String apiKey) {
        this.webClient = WebClient.builder()
                .baseUrl("https://api.tavily.com")
                .build();
        this.apiKey = apiKey;
    }

    @Tool(description = "搜索互联网获取实时信息。当用户询问最新新闻、实时数据、不确定的事实时使用此工具")
    public String searchWeb(
            @ToolParam(description = "搜索关键词") String query) {
        try {
            Map<String, Object> response = webClient.post()
                    .uri("/search")
                    .bodyValue(Map.of(
                            "api_key", apiKey,
                            "query", query,
                            "max_results", 5,
                            "include_answer", true
                    ))
                    .retrieve()
                    .bodyToMono(Map.class)
                    .block();

            if (response == null) return "搜索无结果";

            StringBuilder sb = new StringBuilder();

            // Tavily 直接返回的 AI 摘要回答
            String answer = (String) response.get("answer");
            if (answer != null && !answer.isBlank()) {
                sb.append("摘要: ").append(answer).append("\n\n");
            }

            // 搜索结果列表
            List<Map<String, Object>> results = (List<Map<String, Object>>) response.get("results");
            if (results != null && !results.isEmpty()) {
                sb.append("搜索结果:\n");
                for (Map<String, Object> result : results) {
                    String title = (String) result.get("title");
                    String content = (String) result.get("content");
                    String url = (String) result.get("url");
                    if (title != null) {
                        sb.append("- **").append(title).append("**\n");
                        if (content != null) sb.append("  ").append(content, 0, Math.min(200, content.length())).append("\n");
                        if (url != null) sb.append("  来源: ").append(url).append("\n");
                    }
                }
            }

            return sb.isEmpty() ? "未找到相关信息" : sb.toString();
        } catch (Exception e) {
            return "搜索失败: " + e.getMessage();
        }
    }
}

Tavily 的 include_answer: true 参数会让 API 直接返回一个 AI 摘要,加上原始搜索结果,模型可以基于这些信息组织更准确的回答。

3. 注册工具到 ChatClient

工具需要注册到 ChatClient 才能被模型调用。更新 AiConfig,将工具注册为默认工具:

src/main/java/com/albertstack/aichat/config/AiConfig.java

java
package com.albertstack.aichat.config;

import com.albertstack.aichat.tool.DateTimeTool;
import com.albertstack.aichat.tool.MathTool;
import com.albertstack.aichat.tool.WebSearchTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Configuration
public class AiConfig {

    @Value("${ai.model.no-think}")
    private String noThinkModel;

    @Value("${ai.prompt.default}")
    private Resource defaultPrompt;

    @Value("${ai.prompt.no-think}")
    private Resource noThinkPrompt;

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder,
                                 DateTimeTool dateTimeTool,
                                 MathTool mathTool,
                                 WebSearchTool webSearchTool) throws IOException {
        return builder
                .defaultSystem(defaultPrompt.getContentAsString(StandardCharsets.UTF_8))
                .defaultTools(dateTimeTool, mathTool, webSearchTool)
                .build();
    }

    @Bean
    public ChatClient noThinkChatClient(ChatClient.Builder builder,
                                        DateTimeTool dateTimeTool,
                                        MathTool mathTool,
                                        WebSearchTool webSearchTool) throws IOException {
        return builder
                .defaultSystem(noThinkPrompt.getContentAsString(StandardCharsets.UTF_8))
                .defaultTools(dateTimeTool, mathTool, webSearchTool)
                .defaultOptions(OllamaChatOptions.builder()
                        .model(noThinkModel)
                        .build())
                .build();
    }
}

关键变化:

  • 三个工具通过构造函数注入(它们是 @Component,Spring 自动管理)
  • .defaultTools(dateTimeTool, mathTool, webSearchTool) 将工具注册为默认工具,每次对话都可用
  • 两个 ChatClient 都注册了相同的工具,思考模型和非思考模型都能使用

4. 更新系统提示词

工具虽然已注册,但模型需要知道何时该使用它们。更新系统提示词,添加工具使用指引:

src/main/resources/prompts/default.txt

text
你是一个严谨的AI助手,请严格遵循以下规则:

【事实性要求】
- 只能基于已知信息或合理推断回答问题
- 不允许编造、虚构或猜测事实
- 若信息不足,请明确说明"无法确定"或"需要更多信息"

【表达要求】
- 使用简洁、清晰、专业的中文回答
- 避免冗余、废话和重复内容
- 优先给出结论,再补充必要解释

【风格要求】
- 保持客观、中立、技术导向
- 不使用情绪化表达或迎合性语言

【工具使用】
- 当用户询问当前时间、日期时,使用时间工具获取准确时间
- 当需要数学计算时,使用计算工具确保结果准确
- 当用户询问实时信息、最新新闻、不确定的事实时,使用搜索工具获取信息
- 基于工具返回的结果组织回答,注明信息来源

请按照以上规则回答用户问题。

src/main/resources/prompts/no-think.txt

text
你是一个高效的AI助手,请严格遵循以下规则:

【核心要求】
- 直接给出结论,不展示推理过程
- 不编造事实,不确定时直接说明

【表达要求】
- 使用简洁、清晰的中文
- 控制输出长度,避免冗余解释

【行为约束】
- 不输出思考过程
- 不进行不必要的扩展分析

【工具使用】
- 需要当前时间时,使用时间工具
- 需要精确计算时,使用计算工具
- 需要实时信息时,使用搜索工具

请直接回答用户问题。

5. 后端测试

5.1 ToolCallingTest

src/test/java/com/albertstack/aichat/ai/ToolCallingTest.java

java
package com.albertstack.aichat.ai;

import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import lombok.extern.slf4j.Slf4j;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
@SpringBootTest
class ToolCallingTest {

    @Autowired
    private ChatClient chatClient;

    @Test
    void dateTimeTool_shouldReturnCurrentTime() {
        String response = chatClient.prompt()
                .user("现在几点了?")
                .call()
                .content();

        log.info("时间查询回复: {}", response);
        assertNotNull(response);
        // 回复中应包含时间相关内容(数字、冒号等)
        assertTrue(response.matches(".*\\d{2}.*"),
                "回复应包含时间信息,实际: " + response);
        log.info(" ✅ 时间工具调用成功");
    }

    @Test
    void mathTool_shouldCalculateExpression() {
        String response = chatClient.prompt()
                .user("请帮我计算 (123 + 456) * 789 是多少?")
                .call()
                .content();

        log.info("计算回复: {}", response);
        assertNotNull(response);
        // 123 + 456 = 579, 579 * 789 = 456831
        assertTrue(response.contains("456831"),
                "回复应包含正确计算结果 456831,实际: " + response);
        log.info(" ✅ 数学工具调用成功");
    }

    @Test
    void webSearchTool_shouldReturnSearchResults() {
        String response = chatClient.prompt()
                .user("请搜索一下 Spring Framework 是什么?")
                .call()
                .content();

        log.info("搜索回复: {}", response);
        assertNotNull(response);
        assertFalse(response.isBlank(), "搜索回复不应为空");
        log.info(" ✅ 联网搜索工具调用成功");
    }
}

6. 前端更新

工具调用完全在后端完成,对前端 透明无感,用户照常输入问题,AI 在需要时自动调用工具,前端只看到最终的流式回复。

不需要修改 ChatView 或任何前端代码。这正是 Spring AI 工具调用的优势:后端增加能力,前端无需改动

7. 功能测试

  1. 启动后端,打开 http://localhost:5173,登录并创建话题
  2. 时间查询:输入 "现在几点了?",AI 应该返回准确的当前时间(而不是说"我无法获取时间")
  3. 数学计算:输入 "请帮我算一下 2 的 32 次方",AI 应该返回 4,294,967,296(而不是自己估算一个近似值)
  4. 联网搜索:输入 "帮我查询一下这最近这一周最火的 Github 项目有哪些?",AI 应该返回基于搜索结果的回答
  5. 组合使用:输入 "现在纽约时间几点?搜索一下纽约今天有什么大事件?",观察 AI 是否同时调用时间工具和搜索工具
  6. 普通对话:输入 "你好",AI 不应该调用任何工具(模型自己判断不需要)

8. 扩展方向

工具调用机制是可扩展的,你可以根据业务需求添加更多工具:

工具 用途 实现方式
天气查询 获取指定城市实时天气 调用天气 API(如和风天气)
数据库查询 查询业务数据 注入 Repository,执行查询
邮件发送 让 AI 帮你发邮件 调用 JavaMail API
代码执行 运行用户提供的代码片段 沙箱执行(需注意安全)
文件操作 读写文件内容 操作本地或 OSS 文件

添加新工具只需三步:

  1. 创建 @Component 类,方法加 @Tool 注解
  2. AiConfig.defaultTools() 中注册
  3. (可选)更新系统提示词,引导模型使用

9. 小结

至此 AI 对话系统已接入工具调用能力:

新增内容
工具定义 DateTimeTool(时间查询)、MathTool(数学计算)、WebSearchTool(联网搜索)
配置 AiConfig 注册 defaultTools,两个 ChatClient 均可使用工具
系统提示词 default.txtno-think.txt 新增【工具使用】指引
测试 ToolCallingTest(3 个用例验证三种工具)
前端 无需改动(工具调用对前端透明)

新增的项目文件:

文件 用途
tool/DateTimeTool.java 时间查询工具
tool/MathTool.java 数学计算工具
tool/WebSearchTool.java 联网搜索工具
ai/ToolCallingTest.java 工具调用测试

最后一章是专栏总结,我们将回顾整个项目的架构演进,梳理技术要点,并探讨后续扩展方向。