从零设计 DAG 编排引擎:6 种步骤类型的架构决策
做 Agent 项目越久,越发现一个扎心的事实:
"能调模型"和"能做成系统",中间隔了十万八千里。
调模型不过是 prompt 一写、API 一调、结果一解析。但要把它变成一套能长期运转的系统,麻烦事才刚开始:
- 步骤之间有没有依赖?
- 哪些能并行,哪些必须串行?
- 中途挂了怎么恢复,总不能让用户从头等吧?
- 工具调用会不会把系统拖崩?
- 链路一长,出了问题该去哪查?
我在做 agent-exec-engine 的时候,核心想解决的其实就是这些"脏活累活"。
所以今天这篇文章,不聊哪个模型更强,也不聊 Prompt 怎么写。我想往下沉一层,聊聊执行层的设计:
如果你的 Agent 不只想跑 Demo,执行引擎到底该怎么搭?
1. 为什么非得造一个"执行引擎"
很多 Agent 项目起步都很顺。
用户一句话 → 模型推理 → 调个工具 → 总结输出,页面上看起来已经有模有样了。
但只要往前多走几步,大概率会踩到三个坑:
- 流程一复杂,代码就成面条。 线性写法很快hold不住。
- 工具一多,安全边界就开始漏风。 错误处理七零八落。
- 长任务中途一崩,只能从头再来。 用户体验直接归零。
这时候你会发现,问题的核心已经不是"模型答得对不对",而是**"这条执行链能不能稳稳当当地跑完"**。
很多看起来像"Agent 不够聪明"的 bug,追根溯源,其实是执行模型没设计好。
所以我把精力放在了执行层,而不是追着换最新模型或套最新框架。
2. 为什么选 DAG,而不是链式流程
最早思考这个问题时,我的想法很朴素:
如果一个任务不是简单的 A → B → C,而是带分支、带并行、还得中途停下来等人确认,那该怎么描述它?
链式流程有个默认假设:前一步做完,后一步才能开始。这个假设在简单场景下没问题,但流程一复杂,短板就露出来了:
- 明明互不依赖的两个步骤,也被迫串行排队;
- 某一步只是局部失败,结果整条链跟着陪葬;
- 分支和人工确认只能写成一堆零散的判断,越写越乱。
我最后选择 DAG,原因没多玄乎,核心就一句话:
我想把"谁依赖谁、谁先执行、谁能并行"这件事,明明白白写进数据结构里。
系统不需要猜,也不需要靠一堆 if/else 硬维护顺序。一个节点能不能跑,只看它的前置依赖有没有满足;一个节点跑完了,会解锁哪些下游节点,也是一眼就能推出来的。
DAG 的价值不在于"听起来高级",而在于它让整个系统的执行关系变得清澈见底。
3. 步骤类型为什么要收敛成 6 种
在这个引擎里,我没有搞一个"万能节点",而是把步骤收敛成了 6 种基础类型:
llm_calltool_callreactbranchparallelhuman
做这个决定时,我没在纠结"6 种以后够不够用",而是在想另一件事:
哪些能力值得成为系统里的"基础语义",而不是靠临时配置拼出来的特例?
我对这 6 种类型的理解是这样的:
| 类型 | 解决什么问题 |
|---|---|
llm_call | 一次标准的模型调用 |
tool_call | 调用外部工具 |
react | 多轮思考-行动循环 |
branch | 运行时动态分支 |
parallel | 并行执行 |
human | 人工确认和流程暂停 |
重点不是"正好 6 个",而是边界必须清楚。
比如 branch 不是普通步骤加个条件,它会直接影响后面的执行路径;human 也不是某种异常处理,而是真实业务里高频出现的正常环节;react 更不是"多调几次模型",而是一个会自己循环、会调工具、会自己决定什么时候停的复合过程。
如果一开始不把这些差异拉开,系统往往会滑向一个常见陷阱:
表面上只有一种"通用步骤",但实际复杂度全被塞进了配置项、字符串和隐式约定里。短期看起来灵活,长期维护起来欲哭无泪。
4. 调度器最重要的工作不是"跑得快",而是"把控制流理顺"
聊到调度器,很多人第一反应是并发、性能、协程池。
这些当然重要,但我越做越觉得,调度器最核心的职责不是"跑得有多快",而是:
先把控制节点和执行节点分开。
在我的实现里,branch 和 human 这类节点不会直接丢进统一的执行批次,而是优先单独处理。
原因很简单:branch 的任务是决定"接下来走哪条路";human 的任务是让流程"停下来,等人确认"。它们的关键不是执行效率,而是流程该不该继续、该怎么继续。
只有先把这类控制节点消化掉,后面真正需要跑的业务步骤,才适合一起并发执行。
这个细节看起来不大,但会直接影响整个系统后面是不是清爽。如果控制流和执行流混在一起,虽然功能也能实现,但每加一种新能力,主循环就会臃肿一分。
5. 为什么 ReAct 没有被我藏进普通的 LLM 调用里
不少系统会把 ReAct 当作 LLM 调用的一种"高级模式"。
接入是快,但隐患也很明显:在工作流层面,你已经分不清它到底是一个普通调用,还是一个会自己转好多轮的复杂节点。
我最后选择把 ReAct 单独做成一种步骤类型,主要基于两个原因。
第一,行为模式完全不同
普通的 llm_call 是"一问一答"。而 react 更像这样:
- 先想下一步该做什么;
- 决定调哪个工具;
- 拿到结果后继续想;
- 循环多轮,直到自己决定结束。
这已经和普通模型调用不是一回事了。
第二,它需要被单独观察和度量
只要一个步骤会自己循环、多次调用工具,就需要单独追踪:
- 一共转了多少轮?
- 调了多少次工具?
- 哪一轮最容易出错?
- token 消耗集中在哪?
如果把它完全藏进普通 LLM 调用里,这些信息就会糊成一团,排查问题时只能抓瞎。
做工程系统有个原则我越来越信奉:
只要某种行为已经明显不同,就不要假装它和别的一样。
6. 真正拉开 Demo 和系统差距的,是恢复能力和失败边界
如果目标只是"跑通一次",很多东西确实可以先省掉:
- 不做状态机
- 不做 checkpoint
- 不做沙箱
- 不做链路追踪
这些都不会影响第一次演示的效果。
但只要流程变长、工具变多、执行时间变久,它们就会从"锦上添花"变成"不得不补"。
6.1 状态机不是装饰
我在项目里把步骤状态收敛成了一组固定状态:
pendingrunningsuccessfailedtimeoutskippedcanceled
这样做的意义不是"枚举写得好看",而是让系统明确知道:
哪些状态转换是合法的,哪些是非法的。
如果这件事没定义清楚,重试、恢复、取消这些动作很容易互相打架,最后变成一场状态混乱的灾难。
6.2 Checkpoint 远不只是"存一下"
Checkpoint 说起来轻描淡写:"就是把当前状态存一下嘛。"
真做起来才会发现,它要回答的问题远比想象中多:
- 当前版本和之前版本是否一致?
- 一个执行到一半的步骤恢复后该算什么状态?
- 已经成功的节点还要不要再跑?
- 图状态和运行状态怎么重新对齐?
所以恢复能力真正难的地方,不在于"能不能重新开始",而在于:
能不能从正确的位置,继续往下走。
6.3 失败边界一定要收住
这个道理在工具调用上体现得尤为明显。
如果工具直接在主进程里跑,一旦失控,影响的不是某个步骤,而是整个服务。
所以我选择把工具执行放进 Docker 沙箱,再配上一整套资源限制、文件系统隔离、网络限制和超时处理。
做起来确实麻烦,但意义很清楚:
让一个问题停在这个步骤,不要往外扩。
7. 这 6 种步骤类型不是终点,而是一个稳定的起点
现在回头看,这套设计最重要的不是它已经覆盖了多少能力,而是给后续的扩展留了一个比较稳的骨架。
不管是后面做 Multi-Agent、Memory、Eval,还是各种 Demo 场景,都应该是在这个骨架上自然生长,而不是每次来一个需求就把核心结构推翻重写。
这也是我现在越来越在意的一点:
一个系统的长期价值,往往不取决于第一版做了多少功能,而取决于它后面能不能持续生长,还不把自己长坏。
如果你也在做 Agent 执行层的设计,欢迎在评论区聊聊你的思路。👋