(第三篇)Spring AI 核心技术攻坚:工具调用深度解析(从 Function-call 到企业级 Tools 模块实战)

前言

在 AI 应用开发中,"闭门造车" 的 AI 毫无价值 ------ 真正的企业级 AI 助手,必须能连接外部系统:查股票行情、查天气预警、查数据库数据、调用第三方 API。早期的 Function-call 虽然能实现基础的外部调用,但存在注册繁琐、参数校验缺失、调用流程不规范等问题,难以支撑复杂场景。

Spring AI 的 Tools 模块横空出世,彻底解决了这些痛点:通过@Tool注解实现工具一键注册、内置参数校验、标准化调用流程,让开发者能快速搭建 "AI 大脑 + 外部工具" 的强大应用。本文将从底层原理、常用工具开发、调用流程拆解到实战落地,全方位解析 Spring AI 工具调用技术,带你从 Function-call 的 "能用",升级到 Tools 模块的 "好用、稳定、可扩展",真正让 AI 连接外部世界。

一、Spring AI Tools 模块核心原理:注解驱动 + 标准化注册

Spring AI Tools 模块的核心设计思想是 "标准化、可扩展、低侵入",通过@Tool注解简化工具定义,通过ToolRegistry统一管理工具,通过ToolExecutor标准化执行流程。理解这三层核心机制,是掌握工具调用的关键。

1.1 @Tool 注解:工具定义的 "魔法开关"

@Tool是 Spring AI 中定义工具的核心注解,只需一个注解,就能将普通 Java 方法转化为 AI 可调用的工具。其底层是通过 AOP 扫描注解方法,自动生成ToolDefinition(工具定义),包含工具名称、描述、参数信息等元数据,供 LLM 识别和选择。

1.1.1 @Tool 注解核心属性
属性名 作用 必填性 实战注意事项
name 工具唯一标识 可选(默认取方法名) 建议手动指定,避免方法名修改导致 LLM 调用失败
description 工具功能描述 必选 必须详细、直白,包含 "什么场景用""输入什么""返回什么",LLM 靠这个选工具
parameters 自定义参数描述 可选 复杂参数需手动指定名称、描述、是否必填,提升 LLM 参数提取准确率
1.1.2 基础工具定义示例(无参 / 单参 / 多参)
java 复制代码
import org.springframework.ai.annotation.Tool;
import org.springframework.stereotype.Component;

// 工具类必须交给Spring管理(@Component/@Service等)
@Component
public class CommonTools {

    // 无参工具:获取当前系统时间
    @Tool(description = "获取当前系统时间,格式为yyyy-MM-dd HH:mm:ss,无需输入参数")
    public String getCurrentTime() {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
    }

    // 单参工具:根据城市名查询所属省份
    @Tool(description = "根据输入的城市名称,查询该城市所属的省份。输入参数:城市名称(如北京、上海)")
    public String getProvinceByCity(String city) {
        // 实际开发中可对接数据库或地理信息API
        Map<String, String> cityProvinceMap = new HashMap<>();
        cityProvinceMap.put("北京", "北京市");
        cityProvinceMap.put("上海", "上海市");
        cityProvinceMap.put("深圳", "广东省");
        cityProvinceMap.put("杭州", "浙江省");
        return cityProvinceMap.getOrDefault(city, "未查询到该城市的省份信息");
    }

    // 多参工具:计算两个数的加减乘除
    @Tool(
        name = "calculate",
        description = "实现两个数字的四则运算,支持加法、减法、乘法、除法",
        parameters = {
            @ToolParameter(name = "num1", description = "第一个数字,支持整数和小数", required = true),
            @ToolParameter(name = "num2", description = "第二个数字,支持整数和小数,除法时不能为0", required = true),
            @ToolParameter(name = "operator", description = "运算符号,可选值:+、-、*、/", required = true)
        }
    )
    public String calculate(double num1, double num2, String operator) {
        switch (operator) {
            case "+": return num1 + " + " + num2 + " = " + (num1 + num2);
            case "-": return num1 + " - " + num2 + " = " + (num1 - num2);
            case "*": return num1 + " * " + num2 + " = " + (num1 * num2);
            case "/": 
                if (num2 == 0) return "错误:除数不能为0";
                return num1 + " / " + num2 + " = " + (num1 / num2);
            default: return "错误:不支持的运算符号,仅支持+、-、*、/";
        }
    }
}

1.2 工具注册机制:从扫描到管理的全流程

Spring AI 的工具注册分为 "自动注册" 和 "手动注册" 两种方式,核心是通过ToolRegistry接口管理所有工具,供后续调用时查询和匹配。

1.2.1 自动注册(默认推荐)

Spring AI 启动时,会自动扫描带有@Tool注解的 Spring Bean 方法,将其转化为ToolDefinition并注册到DefaultToolRegistry(默认实现)中。无需额外配置,开箱即用。

自动注册核心流程

1.2.2 手动注册(复杂场景适用)

当工具未被 Spring 管理(如第三方工具类)、或需要动态注册 / 卸载工具时,可通过ToolRegistry手动操作。

