纯前端提示流编排器:双引擎驱动的DAG可视化与执行架构
2026/9/8 21:12:25 网站建设 项目流程

最近在搭一套提示流编排器的原型时,很多朋友第一反应都是:这玩意不就是 Dify 或者 Flowise 换个皮?说实话,我也用过不少工作流工具,但真正落到自己项目里时总会卡在几个点上——提示词版本管理不透明、节点逻辑被封装在云端黑盒里、想改一个分支条件还得先部署一个后端服务。所以这次我干脆自己动手,从零开始做了一版纯前端的提示流编排器,核心结构是两个引擎:一个是负责 DAG 可视化和交互的渲染引擎,一个是负责解析执行节点的运行时引擎。这篇文章是开源系列的第一篇,主要把整体架构和 DAG 部分的落地思路讲清楚。

这套编排器定位很明确:跑在浏览器里、不需要自建后端,节点拖拽、连线、配置、执行全在本地完成。它能帮你把多步骤的 AI 调用链串起来,比如把「用户输入 → 提示词模板渲染 → 模型 A 推理 → 结果作为上下文 → 模型 B 二次推理 → 输出格式化」这类流程可视化编排,并且每一步的结果都能在界面里直接看。适合那些正在做 AI 应用原型、需要频繁调整提示词链路的开发者,也适合想搞懂工作流引擎内部机制的读者。下面按我的实际开发顺序来拆解。

1. 为什么要自己造一个基于浏览器的提示流编排器

先回到最原始的问题:在 AI 应用开发里,提示流到底解决的是什么。现在大家写 AI 功能,很少只是单次调用大模型,真实场景通常是多个模型调用、多次工具调用、条件判断组合在一起,比如先让模型把用户问题分类,根据分类结果走不同提示词模板,再把答案喂给另一个模型做结构化提取。这个过程中,提示词是会被频繁修改的,链路也是会被频繁调整的。

1.1 提示流(Prompt Flow)到底解决什么问题

提到提示流,不用把它想得特别玄乎。本质上就是一套有向的、分阶段的提示词处理管线。传统的写代码方式,是把每一个步骤写死在 Python 或 Node 脚本里,改一次提示词就改一次代码,跑一次全流程才能看到效果。提示流编排器的核心价值,是把这种「代码内嵌的流程」变成「可视化、可配置、可复用的流程」。

我把最常用的场景列一下:

  • 多模型协同:先用一个模型做意图分类,再用另一个模型做生成,分类结果影响生成策略。
  • 上下文组装:从多个数据源拉取内容,按模板拼装成最终 Prompt,再交给模型。
  • 条件分支:根据模型输出的结构化字段决定走 A 分支还是 B 分支。
  • 人机审核环节:在关键节点暂停执行,人工修改中间结果后再继续。

这些场景的共同点是:流程结构相对稳定,但节点参数变化频繁。如果你把流程本身也做成数据,把节点做成可配置组件,就能在不改一行业务代码的情况下完成流程调整。这也是我坚持做可视化编排而不是写死脚本的根本原因。

1.2 现有工作流工具的归类与各自的死角

我调研过几类方案,不能说谁不好,但都有不适合我的地方。

方案类型典型代表优势死角
云端一站式工作流Dify、Coze 类开箱即用,组件丰富流程数据在云端,本地定制受限,提示词迭代依赖平台发布
代码框架LangChain、LlamaIndex 类灵活可控,生态好对非工程师不友好,调试链路不够直观
桌面节点编辑器ComfyUI 类节点式交互成熟定位于图像生成,LLM 相关节点需要大量自建
企业级集成平台n8n、Node-RED 类集成能力强,成熟稳定偏通用自动化,AI 语义节点支持弱,前端较重

我做这套系统时最在意的,是「编排的透明度」和「调试的即时性」。很多云端工具,节点里到底怎么拼提示词的,对用户是黑盒;很多代码框架,调试一次得从头跑一遍。我希望有一个工具:启动就是纯前端,流程数据是 JSON,节点逻辑是我的 TypeScript 代码,每一步执行结果都能立即看到。这就是我定义的产品形态。

