Spring AI 入门教程(四) Tool calling

前言

在前几篇文章中,我们学习了如何使用 Spring AI 进行基础的对话、流式响应、会话记忆等功能。但在实际应用中,AI 模型往往需要与外部系统交互,获取实时信息或执行特定操作。这就是 Tool Calling(工具调用) 的核心价值所在。

本文将深入探讨 Spring AI 的工具调用机制,从基础概念到高级特性,帮助您构建功能强大的 AI 应用。


一、什么是 Tool Calling?

1.1 核心概念

Tool Calling 是指 AI 模型在执行任务时,可以调用预定义的工具(函数/方法)来获取信息或执行操作。这是一种让 AI "突破"自身知识限制的重要机制。

典型场景:

  • 📅 获取当前时间、日期等实时信息
  • 🌤️ 查询天气、股票等动态数据
  • 💾 执行数据库增删改查操作
  • 🔔 设置闹钟、发送通知等系统操作
  • 📊 检索企业内部文档(RAG)

1.2 工作原理

用户提问 → AI 分析 → 决定调用工具 → Spring AI 执行工具 → 返回结果给 AI → AI 生成最终响应

关键组件:

  1. Tool Definition:工具定义(名称、描述、参数 Schema)
  2. Tool Callback:工具回调(实际执行的 Java 方法)
  3. ToolCallingManager:工具执行管理器
  4. ToolCallAdvisor:工具调用拦截器(自动模式)

二、快速开始:最简单的工具调用

2.1 使用 @Tool 注解

Spring AI 提供了最简洁的方式——通过 @Tool 注解将普通方法转换为 AI 可调用的工具。

@Slf4j
public class DateTimeTools {

    /**
     * 获取当前日期时间
     */
    @Tool(description = "获取用户时区的当前日期和时间")
    String getCurrentDateTime() {
        log.info("工具调用: getCurrentDateTime");
        LocalDateTime now = LocalDateTime.now();
        return now.atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
    }

    /**
     * 设置闹钟
     */
    @Tool(description = "为用户设置闹钟")
    void setAlarm(@ToolParam(description = "ISO-8601 格式的时间") String time) {
        log.info("设置闹钟: {}", time);
        LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME);
        System.out.println("闹钟已设置为 " + alarmTime);
    }
}

2.2 在 ChatClient 中使用

@RestController
@RequestMapping("/tool")
public class ToolCallingController {

    @Resource
    private ZhiPuAiChatModel zhiPuAiChatModel;

    @RequestMapping("/datetime-now")
    public String getCurrentDateTime() {
        ChatClient chatClient = ChatClient.create(zhiPuAiChatModel);

        String response = chatClient.prompt("What day is tomorrow?")
                .tools(new DateTimeTools())  // 注册工具
                .call()
                .content();

        return response;
    }
}

执行流程:

  1. 用户问:“明天是星期几?”
  2. AI 分析后决定调用 getCurrentDateTime 工具
  3. Spring AI 执行该方法,获取当前时间
  4. 将结果返回给 AI 模型
  5. AI 根据当前时间计算明天是星期几,生成最终响应

三、工具注册的三种方式

3.1 方式一:@Tool 注解(推荐用于简单工具)

public class MyTools {
    @Tool(description = "计算数学表达式")
    public String calculate(String expression) {
        // 实现逻辑
    }
}

// 使用时
chatClient.prompt("计算 6 * 8")
    .tools(new MyTools())
    .call()
    .content();

优点:

  • ✅ 简单直观,代码可读性好
  • ✅ 支持任意可见性(public、private 均可)
  • ✅ 自动生成 JSON Schema

缺点:

  • ❌ 不支持依赖注入
  • ❌ 工具必须在运行时显式传递

3.2 方式二:Function Bean(推荐用于复杂工具)

这是 Spring AI 的高级特性,通过 Spring Bean 注册工具,支持依赖注入和全局共享。