java 复制代码
import org.springframework.ai.tool.ToolDefinition;
import org.springframework.ai.tool.ToolParameter;
import org.springframework.ai.tool.DefaultToolRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class ToolConfig {

    // 手动注册第三方工具
    @Bean
    public ToolRegistry customToolRegistry() {
        DefaultToolRegistry toolRegistry = new DefaultToolRegistry();
        
        // 1. 定义第三方工具(无Spring注解的普通类)
        ThirdPartyTool thirdPartyTool = new ThirdPartyTool();
        
        // 2. 构建ToolDefinition
        ToolDefinition toolDefinition = ToolDefinition.builder()
                .name("thirdPartyWeatherTool")
                .description("查询指定城市的实时天气,返回温度、湿度、天气状况")
                .parameters(List.of(
                        ToolParameter.builder()
                                .name("city")
                                .description("城市名称,如北京、上海")
                                .required(true)
                                .build()
                ))
                // 绑定工具执行逻辑
                .function(()-> thirdPartyTool.getWeather(((String) parameters.get("city"))))
                .build();
        
        // 3. 注册工具
        toolRegistry.register(toolDefinition);
        
        return toolRegistry;
    }

    // 第三方工具类(无Spring注解)
    static class ThirdPartyTool {
        public String getWeather(String city) {
            // 调用第三方天气API逻辑
            return city + " 实时天气:晴,25℃,湿度50%";
        }
    }
}

1.3 核心组件交互:ToolRegistry + ToolExecutor

Spring AI 工具调用的核心是 "注册 - 查询 - 执行" 的闭环,涉及两个关键组件:

  • ToolRegistry :工具注册表,负责存储和查询ToolDefinition,提供get(String toolName)findAll()等方法;
  • ToolExecutor:工具执行器,接收工具名称和参数,从注册表中获取工具,执行并返回结果,内置参数校验、异常处理。

组件交互时序图

1.4 原理避坑核心要点

  1. @Tool注解的方法必须是 Spring Bean 的方法,否则无法被自动扫描;
  2. 工具描述(description)是 LLM 选择工具的唯一依据,描述模糊会导致工具选错,建议按 "功能 + 输入 + 输出" 格式编写;
  3. 复杂参数(如自定义对象)需手动指定@ToolParameter,否则 LLM 无法正确提取参数;
  4. 手动注册工具时,需确保ToolDefinition的 name 唯一,避免覆盖自动注册的工具。

二、常用工具开发实战:HTTP / 数据库 / 文件操作

企业开发中,HTTP 客户端、数据库查询、文件操作是最常用的三类工具。本节将基于 Spring AI Tools 模块,提供可直接复用的企业级实现方案,包含参数校验、异常处理、LLM 友好描述。

2.1 HTTP 客户端工具:调用第三方 API

HTTP 工具是 AI 连接外部世界的核心,用于调用天气、股票、地图等第三方 API。核心需求:支持 GET/POST 请求、参数传递、响应解析、异常处理。

2.1.1 工具实现(支持 GET/POST)
java 复制代码
import org.springframework.ai.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Map;

@Component
public class HttpTool {

    @Autowired
    private RestTemplate restTemplate;

    /**
     * HTTP GET请求工具
     * 适用场景:调用第三方GET接口,获取数据(如天气查询、股票行情)
     * @param url 接口URL(必填,如https://api.example.com/weather)
     * @param params 请求参数(可选,格式:key1=value1&key2=value2)
     * @param headers 请求头(可选,格式:key1=value1&key2=value2,如Authorization=Bearer token)
     * @return 接口返回的JSON字符串
     */
    @Tool(
        name = "httpGet",
        description = "发送HTTP GET请求,用于调用第三方GET接口获取数据。参数说明:url为接口地址(必填),params为请求参数(可选,格式key1=value1&key2=value2),headers为请求头(可选,格式key1=value1&key2=value2)",
        parameters = {
            @ToolParameter(name = "url", description = "接口URL,如https://api.example.com/weather", required = true),
            @ToolParameter(name = "params", description = "请求参数,格式key1=value1&key2=value2,可选"),
            @ToolParameter(name = "headers", description = "请求头,格式key1=value1&key2=value2,可选")
        }
    )
    public String httpGet(String url, String params, String headers) {
        try {
            // 1. 构建请求头
            HttpHeaders httpHeaders = buildHeaders(headers);
            
            // 2. 构建带参数的URL
            UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
            if (params != null && !params.isEmpty()) {
                for (String param : params.split("&")) {
                    String[] keyValue = param.split("=");
                    if (keyValue.length == 2) {
                        builder.queryParam(keyValue[0], keyValue[1]);
                    }
                }
            }
            
            // 3. 发送请求
            HttpEntity<?> entity = new HttpEntity<>(httpHeaders);
            ResponseEntity<String> response = restTemplate.exchange(
                    builder.toUriString(),
                    HttpMethod.GET,
                    entity,
                    String.class
            );
            
            // 4. 处理响应
            if (response.getStatusCode().is2xxSuccessful()) {
                return response.getBody();
            } else {
                return "HTTP GET请求失败,状态码:" + response.getStatusCodeValue() + ",响应体:" + response.getBody();
            }
        } catch (Exception e) {
            return "HTTP GET请求异常:" + e.getMessage();
        }
    }

