025、并行工具调用实战:多工具同时发起、等待策略、错误隔离与性能优化
上周在调试一个Claude Code自动化部署流水线时,遇到了一个让人头疼的问题——Agent调用三个工具(代码扫描、单元测试、依赖检查)时,居然串行执行,整个流程耗时超过8分钟。更离谱的是,其中一个工具超时后,整个任务直接崩溃,连已经完成的扫描结果都没保存。这种“一损俱损”的设计在工程化场景下简直是灾难。
今天这篇笔记,就围绕并行工具调用的几个核心痛点展开:如何让多个工具同时跑起来、怎么处理等待与超时、如何隔离单个工具的错误、以及最终的性能调优。不扯理论,直接上实战。
多工具同时发起:别让Agent傻等
Claude Code默认的Tool Call机制是顺序的——Agent调用一个工具,拿到结果,再决定下一步。这在简单场景下没问题,但当你需要同时获取代码质量报告、测试覆盖率、依赖漏洞列表时,串行就是浪费生命。
核心思路:在System Prompt中明确告诉Agent“你可以同时发起多个工具调用”,并在工具定义中标注哪些是“可并行”的。
我常用的做法是在工具描述里加一个parallel_safe标记:
# 这里踩过坑:如果不显式声明,Agent会默认串行tools=[{"name":"run_code_scan","description":"执行代码静态扫描,parallel_safe=true,可与其他工具同时调用","parameters":{...}},{"name":"run_unit_tests","description":"执行单元测试,parallel_safe=true,注意测试环境隔离","parameters":{...}}]然后在System Prompt里写一段类似这样的指令:
当你需要获取多个独立数据源的信息时,可以同时发起多个工具调用。 例如:代码扫描、单元测试、依赖检查可以并行执行,因为它们之间没有数据依赖。 注意:并行调用的工具必须满足“无副作用冲突”和“资源不竞争”两个条件。实际测试下来,三个工具并行比串行快了将近3倍。但有个坑——Claude Code的上下文窗口有限,如果同时返回大量数据,Agent可能会“看不过来”,导致后续决策质量下降。后面会讲怎么处理。
等待策略:别让一个慢工具拖死全队
并行调用最怕什么?怕某个工具卡住。比如依赖检查工具去拉取NPM registry,网络抖动导致30秒没响应,其他两个工具早就跑完了,整个流程却还在等。
解决方案:给每个工具设置独立的超时时间,并且实现“部分结果可用”机制。
我实现了一个简单的ParallelToolExecutor,核心逻辑是:
# 别这样写:用一个全局超时控制所有工具# 正确做法:每个工具独立超时asyncdefexecute_parallel(tool_calls,timeouts):tasks=[]forcallintool_calls:# 每个工具绑定自己的超时,互不影响task=asyncio.create_task(asyncio.wait_for(call.execute(),timeout=timeouts.get(call.name,30)# 默认30秒,可单独配置))tasks.append(task)# 这里踩过坑:用gather会等所有任务完成,包括超时的# 改用as_completed,谁先完成谁先返回forcompletedinasyncio.as_completed(tasks):try:result=awaitcompleted# 立即处理已完成的工具结果,不用等其他人process_partial_result(result)exceptasyncio.TimeoutError:# 超时的工具单独处理,不影响其他结果handle_timeout(call.name)关键点在于asyncio.as_completed——它允许你按完成顺序处理结果,而不是等所有工具都跑完。这样即使某个工具超时,你已经拿到了其他工具的结果,可以继续往下走。
超时时间怎么设?我的经验是:根据工具的历史P99耗时来定,比如代码扫描通常3-5秒,设10秒;依赖检查可能波动大,设30秒。别用固定值,也别太宽松,否则并行就失去了意义。
错误隔离:别让一个工具的错误污染全局
这是最容易被忽视的点。很多人在并行调用时,一个工具抛异常,整个任务就炸了。更糟糕的是,异常信息可能被错误地传递给其他工具,导致连锁反应。
错误隔离的核心原则:每个工具的错误应该被“包裹”起来,而不是“传播”出去。
我设计了一个ToolResult包装类:
# 别这样写:直接抛异常# raise ToolException("扫描失败")# 正确做法:返回一个包含错误信息的Result对象classToolResult:def__init__(self,name,status,data=None,error=None):self.name=name self.status=status# "success", "error", "timeout"self.data=data self.error=errordefis_ok(self):returnself.status=="success"defget_or_default(self,default):returnself.dataifself.is_ok()elsedefault# 调用时统一捕获异常,转为Resultasyncdefsafe_execute(tool_call,timeout):try:result=awaitasyncio.wait_for(tool_call.execute(),timeout)returnToolResult(tool_call.name,"success",data=result)exceptasyncio.TimeoutError:returnToolResult(tool_call.name,"timeout",error="超时")exceptExceptionase:# 这里踩过坑:不要直接log.error然后继续,要保留原始错误信息returnToolResult(tool_call.name,"error",error=str(e))这样处理后,Agent拿到的是一个结果列表,其中可能有成功的、超时的、报错的。Agent可以根据这些状态做决策——比如“代码扫描成功,测试成功,依赖检查超时,先继续部署,依赖检查稍后重试”。
还有一个容易被忽略的点:错误信息不要包含敏感数据。比如依赖检查报错时,可能把API密钥泄露在错误堆栈里。我习惯在safe_execute里加一层错误信息清洗,只保留关键信息。
性能优化:从“能跑”到“跑得快”
并行调用不是简单地把工具扔进线程池就完事了。实际工程中,资源竞争、上下文切换、数据序列化都会成为瓶颈。
第一个优化点:控制并发度。别一股脑把所有工具都并行。我遇到过同时启动8个工具,结果CPU被打满,每个工具反而比串行还慢。经验值是:IO密集型工具(如网络请求、文件读写)可以开5-8个并发,CPU密集型工具(如代码编译、静态分析)控制在2-3个。
第二个优化点:复用连接。如果多个工具都访问同一个数据库或API,别每个工具都新建连接。我习惯在工具调用层做一个连接池,比如:
# 别这样写:每个工具自己建连接# 正确做法:共享连接池classToolContext:def__init__(self):self.db_pool=create_connection_pool(max_size=5)self.http_session=aiohttp.ClientSession()asyncdefclose(self):awaitself.db_pool.close()awaitself.http_session.close()# 所有工具共享同一个contextcontext=ToolContext()results=awaitexecute_parallel(tools,context)第三个优化点:结果缓存。有些工具的结果在短时间内是稳定的,比如代码扫描结果,5分钟内不会变。我加了一层内存缓存,相同参数的工具调用直接返回缓存结果,避免重复执行。注意缓存要有TTL,别用过期数据。
第四个优化点:数据压缩。工具返回的结果可能很大,比如代码扫描报告可能有几万行。在Agent上下文窗口有限的情况下,大结果会挤占其他信息的空间。我习惯在工具返回前做一次摘要——只返回关键指标和异常列表,完整报告存到文件或对象存储里,Agent需要时再按需加载。
个人经验性建议
别迷信“全并行”。有些工具之间有隐式依赖,比如“代码扫描”和“单元测试”可能都依赖编译产物,同时跑会导致文件锁冲突。先做依赖分析,再决定哪些可以并行。
超时时间要动态调整。我写了一个简单的自适应算法:记录每个工具最近10次调用的耗时,取P95作为下次超时的基准,再加一个安全余量。这样网络波动时自动放宽,稳定时自动收紧。
错误隔离不是吞错误。很多人的错误隔离就是catch异常然后print一下,等于没隔离。真正的隔离是:错误信息要保留、要可追溯、要能触发重试或降级策略。我习惯把每个工具的错误写到一个独立的错误日志里,同时更新一个全局状态机,让Agent知道“这个工具有问题,后续决策要避开它”。
性能优化要量化。别凭感觉调优。我每次改完并行策略,都会跑一次基准测试,记录总耗时、各工具耗时、CPU/内存峰值。只有数据说话,才能避免“优化了个寂寞”。
最后一条,也是最重要的:并行工具调用的设计,最终要服务于Agent的决策质量。如果并行导致Agent信息过载、决策混乱,那还不如串行。我见过一个团队把10个工具并行,结果Agent的回复质量直线下降,因为上下文里塞满了无关数据。少即是多,只并行那些真正独立且必要的信息源。
这篇笔记就到这里。下一篇会讲“工具调用的幂等性与重试机制”,这是工程化落地的另一个大坑。