步骤 1:定义 Function Bean
@Configuration
public class ToolCallingConfig {

    public static final String CURRENT_WEATHER_TOOL = "currentWeather";

    @Bean(CURRENT_WEATHER_TOOL)
    @Description("Get the current weather for a given location")
    public Function<WeatherRequest, WeatherResponse> currentWeather() {
        return new WeatherService();
    }
}
步骤 2:实现 Function 接口
@Slf4j
public class WeatherService implements Function<WeatherRequest, WeatherResponse> {

    @Override
    public WeatherResponse apply(WeatherRequest request) {
        log.info("查询天气: location={}, unit={}", request.location(), request.unit());
        
        // 模拟天气数据(实际应调用真实 API)
        double temperature = getTemperatureFromApi(request.location(), request.unit());
        return new WeatherResponse(temperature, request.unit());
    }

    public record WeatherRequest(
        @ToolParam(description = "城市名称") String location,
        @ToolParam(description = "温度单位") Unit unit
    ) {}

    public record WeatherResponse(double temp, Unit unit) {}
}
步骤 3:通过工具名称引用
@RequestMapping("/weather-bean")
public String getWeatherByBean(String location) {
    ChatClient chatClient = ChatClient.create(zhiPuAiChatModel);

    String response = chatClient.prompt("What's the weather in " + location + "?")
            .toolNames(ToolCallingConfig.CURRENT_WEATHER_TOOL)  // 通过名称引用
            .call()
            .content();

    return response;
}

底层原理:

  • Spring AI 的 SpringBeanToolCallbackResolver 会扫描所有 Function 类型的 Bean
  • 自动将其转换为 ToolCallback
  • 使用 DelegatingToolCallbackResolver 在运行时动态解析工具名称

优点:

  • ✅ 支持依赖注入(可注入 Service、Repository)
  • ✅ 工具可被多个 ChatClient 共享
  • ✅ 工具名称集中管理

缺点:

  • ❌ 缺少编译时类型安全(工具名称是字符串)
  • ❌ 配置相对复杂

3.3 方式三:Lambda 表达式(适合简单计算)

@Bean("mathCalculator")
@Description("Perform basic mathematical calculations")
public Function<MathRequest, MathResponse> mathCalculator() {
    return request -> {
        double result;
        switch (request.operation()) {
            case "+" -> result = request.a() + request.b();
            case "-" -> result = request.a() - request.b();
            case "*" -> result = request.a() * request.b();
            case "/" -> result = request.a() / request.b();
            default -> throw new IllegalArgumentException("Unsupported operation");
        }
        return new MathResponse(result);
    };
}

// 使用
chatClient.prompt("What is 6 * 8?")
    .toolNames("mathCalculator")
    .call()
    .content();

四、高级特性

4.1 ToolContext:传递上下文信息

在某些场景下,我们需要向工具传递额外的上下文信息(如租户 ID、用户权限),但这些信息不应该发送给 AI 模型。这时可以使用 ToolContext

@Slf4j
public class CustomerTools {

    @Tool(description = "Retrieve customer information by ID")
    Customer getCustomerInfo(
            @ToolParam(description = "客户 ID") Long id,
            ToolContext toolContext  // 最后一个参数,由 Spring AI 自动注入
    ) {
        // 从上下文中获取租户 ID
        String tenantId = (String) toolContext.getContext().get("tenantId");
        log.info("Tenant ID: {}", tenantId);

        // 根据租户 ID 和客户 ID 查询数据(多租户隔离)
        Customer customer = customerRepository.findByIdAndTenant(id, tenantId);
        return customer;
    }
}

使用时传递上下文:

Map<String, Object> context = Map.of(
    "tenantId", "acme-corp",
    "requestId", UUID.randomUUID().toString()
);

String response = chatClient.prompt("Tell me about customer 42")
        .tools(new CustomerTools())
        .toolContext(context)  // 传递上下文
        .call()
        .content();

