Agent 并行工具执行 —— 让多个工具同时干活,而非排队等

前置阅读:Agent 容错机制 | 系列目录 核心增量:run_agent_with_trace 中工具执行段改写(~50 行净增)


一、问题:工具在排队,Agent 在空转

前几篇文章构建的 Agent 有一个隐含的性能瓶颈------多个工具是串行执行的。当 LLM 一次性要求调用多个独立工具时:

scss 复制代码
LLM → 要查三个城市的天气:北京、上海、广州

串行执行(之前)
  search_web("北京") → 等 0.5s → 结果
  search_web("上海") → 等 0.5s → 结果
  search_web("广州") → 等 0.5s → 结果
  总耗时: 1.5s

并行执行(本篇)
  search_web("北京") ─┐
  search_web("上海") ─┼─ 同时跑 → 0.5s 全部完成
  search_web("广州") ─┘
  总耗时: 0.5s

这不是理论优化------当 LLM 决定同时调用 5 个独立的搜索、计算或文件读取工具时,串行意味着用户要多等 4 倍的时间。更糟糕的是,一个慢工具会阻塞后面所有快工具。


二、核心思路:线程池 + 批量提交 + 按完成顺序收集

flowchart LR subgraph OLD[串行模式] O1[search_web 北京 0.5s] --> O2[search_web 上海 0.5s] O2 --> O3[search_web 广州 0.5s] O3 --> O4[总耗时 1.5s] end subgraph NEW[并行模式] N1[search_web 北京 0.5s] N2[search_web 上海 0.5s] N3[search_web 广州 0.5s] N1 --> N4[总耗时 0.5s] N2 --> N4 N3 --> N4 end

三个关键决策:

  1. 线程池而非 asyncio ------ 工具函数是同步的,线程池最自然,且与已有 call_with_timeout 完美兼容
  2. 按完成顺序收集 ------ as_completed() 让快的工具先返回,不等的工具也不阻塞
  3. final_output 特殊处理 ------ 先到但不返回,等其他工具跑完

三、实现

python 复制代码
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_calls)) as executor:

    # Step 1: 提交所有工具到线程池
    future_map = {}
    for tc in tool_calls:
        name = tc["function"]["name"]
        args = json.loads(tc["function"]["arguments"])
        future = executor.submit(
            call_with_timeout,              # ← 复用已有的超时包装
            active_tool_map[name],
            kwargs=args, timeout=30,
        )
        future_map[future] = {"name": name, "args": args, "tc": tc}

    # Step 2: 按完成顺序收集结果
    tool_result_spans = []
    final_output_found = None

    for future in concurrent.futures.as_completed(future_map):
        meta = future_map[future]
        name, args, tc = meta["name"], meta["args"], meta["tc"]
        try:
            result = future.result()
        except Exception as e:
            result = f"工具执行错误: {e}"

        # final_output:记录但不立即返回
        if name in OUTPUT_TOOL_NAMES:
            final_output_found = {"result": result, "tc": tc}
            tracer.log_tool_call(name, args, result, ...)
            continue

        tracer.log_tool_call(name, args, result, ...)
        tool_result_spans.append((tc["id"], name, result))

    # Step 3: final_output 到达 → 返回
    if final_output_found:
        messages.append({"role": "assistant", "content": ...})
        return final_output_found["result"]

    # Step 4: 追加工具结果到 messages
    for tc_id, name, result in tool_result_spans:
        messages.append({"role": "tool", "tool_call_id": tc_id, "content": result})

四、final_output 的特殊处理

在并行模式下,final_output 可能与其他工具同时被调用。如果先于其他工具完成就立即返回,会导致并发工具的消息未被记录,下一次对话丢失上下文。

sequenceDiagram participant LLM as LLM participant EX as ThreadPoolExecutor participant SW as search_web participant FO as final_output LLM->>EX: 提交 3 个工具 EX->>SW: search_web("北京") EX->>SW: search_web("上海") EX->>FO: final_output({...}) SW-->>EX: "北京结果" (0.3s) FO-->>EX: "结构化答案" (0.4s) Note over EX: final_output 到了但不返回 SW-->>EX: "上海结果" (0.8s) Note over EX: 所有工具完成,final_output 返回 EX-->>LLM: 结构化答案

五、与串行模式的对比

维度 串行 并行
执行方式 for tc in tool_calls: result = ... executor.submitas_completed
总耗时 N × 单次耗时 max(单次耗时)
慢工具影响 阻塞后续所有工具 不阻塞其他工具
结果顺序 按 LLM 输出顺序 按完成先后顺序
代码复杂度 简单 需 future_map + as_completed
final_output 立刻返回 等其他工具跑完才返回

六、设计精要

6.1 最大并行度 = 工具数量

max_workers=len(tool_calls) ------ LLM 一次调几个工具就开几个线程,不预设上限也不浪费资源。

6.2 复用 call_with_timeout

两层线程池各司其职:外层管理工具间并行,内层管理单个工具超时。并行化没有重新实现超时逻辑。

6.3 结果顺序不影响 LLM 推理

每个 tool 消息都带有 tool_call_id,LLM 通过 id 关联而非位置。

6.4 零侵入前序代码

只影响 run_agent_with_trace 中一段代码。SkillManager、AgentTracer、ContextManager、PersistenceManager、chat_loop 完全无感。


七、完整演进路径

yaml 复制代码
Phase 1: 基础 Agent               (~50 行)
Phase 2: 可观测 + 压缩 + 持久化     (+~200 行)
Phase 3: Skill 渐进加载            (+~150 行)
Phase 4: 多轮对话循环              (+~120 行)
Phase 5: 流式输出                  (+~80 行)
Phase 6: 重试 + 超时               (+~128 行)
Phase 7: 结构化输出                (+~140 行)
Phase 8: 并行工具执行              (+~50 行)  ← 本文

不改架构、不加新文件,只是把串行 for 升级为线程池并行。


推荐学习资源