第14章:DAG 工作流:解开任务的“死结”
如果说“并行”是让一群人一起冲,那么 DAG(有向无环图)就是指挥官手中的作战地图——它规定了谁先冲,谁掩护,谁最后打扫战场。没有这张图,你的 Agent 军团只是一群乌合之众。
上一章我们讲了多 Agent 编排的基础:要么大家排队做(串行),要么大家一起上(并行)。 但在现实世界中,大部分商业任务既不是单纯的“一条线”,也不是单纯的“一窝蜂”。
看一个真实的商业分析场景: 老板让你分析“特斯拉 2024 年的竞争力”。 这包含三个看似独立的子任务,实则暗藏玄机:
- 搜财报:查特斯拉去年的营收和利润。
- 搜竞品:查比亚迪和 Rivian 的数据。
- 写报告:对比两者的优劣势,给出结论。
你怎么调度?
- 全串行? 太慢了。搜财报和搜竞品明明是两件互不干扰的事,为什么要像接力赛一样等?这会浪费宝贵的响应时间。
- 全并行? 不行。如果你让“写报告”的 Agent 和前两个一起开工,它会一脸懵逼:“老板,数据还没查到,你让我分析什么?”对着空气分析,只能产生幻觉。
这时候,我们需要引入 DAG(Directed Acyclic Graph,有向无环图) 。 它是解决复杂依赖关系的终极武器,也是区分“脚本小子”和“系统架构师”的分水岭。
01. 什么是 DAG?(给产品经理的图论课)
别被这个数学名词吓到了。我们把它拆解开来,它其实描述了世间万物运转的基本规律:
- 有向(Directed) :时间是单向流动的,任务也是。A 做完才能做 B,箭头只能从 A 指向 B。如果你想时光倒流,那是科幻片。
- 无环(Acyclic) :不能有死循环。A 等 B,B 等 C,C 又回头等 A —— 这就是著名的死锁(Deadlock) 。一旦形成环,三个任务都会永远卡在“等待”状态,直到服务器重启。
DAG 的本质就是一张“依赖关系网”。 它不关心任务的具体内容,只关心“谁在谁前面”。
形象的比喻:盖房子
想象一下建筑工地的流程,这就是一个完美的 DAG:
-
地基(任务 A) :这是起点,必须最先做。没有地基,一切免谈。
-
砌墙(任务 B) 和 布线(任务 C) :
- 它们都依赖地基(A)。
- 但它们之间互不依赖。砌墙的师傅和拉电线的师傅可以同时干活(并行),互不干扰,极大提升效率。
-
封顶(任务 D) :
- 它是汇聚点。
- 必须等墙砌好(B 完成)、线布好(C 完成)之后,才能封顶。缺一不可,否则房子就塌了。
这就是 DAG 工作流:既有串行的严谨,又有并行的效率。
02. 三种执行模式:从简单到复杂
在 Shannon 的编排器中,我们不会让用户手写复杂的图算法。我们根据任务的“拓扑结构”,自动选择三种执行模式:
模式 A:并行蜂群(Parallel)
- 拓扑结构:所有节点互不相连,像散落的弹珠。
- 适用场景:搜集 5 家公司的 Logo、生成 10 个不同的创意标题。
- 核心逻辑:
go func()全部启动,谁先回来谁赢。这是最快的模式,但也最容易把系统打挂。
模式 B:串行接力(Sequential)
- 拓扑结构:一条直线(A -> B -> C),像糖葫芦。
- 适用场景:写代码 -> 运行代码 -> 根据报错修复 Bug。
- 核心逻辑:上一步的输出(Output)是下一步的输入(Input)。如果第一步就错了,后面做得再快也是错的。
模式 C:混合 DAG(Hybrid)
- 拓扑结构:网状,有分叉,有汇合。
- 适用场景:绝大多数复杂的商业分析、长文本生成、多模态任务。
- 核心逻辑: “智能等待” 。它能自动识别哪些可以“抢跑”,哪些必须“原地待命”。
03. 核心算法:如何实现“智能等待”?
DAG 调度的核心难点在于:C 任务怎么知道 A 和 B 做没做完?
如果你写一个 while(true) 循环去不断轮询(Polling)状态,CPU 会被你跑满,这是最低效的做法。 Shannon 采用了 增量检查机制,配合事件驱动。
调度器逻辑(伪代码深度解析)
def execute_dag(tasks):
completed = set() # 已完成的任务 ID 集合
# 只要还有任务没做完,就继续循环
while len(completed) < len(tasks):
# 1. 扫描所有还没做的任务(寻找“入度为0”的节点)
for task in tasks:
if task.is_done: continue # 做过的跳过
# 2. 【关键】检查它的依赖是否都满足了
# 比如任务 C 依赖 [A, B],系统会去 completed 集合里查 A 和 B 在不在
dependencies = task.dependencies
if all(d in completed for d in dependencies):
# 3. 依赖都满足了?开工!
# 这里不是阻塞执行,而是丢进线程池异步跑
start_async(task)
# 4. 智能挂起:等待任意一个任务完成信号
# 这里使用 select/epoll 机制,而不是死循环空转
# 一旦有任务跑完,立刻唤醒调度器,把它的 ID 加入 completed 集合
newly_finished = wait_for_any_task_finish()
completed.add(newly_finished.id)
这种机制保证了:能并行的绝不排队(最大化吞吐),该等待的绝不抢跑(保证正确性)。
04. 避免“交通拥堵”:信号量控制
当你用 DAG 并行启动任务时,很容易开心过头。 比如任务分解出了 50 个子任务(例如搜 50 个州的法律条文),DAG 分析发现它们彼此独立,都可以并行。 于是编排器瞬间发起了 50 个 HTTP 请求给 LLM。
后果是很严重的:
- 触发限流(429 Too Many Requests) :API 提供商(如 OpenAI)会以为你在进行 DDoS 攻击,直接封禁你的 Key。
- 费用爆炸:一秒钟烧掉 100 美元,你的信用卡会哭泣。
- 系统崩溃:本地网络连接数耗尽。
解决方案:信号量(Semaphore) 你可以把它想象成 银行的柜台窗口。 虽然大厅里(DAG 图中)有 50 个人在排队,大家都很急,但窗口(最大并发数)只有 5 个。
- 拿号(Acquire) :只有拿到“令牌”的任务才能去窗口办事。
- 等待(Block) :没有令牌的任务,即使依赖都满足了,也得在黄线外等着。
- 归还(Release) :办完事的人离开窗口,把令牌还回来,下一个任务补位。
// 限制最大并发数为 5
semaphore = NewSemaphore(5)
for task in ready_tasks:
semaphore.Acquire() // 拿号,没有号就阻塞等待
go func() {
defer semaphore.Release() // 办完事,一定要记得还号!
execute(task)
}()
05. 常见陷阱与避坑指南
在实际构建 DAG 系统时,有三个坑是新手必踩的。
坑 1:隐形死锁(The Invisible Loop)
现象:A 依赖 B,B 依赖 A。或者更隐蔽的:A->B->C->A。 后果:程序挂起,永远不结束。调度器在等任务完成,任务在等依赖满足,形成了逻辑闭环。 解法:在构建 DAG 图时(Task 入库前),必须运行 环检测算法(如 Kahn 算法) 。如果发现有环,直接报错拒绝执行,不要等到运行时才发现。
坑 2:依赖超时(The Zombie Task)
现象:任务 C 等待 A 和 B。结果 A 很快做完了,但 B 因为网络原因卡住了(或者挂了)。C 就一直傻等,直到天荒地老。 解法:
- 超时机制:给等待设置
Timeout(例如 5 分钟)。如果 5 分钟依赖还没齐,直接报错。 - 快速失败(Fail Fast) :如果 B 明确失败了,立刻通知 C “别等了,你的上游崩了”,C 也随即取消。不要让错误蔓延。
坑 3:数据传递丢失(The Missing Context)
现象:B 任务确实跑完了,但它产生的数据(比如一份 PDF)没有传给 C。C 虽然启动了,但因为拿不到输入数据而报错。这在分布式系统中很常见。 解法:建立统一的 上下文黑板(Context Blackboard) 。
- 所有任务不直接对话。
- A 做完了,把结果贴在黑板上:“我算出了 X=1”。
- B 做完了,把结果贴在黑板上:“我算出了 Y=2”。
- C 启动时,去黑板上读取 X 和 Y。
- 这解耦了任务之间的直接通信,提高了系统的健壮性。
总结
DAG 工作流是多 Agent 系统的 骨架。
- 它把杂乱无章的任务列表,梳理成了井井有条的 依赖图谱。
- 它通过 智能调度,榨干了系统的每一分并发性能,让 1 小时的任务能在 20 分钟做完。
- 它通过 信号量 和 超时控制,保证了系统的稳定性,防止被突发流量冲垮。
搞定了 DAG,你的 Agent 就能处理那些“牵一发而动全身”的复杂大项目了。
下一章预告
DAG 虽然强大,但它有一个致命弱点:它是静态的。图一旦画好,执行过程中就不能变了。 但如果任务执行到一半,发现需要加派人手怎么办?或者发现原来的计划行不通,需要临时变阵?
下一章,我们将介绍最高级的编排模式 —— Supervisor(主管)模式:一个拥有动态决策权、能随时增删改任务的“活体”管理器。