① 引子:客服每天被问的,答案都在别的系统里
做过后端的都懂这种场景:售后客服一天到晚被问三件事------
- "我订单 SO12345 到哪了?"
- "从杭州寄 3 公斤到北京多少钱?"
- "上海今天天气咋样,适合收货不?"
这三句话,答案没有一个在你自己的代码里:订单在订单中心,运费在计费服务,天气在第三方接口。传统写法你得写一堆 if (问运费) 调计费; if (问订单) 查订单 的胶水代码,而且用户一旦把三个问题揉在一句话里,你就傻了。
Agent 的思路不一样:你把这些业务能力包装成"工具"暴露出去,让模型自己决定调哪个、按什么顺序调。 你从"写调度逻辑的人"变成"提供能力的人"。
本篇就把"怎么把能力变成工具、怎么让 ReactAgent 自动调度、怎么在调试时看见它调了啥"讲透。
② 目标
读完你能做到三件事:
- 用两种写法把业务方法变成工具(函数式 / 方法式);
- 让 ReactAgent 自动跑工具循环,不用手写"该调哪个工具"的判断;
- 在调试时清楚地看见这一轮到底调了哪些工具、传了什么参数。
③ 最小代码:先上一个工具跑起来
如果你用过 Spring AI 的
ChatClient,chatClient.prompt("...").tools(callback).call().content()这种写法一定眼熟。SAA 的工具机制和它同源 ------底层都是同一套ToolCallback,但挂法 和运行方式不同。本篇先把 SAA 跑起来,⑥ 节再正面和 Spring AI 对比差异。
先别贪多,只做一个工具------算运费,用函数式写法。核心就这几行:
java
public class ShippingTool {
// 工具输入:必须是 JSON 可反序列化的结构
public record ShipRequest(String fromProvince, String toProvince, double weightKg) {}
public static ToolCallback shippingTool() {
return FunctionToolCallback.builder("calc_shipping_fee",
(ShipRequest req, ToolContext ctx) -> {
double base = 8.0, perKg = 2.0;
boolean cross = !req.fromProvince().equals(req.toProvince());
double fee = base + req.weightKg() * perKg + (cross ? 5.0 : 0.0);
return String.format("从%s到%s,%.1f公斤,运费约 %.2f 元",
req.fromProvince(), req.toProvince(), req.weightKg(), fee);
})
.description("根据出发省份、目的省份和重量(公斤)计算快递运费,单位元")
.inputType(ShipRequest.class) // 关键:告诉框架怎么把模型传来的 JSON 还原成对象
.build();
}
}
然后把工具挂到 Agent 上,一个最小配置就齐了:
java
@Bean
public ReactAgent miniAgent(ChatModel chatModel) {
return ReactAgent.builder()
.name("mini")
.model(chatModel)
.systemPrompt("你是一个快递运费助手,只能调用算运费工具回答运费问题。")
.tools(ShippingTool.shippingTool()) // 只挂这一个工具
.saver(new MemorySaver())
.build();
}
调用就一行:
java
AssistantMessage r = miniAgent.call("从浙江杭州寄 3 公斤到北京多少钱?", cfg("demo-a"));
System.out.println(r.getText());
④ 跑起来:你没写"该调哪个",是模型自己决定的
控制台会打出类似这样的答案:
css
=== Demo A: 单工具 call() ===
答案: 从浙江杭州到北京,3.0公斤,运费约 19.00 元
注意一个关键点:你从头到尾没写 if (问运费) 调 calc_shipping_fee。你只做了两件事------把工具交给 Agent、把问题交给 Agent。至于"这个问题该调运费工具",是模型在运行时自己判断的。这就是 Agent 和"硬编码 if-else 调接口"的本质区别。
再进阶一步。我们加两个工具(查订单、查天气),用方法式写法,让一个 Agent 同时持有三个工具,然后问一个组合问题:
java
public class LogisticsTools {
public record OrderRequest(String orderId) {}
public record WeatherRequest(String city) {}
@Tool(name = "query_order", description = "根据订单号查询订单当前状态和物流信息")
public String queryOrder(@ToolParam(description = "订单号,例如 SO12345") OrderRequest req) {
return "订单 " + req.orderId() + ":已发货,目前正在派送中,预计今天送达。";
}
@Tool(name = "query_weather", description = "查询指定城市当天的天气情况")
public String queryWeather(@ToolParam(description = "城市名称,例如 上海") WeatherRequest req) {
return req.city() + " 今天晴,气温 22~28℃,东南风 3 级,适合户外活动。";
}
}
配置时用 .methodTools(new LogisticsTools()) 把对象里所有 @Tool 方法扫进 Agent:
java
@Bean
public ReactAgent helperAgent(ChatModel chatModel) {
return ReactAgent.builder()
.name("helper")
.model(chatModel)
.systemPrompt("你是一个智能售后助手,可以调用算运费、查订单、查天气等工具。")
.tools(ShippingTool.shippingTool()) // 函数式工具
.methodTools(new LogisticsTools()) // 方法式工具(自动扫描 @Tool)
.saver(new MemorySaver())
.build();
}
问一句组合问题,Agent 会自动拆成两个工具调用:
java
helperAgent.call("我的订单 SO12345 现在到哪了?另外顺便告诉我上海今天天气怎么样。", cfg("demo-b"));
你不用告诉它"先查订单再查天气",它自己排。
⑤ 知识点
5.1 工具两种写法,怎么选
函数式 FunctionToolCallback |
方法式 @Tool 注解 |
|
|---|---|---|
| 载体 | 一个 BiFunction<Input, ToolContext, Output> |
普通类上的普通方法 |
| 适用 | 逻辑简单、纯函数、想就近写完 | 工具多、想按类组织、复用已有 Service |
| 挂法 | .tools(callback) |
.methodTools(new X()) 或 ToolCallbacks.from(new X()) |
| 输入 | .inputType(POJO.class) 必填 |
方法参数即输入(同样要 JSON 可反序列化) |
两种方法最终都是 ToolCallback,ReactAgent 一视同仁。真实项目里我更常用方法式------直接把已有的 OrderService、WeatherService 的方法标个 @Tool 就上线了,零改造。
5.2 description 决定模型调不调
容易翻车的事实:
- 模型靠
description选工具。你写"计算运费",它就只在问钱时调;你写含糊了,它可能乱调或干脆不调。description 不是在写给你看的注释,是在写"给模型的说明书"。
5.3 自动工具循环(ReAct)才是 Agent 的灵魂
一次 call 背后其实是个循环:
模型思考 → 决定调工具 → 框架执行工具 → 结果喂回模型 → 模型再思考 → ... → 直到能直接回答
这就是 ReAct(Reasoning + Acting)。你只管提供工具,循环由框架驱动。多轮工具调用(比如先查订单发现异常、再查天气决定要不要改派送)也是它自己串起来的。
5.4 怎么"看见"工具调用(调试必备)
第 2 篇我们确认过:call() 返回的是最终答案 那条消息,它身上的 getToolCalls() 永远是空的。要看工具调用,两条正路:
stream()------边跑边看节点:
java
helperAgent.stream("从深圳寄 2 公斤到成都运费多少?成都天气如何?", cfg("demo-c1"))
.doOnNext(out -> System.out.println("节点: " + out.node()))
.blockLast();
每个 NodeOutput.node() 告诉你当前走到哪个节点(模型节点 / 工具节点),"想→调工具→再想→给答案"的过程全摊开了。
invoke()------跑完拿完整状态翻所有调用:
java
Optional<OverAllState> state = helperAgent.invoke("查订单 SO12345,再看北京天气", cfg("demo-c2"));
List<Message> messages = (List<Message>) state.get().value("messages").orElse(List.of());
List<ToolCall> calls = messages.stream()
.filter(m -> m instanceof AssistantMessage)
.map(m -> (AssistantMessage) m)
.flatMap(m -> m.getToolCalls().stream())
.toList();
calls.forEach(c -> System.out.println("调了工具: " + c.name() + " 参数: " + c.arguments()));
⑥ Spring AI 的 tools 怎么用?和 SAA 到底差在哪
前面 ③④⑤ 都在讲 SAA,但你心里一定有个对照物:在 Spring AI 里 tools 明明也能用,那 SAA 到底差在哪?这一节正面回答,先把你熟悉的 Spring AI 写法摆出来,再和 SAA 并排看。
6.1 先看 Spring AI ChatClient 里怎么用工具(锚点)
工具定义和 SAA 完全一样 ------都是 FunctionToolCallback,因为底层就是同一套 ToolCallback:
java
ToolCallback calcFee = FunctionToolCallback.builder("calc_shipping_fee",
(ShipRequest req, ToolContext ctx) -> { ... })
.description("根据出发省份、目的省份和重量计算快递运费")
.inputType(ShipRequest.class)
.build();
// 调用:工具是这一次请求的一部分,每次 call 时挂上去
String answer = chatClient.prompt("从杭州寄 3 公斤到北京多少钱")
.tools(calcFee) // ← 每次调用都要显式挂
.call()
.content();
方法式也一样:chatClient.prompt("...").tools(ToolCallbacks.from(new LogisticsTools())).call().content()。
关键落在 .tools() 的位置:它挂在每一次 call() 上。在 ChatClient 眼里,工具是"这次请求的参数",不是"长期属于某个对象的能力"。
6.2 再看 SAA ReactAgent 里怎么用(回顾 ③④)
工具是在构建 Agent 时一次性挂好的:
java
ReactAgent helperAgent = ReactAgent.builder()
.model(chatModel)
.tools(ShippingTool.shippingTool()) // 构建时挂好
.methodTools(new LogisticsTools()) // 方法式工具也构建时扫入
.saver(new MemorySaver())
.build();
// 之后每次调用都自动带这些工具,不再重复指定
helperAgent.call("运费多少?订单到哪了?", cfg("demo-b"));
6.3 所以真实增量是什么(不抢功)
- 自动工具循环、
@Tool/FunctionToolCallback/ToolCallbacks、description选工具------这些 ChatClient 全都有,SAA 没重新发明。 - SAA 多出来的,是把工具从"请求参数"升级成"Agent 的一等公民能力":构建时挂载、图节点级可观测、整轮状态可回放、可并行 / 超时 / 异常治理。
一句话:差异不在"能不能调工具",而在"工具被怎么组织、怎么被运行、怎么被看见和管控"。 而且因为复用同一套 ToolCallback,你从 ChatClient 迁到 ReactAgent 几乎零成本------把 .tools() 从 call 上挪到 Agent 构建上即可。
⑦ 小结 & 预告
本篇要点:
- 工具 = 把业务能力包装成模型能调的"接口",两种写法:函数式
FunctionToolCallback(就近写完) 和 方法式@Tool(零改造接已有 Service); - 你只提供工具,ReAct 循环让模型自己决定调哪个、调几次、什么顺序;
- 调不调工具、调哪个,模型看的是
description,不是你的代码;
下一篇《Agent 记忆体系》 :这一篇的 call() 都是"一问一答",下一问就忘了上一问。怎么让 Agent 跨轮记住上下文?saver + threadId 是怎么回事?为什么 SAA 的"记忆"不只是聊天记录,而是能暂停、恢复、甚至时间旅行?咱们下回拆。