【撕开黑盒学大模型】告别大模型连环网络等待:用 DAG 异步调度实现 Agent 多工具并发执行

告别大模型连环网络等待:用 DAG 异步调度实现 Agent 多工具并发执行

配套代码:../v3_rewoo/

代码地址:撕开黑盒学大模型-从白盒状态机演进到工业级Agent框架

本文目标:用一个克制版 Plan-then-Execute Demo 说明如何把串行工具调用改造成 DAG 异步调度。


文章目录

  • [告别大模型连环网络等待:用 DAG 异步调度实现 Agent 多工具并发执行](#告别大模型连环网络等待:用 DAG 异步调度实现 Agent 多工具并发执行)
    • [1. 问题与背景](#1. 问题与背景)
    • [2. 计划文本:把隐式步骤显式化](#2. 计划文本:把隐式步骤显式化)
    • [3. 依赖解析:找出哪些节点可以并发](#3. 依赖解析:找出哪些节点可以并发)
    • [4. 异步执行与超时 Observation](#4. 异步执行与超时 Observation)
    • [5. 运行与验证](#5. 运行与验证)
    • [6. 时间线可视化](#6. 时间线可视化)
    • [7. 风险边界与生产化迁移](#7. 风险边界与生产化迁移)
    • [8. 总结](#8. 总结)
    • [9. 环境与复现范围](#9. 环境与复现范围)
    • 参考资料

1. 问题与背景

ReAct 的优势是简单:模型每一步决定下一步做什么,程序执行工具,然后把 Observation 写回上下文。

但这种模式在多工具任务里有明显问题。假设任务需要:

  • 查询 GPU 价格;
  • 查询杭州天气;
  • 查询库存状态;
  • 汇总出门采购建议。

传统串行模式可能是:

text 复制代码
模型判断 -> 查价格 -> 模型判断 -> 查天气 -> 模型判断 -> 查库存 -> 模型汇总

如果每一步都涉及网络等待,总耗时会不断累加。很多工具之间其实没有依赖关系,却被迫串行。

v3_rewoo/ 演示的是一种简化版 ReWOO 思路:先规划,再执行。Planner 一次性生成包含依赖关系的计划,Scheduler 再根据依赖图并发执行无依赖节点。

2. 计划文本:把隐式步骤显式化

planner.py 生成的计划文本如下:

text 复制代码
E1: fetch_price[GPU]
E2: fetch_weather[杭州]
E3: unstable_inventory[GPU]
E4: summarize[结合 #E1、#E2、#E3 给出出门采购建议]

这里有两个关键点:

  1. 每个步骤都有唯一 ID,例如 E1E2
  2. 后续步骤可以用 #E1 引用前置步骤结果。

parser.py 负责把计划文本解析成结构化 PlanStep

python 复制代码
PLAN_LINE_RE = re.compile(
    r"^(?P<step_id>E\d+):\s*(?P<tool>\w+)\[(?P<argument>.*)\]\s*$"
)

这一步看起来简单,但很重要。只要计划文本被解析成结构化数据,后续就可以做依赖分析、并发调度、超时控制和可视化。

3. 依赖解析:找出哪些节点可以并发

scheduler.py 中用正则查找参数里的依赖占位符:

python 复制代码
DEPENDENCY_RE = re.compile(r"#(?P<step_id>E\d+)")

def dependencies(step: PlanStep) -> set[str]:
    return set(DEPENDENCY_RE.findall(step.argument))

对于上面的计划:

  • E1 没有依赖;
  • E2 没有依赖;
  • E3 没有依赖;
  • E4 依赖 E1E2E3

所以 Scheduler 可以先并发执行 E1E2E3。等它们都有结果后,再执行 E4

参数替换由 resolve_argument() 完成:

python 复制代码
def resolve_argument(argument: str, results: dict[str, str]) -> str:
    for step_id, value in results.items():
        argument = argument.replace(f"#{step_id}", value)
    return argument

这就是 DAG 调度的核心:不是让模型一步一步等,而是把依赖关系显式化。

4. 异步执行与超时 Observation

并行执行用的是 asyncio.gather

python 复制代码
completed = await asyncio.gather(*(run_step(step) for step in ready))

每个工具调用都包了一层超时控制:

python 复制代码
async def call_tool(step: PlanStep, argument: str, timeout: float) -> str:
    return await asyncio.wait_for(TOOLS[step.tool](argument), timeout=timeout)

如果工具超时,不直接让整个任务崩溃,而是返回错误 Observation:

python 复制代码
except asyncio.TimeoutError:
    value = f"ERROR: {step.tool} timed out after {timeout}s"
    return step.step_id, value

这体现了一个重要工程原则:Agent 的工具失败应该变成可见事实,而不是吞掉或直接中断。后续节点可以看到这个失败,并在最终回答中说明限制。

5. 运行与验证

运行:

powershell 复制代码
python v3_rewoo\benchmark.py

一次验证输出如下:

text 复制代码
serial ReAct-like execution: 1.619s
  E1: GPU 预算价格区间已获取
  E2: 杭州 小雨,建议带伞
  E3: ERROR: unstable_inventory timed out after 0.6s
  E4: 综合结论:结合 GPU 预算价格区间已获取、杭州 小雨,建议带伞、ERROR: unstable_inventory timed out after 0.6s 给出出门采购建议

parallel ReWOO-like execution: 0.820s
  E1: GPU 预算价格区间已获取
  E2: 杭州 小雨,建议带伞
  E3: ERROR: unstable_inventory timed out after 0.6s
  E4: 综合结论:结合 GPU 预算价格区间已获取、杭州 小雨,建议带伞、ERROR: unstable_inventory timed out after 0.6s 给出出门采购建议

这个结果说明两件事:

  1. 并发调度确实降低了端到端等待;
  2. 超时工具没有阻塞最终汇总,而是作为错误 Observation 进入结果。

注意,具体耗时会随机器负载略有变化,文章中不应该把某一次数字写成绝对性能结论。更稳妥的表述是:在本 demo 的模拟延迟条件下,并行版本明显短于串行版本。

这组耗时来自 worker.py 中的确定性模拟延迟:

工具 模拟耗时 调度含义
fetch_price 0.4s 可独立执行
fetch_weather 0.4s 可独立执行
unstable_inventory 0.8s,但被 0.6s 超时截断 失败应回写 Observation
summarize 0.2s 必须等待 E1E2E3

因此,串行版本接近 0.4 + 0.4 + 0.6 + 0.2 = 1.6s,并行版本接近 max(0.4, 0.4, 0.6) + 0.2 = 0.8s。这不是泛化性能承诺,而是用可控延迟验证"无依赖节点可以重叠执行"这个调度事实。

6. 时间线可视化

运行后会生成:

text 复制代码
v3_rewoo/trace.json

打开:

text 复制代码
v3_rewoo/visualization.html

可以看到串行和并行两组事件:

  • start;
  • success;
  • timeout;
  • 每个事件的 timestamp;
  • 每个节点的工具名和参数。

时间线对理解调度非常重要。没有时间线时,读者只能看到总耗时;有时间线后,读者能看到哪些节点重叠执行,哪些节点等待依赖。

运行后可以直接检查 trace.json,确认并行分支不是口头描述:

json 复制代码
{
  "runs": [
    { "label": "serial ReAct-like execution", "elapsed": 1.632 },
    { "label": "parallel ReWOO-like execution", "elapsed": 0.809 }
  ],
  "timeline": [
    { "runner": "parallel", "step_id": "E1", "event": "start" },
    { "runner": "parallel", "step_id": "E2", "event": "start" },
    { "runner": "parallel", "step_id": "E3", "event": "start" },
    { "runner": "parallel", "step_id": "E3", "event": "timeout" },
    { "runner": "parallel", "step_id": "E4", "event": "start" }
  ]
}

这里最值得关注的不是加速倍率,而是事件顺序:并行版本里 E1E2E3 同时 start,E4 在依赖节点结束后才 start。这说明调度器确实按依赖图执行,而不是简单把所有工具一起扔进 gather

7. 风险边界与生产化迁移

当前实现是克制版 Plan-then-Execute,不包含复杂生产能力。它刻意只保留计划解析、依赖分析、并发执行、超时 Observation 和时间线,避免把调度核心淹没在框架细节里。

生产环境至少还要补齐这些边界:

边界 当前 Demo 生产系统需要补齐
循环依赖 发现无 ready 节点后抛错 在入队前校验 DAG,给 Planner 反馈可修复错误
工具失败 转成 ERROR: ... Observation 区分可重试、不可重试、可降级、需人工介入
超时控制 每个工具统一 0.6s 按工具 SLA 配置超时、重试次数和熔断策略
取消传播 未实现 用户取消或上游失败时,取消未完成分支并清理资源
幂等控制 未实现 为写操作增加 request id、去重键和补偿逻辑
持久化 只写 trace.json checkpoint、任务状态机、恢复执行、审计日志
可观测性 本地时间线 trace id、span、指标、错误分类、耗时分布

迁移到工业级 Agent 框架时,不要只问"能不能并发"。更关键的问题是:并发分支失败后怎么恢复?工具是否幂等?超时结果是否应该参与最终总结?状态是否能从 checkpoint 恢复?这些问题决定了调度器能不能进生产。

一个更稳的落地路径是:

  1. 先把计划解析成结构化 DAG,不允许隐式字符串依赖到处散落。
  2. 在调度前做 DAG 校验,阻断循环依赖、未知工具和未定义变量。
  3. 为每个工具声明超时、重试、幂等、是否可降级。
  4. 把每次执行写入 trace,用于复盘"哪个节点等待了谁"。
  5. 再接入持久化 checkpoint,支持失败恢复和人工审计。

本文 demo 只完成第 1 到第 4 步的最小闭环。它适合作为理解 ReWOO / DAG 调度的白盒模型,不应该直接当成生产编排引擎。

8. 总结

ReAct 适合建立 Agent 工具调用认知,但它天然偏串行。面对多工具任务,可以把"模型逐步决定"改造成"模型先规划,调度器再执行"。

DAG 异步调度的关键不是 asyncio.gather 这一行代码,而是四个工程动作:

  • 把计划结构化;
  • 解析依赖;
  • 并发执行无依赖节点;
  • 把失败转成 Observation。

第 3 期的重点不是宣称 Plan-then-Execute 一定优于 ReAct,而是给读者一套可观察的判断方法:看计划是否结构化,看依赖是否显式,看无依赖节点是否重叠执行,看失败是否进入最终上下文。理解这套机制后,再看 LangGraph 的并行分支、持久化执行或更复杂 Agent 编排,就会更容易判断框架能力边界。

9. 环境与复现范围

本文配套代码是一个无第三方服务依赖的教学 Demo,用于复现 DAG 调度和超时 Observation,不依赖真实网络 API。

项目 说明
操作系统 Windows 10/11、macOS、Linux 均可
Python 建议 Python 3.10+
第三方依赖 无,使用 Python 标准库 asynciojsonretime
运行目录 仓库根目录或 v3_rewoo/ 目录
输出文件 v3_rewoo/trace.json
可视化页面 v3_rewoo/visualization.html

如果从仓库根目录运行:

powershell 复制代码
python v3_rewoo\benchmark.py

如果已经进入 v3_rewoo/ 目录运行:

powershell 复制代码
python benchmark.py

运行后会刷新 trace.json。再打开 visualization.html,可以看到串行和并行两组时间线。


参考资料


下一篇:小z疯狂码字ing...

感谢阅读,记得点赞、关注、收藏,欢迎各位评论区交流!!!