1.3 我想要的形态:纯前端 + 双引擎

纯前端的意思是,整个编排器没有任何服务端依赖,静态托管即可运行,浏览器本地执行所有逻辑。双引擎则指:

  • 渲染引擎(Canvas/DOM 可视化层):负责 DAG 的绘制、拖拽、连线、缩放、节点选中与属性编辑。
  • 运行时引擎(Executor):负责读取 DAG 数据,进行拓扑排序、节点调度、状态管理、结果传递。

这里我不采用把渲染和执行混在一起的做法。混在一起写 Demo 很快,但一旦节点数量上去、分支逻辑复杂,代码就会变成一团乱麻。两个引擎通过标准 DAG 数据结构通信,渲染引擎只管图像和交互,执行引擎只管计算和流转,职责边界非常清晰。

2. 双引擎架构拆解:渲染引擎与执行引擎如何协同

明确双引擎之后,最重要的问题是:边界划在哪、数据怎么传、状态由谁管。如果这一步没想清楚,后面每加一个节点类型都是一场灾难。

2.1 两个引擎的职责边界划分

我把整个系统分成三层:

  • 表示层(View):节点、连线、画布、侧边栏表单的 DOM 和 Canvas。
  • 编排层(Controller):负责把界面操作翻译成 DAG 数据变更,比如新增节点、删除连线、修改参数。
  • 执行层(Runtime):负责 DAG 的实际运行,包括拓扑排序、节点调用、结果传递。

渲染引擎由表示层和编排层组成,运行时引擎就是执行层。两层之间通过一个叫GraphStore的中央状态仓库通信。GraphStore保存的数据结构是纯 JSON,不包含任何 DOM 引用或运行时状态。

一个关键的设计决定是:执行状态不写在节点对象里,而是写在一个独立的RunState对象里。原因很简单,节点对象是持久化数据,执行状态是临时数据,混在一起会导致你保存流程时把上一次的运行结果也存进去,以后每次打开文件都得做数据清洗。

2.2 GraphStore:一份 DAG 数据,两边共用

GraphStore 我用的基本结构长这样(TypeScript 简化版):

