工具并行调用原理与实现:CompletableFuture 实战
写 Agent 的时候遇到一个很有意思的场景:LLM 一次推理返回了三个工具调用请求------查数据库、调 API、读文件。如果串行执行,总耗时是三个工具各自耗时之和;如果并行执行,总耗时取决于最慢的那个。
这个优化空间不抓住,用户体验直接拉垮。
串行 vs 并行:一笔简单的账
假设三个工具调用的耗时分别是:
- 查数据库:200ms
- 调外部 API:500ms
- 读本地文件:100ms
串行执行:200 + 500 + 100 = 800ms
并行执行:max(200, 500, 100) = 500ms
省了 300ms,接近 40% 的性能提升。工具越多、耗时差异越大,并行的优势越明显。
#mermaid-svg-HVI11dSTuuc4BxUb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HVI11dSTuuc4BxUb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HVI11dSTuuc4BxUb .error-icon{fill:#552222;}#mermaid-svg-HVI11dSTuuc4BxUb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HVI11dSTuuc4BxUb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HVI11dSTuuc4BxUb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HVI11dSTuuc4BxUb .marker.cross{stroke:#333333;}#mermaid-svg-HVI11dSTuuc4BxUb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HVI11dSTuuc4BxUb p{margin:0;}#mermaid-svg-HVI11dSTuuc4BxUb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HVI11dSTuuc4BxUb .cluster-label text{fill:#333;}#mermaid-svg-HVI11dSTuuc4BxUb .cluster-label span{color:#333;}#mermaid-svg-HVI11dSTuuc4BxUb .cluster-label span p{background-color:transparent;}#mermaid-svg-HVI11dSTuuc4BxUb .label text,#mermaid-svg-HVI11dSTuuc4BxUb span{fill:#333;color:#333;}#mermaid-svg-HVI11dSTuuc4BxUb .node rect,#mermaid-svg-HVI11dSTuuc4BxUb .node circle,#mermaid-svg-HVI11dSTuuc4BxUb .node ellipse,#mermaid-svg-HVI11dSTuuc4BxUb .node polygon,#mermaid-svg-HVI11dSTuuc4BxUb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HVI11dSTuuc4BxUb .rough-node .label text,#mermaid-svg-HVI11dSTuuc4BxUb .node .label text,#mermaid-svg-HVI11dSTuuc4BxUb .image-shape .label,#mermaid-svg-HVI11dSTuuc4BxUb .icon-shape .label{text-anchor:middle;}#mermaid-svg-HVI11dSTuuc4BxUb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HVI11dSTuuc4BxUb .rough-node .label,#mermaid-svg-HVI11dSTuuc4BxUb .node .label,#mermaid-svg-HVI11dSTuuc4BxUb .image-shape .label,#mermaid-svg-HVI11dSTuuc4BxUb .icon-shape .label{text-align:center;}#mermaid-svg-HVI11dSTuuc4BxUb .node.clickable{cursor:pointer;}#mermaid-svg-HVI11dSTuuc4BxUb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HVI11dSTuuc4BxUb .arrowheadPath{fill:#333333;}#mermaid-svg-HVI11dSTuuc4BxUb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HVI11dSTuuc4BxUb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HVI11dSTuuc4BxUb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HVI11dSTuuc4BxUb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HVI11dSTuuc4BxUb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HVI11dSTuuc4BxUb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HVI11dSTuuc4BxUb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HVI11dSTuuc4BxUb .cluster text{fill:#333;}#mermaid-svg-HVI11dSTuuc4BxUb .cluster span{color:#333;}#mermaid-svg-HVI11dSTuuc4BxUb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HVI11dSTuuc4BxUb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HVI11dSTuuc4BxUb rect.text{fill:none;stroke-width:0;}#mermaid-svg-HVI11dSTuuc4BxUb .icon-shape,#mermaid-svg-HVI11dSTuuc4BxUb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HVI11dSTuuc4BxUb .icon-shape p,#mermaid-svg-HVI11dSTuuc4BxUb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HVI11dSTuuc4BxUb .icon-shape .label rect,#mermaid-svg-HVI11dSTuuc4BxUb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HVI11dSTuuc4BxUb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HVI11dSTuuc4BxUb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HVI11dSTuuc4BxUb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 并行执行
数据库 200ms
API 500ms
文件 100ms
串行执行
数据库 200ms
API 500ms
文件 100ms
CompletableFuture:Java 并行的瑞士军刀
Java 8 引入的 CompletableFuture 解决了早期 Future 的痛点------不能链式调用、不能组合、阻塞式获取结果。
核心 API 速览
java
// 异步执行一个有返回值的任务
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return queryDatabase();
});
// 链式转换结果
CompletableFuture<Integer> lengthFuture = future.thenApply(result -> result.length());
// 消费结果(无返回值)
future.thenAccept(result -> System.out.println(result));
// 异常处理
future.exceptionally(ex -> {
log.error("查询失败", ex);
return "默认值";
});
// 超时控制(Java 9+)
future.orTimeout(30, TimeUnit.SECONDS);
allOf:等待所有任务完成
这是并行调用的核心。allOf() 接收多个 CompletableFuture,返回一个新的 CompletableFuture,当所有传入的 future 都完成时它才完成。
坑点来了:allOf() 本身没有返回值,它的泛型是 CompletableFuture<Void>。要拿到每个任务的结果,得逐个 join()。
java
CompletableFuture<String> dbFuture = CompletableFuture.supplyAsync(() -> queryDatabase());
CompletableFuture<String> apiFuture = CompletableFuture.supplyAsync(() -> callExternalApi());
CompletableFuture<String> fileFuture = CompletableFuture.supplyAsync(() -> readFile());
// 等待所有完成
CompletableFuture.allOf(dbFuture, apiFuture, fileFuture).join();
// 收集结果
String dbResult = dbFuture.join();
String apiResult = apiFuture.join();
String fileResult = fileFuture.join();
anyOf:谁先完成用谁
有时候我们只需要最快的那个结果,比如从多个数据源查询,哪个先返回用哪个:
java
CompletableFuture<Object> fastest = CompletableFuture.anyOf(future1, future2, future3);
Object result = fastest.join();
线程池:最容易踩坑的地方
supplyAsync 有两个重载:
java
// 使用默认线程池 ForkJoinPool.commonPool()
supplyAsync(supplier)
// 使用自定义线程池
supplyAsync(supplier, executor)
生产环境必须用自定义线程池,原因有两个:
ForkJoinPool.commonPool()是全局共享的,一个慢任务会阻塞其他所有使用默认池的任务- Spring 的
@Async默认使用SimpleAsyncTaskExecutor,每次创建新线程,高并发下直接 OOM
线程池配置示例
java
@Configuration
public class ThreadPoolConfig {
@Bean("toolExecutorPool")
public ExecutorService toolExecutorPool() {
return new ThreadPoolExecutor(
10, // 核心线程数
20, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(100), // 任务队列
new ThreadFactoryBuilder()
.setNameFormat("tool-executor-%d")
.build(),
new CallerRunsPolicy() // 拒绝策略
);
}
}
拒绝策略选择
当队列满了、线程也到上限了,新任务怎么办?
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛异常 | 不重要的任务 |
| CallerRunsPolicy | 调用者线程执行 | 需要保证任务不丢失 |
| DiscardPolicy | 静默丢弃 | 可丢失的任务 |
| DiscardOldestPolicy | 丢弃队列最老的任务 | 只关心最新任务 |
Agent 场景推荐 CallerRunsPolicy------任务不能丢,降级到调用者线程执行,虽然会阻塞当前线程,但至少保证任务完成。
NVC 项目实战:ToolExecutor 设计
在 NVC 项目中,ToolExecutor 是工具调用的核心组件,负责并行执行 LLM 返回的多个工具调用请求。
整体架构
#mermaid-svg-DeJMsCuwFXtVjdKT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DeJMsCuwFXtVjdKT .error-icon{fill:#552222;}#mermaid-svg-DeJMsCuwFXtVjdKT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DeJMsCuwFXtVjdKT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DeJMsCuwFXtVjdKT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DeJMsCuwFXtVjdKT .marker.cross{stroke:#333333;}#mermaid-svg-DeJMsCuwFXtVjdKT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DeJMsCuwFXtVjdKT p{margin:0;}#mermaid-svg-DeJMsCuwFXtVjdKT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT .cluster-label text{fill:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT .cluster-label span{color:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT .cluster-label span p{background-color:transparent;}#mermaid-svg-DeJMsCuwFXtVjdKT .label text,#mermaid-svg-DeJMsCuwFXtVjdKT span{fill:#333;color:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT .node rect,#mermaid-svg-DeJMsCuwFXtVjdKT .node circle,#mermaid-svg-DeJMsCuwFXtVjdKT .node ellipse,#mermaid-svg-DeJMsCuwFXtVjdKT .node polygon,#mermaid-svg-DeJMsCuwFXtVjdKT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DeJMsCuwFXtVjdKT .rough-node .label text,#mermaid-svg-DeJMsCuwFXtVjdKT .node .label text,#mermaid-svg-DeJMsCuwFXtVjdKT .image-shape .label,#mermaid-svg-DeJMsCuwFXtVjdKT .icon-shape .label{text-anchor:middle;}#mermaid-svg-DeJMsCuwFXtVjdKT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DeJMsCuwFXtVjdKT .rough-node .label,#mermaid-svg-DeJMsCuwFXtVjdKT .node .label,#mermaid-svg-DeJMsCuwFXtVjdKT .image-shape .label,#mermaid-svg-DeJMsCuwFXtVjdKT .icon-shape .label{text-align:center;}#mermaid-svg-DeJMsCuwFXtVjdKT .node.clickable{cursor:pointer;}#mermaid-svg-DeJMsCuwFXtVjdKT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DeJMsCuwFXtVjdKT .arrowheadPath{fill:#333333;}#mermaid-svg-DeJMsCuwFXtVjdKT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DeJMsCuwFXtVjdKT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DeJMsCuwFXtVjdKT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DeJMsCuwFXtVjdKT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DeJMsCuwFXtVjdKT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DeJMsCuwFXtVjdKT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DeJMsCuwFXtVjdKT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DeJMsCuwFXtVjdKT .cluster text{fill:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT .cluster span{color:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DeJMsCuwFXtVjdKT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DeJMsCuwFXtVjdKT rect.text{fill:none;stroke-width:0;}#mermaid-svg-DeJMsCuwFXtVjdKT .icon-shape,#mermaid-svg-DeJMsCuwFXtVjdKT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DeJMsCuwFXtVjdKT .icon-shape p,#mermaid-svg-DeJMsCuwFXtVjdKT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DeJMsCuwFXtVjdKT .icon-shape .label rect,#mermaid-svg-DeJMsCuwFXtVjdKT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DeJMsCuwFXtVjdKT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DeJMsCuwFXtVjdKT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DeJMsCuwFXtVjdKT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LLM 响应
解析工具调用
工具调用 1
工具调用 2
工具调用 N
CompletableFuture
allOf 等待
收集结果
组装响应
核心实现
java
@Component
public class ToolExecutor {
private final ExecutorService executorService;
private static final Duration TOOL_TIMEOUT = Duration.ofSeconds(30);
public ToolExecutor(@Qualifier("toolExecutorPool") ExecutorService executorService) {
this.executorService = executorService;
}
/**
* 并行执行多个工具调用
*/
public List<ToolResult> executeParallel(List<ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Collections.emptyList();
}
// 每个工具调用独立包装为 CompletableFuture
List<CompletableFuture<ToolResult>> futures = toolCalls.stream()
.map(call -> CompletableFuture.supplyAsync(
() -> executeSingleTool(call),
executorService
).orTimeout(TOOL_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
.exceptionally(ex -> handleToolError(call, ex)))
.collect(Collectors.toList());
// 等待所有完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 收集结果
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
/**
* 执行单个工具调用
*/
private ToolResult executeSingleTool(ToolCall call, String userId, String conversationId) {
// 每个调用独立的上下文,避免线程安全问题
ToolContext context = new ToolContext(userId, conversationId);
try {
Tool tool = toolRegistry.getTool(call.getName());
Object result = tool.execute(call.getArguments(), context);
return ToolResult.success(call.getId(), result);
} catch (Exception e) {
return ToolResult.error(call.getId(), e.getMessage());
}
}
/**
* 错误处理:超时、异常等
*/
private ToolResult handleToolError(ToolCall call, Throwable ex) {
if (ex instanceof TimeoutException) {
log.warn("工具执行超时: {}", call.getName());
return ToolResult.timeout(call.getId());
}
log.error("工具执行异常: {}", call.getName(), ex);
return ToolResult.error(call.getId(), ex.getMessage());
}
}
关键设计点
1. 独立的 ToolContext
每个工具调用创建独立的 ToolContext,不共享状态。这是因为并行执行时,多个线程同时操作同一个上下文会出问题。独立上下文虽然多了一点内存开销,但换来的是线程安全。
2. 单工具超时 30 秒
设置合理的超时时间很重要。太短会导致正常工具调用失败,太长会导致用户等待过久。30 秒是我们根据实际工具调用的 P99 耗时定的。
3. 异常不中断整体
一个工具调用失败不应该影响其他工具。exceptionally 把异常转换为 ToolResult.error,保证所有工具都能返回结果。
@Async 的适用场景
不是所有异步场景都适合用 CompletableFuture。在 NVC 项目中,EvaluationTriggerHook 用 CompletableFuture.runAsync 做 fire-and-forget:
java
// EvaluationTriggerHook - evaluate_nvc 成功后异步触发 Wiki 生成
public class EvaluationTriggerHook implements ToolHook {
@Override
public void afterToolCall(ToolCall call, ToolResult result, ToolContext context) {
if ("evaluate_nvc".equals(call.getName()) && result.isSuccess()) {
CompletableFuture.runAsync(() -> {
wikiWriteTool.generateFromEvaluation(result, context);
}, executorService);
}
}
}
这些场景的特点是:调用者不需要返回值,不需要等待完成,失败了记录日志就行。Wiki 生成可能需要 5-10 秒,但用户不需要等它完成。
线程池监控
生产环境必须监控线程池状态,否则出了问题只能猜:
java
@Component
public class ThreadPoolMonitor {
@Autowired
@Qualifier("toolExecutorPool")
private ThreadPoolExecutor executor;
@Scheduled(fixedRate = 60000)
public void logPoolStatus() {
log.info("线程池状态 - 活跃线程: {}, 池大小: {}, 队列积压: {}, 完成任务数: {}",
executor.getActiveCount(),
executor.getPoolSize(),
executor.getQueue().size(),
executor.getCompletedTaskCount());
}
}
如果发现队列积压持续增长,说明线程池配置不够用,需要调整参数或者排查是否有慢任务阻塞。
常见问题排查
1. 任务不执行
检查线程池是否被 shutdown,或者队列是否满了。CallerRunsPolicy 下,队列满时任务会由调用者线程执行,看起来像是"不异步了"。
2. 结果顺序乱了
CompletableFuture 不保证顺序。如果需要按原始顺序返回结果,要在 toolCalls 和 futures 之间维护索引映射。
3. 内存泄漏
忘记设置超时的 CompletableFuture 可能永远不会完成,一直占用内存。一定要加 orTimeout 或 completeOnTimeout。
4. 上下文丢失
异步执行时,ThreadLocal 里的数据拿不到。解决方案是在提交任务前捕获上下文,在任务内部手动设置。
实测效果
上线并行调用后,NVC 项目的工具调用响应时间从平均 1.2 秒降到 0.6 秒,P99 从 3 秒降到 1.5 秒。用户体感明显流畅了。
写在最后
工具并行调用不是什么高深的技术,但细节很多:线程池配置、超时控制、异常处理、上下文隔离,每个环节都可能出问题。把这些细节处理好,Agent 的响应速度和稳定性才有保障。
CompletableFuture 是 Java 并行编程的利器,API 设计得很优雅。配合合理的线程池配置,能优雅地解决 Agent 工具调用的并行问题。