【工程优化】我重写了 Dify 的“一键整理”
🧫

【工程优化】我重写了 Dify 的“一键整理”

Tags
Created time
Nov 3, 2025 03:25 AM

一、问题的起点:当布局失去结构意识

在早期的 Dify 工作流编辑器中,“一键整理”是个让人又爱又恨的功能——
它能让节点自动排布整齐,却常常在复杂场景下“越整理越乱”。尤其当一个工作流膨胀到几十甚至上百个节点时,Dagre 的表现开始失控。
其实,这并算是一个 Bug,而是Dagre 不符合工作流日益增长的现状。
Dagre 的算法本质上是为中小型、层次化的有向图(DAG)设计的,它擅长把树状结构压成二维层级。但当图中出现循环、嵌套、子图容器、交叉依赖等复杂逻辑时,它的布局假设被破坏,整体排列就会失去“语义的稳定性”。
于是我意识到:

二、算法的边界:Dagre 能做什么,不能做什么

Dagre 的设计哲学是“确定性 + 简洁性”。它基于 Graphlib 图模型,将所有节点按照 rank(层级)分组,通过线性优化算法最小化边交叉与节点偏移。它的典型特征包括:
特性
说明
优势
局限
Layered Layout
按拓扑排序分层
结构清晰、可预测
无法处理环路与复杂嵌套
Rankdir (TB/LR)
控制方向
简单易用
缺乏自适应能力
固定 Node Size
假设节点尺寸固定
快速布局
不支持动态尺寸与容器节点
在 Dify 早期版本中,Dagre 一度完美解决了 5~10 个节点的布局需求。但当规模超过 30 个节点后,“整齐”变成了幻觉
  • 子图(Loop、Iteration)内部节点被挤压;
  • 多层嵌套时位置漂移;
  • 边路径交错,语义层级被破坏。
因此,用户开始抱怨“一键整理没用”,而我意识到:我们可能需要一个更优的解法

三、也许可以试试 ELK

我决定从“让图变整齐”转向“让系统变可读”。这就是从 Dagre 到 ELK 的转折点:我开始意识到布局并不仅仅是画面的问题,而是逻辑关系的映射问题。
ELK(Eclipse Layout Kernel)并不仅仅是一个图布局库,它更像是一种结构化思维工具。
它把图形排布分成多个可控制的维度:算法层负责整体走向,约束层控制间距与边线,语义层负责表达节点间的逻辑含义。
这种方式让开发者可以更精细地定义“什么重要”、“谁先谁后”。
这一转变意味着:
我不再让算法决定形态,而是让思想决定结构。
 
notion image

四、ELK 的体系

ELK 的底层架构分为三个层次:
  1. 算法层(Algorithm Layer):定义布局范式,如 layered, force, radial, mrtree 等。
  1. 约束层(Constraint Layer):通过参数表达语义,如 elk.spacing.nodeNode, elk.edgeRouting, elk.alignment
  1. 语义层(Semantic Layer):你可以在节点元数据中表达逻辑关系,例如子图、分组、优先级、依赖方向。
它不像 Dagre 那样“一次排好”,而是允许我构建一个可演化的秩序生成器。我可以针对不同类型的子结构使用不同算法:
const elkOptions = { 'elk.algorithm': 'layered', 'elk.layered.spacing.nodeNodeBetweenLayers': '100', 'elk.spacing.nodeNode': '80', }
或者在循环子图中用 force,在主干用 layered
layout.algorithm = isLoop ? 'force' : 'layered'
这让“整理”变成一种结构理解的过程,而非几何调整。

五、代码层解读

我的核心逻辑在 useWorkflowOrganize 这个 Hook 中实现。
const handleLayout = useCallback(async () => { const { getNodes, edges, setNodes } = store.getState() const nodes = getNodes() // Step 1: 子图(Loop / Iteration)内部单独布局 const childLayouts = await Promise.all( nodes.filter(isLoopOrIteration).map(node => getLayoutForChildNodes(node.id, nodes, edges)) ) // Step 2: 动态调整容器尺寸 updateContainerSize(childLayouts) // Step 3: 主图布局 const layout = await getLayoutByELK(nodes, edges) // Step 4: 对齐层级中心 alignByLayer(layout) // Step 5: 更新并同步历史 setNodes(layoutedNodes) saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize) }, [])

六、实现深析:ELK 布局栈的结构思想

在迁移过程中,我重写了整个布局栈,让 Dagre 的调用保持兼容,但底层全面替换为 ELK 的 layered 布局。

1. ROOT 层(主图布局)

核心参数定义在 ROOT_LAYOUT_OPTIONS
const ROOT_LAYOUT_OPTIONS = { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.layered.spacing.nodeNodeBetweenLayers': '100', 'elk.spacing.nodeNode': '80', 'elk.edgeRouting': 'SPLINES', 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.thoroughness': '10', 'elk.separateConnectedComponents': 'true', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', }
这层定义了系统级的走向、连线方式与分层逻辑。

2. CHILD 层(子图递归布局)

每个循环或迭代节点都会调用:
const CHILD_LAYOUT_OPTIONS = { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.spacing.nodeNode': '60', 'elk.edgeRouting': 'SPLINES', 'elk.layered.thoroughness': '10', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', }
以保证嵌套结构能在独立空间中自洽展开。

3. If-Else 分支:语义端口化

对于条件节点(IfElseNode),我使用 ELK 的 port 机制来替代虚拟节点。这是从几何思维走向语义思维的关键转折。
Port(端口) 是 ELK 在“节点级别的连接点”抽象:
  • 节点(Node)是容器;边(Edge)并不是直接连到节点本体,而是连到节点上的“端口(Port)”;
  • 每个 Port 定义了“放在哪个边”(side)、“在该边的顺序”(index);
  • 它是布局算法在“多出口场景”中保持秩序的锚点。
const ports = sortedChildEdges.map((edge, index) => ({ id: `${ifElseNode.id}-port-${edge.sourceHandle ?? index}`, layoutOptions: { 'port.side': 'EAST', 'port.index': String(index), }, }))
然后,边(edge)不再直接连接节点,而是连接具体端口:
elkEdges.push({ id: `edge-${index}`, sources: [edge.source], targets: [edge.target], sourcePort: `${ifElseNode.id}-port-${edge.sourceHandle ?? index}`, })
在节点级设置:
'elk.portConstraints': 'FIXED_ORDER'
这样可以确保:
  • 分支顺序与 UI 保持一致;
  • 交叉显著减少;
  • 省去“虚拟节点”的维护成本。

七、ELK 的哲学

ELK 的哲学,要从 三个问题 出发:
  1. 你的图想表达什么?(逻辑层)
  1. 图中的“重力中心”是什么?(语义层)
  1. 哪些约束决定了美观?(几何层)
学习顺序应是:
学习层次
问题
对应 ELK 概念
建议实践
结构逻辑
图想表达什么
algorithm
试验不同算法(layered, force, radial)
语义关系
节点的优先/依赖关系
constraints
调整 elk.spacing, elk.direction
表达方式
布局的视觉节奏
rendering
结合 ReactFlow / canvas 动态调整
不要追求“最整齐的布局”,而是要寻找“最能表达系统逻辑的布局”。
Dagre 是确定性的——它相信世界有唯一的最优排列。
ELK 是开放的——它相信世界可以在张力中达成平衡。
所以我从 Dagre 迁移到 ELK。我的解决方案也从“怎么排得好看”,变成:
“系统是否在空间中表达了它的逻辑与意图?”

八、尾声

也许“一键整理”永远不会完美,但它在不断逼近的,不是最优解,而是我对 workflow 一种理解