系列第 4 篇 · 能力篇
上篇我们讲清了 ReAct 循环:模型在"推理(Reasoning)"和"行动(Acting)"之间反复横跳,直到产出答案。但有个绕不开的问题------
模型本身只会"生成文本",它怎么可能真的去查天气、算账单、读数据库?
答案就是本篇的主角:Tool Calling(工具调用)。它把 ReAct 循环里那个 "Acting" 步骤,接到你写的 Java 方法上。理解它,Agent 才第一次真正"动手"干活。
读完你会做到:用 @Tool 注解把任意 Java 方法变成 Agent 的能力,注册进 Agent,然后让模型自己决定什么时候调、怎么调。
一、先厘清:Tool 在 ReAct 里站在哪
回顾第 3 篇的循环图,"行动"那一步就是工具调用的位置:
sql
flowchart TD
Think[Reasoning:模型推理] --> Decide{需要外部数据?}
Decide -->|否| Answer([直接回答])
Decide -->|是| Action[Acting:调用 Tool]
Action --> Observe[Observation:工具返回结果]
Observe --> Think
整个过程对你来说是零硬编码的:你不用写 if (用户问天气) 调 getWeather(),模型读着工具描述自己判断。这正是 ReAct 比"写死流程图"聪明的地方。
而"工具描述"怎么写,直接决定了模型调得准不准------这点放到后面"避坑"细讲,它是实战里最常被忽略的命门。
二、三步走:定义 → 注册 → 使用
2.1 用 @Tool 定义工具
任意 Java 方法,加上 AgentScope 自己的 @Tool 注解,就成了 Agent 可调用的能力:
typescript
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
public class WeatherTools {
@Tool(name = "get_weather",
description = "查询指定城市的当前天气,返回天气状况和温度")
public String getWeather(
@ToolParam(name = "city", description = "城市名称,例如 '北京' 或 'Shanghai'")
String city) {
// 这里可以接真实天气 API;示例先返回模拟数据
return city + " 今天:晴,25℃,微风";
}
@Tool(name = "calculate",
description = "计算一个数学表达式,例如 '23 * 7 + 4'")
public double calculate(
@ToolParam(name = "expression", description = "数学表达式字符串")
String expression) {
// 示例:真实场景可接表达式引擎(如 FelEvaluator / Janino)
return 42.0;
}
}
几个关键点(后面会反复用到):
@Tool
的name是工具的唯一标识,模型调用时用的就是它;
@Tool
的description告诉模型"这个工具能干嘛、什么时候该用",模型靠它决策;
@ToolParam
必须显式写name------因为 Java 编译后会丢掉参数名,不写框架拿不到;
方法返回值就是工具的执行结果,会被塞回 ReAct 循环当作 Observation。
注意:这是 AgentScope 自己的
io.agentscope.core.tool.Tool,不是 Spring AI 的@Tool。混用注解是新手高频报错来源。
2.2 注册到 Toolkit
定义了方法 ≠ 模型看得到它。还要把工具对象注册进 Toolkit:
java
import io.agentscope.core.tool.Toolkit;
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new WeatherTools()); // 反射扫描 @Tool 方法并注册
职责划分要清楚:
WeatherTools
:工具真正执行的业务逻辑;
Toolkit
:收集、描述、分发多个工具;
HarnessAgent
:把模型、提示词、工具、Harness 能力组合起来。
2.3 交给 Agent 并调用
把 Toolkit 传给 HarnessAgent.builder().toolkit(...),然后用普通 call 触发:
java
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.UserMessage;
import io.agentscope.harness.HarnessAgent;
import java.nio.file.Paths;
public class ToolDemo {
public static void main(String[] args) {
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new WeatherTools());
HarnessAgent agent = HarnessAgent.builder()
.name("assistant")
.sysPrompt("你是一个有用的助手,需要查天气或计算时请调用对应工具,不要编造数据。")
.model("dashscope:qwen-plus") // ModelRegistry 解析
.toolkit(toolkit) // 接上工具
.workspace(Paths.get(".agentscope/workspace"))
.build();
UserMessage userMsg = new UserMessage("北京今天天气怎么样?另外帮我算一下 23 乘以 7 加 4。");
String reply = agent.call(userMsg, RuntimeContext.empty())
.block()
.getTextContent();
System.out.println(reply);
}
}
RuntimeContext.empty() 表示不区分用户/会话(演示用);真实业务请用 RuntimeContext.builder().sessionId(...).userId(...).build() 做多租户隔离(见第 2 篇)。
三、运行预期:模型自己"决定"调不调
跑起来后,你不会看到代码里任何 if 去判断"要不要查天气"。实际发生的是:
-
模型读到 get_weather / calculate 的描述;
-
判断用户这句话需要外部数据 → 返回工具调用请求(带参数 city="北京"、expression="23 * 7 + 4");
-
框架把请求分发到 WeatherTools.getWeather(...) / calculate(...),拿到结果;
-
结果作为 Observation 塞回循环,模型基于真实数据生成最终回复。
输出大致是:"北京今天晴,25℃。23 × 7 + 4 = 165。"------数字来自工具,不是模型编的。这正是 Tool Calling 的价值:把"会聊天"变成"能办事"。
四、进阶:让工具"调得准、用得稳"
4.1 工具描述写得好不好,决定一切
模型完全靠 @Tool / @ToolParam 的描述来决策。描述模糊,模型就会传错参数、甚至干脆不调。对比一下:
| 写法 | 后果 | | --- | --- | | @ToolParam(description = "城市") | 太泛,模型可能传"中国""北方"这类歧义值 | | @ToolParam(description = "城市名称,例如 '北京' 或 'Shanghai'") | 给示例,模型传参更准 | | @Tool(name = "t", description = "处理一些事") | 模型几乎不会调用(不知道何时用) |
经验法则:把工具描述当成"写给另一个工程师看的接口说明"------讲清能力边界 + 参数含义 + 给示例。
4.2 系统提示词是补充,不是替代
工具描述讲"工具能做什么",sysPrompt 讲"业务规则"。在关键场景,用 sysPrompt 给硬约束:
js
当用户询问天气、温度时,必须调用 get_weather 工具,不得编造天气数据。
这能压住模型"幻觉式作答"的冲动,尤其在医疗、金融等不能编的领域。
4.3 工具可以是同步 / 异步、实例 / 静态
框架支持:实例方法、静态方法;同步或异步返回;流式或非流式返回。需要非阻塞时,工具方法可直接返回 Reactive 类型(如 Mono / Flux),与 AgentScope 的响应式内核天然契合------具体签名以官方文档为准。
4.4 readOnly 与计划模式(2.0 亮点)
2.0 的 HarnessAgent 支持计划模式(Plan Mode):Agent 先"只读规划"再"执行"。标注了只读性质的工具应加 readOnly = true,这样它在规划阶段也能被调用:
typescript
@Tool(name = "getCurrentTime", description = "获取当前系统时间", readOnly = true)
public String getCurrentTime() { return LocalDateTime.now().toString(); }
4.5 工作区声明工具(进阶)
Harness 工作区模式下,还能在 workspace/tools.json 里声明 MCP server 与工具白名单,无需改 Java 代码即可扩工具------在MCP 协议相关的篇幅里面会详细介绍
五、新手最常踩的 6 个坑
@ToolParam
没写name------ Java 运行期拿不到参数名,工具 Schema 生成会出错或参数错位。务必显式写 name。
用了 Spring AI 的@Tool------ 认准 io.agentscope.core.tool.Tool,注解混用导致工具扫描不到。
忘了toolkit.registerTool(...)------ 只写了 @Tool 方法却没注册,模型根本不知道有这个工具。
工具描述太敷衍------ "处理事情""输入内容"这类描述,模型要么不调、要么乱传参。描述要讲清能力 + 参数示例。
让模型"编"数据------ 没在 sysPrompt 里约束"必须调工具",模型可能直接幻觉作答。涉及真实数据务必强约束。
非 DashScope 模型没装扩展包------ dashscope: 也要单独引 agentscope-extensions-model-dashscope,并非 harness 内置;换 DeepSeek / OpenAI 要补 agentscope-extensions-model-*。
小结
这一篇你让 Agent 第一次"动手":
Tool Calling 站在 ReAct 循环的Acting环节,把"生成文本"接到真实 Java 方法;
用@Tool+@ToolParam定义工具,Toolkit.registerTool(...)注册,.toolkit(toolkit)接入 Agent;
框架自动把注解编译成 JSON Schema,你不用手写;模型按描述自主决策何时调用;
调得准不准,七分看工具描述和sysPrompt 约束;2.0 还支持 readOnly、异步、工作区声明等进阶玩法。
工具是 Agent 能力的"手脚"。下一篇我们解决另一个 Java 工程师最痛的点:怎么把模型吐出的自由文本,稳稳地变成 Java 对象------结构化输出(Structured Output)与自纠错解析。
下篇预告:《结构化输出:把 LLM 文本变成 Java 对象》
如果这篇文章对你有帮助,欢迎关注公众号「栈知见」,我会持续输出有深度的技术实战笔记