type PortType = 'text' | 'json' | 'file' | 'boolean' | 'any'; interface PortDef { id: string; name: string; type: PortType; direction: 'input' | 'output'; } interface NodeData { id: string; type: string; // 节点类型,对应注册表里的定义 position: { x: number; y: number }; config: Record<string, unknown>; // 用户的节点参数 inputPorts: PortDef[]; outputPorts: PortDef[]; } interface EdgeData { id: string; source: { nodeId: string; portId: string }; target: { nodeId: string; portId: string }; } interface GraphModel { nodes: NodeData[]; edges: EdgeData[]; }

这两个引擎只用这一份GraphModel通信。渲染引擎监听GraphStore的更新事件来重绘画布,执行引擎在运行前读取GraphModel构建执行计划,执行时再把每个节点的输入输出写到RunState,跑完以后把结果关联回节点 ID,渲染引擎就能把结果显示在节点下方。

2.3 渲染引擎的核心能力与实现思路

渲染引擎我最终选择了 DOM 节点 + SVG 连线的组合,而不是全 Canvas。原因有两个:

  • DOM 节点天然支持事件系统、表单交互、样式覆盖,做属性配置面板很方便。
  • SVG 连线在节点数量几百级别内性能可接受,而且支持箭头、路径动画等效果。

渲染引擎的三个关键能力:

画布平移与缩放。这一块借鉴了图形编辑器常用的思路:用一个 viewport 容器transform: translate + scale。所有节点的坐标统一存在 GraphStore 里,存的是「世界坐标」,渲染时再通过 viewport 转换层变成「屏幕坐标」。拖拽节点时,更新的是世界坐标;滚动滚轮时,更新的是 scale 和 translate。

连线的动态创建。用户从节点输出端口拖出一条线,到另一个节点的输入端口松手,这期间只有一条「临时连线」,松手后根据端口类型做校验,通过就写入 GraphStore。校验规则包括:输入端口只能连一条入边、源端口和目标端口类型必须兼容、不能形成环。

最小化渲染开销。节点移动时不需要所有节点重绘,只更新被移动节点及其关联连线的坐标。为了做到这一点,每个节点绑定了自身坐标系,连线在节点移动时只更新 d 属性的 path 部分。

2.4 执行引擎的调度逻辑与节点生命周期

执行引擎的目标是:给定一份 DAG,按照依赖关系确定执行顺序,逐节点执行,把上游输出传给下游输入,并处理异常中止。

执行一次流程的流程:

  1. 解析 GraphModel,构建邻接表。
  2. 做拓扑排序,得到线性执行序列。
  3. 按序列顺序依次执行节点,对每个节点执行beforeExecute → execute → afterExecute生命周期。
  4. 如果某个节点抛异常,标记该节点为 failed,并按配置决定是中止整个流程,还是走 fallback 分支。
  5. 每个节点执行完,把输出写入RunState,同时发出NodeFinishedEvent,渲染引擎收到事件后显示节点状态。

拓扑排序我采用的 Kahn 算法,同时检测环。存在环时不能执行,但编辑阶段允许成环——这点在后面专门说。

3. 纯前端 DAG 的关键算法与数据设计

DAG 可视化编辑看起来不难,真正落地时细节非常多。这里把我踩过、也解决过的问题集中说一下。

3.1 节点端口建模:从“输入输出”到“数据类型”

第一版实现里,端口就只有简单的 input 和 output 两种,连线也不做类型检查。结果第一个真实用例就把我坑了:一个节点输出的是 JSON,我却拖到了只接受文本的端口上,执行到一半报错。从那之后,端口增加了类型系统。

端口类型目前定义的是:

类型说明典型节点
text文本内容提示词模板节点输出
json结构化数据函数调用节点输出
boolean布尔值条件分支节点输出
file文件路径或数据 URL文件读取节点输出
any任意类型,自动兼容转换节点输出

连线校验规则:

  • 输出端口连输入端口,方向不能反。
  • 类型兼容矩阵里,only same type 或 any 可以与任意类型相连。
  • 一个输入端口最多一条入边,输出端口可多条出边。

这套设计让用户在画布上就能发现大部分连接错误,而不是等到执行时才弹出一个莫名其妙的报错。

3.2 成环检测与拓扑排序:什么时候校验

这里有个设计取舍。如果用户在编辑时每次连一条线都做环检测,体验其实很差,因为用户可能正在调整结构,中间状态往往是「暂时成环」。我的做法是:

  • 编辑阶段只做基础校验(方向、类型、端口占用)。
  • 点击「运行」时再做完整的环检测和拓扑排序。

环检测用 Kahn 算法可以同时拿到拓扑序列,代码如下:

function topoSort(graph: GraphModel): { order: string[]; hasCycle: boolean } { const inDegree = new Map<string, number>(); const adj = new Map<string, string[]>(); for (const node of graph.nodes) { inDegree.set(node.id, 0); adj.set(node.id, []); } for (const edge of graph.edges) { const from = edge.source.nodeId; const to = edge.target.nodeId; inDegree.set(to, (inDegree.get(to) ?? 0) + 1); adj.get(from)?.push(to); } const queue = [...inDegree.keys()].filter((id) => (inDegree.get(id) ?? 0) === 0); const order: string[] = []; while (queue.length > 0) { const current = queue.shift()!; order.push(current); for (const neighbor of adj.get(current) ?? []) { inDegree.set(neighbor, (inDegree.get(neighbor) ?? 0) - 1); if (inDegree.get(neighbor) === 0) queue.push(neighbor); } } return { order, hasCycle: order.length !== graph.nodes.length }; }

如果返回hasCycle = true,执行引擎直接中止,并在界面上高亮仍在环中的节点。这种「编辑宽松、执行严格」的模式,兼顾了操作体验和正确性。

3.3 坐标系统与交互背后的数学

DAG 可视化编辑器里,坐标是最容易出 bug 的地方。鼠标点击画布坐标 → 世界坐标 → 节点坐标,三层转换必须保持一致。

我维护的转换公式很简单:

function screenToWorld(screenX: number, screenY: number, viewport: Viewport) { return { x: (screenX - viewport.translateX) / viewport.scale, y: (screenY - viewport.translateY) / viewport.scale, }; }

所有持久化坐标都是世界坐标,因为它是与缩放无关的数据。这个设计有几个好处:

  • 两个用户在同一个流程文件上协作时,坐标不会因为各自视口不同而不一致。
  • 导出/导入 JSON 时,坐标数据是稳定的。
  • 后续做自动布局算法时,可以直接在世界坐标空间计算,不依赖屏幕状态。

另外,节点对齐线(吸附效果)也是基于世界坐标计算的:当两个节点的 x 或 y 坐标差值小于某个阈值(比如 8px)时,显示出对齐参考线,并强制把坐标吸附到一致。实现时要注意阈值也应该除以缩放比例,否则缩小后吸附会变得过于灵敏。

3.4 持久化格式与向后兼容

流程文件的格式就是 GraphModel 的 JSON 序列化。但直接裸存 JSON 会有一个问题:后续版本加字段,旧文件怎么办。

我在格式里加了version字段,并预留了迁移函数表:

const migrations: Record<number, (data: any) => any> = { 1: (data) => data, 2: (data) => ({ ...data, nodes: data.nodes.map((node) => ({ ...node, inputPorts: node.inputPorts ?? [], outputPorts: node.outputPorts ?? [], })), }), }; function migrate(data: any, targetVersion: number) { let current = data.version ?? 1; let result = data; while (current < targetVersion) { result = migrations[current](result); current += 1; } result.version = targetVersion; return result; }

每次 JSON 结构有 breaking change,就新增一个迁移函数,而不是直接改旧数据。这个习惯帮我省了很多调试时间,因为开源项目用户一旦保存了旧流程文件,不能指望他们自己改 JSON。

4. 引擎适配层:让提示流编排器做到多模型、多协议无缝切换

提示流编排器如果只能调一个固定模型,意义就很小。我设计了一个 Provider 抽象层,让节点执行时不知道也无需关心底层模型是哪家的。

4.1 为什么需要一个 Provider 抽象层

市面上有各种模型服务:有的是 OpenAI 兼容的 HTTP 接口,有的是本地的 Ollama,有的是各家云厂商的私有协议。如果我们把 API 调用逻辑直接写在节点类型代码里,每接入一个新模型来源,都要改节点代码,这属于典型的「面向实现编程」。

我的抽象方式是把「模型调用」只收敛成一个接口,节点代码只面对这个接口。接口长这样:

interface LLMProvider { id: string; name: string; chat(messages: ChatMessage[], options: ChatOptions): Promise<ChatResult>; listModels?(): Promise<string[]>; }

内置节点「LLM 推理」运行时,只做这一件事:拿到providerId,从 ProviderRegistry 里找到实例,调用chat。具体 provider 是 OpenAI 兼容还是 Ollama,由注册表决定。

4.2 节点类型如何拆解成可扩展的协议

提示流编排器的节点,不能只是「一个函数」,因为用户需要在界面里配置参数、查看结果。所以我定义了一个统一的节点协议:

interface FlowNodeDefinition { type: string; displayName: string; category: 'llm' | 'prompt' | 'logic' | 'tool' | 'io'; inputs: PortDef[]; outputs: PortDef[]; execute(ctx: ExecuteContext): Promise<NodeOutput>; }

ExecuteContext里有三个东西:

  • inputs:上游节点传过来的数据,key 是输入端口名。
  • config:用户在属性面板里填的参数。
  • runtime:包含LLMProviderRegistryfetchstorageeventBus等基础能力。

节点实现者只需要关心输入、配置、输出。至于节点在哪执行、如何被调度、结果如何展示,全都由引擎框架接管。这大概是整个项目里收益最大的设计决定——后面新增节点类型,永远只写一个文件,不动框架核心。

4.3 内置节点设计与一个实际例子

目前内置的节点类型覆盖了最常用的提示流场景:

节点类型类别作用
文本输入io手动输入一段文本作为流程起点
提示词模板prompt用模板语法渲染 Prompt,变量自动替换
LLM 推理llm调用配置好的模型服务
JSON 解析tool把模型输出解析成结构化 JSON
条件分支logic按布尔值或规则选择下游分支
聚合拼接tool合并多条上游结果

举个例子。一个人设生成流程可以是这样:

用户输入 → 提示词模板「请你给一个{主题}设计人设」→ LLM 推理 → JSON 解析 → 条件分支

提示词模板节点内部用简单的{{placeholder}}做变量替换:

function renderTemplate(template: string, variables: Record<string, string>) { return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { return variables[key] ?? match; }); }

