用自然语言生成Dify工作流:从拖拽画布到代码化流程管理

发布时间:2026/11/4 18:01:20
用自然语言生成Dify工作流:从拖拽画布到代码化流程管理

1. 为什么我想把鼠标从画布上拿开:拖节点式工作流的真实痛点

在 Dify 画布里工作流节点一多,事情就开始变得不受控。我手里维护过几个超过三十个节点的流程,每次打开画布第一件事不是看逻辑,而是花十分钟找线:这条边从哪接到哪,那个分支为什么没有出口,Start 节点下方密密麻麻的箭头像一团被猫玩过的毛线。真正让人崩溃的是改需求,产品说"把第三层的判断条件换个说法",你顺着连线一处处找,改完还要确认没有遗漏引用。Dify 本身确实是目前把 LLM 应用落地做得很顺的平台,节点丰富,调试方便,但当工作流变成生产过程的一部分,拖拽这个交互方式就成了瓶颈。

拖节点最大的问题是"不可评审、不可追溯、不可复用"。工作流写完后,别人接手时只能对着画布猜;想对比上一个版本,画布没有 diff;想复制一套类似流程,只能重新拖一遍。更别提发不到生产环境时的忐忑——看起来所有节点都是绿的,但测试时发现某个变量名写错,整条链路直接静默失败。这些痛点不是 Dify 独有,所有可视化工作流工具都会遇到,但 Dify 因为是 AI 应用编排平台,节点逻辑复杂、分支多,问题暴露得更明显。

所以我决定换一条路:用自然语言描述工作流,让程序自动生成节点、自动排版、自动校验,最后通过接口发布到 Dify。现在这套流程我跑了小半年,稳定发布过几个生产应用,回头再看画布,它只承担两类工作:查数据和手工微调。这篇文章就是把完整的方案、代码思路、踩过的坑都整理出来,给同样被画布折磨的人一个可复制的参考。

1.1 复杂工作流里,画布不是舞台而是事故现场

当工作流只有五六个节点时,拖拽很愉快,连线清晰,一眼看全。但当节点超过十五个,问题就出来了:画布缩放比例不合适时,要么看全局看不清字,要么看局部看不到全局;连线的交叉路径毫无规律,检查一条链路的上下文非常费眼神;节点之间的间距没有统一标准,新加的节点随手放,越放越乱。我问过团队里几个同样用 Dify 的同学,大家一致表示,超过二十个节点后,"可视化"已经名存实亡,实际工作要退回对着 JSON 配置和日志来理解流程。

画布还有一层隐藏成本:它让我们忽略了"工作流本质上是结构化数据"这个事实。每次拖一个节点、连一条线,编辑器底层都在生成一个节点记录和一条边记录,只是我们操作的是图形界面而已。既然底层已经是结构化数据,那编辑方式完全可以换成程序化生成,拖拽仅仅是一个可选的交互形态,而不是唯一路径。

1.2 拖拽之外的另一条路:把工作流当代码管

我的思路很简单粗暴:把工作流当成一份需要编译、测试、发布的工程代码来管理。自然语言描述先进大模型,解析成结构化清单;结构化清单再渲染成 Dify 的 DSL 文件;DSL 文件做静态检查;通过后调用 Dify 控制台 API 导入并发布。整个过程里,人只负责写描述和确认关键决策点,其余全是脚本干。

这样换来几个实打实的好处。第一,工作流可以进 Git 了,每次改动都有 diff,可以回溯、可以评审,和管代码没有区别。第二,生成规则统一,所有节点命名、变量命名、位置布局都按程序约定来,不会再出现"开始2""LLM3"这种毫无辨识度的名字。第三,自动化校验能挡住大量低级错误,比如节点引用不存在、变量作用域越界、分支没有收敛,这些问题在画布里往往是运行到中间才暴露,而现在导入前就能发现。

1.3 我给自己定的三个目标

动手之前,我给自己定了三条硬指标:第一,能够用一句完整自然语言描述出目标流程,脚本能把它解析为节点和连线,我不需要手动指定任何坐标;第二,生成的 DSL 文件必须先通过静态校验,校验不过就不允许进下一步;第三,整个流程最后一步是调用 Dify 接口完成导入与发布,而不是人工复制粘贴 YAML。后来这三条标准基本都实现了,只有少数边缘场景需要人工兜底,后面我会单独说清楚哪些地方兜底。

