Function calling/tool use 在 Spring AI 中的实例
1. 总览
LLM 只知道训练数据中的内容。它不知道你的订单 ORD-1001 现在在哪里。Function calling(也称为 tool use)正是解决这个问题的方法。你向模型描述一些函数,模型决定何时需要调用哪个函数,以及用什么参数。你的应用程序执行该函数,然后将结果返回给模型。
本文先从 JSON 级别介绍机制,然后用 Spring AI 构建一个客服助手。这个助手可以查询订单状态和取消订单。读完后,你将能够声明 tool、安全地传递上下文、处理错误,以及编写不需要调用真实模型的测试。
2. Function calling 如何工作
2.1. 问询 – 调用 – 返回循环
最容易误解的一点是:模型不会自己运行你的代码。它只是返回一个函数调用请求,采用结构化 JSON 的形式。一次包含 tool 的对话流程如下:
- 应用程序发送用户的问题,并附上 tool 列表(名称、描述、参数的 JSON Schema)。
- 模型返回
tool_calls而不是最终答案。 - 应用程序运行相应的函数,然后以
tool角色的 message 形式发回结果。 - 模型读取结果。它可能调用另一个 tool,或者给出最终答案。
2.2. 查看原始 JSON
下面的格式遵循 OpenAI 的风格。其他提供商使用类似的结构,只是字段名不同。发送的请求包含 tools 部分:
{
"model": "gpt-4o-mini",
"messages": [
{ "role": "user", "content": "我的订单 ORD-1001 到哪了?" }
],
"tools": [{
"type": "function",
"function": {
"name": "getOrderStatus",
"description": "查询订单的状态和预期交付日期",
"parameters": {
"type": "object",
"properties": { "orderId": { "type": "string" } },
"required": ["orderId"]
}
}
}]
}
模型不会立即回答。它请求调用函数:
{
"role": "assistant",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": { "name": "getOrderStatus", "arguments": "{\"orderId\":\"ORD-1001\"}" }
}]
}
应用程序运行该函数并发回结果,附上正确的 tool_call_id:
{ "role": "tool", "tool_call_id": "call_abc123",
"content": "{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\",\"eta\":\"2026-10-30\"}" }
此时模型才写出最终答案,例如:“订单 ORD-1001 已交给快递公司,预计在 10 月 30 日送达。”
有两点需要记住。首先,arguments 是模型生成的一个 JSON 字符串。这个字符串可能类型错误或缺少字段。其次,description 的质量决定了模型是否会选择正确的 tool。
自己写这个循环不难,但相当冗长。Spring AI 已经为你做好了这部分。
3. 安装 Spring AI
为 OpenAI 添加 BOM 和 starter。如果使用其他提供商,你只需要更换 starter,tool 代码部分保持不变。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
配置 API key 和模型:
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2
较低的 temperature 有助于模型更稳定地选择 tool。对于查询任务,你不需要“创意”。
4. 用 @Tool 声明 tool
我们使用一个内存订单存储库来简化:
public record Order(String id, String customerId, String status, String eta) {}
public record OrderStatusReply(String orderId, String status, String eta) {}
Tool 类是一个普通的 bean。每个标记为 @Tool 的方法都会变成一个 tool:
@Component
public class OrderTools {
private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
"ORD-1001", new Order("ORD-1001", "CUS-7", "SHIPPED", "2026-10-30"),
"ORD-1002", new Order("ORD-1002", "CUS-7", "PENDING", "2026-11-02")
));
@Tool(description = "查询订单的状态和预期交付日期")
public OrderStatusReply getOrderStatus(
@ToolParam(description = "订单编号,格式 ORD-xxxx") String orderId,
ToolContext context) {
Order order = findOwnedOrder(orderId, context);
return new OrderStatusReply(order.id(), order.status(), order.eta());
}
@Tool(description = "取消尚未交给快递公司的订单。仅在客户明确确认要取消时调用")
public String cancelOrder(
@ToolParam(description = "订单编号,格式 ORD-xxxx") String orderId,
@ToolParam(description = "客户提供的取消原因", required = false) String reason,
ToolContext context) {
Order order = findOwnedOrder(orderId, context);
if (!"PENDING".equals(order.status())) {
throw new IllegalStateException(
"订单 " + orderId + " 的状态是 " + order.status() + ",无法取消");
}
orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
return "已取消订单 " + orderId;
}
private Order findOwnedOrder(String orderId, ToolContext context) {
String customerId = (String) context.getContext().get("customerId");
Order order = orders.get(orderId);
if (order == null || !order.customerId().equals(customerId)) {
throw new IllegalArgumentException("未找到订单 " + orderId);
}
return order;
}
}
值得注意的几点:
@Tool(description = …)就是第 2.2 节中 JSON 中的description。默认情况下,tool 的名称是方法的名称。@ToolParam描述每个参数。required = false将参数从 schema 中的required列表中排除。ToolContext类型的参数不会出现在发送给模型的 schema 中。模型看不到它,也填不了它。我们用它来传递登录会话中的customerId。这样模型就无法“编造”其他人的客户代码。- 返回类型
OrderStatusReply被 Spring AI 序列化为 JSON,然后发送回模型。 - 对于不属于当前客户的订单,我们返回“未找到”而不是“没有权限”。这种方法避免泄露该订单存在的信息。
5. 通过 ChatClient 调用 tool
@Service
public class SupportAssistant {
private final ChatClient chatClient;
private final OrderTools orderTools;
public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
this.chatClient = builder
.defaultSystem("""
你是购物网站的客服助手。
只根据 tool 检索的数据回答,不要猜测订单状态。
取消订单前,请再次询问客户确认。""")
.build();
this.orderTools = orderTools;
}
public String answer(String customerId, String question) {
return chatClient.prompt()
.user(question)
.tools(orderTools)
.toolContext(Map.of("customerId", customerId))
.call()
.content();
}
}
.tools(orderTools) 扫描 bean 中的所有 @Tool 方法,并将它们的定义发送给模型。当模型返回 tool_calls 时,Spring AI 自动做三件事:解析 arguments、调用正确的方法,然后发回结果。这个循环一直重复,直到模型给出文本答案。.content() 仅返回最终答案。
尝试一个小型 controller:
@RestController
@RequestMapping("/support")
public class SupportController {
private final SupportAssistant assistant;
public SupportController(SupportAssistant assistant) {
this.assistant = assistant;
}
@PostMapping
public String ask(@RequestBody String question, Principal principal) {
return assistant.answer(principal.getName(), question);
}
}
当客户 CUS-7 问“我的订单 ORD-1001 到哪了?“时,你会得到类似的结果:
你的订单 ORD-1001 已交给快递公司,预计在 2026 年 10 月 30 日送达。
具体的措辞会因每次运行而不同。但数据总是来自 tool。
6. 观察内部:ToolCallback 和 schema
Spring AI 将每个 @Tool 方法转变为 ToolCallback。你可以自己创建它们,看看模型实际收到了什么:
ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
for (ToolCallback cb : callbacks) {
System.out.println(cb.getToolDefinition().name());
System.out.println(cb.getToolDefinition().inputSchema());
}
结果(已简化):
cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"订单编号,格式 ORD-xxxx"},
"reason":{"type":"string","description":"客户提供的取消原因"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"订单编号,格式 ORD-xxxx"}},
"required":["orderId"],...}
如前所述,ToolContext 不会出现在 schema 中。当模型选择了错误的 tool 时,你应该像上面那样打印出 schema 来首先检查。
7. 不需要调用模型的测试
在单元测试中调用真实模型既慢又昂贵,而且结果不稳定。相反,你应该测试两件事:模型收到的 schema 和当 tool 接收到与模型会发送的字符串完全相同的 arguments 字符串时的 行为。
class OrderToolsTest {
private final ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
private final ToolContext customer7 = new ToolContext(Map.of("customerId", "CUS-7"));
private ToolCallback tool(String name) {
return Arrays.stream(callbacks)
.filter(cb -> cb.getToolDefinition().name().equals(name))
.findFirst().orElseThrow();
}
@Test
void schemaShouldNotExposeToolContext() {
String schema = tool("getOrderStatus").getToolDefinition().inputSchema();
assertThat(schema).contains("\"orderId\"").doesNotContain("context");
}
@Test
void shouldReturnStatusAsJson() {
String result = tool("getOrderStatus")
.call("{\"orderId\":\"ORD-1001\"}", customer7);
assertThat(result).contains("\"status\":\"SHIPPED\"", "\"eta\":\"2026-10-30\"");
}
@Test
void shouldHideOrdersOfOtherCustomers() {
ToolContext stranger = new ToolContext(Map.of("customerId", "CUS-99"));
assertThatThrownBy(() -> tool("getOrderStatus").call("{\"orderId\":\"ORD-1001\"}", stranger))
.hasRootCauseInstanceOf(IllegalArgumentException.class)
.hasRootCauseMessage("未找到订单 ORD-1001");
}
@Test
void shouldRefuseToCancelShippedOrder() {
assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
.isInstanceOf(ToolExecutionException.class)
.hasRootCauseMessage("订单 ORD-1001 的状态是 SHIPPED,无法取消");
}
@Test
void shouldCancelPendingOrder() {
String result = tool("cancelOrder")
.call("{\"orderId\":\"ORD-1002\",\"reason\":\"下错了\"}", customer7);
assertThat(result).contains("已取消订单 ORD-1002");
}
}
MethodToolCallback 将你方法中的所有异常包装在 ToolExecutionException 中。因此上面的测试检查根本原因而不是原始异常类型。这个测试套件在几毫秒内运行,并测试你能控制的部分。
模型的行为(是否选择了正确的 tool,是否在取消前询问确认)属于不同类型的测试。你应该用一个单独的 eval 集合和真实模型运行它,而不是将其纳入单元测试。
8. 处理来自 tool 的错误
当 cancelOrder 在实际运行时抛出异常会发生什么?默认情况下(spring.ai.tools.throw-exception-on-error=false),Spring AI 不会破坏请求。它获取异常的 message,并以 tool 结果的形式发送回模型。模型读取 message 并向客户解释:
很遗憾,订单 ORD-1001 已交给快递公司,所以无法再取消了。
你想要我指导你如何在收货时退货吗?
这个行为很方便,但带来了两个你需要注意的后果:
- **异常的 message 会被模型读取,通常会被重述给用户。**不要抛出包含堆栈跟踪、SQL 语句或内部信息的异常。
- 如果你想让基础设施错误(比如数据库宕机)直接冒泡到上层,请设置:
spring.ai.tools.throw-exception-on-error=true
一个合理的做法是:业务错误(订单已发货、未找到订单)返回给模型,而基础设施错误让其冒泡以便监控捕获。
9. 常见错误和何时不应该使用
注意:把模型生成的
arguments当作来自不可信用户的输入。Prompt injection 可能会导致模型用任意订单代码调用cancelOrder。所有权限检查都必须在 tool 代码中,基于ToolContext或 security context,而不是依赖系统提示中的指令。
常见的错误:
- **描述不清楚。**写“获取订单”模型难以区分与“获取订单历史”。清楚地描述 tool 做什么,什么时候应该使用,什么时候不应该。
- **一个请求中的 tool 过多。**每个 tool 定义都会消耗 token,而当列表较长时,模型选择效果更差。只传递与当前用例相关的 tool。
- **没有确认步骤的有副作用的 tool。**对于取消订单、退款或发送电子邮件等操作,考虑分成两个步骤。第一个 tool 只准备并返回摘要。实际操作仅在用户在 UI 上单击确认后运行,而不仅仅是在模型“认为”客户同意后。
- **缺乏幂等性。**模型可能调用同一个 tool 两次。设计
cancelOrder使得第二次调用不会造成伤害。 - **返回数据过大。**将整个 500 行订单历史倒入上下文既浪费 token,又会使模型“迷路”。在 tool 中进行过滤和总结。
- **忘记每个 tool 调用都是与模型的往返。**一个需要三个 tool 的问题可能要花几秒钟。在将其放入需要快速响应的流程之前,请测量延迟。
不应该使用 function calling 的情况:
- 处理流程已固定且已知(总是先查询订单然后回答)。在这种情况下,直接调用服务,然后将结果放入提示。这种方式更便宜、更快、更可预测。
- 你只需要模型返回结构化数据。使用结构化输出(例如
.call().entity(MyRecord.class))而不是用 tool 模拟。 - 敏感的、不可逆转的操作(转账、删除数据),但没有人工确认步骤。
10. 总结
Function calling 不是魔法。模型只返回一个 JSON 格式的函数调用请求,而你的应用程序才是执行方。Spring AI 让执行部分更简洁:@Tool 和 @ToolParam 生成 schema,ChatClient.tools(...) 处理调用-返回结果的循环,而 ToolContext 传递模型无法访问的会话数据。
最重要的是正确划定信任边界。编写清晰的描述并在 tool 中立即检查权限。对于有副作用的操作,需要用户确认。最后,通过 ToolCallback 用真实 JSON 字符串测试 tool,以确保你的代码部分工作正确,无论模型如何回答。