# 从零设计 DAG 编排引擎:6 种步骤类型的架构决策

8 阅读8分钟

从零设计 DAG 编排引擎:6 种步骤类型的架构决策

做 Agent 项目越久,越发现一个扎心的事实:

"能调模型"和"能做成系统",中间隔了十万八千里。

调模型不过是 prompt 一写、API 一调、结果一解析。但要把它变成一套能长期运转的系统,麻烦事才刚开始:

  • 步骤之间有没有依赖?
  • 哪些能并行,哪些必须串行?
  • 中途挂了怎么恢复,总不能让用户从头等吧?
  • 工具调用会不会把系统拖崩?
  • 链路一长,出了问题该去哪查?

我在做 agent-exec-engine 的时候,核心想解决的其实就是这些"脏活累活"。

所以今天这篇文章,不聊哪个模型更强,也不聊 Prompt 怎么写。我想往下沉一层,聊聊执行层的设计:

如果你的 Agent 不只想跑 Demo,执行引擎到底该怎么搭?


1. 为什么非得造一个"执行引擎"

很多 Agent 项目起步都很顺。

用户一句话 → 模型推理 → 调个工具 → 总结输出,页面上看起来已经有模有样了。

但只要往前多走几步,大概率会踩到三个坑:

  1. 流程一复杂,代码就成面条。 线性写法很快hold不住。
  2. 工具一多,安全边界就开始漏风。 错误处理七零八落。
  3. 长任务中途一崩,只能从头再来。 用户体验直接归零。

这时候你会发现,问题的核心已经不是"模型答得对不对",而是**"这条执行链能不能稳稳当当地跑完"**。

很多看起来像"Agent 不够聪明"的 bug,追根溯源,其实是执行模型没设计好。

所以我把精力放在了执行层,而不是追着换最新模型或套最新框架。


2. 为什么选 DAG,而不是链式流程

最早思考这个问题时,我的想法很朴素:

如果一个任务不是简单的 A → B → C,而是带分支、带并行、还得中途停下来等人确认,那该怎么描述它?

链式流程有个默认假设:前一步做完,后一步才能开始。这个假设在简单场景下没问题,但流程一复杂,短板就露出来了:

  • 明明互不依赖的两个步骤,也被迫串行排队;
  • 某一步只是局部失败,结果整条链跟着陪葬;
  • 分支和人工确认只能写成一堆零散的判断,越写越乱。

我最后选择 DAG,原因没多玄乎,核心就一句话:

我想把"谁依赖谁、谁先执行、谁能并行"这件事,明明白白写进数据结构里。

系统不需要猜,也不需要靠一堆 if/else 硬维护顺序。一个节点能不能跑,只看它的前置依赖有没有满足;一个节点跑完了,会解锁哪些下游节点,也是一眼就能推出来的。

DAG 的价值不在于"听起来高级",而在于它让整个系统的执行关系变得清澈见底


3. 步骤类型为什么要收敛成 6 种

在这个引擎里,我没有搞一个"万能节点",而是把步骤收敛成了 6 种基础类型:

  • llm_call
  • tool_call
  • react
  • branch
  • parallel
  • human

做这个决定时,我没在纠结"6 种以后够不够用",而是在想另一件事:

哪些能力值得成为系统里的"基础语义",而不是靠临时配置拼出来的特例?

我对这 6 种类型的理解是这样的:

类型解决什么问题
llm_call一次标准的模型调用
tool_call调用外部工具
react多轮思考-行动循环
branch运行时动态分支
parallel并行执行
human人工确认和流程暂停

重点不是"正好 6 个",而是边界必须清楚

比如 branch 不是普通步骤加个条件,它会直接影响后面的执行路径;human 也不是某种异常处理,而是真实业务里高频出现的正常环节;react 更不是"多调几次模型",而是一个会自己循环、会调工具、会自己决定什么时候停的复合过程。

如果一开始不把这些差异拉开,系统往往会滑向一个常见陷阱:

表面上只有一种"通用步骤",但实际复杂度全被塞进了配置项、字符串和隐式约定里。短期看起来灵活,长期维护起来欲哭无泪。


4. 调度器最重要的工作不是"跑得快",而是"把控制流理顺"

聊到调度器,很多人第一反应是并发、性能、协程池。