    /**
     * HTTP POST请求工具
     * 适用场景:调用第三方POST接口,提交数据(如表单提交、数据上报)
     * @param url 接口URL(必填)
     * @param jsonBody 请求体(JSON格式,必填)
     * @param headers 请求头(可选,格式:key1=value1&key2=value2)
     * @return 接口返回的JSON字符串
     */
    @Tool(
        name = "httpPost",
        description = "发送HTTP POST请求,用于调用第三方POST接口提交数据。参数说明:url为接口地址(必填),jsonBody为请求体(JSON格式,必填),headers为请求头(可选,格式key1=value1&key2=value2)",
        parameters = {
            @ToolParameter(name = "url", description = "接口URL,如https://api.example.com/submit", required = true),
            @ToolParameter(name = "jsonBody", description = "请求体,JSON格式字符串,如{\"name\":\"test\"}", required = true),
            @ToolParameter(name = "headers", description = "请求头,格式key1=value1&key2=value2,可选")
        }
    )
    public String httpPost(String url, String jsonBody, String headers) {
        try {
            // 1. 构建请求头
            HttpHeaders httpHeaders = buildHeaders(headers);
            httpHeaders.setContentType(MediaType.APPLICATION_JSON);
            
            // 2. 发送请求
            HttpEntity<String> entity = new HttpEntity<>(jsonBody, httpHeaders);
            ResponseEntity<String> response = restTemplate.exchange(
                    url,
                    HttpMethod.POST,
                    entity,
                    String.class
            );
            
            // 3. 处理响应
            if (response.getStatusCode().is2xxSuccessful()) {
                return response.getBody();
            } else {
                return "HTTP POST请求失败,状态码:" + response.getStatusCodeValue() + ",响应体:" + response.getBody();
            }
        } catch (Exception e) {
            return "HTTP POST请求异常:" + e.getMessage();
        }
    }

    // 辅助方法:构建请求头
    private HttpHeaders buildHeaders(String headersStr) {
        HttpHeaders headers = new HttpHeaders();
        if (headersStr != null && !headersStr.isEmpty()) {
            for (String header : headersStr.split("&")) {
                String[] keyValue = header.split("=");
                if (keyValue.length == 2) {
                    headers.add(keyValue[0], keyValue[1]);
                }
            }
        }
        return headers;
    }
}

// 配置RestTemplate(Spring Boot 3.x)
@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
2.1.2 开发避坑点
  1. 必须明确 HTTP 工具的适用场景(GET/POST),避免 LLM 混淆;
  2. 请求参数和请求头的格式要明确(如key1=value1&key2=value2),LLM 无法识别复杂格式;
  3. 异常处理要详细,返回具体的错误信息(如状态码、异常原因),方便 LLM 调整请求参数;
  4. 敏感信息(如 API 密钥)不要硬编码,通过配置文件注入,工具描述中提示 "headers 参数传入密钥"。

2.2 数据库查询工具:操作结构化数据

数据库工具用于 AI 查询数据库中的结构化数据(如用户信息、订单数据),核心需求:支持简单 SQL 查询、参数绑定、结果格式化,避免 SQL 注入。

2.2.1 工具实现(基于 JPA)
java 复制代码
import org.springframework.ai.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

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

@Component
public class DatabaseTool {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 数据库查询工具(仅支持SELECT语句)
     * 适用场景:查询数据库中的结构化数据(如用户信息、订单列表)
     * @param sql SELECT语句(必填,仅支持SELECT,不支持INSERT/UPDATE/DELETE)
     * @param params SQL参数(可选,格式:value1,value2,value3,顺序与SQL中的?对应)
     * @return 查询结果(JSON格式,包含字段名和数据)
     */
    @Tool(
        name = "dbQuery",
        description = "查询数据库中的结构化数据,仅支持SELECT语句,避免SQL注入。参数说明:sql为SELECT语句(必填,如SELECT * FROM user WHERE id=?),params为SQL参数(可选,格式value1,value2,顺序与SQL中的?对应)",
        parameters = {
            @ToolParameter(name = "sql", description = "SELECT语句,如SELECT name,age FROM user WHERE id=?,仅支持查询操作", required = true),
            @ToolParameter(name = "params", description = "SQL参数,多个参数用逗号分隔,顺序与SQL中的?对应,可选")
        }
    )
    public String dbQuery(String sql, String params) {
        try {
            // 1. 校验SQL是否为SELECT语句
            if (!sql.trim().toUpperCase().startsWith("SELECT")) {
                return "错误:仅支持SELECT查询语句,不支持增删改操作";
            }
            
            // 2. 处理参数
            Object[] paramArray = new Object[0];
            if (StringUtils.hasText(params)) {
                paramArray = params.split(",");
                // 去除参数前后空格
                for (int i = 0; i < paramArray.length; i++) {
                    paramArray[i] = paramArray[i].toString().trim();
                }
            }
            
            // 3. 执行查询
            List<Map<String, Object>> resultList = jdbcTemplate.queryForList(sql, paramArray);
            
            // 4. 格式化结果为JSON
            return new ObjectMapper().writeValueAsString(resultList);
        } catch (Exception e) {
            return "数据库查询异常:" + e.getMessage();
        }
    }
}