我要强调的一点是:这个替换逻辑要用在节点内部,让用户能在界面里可视化地看到渲染后的完整 Prompt,而不是等到执行之后才看到。这也是提示流工具比「纯代码调用 Prompt」更有价值的地方。

4.4 复用已有开源生态的取舍

做 Provider 适配时,曾经纠结过要不要直接引入某个开源 SDK。试下来发现,很多 SDK 体积不小,而且引入之后被浏览器打包会多出不少问题。最终只依赖原生fetch,自己封装了一层轻量 HTTP 客户端。

这样做的好处:

  • 打包体积小,整个前端核心才几十 KB。
  • 不依赖某个 SDK 的更新节奏,接口变更自己可控。
  • Provider 适配只和服务器的协议有关,和 SDK 无关。

坏处是,每适配一个新协议都得自己写一遍鉴权和流式解析逻辑。但目前支持 OpenAI 兼容协议和 Ollama 两种主流形态,已经能覆盖绝大多数使用场景。后续如果社区呼声高,再考虑加 SSE 流式输出的标准封装。

5. 从单文件原型到插件化架构:我踩过的那些坑

如果这个项目从一开始就设计成一个大模块化架构,大概率写不到一半就放弃了。我的路线是先快速做一个单 HTML 文件的可运行原型,跑通核心交互,再逐步拆分模块。这个过程中踩了不少坑,挑几个比较典型的说说。

