目录
[一、先别急着写代码:BOM 是 Spring AI Alibaba 的"版本锚点"](#一、先别急着写代码:BOM 是 Spring AI Alibaba 的“版本锚点”)
[二、声明式工具:@Tool 把方法签名变成模型可读的 schema](#二、声明式工具:@Tool 把方法签名变成模型可读的 schema)
[三、把工具交给 ChatClient:defaultTools 与 tools 的取舍](#三、把工具交给 ChatClient:defaultTools 与 tools 的取舍)
在 Java 生态中落地大模型应用,Spring AI Alibaba 提供了一条相对平滑的路径:它把模型接入、对话管理和工具调用封装成 Spring 开发者熟悉的 Bean 与注解范式。本文从工程地基出发,先解决一个容易被轻视但影响深远的问题------依赖版本对齐,再完整走通"定义工具 → 注册工具 → 由模型自主调用"的工具调用链路。
一、先别急着写代码:BOM 是 Spring AI Alibaba 的"版本锚点"
任何一个涉及模型接入的 Spring Boot 项目,依赖清单都不会只有一两项。Spring AI Alibaba 的运行时依赖至少涉及三层:Spring AI Alibaba 自身的 starter 与模块、Spring AI 的核心抽象(ChatModel、ChatClient、ToolCallback 等),以及底层的 Spring Boot 与 Spring Framework。如果每一项都在 <dependencies> 里手动写 <version>,传递性依赖的版本仲裁机制(如"最近者优先")很容易把同一个库的不同传递路径解析成不一致的版本,编译期或运行期的问题往往以难以定位的形式暴露出来。
spring-ai-alibaba-bom 解决的正是这个问题。它是一个只做版本声明的特殊 POM,通过 <scope>import</scope> 导入到 dependencyManagement 之后,你在 <dependencies> 中声明 spring-ai-alibaba-starter-dashscope 等具体依赖时,就无需再写版本号,Maven 会自动从 BOM 中取用经过官方验证、彼此兼容的版本。官方文档也明确推荐:新项目应当通过 BOM 统一管理 Spring AI Alibaba、Spring AI 和 Spring Boot 的版本,避免三者在版本演进中相互冲突。
一个最小可用的依赖配置如下:
XML
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-bom</artifactId>
<version>1.0.0.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
spring-ai-alibaba-starter-dashscope 负责自动装配 DashScope 的 ChatModel 实现。你还需要在 application.yml 中提供 API Key,推荐通过环境变量注入,避免密钥硬编码:
XML
spring:
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
spring.ai.dashscope.api-key 是访问 DashScope 的凭证属性,spring.ai.dashscope.chat.options.model 则用于指定默认模型。
二、声明式工具:@Tool 把方法签名变成模型可读的 schema
工具调用的核心机制并不复杂:框架把带有 @Tool 注解的方法签名转换成一份 JSON Schema,连同工具描述一起发送给模型;模型在需要时返回 tool_calls,框架再将参数反序列化,通过反射调用你的方法,并把返回值送回对话上下文。模型只做"选工具、填参数"的决策,方法的实际执行始终在客户端应用中完成,这是一个关键的安全边界。
@Tool 注解的源码
java
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Tool {
/**
* The name of the tool. If not provided, the method name will be used.
*/
String name() default "";
/**
* The description of the tool. If not provided, the method name will be used.
*/
String description() default "";
/**
* Whether the tool result should be returned directly or passed back to the model.
*/
boolean returnDirect() default false;
/**
* The class to use to convert the tool call result to a String.
*/
Class<? extends ToolCallResultConverter> resultConverter() default DefaultToolCallResultConverter.class;
}
@Tool 注解的四个属性中,description 是最需要认真对待的:
| 属性 | 作用 |
|---|---|
name |
工具名称;未提供时默认使用方法名,同一模型上下文中必须唯一 |
description |
工具用途说明;模型据此判断何时 以及如何调用该工具 |
returnDirect |
工具结果直接返回客户端,还是回传给模型继续推理 |
resultConverter |
将工具返回值转换为字符串的回传转换器,默认使用 Jackson 序列化为 JSON |
description 写不好,模型要么在应该调用工具时错过,要么在参数上"自由发挥"。一个可用的天气工具类如下:
java
@Component
public class WeatherTools {
@Tool(description = "根据城市名称查询实时天气,返回天气状况和温度")
public String getCurrentWeather(
@ToolParam(description = "城市名称,例如:北京、上海、深圳") String city) {
return switch (city) {
case "上海" -> "上海:暴雨,26℃,湿度 88%";
case "北京" -> "北京:大雪,-3℃,北风 4 级";
case "深圳" -> "深圳:晴,30℃,湿度 65%";
default -> "暂不支持该城市的天气查询";
};
}
}
这里使用了 @ToolParam 为参数补充描述。Spring AI 默认将所有参数视为必需,@ToolParam(required = false) 或 @Nullable 可以将其标记为可选。对于参数含义不够直观的工具,参数级描述同样会进入 JSON Schema,直接影响模型填参的准确率。
需要留意的是,方法工具对参数类型有一定限制:Optional、异步类型、响应式类型(Mono、Flux)以及函数类型均不在支持范围内。保持参数为简单类型或可序列化的 POJO,是最稳妥的选择。
三、把工具交给 ChatClient:defaultTools 与 tools 的取舍
工具定义好了,模型并不会"自动发现"它。需要显式地将工具对象注册到 ChatClient。Spring AI 提供了两个层面的注册入口:
-
ChatClient.Builder.defaultTools(...):构建期注册,对由该 Builder 创建的所有请求生效; -
ChatClient.prompt().tools(...):运行期注册,只对当次请求生效,且会完全覆盖默认工具。
如果工具是应用的固定能力集,defaultTools 更简洁;如果工具需要根据用户权限或场景动态变化,运行期 tools 更合适。
ChatClient 本身有两种初始化方式。第一种是构造器注入,利用 Spring 自动装配的 ChatModel(DashScope starter 生成的是 DashScopeChatModel,它实现了 ChatModel 接口,注入时会自然向上转型):
java
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatModel chatModel, WeatherTools weatherTools) {
this.chatClient = ChatClient.builder(chatModel)
.defaultTools(weatherTools)
.defaultSystem("你是一个乐于助人的助手。需要天气信息时请调用工具。")
.build();
}
@GetMapping("/weather")
public String weather(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
第二种是声明 @Bean,将 ChatClient 作为 Spring 容器中的单例统一管理,适合在多处注入复用的场景:
java
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatModel chatModel, WeatherTools weatherTools) {
return ChatClient.builder(chatModel)
.defaultTools(weatherTools)
.build();
}
}
两种方式在行为上等价,选择取决于你希望将装配逻辑放在 Controller 内部还是独立配置类中。完成注册后,当用户发送"北京天气怎么样",模型会自行判断需要调用 getCurrentWeather,填入 city = "北京",框架执行方法并将结果回传,最终由模型整合成自然语言回复。整个过程中,Controller 代码不需要任何 if (message.contains("天气")) 式的硬编码分发。
四、几个容易踩的坑
第一,工具返回值尽量结构化。 默认转换器会用 Jackson 把对象序列化为 JSON。如果返回一段随意拼接的中文长句,模型仍能处理,但结构化 JSON 在多次工具调用的场景下更稳定,也便于后续扩展为 RAG 或真实数据源时保持接口一致。
第二,工具调用会增加 Token 消耗。 每一次工具交互都意味着额外的"模型决策 → 工具执行 → 结果回传"轮次,复杂任务下成本会叠加。工具描述应精确但不过度冗长,工具粒度也不宜过细。
第三,涉及写操作的工具必须做权限围栏。 查询类工具(读天气、读订单状态)风险可控;但一旦工具方法包含写库、发邮件、修改配置等副作用,模型的一个错误决策就可能产生真实影响。建议在工具方法内部或切面层统一实现确认机制、幂等控制和审计日志,而不是把安全性寄托在 System Prompt 的约束上。
第四,工具与 RAG 不是互斥的。 本文天气工具返回的是虚构数据,是为了演示链路的最小化。在真实系统中,工具方法内部完全可以接入向量检索或业务数据库------工具负责"何时调用",检索负责"拿什么数据",二者组合才是生产级 AI 应用的常态。