Work Graph 节点并行执行——需求文档
日期: 2026-08-04(2026-08-05 更新:已确认,标记为核心优先级,进入详细设计阶段)
性质: 需求文档(BRD/PRD 层面),不含详细技术设计。回应对 docs/reference/Graph Engineering with Claude_ 14-Step roadmap from 0 to graph architect (Full Course).diff-with-graphlemon.md 开放问题第 1 条的讨论——"现在的每节点独立子进程设计,断点续跑收益是否值得放弃 fan-out 并行?能否两者兼得?"。详细设计(SDD 层面:topological_layers() 的具体实现、线程池调度器接口、锁的粒度)留到需求确认后单独产出。
状态: ✅ 已确认。验收口径固化为一句话(2026-08-05 用户原话):"只要 Work Graph 规划了 fan-out、fan-in 的并行节点,就可以在节点触发的时候并行执行"——这与本文档 FR-1/FR-2 一致,无需改动功能需求本身。详细设计已产出,见 docs/work-graph-parallel-execution-design.md。
1. 背景
src/work_graph_orchestrate.py::run_graph() 目前用 Kahn 算法把 graph_manifest 拍平成一条扁平的拓扑序,用纯 Python for 循环逐个节点执行——即使两个节点在 graph_manifest 里互相没有 depends_on 关系(本可以并行),现在也是严格排队执行,跑完一个才开始下一个。
这是一个记录在案的、有意为之的权衡(见该模块 docstring):早先方案(docs/graphlemon_architecture.md §10.1)设想直接复用 Claude Code 原生动态工作流的 parallel()/pipeline() 来跑整个 Work Graph,但那要求把整张图塞进一次不透明的 claude 子进程调用,Python 侧在执行过程中对每个节点的完成状态完全不可见——这跟 REQ-06"进程重启后从已完成节点继续、不重跑"的断点续跑硬需求根本冲突。所以最终选择了"每个节点一次独立的 claude -p 子进程调用 + Python 层持久化每个节点的状态",代价是放弃了图内并行。
随着 Work Graph 规模变大(例如 Planner 阶段 PRD/SDD 的多场景/多方案 fan-out 候选,docs/graphlemon_architecture.md §6.1 已经设想过、但目前从未真正在生成的 graph_manifest 里出现过),纯串行执行会让原本可以同时进行的工作被迫排队,拖长任务的墙钟时间。本需求要解决的问题:在不牺牲现有断点续跑能力的前提下,让 graph_manifest 里互不依赖的节点能够真正并发执行。
2. 目标
graph_manifest中没有依赖关系(无论是直接depends_on还是通过其他节点间接依赖)的节点集合,能够在同一时间窗口内并发执行,而不是被迫排队。- 现有的断点续跑语义(进程重启后,已经落库为
done的节点不重跑,从未完成的节点继续)在并行改造后必须继续成立,不能因为改成并发就丢失这个能力。 - 现有的每节点独立 worktree 隔离(self-target 场景无条件强制)在并发场景下继续正确工作,不出现多个节点互相踩写文件。
- 现有的单节点验证-重试机制(
verify_fn:验证失败则原地重跑一次,验证通过才允许下游节点开始)语义保持不变。 - 并发规模可控,不因为一次任务的
graph_manifest节点数变多就无限制地同时拉起等量的claude子进程,导致失控的 token/账单开销或系统资源耗尽。
3. 非目标(本轮明确不做)
- 不做完整的"随时有依赖满足就调度"的数据流式调度器。本轮只做"按拓扑层分批 + 层内并发、层间屏障"的粗粒度并行——层边界由 Kahn 算法的天然分层给出,不追求"某个节点的依赖一满足就立刻插队执行,不必等同层其他节点跑完"这种更细粒度的最优调度。理由:现在的 Work Graph 规模是个位数到十几个节点,层级屏障和真正的最优调度之间的墙钟差异预计很小,不值得为此引入更复杂的调度器和更难验证的正确性。
- 不改变"每个节点是一次独立
claude -p子进程调用"这个既有设计。本需求只改变"什么时候触发这次调用"(从严格串行改成同层并发触发),不改变每次调用本身的机制(仍然是单节点 workflow 脚本 +--json-schema校验),也不重新尝试把整图塞进一次 Claude 原生parallel()/pipeline()编排脚本。 - 不引入按节点选模型的策略。
_render_node_script已经预留的model参数、"哪个节点该用哪个模型"这层策略是独立的开放问题(见另一份对照笔记开放问题第 3 条),本需求不涉及。 - 不改变 Org-level Loop 的三轮上限、loop-until-dry 机制。本需求只影响单次
run_graph()调用内部的节点调度,不涉及跨 Org Graph 的重试/收敛逻辑,也不试图让graph_manifest支持图内有环(topological_order目前对有环图直接报错,这个限制维持不变)。 - 不承诺降低 token 成本。并行改造换来的是墙钟时间缩短,节点总数、每个节点的调用量不变,token 总消耗预计基本持平(详见「7. 风险与已知权衡」)。
4. 功能需求
| # | 需求描述 | 优先级 |
|---|---|---|
| FR-1 | run_graph() 在拿到 graph_manifest 后,应能识别出可以并发执行的节点分组(同一拓扑层内、彼此无依赖关系的节点集合),而不是像现在这样拍平成一条无结构信息的顺序列表。 |
必须 |
| FR-2 | 同一分组内的多个节点应能真正并发触发各自的 claude -p 子进程调用,而不是等前一个节点完全结束才开始下一个。 |
必须 |
| FR-3 | 并发执行期间,每个节点各自的持久化状态写入(节点行创建、状态流转、执行事件日志)必须保持数据完整性——不能因为多线程同时写同一个 SQLite 连接而出现"database is locked"报错、脏写、或状态丢失。 | 必须 |
| FR-4 | 并发执行期间,多个节点各自需要创建 git worktree 时(needs_worktree=True 场景)不能相互冲突,每个节点仍然拿到一个独立、干净的工作区。 |
必须 |
| FR-5 | 若同一分组内某个节点执行失败(含配额限制 UsageLimitReachedError、一般执行错误、verify_fn 验证失败且重试后仍不通过),已经在并发执行中的其他兄弟节点应当被允许自然跑完,不因为一个节点失败就中途取消其他正在消耗资源的调用;该分组内全部节点结束后再统一判定整个 run_graph() 调用的最终结果(成功/失败/需要升级)。 |
必须 |
| FR-6 | 并发度必须可配置且有合理的默认上限,防止一次任务的 graph_manifest 节点数暴涨时同时拉起等量的 claude 子进程。 |
必须 |
| FR-7 | 进程在某个分组执行到一半时中断重启后,恢复执行应能正确识别"这个分组里哪些节点已经 done、哪些还没跑完",只重新执行未完成的部分,不重跑已完成节点——这个语义必须和现在的单节点断点续跑完全等价,只是作用范围从"单个节点"扩展到了"一个分组内的多个节点"。 |
必须 |
| FR-8 | verify_fn 的语义保持不变:一个节点的输出必须先通过验证(或验证失败重试后通过),该节点的结果才能进入下游节点可读取的 outputs;依赖该节点的下游节点所在的分组,必须等到这一验证真正完成后才能开始执行,不能提前读取未经验证的中间结果。 |
必须 |
| FR-9 | 现有调用方(e2e_pipeline.py 等)对 run_graph() 的调用方式和返回值格式({node_id: output_payload})应保持兼容,不需要因为内部改成并行调度而修改调用方代码。 |
应该 |
| FR-10 | 并行执行的分组信息(哪些节点被并发调度、每组的起止时间)应当可观测——至少体现在现有的 execution_event/日志机制里,方便事后排查"某次运行到底并发了多少节点、并发是否生效"。 |
应该 |
5. 验收标准
- 给定一个
graph_manifest,其中包含至少一组"两个节点互不依赖、都依赖同一个上游节点"的结构(例如 A→{B, C}→D),跑一次run_graph(),能观测到 B 和 C 的claude -p子进程调用在时间上有重叠(不是 B 完全结束后 C 才开始)。 - 用 mock 的
run_fn(不真实调用claude,返回固定输出,可注入人为延迟)写单元测试:断言同一分组内的节点确实并发触发(例如用一个共享计数器/时间戳断言重叠),且分组之间保持严格先后顺序(下一层的节点不会在上一层未全部完成前开始)。 - 模拟"进程在某个分组执行到一半时中断"(例如分组内两个节点,mock 让其中一个成功落库为
done、另一个抛异常模拟中断),重新调用run_graph(),断言已完成的节点不会被重新执行(run_fn没有被再次调用),只有未完成的节点被重新调度。 - 并发场景下跑一轮包含多个节点写入 SQLite 的测试,不出现
sqlite3.OperationalError: database is locked或类似的并发写入异常。 - 模拟
needs_worktree=True场景下同一分组内多个节点并发创建 worktree,断言每个节点拿到的 worktree 路径互不相同、互不冲突,且git worktree list能看到全部预期数量的 worktree。 - 模拟同一分组内一个节点抛
UsageLimitReachedError、另一个节点正常完成,断言正常完成的节点的输出被正确保留(不因为兄弟节点失败而丢失),同时整体调用仍然正确地向上抛出/传播配额限制信号。 - 现有
src/test_work_graph_orchestrate.py、src/test_work_graph_execute.py、src/test_e2e_pipeline.py中的既有测试用例在改造后无需修改断言即可继续通过(验证行为兼容性),除非该用例本身就是在断言"严格串行"这一将被改变的实现细节(这类用例需要明确识别出来,逐一确认是否要更新)。
6. 约束与假设
- 复用
src/backend_server.py::_Server里已经验证过的模式:共享一个sqlite3.connect(..., check_same_thread=False)连接 +threading.Lock()保护并发写入,不引入连接池或每线程独立连接这类更重的方案。 - 复用现有的
work_node_run.py断点续跑机制("已done的节点短路,直接返回持久化输出"),并行改造不重新设计这一层,只是让触发多个节点执行这件事本身从串行变成并发。 - 复用现有的每节点 worktree 隔离机制(
work_node_run.py::create_worktree),如果发现并发创建 worktree 本身有 git 层面的竞态,允许在"创建 worktree"这一步单独加锁串行化,不要求整个节点执行都串行——真正耗时的claude -p调用部分仍然并发。 - 默认并发上限取一个保守值(具体数字留给设计阶段结合实测确定,不在需求阶段写死),避免一次运行同时拉起过多
claude子进程。
7. 风险与已知权衡
- 不降低 token 成本,只缩短墙钟时间:并行改造让节点调用"同时发生"而不是排队,但总的节点数量、每个节点的 token 消耗不变。如果验收标准或后续讨论里出现"并行后应该更省钱"的预期,需要提前澄清纠正。
- 层级屏障不是最优调度:如果某一层里有一个节点明显比同层其他节点慢很多,下一层要等这个最慢的节点跑完才能开始,即使下一层里有些节点其实只依赖同层里已经跑完的那些节点。本需求接受这个次优性(见「3. 非目标」第 1 条),如果未来图规模变大、这个次优性变成真实瓶颈,需要另立需求评估数据流式调度器。
- SQLite 并发写入的锁粒度需要在设计阶段仔细确定:过粗的锁(整个
run_graph()调用期间持锁)会退化成事实上的串行,失去并行的意义;过细的锁需要更仔细地审视哪些写入操作之间存在真正的竞态。这是详细设计阶段需要重点验证的部分,不是需求阶段能定论的。 - worktree 创建的 git 层面竞态目前只是推测,未经验证:需要在设计/实现阶段用真实的
git worktree add并发调用做实测,确认是否真的需要额外加锁,还是 git 本身已经能安全处理并发创建。
8. 开放问题(留给详细设计阶段回答)
- [ ]
topological_layers()的具体实现:是在现有topological_order()旁边新增一个函数,还是重构topological_order()本身让它返回分层结构,由调用方按需拍平? - [ ] 并发上限(
max_workers)默认值定多少?是否需要按 Org Agent 类型(Planner 偏文档生成、Coder 偏代码修改)给不同的默认上限?是否需要暴露成可配置项(管理后台/环境变量)? - [ ] FR-5 的"该分组内全部节点结束后再统一判定整体结果"具体的异常聚合/优先级规则是什么(比如同组里既有配额限制又有一般执行错误,该抛出哪一个、还是都记录?)?
- [ ] 是否需要在
execution_event里新增一种事件类型来标记"分组开始/分组结束",还是复用现有的node_start/node_completed事件、靠时间戳重叠反推并发情况就够用? - [ ] worktree 创建竞态如果实测确认存在,加锁的粒度是"整个
create_worktree函数"还是可以更细(比如只锁git worktree add这一条命令的调用)?