2. 一句话到可发布工作流的完整链路:核心设计与选型

整套链路我用一个"中间表示"作为枢纽,所有下游处理都围绕同一份标准化 JSON 展开。你不需要关心大模型输出什么格式、Dify 的 DSL 字段怎么变,只要中间表示稳定,上游换模型、下游换平台版本都不至于推倒重来。

2.1 核心设计:中间表示是唯一的真源

所谓中间表示(IR),就是用一套与具体平台无关的 JSON 结构描述工作流:有哪些节点、每个节点什么类型、节点输入来自哪里、节点之间怎么连线、需要哪些全局变量。它既是大模型输出的目标格式,也是生成 Dify DSL 的输入。这个设计优点很明显:如果哪天我从 Dify 迁移到别的编排平台,只需要重写 IR 到目标格式的转换器,解析、排版、校验全部复用。

IR 大概长这样:

{ "app_name": "客服意图分流", "description": "根据用户问题判断意图,查询订单或转人工", "variables": [ {"name": "user_query", "value": ""} ], "nodes": [ {"id": "start", "type": "start"}, {"id": "question_classifier", "type": "question-classifier", "title": "意图分类"}, {"id": "http_query_order", "type": "http-request", "title": "查询订单"} ], "edges": [ {"source": "start", "target": "question_classifier", "sourceHandle": "source", "targetHandle": "target"}, {"source": "question_classifier", "target": "http_query_order", "sourceHandle": "class_1", "targetHandle": "target"} ] }

节点和连线分开存,目的就是让排版逻辑只读 edges,让 DSL 转换器只读 nodes,各干各的活。

2.2 技术选型与运行环境

我用的是 Python 3.10,配合 OpenAI 兼容接口的模型服务来解析自然语言,任意支持 function calling 或输出 JSON 的模型都能干这个活。环境上只要有 Python 和 requests 就行,不需要额外框架。Dify 侧用的是社区版,DSL 文件本质上是一个 YAML,里面包含 app 配置和 workflow 图结构。在这一步我特意把 Dify 官方 DSL 的字段名和 IR 做了隔离,避免 model 名称、temperature 这类参数把 IR 弄脏。

实际跑起来,链路命令是这样:

python gen_workflow.py --desc "用户提问后先判断意图,如果是订单查询就调用查询接口,否则转人工" --output workflow.yaml python validate_workflow.py --file workflow.yaml python publish_workflow.py --file workflow.yaml --base-url http://dify.example.com

三步独立执行,哪一步挂了都能单独重试。这个拆法对排障特别友好,后面每个脚本的报错信息也都按步骤收敛。

2.3 为什么不直接让大模型输出 Dify DSL

有人肯定想问:既然 Dify 本身吃 YAML,为什么不直接让大模型输出 YAML,省掉 IR 这一层?我刚开始也这么干过,结果非常惨。大模型直接输出 DSL 时,经常出现字段名编造、节点类型写错、sourceHandle 用了不存在的值。更麻烦的是,Dify 的 DSL 不同版本字段会变,模型训练数据里混着多个版本的写法,直接生成几乎必然踩版本差异的坑。

IR 因为字段少、语义固定,模型生成的准确率高得多。等 IR 稳定后,由代码去填充 Dify 需要的完整字段,该填默认值填默认值,该转枚举转枚举,机器做这种确定性的转换不会出错。这就是"让模型做擅长的事,让代码做确定的事"的原则。

3. 自然语言解析层:把用户描述变成标准化的节点清单

整个链路里,自然语言解析是最容易飘的一部分,也是体验感最强的部分。描述写得太含糊,后面的节点一定乱;描述清晰,生成的工作流基本八九不离十。这个环节的核心不在模型,而在 Prompt 和输出约束。

3.1 输入描述与 Prompt 设计

我给模型设置的指令很明确:从自然语言中抽取三样东西——全局变量、节点列表、连线列表。节点类型只允许使用枚举值,枚举直接对应 Dify 的核心节点:start、end、llm、question-classifier、if-else、http-request、code 等。连线必须显式说明从哪个节点的哪个出口到哪个节点的入口,禁止模型自己脑补不存在的中间节点。