应用场景:

  • 🔐 多租户系统:传递 tenantId 实现数据隔离
  • 👤 权限控制:传递 userId、roles 进行权限校验
  • 📝 审计日志:传递 requestId、sessionId 用于追踪

4.2 Return Direct:直接返回工具结果

默认情况下,工具执行结果会发送回 AI 模型,让模型生成自然语言响应。但有些场景需要直接返回原始数据,这时可以使用 returnDirect=true

@Tool(
    description = "Retrieve customer information by ID",
    returnDirect = true  // 直接返回,不经过 AI 处理
)
Customer getCustomerInfo(Long id, ToolContext toolContext) {
    return customerRepository.findById(id);
}

对比:

特性 默认行为 (returnDirect=false) Return Direct (returnDirect=true)
执行流程 AI → 工具 → AI → 用户 AI → 工具 → 用户
返回内容 自然语言 原始数据(JSON/对象)
适用场景 对话式 AI RAG、数据库查询、精确数据

重要注意事项:

  • ⚠️ 如果一次请求调用了多个工具,所有工具的 returnDirect 都必须为 true 才能直接返回
  • ⚠️ 直接返回的是原始工具返回值,不是自然语言

适用场景:

  • 📚 RAG 检索:检索到的文档片段直接返回
  • 💾 数据库查询:精确的结构化数据直接返回
  • 🔒 敏感信息:保证数据完整性和准确性

4.3 可选参数:防止 AI 幻觉

某些参数不是必需的,应该标记为 required=false,避免 AI 编造不存在的值。

@Tool(description = "Update customer information")
void updateCustomerInfo(
    @ToolParam(description = "客户 ID") Long id,
    @ToolParam(description = "客户姓名") String name,
    @ToolParam(description = "客户邮箱(可选)", required = false) String email
) {
    // 更新逻辑
}

为什么需要可选参数?

如果参数标记为 required=true,但用户没有提供该值,AI 模型可能会产生幻觉(hallucination),编造一个看似合理的值。将非必需参数标记为 optional 可以避免这个问题。


五、用户控制的工具执行

5.1 框架控制 vs 用户控制

Spring AI 提供了两种工具执行模式:

框架控制(Framework-Controlled)
// 默认行为:Spring AI 自动处理工具调用循环
String response = chatClient.prompt("What day is tomorrow?")
        .tools(new DateTimeTools())
        .call()
        .content();

优点:

  • ✅ 简单易用,一行代码搞定
  • ✅ 自动处理工具调用循环
  • ✅ 适合大多数场景
用户控制(User-Controlled)
// 禁用自动工具执行
ZhiPuAiChatOptions chatOptions = ZhiPuAiChatOptions.builder()
        .toolCallbacks(ToolCallbacks.from(new DateTimeTools()))
        .internalToolExecutionEnabled(false)  // 关键配置
        .build();

// 手动执行工具调用循环
Prompt prompt = new Prompt(
    List.of(new SystemMessage("You are helpful."), new UserMessage("What day is tomorrow?")),
    chatOptions
);

ChatResponse chatResponse = zhiPuAiChatModel.call(prompt);

// 循环执行工具调用
while (chatResponse.hasToolCalls()) {
    ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, chatResponse);
    
    prompt = new Prompt(result.conversationHistory(), chatOptions);
    chatResponse = zhiPuAiChatModel.call(prompt);
}

String finalResponse = chatResponse.getResult().getOutput().getText();

适用场景:

  • 🔧 需要在工具执行前后添加自定义逻辑(日志、监控、权限检查)
  • 📊 需要访问工具执行的中间结果
  • ⚙️ 需要自定义错误处理策略
  • 🔗 需要与外部系统集成(消息队列、事件总线)

5.2 结合 ChatMemory 的用户控制执行

将用户控制的工具执行与 ChatMemory 结合,可以实现有状态的多轮工具调用对话。

