Agent工具库建设:打造企业级可复用AI工具生态
Agent工具库建设:打造企业级可复用AI工具生态
开篇故事:陈浩的"工具复制粘贴"噩梦
陈浩是某电商公司的Java技术负责人,3年经验,2025年带领团队做了4个AI Agent项目:
- 客服Agent:查订单、发邮件通知、查库存
- 数据分析Agent:查数据库、生成Excel报告、发邮件
- 运营Agent:查数据库、调内部API、发邮件
- 招聘Agent:发邮件、查数据库、调外部API
回顾这四个项目,陈浩发现一个令人沮丧的事实:"发邮件"工具被写了4遍,"查数据库"工具被写了3遍,"调HTTP接口"工具被写了4遍。
更糟的是,每次实现细节都有微小差别:
- 邮件工具:有的项目发HTML邮件,有的发纯文本;有的有重试,有的没有
- 数据库工具:有的做了SQL注入防护,有的没有
- HTTP工具:有的有超时设置,有的会因为第三方挂了把整个Agent卡死
"如果当初有个统一的工具库,这4个项目的工期能少2周。"陈浩说。
于是他开始了企业级工具库的建设。3个月后,团队新建一个Agent项目,工具接入时间从1周降到1天,工具相关Bug减少了73%。
这篇文章,我把陈浩的工具库设计完整还原。
一、工具库设计原则
1.1 三大核心原则
原则一:单一职责
每个工具只做一件事。"发邮件+记日志"应该是两个工具的组合,而不是一个工具。
原则二:幂等设计
工具被调用多次,效果应该相同。发邮件工具要有去重机制(相同idempotencyKey不重复发送),查询工具天然幂等。
原则三:无状态
工具内部不保存任何状态。每次调用都是独立的,这样才能安全地并发执行、水平扩展。
1.2 工具库整体架构
二、工具库项目结构
2.1 Maven多模块结构
enterprise-ai-tools/
├── pom.xml # 父POM
├── tools-core/ # 核心抽象层
│ └── src/main/java/
│ └── com/company/tools/
│ ├── annotation/ # 工具注解
│ ├── registry/ # 工具注册中心
│ ├── model/ # 通用模型
│ └── infrastructure/ # 重试、限流基础设施
├── tools-http/ # HTTP工具
├── tools-database/ # 数据库工具
├── tools-file/ # 文件工具
├── tools-email/ # 邮件工具
└── tools-starter/ # Spring Boot自动配置2.2 父POM
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
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>
<groupId>com.company</groupId>
<artifactId>enterprise-ai-tools</artifactId>
<version>1.2.0</version>
<packaging>pom</packaging>
<name>Enterprise AI Tools</name>
<modules>
<module>tools-core</module>
<module>tools-http</module>
<module>tools-database</module>
<module>tools-file</module>
<module>tools-email</module>
<module>tools-starter</module>
</modules>
<properties>
<java.version>21</java.version>
<spring-boot.version>3.3.5</spring-boot.version>
<spring-ai.version>1.0.0</spring-ai.version>
<resilience4j.version>2.2.0</resilience4j.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>三、工具注册中心核心实现
3.1 工具元数据定义
package com.company.tools.registry;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 工具元数据
* 描述一个工具的所有信息
*/
@Data
@Builder
public class ToolMetadata {
private String toolId; // 工具唯一ID,如:http.get
private String name; // 工具名称(展示用)
private String description; // 工具描述(AI理解用)
private String category; // 分类:HTTP/DATABASE/FILE/EMAIL
private String version; // 版本:1.2.0
private String author; // 作者或维护团队
private List<String> tags; // 标签,便于搜索
private boolean deprecated; // 是否废弃
private String deprecatedReason; // 废弃原因和替代工具
private Map<String, ParameterSchema> inputSchema; // 输入参数Schema
private Map<String, ParameterSchema> outputSchema; // 输出参数Schema
private ToolMetrics metrics; // 运行时指标
@Data
@Builder
public static class ParameterSchema {
private String type; // string/number/boolean/object/array
private String description;
private boolean required;
private Object defaultValue;
private List<String> allowedValues; // 枚举值
}
@Data
@Builder
public static class ToolMetrics {
private long totalCalls; // 总调用次数
private long successCalls; // 成功次数
private long failedCalls; // 失败次数
private double avgLatencyMs; // 平均延迟(毫秒)
private double p99LatencyMs; // P99延迟
}
}3.2 工具注册中心
package com.company.tools.registry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* 工具注册中心
* 统一管理企业内所有AI工具的注册、发现和版本管理
*/
@Slf4j
@Component
public class ToolRegistry {
// 工具存储:toolId -> 版本列表(按版本号排序)
private final Map<String, List<RegisteredTool>> toolStore = new ConcurrentHashMap<>();
/**
* 注册工具
*/
public void register(RegisteredTool tool) {
String toolId = tool.getMetadata().getToolId();
toolStore.computeIfAbsent(toolId, k -> new ArrayList<>())
.add(tool);
log.info("工具注册成功: {} v{}", toolId, tool.getMetadata().getVersion());
}
/**
* 获取工具(默认返回最新版本)
*/
public Optional<RegisteredTool> getTool(String toolId) {
List<RegisteredTool> versions = toolStore.get(toolId);
if (versions == null || versions.isEmpty()) {
return Optional.empty();
}
// 返回最新非废弃版本
return versions.stream()
.filter(t -> !t.getMetadata().isDeprecated())
.max((a, b) -> compareVersions(
a.getMetadata().getVersion(),
b.getMetadata().getVersion()));
}
/**
* 获取指定版本的工具
*/
public Optional<RegisteredTool> getTool(String toolId, String version) {
List<RegisteredTool> versions = toolStore.get(toolId);
if (versions == null) return Optional.empty();
return versions.stream()
.filter(t -> t.getMetadata().getVersion().equals(version))
.findFirst();
}
/**
* 按分类搜索工具
*/
public List<ToolMetadata> findByCategory(String category) {
return toolStore.values().stream()
.flatMap(List::stream)
.filter(t -> category.equals(t.getMetadata().getCategory()))
.filter(t -> !t.getMetadata().isDeprecated())
.map(RegisteredTool::getMetadata)
.toList();
}
/**
* 搜索工具(按名称和标签模糊搜索)
*/
public List<ToolMetadata> search(String keyword) {
String lower = keyword.toLowerCase();
return toolStore.values().stream()
.flatMap(List::stream)
.filter(t -> !t.getMetadata().isDeprecated())
.filter(t -> {
ToolMetadata meta = t.getMetadata();
return meta.getName().toLowerCase().contains(lower)
|| meta.getDescription().toLowerCase().contains(lower)
|| (meta.getTags() != null && meta.getTags().stream()
.anyMatch(tag -> tag.toLowerCase().contains(lower)));
})
.map(RegisteredTool::getMetadata)
.toList();
}
/**
* 获取所有已注册工具的摘要列表
*/
public List<ToolMetadata> listAll() {
return toolStore.values().stream()
.flatMap(List::stream)
.filter(t -> !t.getMetadata().isDeprecated())
.map(RegisteredTool::getMetadata)
.toList();
}
private int compareVersions(String v1, String v2) {
String[] parts1 = v1.split("\\.");
String[] parts2 = v2.split("\\.");
for (int i = 0; i < Math.min(parts1.length, parts2.length); i++) {
int cmp = Integer.compare(
Integer.parseInt(parts1[i]),
Integer.parseInt(parts2[i]));
if (cmp != 0) return cmp;
}
return Integer.compare(parts1.length, parts2.length);
}
}package com.company.tools.registry;
import lombok.Builder;
import lombok.Data;
import org.springframework.ai.tool.ToolCallback;
/**
* 已注册的工具,包含元数据和实际执行器
*/
@Data
@Builder
public class RegisteredTool {
private ToolMetadata metadata;
private ToolCallback callback; // Spring AI的工具回调接口
}四、核心工具实现
4.1 HTTP工具(生产级)
package com.company.tools.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.retry.annotation.Retry;
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.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
* HTTP工具
* 支持GET/POST/PUT/DELETE,内置超时、重试、错误处理
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HttpTools {
private final ObjectMapper objectMapper;
// 共享HttpClient,连接池复用
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
@Tool(description = """
发起HTTP GET请求并返回响应内容。
用于调用外部REST API、获取网页内容等场景。
自动处理超时(默认10秒)和常见错误。
返回响应状态码、响应体(JSON或文本)。
""")
@Retry(name = "httpGet", fallbackMethod = "httpGetFallback")
public HttpToolResult httpGet(
@ToolParam(description = "请求URL,必须是完整URL,例如:https://api.example.com/users/1") String url,
@ToolParam(description = "请求头,JSON格式,例如:{\"Authorization\": \"Bearer token\"},不需要传null") String headersJson,
@ToolParam(description = "超时秒数,默认10秒") int timeoutSeconds) {
log.info("HTTP GET: {}", url);
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(timeoutSeconds <= 0 ? 10 : timeoutSeconds));
// 添加请求头
if (headersJson != null && !headersJson.isBlank()) {
JsonNode headers = objectMapper.readTree(headersJson);
headers.fields().forEachRemaining(entry ->
requestBuilder.header(entry.getKey(), entry.getValue().asText()));
}
HttpResponse<String> response = httpClient.send(
requestBuilder.build(),
HttpResponse.BodyHandlers.ofString());
return HttpToolResult.builder()
.statusCode(response.statusCode())
.body(response.body())
.success(response.statusCode() >= 200 && response.statusCode() < 300)
.build();
} catch (Exception e) {
log.error("HTTP GET失败: {}", url, e);
return HttpToolResult.builder()
.statusCode(-1)
.success(false)
.errorMessage("请求失败: " + e.getMessage())
.build();
}
}
@Tool(description = """
发起HTTP POST请求,发送JSON数据。
用于调用外部API的写操作,例如创建资源、提交表单。
自动设置Content-Type: application/json。
返回响应状态码和响应体。
""")
public HttpToolResult httpPost(
@ToolParam(description = "请求URL") String url,
@ToolParam(description = "请求体,JSON字符串") String bodyJson,
@ToolParam(description = "额外请求头,JSON格式,可以为null") String headersJson,
@ToolParam(description = "超时秒数,默认15秒") int timeoutSeconds) {
log.info("HTTP POST: {}", url);
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.POST(HttpRequest.BodyPublishers.ofString(bodyJson != null ? bodyJson : "{}"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(timeoutSeconds <= 0 ? 15 : timeoutSeconds));
if (headersJson != null && !headersJson.isBlank()) {
JsonNode headers = objectMapper.readTree(headersJson);
headers.fields().forEachRemaining(entry ->
requestBuilder.header(entry.getKey(), entry.getValue().asText()));
}
HttpResponse<String> response = httpClient.send(
requestBuilder.build(),
HttpResponse.BodyHandlers.ofString());
return HttpToolResult.builder()
.statusCode(response.statusCode())
.body(response.body())
.success(response.statusCode() >= 200 && response.statusCode() < 300)
.build();
} catch (Exception e) {
log.error("HTTP POST失败: {}", url, e);
return HttpToolResult.builder()
.statusCode(-1)
.success(false)
.errorMessage("请求失败: " + e.getMessage())
.build();
}
}
// Resilience4j的fallback方法
public HttpToolResult httpGetFallback(String url, String headersJson,
int timeoutSeconds, Exception e) {
log.error("HTTP GET重试耗尽,执行降级: {}", url, e);
return HttpToolResult.builder()
.statusCode(-1)
.success(false)
.errorMessage("请求多次重试后失败,服务可能不可用: " + e.getMessage())
.build();
}
}package com.company.tools.http;
import lombok.Builder;
import lombok.Data;
/**
* HTTP工具返回结果
*/
@Data
@Builder
public class HttpToolResult {
private int statusCode;
private String body;
private boolean success;
private String errorMessage;
public String getSummary() {
if (success) {
return String.format("HTTP请求成功,状态码:%d,响应长度:%d字符",
statusCode, body != null ? body.length() : 0);
} else {
return String.format("HTTP请求失败,状态码:%d,错误:%s",
statusCode, errorMessage);
}
}
}4.2 数据库工具(含SQL注入防护)
package com.company.tools.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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 数据库查询工具(只读)
* 只允许SELECT查询,防止AI执行危险的写操作
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DatabaseQueryTool {
private final JdbcTemplate jdbcTemplate;
// 只允许SELECT语句
private static final Pattern ALLOWED_PATTERN =
Pattern.compile("^\\s*SELECT\\s+.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
// 禁止危险关键字(双重保险)
private static final Pattern DANGEROUS_PATTERN =
Pattern.compile("(DROP|DELETE|UPDATE|INSERT|TRUNCATE|ALTER|CREATE|EXEC|EXECUTE|xp_|sp_)",
Pattern.CASE_INSENSITIVE);
@Tool(description = """
执行SQL SELECT查询并返回结果。
只支持SELECT语句,不允许增删改操作。
结果以JSON格式返回,最多返回100行。
当需要从数据库查询业务数据时使用此工具。
示例:SELECT * FROM orders WHERE status='pending' LIMIT 10
""")
public DatabaseQueryResult executeQuery(
@ToolParam(description = "SQL SELECT语句,只支持SELECT,不支持增删改") String sql,
@ToolParam(description = "最大返回行数,默认20,最大100") int maxRows) {
log.info("数据库查询工具调用: {}", sql);
// 安全校验
if (sql == null || sql.isBlank()) {
return DatabaseQueryResult.error("SQL语句不能为空");
}
if (!ALLOWED_PATTERN.matcher(sql).matches()) {
log.warn("危险SQL被拒绝: {}", sql);
return DatabaseQueryResult.error("只允许SELECT查询语句");
}
if (DANGEROUS_PATTERN.matcher(sql).find()) {
log.warn("包含危险关键字的SQL被拒绝: {}", sql);
return DatabaseQueryResult.error("SQL包含不允许的操作关键字");
}
// 强制加LIMIT
int limit = (maxRows <= 0 || maxRows > 100) ? 20 : maxRows;
String safeSql = appendLimitIfAbsent(sql, limit);
try {
List<Map<String, Object>> results = jdbcTemplate.queryForList(safeSql);
return DatabaseQueryResult.builder()
.success(true)
.rows(results)
.rowCount(results.size())
.sql(safeSql)
.build();
} catch (Exception e) {
log.error("SQL执行失败: {}", safeSql, e);
return DatabaseQueryResult.error("查询执行失败: " + e.getMessage());
}
}
/**
* 如果SQL没有LIMIT子句,自动添加
*/
private String appendLimitIfAbsent(String sql, int limit) {
String trimmed = sql.trim().toUpperCase();
if (!trimmed.contains("LIMIT")) {
return sql.trim() + " LIMIT " + limit;
}
return sql;
}
}package com.company.tools.database;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class DatabaseQueryResult {
private boolean success;
private List<Map<String, Object>> rows;
private int rowCount;
private String sql;
private String errorMessage;
public static DatabaseQueryResult error(String message) {
return DatabaseQueryResult.builder()
.success(false)
.errorMessage(message)
.build();
}
}4.3 邮件工具(含幂等去重)
package com.company.tools.email;
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.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import jakarta.mail.internet.MimeMessage;
import java.time.Duration;
/**
* 邮件发送工具
* 支持HTML邮件,内置幂等性保证(防止重复发送)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailTool {
private final JavaMailSender mailSender;
private final StringRedisTemplate redisTemplate;
private static final String IDEMPOTENCY_KEY_PREFIX = "email:sent:";
private static final Duration IDEMPOTENCY_TTL = Duration.ofHours(24);
@Tool(description = """
发送HTML或纯文本邮件。
支持幂等性:相同idempotencyKey的邮件不会重复发送(24小时内有效)。
用于通知用户、发送报告、告警等场景。
收件人可以是多个地址,用逗号分隔。
""")
public EmailResult sendEmail(
@ToolParam(description = "收件人邮箱地址,多个用逗号分隔") String toAddresses,
@ToolParam(description = "邮件主题") String subject,
@ToolParam(description = "邮件正文,支持HTML格式") String body,
@ToolParam(description = "是否是HTML邮件,true或false") boolean isHtml,
@ToolParam(description = "幂等性Key,防止重复发送,建议使用业务唯一标识,例如:order-confirm-ORDER001,可以为null") String idempotencyKey) {
log.info("邮件发送工具调用: to={}, subject={}", toAddresses, subject);
// 幂等性检查
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
String redisKey = IDEMPOTENCY_KEY_PREFIX + idempotencyKey;
Boolean alreadySent = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(alreadySent)) {
log.info("邮件已发送过,跳过重复发送: {}", idempotencyKey);
return EmailResult.builder()
.success(true)
.message("邮件已在之前发送过(幂等性保证)")
.skipped(true)
.build();
}
}
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(toAddresses.split(","));
helper.setSubject(subject);
helper.setText(body, isHtml);
mailSender.send(message);
// 记录已发送(幂等性)
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
String redisKey = IDEMPOTENCY_KEY_PREFIX + idempotencyKey;
redisTemplate.opsForValue().set(redisKey, "1", IDEMPOTENCY_TTL);
}
log.info("邮件发送成功: to={}, subject={}", toAddresses, subject);
return EmailResult.builder()
.success(true)
.message("邮件发送成功")
.build();
} catch (Exception e) {
log.error("邮件发送失败", e);
return EmailResult.builder()
.success(false)
.message("邮件发送失败: " + e.getMessage())
.build();
}
}
}package com.company.tools.email;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class EmailResult {
private boolean success;
private String message;
private boolean skipped; // true表示因幂等性被跳过
}4.4 文件工具(带安全限制)
package com.company.tools.file;
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 java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 文件操作工具
* 限制在指定安全目录内操作,防止路径遍历攻击
*/
@Slf4j
@Component
public class FileTools {
// 允许操作的根目录(从配置读取)
@Value("${tools.file.safe-root:/tmp/ai-workspace}")
private String safeRoot;
@Tool(description = """
读取文件内容。
只能读取AI工作区目录下的文件,不能读取系统文件。
返回文件的文本内容。
""")
public FileResult readFile(
@ToolParam(description = "文件路径(相对于AI工作区),例如:reports/2024Q4.txt") String relativePath) {
log.info("文件读取工具调用: {}", relativePath);
try {
Path safePath = resolveSafePath(relativePath);
if (!Files.exists(safePath)) {
return FileResult.error("文件不存在: " + relativePath);
}
if (Files.isDirectory(safePath)) {
return FileResult.error("指定路径是目录,不是文件");
}
// 文件大小限制:最大1MB
long fileSize = Files.size(safePath);
if (fileSize > 1024 * 1024) {
return FileResult.error("文件过大(>" + fileSize / 1024 + "KB),请使用分页读取");
}
String content = Files.readString(safePath);
return FileResult.builder()
.success(true)
.content(content)
.filePath(relativePath)
.fileSize(fileSize)
.build();
} catch (SecurityException e) {
log.warn("文件路径安全检查失败: {}", relativePath);
return FileResult.error("文件路径不允许访问: " + relativePath);
} catch (IOException e) {
log.error("文件读取失败: {}", relativePath, e);
return FileResult.error("文件读取失败: " + e.getMessage());
}
}
@Tool(description = """
写入文件内容。
只能写入AI工作区目录下的文件。
如果文件已存在,会覆盖原有内容。
用于生成报告、保存分析结果等场景。
""")
public FileResult writeFile(
@ToolParam(description = "文件路径(相对于AI工作区),例如:output/report.txt") String relativePath,
@ToolParam(description = "要写入的文件内容") String content) {
log.info("文件写入工具调用: {}", relativePath);
try {
Path safePath = resolveSafePath(relativePath);
// 创建父目录
Files.createDirectories(safePath.getParent());
Files.writeString(safePath, content);
log.info("文件写入成功: {}, 大小: {}字节", relativePath, content.length());
return FileResult.builder()
.success(true)
.filePath(relativePath)
.fileSize((long) content.length())
.message("文件写入成功")
.build();
} catch (SecurityException e) {
log.warn("文件写入路径安全检查失败: {}", relativePath);
return FileResult.error("文件路径不允许访问");
} catch (IOException e) {
log.error("文件写入失败: {}", relativePath, e);
return FileResult.error("文件写入失败: " + e.getMessage());
}
}
@Tool(description = """
列出AI工作区目录下的文件和子目录。
返回文件名、大小、修改时间等信息。
""")
public FileListResult listFiles(
@ToolParam(description = "目录路径(相对于AI工作区),根目录传空字符串") String relativePath) {
// 实现省略,逻辑类似readFile
return FileListResult.builder().success(true).build();
}
/**
* 安全路径解析:确保路径不会跳出safeRoot
*/
private Path resolveSafePath(String relativePath) {
Path root = Paths.get(safeRoot).toAbsolutePath().normalize();
Path resolved = root.resolve(relativePath).normalize();
// 路径遍历攻击检测
if (!resolved.startsWith(root)) {
throw new SecurityException("路径超出允许范围: " + relativePath);
}
return resolved;
}
}五、工具版本管理与兼容性
5.1 版本策略
package com.company.tools.registry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 工具版本管理器
* 处理工具升级时的向后兼容问题
*/
@Slf4j
@Component
public class ToolVersionManager {
/**
* 废弃旧版本工具
* 旧版本工具依然可用,但会警告并引导升级
*/
public void deprecate(ToolRegistry registry, String toolId, String version, String reason) {
registry.getTool(toolId, version).ifPresent(tool -> {
ToolMetadata meta = tool.getMetadata();
meta.setDeprecated(true);
meta.setDeprecatedReason(reason);
log.info("工具已废弃: {} v{}, 原因: {}", toolId, version, reason);
});
}
/**
* 版本兼容性检查
* 调用者请求旧版本时,尝试返回兼容的新版本
*/
public String resolveCompatibleVersion(String requestedVersion, String latestVersion) {
// 语义化版本:主版本相同则向后兼容
String[] requested = requestedVersion.split("\\.");
String[] latest = latestVersion.split("\\.");
if (requested[0].equals(latest[0])) {
// 主版本相同,使用最新版
log.debug("版本兼容升级: {} -> {}", requestedVersion, latestVersion);
return latestVersion;
}
// 主版本不同,不能自动升级
log.warn("工具版本不兼容: 请求 v{}, 最新 v{}", requestedVersion, latestVersion);
return requestedVersion;
}
}六、工具组合:复合工具实现
package com.company.tools.composite;
import com.company.tools.database.DatabaseQueryTool;
import com.company.tools.email.EmailTool;
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.stereotype.Component;
import java.util.Map;
/**
* 复合工具:查询数据库并通过邮件发送报告
* 组合了数据库工具和邮件工具
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QueryAndReportTool {
private final DatabaseQueryTool dbTool;
private final EmailTool emailTool;
@Tool(description = """
查询数据库并将结果通过邮件发送给指定收件人。
适用于定时报告、数据导出通知等场景。
例如:查询今日订单并发送给销售总监。
""")
public String queryAndSendReport(
@ToolParam(description = "SQL SELECT语句") String sql,
@ToolParam(description = "收件人邮箱") String toEmail,
@ToolParam(description = "邮件主题") String subject,
@ToolParam(description = "幂等性Key,防止重复发送") String idempotencyKey) {
log.info("复合工具调用 - QueryAndReport: to={}", toEmail);
// Step 1: 查询数据库
var queryResult = dbTool.executeQuery(sql, 50);
if (!queryResult.isSuccess()) {
return "查询失败: " + queryResult.getErrorMessage();
}
// Step 2: 将查询结果格式化为HTML表格
String htmlContent = buildHtmlTable(queryResult.getRows(), subject);
// Step 3: 发送邮件
var emailResult = emailTool.sendEmail(
toEmail,
subject,
htmlContent,
true,
idempotencyKey
);
if (emailResult.isSuccess()) {
return String.format("报告发送成功,共%d行数据已发送至%s",
queryResult.getRowCount(), toEmail);
} else {
return "邮件发送失败: " + emailResult.getMessage();
}
}
private String buildHtmlTable(java.util.List<Map<String, Object>> rows, String title) {
if (rows == null || rows.isEmpty()) {
return "<p>查询结果为空</p>";
}
StringBuilder sb = new StringBuilder();
sb.append("<h2>").append(title).append("</h2>");
sb.append("<table border='1' style='border-collapse:collapse;width:100%'>");
// 表头
sb.append("<tr style='background:#4CAF50;color:white'>");
rows.get(0).keySet().forEach(key ->
sb.append("<th style='padding:8px'>").append(key).append("</th>")
);
sb.append("</tr>");
// 数据行
for (int i = 0; i < rows.size(); i++) {
String bgColor = i % 2 == 0 ? "#fff" : "#f5f5f5";
sb.append("<tr style='background:").append(bgColor).append("'>");
rows.get(i).values().forEach(val ->
sb.append("<td style='padding:8px'>").append(val != null ? val : "").append("</td>")
);
sb.append("</tr>");
}
sb.append("</table>");
sb.append("<p style='color:#666;font-size:12px'>生成时间: ")
.append(java.time.LocalDateTime.now())
.append("</p>");
return sb.toString();
}
}七、工具监控与统计
7.1 监控切面
package com.company.tools.monitor;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 工具调用监控切面
* 自动统计所有工具的调用次数、成功率、耗时
*/
@Slf4j
@Aspect
@Component
public class ToolMonitoringAspect {
private final MeterRegistry meterRegistry;
public ToolMonitoringAspect(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object monitorToolCall(ProceedingJoinPoint joinPoint) throws Throwable {
String toolName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
long startTime = System.currentTimeMillis();
boolean success = false;
try {
Object result = joinPoint.proceed();
success = true;
return result;
} catch (Exception e) {
// 记录异常
Counter.builder("tool.calls.exception")
.tag("tool", toolName)
.tag("class", className)
.tag("exception", e.getClass().getSimpleName())
.register(meterRegistry)
.increment();
throw e;
} finally {
long duration = System.currentTimeMillis() - startTime;
// 记录调用总数
Counter.builder("tool.calls.total")
.tag("tool", toolName)
.tag("class", className)
.tag("success", String.valueOf(success))
.register(meterRegistry)
.increment();
// 记录耗时
Timer.builder("tool.calls.duration")
.tag("tool", toolName)
.tag("class", className)
.register(meterRegistry)
.record(duration, TimeUnit.MILLISECONDS);
log.debug("工具调用完成: {}.{}, 耗时: {}ms, 成功: {}",
className, toolName, duration, success);
}
}
}7.2 工具使用率报表
package com.company.tools.monitor;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.search.Search;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 工具使用统计API
* 展示各工具的调用次数、成功率、平均耗时
*/
@RestController
@RequestMapping("/api/tools/stats")
@RequiredArgsConstructor
public class ToolStatsController {
private final MeterRegistry meterRegistry;
@GetMapping("/summary")
public List<Map<String, Object>> getToolStats() {
return meterRegistry.find("tool.calls.total")
.counters()
.stream()
.collect(Collectors.groupingBy(
c -> c.getId().getTag("tool"),
Collectors.summarizingDouble(c -> c.count())
))
.entrySet()
.stream()
.map(entry -> Map.<String, Object>of(
"toolName", entry.getKey(),
"totalCalls", entry.getValue().getSum()
))
.sorted((a, b) -> Double.compare(
(double) b.get("totalCalls"),
(double) a.get("totalCalls")))
.collect(Collectors.toList());
}
}八、Spring Boot自动配置(Starter)
package com.company.tools.autoconfigure;
import com.company.tools.database.DatabaseQueryTool;
import com.company.tools.email.EmailTool;
import com.company.tools.file.FileTools;
import com.company.tools.http.HttpTools;
import com.company.tools.monitor.ToolMonitoringAspect;
import com.company.tools.registry.ToolRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
/**
* 企业工具库自动配置
* 引入tools-starter依赖后,根据配置自动注册工具
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "enterprise.tools", name = "enabled", havingValue = "true",
matchIfMissing = true)
@Import({
ToolRegistry.class,
ToolMonitoringAspect.class
})
public class EnterpriseToolsAutoConfiguration {
@Bean
@ConditionalOnProperty(prefix = "enterprise.tools.http", name = "enabled",
havingValue = "true", matchIfMissing = true)
public HttpTools httpTools() {
return new HttpTools(null); // ObjectMapper会自动注入
}
@Bean
@ConditionalOnProperty(prefix = "enterprise.tools.email", name = "enabled",
havingValue = "true", matchIfMissing = false)
public EmailTool emailTool() {
return new EmailTool(null, null); // 依赖会自动注入
}
@Bean
@ConditionalOnProperty(prefix = "enterprise.tools.file", name = "enabled",
havingValue = "true", matchIfMissing = true)
public FileTools fileTools() {
return new FileTools();
}
}业务项目的application.yml配置:
enterprise:
tools:
enabled: true
http:
enabled: true
email:
enabled: true
file:
enabled: true
safe-root: /data/ai-workspace
database:
enabled: true
monitor:
enabled: true九、工具测试规范
package com.company.tools.http;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
class HttpToolsTest {
private MockWebServer mockWebServer;
private HttpTools httpTools;
@BeforeEach
void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
httpTools = new HttpTools(new ObjectMapper());
}
@AfterEach
void tearDown() throws IOException {
mockWebServer.shutdown();
}
@Test
void httpGet_success_returnsBody() {
// Given
mockWebServer.enqueue(new MockResponse()
.setBody("{\"id\": 1, \"name\": \"test\"}")
.setResponseCode(200)
.addHeader("Content-Type", "application/json"));
String url = mockWebServer.url("/api/test").toString();
// When
HttpToolResult result = httpTools.httpGet(url, null, 10);
// Then
assertThat(result.isSuccess()).isTrue();
assertThat(result.getStatusCode()).isEqualTo(200);
assertThat(result.getBody()).contains("\"name\": \"test\"");
}
@Test
void httpGet_timeout_returnsError() {
// Given: 服务器延迟3秒响应,但超时设置1秒
mockWebServer.enqueue(new MockResponse()
.setBody("too late")
.setBodyDelay(3, java.util.concurrent.TimeUnit.SECONDS));
String url = mockWebServer.url("/slow").toString();
// When
HttpToolResult result = httpTools.httpGet(url, null, 1);
// Then
assertThat(result.isSuccess()).isFalse();
assertThat(result.getErrorMessage()).isNotBlank();
}
@Test
void httpGet_serverError_returnsFailure() {
// Given
mockWebServer.enqueue(new MockResponse().setResponseCode(500));
String url = mockWebServer.url("/error").toString();
// When
HttpToolResult result = httpTools.httpGet(url, null, 10);
// Then
assertThat(result.isSuccess()).isFalse();
assertThat(result.getStatusCode()).isEqualTo(500);
}
}十、性能数据
工具库上线3个月后的统计数据(来自实际生产环境):
| 指标 | 上线前 | 上线后 | 变化 |
|---|---|---|---|
| 新Agent工具接入时间 | 3-5天 | 2-4小时 | 减少90% |
| 工具相关Bug数/月 | 22个 | 6个 | 减少73% |
| 代码重复率(工具层) | 65% | 8% | 减少88% |
| 工具平均响应时间 | 180ms | 95ms(缓存优化) | 减少47% |
| 工具调用成功率 | 91% | 98.5%(重试机制) | 提升7.5% |
FAQ
Q1:工具库和MCP Server有什么关系?
工具库是工具的实现层,MCP Server是工具的暴露层。工具库里的@Tool方法可以直接在Spring AI Agent中使用,也可以通过MCP Server协议暴露给外部AI应用。两者不互斥,通常配合使用。
Q2:工具的description该写多详细?
至少要说明:①什么时候用这个工具;②参数的格式要求;③可能的返回结果。太简短("发邮件")AI不知道何时调用;太冗长(>500字)AI理解成本高,建议100-200字。
Q3:工具库是否需要独立部署?
不需要。工具库以Maven依赖的形式引入各Agent项目,在同一个JVM内运行。只有当工具需要跨项目共享,或者工具本身需要独立扩缩容时,才考虑部署成独立服务(MCP Server)。
Q4:复合工具有什么风险?
复合工具的失败模式更复杂:Step1成功但Step2失败怎么办?需要明确处理部分失败的情况。建议:复合工具要做好日志,让AI知道每个步骤的执行结果,便于判断是否需要重试。
Q5:如何做工具的灰度升级?
利用版本管理器,先注册新版本(不废弃旧版),观察新版本的成功率和延迟。确认无问题后,将旧版本标记为deprecated,一段时间后(1-2个月)再从注册表移除。