// 数据库配置(application.yml)
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
2.2.1 开发避坑点
  1. 严格限制仅支持 SELECT 语句,禁止增删改操作,避免 AI 误操作数据库;
  2. 强制使用参数绑定(? 占位符),禁止直接拼接 SQL,防止 SQL 注入;
  3. 结果格式化为 JSON,方便 LLM 解析和展示;
  4. 生产环境中,建议给数据库账号分配最小权限(仅查询权限),进一步降低风险。

2.3 文件操作工具:读写本地 / 云存储文件

文件工具用于 AI 读写文件(如读取配置文件、生成报告并保存),核心需求:支持本地文件和云存储文件(如 OSS),包含路径校验、权限控制。

2.3.1 工具实现(本地文件 + OSS)
java 复制代码
import org.springframework.ai.annotation.Tool;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

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

@Component
public class FileTool {

    // OSS配置(实际开发中使用OSS SDK,此处简化)
    @Value("${oss.bucket:test-bucket}")
    private String ossBucket;

    /**
     * 读取文件工具
     * 适用场景:读取本地文件或OSS文件内容(如配置文件、报告文档)
     * @param filePath 文件路径(必填,本地文件:/data/test.txt;OSS文件:oss://bucket/path/test.txt)
     * @return 文件内容(文本格式)
     */
    @Tool(
        name = "fileRead",
        description = "读取本地文件或OSS文件的文本内容。参数说明:filePath为文件路径(必填,本地文件格式:/data/test.txt;OSS文件格式:oss://bucket/path/test.txt)",
        parameters = {
            @ToolParameter(name = "filePath", description = "文件路径,本地文件:/data/test.txt;OSS文件:oss://bucket/path/test.txt", required = true)
        }
    )
    public String fileRead(String filePath) {
        try {
            // 1. 区分本地文件和OSS文件
            if (filePath.startsWith("oss://")) {
                // 读取OSS文件(实际开发中使用OSS SDK,此处简化)
                return readOssFile(filePath);
            } else {
                // 读取本地文件
                return readLocalFile(filePath);
            }
        } catch (Exception e) {
            return "文件读取异常:" + e.getMessage();
        }
    }

    /**
     * 写入文件工具
     * 适用场景:将内容写入本地文件或OSS文件(如生成报告、保存配置)
     * @param filePath 文件路径(必填,格式同读取工具)
     * @param content 写入内容(必填,文本格式)
     * @param append 是否追加(可选,true=追加,false=覆盖,默认false)
     * @return 写入结果(成功/失败信息)
     */
    @Tool(
        name = "fileWrite",
        description = "将文本内容写入本地文件或OSS文件。参数说明:filePath为文件路径(必填),content为写入内容(必填),append为是否追加(可选,true=追加,false=覆盖,默认false)",
        parameters = {
            @ToolParameter(name = "filePath", description = "文件路径,本地文件:/data/test.txt;OSS文件:oss://bucket/path/test.txt", required = true),
            @ToolParameter(name = "content", description = "写入的文本内容", required = true),
            @ToolParameter(name = "append", description = "是否追加内容,true=追加,false=覆盖,默认false", required = false)
        }
    )
    public String fileWrite(String filePath, String content, String append) {
        try {
            // 处理append参数(默认false)
            boolean appendFlag = StringUtils.hasText(append) && Boolean.parseBoolean(append);
            
            // 区分本地文件和OSS文件
            if (filePath.startsWith("oss://")) {
                // 写入OSS文件(实际开发中使用OSS SDK,此处简化)
                writeOssFile(filePath, content, appendFlag);
                return "OSS文件写入成功:" + filePath;
            } else {
                // 写入本地文件
                writeLocalFile(filePath, content, appendFlag);
                return "本地文件写入成功:" + filePath;
            }
        } catch (Exception e) {
            return "文件写入异常:" + e.getMessage();
        }
    }

    // 读取本地文件
    private String readLocalFile(String filePath) throws IOException {
        File file = new File(filePath);
        if (!file.exists()) {
            throw new FileNotFoundException("文件不存在:" + filePath);
        }
        if (!file.isFile()) {
            throw new IOException("不是文件:" + filePath);
        }
        // 读取文件内容
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            return sb.toString().trim();
        }
    }

    // 写入本地文件
    private void writeLocalFile(String filePath, String content, boolean append) throws IOException {
        File file = new File(filePath);
        // 创建父目录
        File parentDir = file.getParentFile();
        if (!parentDir.exists()) {
            parentDir.mkdirs();
        }
        // 写入文件
        try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, append), StandardCharsets.UTF_8))) {
            writer.write(content);
        }
    }

    // 读取OSS文件(简化实现,实际使用阿里云OSS/华为云OBS SDK)
    private String readOssFile(String filePath) {
        String ossPath = filePath.replace("oss://" + ossBucket + "/", "");
        // 实际逻辑:调用OSS SDK获取文件内容
        return "OSS文件[" + ossPath + "]内容:模拟读取成功";
    }

    // 写入OSS文件(简化实现)
    private void writeOssFile(String filePath, String content, boolean append) {
        String ossPath = filePath.replace("oss://" + ossBucket + "/", "");
        // 实际逻辑:调用OSS SDK写入文件
    }
}
2.3.2 开发避坑点
  1. 明确文件路径格式(本地 / OSS),避免 LLM 传入错误路径;
  2. 本地文件操作需处理父目录创建、文件不存在等异常;
  3. 生产环境中,禁止 AI 写入敏感目录(如 /root、/etc),可通过路径白名单限制;
  4. 大文件读写需用流式处理,避免内存溢出。

