第五章-工具调用

之前我们通过 RAG 技术让 AI 应用具备了根据外部知识库来获取信息并回答的能力,但是直到目前为止,AI应用还只是个"知识问答助手"。本节我们可以利用 工具调用 特性,实现更多需求如:网页抓取、联网搜索、资源下载、终端操作、文件读写、pdf生成等。

一、工具调用简介

什么是工具调用?

工具调用(Tool Calling/Function Calling)可以理解为让Al大模型借用外部工具 来完成它自己做不到的事情。 跟人类一样,如果只凭手脚完成不了工作,那么就可以利用工具箱来完成。 工具可以是任何东西,比如网页搜索、对外部 API的调用、访问外部数据、或执行特定的代码等。 比如用户提问"帮我查询上海最新的天气",AI 本身并没有这些知识,它就可以调用""查询天气工具",来完成任务。 目前工具调用技术发展的已经比较成熟了,几乎所有主流的、新出的 Al 大模型和 AI 应用开发平台都支持工具调用。

工具调用的原理

并不是让AI服务器自己调用工具,也不是把工具代码给AI让它执行,AI需要向我们的程序提出调用请求,并附带工具名称对应参数,当我们的程序执行完后再把结果告诉AI让他继续工作。 比如:

  1. 用户提出问题:"编程导航网站有哪些热门文章?
  2. 程序将问题传递给大模型
  3. 大模型分析问题,判断需要使用工具(网页抓取工具)来获取信息
  4. 大模型输出工具名称和参数(网页抓取工具,URL参数为 codefather.cn)
  5. 程序接收工具调用请求,执行网页抓取操作
  6. 工具执行抓取并返回文章数据7.程序将抓取结果传回给大模型
  7. 大模型分析网页内容,生成关于编程导航热门文章的回答
  8. 程序将大模型的回答返回给用户

为何要这么设计?这样不是会导致程序请求AI多次造成token浪费吗?为何不让AI服务直接调用工具程序? 是出于安全性 考虑,这样设计其实AI的所有调用工具行为都可以通过我们的程序来控制,我们能掌握AI的行为,如果将工具调用能力直接赋予AI就会导致一些不确定的安全性问题,AI最大的弊端就是不确定性

工具调用流程

  1. 工具定义:程序告诉 A!"你可以使用这些工具",并描述每个工具的功能和所需参数
  2. 工具选择:AI 在对话中判断需要使用某个工具,并准备好相应的参数
  3. 返回意图:A| 返回"我想用 XX 工具,参数是 XXX"的信息
  4. 工具执行:我们的程序接收请求,执行相应的工具操作
  5. 结果返回:程序将工具执行的结果发回给 AI
  6. 继续对话:AI 根据工具返回的结果,生成最终回答给用户

通过上述流程,我们会发现,程序需要和 AI 多次进行交互、还要能够执行对应的工具,怎么实现这些呢?我们当然可以自主开发,不过还是更推荐使用 Spring Al、La1gChain 等开发框架。此外,有些 A1大模型服务商也提供了对应的 SDK,都能够简化代码编写。 不是所有大模型都支持工具调用,可在Spring AI 官方文档查看不同模型支持情况

二、Spring AI工具开发

工具的定义

在 Spring AI 中,定义工具主要有两种模式:基于 Method⁢s 方法或者 Fun⁡ctions 函数式编程。

记结论就行了,我们只用学习 基于 Methods 方法 来定义工具,另外一种了解即可。原因是 Methods 方式更容易编写、更容易理解、支持的参数和返回类型更多。

Spring AI 提供了两种定义工具的方法 ------ 注解式编程式

1)注解式:只需使用 @Tool 注解标记普通 Java 方法,就可以定义工具了,简单直观。 每个工具最好都添加详细清晰的描述,帮助 AI 理解何时应该调用这个工具。对于工具方法的参数,可以使用@ToolParam 注解提供额外的描述信息和是否必填。

java 复制代码
class WeatherTools {
    @Tool(description = "获取指定城市的当前天气情况")
    String getWeather(@ToolParam(description = "城市名称") String city) {
        // 获取天气的实现逻辑
        return "北京今天晴朗,气温25°C";
    }
}

