节点体系:几十种节点,怎么"按需造、注对料"
Day08 引擎跑图时,一个个"节点对象"是从哪来的?主角是 core/workflow/node_factory.py。弄清三件事:①几十种节点(LLM/工具/代码/HTTP/知识检索/Agent…)怎么注册到一个统一的登记表里,且能"内置节点 + Dify 本地节点"合流;②拿到一份节点配置,怎么按"类型 + 版本"精确解析出对应的节点类;③造节点时,为什么不同类型的节点要注入完全不同的依赖——LLM 节点要塞模型实例、工具节点要塞工具运行时、代码节点要塞代码执行器。这一节讲清"节点工厂"这个承上启下的枢纽。
DifyNodeFactory 像剧组的选角 + 场务——剧本(节点配置)写着"这场需要一个'医生'角色",选角按"角色类型 + 版本(第几季的医生)"找到对应演员(节点类),场务再按角色给他配道具(LLM 节点配'话筒和模型'、工具节点配'工具箱')。类比二:节点注册表像手机的 App 商店——系统预装的 App(graphon.nodes 内置节点)和你自己装的 App(core.workflow.nodes Dify 本地节点)都出现在同一个应用列表里,点哪个都能打开;register_nodes 就是"把两处 App 都扫描进列表"的那次开机加载。痛点:几十种节点,引擎不该认识它们
GraphEngine 只懂图论,绝不能让它 if node_type == "llm": ... elif node_type == "tool": ... ——那样引擎每加一种节点都得改,彻底违背"引擎与节点解耦"。而且不同节点要的"料"天差地别:LLM 节点要模型实例和 prompt 序列化器,代码节点要沙箱执行器。这些"造节点 + 配料"的活得有个专门的地方管。DifyNodeFactory(api/core/workflow/node_factory.py:279)作为"节点工厂":注册表(所有节点类按"类型→版本→类"登记)解决"有哪些节点";解析(resolve_workflow_node_class)解决"按类型+版本找到类";依赖注入(create_node 按类型注入不同 kwargs)解决"每种节点配不同的料"。引擎只调 factory.create_node(config) 拿到一个 Node,完全不关心是哪种节点。注册:内置节点 + Dify 本地节点合流
所有节点得先"报到"才能被造出来。register_nodes(api/core/workflow/node_factory.py:113)负责这次报到:
# api/core/workflow/node_factory.py:113
def register_nodes() -> None:
"""Import production node modules so they self-register with ``Node``."""
_import_node_package("graphon.nodes") # ① 导入引擎内置的通用节点
_import_node_package("core.workflow.nodes") # ② 导入 Dify 本地的专有节点
def get_node_type_classes_mapping() -> Mapping[NodeType, Mapping[str, type[Node]]]:
register_nodes() # ③ 先确保都注册了
return Node.get_node_type_classes_mapping() # ④ 返回"类型→版本→类"的登记表快照
self-register 机制关键词 "self-register":节点类在被 import 时,通过基类 Node 的元类/装饰器自动把自己登记进全局注册表。所以 register_nodes 只需"把两个包 import 一遍",注册就自动发生了。graphon.nodes(内置)引擎自带的通用节点:开始/结束/LLM/代码/条件/HTTP 等——这些是"图执行框架"的标配,跟 Dify 无关,所以放在通用的 graphon 包。core.workflow.nodes(本地)Dify 专有节点:知识检索、数据源、各种触发器(webhook/定时)、人工输入等(Day08 那个 nodes 目录里看到的 knowledge_retrieval/datasource/trigger_*/human_input)。这些绑定 Dify 的业务,放本地包。为什么工作流层来做合流源码注释说得明白:"workflow layer owns node bootstrap because it must compose built-in graphon.nodes with workflow-local nodes"——只有工作流层同时知道这两处,所以由它负责把两边合并,而不是塞进更底层的图原语里。register_nodes 就干这一件事:把 graphon 内置的 + Dify 自家的节点全 import 一遍,它们各自"举手报到",登记表就齐了。之后想造哪个节点,查这张表就行。解析:按"类型 + 版本"精确找到节点类
登记表是 {类型: {版本: 类}} 两层结构。resolve_workflow_node_class(api/core/workflow/node_factory.py:131)负责查表:
# api/core/workflow/node_factory.py:131
def resolve_workflow_node_class(*, node_type, node_version) -> type[Node]:
node_mapping = get_node_type_classes_mapping().get(node_type) # ① 先按类型取"版本表"
if not node_mapping:
raise ValueError(f"No class mapping found for node type: {node_type}")
latest_node_class = node_mapping.get(LATEST_VERSION) # ② 取"latest"兜底
matched_node_class = node_mapping.get(node_version) # ③ 取精确匹配的版本
node_class = matched_node_class or latest_node_class # ④ ★精确优先,否则退 latest
if not node_class:
raise ValueError(f"No latest version class found for node type: {node_type}")
return node_class
两层查找先按 node_type(如 LLM)拿到"这个类型的所有版本",再按 node_version 精确挑一个版本的实现类。类型不存在或版本表为空都直接报错——诚实失败。matched or latest★这行是版本兼容的精华:优先用精确匹配的版本(老工作流存了 v1,就给它 v1 的类,行为不变);匹配不到才退回 latest(新建的没指定版本,用最新的)。老图不会因为出了新版本节点而被改变行为——这就是"向后兼容"落到代码里的样子。LATEST_VERSION = "latest"(api/core/workflow/node_factory.py:73)就是字符串 "latest",作为登记表里"最新实现"的固定键。找起点:哪个节点是"开始节点"
Day08 说拓扑不选起点。选起点是 get_default_root_node_id(api/core/workflow/node_factory.py:150)的活:
# api/core/workflow/node_factory.py:150
def get_default_root_node_id(graph_config) -> str:
nodes = graph_config.get("nodes")
for node in nodes:
if node.get("type") == "custom-note": # ① 跳过"便签"这种非执行节点
continue
node_id = node.get("id"); data = node.get("data")
node_type = data.get("type")
if is_start_node_type(node_type): # ② ★找到第一个"开始类"节点
return node_id
raise ValueError("Unable to determine default root node ID from workflow graph")
# api/core/workflow/node_factory.py:145
def is_start_node_type(node_type) -> bool:
return node_type in _START_NODE_TYPES # 开始/数据源/各种触发器
_START_NODE_TYPES(api/core/workflow/node_factory.py:74)不止一种!包含 START(普通开始)、DATASOURCE(数据源触发)、还有 TRIGGER_NODE_TYPES(webhook/定时触发器)。因为工作流的"起点"可以是"用户点运行",也可以是"被 webhook/定时器唤醒"。跳过 custom-note画布上的"便签"是给人看的注释,不参与执行。找起点时得先把这类"非执行节点"过滤掉。又一个"用户数据不干净、要防御"的例子。找不到就报错一张合法工作流必须有起点。没有开始类节点直接抛错,而不是随便挑一个——宁可明确失败,不留隐患。create_node:造节点,并按类型注入不同依赖
核心方法 DifyNodeFactory.create_node(api/core/workflow/node_factory.py:377)。它先解析类,再按节点类型选一份"依赖配方":
# api/core/workflow/node_factory.py:377
def create_node(self, node_config) -> Node:
adapted_node_config = adapt_node_config_for_graph(node_config)
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config) # ① 校验配置
node_id = typed_node_config["id"]; node_data = typed_node_config["data"]
node_class = self._resolve_node_class(node_type=node_data.type, node_version=str(node_data.version))
resolved_node_data = self._validate_resolved_node_data(node_class, node_data) # ② 用具体类再校验一遍
node_type = node_data.type
# ③ ★按节点类型选"依赖配方"——每种节点要的料不同
node_init_kwargs_factories = {
BuiltinNodeTypes.CODE: lambda: {"code_executor": self._code_executor, "code_limits": ...},
BuiltinNodeTypes.HTTP_REQUEST: lambda: {"http_client": ..., "file_manager": ...},
BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs(node_class=node_class, ...),
BuiltinNodeTypes.TOOL: lambda: {"tool_file_manager": ..., "runtime": self._tool_runtime},
BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class),
# ... QUESTION_CLASSIFIER / PARAMETER_EXTRACTOR / DOCUMENT_EXTRACTOR / HUMAN_INPUT ...
}
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() # ④ 取配方;没有就空
constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True)
return node_class( # ⑤ ★造节点,注入依赖
node_id=node_id, data=constructor_node_data,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
**node_init_kwargs,
)
两次校验先用宽松的通用 schema 校验一遍,解析出节点类后再用该类专属的 schema 校验一遍(_validate_resolved_node_data)。因为通用校验只能保证"长得像个节点",专属校验才能保证"这个 LLM 节点的字段都齐全合法"。node_init_kwargs_factories(配方表)★一个 {类型: 造料函数} 的字典。用字典派发代替 if-else 链——加新节点类型只需往字典里加一项,不动主流程。且是 lambda 惰性构造:只有命中的那种节点才真正去造料,不浪费。每种节点料不同CODE 要"代码执行器 + 限制"(跑用户代码要沙箱);HTTP 要"http 客户端 + 文件管理";TOOL 要"工具运行时";LLM/QUESTION_CLASSIFIER/PARAMETER_EXTRACTOR 都要"LLM 兼容套件"(L06 讲);没在表里的(如开始/结束节点)就 {} 空料。统一的构造签名所有节点最终都用 node_class(node_id, data, graph_init_params, graph_runtime_state, **node_init_kwargs) 造。前四个参数所有节点都有(引擎需要),**node_init_kwargs 才是各类型的差异——共性走位置参数,差异走关键字参数。LLM 类节点为何要"特殊照顾"
注意配方表里,LLM、QUESTION_CLASSIFIER(问题分类)、PARAMETER_EXTRACTOR(参数提取)三种节点都调同一个 _build_llm_compatible_node_init_kwargs(api/core/workflow/node_factory.py:534),只是开关不同:
# api/core/workflow/node_factory.py:418(LLM 节点的配方)
BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs(
node_class=node_class, node_data=resolved_node_data,
wrap_model_instance=True, # ① 要把 ModelInstance 包成节点能用的适配器(Day10!)
include_http_client=True,
include_llm_file_saver=True,
include_prompt_message_serializer=True, # ② 要 prompt 消息序列化器
include_retriever_attachment_loader=True,
include_jinja2_template_renderer=True,
),
# api/core/workflow/node_factory.py:442(参数提取节点:同一套,但关掉大部分开关)
BuiltinNodeTypes.PARAMETER_EXTRACTOR: lambda: self._build_llm_compatible_node_init_kwargs(
node_class=node_class, node_data=resolved_node_data,
wrap_model_instance=True,
include_http_client=False, include_llm_file_saver=False,
include_prompt_message_serializer=True,
include_retriever_attachment_loader=False, include_jinja2_template_renderer=False,
),
为何这三种归一类问题分类、参数提取,本质上都是在内部调 LLM(让模型判断类别、抽取字段)。所以它们和 LLM 节点共享"要一个模型实例 + prompt 相关工具"这套基础料——用同一个 builder,靠布尔开关裁剪细节。wrap_model_instance=True★注意:不是直接把 Day05 的 ModelInstance 塞进去,而是包一层适配器。因为引擎里的节点只认一个精简接口,不该看到 ModelInstance 的全部 API——这个包装就是 Day10 要讲的 DifyPreparedLLM。开关裁剪LLM 节点功能全(要文件、要检索附件、要模板渲染),参数提取只需要"模型 + prompt 序列化"就够了。同一个 builder 用开关按需给料——避免给参数提取节点塞它用不到的一堆依赖。_build_model_instance_for_llm_nodebuilder 内部会为节点真正取一个 ModelInstance(走的正是 Day05 的 ModelManager!)。这就是"节点工厂"和"模型管理"两个体系的接头处。ModelManager 要 ModelInstance,再包成适配器(Day10 的 DifyPreparedLLM)塞给节点;节点跑起来时用它调 invoke_llm,prompt 由 Day07 的 transform 思路拼。阶段2(模型)和阶段3(工作流)就是在 node_factory 这里咬合的。串起来 + 今日小结
{"id":"llm_1","data":{"type":"llm","version":"1","model":{...},"prompt_template":[...]}}。→ 引擎调 factory.create_node(该配置) → create_node 先校验、再 resolve_node_class(type="llm", version="1"):查登记表 llm 类型的版本表,精确命中 v1 的类(没命中才退 latest)→ 命中后按 BuiltinNodeTypes.LLM 取配方,调 _build_llm_compatible_node_init_kwargs(wrap_model_instance=True, ...):内部用 Day05 的 ModelManager 取到该模型的 ModelInstance,包成 DifyPreparedLLM,连同 prompt 序列化器等一起打包成 kwargs → 最后 LLMNode(node_id="llm_1", data=..., graph_init_params, graph_runtime_state, llm=DifyPreparedLLM, ...) → 返回给引擎。引擎从头到尾只调了一句 create_node,完全不知道 LLM 长什么样。👶 小白:为什么要"注册"这一步?直接 import 节点类用不行吗?
👨🏫 老师:因为工厂拿到的是数据里的字符串("type":"llm"),不是代码里的类名。它没法 import LLMNode——它只知道字符串 "llm"。所以需要一张"字符串 → 类"的登记表:节点类在 import 时"报到"登记(self-register),工厂运行时拿字符串查表。这就是注册表模式的意义:把"配置里的名字"和"代码里的实现"解耦。你在 JSON 里写个 type,后端就能找到对应的类——中间靠这张表牵线。加新节点?写好类、让它被 import 到,就自动进表,工厂立刻会造它,主流程一行不用改。
🧠 今天你应该能回答
- 引擎为什么不该认识具体节点?(解耦;引擎只懂图论,节点交给工厂)
- 节点怎么被登记?(
register_nodesimport 两处包 → self-register) - 内置节点和本地节点分别在哪?(
graphon.nodes/core.workflow.nodes) - 怎么按类型+版本找到类?(两层表;精确匹配优先,否则退 latest)
- 节点为什么要版本?(老工作流行为保持稳定,向后兼容)
- create_node 怎么给不同节点配料?(配方字典按类型派发,惰性造料)
- LLM 节点的模型从哪来?(builder 内用 Day05 ModelManager 取 + 包成适配器)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/dify
# 1. 注册与解析
sed -n '113,145p' api/core/workflow/node_factory.py # register_nodes / mapping / resolve
sed -n '145,178p' api/core/workflow/node_factory.py # is_start_node_type / get_default_root_node_id
# 2. 造节点 + 依赖配方
sed -n '377,462p' api/core/workflow/node_factory.py # create_node + 配方字典
# 3. LLM 兼容套件
sed -n '534,600p' api/core/workflow/node_factory.py # _build_llm_compatible_node_init_kwargs
# 4. 看看本地节点有哪些
ls api/core/workflow/nodes/
core/workflow/node_runtime.py,看 Dify 侧的一整套"运行时适配器"(DifyPreparedLLM/DifyToolNodeRuntime/DifyFileReferenceFactory)怎么把 Dify 的能力"翻译"成引擎节点认识的精简协议,把阶段3 收官。