三、工具调用全流程拆解:从需求到结果的闭环

Spring AI 的工具调用不是简单的 "调用方法",而是一套标准化的闭环流程:需求解析→工具选择→参数生成→结果处理。每个环节都有其核心逻辑和避坑点,理解流程才能更好地调试和优化 AI 助手。

3.1 需求解析:LLM 理解用户意图

核心目标:让 LLM 从用户的自然语言中,提取 "要做什么"(核心需求)。

3.1.1 实现逻辑
  • 依赖 Prompt Engineering:通过 Prompt 告诉 LLM"需要分析用户需求,判断是否需要调用工具";
  • 无工具需求:直接返回答案(如 "你好""什么是 AI");
  • 有工具需求:明确核心动作(如 "查天气""查股票""查数据库")。
3.1.2 示例 Prompt(工具调用专用)
java 复制代码
String toolPrompt = """
        你是一个智能助手,具备调用外部工具的能力。请按照以下规则处理用户请求:
        1. 分析用户需求:判断是否需要调用工具(如查天气、查股票、查数据库、读文件);
        2. 不需要调用工具:直接用自然语言回答用户;
        3. 需要调用工具:明确核心需求(如"查询北京的天气""查询贵州茅台的股价");
        4. 工具选择:根据需求选择合适的工具(参考工具描述);
        5. 参数提取:从需求中提取工具所需的必填参数,若参数缺失,询问用户补充。
        """;
3.1.3 避坑点
  • Prompt 必须明确 "工具调用的触发条件",避免 LLM 无工具需求时强行调用;
  • 复杂需求(如 "查北京明天的天气并保存到文件")需让 LLM 拆分步骤(先查天气,再写文件)。

3.2 工具选择:匹配最适合的工具

核心目标:LLM 根据需求和工具描述,选择唯一的、最合适的工具。

3.2.1 实现逻辑
  • LLM 获取所有工具的ToolDefinition(名称 + 描述);
  • 对比需求与工具描述的匹配度(如 "查天气" 匹配 "httpGet 工具(调用天气 API)");
  • 输出工具名称(如 "httpGet"),供后续执行。
3.2.2 避坑点
  • 工具描述必须唯一且不重叠(如 "查天气" 和 "查股票" 的描述要明确区分);
  • 避免工具名称过于相似(如 "weatherTool" 和 "weatherApiTool"),否则 LLM 会混淆;
  • 多工具匹配时(如 "查天气" 可通过 HTTP 工具或第三方天气工具),在 Prompt 中指定优先级。

3.3 参数生成:提取工具所需参数

核心目标:LLM 从用户需求中,提取工具的必填参数和可选参数。

3.3.1 实现逻辑
  • LLM 读取工具的ToolParameter(名称 + 描述 + 是否必填);
  • 从用户需求中提取参数(如需求 "查北京的天气",提取参数url="https://api.weather.com/now", params="city=北京");
  • 参数缺失时,LLM 主动询问用户补充(如 "请告诉我你要查询哪个城市的天气")。
3.3.2 避坑点
  • 工具参数的描述要明确 "参数格式"(如 "城市名称,如北京、上海"),避免 LLM 提取错误格式;
  • 必填参数必须标注required=true,否则 LLM 可能忽略;
  • 复杂参数(如 JSON、多值参数)需在工具描述中给出示例。

3.4 结果处理:格式化并返回给用户

核心目标:将工具的执行结果(如 JSON 字符串)转化为自然语言,返回给用户。

3.4.1 实现逻辑
  • 工具执行后返回原始结果(如 HTTP 接口返回的 JSON、数据库查询的 JSON);
  • LLM 解析原始结果,提取关键信息(如天气接口返回{"city":"北京","temp":25,"status":"晴"},提取 "北京实时天气:晴,25℃");
  • 用自然语言组织信息,返回给用户。
3.4.2 避坑点
  • 原始结果可能包含冗余信息,需在 Prompt 中让 LLM "只保留关键信息";
  • 工具执行失败时(如接口返回 404),LLM 需告知用户失败原因,而非直接返回错误码;
  • 多工具调用结果(如 "查股票 + 保存到文件")需按步骤展示结果。

3.5 完整流程

四、实战:开发股票查询 + 天气预警双工具 AI 助手

本节将基于前面的技术铺垫,开发一个企业级 AI 助手:支持股票行情查询、天气预警两大功能,自动选择工具、提取参数、处理结果。代码完整可复用,包含环境搭建、工具开发、AI 助手整合、测试验证。

4.1 环境搭建与依赖配置

4.1.1 项目依赖(pom.xml)
XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>spring-ai-tool-assistant</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-ai-tool-assistant</name>
    
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-M1</spring-ai.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot核心 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <!-- Spring AI核心 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-core</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <!-- Spring AI Tools模块 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-tool</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        
        <!-- 工具依赖 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.48</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