2)编程式:如果想在运行时动态创建工具,可以选择编程式来定义工具,⁢更灵活。 先定义工具类

java 复制代码
class WeatherTools {
    String getWeather(String city) {
        // 获取天气的实现逻辑
        return "北京今天晴朗,气温25°C";
    }
}

然后将工具类转换为 ToolCallback 工具定义类,之后就可以把这个类绑定给 ⁢ChatClient,从⁡而让 AI 使用工具了。

java 复制代码
Method method = ReflectionUtils.findMethod(WeatherTools.class, "getWeather", String.class);
ToolCallback toolCallback = MethodToolCallback.builder()
    .toolDefinition(ToolDefinition.builder(method)
            .description("获取指定城市的当前天气情况")
            .build())
    .toolMethod(method)
    .toolObject(new WeatherTools())
    .build();

使用工具

1)按需使用:这是最简单的方式,直接在构建 ChatClient 请求时通过 tools() 方法附加工具。这种方式适合只在特定对话中使用某些工具的场景。

java 复制代码
String response = ChatClient.create(chatModel)
    .prompt("北京今天天气怎么样?")
    .tools(new WeatherTools())  // 在这次对话中提供天气工具
    .call()
    .content();

2)全局使用:如果某些工具需要在所有对话中都可用,可以在构建 ChatClient 时注册默认工具。这样,这些工⁢具将对从同一个 ChatClie⁡nt 发起的所有对话可用。

java 复制代码
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultTools(new WeatherTools(), new TimeTools())  // 注册默认工具
    .build();

3)更底层的使用方؜式:除了给 ChatClient ‏绑定工具外,也可以给更底层的 Ch​atModel 绑定工具(毕竟工具⁢调用是 AI 大模型支持的能力),⁡适合需要更精细控制的场景。

java 复制代码
// 先得到工具对象
ToolCallback[] weatherTools = ToolCallbacks.from(new WeatherTools());
// 绑定工具到对话
ChatOptions chatOptions = ToolCallingChatOptions.builder()
    .toolCallbacks(weatherTools)
    .build();
// 构造 Prompt 时指定对话选项
Prompt prompt = new Prompt("北京今天天气怎么样?", chatOptions);
chatModel.call(prompt);

工具生态

首先,工具的本质就是一种插件。能不自己写的插件,就尽量不要自己写。我们可以直接在网上找一些优秀的工具实现,比如 Spring AI Alibaba 官方文档 中提到了社区插件。在 GitHub 社区找到官方提供的更多 工具源码,包含大量有用的工具!比如翻译工具、网页搜索工具、爬虫工具、地图工具等。

三、主流工具开发

文件操作

文件操作工具主要提供 2 大功能:保存文件、读取文件。

由于会影响系统资源,所以我们需要将文件统一存放到一个隔离的目录进行存储,在 constant 包下新建文件常量类,约定文件保存目录为项目根目录下的 /tmp 目录中。

java 复制代码
public interface FileConstant {

    /**
     * 文件保存目录
     */
    String FILE_SAVE_DIR = System.getProperty("user.dir") + "/tmp";
}

建议同时将这个目录添加到 .gitignore 文件中,避免提交隐私信息。

编写文件操作工具类,通过注解式定义工具,代码如下:

java 复制代码
public class FileOperationTool {

    private final String FILE_DIR = FileConstant.FILE_SAVE_DIR + "/file";

    @Tool(description = "Read content from a file")
    public String readFile(@ToolParam(description = "Name of the file to read") String fileName) {
        String filePath = FILE_DIR + "/" + fileName;
        try {
            return FileUtil.readUtf8String(filePath);
        } catch (Exception e) {
            return "Error reading file: " + e.getMessage();
        }
    }