@RequestMapping("/memory-tool-execution")
public String memoryWithToolExecution() {
    ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
    String conversationId = UUID.randomUUID().toString();

    // 配置选项
    ZhiPuAiChatOptions chatOptions = ZhiPuAiChatOptions.builder()
            .toolCallbacks(ToolCallbacks.from(new DateTimeTools()))
            .internalToolExecutionEnabled(false)
            .build();

    // 创建初始提示词
    Prompt prompt = new Prompt(
        List.of(
            new SystemMessage("You are a helpful assistant."),
            new UserMessage("What day is tomorrow?")
        ),
        chatOptions
    );

    // 将初始消息添加到 ChatMemory
    chatMemory.add(conversationId, prompt.getInstructions());

    // 从 ChatMemory 获取完整对话历史
    Prompt promptWithMemory = new Prompt(chatMemory.get(conversationId), chatOptions);

    // 第一次调用模型
    ChatResponse chatResponse = zhiPuAiChatModel.call(promptWithMemory);

    // 循环执行工具调用
    int maxIterations = 5;
    int iteration = 0;
    while (chatResponse.hasToolCalls() && iteration < maxIterations) {
        iteration++;

        // 如果有工具调用,将 AI 的响应添加到记忆
        if (chatResponse.hasToolCalls()) {
            chatMemory.add(conversationId, chatResponse.getResult().getOutput());
        }

        // 执行工具调用
        ToolExecutionResult toolExecutionResult = 
            toolCallingManager.executeToolCalls(promptWithMemory, chatResponse);

        // 将工具执行结果添加到 ChatMemory
        chatMemory.add(conversationId, 
            toolExecutionResult.conversationHistory()
                .get(toolExecutionResult.conversationHistory().size() - 1));

        // 更新提示词
        promptWithMemory = new Prompt(chatMemory.get(conversationId), chatOptions);

        // 再次调用模型
        chatResponse = zhiPuAiChatModel.call(promptWithMemory);
    }

    // 继续对话:询问之前的问题
    chatMemory.add(conversationId, new UserMessage("What did I ask you earlier?"));
    ChatResponse newResponse = zhiPuAiChatModel.call(
        new Prompt(chatMemory.get(conversationId))
    );

    return newResponse.getResult().getOutput().getText();
}

优势:

  • ✅ 支持复杂的多轮工具调用场景
  • ✅ 保持完整的对话上下文
  • ✅ 可以查询之前的对话内容
  • ✅ 适合构建智能 Agent 系统

六、组合多种工具

在实际应用中,我们经常需要同时使用多种类型的工具。

@RequestMapping("/combined-tools")
public String combinedTools() {
    ChatClient chatClient = ChatClient.create(zhiPuAiChatModel);

    String response = chatClient.prompt(
            "What's the current date and time, and what's the weather in Beijing? " +
            "Also, can you calculate 15 * 7 for me?"
        )
        .tools(new DateTimeTools())  // @Tool 注解工具
        .toolNames(  // Bean 注册工具
            ToolCallingConfig.CURRENT_WEATHER_TOOL,
            ToolCallingConfig.MATH_CALCULATOR_TOOL
        )
        .call()
        .content();

    return response;
}

关键点:

  • 🔄 可以同时使用 @Tool 注解工具和 Bean 注册工具
  • 🎯 AI 模型会自动选择需要的工具并编排调用顺序
  • ⚡ 多个工具可以协同完成复杂任务

七、最佳实践