4.1.2 配置文件(application.yml)
bash 复制代码
spring:
  application:
    name: spring-ai-tool-assistant
  # Spring AI配置(OpenAI兼容接口,如ChatGPT、智谱AI、通义千问)
  ai:
    openai:
      api-key: sk-your-api-key
      base-url: https://api.openai.com/v1 # 若用国内模型,替换为对应base-url
      chat:
        options:
          model: gpt-3.5-turbo # 模型名称
          temperature: 0.3 # 温度越低,结果越稳定

# 第三方API配置
third-party:
  stock:
    url: https://api.money.126.net/data/feed/1000001,1000002,000001?callback=jsonpCallback
    headers: User-Agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
  weather:
    url: https://restapi.amap.com/v3/weather/weatherInfo
    key: your-amap-key # 高德地图API密钥(需自行申请)

4.2 核心工具开发

4.2.1 股票查询工具(调用新浪财经 API)
java 复制代码
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.ai.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;

@Component
public class StockQueryTool {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${third-party.stock.url}")
    private String stockApiUrl;

    @Value("${third-party.stock.headers}")
    private String stockHeaders;

    /**
     * 股票行情查询工具
     * 适用场景:查询A股股票的实时行情(代码支持:6开头沪市、0开头深市、3开头创业板)
     * @param stockCode 股票代码(必填,如贵州茅台:600519;平安银行:000001)
     * @return 股票实时行情(包含股票名称、最新价、涨跌额、涨跌幅、成交量)
     */
    @Tool(
        name = "stockQuery",
        description = "查询A股股票的实时行情,支持沪市、深市、创业板股票。参数说明:stockCode为股票代码(必填,如贵州茅台600519、平安银行000001)",
        parameters = {
            @ToolParameter(name = "stockCode", description = "股票代码,如600519(贵州茅台)、000001(平安银行),必填", required = true)
        }
    )
    public String queryStock(String stockCode) {
        try {
            // 1. 处理股票代码(新浪API要求:沪市6开头加sh,深市0/3开头加sz)
            String apiCode;
            if (stockCode.startsWith("6")) {
                apiCode = "sh" + stockCode;
            } else if (stockCode.startsWith("0") || stockCode.startsWith("3")) {
                apiCode = "sz" + stockCode;
            } else {
                return "错误:股票代码格式不正确,沪市6开头、深市0/3开头";
            }

            // 2. 构建请求URL(替换API中的默认股票代码)
            String url = stockApiUrl.replace("1000001,1000002,000001", apiCode);

            // 3. 构建请求头
            HttpHeaders headers = new HttpHeaders();
            String[] headerArray = stockHeaders.split(",");
            for (String header : headerArray) {
                String[] keyValue = header.split("=");
                if (keyValue.length == 2) {
                    headers.add(keyValue[0], keyValue[1]);
                }
            }

            // 4. 发送请求(新浪API返回格式:jsonpCallback({...}))
            HttpEntity<?> entity = new HttpEntity<>(headers);
            ResponseEntity<String> response = restTemplate.exchange(
                    url, HttpMethod.GET, entity, String.class
            );

            // 5. 解析响应结果
            if (response.getStatusCode().is2xxSuccessful()) {
                String responseBody = response.getBody();
                // 去除jsonpCallback包装
                String jsonStr = responseBody.substring(
                        responseBody.indexOf("{"),
                        responseBody.lastIndexOf("}") + 1
                );
                JSONObject jsonObject = JSON.parseObject(jsonStr);
                JSONObject stockData = jsonObject.getJSONObject(apiCode);

                if (stockData == null) {
                    return "未查询到股票代码:" + stockCode + " 的行情数据";
                }

                // 提取关键字段
                String stockName = stockData.getString("name"); // 股票名称
                double currentPrice = stockData.getDoubleValue("price"); // 最新价
                double change = stockData.getDoubleValue("updown"); // 涨跌额
                double changeRate = stockData.getDoubleValue("percent"); // 涨跌幅(%)
                long volume = stockData.getLongValue("volume"); // 成交量(手)

                // 格式化结果
                return String.format("""
                        %s(%s)实时行情:
                        最新价:%.2f 元
                        涨跌额:%.2f 元
                        涨跌幅:%.2f%%
                        成交量:%d 手
                        """, stockName, stockCode, currentPrice, change, changeRate, volume);
            } else {
                return "股票查询API请求失败,状态码:" + response.getStatusCodeValue();
            }
        } catch (Exception e) {
            return "股票查询异常:" + e.getMessage();
        }
    }
}
4.2.2 天气预警工具(调用高德天气 API)
java 复制代码
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.ai.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

@Component
public class WeatherAlertTool {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${third-party.weather.url}")
    private String weatherApiUrl;

    @Value("${third-party.weather.key}")
    private String weatherApiKey;

