AgentScope 学习系列 - 04:Tool 工具调用实战

1. Tool的概念

Tool 是 agent 与外部世界交互的方式,用来执行业务操作、调用 API、读写数据。每个 tool 通过 JSON Schema 暴露给 LLM,agent 通过统一接口调用。

AgentScope 里和 tool 相关的构件有三个:Tool、Toolkit、Tool Group。

Tool 是任意实现 AgentTool 接口(通常通过继承 ToolBase)的对象,也可以是在普通类方法上标注 @Tool 注解的对象。Java 端把后者称为 reflective function tool ,由 Toolkit#registerTool(Object) 自动反射注册。

Toolkit 是容器,负责注册 tool、MCP 客户端与 skill,向模型暴露它们的 JSON schema,并把每次工具调用分发到对应的 tool 对象。

Tool Group 是一组带名称的 tool / MCP / skill 集合,可以作为整体激活或停用。Agent 在运行时通过内置 meta tool 切换 group,让上下文保持聚焦。

1.1 两种定义方式怎么选

AgentScope Java 支持两种定义 tool 的方式,选型建议:

场景 推荐方式
有真实执行逻辑、参数简单 @Tool 注解式
需要自定义 inputSchema、权限策略、外部执行 继承 ToolBase
只声明不执行(人工审批、外部触发) 继承 ToolBase + externalTool(true)
需要注入 AgentState 继承 ToolBase + isStateInjected(true)

1.2 外部执行 Tool 是什么

isExternalTool() 为 true 时,工具不执行 callAsync,只发出一个外部执行事件,调用交给外部系统处理,比如前端弹窗、人工审批界面或另一个服务。外部系统处理完把结果回传,agent 继续推理。

典型场景是需要人工确认的高风险操作(转账、删除数据),或需要 UI 交互的操作(弹出文件选择框)。HumanApprovalTool 就属于这类工具,它只声明 schema 和权限,不执行逻辑。

2. AgentTool / ToolBase 接口

ToolBase 是 AgentTool 的抽象实现。先看向 agent 与运行时描述 tool 的属性:

方法 类型 说明
getName() String 暴露给 agent 的 tool 名称
getDescription() String 面向 agent 的功能描述
getParameters() Map<String, Object> 定义参数的 JSON Schema
isConcurrencySafe() boolean 是否可并发调用
isReadOnly() boolean 是否只读、不产生副作用
isExternalTool() boolean 为 true 时执行委派给外部(见 定义外部执行 Tool)
isStateInjected() boolean 为 true 时框架注入 AgentState 参数
isMcp() boolean 是否来自 MCP 服务
getMcpName() String isMcp() 为 true 时所属 MCP 服务名

接入执行流程与权限系统的方法:

方法 必需 说明
checkPermissions(toolInput, context) 是 执行前的运行时权限检查;返回 Mono<PermissionDecision>
matchRule(ruleContent, toolInput) 可选 权限系统中的自定义规则匹配;返回 boolean
generateSuggestions(toolInput) 可选 基于本次工具调用生成建议规则;返回 List<PermissionRule>
callAsync(param) 可选 tool 的执行逻辑;返回 Mono<ToolResultBlock>。外部执行 tool 不需要实现。

2.1 checkPermissions 到底做什么

模型决定调用某个工具时,框架先调用 checkPermissions 做运行时检查,询问"允不允许?",返回两种决定:

  • PermissionDecision.allow("理由"):放行,框架继续执行 callAsync,外部工具则发出外部执行事件。
  • PermissionDecision.deny("理由"):拦截,模型看到拒绝理由后调整行为,通常换个做法,比如去问用户。

checkPermissions 必须实现,即使永远放行也要返回一个 allow。第一次写 ToolBase 子类漏掉它会编译报错。

一个典型的带参数校验的写法:

java 复制代码
@Override
public Mono<PermissionDecision> checkPermissions(
        Map<String, Object> toolInput, PermissionContextState context) {

    Object location = toolInput.get("location");
    if (location == null || location.toString().isBlank()) {
        return Mono.just(PermissionDecision.deny("缺少城市名称,请先向用户询问城市。"));
    }
    return Mono.just(PermissionDecision.allow("参数完整,可以执行。"));
}

这样模型不给 location 就调工具时会被拦下,转而问用户"请问你想查哪个城市?"。用这种方式逼模型向用户补问参数,比在 description 里写提示词更可靠。

