跳到主要内容

数据模型与节点系统:一张图怎么被表示

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 / draggingReactFlow 的渲染/交互状态

依据: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归类(如 ToolsAgent Flows),画布左栏按此分组
icon / color / version图标文件名、主题色、版本号
filePath加载时回填的磁盘路径(见第 4 节)

INode 在元数据之上再加了四组"能力字段":

  • inputs:这块积木暴露给用户填的参数列表(每项是 INodeParams,见 3.3)。
  • credential:若需要密钥,声明一个凭据槽(见第 5 节)。
  • output:显式声明多个输出锚点(不声明则默认单一输出)。
  • init() / run():两段式生命周期方法,是理解执行的关键(见 3.2)。

loadMethodsvectorStoreMethods 是两个可选的特殊能力:

  • 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默认值 / 是否可空 / 占位提示
optionstype: 'options' 时的下拉静态选项
loadMethodtype: '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 }

三个要点:

  1. 元数据在构造函数里写死——name='calculator' 就是 node.data.name 指向它的钥匙。
  2. init() 只造对象、不执行——返回一个 LangChain Calculator 实例,交给经典引擎插进链里;它没有 run(),因为"算术"这件事是由上层 Agent 调用这个工具时才发生的。
  3. 导出约定 module.exports = { nodeClass: ... }——下一节的加载器就靠这个 nodeClass 字段认出"这是个节点"。

对照一个复杂节点 agentflow/LLM/LLM.ts:它 implements INode(第 22 行),构造函数里堆了一大串 inputs(Model、Messages、Memory…),并且实现的是 async run()(LLM.ts:340)而非 init()——因为它是 AgentFlow V2 节点,由 V2 引擎直接执行。两相对照,init vs run 的分工一目了然。


4. 积木怎么被加载进内存 —— NodesPool

节点类散落在 packages/components/nodes/** 下几百个文件里。程序启动时,NodesPool 负责把它们全扫进一张大表 componentNodes,键是节点 name,值是 new 出来的类实例。

4.1 扫描 → 实例化 → 登记

dist/nodes/**/*.js
│ getFiles 递归

require(file) ──▶ nodeModule.nodeClass 存在?
│ 是

new nodeClass() ──▶ 回填 filePath、把 icon 换成绝对路径

▼ 过滤(跳过 Analytic/SpeechToText、社区节点开关、DISABLED_NODES)
componentNodes[newNodeInstance.name] = newNodeInstance

真实实现的关键几行:

// packages/server/src/NodesPool.ts:44-48
const nodeModule = await require(file)
if (nodeModule.nodeClass) {
const newNodeInstance = new nodeModule.nodeClass()
newNodeInstance.filePath = file // 回填磁盘路径
// packages/server/src/NodesPool.ts:80-82 登记
if (conditionOne && conditionTwo && !isDisabled) {
nodes[newNodeInstance.name] = newNodeInstance
}

几个细节值得记住:

  • 扫的是 dist/nodes(编译后的 .js),不是源码 .ts(NodesPool.ts:28)——所以引用行号时要看源 .ts,运行加载的是编译产物。
  • icon 被改写成绝对路径(NodesPool.ts:51-60):节点里只写 'calculator.svg' 这种相对名,加载时拼成绝对路径,前端才取得到图。
  • 过滤三道闸:跳过 Analytic/SpeechToText 类别;showCommunityNodes 开关关掉带 author 的社区节点;环境变量 DISABLED_NODES 点名禁用(NodesPool.ts:37, 70-78)。
  • 凭据加载是并行的另一趟initializeCredentials()dist/credentials/*.credential.js,认 credClass,并把第一趟记下的 icon 路径补给凭据(NodesPool.ts:93-108)。

加载完,componentNodes 就成了引擎的"零件总目录":拿到一张流的 node.data.name,去这张表一查,就找到对应的节点类实例来 init() / run()


5. 节点分类总览

packages/components/nodes/ 下按能力分成 25 个类别(目录数 26,减去 index.ts)。挑代表性的列出:

类别目录装的是什么积木
agentflowAgentFlow V2 的节点(Start / LLM / Condition / Tool / Loop…),实现 run()
sequentialagents顺序型多智能体(Sequential Agents)的节点
multiagentsSupervisor / Worker 式多智能体编排节点
agents经典 Agent(如 ReAct、Tool Agent、Conversational Agent)
chains各类 LangChain 链(LLMChain、RetrievalQA、SQL 链…)
chatmodels对话大模型接入(ChatOpenAI、ChatAnthropic…)
llms文本补全型 LLM 接入
tools工具(Calculator、搜索、HTTP 请求、自定义 Function…)
vectorstores向量库(实现 vectorStoreMethods,含 upsert/search/delete)
embeddings向量化模型
memory对话记忆(Buffer、Window、Summary…)
retrievers检索器
documentloaders文档加载器(PDF、网页、Notion…)
textsplitters文本切分
prompts提示词模板
outputparsers输出解析器
moderation内容审核
cache缓存
graphs图/知识图相关
recordmanager增量索引的记录管理
engine / utilities / responsesynthesizer / speechtotext / analytic引擎组件、工具函数节点、响应合成、语音转文字、埋点分析

依据:ls packages/components/nodes/(as-of 本 commit,26 项含 index.ts)。每个类别下再按具体实现分子目录,每个子目录一个(或多个)实现 INode 的类文件——结构与第 3.5 节的 Calculator 一致。


6. 凭据加密:密钥怎么被保护、又怎么在构建时注入

节点要连 OpenAI、数据库等外部服务,需要 API key。Flowise 的原则是:画布和数据库里永不存明文,只存密文;直到构建那一刻,才在内存里解密注入。

6.1 全链路

用户填 key ──encryptCredentialData──▶ AES 密文 ──存 Credential.encryptedData

构建某节点时,node.data.credential = 凭据id │
│ getCredentialData(id) ▼
└──▶ 按 id 查库拿密文 ──decryptCredentialData──▶ 明文对象

getCredentialParam('openAIApiKey', …) ──▶ 注入节点

6.2 落库:加密

存凭据时,明文对象被 AES 加密成一段字符串,包进 Credential 实体:

// packages/server/src/utils/index.ts:1597-1600 encryptCredentialData
export const encryptCredentialData = async (plainDataObj) => {
const encryptKey = await getEncryptionKey()
return AES.encrypt(JSON.stringify(plainDataObj), encryptKey).toString()
}
// packages/server/src/utils/index.ts:1745-1753 transformToCredentialEntity
const encryptedData = await encryptCredentialData(body.plainDataObj)
credentialBody.encryptedData = encryptedData // 只有密文进 DB

(可选走 AWS Secrets Manager:密文以 FlowiseCredential_ 前缀存到 Secrets Manager,见 decryptCredentialData 的分支,index.ts:1616-1632。)

6.3 构建期:解密注入

节点被构建时,用它 data.credential 里存的 id 反查、解密:

// packages/components/src/utils.ts:651-669 getCredentialData
const credential = await appDataSource.getRepository(...['Credential']).findOneBy({ id: selectedCredentialId })
const decryptedCredentialData = await decryptCredentialData(credential.encryptedData) // 明文只活在内存
return decryptedCredentialData

解密由 decryptCredentialData 完成(AES 反解,index.ts:1609-1655)。节点代码里再用 getCredentialParam 取具体字段:

// packages/components/src/utils.ts:686-688 getCredentialParam
export const getCredentialParam = (paramName, credentialData, nodeData, defaultValue?) =>
(nodeData.inputs)[paramName] ?? credentialData[paramName] ?? defaultValue ?? undefined

注意取值优先级:先看节点 inputs 里有没有直填,再看解密出来的凭据,最后默认值——这允许用户在画布上临时覆盖凭据字段。(getCredentialParamgetCredentialDatagetBaseClasses 同属 components 包的 packages/components/src/utils.ts,而 encryptCredentialData/decryptCredentialData 在 server 包的 packages/server/src/utils/index.ts——两个 utils 别混。)

6.4 回传前:脱敏

当凭据要回显给前端时,password 类型的字段会被打码,绝不把明文送回浏览器:

// packages/server/src/utils/index.ts:1784-1789 redactCredentialWithPasswordType
export const redactCredentialWithPasswordType = (componentCredentialName, decryptedCredentialObj, componentCredentials) => {
const plainDataObj = cloneDeep(decryptedCredentialObj)
// ...把 type=password 的字段值替换成掩码
}

decryptCredentialData 在传入了 componentCredentialName 时会调用它做脱敏(index.ts:1646-1648)。"构建注入用明文、回传前端用掩码"这条分界,是凭据安全的关键。


7. 一句话回顾 + 往哪读

  • 流 = 数据flowData 是 JSON,JSON.parseIReactFlowObject { nodes, edges }node.dataINodeData)带填好的 inputs 和加密的 credential id,edges 用 handle 精确串联输入/输出口。
  • 节点 = 积木:一个实现 INode 契约的类;元数据(INodeProperties)决定显示与归类,init() 装配、run() 执行,baseClasses 决定能否对上口。
  • 加载:NodesPool 启动时扫 dist/nodesnew nodeClass()、按 name 登记进 componentNodes
  • 凭据:全程密文,构建那一刻才解密注入,回传前端前脱敏。

静态结构到此为止。 这些 nodes/edges 和节点实例接下来怎么被拼成可执行的东西:


8. 代码地图(导航索引)

主题文件路径符号名
flowData 解析入口packages/server/src/utils/buildAgentflow.tsJSON.parse(flowData)(1592)
解析后的图结构packages/server/src/Interface.tsIReactFlowObjectIReactFlowNodeIReactFlowEdge
节点契约(能力)packages/components/src/Interface.tsINodeinitrunloadMethodsvectorStoreMethods
节点元数据packages/components/src/Interface.tsINodeProperties
运行时节点数据packages/components/src/Interface.tsINodeData
输入参数定义packages/components/src/Interface.tsINodeParamsNodeParamsType
类型血统计算packages/components/src/utils.tsgetBaseClasses
最小节点示例packages/components/nodes/tools/Calculator/Calculator.tsCalculator_Tools
复杂节点(run)packages/components/nodes/agentflow/LLM/LLM.tsLLM_Agentflowrun(340)
节点加载器packages/server/src/NodesPool.tsNodesPoolloadNodesFromDircomponentNodes
凭据加密packages/server/src/utils/index.tsencryptCredentialDatatransformToCredentialEntity
凭据解密packages/server/src/utils/index.tsdecryptCredentialData
凭据取用/注入packages/components/src/utils.tsgetCredentialDatagetCredentialParam
凭据脱敏packages/server/src/utils/index.tsredactCredentialWithPasswordType