Spring AI 工具调用实战:模型自主决策、@Tool 方法封装与 ReAct 模式
前四篇文章中,我们的 AI 应用只能"说话",不能"做事"。用户问"帮我查一下订单"时,模型只能遗憾地表示无法访问数据库。为了让 AI 真正具备行动能力,我们需要让模型能够调用外部业务方法。这便是 Function Calling(工具调用) 。
本文将围绕三个核心问题展开:
- 模型如何自主决定何时调用哪个工具?
- 如何用 Spring AI 将业务逻辑封装为
@Tool方法? - ReAct(Reason → Act → Observe)模式是如何在 Spring AI 中自动运转的?
一、从"生成文本"到"执行动作"
大模型本质上只是文本生成器。但通过 Function Calling,我们可以向模型暴露一组"可用的函数",让它根据用户意图选择调用。模型的输出不再只是自然语言,还可能包含工具调用指令,其中携带函数名和参数 JSON。
整个流程由应用侧负责执行:应用将指令翻译为实际的业务方法,将执行结果作为一条新消息回传给模型,让模型基于结果继续推理或生成最终回答。
因此,Function Calling 的核心模型是:
css
用户请求 → 模型决定调用工具 A(参数) → 应用执行 A → 返回结果给模型 → 模型总结回答
Spring AI 将这套机制封装得极其简洁。在 ChatClient 中注册工具后,模型自动具备调用它们的能力。
二、模型如何"自主决定"调用哪个工具
让我们先理解底层机制。以上下文为例,当你向 OpenAI 发送请求时,可以在 tools参数中描述可用函数(名称、描述、参数 JSON Schema)。模型会根据对话内容判断是否需要调用某个函数。如果决定调用,响应中的 tool_calls数组会包含函数名与参数。
这个决策过程完全由模型自身的推理能力驱动:
- 如果用户问"今天北京天气如何",而模型知道有一个
getWeather工具,它就会输出调用该工具且参数为{"city":"北京"}的指令; - 如果用户只是闲聊,模型不会调用任何工具,仅生成文本。
这就是"自主决定"的含义。开发者的责任是提供优秀的工具描述,让模型能准确匹配意图与工具。
Spring AI 中,工具的注册与描述由 @Tool注解自动完成。我们无需手动编写 JSON Schema。
三、将业务逻辑封装为 @Tool方法
3.1 一个最简单的 @Tool
以一个获取城市天气的方法为例:
kotlin
@Component
public class WeatherTools {
@Tool(description = "查询指定城市的当前天气")
public String getWeather(@ToolParam(description = "城市名称,如:北京、上海") String city) {
// 模拟真实查询逻辑
if ("北京".equals(city)) {
return "北京:晴,25℃,东南风2级";
} else if ("上海".equals(city)) {
return "上海:多云,28℃,南风3级";
}
return "暂未收录该城市天气数据";
}
}
关键点:
@Tool注解标注在公共方法上;description描述了该方法的用途,供模型理解;@ToolParam描述每个参数的语义,帮助模型正确填充参数;- 方法返回值最终会作为字符串传给模型,因此尽量返回简洁、易读的文本。
3.2 注册工具到 ChatClient
在构建 ChatClient 时,使用 .defaultTools()传入工具类的实例:
kotlin
@Configuration
public class ChatConfig {
@Bean
public ChatClient chatClient(ChatModel chatModel, WeatherTools weatherTools) {
return ChatClient.builder(chatModel)
.defaultTools(weatherTools) // 注册工具
.build();
}
}
如果工具有多个,可以这样传入:
scss
.defaultTools(weatherTools, orderTools, productTools)
Spring AI 会自动扫描这些实例中所有标注 @Tool的方法,并生成模型可读的函数描述。
3.3 更丰富的工具:订单查询
假设我们要让 AI 充当智能客服,可以查询订单状态、修改订单备注:
less
@Component
public class OrderTools {
private final OrderRepository orderRepository;
public OrderTools(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Tool(description = "根据订单号查询订单状态、商品明细、物流信息")
public String getOrderInfo(@ToolParam(description = "订单号,例如 ORD20250101001") String orderId) {
Order order = orderRepository.findByOrderId(orderId);
if (order == null) {
return "未找到订单:" + orderId;
}
return "订单 %s,当前状态:%s,商品:%s,配送进度:%s"
.formatted(order.getOrderId(), order.getStatus(),
order.getItems(), order.getLogistics());
}
@Tool(description = "修改订单的收件人备注信息")
public String updateOrderRemark(@ToolParam(description = "订单号") String orderId,
@ToolParam(description = "新备注内容") String remark) {
int rows = orderRepository.updateRemark(orderId, remark);
return rows > 0 ? "备注已更新" : "订单不存在,无法更新";
}
}
模型调用 getOrderInfo后,如果用户接着说"把备注改成尽快发货",模型会接着调用 updateOrderRemark,形成连续的多工具操作。
3.4 工具也可以返回结构化 JSON
如果工具执行后需要将结构化数据交给下游逻辑,可以让方法返回对象而非字符串。Spring AI 会将其序列化后传给模型。但要注意,传递给模型的内容最终是文本,因此复杂对象会变成 JSON 字符串。例如:
less
@Tool(description = "查询用户账户余额")
public AccountInfo getAccountBalance(@ToolParam(description = "用户ID") Long userId) {
return accountService.getBalance(userId);
}
返回的 AccountInfo对象会被序列化成 JSON,然后作为观察结果送给模型。这种方式便于模型读取多个字段,但也会消耗更多 token。对于简单结果,返回纯文本更节省。
3.5 实例方法 vs 静态方法
@Tool支持实例方法,也支持静态方法。如果工具类需要注入依赖(如 Repository、Service),必须使用实例方法并注册为 Spring Bean。静态方法适合无状态工具,但较少见。
四、理解 ReAct 雏形:Reason → Act → Observe
4.1 ReAct 的基本含义
ReAct 是"Reasoning + Acting"的缩写,最初来自一篇关于 LLM 与外部工具协同工作的论文。核心思想是让模型在推理和行动之间交替循环:
- Reason(推理) :模型分析当前问题,确定下一步应该做什么。它可能决定"用户想查询天气,我应该调用 getWeather 工具"。
- Act(行动) :应用执行模型指定的工具调用,得到执行结果。
- Observe(观察) :将工具返回的结果作为新的上下文反馈给模型。模型观察结果后,继续推理:要么再调用下一个工具,要么生成最终回答。
整个过程可以循环多轮,直到模型不再请求调用任何工具,直接输出最终答案。
4.2 Spring AI 中的自动 ReAct 循环
Spring AI 的 ChatClient内部已经实现了这个循环。当你使用 .tools()注册工具后,框架会自动处理多轮交互。让我们从代码层面观察这个过程。
假设用户请求:"帮我查一下订单 ORD123 的物流,如果还没发货,就把备注改成加急。"
对话流程:
- 模型收到该消息,通过
getOrderInfo感知到需要查询订单。 - 模型输出工具调用:
getOrderInfo(orderId="ORD123")。 - Spring AI 执行该方法,得到"订单状态:已发货..."。
- 将结果作为一条 "tool" 消息回传给模型。
- 模型看到订单已发货,可能决策:不需要改备注,生成最终回答。
- ChatClient 返回文本给用户。
如果订单是未发货状态:
- 模型继续输出下一个工具调用:
updateOrderRemark(orderId="ORD123", remark="加急")。 - Spring AI 再次执行工具,结果反馈给模型。
- 模型生成最终回答:"订单已备注加急。"
整个过程对业务代码透明,你只需要注册工具并等待最终结果。
4.3 手动 ReAct 循环的实现
虽然 ChatClient 封装了自动循环,但理解底层机制有助于调试。下面用底层 API 手动实现一个 ReAct 循环:
scss
@Service
public class ManualReActService {
private final ChatModel chatModel;
private final ToolCallingManager toolCallingManager;
public ManualReActService(ChatModel chatModel, ToolCallingManager toolCallingManager) {
this.chatModel = chatModel;
this.toolCallingManager = toolCallingManager;
}
public String run(String userInput, Object... toolObjects) {
// 1. 将工具对象转换为 ChatClient 注册所需的 ToolCallback
List<ToolCallback> toolCallbacks = Arrays.stream(toolObjects)
.map(ToolCallbacks::from)
.flatMap(List::stream)
.toList();
// 2. 构建初始消息
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage(userInput));
// 3. 最多循环5次,防止死循环
for (int i = 0; i < 5; i++) {
ChatResponse response = chatModel.call(
new Prompt(messages, optionsWithTools(toolCallbacks))
);
// 4. 检查是否包含工具调用请求
AssistantMessage assistantMessage = response.getResult().getOutput();
messages.add(assistantMessage);
List<ToolResponse> toolResponses = toolCallingManager.resolveToolCallRequests(
toolCallbacks,
assistantMessage.getToolCalls()
);
if (toolResponses.isEmpty()) {
// 没有工具调用,说明模型已给出最终回答
return assistantMessage.getText();
}
// 5. 将工具结果作为消息加入历史,再次循环
for (ToolResponse toolResponse : toolResponses) {
messages.add(new ToolResponseMessage(toolResponse.id(), toolResponse.responseData()));
}
}
return "智能体执行轮次过多,已停止。";
}
private PromptOptions optionsWithTools(List<ToolCallback> toolCallbacks) {
// Spring AI 1.0 中使用 ToolCallingChatOptions
return ToolCallingChatOptions.builder()
.toolCallbacks(toolCallbacks)
.build();
}
}
这个手动实现展示了 ReAct 的根本机制:
- 发送消息给模型;
- 模型返回 AssistantMessage,其中可能包含 ToolCalls;
- 通过
ToolCallingManager解析并执行工具调用; - 将执行结果封装为 ToolResponseMessage 追加到对话;
- 循环往复。
日常使用强烈建议直接用 ChatClient的内置循环,手动版本只作为学习或特殊定制场景。
4.4 多工具场景下的选择
假设注册了 getWeather、getOrderInfo、updateOrderRemark三个工具。模型如何决定用哪个?
- 取决于工具描述与用户问题的语义匹配;
- 多个工具的描述应责任分明,避免歧义;
- 如果模型犹豫,可能要求用户澄清,而不是乱调用。
然后我们可以观察实际调用日志。Spring AI 会记录工具调用信息。我们也可以在工具方法里打印日志:
typescript
@Tool(description = "查询订单信息")
public String getOrderInfo(String orderId) {
log.info("调用 getOrderInfo, orderId={}", orderId);
// ...
}
通过日志,我们可以分析模型决策是否符合预期,进而优化工具描述。
五、函数调用中的消息角色
当工具执行完毕后,工具结果回传给模型时需要符合多轮消息格式。在 Spring AI 底层,工具结果对应的角色通常是 Tool或 Function消息。ChatClient 自动处理这些细节,但如果你手动编写循环,需要注意消息类型。
常见消息类型:
UserMessage:用户输入;AssistantMessage:模型回复,其中可能附带工具调用;ToolResponseMessage:工具执行结果;SystemMessage:系统提示。
通过将这几类消息按顺序放进列表,模型才能正确理解哪条工具结果对应哪个工具调用。
六、与 ChatMemory 结合:有记忆的工具调用
工具调用天然适合与 ChatMemory 配合。例如:
less
@GetMapping("/chat/with-tools")
public String chat(@RequestParam String conversationId, @RequestParam String message) {
return chatClient.prompt()
.user(message)
.tools(weatherTools) // 每次请求指定工具
.memory(conversationId) // 保持会话记忆
.call()
.content();
}
memory(conversationId)会在自动的 ReAct 循环中保存历史,并在每次迭代时同步更新。用户可以在连续对话中引用之前的工具查询结果,模型仍能记住。
七、工程实践建议
7.1 工具描述是核心质量指标
模型是否正确调用工具,很大程度上取决于 @Tool与 @ToolParam的描述质量。撰写时注意:
- 使用动词开头描述方法:
查询、创建、更新、删除; - 包含使用的业务上下文,例如"根据订单号查询订单状态";
- 参数描述要具体,避免歧义;
- 对于可选参数,在描述中注明"如果用户未提供,请询问";
- 如果参数存在枚举值,尽量在描述中列出。
7.2 控制工具数量与 token 消耗
每注册一个工具,模型接收到的 functions 描述都会占用 token。工具越多,上下文越拥挤。建议:
- 只注册当前可能用到的工具;
- 将多个相关操作合并为一个工具,用参数区分;
- 对于庞大的业务系统,根据用户意图动态加载不同工具集。
7.3 防御恶意工具调用
模型可能被用户提示词诱导去调用危险工具。必须做好权限控制:
- 工具方法内部严格校验用户身份与权限;
- 禁止模型调用未注册的工具;
- 敏感操作(转账、删除)需要用户二次确认,甚至要求前端展示确认面板后再执行;
- 对工具参数做白名单校验,防止注入。
7.4 错误处理与重试
工具可能执行失败(网络异常、数据不存在)。失败信息应作为观察结果反馈给模型,让模型决定下一步。工具方法自身可以返回错误描述,而不必抛出大堆异常:
typescript
@Tool(description = "查询订单")
public String getOrder(String orderId) {
try {
return orderService.query(orderId);
} catch (Exception e) {
return "查询订单失败:" + e.getMessage();
}
}
这样模型能够理解并生成友好的用户提示。
7.5 设置最大迭代轮数
无论是自动还是手动 ReAct 循环,都要防止模型反复调用工具。ChatClient 的默认循环有内部保护,但建议在业务侧设置超时或最大轮数。手动实现时尤其重要。
7.6 日志与审计
工具调用涉及真实业务操作,必须记录:
- 谁在何时调用了哪个工具;
- 参数是什么;
- 执行结果如何;
- 模型最终生成的回复。
日志不仅用于排查问题,也是优化提示词和工具描述的数据基础。
八、完整示例:智能客服工具集成
下面整合一个可运行的示例。假设场景:用户可以通过对话查询天气、查询订单、更新订单备注。
less
@RestController
@RequestMapping("/api/agent")
public class AgentController {
private final ChatClient chatClient;
private final WeatherTools weatherTools;
private final OrderTools orderTools;
public AgentController(ChatModel chatModel,
WeatherTools weatherTools,
OrderTools orderTools) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("""
你是智能客服助手。当你需要查询信息或执行操作时,请优先使用提供的工具。
所有工具执行结果应如实告知用户。如果无法完成,请说明原因。
""")
.defaultTools(weatherTools, orderTools)
.build();
this.weatherTools = weatherTools;
this.orderTools = orderTools;
}
@GetMapping("/chat")
public String chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content();
}
}
用户对话示例:
- "今天北京天气怎么样?"
- "帮我查订单 ORD123 的状态"
- "查一下 ORD456,如果还没支付就取消"
模型会自主完成工具选择、参数提取与多步调用。
九、总结
本文从三个层面递进解读了 Spring AI 的函数调用机制:
- 模型自主决策:模型根据工具描述和用户意图,在输出中生成工具调用指令。开发者无需编写 if-else 判断何时调用哪个工具,模型自己决定。
- @Tool 方法封装:通过注解即可将 Spring Bean 方法暴露给模型,框架自动生成函数描述、解析参数、执行调用、回传结果。
- ReAct 循环:Spring AI 内部实现了 Reason → Act → Observe 的迭代循环。每次工具调用结果都会作为新上下文被模型观察,直到模型认为任务完成。
实际应用中,请务必重视:
- 工具描述的质量;
- 调用权限与安全性;
- 错误信息的设计;
- 日志与可观测性。