2.2 ToolResultBlock 的返回形态

callAsync 只能返回 Mono<ToolResultBlock>。ToolResultBlock.text(...) 是最简单的一种,返回纯文本。你还可以返回结构化数据、错误状态等:

java 复制代码
// 文本
ToolResultBlock.text("北京晴,18-28摄氏度")

// 结构化 JSON(模型能更好理解)
ToolResultBlock.text("{\"city\":\"北京\",\"weather\":\"晴\",\"temp\":\"18-28\"}")

// 错误返回(模型知道调用失败,可以重试或换策略)
ToolResultBlock.error("外部天气 API 超时,请稍后重试")

真实工具调用外部 API 时要捕获异常并返回错误块,别让异常直接抛出。否则模型收到空结果或异常,无法做出合理反应。

3. 自定义Java Tool(注解式)

还是回到很多老师讲课时最爱用的查询天气的tool吧~

我们先使用@Tool注解来创建。

java 复制代码
package com.maple.agent.demo.demo_04;

import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;

public class DemoTool {
    @Tool(
            name = "query_weather_tool",
            description = "当用户查询天气预报信息时,调用改工具",
            readOnly = true,
            concurrencySafe = true)
    public String queryWeatherTool(
            @ToolParam(name = "location", description = "查询地址,没有需要让用户补充") String location
            ) {
        return switch (location) {
            case "上海" -> "上海多云转晴,20-25摄氏度";
            case "北京" -> "北京晴,18-28摄氏度";
            case "广州" -> "广州雷阵雨,24-30摄氏度";
            default -> "暂无该城市天气信息";
        };
    }
}

注解式的好处是框架根据方法签名自动生成 JSON Schema,不用手写 Map.of(...)。@ToolParam(name="location", description="...") 会变成 schema 里的 properties.location,方法返回值类型决定返回结构。参数名必须显式写 name,因为 Java 默认不保留方法参数名。

4. 调用Tool

接下来看看调用Tool的代码,如果需要调用多个工具,直接toolkit.registerTool多个即可。

java 复制代码
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DemoTool());

ReActAgent agent = ReActAgent.builder()
  .name("笑小枫的天气助手")
  .model(model)
  .toolkit(toolkit)
  .sysPrompt("你是一个天气小助手")
  .build();

Msg reply = agent.call(new UserMessage("上海今天天气怎么样?")).block();
System.out.println(reply.getTextContent());

已经返回了我们工具中定义的内容:

是否真的调用我们的工具了呢?我们一起来看一下

java 复制代码
    private static void stream (ReActAgent agent) {
        StringBuilder accumulated = new StringBuilder();

        agent.streamEvents(new UserMessage("user", "北京今天天气怎么样?"))
                .doOnNext(event -> {
                    if (event instanceof AgentStartEvent start) {
                        System.out.println("[start replyId=" + start.getReplyId() + "]");
                    } else if (event instanceof TextBlockDeltaEvent delta) {
                        accumulated.append(delta.getDelta());
                    } else if (event instanceof ToolCallStartEvent tc) {
                        System.out.println("[tool] " + tc.getToolCallName());
                    } else if (event instanceof ToolResultEndEvent end) {
                        System.out.println("[tool result state=" + end.getState() + "]");
                    } else if (event instanceof AgentEndEvent end) {
                        System.out.println("\n[end] full text:\n" + accumulated);
                    }
                })
                .blockLast();
    }

可以看到调用了query_weather_tool,状态是SUCCESS

4.1 让对话支持多轮:用户补充参数的闭环

上面 agent.call(...) 和 streamEvents(...) 都只发一条消息就结束了。如果模型需要用户补充参数(比如"今天天气怎么样?"没给城市),模型问"哪个城市?"之后,程序就退出了,用户根本没机会回答。

要支持多轮对话,需要在 main 里包一个读键盘的循环:

java 复制代码
Scanner scanner = new Scanner(System.in);
while (true) {
    System.out.print("\n你: ");
    String input = scanner.nextLine();
    if (input == null || input.isBlank()) continue;
    if ("exit".equalsIgnoreCase(input.trim())) break;

    StringBuilder accumulated = new StringBuilder();
    agent.streamEvents(new UserMessage("user", input))
            .doOnNext(event -> {
                if (event instanceof TextBlockDeltaEvent delta) {
                    System.out.print(delta.getDelta());
                    accumulated.append(delta.getDelta());
                } else if (event instanceof ToolCallStartEvent tc) {
                    System.out.println("\n[tool] " + tc.getToolCallName());
                } else if (event instanceof ToolResultEndEvent end) {
                    System.out.println("[tool result state=" + end.getState() + "]");
                }
            })
            .blockLast();
}

ReActAgent 自带 memory,会自动维护对话历史,你不需要手动保存。这样整个流程就闭环了:

复制代码
用户输入"今天天气怎么样"
    → 模型发现缺 location
        → checkPermissions 返回 deny(或模型主动提问)
            → 模型输出"请问你想查哪个城市?"
                → 循环读键盘,用户输入"北京"
                    → agent 收到新消息,继续推理
                        → 模型调 query_weather_tool(location="北京")
                            → 返回天气

用户输入必须在 main 的 while 循环里读,不能塞进 tool 的 callAsync 或 checkPermissions。原因有三点:

  1. 模型已经决定调用工具了,你在工具里读键盘补参数,模型不知道用户输入了什么,后续推理会错乱。
  2. streamEvents 是响应式流,在工具里阻塞读键盘会卡住整个 reactor 线程,界面假死。
  3. 多线程下 System.in 可能被争用,直接抛异常。

5. 自定义 Tool(继承 ToolBase)

需要自定义权限策略、外部执行或更复杂的 schema 时,继承 ToolBase

这里简单的看个例子吧

java 复制代码
public class SearchWeatherTool extends ToolBase {

    public SearchWeatherTool() {
        super(ToolBase.builder()
                .name("search_weather_tool")
                .description("用户查询天气,没有查询到时,调用改工具去外部查询。")
                .inputSchema(Map.of(
                        "type", "object",
                        "properties",  Map.of("location", Map.of("type", "string", "description", "查询地址")),
                        "required", List.of("location")))
                .readOnly(true)
                .concurrencySafe(true)
        );
    }

    @Override
    public Mono<PermissionDecision> checkPermissions(
            Map<String, Object> toolInput, PermissionContextState context) {
        return Mono.just(PermissionDecision.allow("Weather is read-only."));
    }

    @Override
    public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
        // 从 param 中获取模型传进来的参数
        Map<String, Object> input = param.getInput();
        String location = (String) input.get("location");

        // 这里写实际的查天气逻辑,比如调用外部 API
        String result = "【" + location + "】天气:晴,25°C";

        return Mono.just(ToolResultBlock.text(result));
    }
}

同时把这个工具也注册进去:toolkit.registerTool(new SearchWeatherTool());。从下图可以看到,先调用 query_weather_tool,没拿到结果,又调用 search_weather_tool,返回里面定义的数据。

5.1 inputSchema 的 JSON 对照

inputSchema(Map.of(...)) 看起来乱,其实对应的就是标准 JSON Schema。上面那段代码等价于:

json 复制代码
{
  "type": "object",
  "properties": {
    "location": {
      "type": "string",
      "description": "查询地址"
    }
  },
  "required": ["location"]
}

你写的每一层 Map.of 都在告诉模型:参数是一个对象,里面有个叫 location 的字符串,而且它是必填的。模型靠这些信息生成正确的工具调用参数。如果觉得 Map.of 嵌套太丑,可以把 schema 抽成一个 private static 方法单独构造。

5.2 完整可运行示例

把本文所有片段拼起来,一个能直接跑的 main:

java 复制代码
public static void main(String[] args) {
    ConfigurableApplicationContext ctx = SpringApplication.run(MapleAgentApplication.class, args);
    OpenAiModelConfig props = ctx.getBean(OpenAiModelConfig.class);

    OpenAIChatModel model = OpenAIChatModel.builder()
            .baseUrl(props.getBaseUrl())
            .apiKey(props.getApiKey())
            .modelName(props.getModelName())
            .stream(true)
            .build();

    Toolkit toolkit = new Toolkit();
    toolkit.registerTool(new DemoTool());
    toolkit.registerTool(new SearchWeatherTool());

    ReActAgent agent = ReActAgent.builder()
            .name("笑小枫的天气助手")
            .model(model)
            .toolkit(toolkit)
            .sysPrompt("你是一个天气小助手")
            .build();

    Scanner scanner = new Scanner(System.in);
    while (true) {
        System.out.print("\n你: ");
        String input = scanner.nextLine();
        if (input == null || input.isBlank()) continue;
        if ("exit".equalsIgnoreCase(input.trim())) break;

        StringBuilder accumulated = new StringBuilder();
        agent.streamEvents(new UserMessage("user", input))
                .doOnNext(event -> {
                    if (event instanceof TextBlockDeltaEvent delta) {
                        System.out.print(delta.getDelta());
                        accumulated.append(delta.getDelta());
                    } else if (event instanceof ToolCallStartEvent tc) {
                        System.out.println("\n[tool] " + tc.getToolCallName());
                    } else if (event instanceof ToolResultEndEvent end) {
                        System.out.println("[tool result state=" + end.getState() + "]");
                    }
                })
                .blockLast();
    }
    System.out.println("再见!");
}

6. 常见问题

Q:checkPermissions 可以不实现吗?

不可以,它是必需方法。即使永远放行,也要返回 PermissionDecision.allow(...)。

Q:模型不调工具怎么办?

description 写给模型看,要写清楚"什么时候该调这个工具"。写"当用户查询天气时调用"比写"天气工具"有效得多。

Q:模型给的工具参数是错的怎么办?

在 checkPermissions 里校验参数,不合格就 deny 并给出理由。模型看到拒绝理由会调整参数重试,或转为询问用户。

Q:callAsync 里能读用户输入吗?

不能。用户输入应该在 main 的循环里读,作为新的 UserMessage 喂回 agent。在工具里读键盘会阻塞响应式流、破坏模型决策链。

Q:多个工具会按顺序调用吗?

由模型决定。模型可能一次调一个,也可能并行调多个(如果 concurrencySafe 为 true)。所以工具内部要线程安全。

Q:ToolResultEndEvent 的 state 除了 SUCCESS 还有哪些?

常见的有 SUCCESS、ERROR、DENIED 等。具体枚举值见 ToolResultState。工具返回错误块或权限被拒时,state 会相应变化。

7. 本章小结

到这里,AgentScope 的 Tool 工具调用就讲完了,回顾一下核心内容:

  • @Tool 注解式适合有真实执行逻辑、参数简单的场景;继承 ToolBase 适合需要自定义 schema、权限策略、外部执行的场景。
  • checkPermissions 是必需的运行时检查,callAsync 承载执行逻辑,externalTool(true) 把执行委托给外部系统。
  • 整个对话闭环是:用户输入 → 模型推理 → 工具调用 → 结果回传 → 继续对话。
  • 在 checkPermissions 里做参数校验,缺参数就 deny,模型会主动问用户,比单纯靠 description 提示词可靠。

理解了 Tool 的注册、权限、执行三条链路,就能把任意 API、数据库、业务系统接进 agent。

本系列源码https://gitee.com/hack-feng/maple-agent-demo

相关推荐
为你学会写情书1 小时前
别再让你的 Agent 当“黑盒”了:LangSmith 如何用可观测性、评估和洞察闭环驯服生产级智能体
agent
故作春风2 小时前
Agent开发-实现一个对话循环
agent
墨心@2 小时前
Bash 工具讲解
agent·harness
沛东的认知和实践2 小时前
不检索比检索更准:模型已经会的题,上RAG后F1 掉 0.15~0.38,token 多花 3~34 倍——彻底搞懂LLMWiki第三篇补一
agent
熊猫钓鱼>_>2 小时前
告别手工贴发票:增值税发票识别+核验,搭一条报销自动化流水线
人工智能·ai·职场和发展·自动化·大模型·llm·agent
RuoyiOffice2 小时前
SpringBoot3+Vue3 企业 AI 工作流:Tinyflow 画布、LLM 节点与在线试跑怎么落地
spring boot·大模型·vue3·agent·ai工作流·ruoyi office·tinyflow
武子康2 小时前
PUBACK 已到,灯还没亮:Agent 设备指令何时算完成?
人工智能·后端·agent
浮生望2 小时前
用 PostgreSQL + pgvector 保存 Agent 长期记忆:从三表模型到语义检索
agent
枫叶丹43 小时前
AGENTS.md 越写越长之后:如何管理 AI 的上下文
人工智能·chatgpt·开源·agent·codex