    /**
     * 天气预警工具
     * 适用场景:查询指定城市的实时天气和预警信息(如暴雨预警、高温预警)
     * @param city 城市名称(必填,如北京、上海、广州)
     * @return 天气信息(包含温度、湿度、天气状况、预警信息)
     */
    @Tool(
        name = "weatherAlert",
        description = "查询指定城市的实时天气和预警信息,支持国内主要城市。参数说明:city为城市名称(必填,如北京、上海、广州)",
        parameters = {
            @ToolParameter(name = "city", description = "城市名称,如北京、上海、广州,必填", required = true)
        }
    )
    public String queryWeather(String city) {
        try {
            // 1. 构建请求URL(带参数)
            UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(weatherApiUrl)
                    .queryParam("key", weatherApiKey)
                    .queryParam("city", city)
                    .queryParam("extensions", "all"); // extensions=all 包含预警信息

            // 2. 发送请求
            HttpHeaders headers = new HttpHeaders();
            HttpEntity<?> entity = new HttpEntity<>(headers);
            ResponseEntity<String> response = restTemplate.exchange(
                    builder.toUriString(), HttpMethod.GET, entity, String.class
            );

            // 3. 解析响应结果
            if (response.getStatusCode().is2xxSuccessful()) {
                String responseBody = response.getBody();
                JSONObject jsonObject = JSON.parseObject(responseBody);

                // 校验返回状态
                String status = jsonObject.getString("status");
                if (!"1".equals(status)) {
                    return "天气查询失败:" + jsonObject.getString("info");
                }

                // 提取实时天气信息
                JSONArray lives = jsonObject.getJSONArray("lives");
                if (lives.isEmpty()) {
                    return "未查询到城市:" + city + " 的天气信息";
                }
                JSONObject liveWeather = lives.getJSONObject(0);
                String temperature = liveWeather.getString("temperature"); // 温度
                String humidity = liveWeather.getString("humidity"); // 湿度
                String weather = liveWeather.getString("weather"); // 天气状况
                String windDirection = liveWeather.getString("winddirection"); // 风向
                String windPower = liveWeather.getString("windpower"); // 风力

                // 提取预警信息
                JSONArray alerts = jsonObject.getJSONArray("alerts");
                String alertInfo = alerts.isEmpty() ? "无预警信息" : "预警信息:" + alerts.getJSONObject(0).getString("info");

                // 格式化结果
                return String.format("""
                        %s 实时天气:
                        天气状况:%s
                        温度:%s ℃
                        湿度:%s %%
                        风向:%s
                        风力:%s 级
                        %s
                        """, city, weather, temperature, humidity, windDirection, windPower, alertInfo);
            } else {
                return "天气查询API请求失败,状态码:" + response.getStatusCodeValue();
            }
        } catch (Exception e) {
            return "天气查询异常:" + e.getMessage();
        }
    }
}

4.3 AI 助手整合:自动选择工具并调用

