11.1 任务分解:把大问题拆成子任务 DAG
5.2 的 Plan-and-Solve 把目标拆成一串子任务,Executor 从头到尾顺序执行。那个「一串」其实是一条链:第 3 步必须等第 2 步,第 2 步必须等第 1 步。但真实任务里,很多子任务之间并没有先后关系——「抽取方法 A 的要点」和「抽取方法 B 的要点」都只依赖「先检索到文档」,彼此毫不相干。把它们硬排成一条链,等于让一个 CPU 跑两个本可以并行的任务。
本节把 minagent 推进到
minagent/plan.py的任务层:新增Task与Plan两个类型,把计划从「一串字符串」升级成「一张带依赖的有向无环图(DAG)」,并给出让模型输出这张图的结构化协议与一个validate_plan校验器。
11.1.1 从线性计划到 DAG
先看清线性计划的浪费在哪。假设目标是「对比方法 A 与方法 B」,一个四步计划:
检索 → 抽取A → 抽取B → 综合
这四步里,抽取A 和 抽取B 是互相独立的——它们都只需要 检索 的产物,谁也用不到谁。但线性计划强制它们一前一后,于是本来能同时做的事被排成了长链。更糟的是,一旦 抽取A 失败,后面的 抽取B 也会被连累停下,尽管它和 A 毫无关系。
解决办法是把计划从「链」改成「图」:每个子任务是一张图上的节点,依赖关系是有向边。抽取A 和 抽取B 都从 检索 拉一条入边,但它们之间没有边——这就是它们可以并行的信号。而 综合 同时依赖 抽取A 和 抽取B,两条入边都满足才能开始。
一个合法的任务图必须是有向无环图(Directed Acyclic Graph,DAG)。「有向」表示依赖是有方向的,「无环」表示不存在「A 等 B、B 等 A」这种永远等不完的死锁。DAG 这个约束不是可有可无的装饰——它是「计划可以被执行完」的数学保证。只要有环,任何执行器都会卡死。
11.1.2 Task 与 Plan
把子任务定义成一个 dataclass,字段就四个:
# minagent/plan.py(任务类型)
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Task:
id: str
description: str
depends_on: list[str] = field(default_factory=list)
tool_hint: str | None = None
Plan = list[Task]
四个字段各有分工:
| 字段 | 作用 | 谁用它 |
|---|---|---|
id | 全局唯一标识,供依赖引用 | 校验器、执行器、重规划 |
description | 一句话说清这一步做什么 | 执行时喂给模型的提示词 |
depends_on | 前置任务 id 列表,空表示可立即执行 | 拓扑排序、并行调度 |
tool_hint | 建议使用的工具名(可选) | 执行器选工具、或只作提示 |
depends_on 是整张图的骨架。它用 list[str] 而不是直接引用 Task 对象,有两个考虑:一是可序列化——计划要能从 JSON 解析、也能存盘再读回(第 7 章检查点的思路);二是避免循环引用——如果 Task 直接持有前置 Task 的引用,构造一张图会很别扭。用 id 做间接层,图的形状完全由 id 之间的字符串引用决定,清晰且可校验。
tool_hint 特意设成可选(默认 None)。规划阶段模型未必知道每一步该用哪个工具,硬要求它填反而会逼它瞎猜。有 hint 时执行器可以优先考虑,没有时按 description 现场判断——把「必须」和「建议」分开,是让结构化输出稳定的关键。
11.1.3 让模型输出任务 DAG
计划是模型产出的,所以要给它一份明确的输出契约。和 5.2 一样,用 JSON 数组,但每一项多了 id 和 depends_on:
# minagent/plan.py(提示词常量)
把目标拆成子任务,只输出 JSON 数组。每项字段:
id 短英文标识,全局唯一
description 一句话说清这一步做什么
depends_on 依赖的任务 id 列表,无依赖写 []
tool_hint 建议使用的工具名(可选)
后置任务的 depends_on 必须列出它需要的前置任务 id。
最后一句是要害。少了它,模型会产出 id 齐全、但 depends_on 全是空数组的计划——那就退化成「四个互不依赖的任务」,执行器会误以为它们全能并行,包括本该在最后的 综合。依赖信息必须由模型显式给出,解析器没法凭空补。解析函数照旧一步到位:
# minagent/plan.py(解析)
import json
def parse_plan(text: str) -> Plan:
raw = json.loads(text)
return [
Task(
id=t["id"],
description=t["description"],
depends_on=list(t.get("depends_on", [])),
tool_hint=t.get("tool_hint"),
)
for t in raw
]
id 和 description 用 [] 强取,缺了就直接 KeyError——这两个字段不可省。depends_on 和 tool_hint 用 .get() 给默认值——它们可以省。该严的严、该松的松,这样既不放过残缺计划,又不至于因为模型省了个可选字段就整个解析失败。
11.1.4 validate_plan:环与悬空依赖
模型给的依赖关系未必合法,执行前必须验。两类致命错误:
- 悬空依赖:
depends_on里写了一个根本不存在的id。执行器排到这一步会发现「等的前置任务永远不会出现」,只能卡死。 - 环:A 依赖 B、B 依赖 A。两者互相等待,永远无法开始。
两类都靠一个函数兜住:
# minagent/plan.py(校验)
def validate_plan(plan: Plan) -> list[str]:
errors: list[str] = []
ids = [t.id for t in plan]
id_set = set(ids)
dup = sorted({i for i in ids if ids.count(i) > 1})
if dup:
errors.append(f"重复的任务 id: {dup}")
for t in plan:
for d in t.depends_on:
if d not in id_set:
errors.append(f"任务 {t.id} 依赖不存在的 {d}")
elif d == t.id:
errors.append(f"任务 {t.id} 依赖自己")
indeg = {t.id: 0 for t in plan}
adj: dict[str, list[str]] = {t.id: [] for t in plan}
for t in plan:
for d in t.depends_on:
if d in id_set and d != t.id:
adj[d].append(t.id)
indeg[t.id] += 1
queue = [i for i, n in indeg.items() if n == 0]
seen = 0
while queue:
cur = queue.pop()
seen += 1
for nxt in adj[cur]:
indeg[nxt] -= 1
if indeg[nxt] == 0:
queue.append(nxt)
if seen != len(plan):
errors.append("计划存在环(无法拓扑排序)")
return errors
环检测用的是 Kahn 算法:反复取出入度为 0 的节点,把它「摘掉」并让它指向的节点入度减一。如果一个 DAG 能被全部摘完,摘掉的节点数等于总数;只要还剩节点没被摘掉,说明它们互相牵制、构成了环。所以判据就是最后那句 seen != len(plan)。悬空依赖在遍历 depends_on 时顺手查 id_set 就能发现,不必等到排序。顺带还检查了重复 id 和自依赖——前者会让依赖指向歧义,后者是最小的环。
validate_plan 返回错误列表而非布尔值,是为了把「哪里错了」一起带出来。返回空列表就是通过。校验器要能说人话,否则重规划时模型拿到一个光秃秃的 False 也无从修改。
11.1.5 真跑:一个 4 任务 DAG
用假模型脚本化「对比方法 A 与方法 B」的分解。脚本里塞进一段合法的计划 JSON:
# demo111.py
from minagent.fake import FakeModel
from minagent.plan import TASK_SCHEMA_PROMPT, parse_plan, topo_order, validate_plan
from minagent.types import Message
PLAN_JSON = """[
{"id": "retrieve", "description": "检索与主题相关的文档", "depends_on": [], "tool_hint": "search"},
{"id": "extract_a", "description": "从检索结果抽取方法 A 的要点", "depends_on": ["retrieve"]},
{"id": "extract_b", "description": "从检索结果抽取方法 B 的要点", "depends_on": ["retrieve"]},
{"id": "synthesize", "description": "综合 A、B 要点写成对比结论", "depends_on": ["extract_a", "extract_b"]}
]"""
model = FakeModel([PLAN_JSON])
resp = model.complete([Message(role="user", content=TASK_SCHEMA_PROMPT + "\n目标:对比方法 A 与方法 B")], [])
plan = parse_plan(resp.message.content)
print("任务 DAG:")
for t in plan:
deps = ", ".join(t.depends_on) or "(无)"
hint = f" [{t.tool_hint}]" if t.tool_hint else ""
print(f" {t.id:<10} <- {deps:<22} {t.description}{hint}")
print("校验:", validate_plan(plan) or "通过")
print("拓扑序:", " -> ".join(topo_order(plan)))
本机实测输出(Python 3.14.6):
任务 DAG:
retrieve <- (无) 检索与主题相关的文档 [search]
extract_a <- retrieve 从检索结果抽取方法 A 的要点
extract_b <- retrieve 从检索结果抽取方法 B 的要点
synthesize <- extract_a, extract_b 综合 A、B 要点写成对比结论
校验: 通过
拓扑序: retrieve -> extract_a -> extract_b -> synthesize
拓扑序里 extract_a 排在 extract_b 前面,纯粹是因为它在 JSON 数组里先出现——拓扑序不唯一,只要是合法的「前置在前」顺序都对。执行器真正关心的不是这个线性序,而是 11.2 会算出的分层:retrieve 单独一层,extract_a 与 extract_b 同一层(可并行),synthesize 最后一层。
再看一个坏计划——把依赖写成环、并塞一个不存在的 x:
BAD_JSON = """[
{"id": "a", "description": "A", "depends_on": ["c"]},
{"id": "b", "description": "B", "depends_on": ["a", "x"]},
{"id": "c", "description": "C", "depends_on": ["b"]}
]"""
bad = parse_plan(BAD_JSON)
print("坏计划校验:", validate_plan(bad))
实测输出:
坏计划校验: ['任务 b 依赖不存在的 x', '计划存在环(无法拓扑排序)']
两类错误都被抓住了:b 引用了不存在的 x,同时 a→c→b→a 构成一个三节点的环。这份计划绝不能进入执行——若放行,执行器会在 a/b/c 三个节点上永远等不到入度归零,卡死。
11.1.6 线性计划 vs DAG 计划
DAG 不是无条件的升级,它换来的并行能力有成本。把差异列成一张表:
| 维度 | 线性计划(5.2) | DAG 计划(本节) |
|---|---|---|
| 数据结构 | list[str] | list[Task](带依赖) |
| 依赖表达 | 隐含(数组顺序) | 显式(depends_on) |
| 可并行 | 否,严格顺序 | 是,无依赖层并行 |
| 模型输出难度 | 低,一句话一步 | 高,要填对 depends_on |
| 执行器复杂度 | 一个 for 循环 | 拓扑排序 + 分层调度 |
| 出错后果 | 慢 | 依赖写错可能死锁,须校验 |
| 适用 | 步骤严格线性 | 存在独立分支的任务 |
一句话选型:子任务之间有没有真正的并行分支。如果每一步都用上一步的结果(流水线),线性计划更简单也更稳;如果存在「几个任务都只依赖同一个前置、彼此不相关」的结构,DAG 才值回它多出来的复杂度。
还有个务实的提醒:DAG 的价值前提是「依赖由模型填对」。真实模型经常漏填或错填 depends_on——漏填会让独立任务被误判成可并行(跑出错误结果),错填可能造出环。所以 validate_plan 不是可选项,而是 DAG 计划必须配的安全带。能并行的收益,永远建立在依赖关系正确的假设上;这个假设一破,DAG 比线性计划更危险。
11.1.7 真实模型下会怎样
本节的计划 JSON 是我们写死在脚本里的,depends_on 填得工工整整。真实模型的产出要糙得多:
- 依赖经常漏填。模型会把
extract_a的depends_on写成[],尽管它显然需要retrieve的结果。这会骗过「无环」检查(没环),却让执行器把抽取和检索一起并发,抽取拿到的是空输入。 - 可能造出环。尤其在任务数多时,模型会把依赖关系绕成一团,
validate_plan此时是唯一的防线——它必须在校验失败时把错误回喂给模型让它重出,这正是 11.3 重规划要处理的情形。 - 粒度不稳定。同一个目标,模型可能给 4 个任务,也可能给 9 个;粒度太细会让依赖图稠密、校验负担重,太粗又浪费并行机会。
假模型证明的是「Task/Plan/校验器这套类型与算法对」;真实模型暴露的是「依赖信息本身不可靠」。所以生产里 DAG 计划通常配三件事:解析失败重试、validate_plan 校验、校验不过就重规划。卷一先把前两件立住,第三件交给 11.3。
顺带一句提示词的工程经验:真实模型对「后置任务必须列出前置 id」这条约束不是听一遍就记住。给它一两个 few-shot 例子(先给一段目标、再给一段正确的 JSON)比反复强调规则有效得多。约束要用示例去锚定,而不是用形容词去强调——这条经验在 6.3 讲结构化输出时还会再见到。
阅读导航:上一节:10.3 带引用的回答 · 下一节:11.2 计划执行器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。