【SAA实战】第 3 篇 · 工具调用全攻略:把业务能力交给 Agent 自己调度

① 引子:客服每天被问的,答案都在别的系统里

做过后端的都懂这种场景:售后客服一天到晚被问三件事------

  • "我订单 SO12345 到哪了?"
  • "从杭州寄 3 公斤到北京多少钱?"
  • "上海今天天气咋样,适合收货不?"

这三句话,答案没有一个在你自己的代码里:订单在订单中心,运费在计费服务,天气在第三方接口。传统写法你得写一堆 if (问运费) 调计费; if (问订单) 查订单 的胶水代码,而且用户一旦把三个问题揉在一句话里,你就傻了。

Agent 的思路不一样:你把这些业务能力包装成"工具"暴露出去,让模型自己决定调哪个、按什么顺序调。 你从"写调度逻辑的人"变成"提供能力的人"。

本篇就把"怎么把能力变成工具、怎么让 ReactAgent 自动调度、怎么在调试时看见它调了啥"讲透。

② 目标

读完你能做到三件事:

  1. 两种写法把业务方法变成工具(函数式 / 方法式);
  2. 让 ReactAgent 自动跑工具循环,不用手写"该调哪个工具"的判断;
  3. 在调试时清楚地看见这一轮到底调了哪些工具、传了什么参数。

③ 最小代码:先上一个工具跑起来

如果你用过 Spring AI 的 ChatClientchatClient.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 一视同仁。真实项目里我更常用方法式------直接把已有的 OrderServiceWeatherService 的方法标个 @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 / ToolCallbacksdescription 选工具------这些 ChatClient 全都有,SAA 没重新发明。
  • SAA 多出来的,是把工具从"请求参数"升级成"Agent 的一等公民能力":构建时挂载、图节点级可观测、整轮状态可回放、可并行 / 超时 / 异常治理。

一句话:差异不在"能不能调工具",而在"工具被怎么组织、怎么被运行、怎么被看见和管控"。 而且因为复用同一套 ToolCallback,你从 ChatClient 迁到 ReactAgent 几乎零成本------把 .tools()call 上挪到 Agent 构建上即可。

⑦ 小结 & 预告

本篇要点:

  • 工具 = 把业务能力包装成模型能调的"接口",两种写法:函数式 FunctionToolCallback(就近写完)方法式 @Tool(零改造接已有 Service)
  • 你只提供工具,ReAct 循环让模型自己决定调哪个、调几次、什么顺序;
  • 调不调工具、调哪个,模型看的是 description,不是你的代码;

下一篇《Agent 记忆体系》 :这一篇的 call() 都是"一问一答",下一问就忘了上一问。怎么让 Agent 跨轮记住上下文?saver + threadId 是怎么回事?为什么 SAA 的"记忆"不只是聊天记录,而是能暂停、恢复、甚至时间旅行?咱们下回拆。

相关推荐
东小西24 分钟前
【SAA实战】第 4 篇 · Agent 短期记忆:saver 让 Agent 跨轮记得住(threadId 隔离)
java·后端·spring
许彰午31 分钟前
22-DataCenter报文序列化
java·低代码·架构·状态模式
2601_962065251 小时前
[MySQL] SQL优化之性能分析
java·sql·mysql
阿kun要赚马内1 小时前
MySQL 索引基础
后端·mysql
小范同学_1 小时前
JDK1.7 与 JDK1.8 HashMap 底层原理对比 + 数组并发扩容死循环详解
java·开发语言
码事漫谈1 小时前
我啥都能做,却不知道做什么了
后端
geovindu1 小时前
CSharp: 万年历
开发语言·后端·c#·.net
予昊2 小时前
从零实现“在线五子棋对战“:WebSocket 实时通信 + 段位匹配
java·开发语言·网络·websocket
2601_962203512 小时前
【SpringAI入门】初识SpringAI
java