4.3.1 工具调用配置
java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.tool.ToolRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiAssistantConfig {

    // 工具调用专用Prompt
    private static final String TOOL_PROMPT = """
            你是一个智能助手,具备股票查询和天气预警两大工具调用能力,请严格按照以下规则处理用户请求:
            1. 需求分析:判断用户是要查股票还是查天气,或不需要调用工具;
            2. 工具选择:
               - 查股票:调用stockQuery工具,必填参数stockCode(股票代码);
               - 查天气:调用weatherAlert工具,必填参数city(城市名称);
            3. 参数提取:从用户需求中提取必填参数,若参数缺失,询问用户补充;
            4. 结果处理:工具返回结果后,用自然语言简洁明了地展示,不要包含原始JSON;
            5. 不需要调用工具:直接用自然语言回答,不要调用任何工具。
            """;

    @Bean
    public ChatClient aiAssistantChatClient(ChatClient.Builder builder, ToolRegistry toolRegistry) {
        return builder
                // 配置Prompt
                .prompt(PromptTemplate.from(TOOL_PROMPT))
                // 添加工具调用Advisor(核心:自动处理工具调用流程)
                .advisor(new ToolCallingAdvisor(toolRegistry))
                .build();
    }

    // 配置RestTemplate
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
4.3.2 AI 助手服务实现
java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class AiAssistantService {

    private final ChatClient aiAssistantChatClient;

    // 构造函数注入ChatClient
    public AiAssistantService(ChatClient aiAssistantChatClient) {
        this.aiAssistantChatClient = aiAssistantChatClient;
    }

    // AI助手核心方法:处理用户请求
    public String handleUserRequest(String userInput) {
        try {
            // 调用ChatClient,自动处理工具调用流程
            return aiAssistantChatClient
                    .prompt(userInput)
                    .call()
                    .content();
        } catch (Exception e) {
            return "AI助手处理异常:" + e.getMessage();
        }
    }
}
4.3.3 控制器实现(HTTP 接口)
java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;

@RestController
@RequestMapping("/api/ai-assistant")
public class AiAssistantController {

    @Autowired
    private AiAssistantService aiAssistantService;

    // 请求DTO
    public record UserRequest(
            @NotBlank(message = "请求内容不能为空")
            String input
    ) {}

    // 响应DTO
    public record UserResponse(
            int code,
            String message,
            String data
    ) {
        public static UserResponse success(String data) {
            return new UserResponse(200, "操作成功", data);
        }

        public static UserResponse fail(String message) {
            return new UserResponse(500, message, null);
        }
    }

    // 处理用户请求
    @PostMapping("/chat")
    public UserResponse chat(@Valid @RequestBody UserRequest request) {
        try {
            String response = aiAssistantService.handleUserRequest(request.input());
            return UserResponse.success(response);
        } catch (Exception e) {
            return UserResponse.fail(e.getMessage());
        }
    }
}

4.4 测试验证:AI 助手是否能正确调用工具

4.4.1 测试用例 1:查询股票行情
  • 请求 URL:http://localhost:8080/api/ai-assistant/chat
  • 请求体:{"input":"查询贵州茅台的股票行情"}
  • 预期响应:
bash 复制代码
{
  "code": 200,
  "message": "操作成功",
  "data": "贵州茅台(600519)实时行情:\n最新价:1800.00 元\n涨跌额:+20.50 元\n涨跌幅:1.15%\n成交量:12345 手"
}
4.4.2 测试用例 2:查询天气预警
  • 请求 URL:http://localhost:8080/api/ai-assistant/chat
  • 请求体:{"input":"查询北京的天气预警"}
  • 预期响应:
bash 复制代码
{
  "code": 200,
  "message": "操作成功",
  "data": "北京 实时天气:\n天气状况:晴\n温度:25 ℃\n湿度:50 %\n风向:北风\n风力:2 级\n无预警信息"
}
4.4.3 测试用例 3:参数缺失(询问用户补充)
  • 请求 URL:http://localhost:8080/api/ai-assistant/chat
  • 请求体:{"input":"查询股票行情"}
  • 预期响应:
bash 复制代码
{
  "code": 200,
  "message": "操作成功",
  "data": "请告诉我你要查询的股票代码(如贵州茅台600519、平安银行000001)"
}
4.4.4 测试用例 4:无需工具调用(直接回答)
  • 请求 URL:http://localhost:8080/api/ai-assistant/chat
  • 请求体:{"input":"你好,介绍一下自己"}
  • 预期响应:
bash 复制代码
{
  "code": 200,
  "message": "操作成功",
  "data": "你好!我是智能助手,具备股票查询和天气预警功能。你可以让我查询A股股票的实时行情,或查询国内城市的天气和预警信息,直接告诉我你的需求即可~"
}

4.5 实战避坑总结

  1. 第三方 API 密钥不要硬编码,通过配置文件注入,生产环境用环境变量;
  2. 工具的description必须和 AI 助手的 Prompt 对应,否则 LLM 会选错工具;
  3. 股票代码、城市名称等参数需做格式校验,避免调用 API 失败;
  4. API 返回结果可能格式不规范(如新浪的 JSONP),需做好解析容错;
  5. 测试时先单独测试工具是否能正常运行,再测试 AI 助手的工具选择逻辑;
  6. 生产环境中,给工具调用添加日志打印,方便调试工具调用流程。

五、总结与展望

Spring AI 的 Tools 模块彻底重构了 AI 的外部调用能力 ------ 从 Function-call 的 "零散方法调用",升级为 "注解驱动 + 标准化注册 + 自动化流程" 的企业级方案。通过@Tool注解,开发者能快速将普通 Java 方法转化为 AI 可调用的工具;通过 ToolRegistry 和 ToolExecutor,实现工具的统一管理和标准化执行;通过 Prompt Engineering,让 LLM 能自动理解需求、选择工具、提取参数、处理结果。

本文从原理、工具开发、流程拆解到实战落地,全方位覆盖了 Spring AI 工具调用的核心技术。无论是 HTTP 客户端、数据库查询、文件操作等通用工具,还是股票查询、天气预警等业务工具,都能基于这套方案快速实现。

未来,Spring AI 的 Tools 模块将支持更多高级特性:工具链(多工具串联调用)、工具权限控制、工具调用日志审计等。建议开发者持续关注 Spring AI 官方文档,将工具调用与 RAG、Agent 等技术结合,构建更强大的企业级 AI 应用。

附录:常用配置与工具类

1. 高德地图 API 申请流程

  1. 访问高德开放平台(https://lbs.amap.com/),注册账号并登录;
  2. 进入 "控制台→应用管理→创建应用",填写应用名称;
  3. 为应用添加 "天气
相关推荐
Skrrapper19 小时前
【大模型开发之数据挖掘】1. 介绍数据挖掘及其产生与发展
人工智能·数据挖掘
rafael(一只小鱼)19 小时前
gemini使用+部署教程
java·人工智能·ai·go
Mr. zhihao19 小时前
深入浅出解析 Word2Vec:词向量的训练与应用
人工智能·自然语言处理·word2vec
南极星100519 小时前
OPENCV(python)--初学之路(十五)Shi-Tomasi 角点检测和追踪的良好特征和SIFT简介
人工智能·opencv·计算机视觉
skywalk816319 小时前
LLM API Gateway:使用Comate Spec Mode创建大模型调用中转服务器
服务器·人工智能·gateway·comate
却道天凉_好个秋19 小时前
OpenCV(三十九):Harris角点检测
人工智能·opencv·计算机视觉
谷粒.19 小时前
AI芯片战争:NVIDIA、AMD、Intel谁将主宰算力市场?
运维·网络·人工智能·测试工具·开源·自动化
snowfoootball19 小时前
java面向对象进阶
java·开发语言
爱学习的张大19 小时前
大话机器学习-1.神经网络
人工智能·神经网络·机器学习
热点速递19 小时前
AI竞争升级:OpenAI在三场“战争”中拉响红色警报,全力聚焦ChatGPT!
人工智能·chatgpt