Prompt 里我放了两个 few-shot 示例,一个简单三节点流程,一个带条件分支的流程。示例的主要作用是让模型理解"我的描述颗粒度是很粗的,你只需要抽骨架,不要自己发明流程"。

我常用的一段描述模板是:

流程背景:{业务背景} 用户输入:{用户会输入什么} 处理要求:{需要做哪些判断,调用哪些外部动作} 输出格式:严格按 JSON Schema 输出,节点类型只能为[start, end, llm, question-classifier, if-else, http-request, code, template-transform]

有一点值得强调:描述里不要写"帮我加一个错误处理分支"这种模糊需求,要写清楚"HTTP 请求失败时输出提示文本并结束流程"。模型对错误分支的理解往往不如预期,人工把边界条件说清楚,解析准确率会显著提升。

3.2 输出 Schema:节点、连线、变量三位一体

为保证模型输出稳定可用,我直接用 JSON Schema 约束输出结构。Schema 里每个节点必须有 id 和 type,title 可以由模型根据上下文生成,但限定不能带特殊字符。连线必须有明确的 source、sourceHandle、target、targetHandle。变量则列出工作流开始节点需要接收的外部输入。

Schema 定义出来后,下一步最关键:写一个解析后校验函数,对模型输出做防御性检查。检查项包括但不限于:是否缺少 start 或 end 节点,连线引用的节点 id 是否存在,sourceHandle 是否符合被引节点类型的出口名称,是否存在环(Dify 不支持循环依赖,虽然部分节点支持自循环语义,但骨架层我不允许环出现)。

3.3 一个真实的解析结果长什么样

我拿一个真实小需求举例。描述是:"用户进来先输入订单号,系统先判断订单号是否为空,为空提示用户先输入;不为空就去查订单接口,查到返回订单状态和物流信息,查不到提示订单不存在。"这段描述解析后生成的核心 JSON 如下:

{ "variables": [{"name": "order_id", "value": ""}], "nodes": [ {"id": "start", "type": "start"}, {"id": "check_empty", "type": "if-else", "title": "判断订单号是否为空"}, {"id": "ask_input", "type": "llm", "title": "提示输入订单号"}, {"id": "query_order", "type": "http-request", "title": "调用订单查询接口"}, {"id": "success_end", "type": "end", "title": "返回查询结果"} ], "edges": [ {"source": "start", "target": "check_empty", "sourceHandle": "source", "targetHandle": "target"}, {"source": "check_empty", "target": "ask_input", "sourceHandle": "true", "targetHandle": "target"}, {"source": "check_empty", "target": "query_order", "sourceHandle": "false", "targetHandle": "target"} ] }

拿到这个 IR 后,后面所有事情都跟模型无关了。这里我不展开每个节点的配置细节,因为 Dify 不同节点的 data 字段差异极大,下一步单独讲。

4. 从中间表示到 Dify DSL:节点数据的翻译细节

IR 是"骨架",Dify DSL 是"完整实体"。这一步要把每个节点的具体配置项填满:模型参数、提示词模板、HTTP 请求配置、代码内容等。翻译得好不好,直接决定导入 Dify 后是否要手工返工。

4.1 Dify DSL 里到底存了什么

Dify 导出应用的 YAML 结构分两大块:app 配置和 workflow 配置。app 段记录应用名称、模式、图标、描述等,workflow 段里最关键的是 graph,graph 下有 nodes、edges、variables 三个数组。每个 node 有 id、type、data 三个字段,data 里再放 title、position、version,以及该类型节点特有的配置项。edges 的构成相对简单,主要是 source、sourceHandle、target、targetHandle。

理解 Data 字段非常重要,因为不同类型节点的 data 差异太大了。例如 llm 节点的 data 里有 model、prompt_template、vision 等;http-request 节点有 method、url、headers、body;code 节点有 code、variables、outputs;question-classifier 节点有 class_list 和 instruction。转换器必须有一个登记表,按类型映射默认配置,否则生成出来的 DSL 会被 Dify 拒绝或运行时出错。

4.2 不同类型节点的 data 映射规则