这些当然重要,但我越做越觉得,调度器最核心的职责不是"跑得有多快",而是:

先把控制节点和执行节点分开。

在我的实现里,branchhuman 这类节点不会直接丢进统一的执行批次,而是优先单独处理。

原因很简单:branch 的任务是决定"接下来走哪条路";human 的任务是让流程"停下来,等人确认"。它们的关键不是执行效率,而是流程该不该继续、该怎么继续

只有先把这类控制节点消化掉,后面真正需要跑的业务步骤,才适合一起并发执行。

这个细节看起来不大,但会直接影响整个系统后面是不是清爽。如果控制流和执行流混在一起,虽然功能也能实现,但每加一种新能力,主循环就会臃肿一分。


5. 为什么 ReAct 没有被我藏进普通的 LLM 调用里

不少系统会把 ReAct 当作 LLM 调用的一种"高级模式"。

接入是快,但隐患也很明显:在工作流层面,你已经分不清它到底是一个普通调用,还是一个会自己转好多轮的复杂节点。

我最后选择把 ReAct 单独做成一种步骤类型,主要基于两个原因。

第一,行为模式完全不同

普通的 llm_call 是"一问一答"。而 react 更像这样:

  1. 先想下一步该做什么;
  2. 决定调哪个工具;
  3. 拿到结果后继续想;
  4. 循环多轮,直到自己决定结束。

这已经和普通模型调用不是一回事了。

第二,它需要被单独观察和度量

只要一个步骤会自己循环、多次调用工具,就需要单独追踪:

  • 一共转了多少轮?
  • 调了多少次工具?
  • 哪一轮最容易出错?
  • token 消耗集中在哪?

如果把它完全藏进普通 LLM 调用里,这些信息就会糊成一团,排查问题时只能抓瞎。

做工程系统有个原则我越来越信奉:

只要某种行为已经明显不同,就不要假装它和别的一样。


6. 真正拉开 Demo 和系统差距的,是恢复能力和失败边界

如果目标只是"跑通一次",很多东西确实可以先省掉:

  • 不做状态机
  • 不做 checkpoint
  • 不做沙箱
  • 不做链路追踪

这些都不会影响第一次演示的效果。

但只要流程变长、工具变多、执行时间变久,它们就会从"锦上添花"变成"不得不补"。

6.1 状态机不是装饰

我在项目里把步骤状态收敛成了一组固定状态:

  • pending
  • running
  • success
  • failed
  • timeout
  • skipped
  • canceled

这样做的意义不是"枚举写得好看",而是让系统明确知道:

哪些状态转换是合法的,哪些是非法的。

如果这件事没定义清楚,重试、恢复、取消这些动作很容易互相打架,最后变成一场状态混乱的灾难。

6.2 Checkpoint 远不只是"存一下"

Checkpoint 说起来轻描淡写:"就是把当前状态存一下嘛。"

真做起来才会发现,它要回答的问题远比想象中多:

  • 当前版本和之前版本是否一致?
  • 一个执行到一半的步骤恢复后该算什么状态?
  • 已经成功的节点还要不要再跑?
  • 图状态和运行状态怎么重新对齐?

所以恢复能力真正难的地方,不在于"能不能重新开始",而在于:

能不能从正确的位置,继续往下走。

6.3 失败边界一定要收住

这个道理在工具调用上体现得尤为明显。

如果工具直接在主进程里跑,一旦失控,影响的不是某个步骤,而是整个服务。

所以我选择把工具执行放进 Docker 沙箱,再配上一整套资源限制、文件系统隔离、网络限制和超时处理。

做起来确实麻烦,但意义很清楚:

让一个问题停在这个步骤,不要往外扩。


7. 这 6 种步骤类型不是终点,而是一个稳定的起点

现在回头看,这套设计最重要的不是它已经覆盖了多少能力,而是给后续的扩展留了一个比较稳的骨架。

不管是后面做 Multi-Agent、Memory、Eval,还是各种 Demo 场景,都应该是在这个骨架上自然生长,而不是每次来一个需求就把核心结构推翻重写。

这也是我现在越来越在意的一点:

一个系统的长期价值,往往不取决于第一版做了多少功能,而取决于它后面能不能持续生长,还不把自己长坏。

如果你也在做 Agent 执行层的设计,欢迎在评论区聊聊你的思路。👋