做AI应用开发的朋友,一定有过这种体验:需求方说“帮我加一个判断,如果用户情绪比较差,就先转人工”,你算了算,要从主流程代码里找到那一段if-else,改完测试,发布,结果他又说“情绪阈值再调一下”。如果只是几次,代码还能撑,可一旦类似流程到了十几条,最怕的就是硬编码。可视化编排说白了就是把流程节点拖来拖去,自动导出DSL配置;Workflow-DSL就是用来描述这套流程的领域语言。我把这套流程代码抽成配置,再配一个可视化画布,最后打造成了一个基于Workflow-DSL的AI工作流编排系统。今天就把整个改造过程、踩过的坑、以及可以直接抄作业的DSL方案整理出来。
这个方案适合谁?跟我一样被AI流程反复调整折磨的开发者,想给业务团队提效的技术负责人,以及在做AI智能体、自动化办公工作流、内容生成流水线的人。零代码不等于消灭代码,而是把经常变的流程逻辑从代码里剥离出来,让业务人员也能看懂流程、甚至自己调整。下面直接讲干货。
1. 先聊聊为什么要折腾Workflow-DSL
1.1 硬编码流程到底硬在哪里
我早期写AI流程都是直接在Python函数里串步骤。比如做一个客户反馈的自动分类和处理,第一版大概是这样的:
- 接收一条用户反馈
- 调用大模型做情绪判断
- 如果情绪消极,转人工
- 如果情绪正常,自动生成回复草稿
- 最后保存到数据库
听起来不复杂,代码写起来也就几十行。但问题是,这类流程会随着业务快速膨胀。今天要加一个“VIP用户优先处理”,明天要把“转人工”改成“先发安抚消息再转人工”,后天可能又要加一个“AI生成内容必须经过敏感词校验”。每次改需求,你都在主函数里小心翼翼地找插入点。
硬编码的痛可以归纳成几个:
第一,流程逻辑和业务代码揉在一起,改流程就等于改代码,测试、发布、回滚,周期极长。哪怕只是调整一个节点顺序,也要走完整的迭代流程。
第二,AI节点天然有不确定性。你在大模型这一步做了Prompt和参数调优,但流程本身的判断条件、分支走向却是写死的。业务方真正想调的东西,往往是流程本身,而不是代码。
第三,沟通成本高。产品经理画了一个流程图,开发照着写成代码,等上线后发现流程图和代码不一致,到底以谁为准?我见过太多次流程图已经改了,代码还是旧的。
这些都是硬编码带来的直接问题。所以我想做的第一件事,就是把流程描述从代码中抽出来,让流程真正变成一份可以读写、可以展现、可以分享的配置。
1.2 可视化编排解决的不只是“不用写代码”
可视化编排听起来像低代码平台里那些拖拽工具,比如类似Coze、Dify这类AI智能体平台做的操作面板。其实思路是一致的:把流程节点画成图形,用连线表达依赖关系,最终生成一份机器可读的DSL。
但很多人理解偏了,以为可视化编排是为了让业务零代码写流程。我的理解是,可视化编排更大的价值在于降低流程的可读成本和修改成本。业务人员拖一拖、连一连,能直观确认“用户先经过大模型,再走条件判断,命中A分支就发邮件,命中B分支就转人工”。这种确认动作本身就是在对齐需求。
真正实现零代码改造,通常分为两步:第一步,核心开发把常用节点封装好,比如LLM节点、条件节点、HTTP请求节点、数据库节点;第二步,业务人员通过画布拖拽组合这些节点,形成自己的AI工作流。底层实际上就是生成一份Workflow-DSL配置,引擎拿到配置后按图执行。业务人员看不懂代码,但能看懂一张流程图,这就是零代码的前提。
可视化编排还有一个隐藏价值:流程版本化和多人协作。代码天然有Git做版本管理,但流程图很容易变成一张到处传的图片。如果把流程沉淀为DSL,就可以纳入版本管理,每一次改动都有迹可循。这个价值在实际落地中非常明显。
1.3 不是所有场景都适合上DSL
讲了这么多DSL的好处,我也要说句公道话。如果一个AI工作流只有三五个节点,且整个项目一年都不变一次,直接用代码写反而更快。DSL、可视化编排、工作流引擎这套东西是有成本的,引擎要维护,DSL要设计,画布要做。
我自己的判断标准是:
- 流程有多个分支,或者需要人工介入
- 流程变更频率超过每个月一次
- 有非开发角色需要理解或参与流程调整
- 同一种流程模型需要在多个项目中复用
只要满足其中两三条,投入做Workflow-DSL和可视化编排就值得。如果只是自己写个小demo,那就别折腾,代码一把梭最香。
2. Workflow-DSL设计:先定义一套能跑起来的流程语言
2.1 最小可用DSL:节点、边、上下文
要设计一套DSL,不需要一开始就整很复杂。我用的底层思路就是有向图模型,核心元素只有三个:节点、边、上下文。
节点表示一个具体的执行步骤,边表示步骤执行的先后关系,上下文是节点和节点之间互相传递数据的载体。用JSON或YAML表达,就是一份很自然的DSL。我选的是YAML,因为可读性好,也方便非开发人员理解。
先看一个最小例子:
id: customer_feedback_pipeline name: 客户反馈自动处理 nodes: - id: start type: start next: llm_emotion - id: llm_emotion type: llm model: gpt-4o-mini prompt: | 你是客户服务助手,请判断以下反馈的情绪倾向。 如果情绪消极,输出 NEGATIVE,否则输出 POSITIVE。 反馈内容:{{ctx.input_text}} output: emotion_result next: emotion_branch - id: emotion_branch type: condition expression: "parse_json(ctx.emotion_result).sentiment == 'NEGATIVE'" true: reject_to_human false: generate_reply - id: reject_to_human type: http url: https://api.example.com/tickets method: POST body: content: "{{ctx.input_text}}" priority: high next: end - id: generate_reply type: llm model: gpt-4o-mini prompt: | 根据反馈生成回复草稿,语气专业友善。 反馈内容:{{ctx.input_text}} output: reply_draft next: save_result - id: save_result type: db action: insert table: feedback_replies data: input: "{{ctx.input_text}}" reply: "{{ctx.reply_draft}}" next: end - id: end type: end这个DSL已经能覆盖AI工作流里最常见的模式了。{{ctx.xxx}}是我约定的一种模板变量语法,表示从当前上下文里取值。看到这个DSL,一个没写过代码的人也能大致猜出流程走向,这就是DSL的可读性优势。
2.2 节点类型怎么定,决定了DSL的上限
节点类型是DSL设计里最关键的部分。节点类型太粗糙,会导致很多能力无法表达;节点类型太细,又会把DSL搞得非常冗长。我目前维护了一套基础节点,覆盖了大部分AI工作流场景:
| 节点类型 | 作用 | 关键属性 |
|---|---|---|
| start | 流程入口 | next |
| end | 流程结束 | 无 |
| llm | 调用大模型,支持自定义Prompt和模型参数 | model, prompt, output, next |
| condition | 条件分支,支持表达式判断 | expression, true, false |
| http | 调用外部API | url, method, headers, body, output |
| db | 读写数据库 | action, table, data |
| code | 执行一段用户自定义Python/JS函数 | language, source, output |
| human | 人工审批节点,会暂停流程等待反馈 | task, assignee, timeout |
| loop | 循环节点,处理列表场景 | items, item_var, body |
这些节点基本能满足AI工作流的常见场景:大模型调用、结构化输出判断、外部系统对接、人工兜底、数据持久化等等。节点类型一旦固定下来,可视化编排工具就能针对每种类型渲染不同的属性表单,比如LLM节点就渲染Prompt文本框和模型下拉框,HTTP节点就渲染URL请求头请求体编辑器。
关键原则是:节点要尽量原子化。不要搞出一个“智能客服节点”里面包含五六步操作,那样DSL的可组合性会变得非常差。每个节点只做一件事,流程的多样性靠节点组合出来。
2.3 从流程图到YAML:可视化画布和DSL的映射关系
做了可视化编排以后,我最常被问的问题是:“前端画布上的节点和线,怎么变成YAML?”这个问题其实没有想象的复杂。
前端画布本质上是维护了一份图数据。每个节点是一个对象,每条连线是一个source加target。举个简单的例子,前端状态里可能有这样一个数组:
{ "nodes": [ { "id": "node_1", "type": "start", "position": { "x": 100, "y": 80 } }, { "id": "node_2", "type": "llm", "position": { "x": 100, "y": 200 } }, { "id": "node_3", "type": "end", "position": { "x": 100, "y": 320 } } ], "edges": [ { "id": "edge_1", "source": "node_1", "target": "node_2" }, { "id": "edge_2", "source": "node_2", "target": "node_3" } ] }导出DSL时,只需要遍历所有节点,把节点属性转换成YAML节点,再根据每条连线的指向,给源节点加上对应的next字段。如果遇到条件节点,还需要读取配置的分支映射,判断哪条线是true分支、哪条线是false分支。
反过来,导入DSL时,把YAML里的每个节点构建成画布节点,给每个节点计算一个默认坐标,再根据next、true、false这些字段创建连线。坐标存不存进DSL?我的建议是:坐标属于画布展示信息,和流程逻辑无关,最好单独存在前端本地或者附加到YAML的可选字段里。这样同一个DSL在不同设备上看到的布局可以不同,逻辑却保持一致。
3. 手写一个轻量工作流引擎:解析、编排、执行
3.1 引擎的数据结构:怎么表示一张图
有了DSL,下一步就是让机器能执行它。我不会去用现成的BPMN引擎,太重了。自己写一个轻量引擎其实不难,核心就是把YAML转成一张图,然后用拓扑排序确定执行顺序。
执行引擎的第一步是解析DSL,构建节点字典:
import yaml def parse_workflow(dsl_str): data = yaml.safe_load(dsl_str) nodes = {} for node in data["nodes"]: nodes[node["id"]] = node return data["id"], nodes workflow_id, node_map = parse_workflow(open("workflow.yaml").read())这个节点字典存储了每个节点的完整配置,包括类型、参数、下一步指向。执行引擎不需要关心整个流程长什么样,只需要根据每个节点的next、条件分支结果、循环逻辑,一步步往下走。
我习惯在内存中预计算每个节点的入度,用于拓扑排序。入度为零的节点可以优先执行,执行完之后,把它指向的下游节点入度减一。这个过程有点类似拓扑排序,但因为是流程执行,我会保留“分支执行”的能力,而不是单纯地全局排序。
3.2 拓扑排序和环检测:避免流程图变成死循环
如果用户画了一个流程,把节点A连到节点B,又把节点B连回节点A,执行引擎就会陷入死循环。所以在导入DSL或执行之前,一定要做环检测。
我一开始偷懒没做,结果有个测试同学不小心画了一个环,请求直接超时,那叫一个惨。后来我直接用一个拓扑排序算法,既能排序,又能检测环,一举两得。
from collections import deque def topo_sort(node_map): indegree = {nid: 0 for nid in node_map} graph = {nid: [] for nid in node_map} for nid, node in node_map.items(): next_ids = get_next_ids(node) for next_id in next_ids: if next_id in graph: graph[nid].append(next_id) indegree[next_id] += 1 queue = deque([nid for nid in node_map if indegree[nid] == 0]) sorted_nodes = [] while queue: nid = queue.popleft() sorted_nodes.append(nid) for neighbor in graph[nid]: indegree[neighbor] -= 1 if indegree[neighbor] == 0: queue.append(neighbor) if len(sorted_nodes) != len(node_map): raise ValueError("流程中存在环,请检查节点连线") return sorted_nodes注意,这个拓扑排序只在没有循环和复杂分支的情况下能给出一个“全序”。真正执行时,条件节点和循环节点需要特殊处理。但环检测是通用的,只要发现排序后的节点数不等于总节点数,就说明流程图里有环,直接返回错误。
3.3 执行器与上下文传递:数据怎么在节点间流动
执行器是整个引擎的心脏。我的设计是每次运行一个工作流实例时,维护一个Context对象,它本质上是一个可变的字典。所有节点的输入都从Context里读取,所有节点的输出也都写入Context。
class WorkflowContext: def __init__(self, initial_data): self.data = initial_data def get(self, key, default=None): return self.data.get(key, default) def set(self, key, value): self.data[key] = value def render(self, template): # 简单支持 {{ctx.xxx}} 占位符 import re def replace(match): return str(self.get(match.group(1), "")) return re.sub(r"\{\{ctx\.([^}]+)\}\}", replace, template)执行一个节点时,引擎根据节点类型分派到不同的处理函数。比如LLM节点,核心就是调用大模型接口,把Prompt渲染好,调用完成后把文本输出写到Context指定的字段里。条件节点则是解析表达式,根据真假返回不同的下一个节点ID。
下面是执行器最核心的骨架:
def run_node(engine, node, context): node_type = node["type"] if node_type == "start": return node.get("next") elif node_type == "llm": return run_llm_node(node, context) elif node_type == "condition": return run_condition_node(node, context) elif node_type == "http": return run_http_node(node, context) elif node_type == "db": return run_db_node(node, context) elif node_type == "end": return None else: raise ValueError(f"未知节点类型: {node_type}")执行引擎的主循环也很简单:从start节点开始,每执行完一个节点,根据返回的下一个节点ID继续执行,直到遇到end节点或返回None。
def execute(workflow_id, node_map, initial_context): context = WorkflowContext(initial_context) current = "start" max_steps = len(node_map) * 20 # 防止意外死循环 step = 0 while current and step < max_steps: node = node_map[current] current = run_node(engine, node, context) step += 1 return context.data这里的max_steps是一个安全阀,就算DSL里存在我们没有检测到的环,执行也会在达到上限后终止,避免把服务打挂。
4. 可视化编排实战:把DSL变成拖拽面板
4.1 画布和图数据的最小实现思路
可视化编排器这块,我最初考虑过React Flow、LogicFlow、Vue Flow这些现成库。最终选了React Flow,因为组件生态好,很多节点、小地图、缩放的交互都已经封装好了。
实现一个最简版画布,核心就是监听节点拖动事件、更新节点坐标、维护节点和边的数据结构。我先把图数据放在一个全局状态里,然后渲染到画布上。React Flow的Nodes和Edges数组正好能映射到我们前面说的图数据。
import { ReactFlow, Background, Controls } from '@xyflow/react'; const [nodes, setNodes] = useState(initialNodes); const [edges, setEdges] = useState(initialEdges); const onNodesChange = useCallback((changes) => { setNodes((nds) => applyNodeChanges(changes, nds)); }, [setNodes]); const onConnect = useCallback((params) => { setEdges((eds) => addEdge({ ...params, id: `e_${Date.now()}` }, eds)); }, [setEdges]); return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onConnect={onConnect} fitView > <Background /> <Controls /> </ReactFlow> );这段代码能实现最基础的拖拽和连线。如果是业务系统的可视化编排,还需要加左侧节点面板、右侧属性面板、顶部保存按钮。整体交互不难,难的是节点数据模型要跟DSL字段严格对齐。
4.2 节点属性编辑:关键参数实时绑定
画布只是入口,真正重要的是属性编辑。用户点击画布上的一个节点,右侧属性面板会展示这个节点类型对应的表单。
比如点击LLM节点,表单里需要展示:
- 模型名称(下拉框)
- Prompt模板(多行文本框,支持
{{ctx.xxx}}变量) - 输出变量名(输入框)
- 失败重试次数(数字输入框)
点击条件节点,表单里需要展示:
- 条件表达式(输入框,支持
ctx.xxx和表达式函数) - true指向的下一个节点(下拉框)
- false指向的下一个节点(下拉框)
这些表单的值都直接从当前节点的属性里读取,修改后直接写回节点对象。保存时,把节点对象序列化成YAML。
这里有个很实用的设计:属性表单的字段和DSL节点的字段保持一一对应。不要搞一层“前端字段”再转一次“DSL字段”,否则你会维护两套映射,改起来非常痛苦。前端字段名可以直接叫model、prompt、output,保存时原样写入。
4.3 从画布导出DSL:所见即所得
导出DSL这一步,最容易出错的是条件节点的分支指向。因为画布上有连线,有节点属性,但DSL里条件节点用的是true和false两个字段,而不是通用next。所以导出逻辑要针对节点类型单独处理。
我的做法是给每条线打一个标签,比如普通线、True线、False线。React Flow的Edge对象可以存label或者自定义data。条件节点上拖出来的线,我会自动让用户选择是True分支还是False分支。
导出代码大概是这样的逻辑:
function exportWorkflow(nodes, edges) { const nodeMap = {}; nodes.forEach(node => { nodeMap[node.id] = { id: node.id, type: node.data.type, ...node.data.props }; }); edges.forEach(edge => { const sourceNode = nodeMap[edge.source]; const targetId = edge.target; const branchLabel = edge.data?.branch; // 'true' | 'false' | undefined if (sourceNode.type === 'condition') { if (branchLabel === 'true') sourceNode.true = targetId; else if (branchLabel === 'false') sourceNode.false = targetId; else sourceNode.next = targetId; } else { sourceNode.next = targetId; } }); const dslObj = { id: generateId(), name: '未命名流程', nodes: Object.values(nodeMap) }; return yaml.dump(dslObj); }这个导出逻辑可以保证“所见即所得”,画布上怎么连,DSL里就怎么描述。反过来,导入时读取next、true、false字段,自动生成连线,也能还原画布。
5. 零代码改造实例:一个AI工作流从硬编码到DSL的完整过程
5.1 原始硬编码版本:需求评审系统的一次真实困扰
我拿一个实际改造过的案例来说。我们内部有一套需求评审系统,业务流程大概是这样的:产品提交一个需求描述,AI先做关键词抽取,判断需求涉及哪个模块,再判断优先级,最后自动分配给对应负责人。如果AI判断置信度不高,就先给产品经理发一个确认邮件。
最早版本就是硬编码,主流程长这样:
def process_requirement(req_text): keywords = extract_keywords(req_text) module = classify_module(keywords) priority = compute_priority(req_text, module) if priority == "P0": assign_to("leader", req_text) elif priority == "P1": assign_to(module_owner[module], req_text) else: send_confirm_email(product_manager, req_text) ...看着不复杂,但后面每个业务部门都提出要定制流程。有的部门希望先做敏感词检测,再走分配;有的部门希望优先级判断交给人工复核;有的部门希望自动生成一段需求摘要放进通知里。最后这段代码出现了大量if department == "xxx"的分支,任何人改动都要小心翼翼。
5.2 DSL版本:流程描述与业务逻辑分离
改造之后,我们没有删掉任何AI能力,只是把流程控制逻辑从Python代码里转移到了Workflow-DSL里。每个部门可以维护自己的流程配置,公共节点比如关键词抽取、摘要生成、敏感词检测,都被封装成可复用的节点类型。
一个部门的新人流程可能长这样:
id: hr_requirement_process name: HR部门需求处理流程 nodes: - id: start type: start next: llm_extract - id: llm_extract type: llm model: gpt-4o-mini prompt: | 从需求文本中抽取关键词、模块、期望优先级。 输出JSON:{"keywords": [], "module": "", "priority": ""} output: extraction next: sensitive_check - id: sensitive_check type: llm model: gpt-4o-mini prompt: | 判断以下需求文本是否包含敏感词,输出YES或NO。 ...... output: sensitive_flag next: branch_sensitive - id: branch_sensitive type: condition expression: "ctx.sensitive_flag == 'YES'" true: human_review false: priority_branch - id: human_review type: human task: 需求包含敏感词,请人工复核 next: end - id: priority_branch type: condition expression: "parse_json(ctx.extraction).priority == 'P0'" true: assign_leader false: assign_module ...这里我把“条件判断”改成DSL里的condition节点,把“人工复核”改成human节点。业务人员通过可视化编排器看到的就是一张流程图,每个分支用不同颜色线表示,谁负责哪一步一目了然。
5.3 改造后的真实收益
这个系统上线用了一个多月后,我统计了几组对比数据:
- 需求变更上线时长:从平均2天缩短到平均2小时,大部分时候改完DSL配置直接生效,不需要发版
- 业务人员参与度:以前需求方只能提“需求”,现在他们可以直接在可视化面板里调整分支条件,自己先跑通再找研发确认
- 事故率:因为流程和代码分离,代码改动的范围显著缩小,和生产相关的硬编码逻辑明显减少
这里必须说明一下,零代码不是说业务人员完全不用学,他们还是需要理解节点、连线、条件表达式这些概念。但比起读Python代码,画流程图的上手难度低了一个数量级。我用一个小白测试过,给她一份节点说明表,她花了半天就搭出了一个简单的自动回复流程。
6. 我踩过的坑和排查技巧:这些细节文档里真的没有
6.1 环检测和拓扑排序的坑
前面说了环会导致死循环,但实际场景里还有更隐蔽的问题:不是所有环都是坏环。比如循环节点的实现本质上就是通过一个循环体回到之前的节点,这种“环”是需要支持的。
我一开始天真地用拓扑排序一刀切,结果把循环节点也判定成了非法。后来我把循环节点单独处理,在执行器里识别loop节点,循环体通过表达式控制,不再走普通DAG的环检测。这里我的建议是:引擎要区分“流程控制环”和“数据依赖环”,前者是功能,后者才是事故。
6.2 变量作用域与命名冲突
DSL里的{{ctx.xxx}}用起来很方便,但节点多了以后,命名冲突一定会出现。比如两个不同的LLM节点都把输出写成ctx.output,后执行的节点就会覆盖前一个节点的结果,下一个节点拿到的数据根本不对。
排查这种问题非常浪费时间。我后来在DSL里加了一个“输出变量名”的校验逻辑:在导出或保存时,检查所有节点的输出变量是否有重复,并自动提示。同时,我建议在Context里做命名空间隔离,比如在节点ID前缀加入输出变量名,变成ctx.llm_1.output,虽然写起来多了几个字符,但避免了大量隐性覆盖问题。
6.3 节点重试与幂等性设计
AI工作流里,LLM调用和HTTP请求经常会因为网络抖动或上游限流失败。所以每个节点最好支持单独配置重试次数和重试策略。
但重试会带来一个新问题:重复执行一个节点,可能导致重复插入数据库记录、重复发送邮件。如果在DB节点和HTTP节点里没有做幂等控制,重试就是灾难。
我的解决方案是给每个工作流实例生成一个唯一的run_id,在节点执行前和执行后都记录下来。DB节点插入数据时带上run_id字段,并在数据库里建唯一索引。HTTP节点在请求头里带上X-Run-Id,让下游做去重。这些细节不处理好,流程引擎跑到线上会给你制造一堆脏数据。
6.4 前端坐标和DSL导入导出的精度问题
我还遇到过一个问题:用React Flow把DSL导入到画布时,如果不保存节点坐标,节点会全部堆叠在左上角,位置随机,非常难用。后来我在DSL里增加了一个可选的view字段,专门存节点坐标和缩放级别。这个字段不影响流程逻辑,只在前端展示时使用。
另外,坐标精度也要注意。有些人拖拽节点,会产生类似x: 100.000000001这种浮点数,传到YAML里既难看又容易干扰diff。我在导出时对坐标做了四舍五入:
position: { x: Math.round(pos.x), y: Math.round(pos.y) }这个细节看着小,但实际协作时,多个开发在看同一个DSL的Git历史,坐标格式统一能减少很多无意义的diff。
在排查问题阶段,我整理了一张速查表,贴出来给大家参考:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 流程执行到一半就停了 | 节点next指向了不存在的节点ID | 检查YAML中所有引用是否有效 |
| 条件分支永远走同一边 | 表达式里的上下文变量名写错 | 打印Context快照,核对字段名 |
| LLM节点输出一直为空 | Prompt返回格式不符合解析预期 | 先用单测跑一次Prompt,看原始输出 |
| HTTP节点返回超时 | 没有区分重试策略 | 配置超时时间和重试次数,记录日志 |
| 导入后节点全部重叠 | 缺少view坐标字段 | 在DSL中加入坐标信息 |
7. 最后分享一点体会
如果非要说有哪些经验最值得分享,我会选三件事。
第一,Workshop-DSL不是越抽象越好,一切设计服务于真实业务。我一开始想做一个通用的流程编排标准,支持子流程、并行网关、定时触发,结果做了一堆功能,真正用的只有几个节点。后来干脆砍掉一半功能,只保留高频节点,反而更稳定。
第二,可视化编排器的体验决定DSL能不能落地。DSL再强大,如果画布拖拽卡顿、属性面板难用,业务团队不会愿意用。我后来花了很多时间在节点表单的交互打磨上,把每个节点类型的表单都做到“填完就能跑”,效果比优化引擎性能还明显。
第三,AI工作流的难点不在于让大模型回答得更聪明,而在于把流程的确定性控制好。大模型负责发散,流程负责收敛;DSL负责描述收敛规则,可视化编排负责让所有人看清楚收敛方式。只要这个分工明确,零代码流程改造就能在团队里真正用起来,而不是变成一个花架子。
我现在每个新项目开始前,都会先问一句:这个流程会不会变?如果会,我会直接上Workflow-DSL,而不是急着写代码。这个习惯,帮我省掉了太多临时改代码的麻烦。