《Agentx专栏》04-工具系统:从@Tool注解到MCP协议,构建企业级Agent工具体系
工具系统深度实现:从 @Tool 注解到 MCP 协议,构建企业级 Agent 工具体系|AgentX 专栏④
本文是 AgentX 技术专栏第四篇。基于真实项目源码(ToolRegistry / WeatherTool / RiskRuleTool / McpToolServer / McpTool),深度拆解 AgentX 的企业级工具体系——从 @Tool 注解到 MCP 协议对外暴露,以及每个细节背后的坑。
本文速览:
- 工具系统的核心职责:AI 与真实世界之间的桥梁
- 两种工具接入方式:
@Tool注解 vsMcpTool接口,各有什么适用场景? ToolRegistry完整源码解析:自动扫描、去重保护、类型安全反射调用ToolInvoker的智能类型转换:为什么 LLM 传来的参数类型经常"对不上号"?- 生产级工具实战:天气工具的双 API 兜底、风控工具的业务逻辑
McpToolServer:把 AgentX 的所有工具作为标准 MCP 服务暴露给外部 AI- 工具描述怎么写?这直接影响 LLM 的决策质量
- 三个实战大坑:ForkJoinPool、工具命名重复、@Tool 参数类型不匹配
一、工具系统的角色
工具(Tool)是 Agent 系统能力的上限。
一个没有工具的 Agent,只是一个稍强一点的 ChatBot。有了工具,Agent 才能:
- 查实时天气,而不是编造一个"晴天"
- 查企业风控规则,而不是凭空输出"建议通过"
- 检索内部知识库,而不是用通用知识胡乱回答
- 调用 REST API,让 AI 真正地操控外部系统
AgentX 的工具系统围绕这个目标设计,支持三种工具来源:
| 来源 | 方式 | 适用场景 |
|---|---|---|
| 内置工具 | @Tool 注解 + @Component |
业务工具,需要 Spring 注入依赖 |
| MCP 接口工具 | 实现 McpTool 接口 |
复杂工具,有独立 Schema 和校验逻辑 |
| 外部 MCP 工具 | langchain4j-mcp 客户端 |
复用生态工具(GitHub、Puppeteer 等) |
这三种来源最终都汇聚到 ToolRegistry,对上层(AgentWorkflow)提供统一入口。
二、两种工具接入方式对比
方式一:@Tool 注解(推荐)
最简单,也是最常用的方式。在 Spring Bean 方法上加 @Tool,框架启动时自动发现:
@Component
public class WeatherTool {
@Tool("查询中国指定城市的实时天气。参数必须是标准的城市名,如'北京'或'西安'。")
public String getCurrentWeather(@P("要查询的城市名称") String city) {
return getCurrentWeatherInternal(city);
}
@Tool("查询中国指定城市未来几天的天气预报。")
public String getWeatherForecast(@P("要查询的城市名称") String city) {
return getWeatherForecastInternal(city);
}
}
@Tool 的值是工具描述,@P 是参数描述——这两个描述会直接发给 LLM,LLM 靠它们决定"要不要调用这个工具"、“该传什么参数”。
方式二:McpTool 接口
适合工具本身有复杂 Schema、需要独立参数校验、或不适合用 Spring 注解管理的场景:
public interface McpTool {
String name(); // 工具唯一标识
String description(); // 功能描述(给 LLM 看)
Map<String, Object> inputSchema(); // JSON Schema 格式的参数定义
Object execute(Map<String, Object> parameters) throws Exception;
// 以下方法有默认实现
default String version() { return "1.0.0"; }
default String category() { return "general"; }
default boolean isEnabled(){ return true; }
default String validate(Map<String, Object> parameters) { return null; }
}
实现一个 McpTool 的完整示例:
@Component
public class SystemInsightTool implements McpTool {
@Override
public String name() { return "system_insight"; }
@Override
public String description() {
return "查询 AgentX 系统运行状态,包括工具数量、JVM 内存、服务版本等";
}
@Override
public Map<String, Object> inputSchema() {
return Map.of(
"type", "object",
"properties", Map.of(
"category", Map.of(
"type", "string",
"description", "查询类别:tools / memory / version",
"enum", List.of("tools", "memory", "version")
)
),
"required", List.of("category")
);
}
@Override
public Object execute(Map<String, Object> parameters) {
String category = (String) parameters.get("category");
return switch (category) {
case "tools" -> Map.of("toolCount", toolRegistry.getToolsCount());
case "memory" -> Map.of("heapUsed", Runtime.getRuntime().totalMemory()
- Runtime.getRuntime().freeMemory());
case "version" -> Map.of("version", "AgentX v1.0.0");
default -> Map.of("error", "未知查询类别");
};
}
}
两种方式的选择标准:
@Tool 注解 |
McpTool 接口 |
|
|---|---|---|
| 代码量 | 极少,1 个注解搞定 | 中等,需实现 4 个方法 |
| Spring 依赖注入 | ✅ 原生支持 | ✅ 也支持 |
| 参数类型 | Java 基本类型/String(框架自动转换) | Map<String, Object>(需手动取值) |
| 参数校验 | LangChain4j 自动生成 Schema | 手动定义 JSON Schema,校验可自定义 |
| 适用场景 | 绝大多数业务工具 | 有复杂参数结构或需要 Schema 定制的工具 |
三、ToolRegistry 完整解析
ToolRegistry 是整个工具系统的核心,我把它的完整实现拆成 5 个部分讲。
3.1 启动时自动扫描
@EventListener(ContextRefreshedEvent.class)
public void autoScanTools() {
log.info("[ToolRegistry] 开始执行企业级 AI 技能自动扫描...");
String[] beanNames = applicationContext.getBeanNamesForType(Object.class);
for (String beanName : beanNames) {
Object bean = applicationContext.getBean(beanName);
// 关键:脱掉 CGLIB/JDK 代理外壳,找到真正的目标类
// 如果 Bean 被 @Transactional 等包了代理,代理类上找不到 @Tool
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
Method[] methods = ReflectionUtils.getAllDeclaredMethods(targetClass);
for (Method method : methods) {
if (method.isAnnotationPresent(Tool.class)) {
ToolSpecification spec = ToolSpecifications.toolSpecificationFrom(method);
String name = spec.name();
// 工具命名冲突检测:全局唯一性保证
if (toolSpecs.containsKey(name)) {
throw new IllegalStateException(
"发现重复的 AI 工具定义: " + name + ". 请确保全局 @Tool 名称唯一。");
}
toolSpecs.put(name, spec);
toolInvokers.put(name, new ToolInvoker(bean, method));
toolBeans.add(bean);
}
}
}
log.info("[ToolRegistry] 注册完成,总计激活 {} 个业务能力节点", toolSpecs.size());
}
三个存储结构的分工:
toolSpecs → Map<String, ToolSpecification> 工具元数据(名称/描述/参数Schema)
用途:生成给 LLM 的工具定义列表
toolInvokers → Map<String, ToolInvoker> 工具执行句柄(bean引用 + Method对象)
用途:LLM 决定调用时,通过反射执行
toolBeans → Set<Object> 工具 Bean 集合(去重)
用途:注入给 AiServices(告诉 LangChain4j 有哪些工具)
为什么用 ConcurrentHashMap?
工具注册在启动时发生(单线程),但工具列表会在多个请求中并发读取。ConcurrentHashMap 保证读操作无锁(对比 Hashtable 的全锁),在高并发下性能明显更好。
3.2 带可观测性的执行入口
// 供 LangGraphOrchestrator 使用(AI 传来的是 JSON 字符串)
public Object executeTool(String toolName, String argumentsJson) {
return Observation.createNotStarted("agent.tool.execute", observationRegistry)
.contextualName("tool:" + toolName) // 在 Jaeger 上显示为 "tool:getCurrentWeather"
.lowCardinalityKeyValue("tool.name", toolName)
.observe(() -> {
Map<String, Object> argumentsMap = new HashMap<>();
if (StringUtils.hasText(argumentsJson) && !"null".equalsIgnoreCase(argumentsJson)) {
argumentsMap = objectMapper.readValue(argumentsJson, new TypeReference<>() {});
}
return executeTool(toolName, argumentsMap);
});
}
// 供 ToolController / 上面方法的重载版本(传结构化 Map)
public Object executeTool(String toolName, Map<String, Object> arguments) {
ToolInvoker invoker = toolInvokers.get(toolName);
if (invoker == null) {
throw new NoSuchElementException("指定的 AI 工具不存在: " + toolName);
}
return invoker.invoke(arguments != null ? arguments : Collections.emptyMap());
}
每次工具调用都自动生成一个 Jaeger Span,在链路追踪里直接看到每个工具的耗时:
[AgentX HTTP 请求] 14,280ms
└─ [ai.inference] 13,900ms
├─ [tool:getCurrentWeather] 312ms
└─ [tool:evaluateTransactionRisk] 8ms
3.3 ToolInvoker:智能类型转换反射执行器
这是整个工具系统里最容易被忽视、也最容易出问题的部分:
// Java Record 风格(不可变数据载体)
private record ToolInvoker(Object bean, Method method) {
public Object invoke(Map<String, Object> arguments) throws Exception {
Parameter[] parameters = method.getParameters();
Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
Parameter p = parameters[i];
Object raw = arguments.get(p.getName());
if (raw != null) {
Class<?> type = p.getType();
// 智能类型强转:优先 Spring Conversion,兜底 JSON 反序列化
if (!type.isAssignableFrom(raw.getClass())) {
if (CONVERSION_SERVICE.canConvert(raw.getClass(), type)) {
args[i] = CONVERSION_SERVICE.convert(raw, type); // 快路径
} else {
args[i] = Json.fromJson(Json.toJson(raw), type); // 兜底路径
}
} else {
args[i] = raw; // 类型匹配,直接用
}
}
}
ReflectionUtils.makeAccessible(method); // 突破 private 限制
return method.invoke(bean, args);
}
}
为什么需要这套类型转换?
LLM 输出的 JSON 在 Java 里被解析成这样:
| LLM 传的值 | JSON 解析后的 Java 类型 | 工具方法期望的类型 | 不转换的后果 |
|---|---|---|---|
42 |
Integer |
long / Long |
ClassCastException |
1000.0 |
Double |
BigDecimal |
ClassCastException |
"true" |
String |
boolean |
空指针或类型不匹配 |
{"key":"val"} |
LinkedHashMap |
自定义 POJO | ClassCastException |
Spring 的 ConversionService 内置了常见基本类型互转。遇到它搞不定的(比如 LinkedHashMap → POJO),用 Jackson 做一次 JSON 序列化再反序列化,基本覆盖所有场景。
3.4 动态注册与注销
运行时不重启,热挂载新工具:
// 动态注册:通过 API 挂载新工具 Bean
public List<String> registerTools(ToolRegistrationRequest request) {
Object bean = applicationContext.getBean(request.toolName());
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
List<String> added = new ArrayList<>();
for (Method method : ReflectionUtils.getAllDeclaredMethods(targetClass)) {
if (method.isAnnotationPresent(Tool.class)) {
ToolSpecification spec = ToolSpecifications.toolSpecificationFrom(method);
// 可合并外部传入的描述(覆盖默认描述)
ToolSpecification finalSpec = mergeSpecs(spec, request.specification());
toolSpecs.put(finalSpec.name(), finalSpec);
toolInvokers.put(finalSpec.name(), new ToolInvoker(bean, method));
toolBeans.add(bean);
added.add(finalSpec.name());
}
}
return added;
}
// 动态注销:下线特定工具
public boolean unregisterTool(String name) {
if (toolSpecs.remove(name) != null) {
ToolInvoker invoker = toolInvokers.remove(name);
if (invoker != null) {
// 检查该 Bean 还有没有其他工具方法,没有了才把 Bean 从 toolBeans 移除
boolean hasMore = toolInvokers.values().stream()
.anyMatch(v -> v.bean().equals(invoker.bean()));
if (!hasMore) toolBeans.remove(invoker.bean());
}
return true;
}
return false;
}
注意:动态注册的工具不会自动注入到已缓存的 AiServices 实例中。AgentWorkflow 里的 DCL 缓存需要配合失效机制(调用 cachedAgent = null)才能让新工具生效。
四、生产级工具实战:天气工具
WeatherTool 是 AgentX 里最完整的一个内置工具,包含了生产场景中几乎所有的工程考量。
4.1 双 API 兜底策略
private String getCurrentWeatherInternal(String city) {
// 首选:高德地图 API(需配置 amap.api.key)
if (amapApiKey != null && !amapApiKey.isEmpty()) {
String adcode = cityCodeMap.get(city); // 城市名 → 行政区划代码
if (adcode != null) {
try {
AmapWeatherResponse resp = fetchAmapWeather(adcode, "base");
if (resp != null && "1".equals(resp.status()) && !resp.lives().isEmpty()) {
Live live = resp.lives().getFirst();
// 校验关键字段:气温为空说明数据不完整,触发回退
if (live.temperature() == null || live.temperature().isBlank()) {
throw new RuntimeException("高德 API 数据不完整");
}
// ⚠️ 故意不用 °C / % 等特殊符号
// 原因:qwen2.5:3b 对特殊符号的数字提取能力很弱
// 全中文格式对 3B 小模型更友好
return String.format("""
%s%s天气:%s。
气温:%s摄氏度。
风向:%s。风力:%s级。
湿度百分之%s。数据时间:%s。
""", live.province(), live.city(), live.weather(),
live.temperature(), live.winddirection(),
live.windpower(), live.humidity(), live.reporttime());
}
} catch (Exception e) {
log.warn("[WeatherTool] 高德 API 失败,回退到 Open-Meteo: {}", e.getMessage());
}
}
}
// 兜底:Open-Meteo 免费 API(无需 API Key)
return fetchOpenMeteoWeather(city);
}
为什么避免特殊符号? 这是一个小模型特有的坑。qwen2.5:3b 在处理"气温:25°C"时,经常把 °C 提取成乱码或直接忽略,导致后续整合上下文时出现奇怪的输出。改成"气温:25摄氏度",小模型的正确率明显提升。
4.2 adcode 映射表(城市名 → 行政区划码)
@PostConstruct
public void init() {
// 从 classpath:adcode.csv 加载城市名 → adcode 映射
ClassPathResource resource = new ClassPathResource("adcode.csv");
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
String line;
boolean isFirstLine = true;
while ((line = reader.readLine()) != null) {
if (isFirstLine) { isFirstLine = false; continue; }
String[] parts = line.split(",");
if (parts.length >= 2) {
String cityName = parts[0].trim();
String adcode = parts[1].trim();
cityCodeMap.put(cityName, adcode);
// 兼容:AI 有时传"西安",有时传"西安市"
if (cityName.endsWith("市") || cityName.endsWith("县") || cityName.endsWith("区")) {
cityCodeMap.put(cityName.substring(0, cityName.length() - 1), adcode);
}
}
}
}
}
LLM 传过来的城市名格式不固定——“北京”、“北京市”、“上海”、"上海市"都可能出现。把带后缀的变体也存一份,兼容两种写法。
4.3 HTTP 客户端配置
// JDK HttpClient + 连接超时 / 读超时
// 防止外部 API 卡死整个 agent 线程
private final RestClient restClient = RestClient.builder()
.requestFactory(new JdkClientHttpRequestFactory(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build()))
.build();
为什么用 JDK HttpClient 而不是默认的 SimpleClientHttpRequestFactory?因为 JDK HttpClient 内置连接池,避免每次请求创建新连接;connectTimeout 加上 ReadTimeout(默认无限等待!),防止高德 API 偶发卡顿把 agent 虚拟线程挂死。
4.4 为什么不在 @Tool 方法里创建 Observation?
WeatherTool 的源码注释里有这样一段说明:
/**
* 💡 设计说明:不在此处创建 Observation。
* 原因:LangChain4j 在 ForkJoinPool 线程上调用 @Tool 方法,
* 该线程没有父 Span 上下文,
* 在这里 createNotStarted() 会生成"孤立 Span",
* 污染 Jaeger(每次工具调用都是一条独立 trace)。
* 改用:ToolRegistry.executeTool() 统一创建 Observation,
* 通过捕获 HTTP Span 引用正确挂载到父 trace 下。
*/
@Tool("查询中国指定城市的实时天气...")
public String getCurrentWeather(@P("...") String city) { ... }
这就是为什么 Observation 要在 ToolRegistry.executeTool() 里创建,而不是在每个 @Tool 方法里——统一位置,统一管控,不污染链路。
五、企业业务工具:风控规则工具
RiskRuleTool 展示了如何把现有的业务规则引擎包装成 AI 工具:
@Component
public class RiskRuleTool {
@Tool("Evaluate risk level for a financial transaction")
public Map<String, Object> evaluateTransactionRisk(
@P("Transaction amount") BigDecimal amount,
@P("Customer risk score (0-100)") int customerRiskScore,
@P("Transaction type") String transactionType) {
double baseRisk = calculateBaseRisk(amount, customerRiskScore, transactionType);
// 风险分层:>80 HIGH, >50 MEDIUM, else LOW
String riskLevel = baseRisk >= 80 ? "HIGH" : baseRisk >= 50 ? "MEDIUM" : "LOW";
boolean manualReview = baseRisk >= 70 ||
(amount.compareTo(new BigDecimal("10000")) > 0 && customerRiskScore > 60);
return Map.of(
"riskScore", Math.round(baseRisk * 10) / 10.0,
"riskLevel", riskLevel,
"manualReviewRequired", manualReview,
"recommendation", switch (riskLevel) {
case "HIGH" -> "Block transaction and require senior manager approval";
case "MEDIUM" -> "Proceed with enhanced monitoring";
default -> "Proceed with standard monitoring";
}
);
}
// 加权风险计算:金额风险40% + 客户风险40% + 交易类型风险20%
private double calculateBaseRisk(BigDecimal amount, int customerRiskScore, String type) {
double amountRisk = amount.doubleValue() / 10000.0 * 10;
double customerRisk = (100 - customerRiskScore) / 10.0;
double transactionRisk = switch (type.toLowerCase()) {
case "wire_transfer" -> 8.0;
case "international" -> 7.0;
case "cash_deposit" -> 6.0;
case "online_payment" -> 5.0;
default -> 3.0;
};
return (amountRisk * 0.4) + (customerRisk * 0.4) + (transactionRisk * 0.2);
}
}
这个工具的价值:传统风控规则分散在业务代码里,金融分析师用 Excel 套公式。把规则包装成 @Tool,银行客服 AI 就可以直接说"帮我评估这笔 5 万元的电汇交易",AI 自动调用工具得出评估结论,而不是凭"感觉"输出一个毫无依据的建议。
六、McpToolServer:把 AgentX 工具暴露给外部 AI
McpToolServer 承担两个角色:
- 对内:统一管理 McpTool 接口实现的工具
- 对外:把所有工具(@Tool 注解 + McpTool 接口)以标准 MCP 协议暴露出去,让外部 AI(Claude、其他 MCP 客户端)直接调用
6.1 两阶段初始化
@PostConstruct // 第一阶段:Bean 初始化时
public void init() {
// 注册所有 McpTool 接口实现(Spring 自动收集注入)
if (mcpTools != null) mcpTools.forEach(this::registerTool);
// ⚠️ 注意:此时不要读 toolRegistry
// 因为 ToolRegistry 用的是 ContextRefreshedEvent,
// 在 @PostConstruct 时它的 @Tool 扫描可能还没跑完
}
@EventListener(ContextRefreshedEvent.class) // 第二阶段:容器刷新完成后
public void onContextRefreshed() {
if (!serverProperties.enabled()) return;
// compareAndSet 确保只打印一次启动日志(容器可能多次刷新)
if (serverRunning.compareAndSet(false, true)) {
log.info("AgentX MCP 工具服务器启动成功");
log.info("已挂载能力数量: {} (McpTool: {} + @Tool: {})",
getActiveToolCount(), registeredMcpTools.size(), toolRegistry.getToolsCount());
}
}
为什么要两阶段?
@PostConstruct时,ToolRegistry的ContextRefreshedEvent还没触发,@Tool工具数是 0- 第二阶段等容器完全刷新,两者的扫描都已完成,才能拿到正确的工具数量
6.2 统一工具列表(合并两个来源)
public List<Map<String, Object>> listTools() {
List<Map<String, Object>> tools = new ArrayList<>();
// 1. McpTool 接口实现的工具
for (McpTool tool : registeredMcpTools.values()) {
tools.add(Map.of(
"name", tool.name(),
"description", tool.description(),
"inputSchema", tool.inputSchema()
));
}
// 2. @Tool 注解的工具(通过 ToolRegistry 桥接)
// 需要把 LangChain4j 的 JsonObjectSchema 转为通用 Map
for (ToolSpecification spec : toolRegistry.getAllToolSpecifications()) {
Map<String, Object> inputSchema = convertToMap(spec.parameters());
tools.add(Map.of(
"name", spec.name(),
"description", spec.description() != null ? spec.description() : "",
"inputSchema", inputSchema
));
}
return tools;
}
外部 AI(比如 Claude)连接到 AgentX MCP Server 后,list_tools 调用就会返回这个列表——你的 WeatherTool、RiskRuleTool、DocumentTool 全部对外可见,Claude 可以直接调用。
6.3 统一执行入口(两级回退)
public McpResponse executeTool(String toolName, Map<String, Object> parameters) {
if (!serverRunning.get()) return McpResponse.error("MCP 服务器未启动");
// 第一级:McpTool 接口实现
McpTool tool = registeredMcpTools.get(toolName);
if (tool != null) {
if (!validateParameters(tool, parameters)) {
return McpResponse.error("参数校验失败,请检查 InputSchema");
}
Object result = tool.execute(parameters);
return McpResponse.success(result, toolName);
}
// 第二级:回退到 ToolRegistry(@Tool 注解工具)
ToolSpecification spec = toolRegistry.getToolSpecification(toolName);
if (spec == null) return McpResponse.error("未找到工具: " + toolName);
String argumentsJson = objectMapper.writeValueAsString(
parameters != null ? parameters : Map.of());
Object result = toolRegistry.executeTool(toolName, argumentsJson);
return McpResponse.success(result, toolName);
}
七、5 分钟开发一个自定义工具
照着 CustomToolTemplate 的套路,新增一个工具只需三步:
第一步:创建 Spring 组件,加 @Tool 注解
@Component
public class OrderQueryTool {
@Autowired
private OrderRepository orderRepository; // 正常注入 Spring Bean
@Tool("根据订单号查询订单详情,包括状态、金额、收货地址。")
public String queryOrder(@P("订单号,格式如:ORD-2024-001234") String orderId) {
Order order = orderRepository.findById(orderId)
.orElse(null);
if (order == null) {
return String.format("未找到订单 %s,请确认订单号是否正确。", orderId);
}
return String.format(
"订单%s:状态%s,金额%s元,收货地址:%s,预计送达:%s。",
order.getId(), order.getStatus(), order.getAmount(),
order.getAddress(), order.getEstimatedDelivery()
);
}
}
第二步:启动项目
ToolRegistry 的 ContextRefreshedEvent 监听器自动发现并注册 OrderQueryTool,日志:
[ToolRegistry] Agent Capability 注册成功: [queryOrder] from OrderQueryTool
第三步:测试
curl -X POST http://localhost:8080/api/v1/tools/queryOrder/execute \
-H "Content-Type: application/json" \
-d '{"orderId": "ORD-2024-001234"}'
就这三步,AI 就可以调用你的订单查询工具了。
八、工具描述怎么写:影响 LLM 决策质量的关键
@Tool 的描述文本直接发给 LLM,决定 LLM “要不要调用"和"传什么参数”。写好描述,工具调用准确率可以从 60% 提升到 95%+。
反面示例
// ❌ 太简单,LLM 不知道何时该调
@Tool("获取天气")
public String getWeather(String city) { ... }
// ❌ 没说参数格式,LLM 可能传 "Shanghai" 而不是 "上海"
@Tool("查询城市天气信息")
public String getWeather(@P("城市") String city) { ... }
正面示例
// ✅ 说清楚:1)能做什么 2)参数格式 3)限制条件
@Tool("查询中国指定城市的实时天气。参数必须是标准的中文城市名,如'北京'或'西安'," +
"不要加'市'字后缀。如需查询海外城市,请使用 getInternationalWeather 工具。")
public String getCurrentWeather(
@P("要查询的城市名称,例如:上海、广州、成都") String city) { ... }
描述写作的四个原则:
| 原则 | 说明 | 示例 |
|---|---|---|
| 功能明确 | 说清楚这个工具能做什么 | “查询中国城市实时天气” |
| 参数规范 | 说清楚参数的格式/范围 | “中文城市名,不加’市’后缀” |
| 边界说明 | 说清楚工具的限制 | “仅支持国内城市” |
| 区分相似工具 | 有多个相似工具时,说清楚差异 | “实时天气用此工具,预报用 getWeatherForecast” |
九、三个实战大坑
坑一:ForkJoinPool 与 @Tool 方法里的 Observation
LangChain4j 在调用 @Tool 方法时,使用的是 ForkJoinPool 线程(平台线程),不是我们自己的虚拟线程池。这个线程没有 HTTP 请求的父 Span 上下文。
如果在 @Tool 方法里直接 Observation.createNotStarted(...) 创建监控,得到的是孤立 Span——在 Jaeger 里显示为一条独立的 Trace,而不是挂在父请求下面的子 Span,污染链路图。
正确做法:所有 Observation 在 ToolRegistry.executeTool() 里统一创建(已有 HTTP Span 上下文),@Tool 方法里只写业务逻辑。
坑二:工具命名重复
项目增长到一定规模,多人开发时极容易出现重名工具。AgentX 的做法是快速失败:
if (toolSpecs.containsKey(name)) {
throw new IllegalStateException(
"发现重复的 AI 工具定义: " + name + ". 请确保全局 @Tool 名称唯一。");
}
启动直接报错,比运行时"后者覆盖前者"导致的灵异 Bug 好处理得多。
命名规范建议: 动词_名词_范围,如 query_weather_china、evaluate_risk_transaction、search_knowledge_internal。
坑三:AiServices 注入工具时忘记解包 AOP 代理
// ❌ 直接把 Spring Bean 传给 AiServices
// 如果 Bean 被 CGLIB 代理(@Transactional 等),AiServices 扫描代理类
// 代理类上找不到 @Tool 注解,工具注册数量为 0,AI 永远不会调用工具
AiServices.builder(GeneralService.class)
.tools(riskRuleTool) // Bean 可能是 CGLIB 代理
.build();
// ✅ 先解包,再传给 AiServices
Object target = AopProxyUtils.getSingletonTarget(riskRuleTool);
AiServices.builder(GeneralService.class)
.tools(target != null ? target : riskRuleTool) // 原始对象
.build();
这个坑最隐蔽:代码不报错,工具注册看似成功(toolBeans 里有数据),但 AiServices 内部扫描不到 @Tool,AI 调用时直接忽略工具,用自身知识胡乱回答。
十、工具系统扩展路线图
AgentX 工具系统的演进方向:
当前(V1) 计划中(V2)
────────────────────────────────────────────
静态注册(启动扫描) → 支持运行时热加载 JAR 包工具
工具数量上限 8 → 按 Agent 类型动态配置上限
无权限控制 → 工具级别 RBAC 权限(@ToolAuth 注解)
单机工具 → 工具集群(ToolRegistry 分布式同步)
手动写 adcode.csv → 自动化城市码维护 + 模糊匹配
十一、总结
AgentX 工具系统的核心是三个层次:
@Tool 注解(开发者写)
↓ ContextRefreshedEvent 触发
ToolRegistry(框架管)
├── 自动扫描 + AopProxy 解包
├── ConcurrentHashMap 线程安全存储
├── ToolInvoker 智能类型转换 + 反射调用
└── Observation 统一埋点(挂在父 HTTP Span 下)
↓
McpToolServer(对外暴露)
├── McpTool 接口工具
├── @Tool 注解工具(通过 ToolRegistry 桥接)
└── 统一 MCP 协议响应(listTools / callTool)
每一个看起来"多余"的细节——重名检测、两阶段初始化、AopProxy.getSingletonTarget()、ForkJoinPool 线程上不创建 Observation——背后都有一个真实踩过的坑。
工具系统是 Agent 能力的上限,把这一层做扎实,比什么 Prompt 技巧都有用。
💬 关注公众号「SuniaCoder-AI全栈架构实战」,回复「工具」获取 AgentX 完整工具开发模板(含 @Tool 最佳实践、ToolInvoker 类型转换全覆盖场景、McpTool 完整示例)。
关于作者 & 联系方式
汪旭 / Sunia — Java 全栈开发者,AI 应用工程化实践者
专注企业级 AI 落地,擅长极限资源优化,有 RAG、Agent、知识图谱方向的完整实战经验。
| 平台 | 地址 / 说明 |
|---|---|
| CSDN | SuniaCoder-AI|13.5 万+ 阅读,RAG/Agent 系列持续更新 |
| 微信公众号 | 搜索【SuniaCoder-AI全栈架构实战】|回复「工具」获取工具开发完整模板 |
| 掘金 | SuniaCoder-AI |
| 知乎 | SuniaCoder-AI |
| 合作咨询 | 企业私有化大模型部署与定制开发,欢迎私信洽谈 |
如果内容对你有帮助,点赞 + 收藏 + 关注是最大的支持。
上一篇 → AgentX 架构设计全解析:一个请求是如何从 HTTP 走到 LLM 再回来的
Tags: Java Agent工具系统 / LangChain4j Tool / ToolRegistry / McpTool / MCP协议 / @Tool注解 / 企业级AI工具 / Spring Boot AI / AopProxy / 风控工具
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)