    @Tool(description = "Write content to a file")
    public String writeFile(
        @ToolParam(description = "Name of the file to write") String fileName,
        @ToolParam(description = "Content to write to the file") String content) {
        String filePath = FILE_DIR + "/" + fileName;
        try {
            // 创建目录
            FileUtil.mkdir(FILE_DIR);
            FileUtil.writeUtf8String(content, filePath);
            return "File written successfully to: " + filePath;
        } catch (Exception e) {
            return "Error writing to file: " + e.getMessage();
        }
    }
}

编写单元测试验证工具功能:            ⁢         ⁡

java 复制代码
@SpringBootTest
public class FileOperationToolTest {

    @Test
    public void testReadFile() {
        FileOperationTool tool = new FileOperationTool();
        String fileName = "编程导航.txt";
        String result = tool.readFile(fileName);
        assertNotNull(result);
    }

    @Test
    public void testWriteFile() {
        FileOperationTool tool = new FileOperationTool();
        String fileName = "编程导航.txt";
        String content = "https://www.codefather.cn 程序员编程学习交流社区";
        String result = tool.writeFile(fileName, content);
        assertNotNull(result);
    }
}

联网搜索

联网搜索工具的作用是根据关键词搜索网页列表。

我们可以使用专业的网页搜索 API,如 Search API 来实现从多个网站搜索内容,这类服务通常按量计费。当然也可以直接使用 Google 或 Bing 的搜索 API(甚至是通过爬虫和网页解析从某个搜索引擎获取内容)。

1)阅读 Search API 的 官方文档,重点关注 API 的请求参数和返回结果。从 API 返回的结果中,我们只需要提取关键部分:

json 复制代码
{
  "organic_results": [
    ...
    {
      "position": 1,
      "title": "编程导航 - 程序员一站式编程学习交流社区,做您编程学习路...",
      "link": "https://codefather.cn/",
      "displayed_link": "codefather.cn/",
      "snippet": "学编程,就来编程导航,程序员免费编程学习交流社区。Java,Python,前端,web网站开发,C语言,C++,Go,后端,SQL,数据库,PHP入门学习、技能提升、求职面试法宝。提升编程效率、优质IT技术文章、海...",
      "snippet_highlighted_words": [
        "编程",
        "编程导航",
        "程序员"
      ],
      "thumbnail": "https://t8.baidu.com/it/u=661528516,2886240705&fm=217&app=126&size=f242,150&n=0&f=JPEG&fmt=auto?s=73B489634AD237E3660C19280200A063&sec=1744477200&t=b5d8762a6f5728d5f2fbc6bcf1774b20"
    },
    ...
  ]
}

2)可以把接口文档喂给 AI,让它帮我们生成工具代码,网页搜索工⁢具代码如下:

java 复制代码
public class WebSearchTool {

    // SearchAPI 的搜索接口地址
    private static final String SEARCH_API_URL = "https://www.searchapi.io/api/v1/search";

    private final String apiKey;

    public WebSearchTool(String apiKey) {
        this.apiKey = apiKey;
    }

    @Tool(description = "Search for information from Baidu Search Engine")
    public String searchWeb(
            @ToolParam(description = "Search query keyword") String query) {
        Map<String, Object> paramMap = new HashMap<>();
        paramMap.put("q", query);
        paramMap.put("api_key", apiKey);
        paramMap.put("engine", "baidu");
        try {
            String response = HttpUtil.get(SEARCH_API_URL, paramMap);
            // 取出返回结果的前 5 条
            JSONObject jsonObject = JSONUtil.parseObj(response);
            // 提取 organic_results 部分
            JSONArray organicResults = jsonObject.getJSONArray("organic_results");
            List<Object> objects = organicResults.subList(0, 5);
            // 拼接搜索结果为字符串
            String result = objects.stream().map(obj -> {
                JSONObject tmpJSONObject = (JSONObject) obj;
                return tmpJSONObject.toString();
            }).collect(Collectors.joining(","));
            return result;
        } catch (Exception e) {
            return "Error searching Baidu: " + e.getMessage();
        }
    }
}

3)我们需要获取 API Key 来调用网页搜索,注意不要泄露哦⁢~

4)在配置文件中添加 API Key:

yaml 复制代码
# searchApi
search-api:
  api-key: 你的 API Key

