Skip to content

@Tool 工具调用:让模型能调用你的 Java 方法 ​

1. 本节产出 ​

一个会主动调用工具的应用:问「北京今天天气怎么样」时,模型不再编造,而是触发你写的 WeatherTools.getWeather("北京"),拿到真实数据后再组织语言回答。同时具备工具异常处理与调用日志。

2. 前置依赖 ​

3. 为什么模型必须能调工具 ​

回到 01-01 的核心结论:模型只会续写,不会查询。所以:

用户问不给工具时模型会怎样给工具后
北京今天天气?编一个「晴,25 度」,看起来很像真的调用天气 API,返回真实数据
订单 SO12345 到哪了?编造物流状态查数据库返回真实状态
帮我把这张工单转成待处理说「好的已处理」,其实什么也没做调用写接口真正改状态

前两种情况叫幻觉,第三种更糟——模型撒谎说自己做了事。在生产环境里,第三种会直接造成业务事故。

工具调用的本质:模型不执行任何代码,它只是输出一段「我想调用 getWeather,参数是 {city: 北京}」的 JSON。真正执行的是你的 Java 代码。理解这一点,后面所有关于工具的设计问题(参数怎么定、错误怎么回、权限怎么控)都迎刃而解。

4. 核心原理 ​

4.1 一次工具调用的完整链路 ​

1. 请求发出
   你 → 模型:用户问题 + 【工具清单 JSON Schema】
                          ├─ name: getWeather
                          ├─ description: 查询指定城市当前天气
                          └─ parameters: {city: string}

2. 模型决策
   模型 → 你:{"tool":"getWeather","args":{"city":"北京"}}

3. 本地执行
   你的 Java 方法被调用 → 返回 "北京,晴,26℃"

4. 结果回填
   你 → 模型:工具结果(作为一条 tool 消息)
   
5. 最终回答
   模型 → 你:「北京今天晴,26℃,适合出行。」

注意第 3 步:执行发生在你的 JVM 里,用的是你配置的超时、连接池、权限。这意味着工具可以做任何事——查库、写文件、调外部 API、甚至删数据。这就是为什么 03B-11 工具权限沙箱是必讲内容。

4.2 描述(description)决定调用准确率 ​

模型靠 description 判断该不该调用、传什么参数。描述写得含糊,调用就随机。

差的描述好的描述
查询天气查询指定中国城市的当前天气,返回温度、天气状况和风力。仅支持中国大陆城市名,如"北京"、"深圳"。不支持国外城市。
查询订单根据订单号查询订单当前状态与物流信息。订单号格式以 SO 开头加数字,如 SO12345。查不到时返回 NOT_FOUND,不要猜测。

好描述的三个要素:能做什么、参数什么格式、边界在哪(不支持什么/失败时怎样)。把描述当成给新同事写的需求说明,而不是给 IDE 看的注释。

4.3 参数设计:少而确定 ​

原则反例正例
参数尽量少queryOrder(userId, orderId, startDate, endDate, status)queryOrder(orderId)
用枚举限定取值status: stringstatus: enum[PENDING, PAID, SHIPPED]
必填性明确全部可选,模型乱传只给真正需要的
字段名用业务语言p1, p2city, orderId

参数越多,模型出错概率指数上升。超过 3 个参数就该考虑拆成多个工具。

4.4 工具返回什么 ​

返回给模型的内容会被放进上下文,每一字节都花钱。所以:

  • 返回结构化但精简的结果,不要返回整个数据库对象;
  • 查不到就明确返回「未找到」,让模型去问用户,而不是返回空字符串让它编;
  • 不要把工具的堆栈异常原样丢给模型——它看不懂,还会学着在回答里复述堆栈。

5. 代码走查 ​

5.1 定义工具类 ​

java
// src/main/java/com/example/aibasics/tool/WeatherTools.java
@Component
public class WeatherTools {

    private final WeatherApi api;

    public WeatherTools(WeatherApi api) { this.api = api; }

    @Tool(description = """
        查询指定中国城市的当前天气,返回温度、天气状况和风力。
        仅支持中国大陆城市名,例如"北京"、"深圳"、"杭州"。
        不支持国外城市与区县级地名。查询失败时返回错误信息文本。
        """)
    public String getWeather(
            @ToolParam(description = "城市名,例如:北京。不含「市」字。")
            String city) {

        try {
            Weather w = api.query(city);
            return String.format("%s:%s,%d℃,风力 %s",
                    city, w.condition(), w.temp(), w.wind());
        } catch (CityNotFoundException e) {
            return "NOT_FOUND:未找到该城市,请向用户确认城市名是否正确。";
        } catch (Exception e) {
            // 关键:异常转成人话返回,不要抛出去
            log.error("weather query failed: {}", city, e);
            return "ERROR:天气服务暂时不可用,请告知用户稍后重试。";
        }
    }
}

