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。原因有三点:
- 模型已经决定调用工具了,你在工具里读键盘补参数,模型不知道用户输入了什么,后续推理会错乱。
streamEvents是响应式流,在工具里阻塞读键盘会卡住整个 reactor 线程,界面假死。- 多线程下
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
