⚠️ 注意:本文中的"变量引擎",为 流程设计态 变量引擎,不涉及流程运行态
flowgram 变量引擎源代码详见:github.com/bytedance/f...
1. 变量,低代码的"神经系统"
今天想和大家分享一个话题:flowgram 变量引擎。 在开始深入技术细节之前,我们先用一个通俗的类比来聊聊"变量"到底是什么。想象一下,我们正在搭建一个复杂的乐高模型,比如一个机器人。这个机器人由很多模块组成:手臂、腿、头部等等。如果我们希望这些模块能够协同工作------比如,当头部转动时,手臂也跟着挥舞------我们就需要在它们之间建立一种信息传递的机制。

在这个场景里,"变量"就扮演着这个信息传递者的角色。它就像连接各个模块的"神经"或者"电路",告诉一个模块另一个模块发生了什么变化。 在低代码平台(如 flowgram)中,每个功能节点(比如"获取数据"、"处理文本"、"调用 API")都像是乐高模型中的一个模块。而变量,就是让这些独立的节点能够互相"沟通",将数据从一个节点传递到另一个节点的关键所在。 变量就是节点间信息传递的载体。没有它,整个流程(Flow)就是一盘散沙,无法形成有意义的应用。
2. 痛点:低代码设计态中的变量难点
既然变量如此重要,那么在一个低代码平台前端的开发过程中,我们遇到了哪些挑战呢? 随着业务逻辑变得越来越复杂,我们发现变量变得 限制复杂、信息复杂、联动复杂,具体体现为以下几个问题:
2.1. 变量的可见性(可选性)
并非所有变量都应该对所有节点可见。例如,一个循环节点内部产生的临时变量(如 item 和 index),就不应该被循环外的节点访问到。如何精确地控制每个节点能"看到"和使用哪些变量?这就是"作用域"的问题。 变量引擎能够精确控制每个变量的有效范围(即作用域)。如同为不同房间配置专属钥匙,它确保了变量只在预期的节点中被访问,从而有效避免了数据污染和意外的逻辑错误。

Start 节点定义的 query 变量,在它后面的 LLM 和 End 节点都能轻松访问