我给转换器定义了一个配置表,每种节点类型对应一套默认 data 生成函数。核心映射逻辑如下:

IR 节点类型Dify 节点类型data 必填项默认经验值
startstartvariables从 IR 的 variables 映射
endendoutputs按上游节点输出名生成
llmllmmodel, prompt_template模型名由环境变量统一指定
http-requesthttp-requestmethod, url, bodybody 使用变量引用上游输出
if-elseif-elseconditions从 IR 的 condition 字段映射
question-classifierquestion-classifierclass_list, instruction类别来自 IR 的 classes
codecodecode, variables, outputs代码块 + 入参出参声明

以 llm 节点为例,转换函数会做这样的事:把 IR 里的 prompt 文本填进 prompt_template,把系统级参数(model、temperature、max_tokens)从环境变量读取并写入 data。这样 IR 里不需要出现模型名,换模型只改环境变量,不用重新生成整个 DSL。

4.3 翻译时容易出错的三个地方

第一个坑是 sourceHandle。if-else 节点的出口 handle 不是"source",而是"true"和"false";question-classifier 的出口 handle 是每个类别的 key。转换器不能用统一的"source"硬写,必须查类型配置表。

第二个坑是节点 id。Dify 里节点 id 必须全局唯一,且变量引用方式用的是 "#节点id.变量名",比如上游 LLM 输出的文本在表达式里写成 "#llm_node.text"。如果转换器生成 id 时不加前缀,很容易与 Dify 内置字段撞车。我习惯用 "node_" 加自增序号作为 id 前缀,保证唯一且可读。

第三个坑是变量作用域。node data 里引用的变量必须存在于该节点的上游链路中,跨分支引用在 Dify 里虽然技术上能写入 DSL,但运行时会报"变量未定义"。这类错误需要专门的校验层拦截,不能靠转换器保证。

5. 自动排版:用拓扑排序解决画布混乱

既然不让人手工拖,那么生成的节点坐标就必须自动算好。排版的目标很简单:节点不重叠、连线尽量少交叉、阅读顺序符合流程方向。我用的不是复杂图布局算法,而是基于拓扑排序的分层布局,效果稳定,代码量也少。

5.1 最简单的分层布局方案

基本思路是:根据 edges 计算每个节点的深度。深度为 0 的是 start 节点;一个节点的深度等于它所有上游节点深度的最大值加一。然后按深度分层,同一层的节点按出现顺序纵向排列,横坐标 = 层级序号 * 层间距,纵坐标 = 层内序号 * 节点间距。对 Dify 工作流这种通常是"从左到右"的流程,这样的布局已经够清晰。

分层结果示例:start 在第 0 层,if-else 在第 1 层,两个分支节点在第 2 层,end 在最后一层。这样视觉上天然形成一条主链路加分支,比手动画的还规整。

5.2 坐标计算的 Python 实现思路

核心代码不复杂,做一次拓扑排序再加一层坐标映射:

from collections import deque def layout_nodes(nodes, edges): indegree = {n["id"]: 0 for n in nodes} children = {n["id"]: [] for n in nodes} for e in edges: children[e["source"]].append(e["target"]) indegree[e["target"]] += 1 queue = deque([n["id"] for n in nodes if indegree[n["id"]] == 0]) depth = {n["id"]: 0 for n in nodes} order = [] while queue: node_id = queue.popleft() order.append(node_id) for child in children[node_id]: depth[child] = max(depth[child], depth[node_id] + 1) indegree[child] -= 1 if indegree[child] == 0: queue.append(child) if len(order) != len(nodes): raise ValueError("检测到循环依赖,无法排版") levels = {} for node in nodes: levels.setdefault(depth[node["id"]], []).append(node["id"]) for layer, ids in levels.items(): for idx, node_id in enumerate(ids): yield node_id, 320 + layer * 360, 160 + idx * 140

坐标算完后,写回每个节点的 data.position。注意 Dify 的 position 是相对画布内容的坐标,数值太大或太小都会导致画布初始视角奇怪,我一般把最小横坐标设在 300 左右,留出左边空白给开始节点。

5.3 生成完排版,为什么还要人工微调