异常处理这一段是本节最值钱的部分。工具方法抛异常时,不同版本行为不一:有的会把堆栈塞回上下文,有的直接中断整个调用。统一转成「人能读懂的字符串返回」是最稳的做法——模型拿到 NOT_FOUND 会去问用户,拿到堆栈只会照着念。

5.2 注册工具 ​

java
// src/main/java/com/example/aibasics/config/ChatConfig.java
@Bean
public ChatClient chatClient(ChatClient.Builder builder, WeatherTools weatherTools) {
    return builder
            .defaultSystem("你是助手。涉及天气、订单等实时信息时必须调用工具,"
                         + "不得凭记忆回答。工具返回 NOT_FOUND 时向用户确认。")
            .defaultTools(weatherTools)          // 注册整个类的所有 @Tool 方法
            .build();
}

也可以按方法粒度注册:

java
.defaultTools(MethodToolCallbackProvider.builder()
        .toolObjects(weatherTools)               // 扫描对象上的 @Tool 方法
        .build())

提示:Spring AI 1.x 里的 .defaultFunctions(...) 在 2.x 已改为 .defaultTools(...)。如果你的代码里还在用 functions,编译时可能不报错但工具不生效——这是版本升级时最隐蔽的坑之一。

5.3 单次调用时临时挂载工具 ​

java
// 只在某类请求上开放工具,避免权限过大
public String chatWithTools(String message, Set<String> allowed) {
    return chatClient.prompt()
            .user(message)
            .tools(MethodToolCallbackProvider.builder()
                    .toolObjects(selectTools(allowed))     // 按用户权限筛选
                    .build())
            .call()
            .content();
}

这是权限控制的第一道门:不要把所有工具一次性挂到全局 ChatClient 上,而是按用户角色/场景动态挂载。完整的分级方案见 03B-11。

5.4 观察调用过程 ​

java
// 打开工具调用日志
logging:
  level:
    org.springframework.ai: DEBUG

或者自己记录:

java
ChatResponse resp = chatClient.prompt()
        .user(message)
        .tools(weatherTools)
        .call()
        .chatResponse();

// 查看模型是否触发了工具
boolean hasToolCall = resp.getResult().getOutput().getToolCalls() != null
        && !resp.getResult().getOutput().getToolCalls().isEmpty();
log.info("tool invoked: {}", hasToolCall);

6. 跑起来 ​

bash
git checkout ch01-07-tool-calling
mvn spring-boot:run

验证一:正常触发

bash
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: text/plain" -d "北京今天天气怎么样?"

期望:日志出现工具调用记录,回答包含真实数值(不是编造的)。

验证二:边界测试(本节核心)

bash
# 1. 不支持的城市 → 模型应回复「未找到,请确认城市名」
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: text/plain" -d "纽约今天天气怎么样?"

# 2. 不需要工具的问题 → 不应触发工具
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: text/plain" -d "什么是依赖注入?"

# 3. 需要两个工具的问题 → 应连续调用
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: text/plain" -d "北京和深圳今天哪里更热?"
检查项通过标准
正常触发日志有工具调用,回答是真实数据
越界参数模型回复「不支持」而不是编造
无需工具时不触发工具,避免过度调用
多工具能连续调用两个城市并比较
异常路径模拟 API 挂掉,回答为「稍后重试」而非堆栈

异常路径必须真的模拟一次:把 WeatherApi 改成抛异常跑一遍,看模型拿到的是不是你写的那句人话。这是很多教程不讲、但生产一定会遇到的情况。

7. 生产避坑 ​

  1. 绝大多数「工具不生效」问题,根因是模型不支持或 description 写得含糊。排查顺序:先用最简工具(返回固定字符串)确认模型能触发 → 再换真实工具 → 最后调描述。不要一上来就改框架配置,那通常不是原因。
  2. 工具方法抛异常会让整轮对话失败。必须把所有异常转成可读文本返回,让模型有机会向用户解释或换一种方式。特别是涉及外部 API 的工具,超时和网络异常是常态而不是例外。
  3. 工具返回值会进上下文,别返回大对象。有人直接把 JPA 实体 toString 返回,一次调用几千 Token,多轮之后直接爆窗口。做法是定义一个专门的 DTO 只放模型需要的字段——给模型看的返回值和给前端看的返回值,本来就不该是同一个对象。

8. 延伸与锚点 ​

  • 思考题:模型编造了一个不存在的工具名来调用,你的代码会怎样?(提示:这是真实会发生的事,需要在执行层做白名单校验——答案在 03B-11 权限沙箱)
  • 代码锚点:git checkout ch01-07-tool-calling
  • 下一课时:01-08 结构化输出
  • 对应课件:L01-07 @Tool 工具调用