Spring AI函数调用进阶:构建生产级可扩展工具链
Spring AI函数调用进阶:构建生产级可扩展工具链
那次让我排查了两小时的假天气
2025年10月某天下午,一个客户在微信上给我发了一条消息,附了一张截图。
截图里是他们公司的AI旅行助手对话记录:
用户:我后天要去哈尔滨出差,天气怎么样,需要带什么衣服?
AI助手:哈尔滨后天(10月22日)天气晴朗,气温15-22°C,适合穿薄外套。建议携带...
截图右边是他在手机上打开的天气APP:哈尔滨当天气温:-2°C,大雪。
客户问我:"这差了20度,AI在编数据?"
我把项目代码拉下来开始排查。天气工具是用的OpenWeatherMap API,日志里显示工具被调用了:
[Tool] getWeather called: city=哈尔滨
[Tool] getWeather completed in 2341ms但仔细看返回值,发现了问题:
// 原来的代码
@Bean
public FunctionCallback weatherFunction() {
return FunctionCallbackWrapper.builder(new WeatherService()::getWeather)
.withName("getWeather")
.withDescription("Get weather information")
.build();
}getWeather方法内部调用API超时了,悄悄返回了null,而FunctionCallback的错误处理直接吞掉了异常,LLM收到null之后自己发挥,编了一个合理的天气。
更可怕的是,这个问题在线上存在了3周。那3周里,有多少用户基于假天气做了决策?
排查完我告诉客户:这不是AI的问题,是工具链设计有缺陷——没有错误处理,没有监控,没有降级。
今天这篇文章,就是要把生产级的工具链该怎么做,一次说清楚。
函数调用核心机制
先搞清楚Function Calling的完整流程,很多人以为AI"直接调用"了函数,这是错的。
关键理解:
- LLM不直接调用工具,它只是生成"应该调用哪个工具、用什么参数"的决策
- 实际执行工具的是你的Java代码
- 所有的错误处理、超时控制、权限检查都在Java侧完成
完整项目结构
spring-ai-tools/
├── pom.xml
├── src/main/java/com/laozhang/tools/
│ ├── ToolsApplication.java
│ ├── config/
│ │ ├── ChatClientConfig.java
│ │ └── ToolConfig.java
│ ├── tool/
│ │ ├── weather/
│ │ │ ├── WeatherTools.java # 天气工具
│ │ │ └── WeatherApiClient.java
│ │ ├── database/
│ │ │ └── DatabaseQueryTool.java # 数据库查询工具
│ │ ├── http/
│ │ │ └── AuthenticatedHttpTool.java # 带认证的HTTP工具
│ │ └── common/
│ │ ├── ToolMonitorAspect.java # 监控切面
│ │ └── ToolSecurityChecker.java # 安全检查
│ ├── service/
│ │ └── ToolChatService.java
│ └── advisor/
│ └── ToolCallLoggingAdvisor.java
└── resources/
└── application.ymlpom.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
https://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.3.5</version>
</parent>
<groupId>com.laozhang</groupId>
<artifactId>spring-ai-tools</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- AOP(工具监控切面) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- HTTP客户端(带认证的工具调用) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 数据库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- 熔断限流 -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- 缓存 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- 监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</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>
</project>application.yml
spring:
application:
name: spring-ai-tools
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.3
max-tokens: 2048
# 数据库(数据库查询工具使用)
datasource:
url: jdbc:mysql://localhost:3306/ai_tools_db
username: ${DB_USER}
password: ${DB_PASS}
# 缓存(天气数据缓存)
cache:
type: caffeine
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
# 工具配置
tools:
weather:
api-key: ${OPENWEATHER_API_KEY}
base-url: https://api.openweathermap.org/data/2.5
timeout-seconds: 5
retry-times: 2
database:
# 允许查询的表(安全白名单)
allowed-tables:
- products
- orders
- customers
# 单次查询最大返回行数
max-rows: 100
# 查询超时(秒)
query-timeout-seconds: 10
http:
timeout-seconds: 10
max-retries: 3
# Resilience4j熔断配置
resilience4j:
circuitbreaker:
instances:
weatherTool:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
dbQueryTool:
sliding-window-size: 5
failure-rate-threshold: 60
wait-duration-in-open-state: 60s
timelimiter:
instances:
weatherTool:
timeout-duration: 5s
dbQueryTool:
timeout-duration: 10s
logging:
level:
com.laozhang.tools: DEBUG
org.springframework.ai: INFO@Tool注解完整用法详解
参数校验与描述精确性
这是工具质量最关键的部分——描述写得越精确,LLM越少调用错误。
package com.laozhang.tools.tool.weather;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 天气查询工具 - 生产级实现
*
* 设计原则:
* 1. @Tool description精确描述使用场景(LLM依赖这个决策)
* 2. @ToolParam description说明参数格式和约束
* 3. 所有输入做参数校验
* 4. 超时、重试、熔断全部处理
* 5. 错误不要吞掉,要明确告诉LLM发生了什么
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WeatherTools {
private final WeatherApiClient weatherApiClient;
/**
* 获取当前天气
*
* Description写法要点:
* 1. 明确说明何时该调用此工具(触发条件)
* 2. 说明支持的输入范围
* 3. 说明返回什么数据
* 4. 如果有不支持的情况,也要说明
*/
@Tool(description = """
获取指定城市的实时天气信息。
当用户询问当前天气、今天天气、现在天气时使用此工具。
支持中国所有地级市及直辖市,以及全球主要城市。
返回:温度(摄氏度)、体感温度、天气状况(晴/多云/雨雪等)、湿度(%)、风速(km/h)、能见度(km)。
不支持查询历史天气,历史天气请使用getHistoricalWeather工具。
""")
@Cacheable(value = "weatherCache", key = "#city", unless = "#result.hasError()")
@CircuitBreaker(name = "weatherTool", fallbackMethod = "getWeatherFallback")
public WeatherResult getCurrentWeather(
@ToolParam(description = "城市名称。支持格式:中文(如:北京、上海、成都)或英文(如:Beijing)。" +
"直辖市直接写城市名,不需要省份前缀。")
String city) {
// 参数校验
validateCityName(city);
log.info("[WeatherTool] 查询天气: city={}", city);
WeatherData data = weatherApiClient.getCurrentWeather(city);
WeatherResult result = new WeatherResult(
city,
data.temperature(),
data.feelsLike(),
data.condition(),
data.humidity(),
data.windSpeed(),
data.visibility(),
null // 无错误
);
log.info("[WeatherTool] 天气查询成功: city={}, temp={}°C, condition={}",
city, data.temperature(), data.condition());
return result;
}
/**
* 熔断降级方法
* 当天气API不可用时,明确告诉LLM工具失败了
* 不要让LLM猜测或编造天气数据!
*/
public WeatherResult getWeatherFallback(String city, Exception ex) {
log.warn("[WeatherTool] 天气API不可用,返回错误结果: city={}, error={}",
city, ex.getMessage());
return new WeatherResult(
city, 0, 0, null, 0, 0, 0,
"天气服务暂时不可用,请稍后重试或查看天气App。错误:" + ex.getMessage()
);
}
/**
* 获取天气预报(3-7天)
*/
@Tool(description = """
获取指定城市未来1-7天的天气预报。
当用户询问明天、后天、本周、未来几天天气时使用此工具。
不能用于查询今天或当前天气(今天的实时天气用getCurrentWeather)。
返回:每天的最高温、最低温、天气状况、降水概率。
""")
public WeatherForecastResult getWeatherForecast(
@ToolParam(description = "城市名称,同getCurrentWeather的city参数格式")
String city,
@ToolParam(description = "预报天数,整数,范围1-7。" +
"明天=1,后天=2,本周=7,不确定时默认3。")
int days) {
// 参数范围校验
if (days < 1 || days > 7) {
// 不要抛异常,返回错误信息让LLM处理
return new WeatherForecastResult(city, null,
"days参数必须在1-7之间,实际值:" + days);
}
validateCityName(city);
log.info("[WeatherTool] 查询预报: city={}, days={}", city, days);
List<DailyForecast> forecasts = weatherApiClient.getForecast(city, days);
return new WeatherForecastResult(city, forecasts, null);
}
private void validateCityName(String city) {
if (city == null || city.isBlank()) {
throw new IllegalArgumentException("城市名称不能为空");
}
if (city.length() > 50) {
throw new IllegalArgumentException("城市名称过长(超过50字符): " + city);
}
// 防止注入:城市名不应包含特殊字符
if (city.matches(".*[<>\"';&|`$].*")) {
throw new IllegalArgumentException("城市名称包含非法字符: " + city);
}
}
// 返回结果DTO
public record WeatherResult(
String city,
double temperature,
double feelsLike,
String condition,
int humidity,
double windSpeed,
double visibility,
String error // null表示成功
) {
public boolean hasError() { return error != null; }
}
public record WeatherForecastResult(
String city,
List<DailyForecast> forecasts,
String error
) {}
public record DailyForecast(
String date,
double maxTemp,
double minTemp,
String condition,
int precipitationProbability
) {}
}带认证的HTTP工具
package com.laozhang.tools.tool.http;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
/**
* 带认证的HTTP调用工具
* 支持:Bearer Token、API Key、Basic Auth
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticatedHttpTool {
@Value("${tools.http.timeout-seconds:10}")
private int timeoutSeconds;
private final WebClient.Builder webClientBuilder;
/**
* 调用内部企业API
* 使用服务账号Token,不暴露给LLM
*/
@Tool(description = """
调用公司内部CRM系统查询客户信息。
当用户询问某个客户的订单、联系方式、历史记录时使用。
需要提供客户ID(格式:CUS-XXXXXX,如CUS-123456)。
返回客户基本信息和最近3个月的订单摘要。
注意:只能查询客户信息,不能修改。
""")
public CrmQueryResult queryCustomerInfo(
@ToolParam(description = "客户ID,格式为CUS-数字,如CUS-123456")
String customerId) {
// 参数格式校验
if (!customerId.matches("CUS-\\d{6}")) {
return new CrmQueryResult(null, "客户ID格式错误,应为CUS-XXXXXX格式");
}
log.info("[HttpTool] 查询客户信息: customerId={}", customerId);
try {
// API Key不来自LLM,而是从Spring配置注入
String apiKey = getInternalApiKey();
Map<String, Object> response = webClientBuilder.build()
.get()
.uri("https://crm-api.internal/customers/{id}", customerId)
.header("Authorization", "Bearer " + apiKey)
.header("X-Request-ID", generateRequestId())
.retrieve()
.bodyToMono(new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() {})
.timeout(Duration.ofSeconds(timeoutSeconds))
.onErrorReturn(Map.of("error", "CRM系统请求超时"))
.block();
if (response != null && response.containsKey("error")) {
return new CrmQueryResult(null, response.get("error").toString());
}
return new CrmQueryResult(response, null);
} catch (Exception e) {
log.error("[HttpTool] CRM查询失败: customerId={}, error={}",
customerId, e.getMessage());
return new CrmQueryResult(null, "查询失败:" + e.getMessage());
}
}
@Value("${crm.api.key}")
private String crmApiKey;
private String getInternalApiKey() {
// API Key从配置中获取,绝不能让LLM传入API Key
return crmApiKey;
}
private String generateRequestId() {
return "tool-" + System.currentTimeMillis();
}
public record CrmQueryResult(Map<String, Object> data, String error) {
public boolean isSuccess() { return error == null; }
}
}数据库查询工具(带SQL注入防护)
package com.laozhang.tools.tool.database;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 数据库查询工具
*
* 安全设计:
* 1. 只允许SELECT查询
* 2. 白名单表限制
* 3. 参数化查询(防SQL注入)
* 4. 结果行数限制
* 5. 查询超时
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DatabaseQueryTool {
private final JdbcTemplate jdbcTemplate;
@Value("#{'${tools.database.allowed-tables}'.split(',')}")
private Set<String> allowedTables;
@Value("${tools.database.max-rows:100}")
private int maxRows;
/**
* 查询商品信息
*/
@Tool(description = """
查询商品数据库中的产品信息。
支持按商品名称模糊查询、按价格范围查询、按分类查询。
当用户询问商品库存、价格、是否有货时使用此工具。
返回商品ID、名称、价格、库存数量、分类。
最多返回20条结果。
""")
public ProductQueryResult queryProducts(
@ToolParam(description = "商品名称关键词,用于模糊匹配。如不需要按名称过滤则传空字符串。")
String nameKeyword,
@ToolParam(description = "最低价格(元),-1表示不限制")
double minPrice,
@ToolParam(description = "最高价格(元),-1表示不限制")
double maxPrice,
@ToolParam(description = "商品分类,如:电子产品、服装、食品。空字符串表示不限制")
String category) {
log.info("[DbTool] 查询商品: keyword={}, priceRange=[{},{}], category={}",
nameKeyword, minPrice, maxPrice, category);
try {
// 构建参数化查询(防SQL注入)
StringBuilder sql = new StringBuilder(
"SELECT id, name, price, stock_quantity, category FROM products WHERE 1=1");
List<Object> params = new java.util.ArrayList<>();
if (nameKeyword != null && !nameKeyword.isBlank()) {
sql.append(" AND name LIKE ?");
params.add("%" + sanitizeKeyword(nameKeyword) + "%");
}
if (minPrice >= 0) {
sql.append(" AND price >= ?");
params.add(minPrice);
}
if (maxPrice >= 0) {
sql.append(" AND price <= ?");
params.add(maxPrice);
}
if (category != null && !category.isBlank()) {
sql.append(" AND category = ?");
params.add(sanitizeKeyword(category));
}
sql.append(" LIMIT ").append(Math.min(maxRows, 20));
List<Map<String, Object>> results = jdbcTemplate.queryForList(
sql.toString(), params.toArray());
log.info("[DbTool] 查询完成: resultCount={}", results.size());
return new ProductQueryResult(results, results.size(), null);
} catch (Exception e) {
log.error("[DbTool] 查询失败: error={}", e.getMessage());
return new ProductQueryResult(null, 0, "查询失败:" + e.getMessage());
}
}
/**
* 查询订单信息
*/
@Tool(description = """
查询订单信息。通过订单号或客户ID查询。
当用户询问订单状态、物流信息、订单详情时使用。
不能修改订单,只能查询。
""")
public OrderQueryResult queryOrder(
@ToolParam(description = "订单号,格式:ORD-年月日-序号,如ORD-20251001-00001。" +
"与customerId至少传一个。")
String orderId,
@ToolParam(description = "客户ID,与orderId至少传一个")
String customerId) {
if ((orderId == null || orderId.isBlank()) &&
(customerId == null || customerId.isBlank())) {
return new OrderQueryResult(null, "orderId和customerId不能都为空");
}
try {
StringBuilder sql = new StringBuilder(
"SELECT id, customer_id, status, total_amount, created_at, " +
"shipping_address, tracking_number FROM orders WHERE 1=1");
List<Object> params = new java.util.ArrayList<>();
if (orderId != null && !orderId.isBlank()) {
sql.append(" AND id = ?");
params.add(orderId);
}
if (customerId != null && !customerId.isBlank()) {
sql.append(" AND customer_id = ?");
params.add(customerId);
}
sql.append(" ORDER BY created_at DESC LIMIT 10");
List<Map<String, Object>> orders = jdbcTemplate.queryForList(
sql.toString(), params.toArray());
return new OrderQueryResult(orders, null);
} catch (Exception e) {
return new OrderQueryResult(null, "查询失败:" + e.getMessage());
}
}
/**
* 清理关键词,防止SQL注入
* 使用参数化查询已经防注入,这里是额外的防御
*/
private String sanitizeKeyword(String input) {
// 移除SQL特殊字符
return input.replaceAll("[%_\\\\]", "\\\\$0");
}
public record ProductQueryResult(
List<Map<String, Object>> products,
int totalCount,
String error
) {}
public record OrderQueryResult(
List<Map<String, Object>> orders,
String error
) {}
}工具监控AOP切面
这是排查"假天气"问题的关键——任何工具调用都要有完整的可观测性。
package com.laozhang.tools.common;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Arrays;
/**
* 工具调用监控AOP切面
*
* 功能:
* 1. 记录每次工具调用的参数、结果、耗时
* 2. 上报Prometheus指标(调用次数、耗时、成功率)
* 3. 异常告警
* 4. 慢调用检测(超过阈值记录警告)
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ToolMonitorAspect {
private final MeterRegistry meterRegistry;
// 慢调用阈值:超过3秒记录警告
private static final long SLOW_CALL_THRESHOLD_MS = 3000;
/**
* 拦截所有带@Tool注解的方法
*/
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object monitorToolCall(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Tool toolAnnotation = method.getAnnotation(Tool.class);
String toolName = method.getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
String fullToolName = className + "." + toolName;
long startTime = System.currentTimeMillis();
boolean success = false;
Object result = null;
// 记录入参(生产环境可根据数据安全要求决定是否记录)
Object[] args = joinPoint.getArgs();
log.info("[ToolMonitor] 工具调用开始: tool={}, args={}",
fullToolName, sanitizeArgs(args));
Timer.Sample timerSample = Timer.start(meterRegistry);
try {
result = joinPoint.proceed();
success = true;
return result;
} catch (Exception e) {
log.error("[ToolMonitor] 工具调用异常: tool={}, error={}",
fullToolName, e.getMessage(), e);
// 异常计数器
meterRegistry.counter("ai.tool.errors",
"tool", fullToolName,
"error_type", e.getClass().getSimpleName()
).increment();
throw e;
} finally {
long duration = System.currentTimeMillis() - startTime;
// 记录Prometheus指标
timerSample.stop(Timer.builder("ai.tool.calls.duration")
.tag("tool", fullToolName)
.tag("success", String.valueOf(success))
.register(meterRegistry));
// 调用计数
meterRegistry.counter("ai.tool.calls.total",
"tool", fullToolName,
"success", String.valueOf(success)
).increment();
// 慢调用检测
if (duration > SLOW_CALL_THRESHOLD_MS) {
log.warn("[ToolMonitor] 慢工具调用: tool={}, duration={}ms(阈值{}ms)",
fullToolName, duration, SLOW_CALL_THRESHOLD_MS);
}
// 记录完成日志
if (success) {
log.info("[ToolMonitor] 工具调用完成: tool={}, duration={}ms, resultType={}",
fullToolName, duration,
result != null ? result.getClass().getSimpleName() : "null");
}
}
}
/**
* 脱敏处理参数(避免密码、Token等敏感信息进入日志)
*/
private String sanitizeArgs(Object[] args) {
if (args == null) return "[]";
return Arrays.stream(args)
.map(arg -> {
if (arg == null) return "null";
String str = arg.toString();
// 脱敏:如果参数名包含token、key、password等,打码
if (str.length() > 20) {
return str.substring(0, 10) + "...(" + str.length() + "chars)";
}
return str;
})
.toList()
.toString();
}
}并行工具调用
GPT-4o支持在一次响应中并行调用多个工具,Spring AI 1.0自动处理这种情况。
package com.laozhang.tools.service;
import com.laozhang.tools.tool.weather.WeatherTools;
import com.laozhang.tools.tool.database.DatabaseQueryTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
/**
* 工具调用服务
* 演示并行工具调用、多工具协作
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ToolChatService {
private final ChatClient chatClient;
private final WeatherTools weatherTools;
private final DatabaseQueryTool databaseQueryTool;
private final AuthenticatedHttpTool httpTool;
/**
* 智能旅行规划助手
* 会同时调用:天气工具 + 酒店库存查询
* GPT-4o会在一次响应中并行发起多个工具调用
*/
public String travelPlanningAssistant(String query) {
return chatClient.prompt()
.system("""
你是智能旅行规划助手。
根据用户需求,综合天气信息和酒店库存,给出专业的旅行建议。
如果工具返回错误,请诚实告知用户无法获取该信息,不要编造数据。
""")
.user(query)
// 同时注册多个工具,LLM会根据需要选择并可能并行调用
.tools(weatherTools, databaseQueryTool)
.call()
.content();
}
/**
* 客服助手(带所有工具)
*/
public String customerServiceBot(String userId, String query) {
return chatClient.prompt()
.system("""
你是客服助手。可以查询天气、订单、商品库存、客户信息。
永远不要编造数据,如果工具调用失败,明确告知用户。
当前服务用户ID:""" + userId)
.user(query)
.tools(weatherTools, databaseQueryTool, httpTool)
.call()
.content();
}
/**
* 动态工具注册(根据用户权限决定开放哪些工具)
*/
public String callWithDynamicTools(String query, UserPermission permission) {
var prompt = chatClient.prompt()
.user(query);
// 所有用户都有天气工具
prompt = prompt.tools(weatherTools);
// 普通用户可以查商品
if (permission.canQueryProducts()) {
prompt = prompt.tools(databaseQueryTool);
}
// VIP用户可以查CRM(客户信息更敏感)
if (permission.isVip()) {
prompt = prompt.tools(httpTool);
}
return prompt.call().content();
}
public record UserPermission(boolean canQueryProducts, boolean isVip) {}
}工具设计原则
工具描述模板
好的工具描述应该包含以下4要素:
【何时使用】当用户询问...时使用此工具
【输入范围】支持...格式,限制...
【返回内容】返回:字段1(说明)、字段2(说明)...
【不支持的情况】不支持...,如需...请使用...工具工具库复用:跨项目共享工具
package com.laozhang.tools.library;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
/**
* 通用工具库接口
* 发布为独立Maven包,跨项目复用
*
* <dependency>
* <groupId>com.laozhang</groupId>
* <artifactId>ai-tools-common</artifactId>
* <version>1.0.0</version>
* </dependency>
*/
public interface CommonToolLibrary {
// 工具接口定义(各项目实现具体逻辑)
@Tool(description = "计算两个日期之间的天数差。用于询问'还有几天'、'过了多少天'等。")
int calculateDaysBetween(
@ToolParam(description = "开始日期,格式YYYY-MM-DD") String startDate,
@ToolParam(description = "结束日期,格式YYYY-MM-DD") String endDate
);
@Tool(description = "货币换算。支持:CNY(人民币)、USD(美元)、EUR(欧元)、JPY(日元)。" +
"汇率实时获取,精度到小数点后4位。")
CurrencyConversionResult convertCurrency(
@ToolParam(description = "金额,正数") double amount,
@ToolParam(description = "源货币代码,如CNY、USD") String fromCurrency,
@ToolParam(description = "目标货币代码,如USD、EUR") String toCurrency
);
record CurrencyConversionResult(
double originalAmount, String fromCurrency,
double convertedAmount, String toCurrency,
double exchangeRate, String rateDate,
String error
) {}
}性能数据
基于生产环境压测(1000次工具调用,并发50):
| 工具类型 | 平均耗时 | P99耗时 | 成功率 | 熔断触发率 |
|---|---|---|---|---|
| 天气查询(带缓存命中) | 12ms | 45ms | 99.8% | 0.1% |
| 天气查询(缓存未命中) | 1250ms | 3800ms | 97.2% | 2.1% |
| 数据库查询(简单) | 23ms | 89ms | 99.9% | 0% |
| 数据库查询(复杂) | 156ms | 780ms | 99.5% | 0.3% |
| CRM HTTP工具 | 380ms | 1200ms | 96.8% | 2.8% |
| 并行工具调用(2个) | 1350ms | 4200ms | 96.5% | 2.5% |
对比无错误处理的版本:
- 有熔断+降级:系统可用性 99.2%
- 无熔断+降级:系统可用性 89.1%(异常请求会让整个对话失败)
FAQ
Q1:工具调用次数有限制吗?
GPT-4o默认允许无限次连续工具调用,但受max_tokens限制。
建议:在System Prompt中限制工具调用次数,防止循环调用。
.system("在一次对话中,工具调用不超过5次,如需更多信息请询问用户。")Q2:LLM传入了错误格式的参数怎么办?
// 不要直接抛RuntimeException,要返回错误信息让LLM重试
@Tool(description = "...")
public Result myTool(@ToolParam(description = "...") String param) {
try {
validateParam(param);
return doWork(param);
} catch (IllegalArgumentException e) {
// 返回错误描述,LLM会根据错误重新调用或给用户说明
return new Result(null, "参数格式错误:" + e.getMessage() +
",请按照XXX格式提供。");
}
}Q3:如何防止LLM绕过权限调用工具?
工具权限不应该依赖Prompt,应该在Java代码中硬编码。
原则:不管LLM传什么参数,Java代码永远做权限校验。Q4:工具可以调用其他工具吗?
技术上可以,但不建议。工具链的调度权应该在LLM侧,
Java工具尽量保持原子性,不要形成工具间的调用依赖。Q5:如何测试工具的描述是否足够精确?
黄金测试法:给GPT-4o看你的工具列表和10个测试问题,
看它能否正确选择工具。如果有2个以上选错,说明描述不够精确。