5)编写单元测试代码,读取配置文件中的密钥来创建网页搜索工具:

java 复制代码
@SpringBootTest
public class WebSearchToolTest {

    @Value("${search-api.api-key}")
    private String searchApiKey;

    @Test
    public void testSearchWeb() {
        WebSearchTool tool = new WebSearchTool(searchApiKey);
        String query = "程序员鱼皮编程导航 codefather.cn";
        String result = tool.searchWeb(query);
        assertNotNull(result);
    }
}

运行效果如图,成功搜索到了网页:

在实际应用中,我们可以进一步过滤结果,只保留title、lin⁢k 和 snipp⁡et 等关键信息就够了。

网页抓取

网页抓取工具的作用是根据网址解析到网页的内容。

1)可以使用 jsoup 库实现网页内容抓取和解析,首先给项目添⁢加依赖:

xml 复制代码
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.19.1</version>
</dependency>

2)编写网页抓取工具类,几行代码就搞定了:

java 复制代码
public class WebScrapingTool {

    @Tool(description = "Scrape the content of a web page")
    public String scrapeWebPage(@ToolParam(description = "URL of the web page to scrape") String url) {
        try {
            Document doc = Jsoup.connect(url).get();
            return doc.html();
        } catch (IOException e) {
            return "Error scraping web page: " + e.getMessage();
        }
    }
}

3)编写单元测试代码:

java 复制代码
@SpringBootTest
public class WebScrapingToolTest {

    @Test
    public void testScrapeWebPage() {
        WebScrapingTool tool = new WebScrapingTool();
        String url = "https://www.codefather.cn";
        String result = tool.scrapeWebPage(url);
        assertNotNull(result);
    }
}

执行效果如图,成功抓取到了网页内容:

终端操作

终端操作工具的作用是在终端执行命令,比如执行 python 命令来运⁢行脚本。

1)可以通过 Java 的 Process API 实现终端命令执行,注意 Windows ⁢和其他操作系统下的实现略⁡有区别)。工具类代码如下:

java 复制代码
public class TerminalOperationTool {

    @Tool(description = "Execute a command in the terminal")
    public String executeTerminalCommand(@ToolParam(description = "Command to execute in the terminal") String command) {
        StringBuilder output = new StringBuilder();
        try {
            Process process = Runtime.getRuntime().exec(command);
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    output.append(line).append("\n");
                }
            }
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                output.append("Command execution failed with exit code: ").append(exitCode);
            }
        } catch (IOException | InterruptedException e) {
            output.append("Error executing command: ").append(e.getMessage());
        }
        return output.toString();
    }
}

如果是 Windows 操作系统,要使用下面这段代码,否则命令执⁢行会报错:

java 复制代码
public class TerminalOperationTool {

    @Tool(description = "Execute a command in the terminal")
    public String executeTerminalCommand(@ToolParam(description = "Command to execute in the terminal") String command) {
        StringBuilder output = new StringBuilder();
        try {
            ProcessBuilder builder = new ProcessBuilder("cmd.exe", "/c", command);
//            Process process = Runtime.getRuntime().exec(command);
            Process process = builder.start();
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    output.append(line).append("\n");
                }
            }
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                output.append("Command execution failed with exit code: ").append(exitCode);
            }
        } catch (IOException | InterruptedException e) {
            output.append("Error executing command: ").append(e.getMessage());
        }
        return output.toString();
    }
}

2)编写单元测试代码:

java 复制代码
@SpringBootTest
public class TerminalOperationToolTest {

    @Test
    public void testExecuteTerminalCommand() {
        TerminalOperationTool tool = new TerminalOperationTool();
        String command = "ls -l";
        String result = tool.executeTerminalCommand(command);
        assertNotNull(result);
    }
}

运行效果如图,成功执行了 ls 打印文件列表命令并获取到了输出结果:

资源下载

资源下载工具的作用是通过链接下载文件到本地。

1)使用 Hutool 的 HttpUtil.downloadFile 方法实现资源下载。资源下载工具类的代码如下:

java 复制代码
public class ResourceDownloadTool {

    @Tool(description = "Download a resource from a given URL")
    public String downloadResource(@ToolParam(description = "URL of the resource to download") String url, @ToolParam(description = "Name of the file to save the downloaded resource") String fileName) {
        String fileDir = FileConstant.FILE_SAVE_DIR + "/download";
        String filePath = fileDir + "/" + fileName;
        try {
            // 创建目录
            FileUtil.mkdir(fileDir);
            // 使用 Hutool 的 downloadFile 方法下载资源
            HttpUtil.downloadFile(url, new File(filePath));
            return "Resource downloaded successfully to: " + filePath;
        } catch (Exception e) {
            return "Error downloading resource: " + e.getMessage();
        }
    }
}

2)编写单元测试代码:

java 复制代码
@SpringBootTest
public class ResourceDownloadToolTest {

    @Test
    public void testDownloadResource() {
        ResourceDownloadTool tool = new ResourceDownloadTool();
        String url = "https://www.codefather.cn/logo.png";
        String fileName = "logo.png";
        String result = tool.downloadResource(url, fileName);
        assertNotNull(result);
    }
}

执行测试,可以在指定目录下看到下载的图片:

PDF 生成

PDF 生成工具的作用是根据文件名和内容生成 PDF 文档并保存⁢。

可以使用 itext 库 实现 PDF 生成。需要注意的是,itext 对中文字体的支持需要额外配置,不同操作系统提供的字体也不同,如果真要做生产级应用,建议自行下载所需字体。

不过对于学习来说,不建议在这里浪费太多时间,可以使用内置中文字体(不引⁢入 font-asi⁡an 字体依赖也可以使用):

java 复制代码
PdfFont font = PdfFontFactory.createFont("STSongStd-Light", "UniGB-UCS2-H");
document.setFont(font);

1)给项目添加依赖:

xml 复制代码
<!-- https://mvnrepository.com/artifact/com.itextpdf/itext-core -->
<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext-core</artifactId>
    <version>9.1.0</version>
    <type>pom</type>
</dependency>
<!-- https://mvnrepository.com/artifact/com.itextpdf/font-asian -->
<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>font-asian</artifactId>
    <version>9.1.0</version>
    <scope>test</scope>
</dependency>

2)编写工具类实现代码:

java 复制代码
public class PDFGenerationTool {

    @Tool(description = "Generate a PDF file with given content")
    public String generatePDF(
            @ToolParam(description = "Name of the file to save the generated PDF") String fileName,
            @ToolParam(description = "Content to be included in the PDF") String content) {
        String fileDir = FileConstant.FILE_SAVE_DIR + "/pdf";
        String filePath = fileDir + "/" + fileName;
        try {
            // 创建目录
            FileUtil.mkdir(fileDir);
            // 创建 PdfWriter 和 PdfDocument 对象
            try (PdfWriter writer = new PdfWriter(filePath);
                 PdfDocument pdf = new PdfDocument(writer);
                 Document document = new Document(pdf)) {
                // 自定义字体(需要人工下载字体文件到特定目录)
//                String fontPath = Paths.get("src/main/resources/static/fonts/simsun.ttf")
//                        .toAbsolutePath().toString();
//                PdfFont font = PdfFontFactory.createFont(fontPath,
//                        PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED);
                // 使用内置中文字体
                PdfFont font = PdfFontFactory.createFont("STSongStd-Light", "UniGB-UCS2-H");
                document.setFont(font);
                // 创建段落
                Paragraph paragraph = new Paragraph(content);
                // 添加段落并关闭文档
                document.add(paragraph);
            }
            return "PDF generated successfully to: " + filePath;
        } catch (IOException e) {
            return "Error generating PDF: " + e.getMessage();
        }
    }
}

上述代码中,为了实现方便,我们是直接保存 PDF 到本地文件系统。此外,你还可以将生成的文件上传到对象存储服务,然后返回可访问的 UR⁢L 给 AI 去输出;或者将本地文件临⁡时返回给前端,让用户直接访问。

3)编写单元测试代码:

java 复制代码
@SpringBootTest
public class PDFGenerationToolTest {

    @Test
    public void testGeneratePDF() {
        PDFGenerationTool tool = new PDFGenerationTool();
        String fileName = "编程导航原创项目.pdf";
        String content = "编程导航原创项目 https://www.codefather.cn";
        String result = tool.generatePDF(fileName, content);
        assertNotNull(result);
    }
}

集中注册

开发好了这么多工具类后,结合我们自己的需求,可以给 AI 一次性提供所有工具,让它自己决定何时调用。所以我们可以创建 工具注册类,方便统一管理和绑定所有工具。

代码如下:

java 复制代码
@Configuration
public class ToolRegistration {

    @Value("${search-api.api-key}")
    private String searchApiKey;

    @Bean
    public ToolCallback[] allTools() {
        FileOperationTool fileOperationTool = new FileOperationTool();
        WebSearchTool webSearchTool = new WebSearchTool(searchApiKey);
        WebScrapingTool webScrapingTool = new WebScrapingTool();
        ResourceDownloadTool resourceDownloadTool = new ResourceDownloadTool();
        TerminalOperationTool terminalOperationTool = new TerminalOperationTool();
        PDFGenerationTool pdfGenerationTool = new PDFGenerationTool();
        return ToolCallbacks.from(
            fileOperationTool,
            webSearchTool,
            webScrapingTool,
            resourceDownloadTool,
            terminalOperationTool,
            pdfGenerationTool
        );
    }
}

💡 可别小瞧这段代码,其实它暗含了好几种设计模式:

  1. 工厂模式:allTools() 方法作为一个工厂方法,负责创建和配置多个工具实例,然后将它们包装成统一的数组返回。这符合工厂模式的核心思想 - 集中创建对象并隐藏创建细节。
  2. 依赖注入模式:通过 @Value 注解注入配置值,以及将创建好的工具通过 Spring 容器注入到需要它们的组件中。
  3. 注册模式:该类作为一个中央注册点,集中管理和注册所有可用的工具,使它们能够被系统其他部分统一访问。
  4. 适配器模式的应用:ToolCallbacks.from 方法可以看作是一种适配器,它将各种不同的工具类转换为统一的 ToolCallback 数组,使系统能够以一致的方式处理它们。

有了这个注册类,如果需要添加或移除工具,只需修改这一个类即可,更利⁢于维护。

使用工具

在构造ChatClient时指定tools参数即可

java 复制代码
@Resource
private ToolCallback[] allTools;

public String doChatWithTools(String message, String chatId) {
    ChatResponse response = chatClient
            .prompt()
            .user(message)
            .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
                    .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
            // 开启日志,便于观察效果
            .advisors(new MyLoggerAdvisor())
            .tools(allTools)
            .call()
            .chatResponse();
    String content = response.getResult().getOutput().getText();
    log.info("content: {}", content);
    return content;
}
相关推荐
夜郎king1 分钟前
基于高德地图的怀化旅发精品路线智能规划导航之旅
人工智能
MarkHD6 分钟前
AI提示词30天入门培训计划
人工智能·chatgpt
xw337340956424 分钟前
目标检测基础
人工智能·yolo
牛哥带你学代码25 分钟前
计算机视觉全流程(基础知识)
人工智能·机器学习
竹子_231 小时前
《零基础入门AI: 目标检测基础知识》
人工智能·python·目标检测·计算机视觉
想你依然心痛1 小时前
零后端、零配置:用 AI 编程工具「Cursor」15 分钟上线「Vue3 留言墙」
人工智能
八个程序员1 小时前
微生产力革命:AI解决生活小任务分享会
人工智能·生活
stbomei1 小时前
当 AI 走进日常:除了聊天机器人,这些 “隐形应用” 正在改变我们的生活
人工智能·机器人·生活
IT_陈寒1 小时前
Python开发者必知的5个高效技巧,让你的代码速度提升50%!
前端·人工智能·后端
Alter12301 小时前
当AI有了温度,三星正在重新定义生活的边界
人工智能·生活