5.1 第一版为什么选择单 HTML 文件

单 HTML 文件的好处是启动成本极低,双击就能打开,也不需要构建工具。对于原型验证非常合适:不需要处理模块加载、打包、跨域这些干扰项,精力全放在 DAG 交互和节点执行上。

原型阶段我验证了几个核心假设:

  • DOM + SVG 方案能否支撑 50 个左右节点的拖拽交互。
  • DAG 执行引擎能否实时反馈每个节点的运行状态。
  • 提示词模板节点渲染后的预览效果是否足够直观。

事实证明这些假设都对。等原型稳定后,再迁移到 ESM 模块化架构就顺理成章了。如果不做这一步验证,直接上 Vite + React + TypeScript,遇到交互问题时很难判断是框架问题还是业务逻辑问题。

5.2 性能优化:节点拖拽卡顿的前因后果

原型刚做完的时候,节点超过 30 个就明显卡顿。排查后发现瓶颈不是 DOM 数量,而是每秒钟触发太多次重绘。

拖拽一个节点时,mousemove 事件每秒触发约 60 次,每次事件我都更新了 GraphStore,导致所有监听 GraphStore 变化的组件都重新渲染。后来做了两个优化:

  • 拖拽过程中使用requestAnimationFrame合并更新,同一帧内多次坐标变更只触发一次渲染。
  • 连线元素只更新路径 d 属性,不重新创建 SVG 元素。

优化后,节点数提升到 200 个左右仍然能保持流畅。这个经验是通用的:图形编辑器里的性能问题,多半不是渲染本身的问题,而是状态更新频率的问题。

5.3 撤销重做与 DAG 历史记录

撤销重做是一个容易低估复杂度的问题。最开始的实现是在每次操作后克隆整个 GraphModel 存入数组,节点少时没问题,节点多了内存暴涨。

后来改成基于 diff 的方式:每次操作只记录变更的部分。比如移动节点记录{ type: 'nodePosition', nodeId, before, after },添加节点记录{ type: 'addNode', node }。撤销时按反方向应用变更。

不过这个方案我还没完全满意,因为两个原子操作之间的组合操作(比如删除一个节点的同时删除关联的两条边)需要定义事务边界。目前的处理是把一组操作合并成一个Command,统一入栈。这一块的详细设计我打算在系列后续专门写一篇。

