数据模型与节点系统:一张图怎么被表示
30 秒导读: Flowise 里你在画布上拖出来的那张图,落到磁盘上就是一段 JSON 字符串(叫
flowData)。这一章讲两件事:这段 JSON 是什么结构(nodes+edges),以及图里每一个"积木"(节点)在代码里长什么样、怎么被加载进内存。只讲静态结构与积木本身;积木怎么被拼成流水线、怎么跑起来,留给 02(经典引擎)和 03(AgentFlow V2 引擎)。
本章属于 Flowise 讲解组,全景见 index.md。
1. 先建立直觉:图 = 数据 + 积木
Flowise 是"用拖拽搭 LLM 应用"的工具(详见 index.md)。要理解它,先分清两个层次:
- 一张具体的流(chatflow / agentflow):你画的这一张图,是运行时数据,存在数据库里。
- 一类积木(节点,node):图里的每个方框背后是一个代码类,随程序启动被加载,所有流共享。
打个比方:积木是乐高零件的模具(程序里固定的一批类),流是你用这些零 件拼出来的某个模型(一份 JSON)。这一章就把这两层各自讲透。
┌─────────────────────────────┐
│ 一类积木(节点类) │ ← 随程序启动加载一次
│ Calculator / LLM / … │ 所有流共享
└──────────────┬──────────────┘
│ 被引用
▼
flowData(JSON) ──解析──▶ { nodes, edges } ← 某一张具体的流
每个 node.data 存在 DB,一流一份
指向一类积木
2. 第一层:一张流怎么被表示
2.1 存储形态 —— 一段 JSON 字符串
一条流在数据库里就是一个 chatflow 记录,图的全部内容压在它的 flowData 字段里,是一段 JSON 文本。要用它,第一步永远是 JSON.parse。AgentFlow V2 引擎入口处就是这么做的:
// packages/server/src/utils/buildAgentflow.ts:1591-1594
const flowData = chatflow.flowData
const parsedFlowData: IReactFlowObject = JSON.parse(flowData)
const nodes = (parsedFlowData.nodes || []).filter((node) => node.data.name !== 'stickyNoteAgentflow')
const edges = parsedFlowData.edges
这四行是理解整个数据模型的锚点:图 = nodes 数组 + edges 数组(便签节点 stickyNoteAgentflow 是纯注释,构建时被过滤掉)。
2.2 解析后的结构 —— IReactFlowObject
JSON.parse 出来的对象类型是 IReactFlowObject,字段极简:
// packages/server/src/Interface.ts:321-329
export interface IReactFlowObject {
nodes: IReactFlowNode[]
edges: IReactFlowEdge[]
viewport: { x: number; y: number; zoom: number }
}
这个命名暴露了出身:前端画布用的是 ReactFlow 这个库(网页上画流程图的开源组件),它的画布状态直接被序列化成 flowData 存起来(前端侧见 05-frontend-canvas.md)。所以 nodes/edges 里混着不少纯画布信息(坐标、宽高、选中状态)——对执行没用,但一起被存了下来。
节点(IReactFlowNode) 里,真正承载"这是什么积木、参数是什么"的是 data 字段,类型是 INodeData:
| 字段 | 作用 |
|---|---|
id | 节点在这张图里的唯一 id(如 llmAgentflow_0) |
data | 业务核心:INodeData,含节点名、输入参数、选中的凭据等 |
position / positionAbsolute / width / height / z | 画布坐标与尺寸,执行无关 |
type / selected / dragging | ReactFlow 的渲染/交互状态 |
依据:IReactFlowNode(packages/server/src/Interface.ts:284-306)。
边(IReactFlowEdge) 表达"谁连到谁",关键是带 handle 的四元组:
// packages/server/src/Interface.ts:309-319
export interface IReactFlowEdge {
source: string // 源节点 id
sourceHandle: string // 源节点的哪个"输出锚点"
target: string // 目标节点 id
targetHandle: string // 目标节点的哪个"输入锚点"
...
}
一根边不只连"节点到节点",还精确到哪个输出口接到哪个输入口(sourceHandle / targetHandle)——这正是引擎后面构建依赖图、判断"某个输入槽该填哪个上游节点的结果"的依据。
2.3 一眼看懂:一张最小的流长这样
下面是 flowData 解析后的示意结构(# 示意,非源码,删去了坐标等噪声):
// 示意,非源码:一张 "开始 → LLM" 的极简 agentflow
{
"nodes": [
{ "id": "startAgentflow_0",
"data": { "name": "startAgentflow", "label": "Start", "category": "Agent Flows",
"inputs": { "startInputType": "chatInput" } } },
{ "id": "llmAgentflow_0",
"data": { "name": "llmAgentflow", "label": "LLM", "category": "Agent Flows",
"inputs": { "llmModel": "chatOpenAI", "llmMessages": [] },
"credential": "a1b2-加密凭据的id" } }
],
"edges": [
{ "source": "startAgentflow_0", "sourceHandle": "startAgentflow_0-output",
"target": "llmAgentflow_0", "targetHandle": "llmAgentflow_0-input" }
]
}
看懂这张图,你就懂了 Flowise 的"源码格式":每个 node.data.name 指向一类积木;inputs 是这次填的参数;credential 只存一个加密凭据的 id(不是明文密钥);edges 用 handle 把积木串起来。
3. 第二层:一块积木(节点)在代码里长什么样
3.1 节点契约 —— INode
每一类积木都是一个 实现 INode 接口的类。这个接口就是 Flowise 对"什么算一个合法节点"的全部要求:
// packages/components/src/Interface.ts:150-164
export interface INode extends INodeProperties {
credential?: INodeParams
inputs?: INodeParams[]
output?: INodeOutputsValue[]
loadMethods?: { [key: string]: (nodeData, options?) => Promise<INodeOptionsValue[]> }
vectorStoreMethods?: { upsert; search; delete }
init?(nodeData: INodeData, input: string, options?: ICommonObject): Promise<any>
run?(nodeData: INodeData, input: string, options?: ICommonObject): Promise<string | ICommonObject>
}
它 extends INodeProperties(packages/components/src/Interface.ts:128-148),后者是元数据——决定这块积木在画布上如何显示、归到哪一类:
| INodeProperties 字段 | 作用 |
|---|---|
label | 画布上显示的名字(如 Calculator) |
name | 程序内唯一标识(如 calculator),node.data.name 就指它 |
type / baseClasses | 这块积木"产出什么类型",决定它能接到哪些输入槽(见 3.4) |
category | 归类(如 Tools、Agent Flows),画布左栏按此分组 |
icon / color / version | 图标文件名、主题色、版本号 |
filePath | 加载时回填的磁盘路径(见第 4 节) |
INode 在元数据之上再加了四组"能力字段":
inputs:这块积木暴露给用户填的参数列表(每项是INodeParams,见 3.3)。credential:若需要密钥,声明一个凭据槽(见第 5 节)。output:显式声明多个输出锚点(不声明则默认单一输出)。init()/run():两段式生命周期方法,是理解执行的关键(见 3.2)。
loadMethods 和 vectorStoreMethods 是两个可选的特殊能力:
loadMethods:给"下拉选项"用的异步加载器。比如 Model 下拉要列出当前可用模型,就用INodeParams.loadMethod指到这里的某个函数,运行时动态拉取选项(INodeOptionsValue[])。vectorStoreMethods:只有向量库类节点才实现,暴露upsert / search / delete三个方法,让"灌数据进向量库""检索"能被独立调用,而不必走整条流。
3.2 两段式生命周期 —— init() vs run()(重点)
INode 有两个方法,语义截然不同,是全章最容易混的一处:
| 方法 | 语义 | 返回 | 谁在用 |
|---|---|---|---|
init() | 装配: 造出这块积木背后的真实对象(一个 LangChain 实例、一个工具、一个模型客户端) | Promise<any>——那个被造出来的对象 | 经典引擎在构建期逐节点调用,把返回对象拼进 LangChain 流水线 |
run() | 执行:这块积木自己"跑一趟",吃 input、吐结果 | Promise<string | ICommonObject> | AgentFlow V2 引擎在运行期调用;部分可独立执行的节点也用它 |
一句话记住:init 是"造零件",run 是"零件自己干活"。 大多数经典节点只实现 init()——它们本身不"执行",只是被造出来插进 LangChain 的链里,由链去驱动(详见 02-classic-engine.md)。而 AgentFlow V2 的节点普遍实现 run(),因为 V2 引擎是自己解释执行每个节点(详见 03-agentflow-v2-engine.md)。
运行时数据 INodeData 与"节点类定义"要分清:类定义(INode 实例)是模板,一次加载后不变;INodeData 是这张流里这个节点的实例数据——同一个 INodeProperties 元数据,外加运行时字段:
// packages/components/src/Interface.ts:166-173
export interface INodeData extends INodeProperties {
id: string
inputs?: ICommonObject // 用户在画布上填的具体值(已是键值对,不是 INodeParams 定义)
outputs?: ICommonObject
credential?: string // 选中的凭据 id(加密态)
instance?: any // init() 造出来的对象,回填在此
loadMethod?: string
}
注意同一个 inputs 名字在两处含义不同:INode.inputs 是参数的定义(INodeParams[],告诉前端画哪些输入框),INodeData.inputs 是填好的值(ICommonObject,键值对)。前者是模具,后者是浇进去的料。
3.3 输入参数 —— INodeParams
INode.inputs 里每一项是一个 INodeParams,它描述"画一个什么样的输入控件"。核心字段:
| 字段 | 作用 |
|---|---|
label / name | 控件标题 / 取值时的键名 |
type | 控件类型:string / number / boolean / options / asyncOptions / password / json / code / file … |
default / optional / placeholder | 默认值 / 是否可空 / 占位提示 |
options | type: 'options' 时的下拉静态选项 |
loadMethod | type: 'asyncOptions' 时,指到 INode.loadMethods 里的动态加载器 |
acceptVariable | 该框是否允许插入 {{变量}} 引用上游结果 |
show / hide | 按其它字段的值条件显隐 |
依据:INodeParams(packages/components/src/Interface.ts:80-118)、NodeParamsType 枚举(同文件 9-24)。
3.4 baseClasses:积木之间怎么"对得上口"
baseClasses 是最不直观、却最关键的字段。它回答:"这块积木产出的对象,属于哪些类型?"——从而决定它能插进哪些输入槽。
Flowise 底层大量对象来自 LangChain,靠沿原型链爬类名自动算出类型血统:
// packages/components/src/utils.ts:127-146 getBaseClasses
export const getBaseClasses = (targetClass: any) => {
const baseClasses: string[] = []
const skipClassNames = ['BaseLangChain', 'Serializable']
// ...沿原型链一路 getPrototypeOf,把每层类名收集起来(跳过基础壳类)
}
所以一个节点常写成 this.baseClasses = [this.type, ...getBaseClasses(SomeLangChainClass)]——意思是"我产出的东西,既是我自己声明的 type,也是那个 LangChain 类及其所有父类"。连线时,前端/引擎就用这份类型清单校验"这根边接得上吗"。
3.5 读一个真实小节点:Calculator
Tools 类别下最简单的一个节点,全文就三十行——正好把 3.1~3.4 串起来:
// packages/components/nodes/tools/Calculator/Calculator.ts:5-30
class Calculator_Tools implements INode {
constructor() {
this.label = 'Calculator'
this.name = 'calculator'
this.type = 'Calculator'
this.icon = 'calculator.svg'
this.category = 'Tools'
this.baseClasses = [this.type, ...getBaseClasses(Calculator)] // 类型血统
}
async init(): Promise<any> {
return new Calculator() // 只"装配":造出一个 LangChain Calculator 工具
}
}
module.exports = { nodeClass: Calculator_Tools }
三个要点:
- 元数据在构造函数里写死——
name='calculator'就是node.data.name指向它的钥匙。 init()只造对象、不执行——返回一个 LangChainCalculator实例,交给经典引擎插进链里;它没有run()