复杂任务规划器:Plan-and-Solve 与 DAG 依赖图并发调度
本节目标
- 理解为什么纯 ReAct(边想边做)在超过 6 步的长链路任务中容易「迷失大局(Goal Drift)」
- 掌握 Plan-and-Solve(先规划、后分步执行) 与 DAG(有向无环图)拓扑并发调度 架构
- 用原生 JavaScript 手写一个支持依赖拓扑排序、同层任务并发执行与失败动态重规划(Re-planning)的 DAG 调度器
纯 ReAct 的短板与 Plan-and-Solve 的破局
ReAct 是一种典型的贪心局部决策(Greedy Local Search):模型每一步只看眼前这一步该调什么工具。当用户提出一个复合型长任务(例如:「对比项目中 A、B、C 三个模块的依赖体积,找出循环引用,生成重构方案并补齐单元测试」)时,纯 ReAct 往往在第 4 步修某个细节报错时彻底忘了后面还要做另外两个模块,或者把本可以并行执行的 3 个模块的扫描任务串行跑了 3 遍,耗时翻了 3 倍。
DAG(有向无环图)规划调度器将任务拆分为两个阶段:
- Planner(全局规划器):先把复杂目标拆解成一张带有显式前驱依赖(
dependsOn)的子任务图。 - DAG Executor(拓扑并发执行器):自动找出当前所有前驱依赖已完成的就绪节点(Ready Nodes),用
Promise.all并发执行,并将上游节点的产出精准注入下游节点的上下文。
┌──► [Task B: 扫描模块 A] ──┐
[Task A] ─┼──► [Task C: 扫描模块 B] ──┼──► [Task E: 汇总对比并生成重构补丁]
(解析配置) └──► [Task D: 扫描模块 C] ──┘
(B, C, D 同层并发执行!)手写带环路检测与分层并发执行的 DAG 任务调度器
export class DagTaskScheduler {
constructor({ workerRunner, onReplan }) {
this.workerRunner = workerRunner // async (task, upstreamOutputs) => result
this.onReplan = onReplan // async ({ failedTask, error, completed }) => newTasks
}
// 校验 DAG 合法性(检测未知依赖与循环依赖 Cycle)
validateGraph(tasks) {
const map = new Map(tasks.map((t) => [t.id, t]))
const visiting = new Set()
const visited = new Set()
const dfs = (id) => {
if (visiting.has(id)) throw new Error(`检测到任务循环依赖 (Cycle): ${id}`)
if (visited.has(id)) return
visiting.add(id)
const node = map.get(id)
if (!node) throw new Error(`存在未定义的依赖任务节点: ${id}`)
for (const depId of node.dependsOn || []) {
dfs(depId)
}
visiting.delete(id)
visited.add(id)
}
for (const t of tasks) dfs(t.id)
return true
}
async executePlan(initialTasks) {
let tasks = [...initialTasks]
this.validateGraph(tasks)
const completed = new Map() // taskId -> output
const failed = new Map()
while (completed.size < tasks.length) {
// 1. 筛选出所有尚未完成、且所有前置依赖均已成功完成的就绪节点
const readyBatch = tasks.filter(
(t) =>
!completed.has(t.id) &&
!failed.has(t.id) &&
(t.dependsOn || []).every((depId) => completed.has(depId))
)
if (readyBatch.length === 0) {
throw new Error('DAG 调度死锁:无就绪任务可执行,但计划尚未全部完成')
}
// 2. 同层无依赖冲突的子任务全部并行启动
const batchResults = await Promise.allSettled(
readyBatch.map(async (task) => {
const upstreamContext = Object.fromEntries(
(task.dependsOn || []).map((depId) => [depId, completed.get(depId)])
)
const output = await this.workerRunner(task, upstreamContext)
return { task, output }
})
)
// 3. 收集本批次结果,若遇节点失败则触发动态重规划(Re-planning)
for (let i = 0; i < batchResults.length; i++) {
const res = batchResults[i]
const currentTask = readyBatch[i]
if (res.status === 'fulfilled') {
completed.set(currentTask.id, res.value.output)
} else {
failed.set(currentTask.id, res.reason)
if (!this.onReplan) throw res.reason
// 调用 Planner 根据已完成进度与报错动态调整剩余子图
tasks = await this.onReplan({
failedTask: currentTask,
error: res.reason?.message || String(res.reason),
completed: Object.fromEntries(completed)
})
this.validateGraph(tasks)
break
}
}
}
return Object.fromEntries(completed)
}
}课后练习
练习 1:在拿到大模型生成的 DAG 任务列表后,为什么在启动执行前必须先跑一遍基于深度优先搜索(DFS 三色标记法:visiting vs visited)的 validateGraph?
答案:大模型在拆分复杂任务时偶尔会写出循环依赖(例如 Task A dependsOn Task B 同时 Task B dependsOn Task A)或引用了拼写错误的 taskId。若不提前用 DFS 检测环路与悬空引用,while 调度循环会因为找不到入度为 0 的就绪节点而直接死锁。
练习 2:为什么静态一次性规划(Static Planning)在真实世界往往不够用,必须在节点失败时引入 onReplan(动态重规划)?
答案:Planner 在任务开始前制定计划时,尚未看到工具返回的真实环境数据(例如计划第 2 步去读取某个配置文件,执行时才发现该项目改用了环境变量配置)。当假设与真实环境不符导致某步失败时,动态重规划能够保留已完成的成果(completed),仅重写后续受影响的子图。