自动排版解决的是"能看"的问题,"能看清"还差一步。分支多的时候,两个分支字节点可能被挤到同一纵坐标附近,导致线交叉。我留了一个手工微调入口:生成 YAML 后先导入到一个"暂存应用",打开画布人工看一遍,把个别节点的 position 拖一拖再导出最新 DSL。这一步不超过五分钟,但体验提升非常明显。毕竟画布不光是机器跑的,更多时候是给人看的。

6. 校验这件事,比想象中更值钱

自动生成的东西如果直接进生产环境,出一次事故的成本可能抵消掉所有效率收益。所以我把校验做成发布的前置门槛,校验不过,整个发布流程直接中断。过去半年,这套校验至少拦住了几十次明显的问题,其中一半以上是变量引用错误。

6.1 校验清单:从结构到变量引用的七道关卡

第一道是 YAML 语法检查,确认文件能被完整解析。第二道检查 graph 结构中 nodes 和 edges 是否为空、id 是否重复。第三道检查所有连线引用的 source 和 target 节点是否真实存在,这在手写 DSL 时是高频错误。第四道检查 start 和 end 节点是否存在且唯一。第五道检查节点类型是否在 Dify 当前版本支持的白名单里。

第六道最重要:变量引用检查。我会遍历所有节点 data 里出现的表达式,抽取形如 "#node_id.variable_name" 的引用,然后用图遍历算出每个节点的上游可见变量集合,验证引用是否可见。第七道检查各节点的必填配置项,比如 http-request 节点的 url 是否为空,llm 节点的 model 是否填写。

6.2 校验实现与错误定位

校验函数我习惯返回结构化错误列表,而不是直接抛异常。这样一条命令能报出所有问题,省得改一个错跑一次。错误信息尽量给出上下文位置,例如:

[edge_error] 边 'edges[3]' 引用了不存在的节点 'llm_2' [variable_error] 节点 'http_query' 引用了变量 '#llm_2.text',但该变量不在上游可见范围内

定位到具体 id 和字段路径以后,配合 YAML 文件的行号,修复成本非常低。我实际使用中,最常见的组合错误是改了上游节点 id,忘了同步所有引用,第七道校验能一网打尽。

6.3 编译不通过就拒绝发布

校验通过与否不应给任何通融的口子。我在写脚本时特意把校验和发布拆成两个独立进程,发布脚本会读取一个"校验通过标记文件",如果没有标记直接拒绝执行。这样在自动化流水线里,谁也没法跳过校验把未通过的版本发上去。

后来我又在流水线里加了一道"变更范围确认":diff 最近两个 DSL 文件,把变化的节点列出来,发布前打印给操作者确认。这个设计虽然简单,但对减少误操作很有帮助,因为生成式工作流的改动经常悄悄影响多个分支。

7. 导入与发布:走通 Dify API 的最后一公里

DSL 生成并校验通过后,剩下的就是让 Dify 认账。我把发布流程分成三步:创建或找到目标应用、导入 DSL、发布为线上运行版本。整个过程全部通过 Dify 控制台 API 完成。

7.1 环境准备与 API 调用基础

Dify 的控制台 API 和应用 API 是两套体系,这里用的是控制台 API,因为它能管理应用定义和发布操作。需要准备的信息有三个:控制台地址、API 访问令牌、目标应用标识。令牌在 Dify 控制台的管理后台里生成,注意不要把它写进仓库,用环境变量传。

调用前先确认网络能访问到 Dify 服务。我自己部署时遇到过一个奇怪现象:服务端口通但 API 请求一直报 SSL 错误,检查半天发现是后面挂了反向代理,证书链没配完整,结果把 requests 库的 verify 参数改成对应根证书后就好了。这类环境问题不是代码问题,排起来很费时间,建议一开始就把证书链条理清楚。

7.2 从 DSL 到线上应用的完整调用链

发布流程的调用链大概是:先通过应用列表接口查一下目标应用是否存在,不存在就用导入接口创建一个新应用,存在就用导入接口覆盖它的 DSL,最后调用发布接口生成一个新的运行版本。

import requests base = "https://dify.example.com/console/api" headers = {"Authorization": "Bearer {}".format(api_key)} # 导入 DSL with open("workflow.yaml", "rb") as f: resp = requests.post( f"{base}/apps/import", headers=headers, files={"file": f}, data={"mode": "workflow", "name": "客服意图分流"} ) app_id = resp.json().get("app", {}).get("id") # 发布为运行版本 pub = requests.post( f"{base}/apps/{app_id}/publish", headers=headers )

