uuos
全部文章

Go 并行 DAG 执行引擎的设计与实现

2026-07-06·uuos 团队深度技术Go引擎

uuos 的执行引擎要回答一个问题:给定一张用户在画布上连出来的图,如何正确、并行、可恢复地执行它?这篇文章讲我们的四个核心设计决策。

决策一:编译与执行分离

我们不在运行时遍历图。工作流保存时先经过编译器,产出一个不可变的执行计划;运行时只消费计划,不理解图。

编译器做两件事:

  1. 节点级校验——9 种节点各有校验规则(LLM 节点必须有模型档位,HTTP 节点必须有合法 URL……),不合法的图在保存时被拒绝;
  2. 分层拓扑排序——Kahn 算法变体,把 DAG 压成有序的层序列。

这个分离带来的好处是错误前移:生产运行时遇到的图,一定是编译期校验过的图。运行时的失败只剩下真正的运行时问题(网络、模型服务、超时),排障范围小得多。

决策二:分层拓扑,而不是逐节点调度

经典 Kahn 算法输出一个节点的线性序列。我们的变体输出

Layer 0: [start]
Layer 1: [llm-a, llm-b]     // 入度同时归零 → 同层
Layer 2: [end]

入度同时归零的节点进同一层——它们之间不存在数据依赖,可以安全并行。运行时逐层推进,层内用 goroutine + errgroup 并发执行:

for _, layer := range plan.Layers {
    g, ctx := errgroup.WithContext(ctx)
    for _, node := range layer {
        g.Go(func() error { return r.executeNode(ctx, node) })
    }
    if err := g.Wait(); err != nil {
        return err // 层内任一失败,取消同层其余节点
    }
}

相比逐节点的动态调度器(工作队列 + 入度计数),分层模型牺牲了一点理论并行度(下一层必须等上一层全部完成),换来的是执行过程可解释:运行面板里的「Layer 1 · 并行」不是可视化的近似,就是引擎的真实行为。对一个用户要盯着调试的产品,可解释比压榨最后 10% 并行度重要。

决策三:队列放在 PostgreSQL 里

运行任务需要持久化:进程重启不能丢任务,长运行要可追踪。我们选了 River——基于 PostgreSQL 的任务队列,由独立 Worker 进程消费。

用数据库做队列在超大吞吐下有天花板,但对工作流执行这个场景它是对的选择:

  • 事务性:任务入队和业务数据写入在同一个事务里,不存在「订单写成功了、任务丢了」的中间态;
  • 少一个组件:不需要为了队列引入 Kafka/RabbitMQ,部署拓扑里只有 PostgreSQL 和 Redis;
  • 可观察:任务就是表里的行,排障用 SQL。

决策四:重试必须尊重「用户已经看到了什么」

流式输出让重试变成一个语义问题。指数退避重试 LLM 调用是标准做法,但如果模型已经流出了 500 个字才断掉,重试会让用户看到同一段话开始出现第二遍。

所以引擎把 LLM 错误分为两类:

  • pre-delta(尚未产生任何可见输出):按 2^attempt 秒退避重试,用户无感知;
  • post-delta(已有流式内容到达用户):不重试,把错误如实上报。

宁可让用户看到一次明确的失败,也不制造「内容重复」这种更难解释的体验。重试策略不只是可靠性参数,它是用户体验的一部分。

小结

四个决策共同的取向:把复杂度花在能被用户感知的地方。编译期校验让错误前移,分层模型让执行可解释,PostgreSQL 队列让部署简单,重试语义保护流式体验。引擎不追求论文里的最优,追求生产里的可靠与可解释。