让 Agent 长出双手:一个注解搞定工具调用,AgentScope-Java @Tool 实战
上篇文章我们跑通了第一个 HarnessAgent,但它还只会"动嘴"------你问什么它答什么,碰不到你的数据,改不了你的文件。这一篇让它真正"动手":用
@Tool注解把普通 Java 方法变成 Agent 的工具,并完整跟踪一次工具调用在 ReAct 循环里的生命周期。本文基于 agentscope-harness 2.0.1。
你能获得什么
- 用
@Tool注解把 Java 方法变成 Agent 工具的完整写法 - 框架自动生成 JSON Schema 的幕后机制(含探针验证输出)
- 一次工具调用从发起到回传的完整事件链路
- 工具设计的五条实战经验与三个踩坑
一、没有工具的 Agent,只是个聊天框
大模型本身是个"无状态的概率引擎":它不知道今天几号、读不到你的数据库、存不了一篇草稿。Agent 和 Chatbot 的分水岭,就在于工具(Tool)------模型在推理中判断"这事我得调个工具",框架执行工具,结果回传给模型继续思考。这就是 ReAct 循环里 Act 那一环。
AgentScope-Java 开箱自带 21 个工具(文件读写、Shell 执行、记忆检索、子智能体编排等),我上篇文章的探针输出里能看到:
csharp
[agent_send, agent_spawn, agent_list, task_list, task_output, task_cancel,
wait_async_results, memory_save, memory_get, memory_search, read_file,
write_file, edit_file, list_files, glob_files, grep_files, execute, ...]
但这些通用工具不懂你的业务。让 Agent 帮你运营写作流程,得给它定制工具------好消息是,在 AgentScope-Java 里这只需要一个注解。
二、最小示例:一个方法就是一个工具
看最简单的例子:
java
@Tool(name = "word_count", description = "统计一段文字的字数(含标点)",
readOnly = true, concurrencySafe = true)
public String wordCount(
@ToolParam(name = "text", description = "待统计的文字内容") String text) {
return "共 " + text.length() + " 字。";
}
三个要点:
@Tool标注方法 :name是模型看到的工具名(必填),description告诉模型"什么时候该用我";@ToolParam标注参数:每个参数的名称和描述同样会进 Schema,模型靠它决定传什么;- 返回 String:工具结果统一以文本形式回喂给模型,复杂对象框架会帮你序列化。
注册一行搞定:
java
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DraftTools(draftsDir)); // 反射扫描全部 @Tool 方法
框架会扫描对象上所有带 @Tool 的 public 方法,自动生成工具名、描述、参数 Schema------不用手写 JSON,不用实现任何接口。
三、完整工具集:写作助手三件套
上篇文章的项目里,我给写作团队写了三个工具(src/main/java/com/mk/agentscope/tool/DraftTools.java):
java
public class DraftTools {
private final Path draftsDir;
public DraftTools(Path draftsDir) {
this.draftsDir = draftsDir;
}
@Tool(name = "save_draft", description = "把一段文字保存为草稿文件,返回保存结果")
public String saveDraft(
@ToolParam(name = "title", description = "草稿标题,将作为文件名(无需扩展名)") String title,
@ToolParam(name = "content", description = "草稿正文内容") String content) {
try {
Files.createDirectories(draftsDir);
Path target = draftsDir.resolve(sanitize(title) + ".md");
Files.writeString(target, content, StandardCharsets.UTF_8);
return "已保存草稿《" + title + "》,共 " + content.length() + " 字";
} catch (IOException e) {
return "保存失败:" + e.getMessage();
}
}
@Tool(name = "list_drafts", description = "列出草稿箱中所有已保存的草稿标题")
public String listDrafts() {
// 无参数工具:模型直接调用,括号里什么都不用传
...
}
@Tool(name = "word_count", description = "统计一段文字的字数(含标点)",
readOnly = true, concurrencySafe = true)
public String wordCount(
@ToolParam(name = "text", description = "待统计的文字内容") String text) {
return "共 " + text.length() + " 字。";
}
}
【图1:DraftTools 源码中 @Tool 注解特写】
三个工具覆盖了三种典型形态:有副作用 (save_draft 写文件)、纯查询 (list_drafts 无参)、纯计算(word_count 只读)。写业务工具时先想清楚你的工具属于哪类,这决定了下面要讲的声明属性怎么配。
四、幕后机制:Schema 是自动生成的
模型怎么知道该调哪个工具、传什么参数?答案是框架为每个工具生成的 JSON Schema。我写了个探针(不需要 API Key)直接验证:
java
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DraftTools(Paths.get(".agentscope/workspace/drafts")));
System.out.println(toolkit.getToolNames());
toolkit.getToolSchemas().forEach(s -> System.out.println(s.getParameters()));
控制台输出:
csharp
INFO io.agentscope.core.tool.Toolkit -- Registered tool 'save_draft' in group 'ungrouped'
INFO io.agentscope.core.tool.Toolkit -- Registered tool 'list_drafts' in group 'ungrouped'
INFO io.agentscope.core.tool.Toolkit -- Registered tool 'word_count' in group 'ungrouped'
TOOL_NAMES=[save_draft, word_count, list_drafts]
=== save_draft ===
params: {type=object, properties={title={type=string, description=草稿标题,将作为文件名(无需扩展名)},
content={type=string, description=草稿正文内容}}, required=[title, content]}
【图2:探针输出------注册日志 + 自动生成的 JSON Schema】
看到没:@ToolParam 里的描述原文进了 Schema 的 description 字段,required 列表自动生成(@ToolParam 的 required 默认 true)。这套 Schema 随每次请求发给模型,模型据此决定调用时机和参数------所以 description 不是写给编译器的注释,是写给模型的 prompt,值得像写需求一样认真。
@Tool 还有几个声明属性值得知道:
| 属性 | 默认 | 语义 |
|---|---|---|
readOnly |
false | 只读工具,无副作用,可安全重试 |
concurrencySafe |
false | 线程安全,可并行调用 |
strict |
false | 严格参数校验模式 |
dangerousFiles / dangerousDirectories |
空 | 声明危险路径,触发权限确认 |
五、一次工具调用的完整生命周期
工具挂到 HarnessAgent 上,发起一个必然触发工具的请求(agent/ToolCallDemo.java):
java
HarnessAgent agent = HarnessAgent.builder()
.name("writing-assistant")
.sysPrompt("你是一个写作助手。用户要求保存草稿时必须调用 save_draft 工具...")
.model(ModelFactory.build())
.workspace(Paths.get(".agentscope/workspace"))
.toolkit(toolkit)
.build();
UserMessage message = new UserMessage(
"帮我把下面这段存成草稿,标题「Java多智能体入门」,内容:「...」。"
+ "存完告诉我草稿箱里现在有哪些草稿,这段内容一共多少字。");
agent.streamEvents(message, ctx)
.doOnNext(event -> {
switch (event.getType()) {
case TEXT_BLOCK_DELTA -> System.out.print(((TextBlockDeltaEvent) event).getDelta());
case TOOL_CALL_START -> System.out.println("[调用工具] " + ((ToolCallStartEvent) event).getToolCallName());
case TOOL_CALL_END -> System.out.println("[参数就绪] " + ((ToolCallEndEvent) event).getToolCallName());
case TOOL_RESULT_TEXT_DELTA -> System.out.print(((ToolResultTextDeltaEvent) event).getDelta());
case TOOL_RESULT_END -> System.out.println("[工具返回] " + ((ToolResultEndEvent) event).getToolCallName());
default -> { }
}
}).blockLast();
运行后控制台会看到完整链路:
css
[调用工具] save_draft
[参数就绪] save_draft
已保存草稿《Java多智能体入门》,共 42 字
[工具返回] save_draft 状态=SUCCESS
[调用工具] list_drafts
...
[调用工具] word_count
...
共 42 字。
[工具返回] word_count 状态=SUCCESS
好的,已为你保存草稿《Java多智能体入门》...(模型基于三次工具结果生成最终回复)
【图3:ToolCallDemo 控制台完整链路截图】
这张图就是 P2 文章的核心素材:一次用户请求背后,模型自主完成了三轮工具调用------保存、查询、统计,最后才综合结果作答。注意最后那段文字不是工具直接返回的,而是模型把三个工具结果"消化"后的产物,这正是 ReAct 循环(Think → Act → Observe)的含义。
【图4:.agentscope/workspace/drafts/ 下真实生成的草稿文件】
跑完去 workspace 里看,Java多智能体入门.md 真的躺在那里------Agent 不是模拟保存,是真的动了你的文件系统。
六、工具设计的五条经验
- 一个工具只做一件事 。
save_draft只管保存,别让它顺便统计字数------工具粒度越细,模型的组合空间越大,出错时定位越准。 - description 是给模型的 prompt。写"保存草稿"不如写"把一段文字保存为草稿文件,返回保存结果"------后者明确了输入输出契约。宁具体勿抽象。
- 工具内自己兜异常 。
saveDraft里 try-catch 返回"保存失败:原因"而不是抛异常------把错误信息回喂给模型,它有机会自己纠正(比如换个标题重试);抛异常会直接中断整个 ReAct 循环。 - 纯计算/查询类工具标上
readOnly = true, concurrencySafe = true,框架可以并行调度,多工具请求时延迟明显下降。 - 路径要清洗 。工具直接暴露文件系统时,像
sanitize(title)那样过滤路径分隔符,否则模型一个"包含 / 的标题"就能写出目录外的文件。
七、踩坑记录
坑 1:终端中文乱码。 注册日志和 Schema 打印里的中文在 Windows GBK 终端显示为乱码,但数据本身是 UTF-8 无损的。跑探针时加 -Dfile.encoding=UTF-8 即可,或者干脆不管------发给模型的内容不受终端编码影响。
坑 2:ToolSchema 没有 toJson()。 我想打印 Schema 验证生成结果,翻了半天只有 getName()/getDescription()/getParameters() 这些 getter(getParameters() 返回的就是 Schema Map)。想自己序列化得借助 Jackson。
坑 3:@ToolParam 的 required 默认 true。 想声明可选参数必须显式写 required = false,否则模型会把它当必传,Schema 里也会进 required 列表。
八、总结与下期预告
到这儿,Agent 已经有了"双手":你给它工具,它自己决定什么时候用、怎么组合。下一篇 P3 是系列核心篇------把单个 Agent 放大成一个写作团队 :总编接需求,agent_spawn 派活给结构策划、撰稿人、审校三个子 Agent,全部用 markdown 文件声明,不写一行胶水代码。
本文完整代码:github.com/525128633/a...(
tool/DraftTools.java+agent/ToolCallDemo.java) 框架官方文档:java.agentscope.io 基于 agentscope-harness 2.0.1,DeepSeek API