有一个细节必须注意:导入接口可能会因为同名应用返回冲突或自动加后缀,所以在导入前先根据名称查询再决定走创建还是覆盖逻辑。我实现的脚本里有一个目标应用查找函数,匹配上了就把导入模式设置成"更新已有应用",匹配不上才新建,避免每次发布都生成一个"客服意图分流(1)"。

7.3 发布后的回检:用真实对话跑一遍

自动化发布完不等于结束,我强制自己在脚本末尾调用一次"运行测试会话"接口,用几条预置输入跑通全流程。回检脚本会有少量断言,比如判断意图分类节点输出的分类名称是否在期望集合内,HTTP 请求的返回码是否为 200。这一步是耗时大头,但也是最有安全感的一步。

回检通过后,把 DSL 文件打上 git tag 提交,版本号和 Dify 里的运行版本一一对应。以后线上出了问题,能迅速知道线上跑的是哪个 DSL 文件,直接反查当时的变更提交,整个排查链路是通的。

8. 这些坑我都踩过,提前写给你

自动生成工作流不是一跑就顺的事,迭代过程中踩过不少坑,有些是 Dify 版本差异带来的,有些是自己设计欠考虑。整理几个典型的,给准备效仿的人排雷。

8.1 DSL 版本陷阱与网络环境问题

Dify 社区版迭代快,不同版本的 DSL 字段有差异。最典型的是 nodes 里部分节点的 data 结构变了,旧版本的 code 节点字段和新版本不兼容,导入后要么报错要么静默丢配置。我的对策是固定容器镜像版本,升级前先在测试环境跑一遍导入脚本,用 diff 确认 DSL 结构没有变化再升生产。

网络环境上,SSL 证书问题前面说过一次,这里再补一个:如果你用自签名证书跑测试环境,requests 会出现证书校验失败,不要顺手设 verify=False 绕过,正确做法是把根证书加到环境变量或请求参数里,否则换到正式环境时同样的代码可能因为证书校验方式不同出现诡异问题。

8.2 function 节点和变量引用的坑

Dify 的 code 节点本质是执行一段 JavaScript,代码里通过 result 返回输出。最初我的转换器把 code 节点的自定义变量名直接写成 IR 里的变量名,后来发现 Dify 对变量名有限制,含有连字符或中文的变量在代码里引用时会出问题。现在我在代码节点内统一做一层映射:入参改成合法的 JS 标识符,代码块里先用映射后的名字取值,最后再映射回原始变量名输出。这一步是踩了线上运行报错后补上的,建议一开始就做。

另外,code 节点的变量引用用的是 "#node_id.input1" 这类写法,但节点 id 里的字符如果要作为 key 使用,必须保持完全一致。有一次我生成 YAML 时 id 里带了引号外的空格,肉眼几乎看不出来,Dify 导入却一直提示变量不存在。后来我在所有 id 生成后强制做正则清洗,只保留字母、数字和下划线,这类问题再没出现过。

8.3 截止目前仍需人工处理的部分

坦白说,自动化还没完全替代画布操作。我所以的流程里,输入条件特别复杂的 if-else 分支逻辑,大模型经常抽不准条件表达式,需要人工在生成的 DSK 上补 conditions。使用 HTTP 接口时,鉴权配置如果涉及动态从变量读取令牌,转换器也需要额外写规则才能正确生成,这类需要我人工确认。

最后生产环境的"发布到线上"操作,即使 API 已经完全自动化,我仍然选择在流水线里保留一个确认步骤,由负责发版的同学检查变更列表没问题后手动确认。发布这种操作,多一道人眼确认,永远比不过度自动化要好。

如果你也想做这套流程,建议从一个小而完整的场景入手,比如把"意图分类加 HTTP 查询"这个经典流程先跑通,再慢慢加节点类型和复杂分支。自然语言生成工作流这件事,最大价值不是取代画布,而是让你重新掌控住工作流本身——当你能像看代码一样看流程,像发布代码一样发布流程,画布就只是展示层,不再是逻辑的真相所在。