7.1 工具设计原则

  1. 单一职责:每个工具只做一件事

    // ✅ 好:单一职责
    @Tool(description = "获取当前时间")
    String getCurrentTime() { ... }
    
    @Tool(description = "获取当前日期")
    String getCurrentDate() { ... }
    
    // ❌ 不好:职责混乱
    @Tool(description = "获取时间或日期")
    String getTimeOrDate(String type) { ... }
    
  2. 详细描述:为工具和参数提供清晰的描述

    @Tool(description = "获取指定城市的当前天气,返回温度和湿度信息")
    WeatherResponse getWeather(
        @ToolParam(description = "城市名称,如:北京、上海、New York") String city,
        @ToolParam(description = "温度单位:C=摄氏度,F=华氏度") Unit unit
    ) { ... }
    
  3. 使用常量定义工具名称

    public class ToolCallingConfig {
        public static final String CURRENT_WEATHER_TOOL = "currentWeather";
        
        @Bean(CURRENT_WEATHER_TOOL)
        public Function<...> currentWeather() { ... }
    }
    
    // 使用时
    .toolNames(ToolCallingConfig.CURRENT_WEATHER_TOOL)
    
  4. 合理设置可选参数

    // ✅ 好:email 是可选的
    @ToolParam(description = "邮箱(可选)", required = false) String email
    
    // ❌ 不好:可能导致 AI 幻觉
    @ToolParam(description = "邮箱") String email
    

7.2 安全性考虑

  1. 工具执行边界:AI 模型只能请求调用工具,不能直接访问工具 API
  2. 权限校验:在工具内部进行权限检查
    @Tool(description = "删除用户")
    void deleteUser(Long userId, ToolContext context) {
        String role = (String) context.getContext().get("userRole");
        if (!"ADMIN".equals(role)) {
            throw new SecurityException("Permission denied");
        }
        // 执行删除
    }
    
  3. 输入验证:始终验证工具参数
    @Tool(description = "执行命令")
    String executeCommand(String command) {
        // 白名单验证
        if (!ALLOWED_COMMANDS.contains(command)) {
            throw new IllegalArgumentException("Command not allowed");
        }
        // 执行
    }
    

7.3 性能优化

  1. 懒加载工具:只在需要时注册工具
  2. 缓存工具结果:对于频繁调用的工具,考虑缓存结果
  3. 异步工具:对于耗时操作,考虑使用异步执行

八、常见问题

Q1: 工具没有被调用怎么办?

可能原因:

  1. 工具描述不够清晰,AI 不知道何时调用
  2. 提示词中没有明确暗示需要调用工具
  3. 工具参数 Schema 不正确

解决方案:

  • 提供更详细的工具描述
  • 在提示词中暗示可能需要工具
  • 检查 @ToolParam 的描述是否准确

Q2: 如何调试工具调用?

方法:

  1. 启用日志:查看工具调用日志
    logging.level.cn.dianyu.ai.myspringai.toolcalling=DEBUG
    
  2. 检查 JSON Schema:确保生成的 Schema 符合预期
  3. 使用用户控制模式:逐步跟踪工具执行过程

Q3: 如何处理工具执行异常?

Spring AI 提供了 ToolExecutionExceptionProcessor 来处理异常:

@Tool(description = "查询数据")
String queryData(String key) {
    try {
        return dataRepository.findByKey(key);
    } catch (Exception e) {
        log.error("Query failed", e);
        throw new RuntimeException("Failed to query data: " + e.getMessage());
    }
}

异常会被捕获并返回给 AI 模型,AI 可以根据错误信息调整策略。


九、总结

本文详细介绍了 Spring AI 的工具调用机制,包括:

基础概念:什么是 Tool Calling,工作原理是什么
三种注册方式:@Tool 注解、Function Bean、Lambda 表达式
高级特性:ToolContext、Return Direct、可选参数
执行模式:框架控制 vs 用户控制
整合 ChatMemory:实现有状态的多轮工具调用
最佳实践:工具设计、安全性、性能优化

核心要点:

  1. 工具调用让 AI 能够突破自身限制,与外部系统交互
  2. 根据场景选择合适的工具注册方式
  3. 合理使用 ToolContext 和 Return Direct 提升灵活性
  4. 用户控制模式适合需要精细控制的复杂场景
  5. 始终遵循最佳实践,确保工具的安全性和可维护性

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