Function Calling 的并行调用——让 AI 同时执行多个工具
Function Calling 的并行调用——让 AI 同时执行多个工具
上周做一个内部查询平台的性能优化,用户反馈说「问一个问题要等三四秒,太慢了」。我拿着 Zipkin 追了一圈,发现问题一目了然:AI 在调用三个工具——查库存、查价格、查物流状态——是串行的,一个接一个来。每个工具调用加上网络往返大概 800ms 到 1 秒,三个加起来就是将近 3 秒。
这种问题放以前我可能会去优化缓存、优化 SQL,但这次最直接的解法是:让 AI 并行调用这三个工具。
改完之后,总耗时从 2.8 秒降到了 1.1 秒。不是什么玄学优化,就是把串行变并行,把等待时间折叠掉了。
这篇文章就来讲透 parallel_tool_use 这个机制——它是什么、怎么用、工程上有哪些坑要提前绕开。
一、Tool Calling 的基本工作原理
在讲并行之前,先把单次 Tool Calling 的流程说清楚,后面才好对比。
当你给 LLM 配置了工具之后,对话的流程大概是这样的:
sequenceDiagram
participant U as "用户"
participant A as "应用层"
participant L as "LLM"
participant T as "工具层"
U->>A: "查一下订单 #12345 的状态"
A->>L: 携带 tools 定义发送请求
L-->>A: finish_reason=tool_calls, 返回调用指令
A->>T: 执行 get_order_status(12345)
T-->>A: 返回工具结果
A->>L: 将工具结果作为新消息发送
L-->>A: 生成最终回答
A-->>U: 返回答案这是最基础的单工具单次调用。注意 LLM 返回的不是最终答案,而是「我要调用这个工具,参数是这些」,然后你的应用层去真正执行,再把结果喂给 LLM,LLM 才生成答案。
单次调用这样没问题。但如果用户问的是「订单 #12345 的状态、库存还有多少、最近的物流节点在哪里」,如果 LLM 串行生成三次工具调用指令,每次都要等上一个结果才决定下一步,那时间就叠加了。
并行 Tool Calling 要解决的就是这个问题:一次 LLM 响应里返回多个工具调用指令,应用层同时执行所有工具,再把所有结果一并回传给 LLM。
二、parallel_tool_use 是什么
parallel_tool_use 是 OpenAI API(以及兼容接口)中的一个参数,用来控制模型是否可以在一轮对话中同时返回多个工具调用。
具体的 API 参数位置:
{
"model": "gpt-4o",
"messages": [...],
"tools": [...],
"tool_choice": "auto",
"parallel_tool_calls": true
}注意 OpenAI 用的是 parallel_tool_calls,Anthropic 的 Claude 用的是 tool_choice 里的配置,但行为类似。
当模型决定可以并行时,它会在一次响应里返回多个 tool_calls:
{
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_order_status",
"arguments": "{\"order_id\": \"12345\"}"
}
},
{
"id": "call_def456",
"type": "function",
"function": {
"name": "get_inventory",
"arguments": "{\"product_id\": \"P001\"}"
}
},
{
"id": "call_ghi789",
"type": "function",
"function": {
"name": "get_logistics_node",
"arguments": "{\"order_id\": \"12345\"}"
}
}
]
},
"finish_reason": "tool_calls"
}]
}三个 tool_calls 在同一次响应里。你的应用层拿到这个响应之后,就可以同时起三个线程去执行,等全部完成后,把三个结果都塞进对话历史,再给 LLM 一次,LLM 才生成最终回答。
三、并行调用的完整流程
这张图里有几个关键点:
- LLM 自己决策是否并行,不是你强制的。如果它认为工具之间有依赖(比如先查订单才能查物流),它会串行返回。
- 等待所有工具完成才把结果回传。不能等一个传一个,那样 LLM 没法处理不完整的上下文。
- 每个工具结果都要有
tool_call_id对应,LLM 需要知道哪个结果对应哪个调用。
四、Spring AI 的并行工具调用实现
Spring AI 从 1.0.0 开始对 parallel tool calls 有了更好的支持。下面是完整的工程实现。
4.1 定义工具
@Component
public class OrderQueryTools {
@Tool(description = "查询订单状态,输入订单ID,返回当前状态(待付款/待发货/已发货/已完成/已取消)")
public String getOrderStatus(@ToolParam(description = "订单ID,格式为纯数字字符串") String orderId) {
// 模拟数据库查询,实际替换为真实逻辑
Map<String, String> mockData = Map.of(
"12345", "已发货",
"12346", "待付款"
);
return mockData.getOrDefault(orderId, "订单不存在");
}
@Tool(description = "查询商品库存数量,输入商品ID,返回当前库存件数")
public Integer getInventory(@ToolParam(description = "商品ID,格式为P+数字,如P001") String productId) {
Map<String, Integer> mockStock = Map.of(
"P001", 150,
"P002", 0
);
return mockStock.getOrDefault(productId, -1);
}
@Tool(description = "查询订单最近物流节点,输入订单ID,返回最新物流信息")
public String getLogisticsNode(@ToolParam(description = "订单ID") String orderId) {
return "2026-04-23 15:30 已到达上海转运中心";
}
}4.2 配置并行工具调用的 ChatClient
@Configuration
public class AiConfig {
@Bean
public ChatClient chatClient(OpenAiChatModel chatModel, OrderQueryTools tools) {
return ChatClient.builder(chatModel)
.defaultTools(tools)
.defaultOptions(
OpenAiChatOptions.builder()
.model("gpt-4o")
.parallelToolCalls(true) // 关键:开启并行工具调用
.temperature(0.0)
.build()
)
.build();
}
}4.3 核心服务层:处理并行工具调用结果
Spring AI 的高级封装会自动处理并行执行,但如果你需要更精细的控制(比如超时、部分失败处理),可以手动处理:
@Service
@Slf4j
public class OrderQueryService {
private final ChatClient chatClient;
private final ExecutorService toolExecutor;
public OrderQueryService(ChatClient chatClient) {
this.chatClient = chatClient;
// 专门给工具执行用的线程池,根据工具数量和并发量调整
this.toolExecutor = Executors.newFixedThreadPool(10);
}
/**
* 使用 Spring AI 高级 API,自动处理并行工具调用
*/
public String queryWithParallelTools(String userQuestion) {
long startTime = System.currentTimeMillis();
String result = chatClient.prompt()
.user(userQuestion)
.call()
.content();
long elapsed = System.currentTimeMillis() - startTime;
log.info("并行工具调用完成,耗时: {}ms", elapsed);
return result;
}
}4.4 如果用原生 OpenAI SDK 手动处理并行
有些场景下你可能需要手动控制,比如工具执行失败要部分重试,或者需要超时熔断。这时候手动实现更灵活:
@Service
@Slf4j
public class ManualParallelToolService {
private final OpenAiChatModel chatModel;
private final Map<String, Function<String, String>> toolRegistry;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); // Java 21
public ManualParallelToolService(OpenAiChatModel chatModel, OrderQueryTools tools) {
this.chatModel = chatModel;
this.toolRegistry = new HashMap<>();
// 注册工具函数
toolRegistry.put("getOrderStatus", tools::getOrderStatus);
toolRegistry.put("getInventory", args -> String.valueOf(tools.getInventory(args)));
toolRegistry.put("getLogisticsNode", tools::getLogisticsNode);
}
public String processQuery(String userQuery) throws Exception {
// 第一轮:获取 LLM 的工具调用指令
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage(userQuery));
ChatResponse firstResponse = chatModel.call(
Prompt.builder()
.messages(messages)
.options(OpenAiChatOptions.builder()
.parallelToolCalls(true)
.build())
.build()
);
AssistantMessage assistantMessage = firstResponse.getResult().getOutput();
List<AssistantMessage.ToolCall> toolCalls = assistantMessage.getToolCalls();
if (toolCalls == null || toolCalls.isEmpty()) {
// LLM 直接回答,没有工具调用
return assistantMessage.getText();
}
log.info("LLM 返回 {} 个工具调用,开始并行执行", toolCalls.size());
// 并行执行所有工具调用
List<CompletableFuture<ToolResponseMessage.ToolResponse>> futures = toolCalls.stream()
.map(toolCall -> CompletableFuture.supplyAsync(() -> {
String toolName = toolCall.name();
String arguments = toolCall.arguments();
try {
log.info("执行工具: {}, 参数: {}", toolName, arguments);
long toolStart = System.currentTimeMillis();
// 解析参数(简化处理,实际要用 Jackson)
String result = executeToolWithTimeout(toolName, arguments, 2000);
log.info("工具 {} 执行完成,耗时: {}ms", toolName,
System.currentTimeMillis() - toolStart);
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, result
);
} catch (Exception e) {
log.error("工具 {} 执行失败: {}", toolName, e.getMessage());
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "工具执行失败: " + e.getMessage()
);
}
}, executor))
.toList();
// 等待所有工具完成
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// 设置整体超时:3 秒,超过就用部分结果
try {
allDone.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.warn("部分工具调用超时,使用已完成的结果");
}
// 收集所有已完成的结果
List<ToolResponseMessage.ToolResponse> toolResponses = futures.stream()
.filter(f -> f.isDone() && !f.isCompletedExceptionally())
.map(f -> {
try { return f.get(); } catch (Exception ex) { return null; }
})
.filter(Objects::nonNull)
.toList();
// 构建工具结果消息
ToolResponseMessage toolResponseMessage = new ToolResponseMessage(toolResponses);
// 第二轮:把工具结果喂给 LLM 生成最终答案
messages.add(assistantMessage);
messages.add(toolResponseMessage);
ChatResponse finalResponse = chatModel.call(
Prompt.builder().messages(messages).build()
);
return finalResponse.getResult().getOutput().getText();
}
private String executeToolWithTimeout(String toolName, String arguments, long timeoutMs)
throws Exception {
Function<String, String> tool = toolRegistry.get(toolName);
if (tool == null) {
throw new IllegalArgumentException("未知工具: " + toolName);
}
// 提取第一个参数值(简化处理)
String param = extractFirstParam(arguments);
return tool.apply(param);
}
private String extractFirstParam(String jsonArgs) {
// 生产环境用 Jackson 解析
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(jsonArgs);
return node.fields().next().getValue().asText();
} catch (Exception e) {
return jsonArgs;
}
}
}五、结果合并策略
并行工具调用结果回来之后,怎么合并给 LLM 是有讲究的。这里有几种策略,各有适用场景。
5.1 直接透传(最常用)
把所有工具结果原样传给 LLM,让 LLM 自己综合:
// Spring AI 会自动处理,不需要手动合并
// LLM 收到的上下文形如:
// Tool getOrderStatus returned: 已发货
// Tool getInventory returned: 150
// Tool getLogisticsNode returned: 2026-04-23 15:30 已到达上海转运中心这种方式简单,LLM 理解能力强,能很好地综合多个工具结果。
5.2 结构化合并(适合复杂场景)
如果工具返回的数据结构复杂,可以在传给 LLM 前做一层整理:
private String mergeToolResults(List<ToolResponseMessage.ToolResponse> results) {
StringBuilder sb = new StringBuilder();
sb.append("以下是各工具查询结果:\n\n");
for (ToolResponseMessage.ToolResponse result : results) {
sb.append(String.format("【%s】\n%s\n\n",
getToolDisplayName(result.name()),
result.responseData()
));
}
return sb.toString();
}
private String getToolDisplayName(String toolName) {
return switch (toolName) {
case "getOrderStatus" -> "订单状态";
case "getInventory" -> "库存信息";
case "getLogisticsNode" -> "物流节点";
default -> toolName;
};
}5.3 部分失败处理
工程上必须考虑:如果三个工具里有一个超时或报错,怎么处理?
public String buildContextWithPartialResults(
List<ToolCall> allCalls,
List<ToolResponse> completedResults) {
Set<String> completedIds = completedResults.stream()
.map(ToolResponse::id)
.collect(Collectors.toSet());
StringBuilder sb = new StringBuilder();
for (ToolCall call : allCalls) {
if (completedIds.contains(call.id())) {
// 找到对应结果
completedResults.stream()
.filter(r -> r.id().equals(call.id()))
.findFirst()
.ifPresent(r -> sb.append(
String.format("[%s]: %s\n", call.name(), r.responseData())
));
} else {
// 这个工具失败或超时
sb.append(String.format("[%s]: 查询超时,数据暂不可用\n", call.name()));
}
}
return sb.toString();
}注意:失败的工具结果也要告诉 LLM「这个工具不可用」,否则 LLM 可能会在最终回答里凭空捏造这部分数据。
六、真实收益:3 秒降到 1 秒的过程
我把那次优化的前后做了详细的对比测试,场景是:用户问「我的订单 #12345 现在在哪里,还有多少同款库存,预计什么时候到货?」
这个问题触发了三个工具调用:
getOrderStatus:查订单状态,平均耗时 650msgetInventory:查库存,平均耗时 420msgetLogisticsNode:查物流,平均耗时 780ms
串行模式:
T=0ms 开始
T=50ms LLM 第一次响应,决定调用 getOrderStatus
T=700ms getOrderStatus 完成
T=800ms LLM 第二次响应,决定调用 getInventory(有时还会先调物流)
T=1220ms getInventory 完成
T=1320ms LLM 第三次响应,决定调用 getLogisticsNode
T=2100ms getLogisticsNode 完成
T=2250ms LLM 最终响应,生成答案
总计:约 2250ms并行模式:
T=0ms 开始
T=60ms LLM 第一次响应,返回三个 tool_calls
T=60ms 同时启动三个工具执行(Java 虚拟线程)
T=840ms 所有工具都完成(受最慢的 getLogisticsNode 780ms 限制)
T=980ms LLM 最终响应,生成答案
总计:约 980ms从 2250ms 到 980ms,降了 56%。这个数字在 QPS 高的场景下,对服务器资源消耗也有明显收益——串行模式下每个请求占用 LLM 连接更长时间。
七、工程注意事项
7.1 工具之间的依赖关系
并行调用的前提是工具之间没有数据依赖。如果工具 B 的参数需要用到工具 A 的结果,LLM 通常会自动串行处理。但你也可以在工具描述里明确说明依赖关系,帮助 LLM 做出正确决策。
@Tool(description = """
查询物流详情。注意:此接口需要先通过 getOrderStatus 确认订单状态为「已发货」
才有意义,如果订单尚未发货请不要调用此工具。
""")
public String getLogisticsDetail(String orderId) { ... }7.2 幂等性
并行执行时,如果某个工具在极端情况下被调用两次(重试逻辑导致),后果会不一样。查询类工具天然幂等,但如果有写操作的工具,一定要保证幂等性:
@Tool(description = "提交退款申请。每个订单只能提交一次退款,重复提交会返回已有退款申请ID")
public String submitRefund(String orderId) {
// 先查是否已有退款申请
if (refundExists(orderId)) {
return "该订单已有退款申请,申请ID:" + getExistingRefundId(orderId);
}
// 创建新退款申请
return createRefund(orderId);
}7.3 线程池配置
工具执行用的线程池要和其他业务线程池隔离,避免互相影响:
@Configuration
public class ToolExecutorConfig {
@Bean("toolExecutor")
public ExecutorService toolExecutor() {
// Java 21+ 推荐用虚拟线程,I/O 密集型工具调用非常合适
return Executors.newVirtualThreadPerTaskExecutor();
// Java 17 或更低版本用这个
// return new ThreadPoolExecutor(
// 5, 20, 60L, TimeUnit.SECONDS,
// new LinkedBlockingQueue<>(100),
// new ThreadFactory() {
// private final AtomicInteger counter = new AtomicInteger(0);
// public Thread newThread(Runnable r) {
// Thread t = new Thread(r, "tool-executor-" + counter.incrementAndGet());
// t.setDaemon(true);
// return t;
// }
// }
// );
}
}7.4 成本控制
并行工具调用会减少 LLM 调用次数(从 N+1 次变成 2 次),这在 Token 消耗上是省的。但要注意:每次调用传递的上下文(包括所有工具定义)不变,如果工具定义很长,Token 成本不会减少太多。
实测数据:同一个三工具查询场景,串行模式约消耗 2800 Token(含三次 LLM 调用的上下文),并行模式约消耗 2100 Token。节省约 25%。
7.5 LLM 不一定会并行
这是一个容易被忽视的问题:即使你开启了 parallel_tool_calls=true,LLM 也不保证会并行。如果它判断工具之间有依赖,或者用户的问题逻辑上需要串行处理,它会自动串行。
你无法强制 LLM 并行。你能做的是:
- 在工具描述里明确说明工具是独立的、可并行的
- 在系统提示里提示「当多个工具相互独立时,请同时调用它们」
- 接受 LLM 的决策,它大部分时候是对的
八、与 Spring AI 的 Function Callback 对比
Spring AI 支持两种工具定义方式:注解式(@Tool)和函数式(FunctionCallback)。并行调用两种都支持,但细节有差异。
// 函数式方式,适合动态注册工具
@Bean
public FunctionCallback inventoryTool() {
return FunctionCallback.builder()
.function("getInventory", (String productId) -> {
// 工具实现
return inventoryService.getStock(productId).toString();
})
.description("查询商品库存数量")
.inputType(String.class)
.build();
}函数式方式的好处是可以在运行时动态注册工具,比如根据用户权限决定暴露哪些工具,这在多租户场景下很有用。
九、一个完整的测试用例
@SpringBootTest
class ParallelToolCallTest {
@Autowired
private OrderQueryService orderQueryService;
@Test
void testParallelToolCallPerformance() {
// 这个问题会触发多个独立工具调用
String question = "请查一下订单12345的状态和物流信息,同时告诉我P001商品还有多少库存";
StopWatch watch = new StopWatch();
watch.start();
String result = orderQueryService.queryWithParallelTools(question);
watch.stop();
System.out.println("回答: " + result);
System.out.println("总耗时: " + watch.getTotalTimeMillis() + "ms");
assertThat(result).isNotEmpty();
// 并行模式下应该在 2 秒内完成(串行需要 3 秒以上)
assertThat(watch.getTotalTimeMillis()).isLessThan(2000);
}
@Test
void testDependentToolCallsAreSerial() {
// 这个问题的第二个工具依赖第一个结果,LLM 应该串行处理
String question = "先查订单12345的状态,如果是已发货状态就帮我查物流详情";
// 这里不断言时间,只断言结果正确
String result = orderQueryService.queryWithParallelTools(question);
assertThat(result).isNotEmpty();
}
}十、小结
parallel_tool_use 不是什么高深特性,它的本质就是一个思路:能同时做的事情不要排队。
关键要记住的几点:
- 开启
parallel_tool_calls=true给 LLM 这个能力,但最终决策权在 LLM - 应用层要能正确处理一次响应里多个
tool_calls,并发执行、统一回传 - 工具之间要保持独立,有依赖关系的工具要在描述里说清楚
- 部分失败要有降级处理,不能因为一个工具超时就让整个查询挂起
- Java 21 的虚拟线程在这个场景下非常合适,I/O 等待密集,线程切换开销低
那次优化之后,我顺手把同类的几个查询接口也做了相同处理,P95 延迟从平均 3.2 秒降到了 1.3 秒。用户再也没有投诉说「太慢了」。
这就是工程的价值:不是去研究最前沿的算法,而是把已有的工具用到位。
