工具系统深度实现:从 @Tool 注解到 MCP 协议,构建企业级 Agent 工具体系|AgentX 专栏④

本文是 AgentX 技术专栏第四篇。基于真实项目源码(ToolRegistry / WeatherTool / RiskRuleTool / McpToolServer / McpTool),深度拆解 AgentX 的企业级工具体系——从 @Tool 注解到 MCP 协议对外暴露,以及每个细节背后的坑。


本文速览:

  • 工具系统的核心职责:AI 与真实世界之间的桥梁
  • 两种工具接入方式:@Tool 注解 vs McpTool 接口,各有什么适用场景?
  • 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 承担两个角色:

  1. 对内:统一管理 McpTool 接口实现的工具
  2. 对外:把所有工具(@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 时,ToolRegistryContextRefreshedEvent 还没触发,@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 调用就会返回这个列表——你的 WeatherToolRiskRuleToolDocumentTool 全部对外可见,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_chinaevaluate_risk_transactionsearch_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 / 风控工具

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