Day 09 / 共 20 天 · 阶段3 工作流

节点体系:几十种节点,怎么"按需造、注对料"

Day08 引擎跑图时,一个个"节点对象"是从哪来的?主角是 core/workflow/node_factory.py。弄清三件事:①几十种节点(LLM/工具/代码/HTTP/知识检索/Agent…)怎么注册到一个统一的登记表里,且能"内置节点 + Dify 本地节点"合流;②拿到一份节点配置,怎么按"类型 + 版本"精确解析出对应的节点类;③造节点时,为什么不同类型的节点要注入完全不同的依赖——LLM 节点要塞模型实例、工具节点要塞工具运行时、代码节点要塞代码执行器。这一节讲清"节点工厂"这个承上启下的枢纽。

📍 你在 20 天里的位置(阶段3:工作流 · D08-10)
D06 Provider/实体 D07 Prompt/生成 D08 工作流总览 D09 节点体系 D10 图执行引擎 S4 RAG S5 工具/Agent S6 收官
💡 先用两个类比兜住今天 类比一:DifyNodeFactory剧组的选角 + 场务——剧本(节点配置)写着"这场需要一个'医生'角色",选角按"角色类型 + 版本(第几季的医生)"找到对应演员(节点类),场务再按角色给他配道具(LLM 节点配'话筒和模型'、工具节点配'工具箱')。类比二:节点注册表像手机的 App 商店——系统预装的 App(graphon.nodes 内置节点)和你自己装的 App(core.workflow.nodes Dify 本地节点)都出现在同一个应用列表里,点哪个都能打开;register_nodes 就是"把两处 App 都扫描进列表"的那次开机加载。
L01

痛点:几十种节点,引擎不该认识它们

🤔 痛点工作流里的节点五花八门:开始、结束、LLM、知识检索、工具、代码、HTTP 请求、条件判断、模板转换、参数提取、Agent……而且还有版本(同一个 LLM 节点 v1/v2 行为不同,老工作流得继续用老版本)。Day08 的 GraphEngine 只懂图论,绝不能让它 if node_type == "llm": ... elif node_type == "tool": ... ——那样引擎每加一种节点都得改,彻底违背"引擎与节点解耦"。而且不同节点要的"料"天差地别:LLM 节点要模型实例和 prompt 序列化器,代码节点要沙箱执行器。这些"造节点 + 配料"的活得有个专门的地方管。
💡 本质:工厂模式 + 注册表 + 依赖注入Dify 用 DifyNodeFactoryapi/core/workflow/node_factory.py:279)作为"节点工厂":注册表(所有节点类按"类型→版本→类"登记)解决"有哪些节点";解析resolve_workflow_node_class)解决"按类型+版本找到类";依赖注入create_node 按类型注入不同 kwargs)解决"每种节点配不同的料"。引擎只调 factory.create_node(config) 拿到一个 Node,完全不关心是哪种节点。
L02

注册:内置节点 + Dify 本地节点合流

所有节点得先"报到"才能被造出来。register_nodesapi/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"——只有工作流层同时知道这两处,所以由它负责把两边合并,而不是塞进更底层的图原语里。
大白话想象开机时系统要把"预装 App"和"你装的 App"都扫进桌面。register_nodes 就干这一件事:把 graphon 内置的 + Dify 自家的节点全 import 一遍,它们各自"举手报到",登记表就齐了。之后想造哪个节点,查这张表就行。
L03

解析:按"类型 + 版本"精确找到节点类

登记表是 {类型: {版本: 类}} 两层结构。resolve_workflow_node_classapi/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",作为登记表里"最新实现"的固定键。
💡 设计取舍:为什么节点要带版本?工作流是用户长期保存、反复运行的资产。如果 LLM 节点从 v1 升到 v2 改了行为(比如换了默认参数),已经在生产跑的老工作流不能被悄悄改变——否则用户的应用会莫名其妙出问题。代价是:每种节点得维护多个版本的类、注册表变成两层。用"多版本共存的复杂度",换"老用户的图永远稳定"——对一个平台产品,这个交换非常划算。
L04

找起点:哪个节点是"开始节点"

Day08 说拓扑不选起点。选起点是 get_default_root_node_idapi/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_TYPESapi/core/workflow/node_factory.py:74)不止一种!包含 START(普通开始)、DATASOURCE(数据源触发)、还有 TRIGGER_NODE_TYPES(webhook/定时触发器)。因为工作流的"起点"可以是"用户点运行",也可以是"被 webhook/定时器唤醒"。
跳过 custom-note画布上的"便签"是给人看的注释,不参与执行。找起点时得先把这类"非执行节点"过滤掉。又一个"用户数据不干净、要防御"的例子。
找不到就报错一张合法工作流必须有起点。没有开始类节点直接抛错,而不是随便挑一个——宁可明确失败,不留隐患。
起点确定后,Day08 的引擎就从这个根节点出发,沿着边一路推进。"类型决定语义"在这里再次出现:拓扑只看"边的连接",而"谁能当起点"是由节点类型决定的业务规则。
L05

create_node:造节点,并按类型注入不同依赖

核心方法 DifyNodeFactory.create_nodeapi/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 才是各类型的差异——共性走位置参数,差异走关键字参数。
create_node:一份配置 → 造好并配齐料的节点 节点配置(含 type/version) resolve:按 type+version 找到类 按 type 选依赖配方 ↓ LLM 节点+模型+序列化器 工具节点+工具运行时 代码节点+沙箱执行器 HTTP 节点+http客户端 开始/结束空料 {}
图注:同一个 create_node 入口,按节点类型注入不同"料",产出统一的 Node 对象交给引擎。
L06

LLM 类节点为何要"特殊照顾"

注意配方表里,LLM、QUESTION_CLASSIFIER(问题分类)、PARAMETER_EXTRACTOR(参数提取)三种节点都调同一个 _build_llm_compatible_node_init_kwargsapi/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!)。这就是"节点工厂"和"模型管理"两个体系的接头处。
💡 承上启下的枢纽这一节把前后全串起来了:节点工厂造 LLM 节点时,向 Day05 的 ModelManagerModelInstance,再包成适配器(Day10 的 DifyPreparedLLM)塞给节点;节点跑起来时用它调 invoke_llm,prompt 由 Day07 的 transform 思路拼。阶段2(模型)和阶段3(工作流)就是在 node_factory 这里咬合的
L07

串起来 + 今日小结

📝 真实值:引擎要跑一个 LLM 节点时发生了什么 Day08 的引擎推进到一个节点,配置是 {"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_nodes import 两处包 → 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/
明日预告 · Day 10:节点造好了、引擎在跑了——今天反复提到的"把 ModelInstance 包成适配器"到底怎么回事?明天进 core/workflow/node_runtime.py,看 Dify 侧的一整套"运行时适配器"(DifyPreparedLLM/DifyToolNodeRuntime/DifyFileReferenceFactory)怎么把 Dify 的能力"翻译"成引擎节点认识的精简协议,把阶段3 收官。
← Day 08 工作流总览 Day 10 · 图执行引擎 →