LLM 节点在一个 Condition 分支里,像是在一个独立的房间。外面的 End 节点自然就拿不到它的 result 变量了。
2.2. 变量结构信息复杂
变量是个复杂的多层级结构哈:
-
变量 Object 能往下深挖好几个子字段,子字段还能定义成 Object,接着再定义往下深挖的字段
-
有些类型能互相嵌套,就像 Array<Map<String, Array>> 这样 变量引擎能让你轻松定制和管理这些结构
在这张图里,你能看到所有节点的输出变量,还有它们之间的层级关系
2.3. 类型自动推导
有时,一个变量的类型依赖于另一个或多个变量的计算结果。 例如,C = A + B。当 A 或 B 的类型发生变化时,C 的类型应该能够自动、响应式地更新。如何优雅地实现这种"级联反应"? 通过变量引擎,可以通过 简单的变量声明定义,变量引擎就会根据上下文自动联动推导出它的类型。
例如,当 Start 节点中 arr 变量的类型发生变更时,Batch 节点输出的 item 类型也会自动同步更新,确保了类型的一致性。
3. 核心概念:作用域/作用域链/AST
想像这样一个变量引擎的世界:
- 通过一个个 作用域 来划定出一个个 国家
- 每个国家包含三大公民:声明、类型、表达式
- 国家与国家之间通过 作用域链 来实现交流
3.1. 作用域与作用域链
作用域的具体划定,可以由不同的业务来确定:
流程图里,作用域通常约定为一个节点
低代码页面编辑器,作用域可以约定为一个组件(含变量)
侧边栏里面的全局变量,也是一个作用域
我们将作用域类比为国家,作用域链类比为贸易链,可以获得以下的概念类比: 国家 + 贸易链:
国家之间通过贸易链,进行商品进出口
贸易链规定:
-
可以从哪些国家进口
-
又可以出口到哪些国家
作用域 + 作用域链:
作用域之间通过作用域链,进行变量的消费与输出
作用域链规定了
- 作用域依赖:可以从哪些作用域消费变量
- 作用域覆盖:又可以输出变量到哪些作用域
javaabstract class ScopeChain { abstract getCovers(scope) abstract getDeps(scope) }
❓ 为什么要在节点之外,新抽象一个作用域的概念?
- 节点 !== 作用域
- 全局作用域(GlobalScope)和节点无关
- 一个节点可以存在多个作用域
3.2. 变量引擎的三大公民
声明
声明 = Identifier + Definition
商品也是一种声明,和变量类似
JavaScript 中的变量,唯一 Key + 变量的值
低代码平台中的变量,唯一Key(apiName)+ 变量定义
声明 通过 Indentifier,被其他作用域给消费
Global 作用域 的 结构体声明 被 Start 节点消费, Start 节点的变量被 LLM 节点的 fString 表达式消费, LLM 节点通过下钻,实际消费了 Global 作用域中的 Property_A 声明
类型
类型(typing,又称类型指派)赋予一组比特某个意义。 来自 Wikipedia
变量引擎中,所有变量的声明,都是围绕着类型建立的。 变量引擎内置了以下类型:String、Number、Integer、Boolean、Object、Array、Map
类型可以相互嵌套:
-
如:Array<Map<String, Array<Array>>>
-
Object 类型 和 属性声明 可以相互嵌套:
Object 类型 和 属性声明 可以相互嵌套
表达式
设计态变量引擎中的表达式,可以通过特定方式 组合 若干个 声明好的变量,并 返回一个变量类型:
例如:
JsonPath 表达式
Python 表达式
3.3. AST Is All You Need
核心思想:流程编排的本质就是一种可视化的编程语言。 Hint:可以通过 ts-ast-viewer.com/ 更加深入理解 AST
三大公民 "声明"、"表达式"、"类型" 在设计上都有一些通用点:
- 三大公民各自有 非常多的类别,且业务有扩展诉求
- 声明:变量声明、自定义类型声明、Interface 声明 等
- 类型:String、Number、Array、Map、Interface 等
- 表达式:KeyPath、JsonPath、Python、JS 等
- 三大公民相互之间可以 组织成一棵树的结构
- 变量声明
- Object
- 属性声明
- Array
- String
- 属性声明
- Number
- 属性声明
- JsonPath 表达
- 三大公民都有 监听 的诉求
Condition 类型联动操作符
变量引擎,定义了 ASTNode 来解决这三个问题:
-
AST:通过树的形式,组合 ASTNode,来描述变量信息
-
ASTNode:AST 中 可扩展、可组合、可监听 的协议节点
继承 ASTNode 可以定义非常多的类型
scalaclass StringType extends ASTNode {} class MapType extends ASTNode {} class VariableDeclaration extends ASTNode {} class StructDeclaration extends ASTNode {} class KeyPathExpression extends ASTNode {} class JsonPathExpression extends ASTNode {} // 业务扩展 AST 节点 class CustomType extends ASTNode {} class CustomType extends BaseExpression {} class CustomType extends BaseType {}
ASTNode 之间 组成了一棵树的结构
csscreateVariableDeclaration({ key: 'variable_xxx' type: createObject({ properties: [ createProperty({ key: 'a', type: createString() }), createProperty({ key: 'b', initializer: createJsonPathExpression({ path: '$.start.outputs.query' }) }) ] }) })
业务可监听任意 ASTNode 的变化
javascript// 监听变量变化 variable.subscribe(() => { // do something }) // 监听变量的 Name 变化 variable.subscribe(() => { // do something }, { selector: (_v) => _v.meta.name }) // 监听变量类型变化 variable.onTypeChange(() => { // do something });
参考 TypeScript AST 的定义,我们用 AST Schema 可以用来描述变量的 AST 信息:
TypeScript AST 定义示范
AST Schema 在 TypeScript AST 的基础上做了简化:
csscreateVariableDeclaration({ key: 'variable_xxx' type: createObject({ properties: [ createProperty({ key: 'a', type: createString() }), createProperty({ key: 'b', initializer: createJsonPathExpression({ path: '$.start.outputs.query' }) }) ] }) })
聊到 AST Schema ,不避免会将其与 Json Schema 进行比较:
Json Schema 和 AST Schema 要解决的问题是不同的
- Json Schema 只能描述类型,AST Schema 可以表达 "声明"、"表达式"、"类型" 三大公民
- AST Schema 中每一个节点都可以对应到一个实际存在的 AST 节点,但是 Json Schema 不行
- Json Schema 相比 AST Schema 在团队沟通上更有优势
两者的应用场景也不同
-
变量引擎内核 需要更强大的扩展与表达能力,因此需要用 AST Schema 来描述更丰富的变量信息,包括类型联动信息
-
上层业务 需要协议更通用更易于沟通,因此可以使用 Json Schema 来降低上手成本,方便前后端沟通,并且复用生态(如 zod)
4. 架构:flowgram 变量引擎整体设计
易用性和可扩展性的平衡一直是架构设计上一大难题。 flowgram 变量引擎通过三大分层架构兼顾了易用性和可扩展性:
flowgram 变量引擎分层架构
变量引擎设计上遵循 DIP(依赖反转)原则,按照 代码稳定性、抽象层次 以及和 业务的远近 分为三层:
-
变量抽象层:变量架构中抽象层次最高,代码也最为稳定的部分。抽象层对 AST、Scope、ScopeChain 等核心概念进行了抽象类定义。
-
变量实现层:变量架构中变动较大,不同业务之间可能存在调整的部分。引擎内置了一批较为稳定的 AST 节点和 ScopeChain 的实现。当用户存在复杂的变量需求时,可以通过依赖注入注册新的 AST 或者重载已有 AST 节点实现定制化。
-
变量物料层:基于外观模式(Facade)的思路提高变量易用性,将复杂的变量逻辑封装为简单开箱即用的变量物料
5. 附录:flowgram 变量底层部分 API 简介
5.1. 变量抽象层部分 API 简介
获取变量引擎
javascriptimport { useService } from "@flowgram.ai/core"; import { VariableEngine } from "@flowgram.ai/variable-core"; const variableEngine = useService(VariableEngine);
Scope CURD
go// 创建 Scope const globalScope = variableEngine.createScope('global') // 创建 Scope,并带上 meta 信息 const loopNodePublicScope = variableEngine.createScope('loop-node-public', { node: loopNode, type: 'public' }) // 获取 Scope const scope = variableEngine.getScopeById('loop-node-public') scope.meta.type === 'public' // 获取全量的 Scope variableEngine.getAllScopes() // 删除 Scope variableEngine.removeScopeById('global')
Scope 的包含依赖和覆盖作用域
arduinoconst scope = variableEngine.getScopeById('test') // 获取依赖作用域 console.log(scope.depScopes) // 获取覆盖作用域 console.log(scope.coverScopes);
ASTNodeJSON 通常表现为一个包含 kind 字段的 Plain Object
- kind 字段用于表示当前 ASTNode 节点的类别
- ASTNodeJSON 可以相互嵌套形成树的结构
css{ kind: ASTKind.VariableDeclaration, key: 'variableKey111', meta: { name: 'xxx' }, type: { ASTKind.String } }
上面的 ASTNodeJSON 描述了一个变量声明:
- kind 为 VariableDeclaration 表明了这是一个描述变量信息的 ASTNode
- 该变量声明的 type 是当前变量声明的子节点,也用 ASTNodeJSON 来描述
- type 中的 kind为 String,表明这个 ASTNode 存储了一个为 String 的变量类型
❗️概念澄清 ASTNodeJSON 和 ASTNode 的关系,类似于 React 中 JSX 和 VDOM 的关系
ASTNode 存储在 Scope 中,Scope 中可以对 ASTNode 进行 CRUD:
phpimport { ASTKind } from "@flowgram.ai/variable-core"; // 在 namespace 1 中存储一个新变量,变量类型为 String scope.ast.set('namespace1', { kind: ASTKind.VariableDeclaration, key: 'new-variable', type: { kind: ASTKind.String, } }) // 在 namespace 2 中存储一个表达式,引用 a 变量下钻的 b 字段 scope.ast.set('namespace2', { kind: ASTKind.KeyPathExpression, keyPath: ['a', 'b'] }) // 更新 namespace 2 中的表达式引用到 c 变量下钻的 d 字段 scope.ast.set('namespace2', { kind: ASTKind.KeyPathExpression, keyPath: ['c', 'd'] }) // 获取 namespace1 中的 ASTNode const astNode = scope.ast.get('namespace1'); // 通过 fromJSON 更新变量中的类型信息 astNode.fromJSON({ type: { kind: ASTKind.Number, } }); // 通过 toJSON 转化回 ASTNodeJSON astNode.toJSON(); // 删除 namespace2 存储的 ASTNode scope.ast.remove('namespace1');
ASTNode 基于 Rxjs 提供了强大的监听能力:
javascriptconst astNode = scope.ast.get('test'); // 监听 ASTNode 节点的变化 const disposable = astNode.subscribe((_node) => { // do something to node }); // 监听 ASTNode 节点中 name 信息的变化 const disposable = astNode.subscribe((_name) => { // do something to name }, { selector: _node => _node.meta.name }); // 将一个 AnimationFrame 中所有该 AST 的变化 debounce 成一个 const disposable = astNode.subscribe((_node) => { // do something to node }, { debounceAnimation: true }); // 进阶用法:直接拿到当前 ASTNode 的 rx observable,通过自由 rx 操作符实现更复杂的异步处理逻辑 const ob$ = astNode.value$.pipe( switchMap(...), throttle(...), takeUntil(...) )
ASTNode 本质上是个抽象类,所有变量存储单元的实现都需要通过继承 ASTNode 进行
假设我们需要将一个 Employee 信息需要存储在变量引擎中:
- 为 Employee 创建一个继承 ASTNode 的实现 通过依赖注入的思想,将该实现注册到 VariableEngine中:
scala// 1. 涉及 Employee 的 ASTNodeJSON 结构 interface EmployeeASTJSON { employeeName: string; } // 2. 继承 ASTNode,细化 EmployeeASTNode 逻辑 class EmployeeASTNode extends ASTNode<EmployeeASTJSON> { // 2.1. 为 EmployeeASTNode 设定一个 kind 用于标识其类别 static kind: string = 'Employee'; _employeeName: string; // 2.2. 实现 Employee 的 fromJSON,描述其更新逻辑 fromJSON(json: EmployeeASTJSON) { if(json.employeeName !== this._employeeName) { this._employeeName = json.employeeName; this.fireChange(); } } toJSON(): EmployeeASTJSON { return { kind: this.kind, employeeName: this._employeeName } } } // 3. 通过依赖注入,将 EmployeeASTNode 注册 到 variableEngine 中 variableEngine.astRegisters.registerAST(EmployeeASTNode)
- 注册完成后, Scope 中即可以创建包含 Employee 的 AST 节点
php// 1. 创建 Employee 节点 // 变量引擎会通过 ASTNodeJSON 中的 kind 找到 Employee 对应的实现 const employeeNode = scope.ast.set('employee-test', { kind: 'Employee', employeeName: 'Mike Johnson' }); // 2. 监听 Employee 信息的变化 const disposable = employeeNode.subscribe(_node => { console.log(_node); }) // 3. 更新 Employee 的名称 employeeNode.fromJSON({ employeeName: 'Blank Mikeson' })
ScopeChain 是变量抽象层提供一个抽象类,包含三个抽象方法,用于让 Scope 获取依赖作用域和覆盖作用域:
csharpexport abstract class ScopeChain { // ... 内置方法实现 // 获取依赖作用域,子类实现 abstract getDeps(scope: Scope): Scope[]; // 获取覆盖作用域,子类实现 abstract getCovers(scope: Scope): Scope[]; // 获取所有作用域的排序 abstract sortAll(): Scope[]; }
下面是一个模拟的简单作用域链实现:
ini@injectable() export class MockScopeChain extends ScopeChain { // 所有作用域都依赖 global,global 不依赖任何作用域 getDeps(scope: Scope): Scope[] { const res: Scope[] = []; if (scope.id === 'global') { return []; } const global = this.variableEngine.getScopeById('global'); if (global) { res.push(global); } return res; } // global 可以覆盖所有作用域,但是其他作用域没有任何覆盖 getCovers(scope: Scope): Scope[] { if (scope.id === 'global') { return this.variableEngine.getAllScopes().filter(_scope => _scope.id !== 'global'); } return []; } sortAll(): Scope[] { return this.variableEngine.getAllScopes(); } } // 作用域链实现会通过 inversify bind 依赖注入到变量引擎中 new ContainerModule(bind => { bind(ScopeChain).to(MockScopeChain).inSingletonScope(); })
5.2. 变量实现层部分 API 简介
类型 是一种 ASTNode 的实现
iniimport { BaseType } from "@flowgram.ai/variable-core"; // 获取变量的类型 const variable = scope.ast.get('variable-declaration'); const vType: BaseType = variable.type;
变量引擎内部提供了一套基于 JSON Types 的预设类型集合实现
基础类型 复合类型 基础类型提供了 Json 四种基本类型定义:String,Number,Integer,Boolean 复合类型可以下钻嵌套其他类型,也可以下钻嵌套其他声明:Object,Array,Map,Union phpconst stringType = scope.ast.set('stringType', { kind: ASTKind.String }) const numberType = scope.ast.set('numberType', { kind: ASTKind.Number }) const integerType = scope.ast.set('integerType', { kind: ASTKind.Integer }) const booleanType = scope.ast.set('booleanType', { kind: ASTKind.Boolean }) const complicatedType = scope.ast.set('complicatedType', { kind: ASTKind.Object, properties: [ // { a: string } { kind: ASTKind.Object, key: '_object', properties: [ { kind: ASTKind.Property, key: 'a', type: ASTKind.String } ] }, // Map<String, String> { kind: ASTKind.Property, key: '_map_string', type: { kind: ASTKind.Map, valueType: { kind: ASTKind.String } } }, // Array<{ test:string }> { kind: ASTKind.Property, key: '_array_object', type: { kind: ASTKind.Array, valueType: { kind: ASTKind.Object, properties: [{ kind: ASTKind.Property, key: 'test', type: ASTKind.String }] } } } ] }); // 获取 下钻 _object.a 字段的类型 const objectDrilldownType = complicatedType.getByKeyPath(['_object', 'a']).type
声明是一种 ASTNode 的实现,它提供了一系列方法,用于获取声明的信息及内容。变量引擎内置了 VariableDeclarationList、VariableDeclaration、Property 三种声明
phpimport { BaseVariableField } from "@flowgram.ai/variable-core"; import { ASTKind, ObjectJSON, VariableDeclarationListJSON } from "@flowgram.ai/variable-core"; const field: BaseVariableField<{ label: string; }> = scope.ast.get('custom-variable'); scope.ast.set('variable-list', { kind: ASTKind.VariableDeclarationList, declarations: [ { type: ASTKind.String, key: 'string', }, { // VariableDeclarationList 的 declarations 中可以不用声明 Kind // kind: ASTKind.VariableDeclaration, type: ASTKind.Number, key: 'number', }, { kind: ASTKind.VariableDeclaration, type: ASTKind.Integer, key: 'integer', }, { kind: ASTKind.VariableDeclaration, type: { kind: ASTKind.Object, properties: [ { key: 'key1', type: ASTKind.String, // Object 的 properties 中可以不用声明 Kind kind: ASTKind.Property, }, { key: 'key4', type: { kind: ASTKind.Array, items: { kind: ASTKind.Object, properties: [ { key: 'key1', type: ASTKind.Boolean, }, ], } as ObjectJSON, }, }, ], } as ObjectJSON, key: 'object', }, { kind: ASTKind.VariableDeclaration, type: { kind: ASTKind.Map, valueType: ASTKind.Number }, key: 'map', }, ], });
变量引擎可以通过 keyPath 访问到声明
iniimport { BaseVariableField } from "@flowgram.ai/variable-core"; // 获取 key 为 string 的变量 const stringVar: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['string']); // 获取 key 为 boolean 的变量 const booleanVar: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['boolean']); // 获取 key 为 object 的变量,下钻到他内部的 key1 字段 const objectKey1Var: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['object', 'key1']); // 获取 key 为 object 的变量,下钻到他内部的 key2 字段,再下钻到 key2 字段中的 key1 字段 const objectKey2Key1Var: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['object', 'key2', 'key1']); // 获取 key 为 object 的变量,下钻到他内部的 key4 字段 const objectKey4Var: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['object', 'key4']);
Scope.outputs 可以获取并监听 Scope 输出变量的信息:
javascript// 获取当前 Scope 输出的所有 VariableDeclaration console.log(scope.outputs.variables); // 获取当前 Scope 输出的所有 VariableDeclaration 的 key 值索引 console.log(scope.outputs.variableKeys); // 在 Scope 的输出变量中寻找指定 key 的 VariableDeclaration scope.outputs.getVariableByKey('test') // 监听当前 Scope 输出的 VariableDeclaration 列表及下钻声明发生变化时 scope.outputs.onListOrAnyVarChange(() => { console.log(scope.outputs.variables); });
Scope.available 可以获取并监听 Scope 可访问变量的信息,即所有依赖作用域的输出变量:
arduino// 获取当前 Scope 依赖的所有可访问的 VariableDeclaration console.log(scope.available.variables) // 获取当前 Scope 输出的所有可访问的 key 值索引 console.log(scope.available.variableKeys) // 在 Scope 可访问变量中寻找指定 key 的 VariableDeclaration scope.available.getVariableByKey('test') // 通过 keyPath 在 Scope 的可访问变量中找到变量或者下钻字段 scope.available.getByKeyPath(['a', 'b', 'c']) // 监听当前 Scope 可访问的变量发生变化时 scope.available.onListOrAnyVarChange(() => { console.log(scope.available.variables) }); // 监听当前 Scope 可访问变量列表的变化 scope.available.onVariableListChange(() => { console.log(scope.available.variables) }); // 监听当前 Scope 可访问变量,任意一个变量内部的信息变化 scope.available.onAnyVariableChange(() => { console.log(scope.available.variables) });
系统预设提供了三个表达式的实现:
- KeyPathExpression:通过 keyPath 引用单个变量
- EnumerateExpression:对子表达式的返回类型进行遍历,获取遍历后的数据类型
- WrapArrayExpression:将子表达式的返回类型封装成 Array
声明抽象的 ASTNodeJSON 中,initializer 可以设置为一个表达式,该声明的类型会自动同步为该表达式的类型
phpconst variable = scope1.ast.set('variable', { kind: ASTKind.VariableDeclaration, key: 'a', type: { kind: ASTKind.Object, properties: [ { key: 'b', type: { kind: ASTKind.Array, items: { kind: ASTKind.String, }, }, }, ], }, }) // scope2 依赖 scope1 const targetVariable = scope2.ast.set('variable2', { kind: ASTKind.VariableDeclaration, key: 'target', initializer: { // 对 子表达式的 Array 类型进行遍历,获取 items 的类型 kind: ASTKind.EnumerateExpression, enumerateFor: { // 获取变量 a 下钻的 b 字段 kind: ASTKind.KeyPathExpression keyPath: ['a', 'b'] } } }) // 变量的类型和变量 a 下钻 b 字段的数组类型的 Item 类型保持一致 targetVariable.type.kind === ASTKind.String
表达式联动变量类型 Demo 效果