5.4 浏览器端模型调用的安全边界

既然是纯前端,就必须面对 API Key 存储的问题。这里我做一个非常重要的提醒:任何前端代码都没法真正保护密钥,即使你做混淆、做加密存储,也都是拖慢攻击者而不是阻止攻击者。

因此我的处理原则是:

  • 默认推荐用户使用本地模型服务或自带 API 网关转发。
  • Key 保存在浏览器localStorage,并在界面上标注清楚「仅存储在本机」。
  • 不提供任何形式的“代理”或“中转”功能,避免给用户造成错误的安全预期。

对开发者来说更容易接受的方式是:通过编排器导出流程配置文件,在自己的后端里用环境变量配置密钥并执行。编排器本身聚焦在流程编辑和本地调试上,生产环境的敏感信息处理交给开发者自己的基础设施。

6. 项目现状、扩展方向与开源参与建议

这个项目目前处于核心功能可用的阶段。DAG 编辑、节点执行、Provider 抽象、流程导入导出都已经跑通。代码仓库还在整理中,我会在系列文章发布的同时把仓库公开,预计第一批会包含:

  • 核心引擎代码(渲染引擎 + 执行引擎)。
  • 内置节点集(文本输入、提示词模板、LLM 推理、JSON 解析、条件分支)。
  • 两个 Provider 适配示例(OpenAI 兼容协议 + Ollama)。
  • 一个完整可运行的示例流程 JSON。

6.1 我自己的下一步规划

优先级最高的是下面几件事:

  • 将运行时的节点升级为 worker 化,避免大模型推理的长耗时拖住 UI 线程。
  • 增加流程的导入导出到通用格式,方便与代码仓库里的配置文件做 diff。
  • 增加「组件化」能力,允许用户把一段子流程封装成一个复合节点。
  • 实现更完善的流式输出体验,模型输出可以逐字显示到节点下方。

worker 化这件事尤其重要。现在的执行引擎在主线程跑await provider.chat(),如果模型响应需要 30 秒,浏览器界面虽然不至于完全冻结,但用户的所有操作都会变得迟滞。把执行流程放到 Web Worker 里,主线程只接收状态事件,体验会好非常多。

6.2 想参与开源的读者可以从哪里入手

如果你也想参与,我建议先从这些角度切入:

  • 增加新节点类型:把你在项目中反复用到的一段提示词逻辑,做成一个通用节点。
  • 适配新 Provider:手上有某个模型服务,按LLMProvider接口写一个适配器即可。
  • 完善 DAG 交互细节:比如自动布局、多选、框选、缩略图导航,这些都是典型的前端算法题。
  • 编写文档和示例:好的示例流程比文档更能帮助新用户理解用法。

开源协作最重要的原则是先沟通再动手。如果你打算实现一个大功能,最好提前在 issue 里说明方案,避免和现有的设计思路冲突。尤其是 DAG 数据结构的变更,属于会影响所有功能的基础模块,更需要充分讨论后动工。

6.3 给同样做前端 AI 工具的人一个建议

通过这个项目我有一个很深的体会:做 AI 工具链,最难的不是调用大模型,而是设计数据结构和交互模型。模型调用只是几行 fetch,而流程的编辑体验、执行状态反馈、异常分支处理,才是真正拉开差距的地方。

如果你也在开发类似项目,建议先把手里的流程用 JSON 画出来,把自己代入用户角色走一遍:新增节点、连线、改参数、执行、看结果,把这一条链路的体验磨顺,再考虑加功能。没有哪一步能替代亲手把整个循环跑通带来的直觉。

这个系列的第一篇就先写到这里。下一篇我会专门深入渲染引擎的实现细节,包括画布坐标系统、连线的贝塞尔曲线绘制、节点对齐辅助线的计算。如果你在阅读过程中有什么问题,或者你在自己的 AI 编排工具里踩过什么坑,欢迎一起交流。项目的最终形态还没有完全定型,但我希望它能成为一个真正让开发者看得见、改得动、跑得通的提示流编辑器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询