跳到主要内容

数据截至 (上游 commit b78a3462c9a6)

从 JSON 到可执行图:graphon 边界与 DifyNodeFactory

30 秒导读: 画布存下来的是一坨 {"nodes": [...], "edges": [...]} 的 JSON。这一章讲它怎么变成一组真的能跑的 Python 节点对象。关键事实只有两条:图调度器和绝大多数节点实现已经被抽成外部 PyPI 包 graphon,不在这个克隆里;Dify 在克隆里留下的,主要是一条依赖注入式的装配线 DifyNodeFactory


1. 这一章要解决的问题(零基础也能懂)

一句话定义: 把一张"图纸"(graph JSON)翻译成一组"装好电、连好线、能立刻开跑的机器"(Node 对象),并交给引擎去调度。

场景化: 你在 Dify 画布上拖了 5 个节点——开始、LLM、代码、HTTP 请求、结束——点了运行。系统必须在几十毫秒内回答一串很具体的问题:

  • 这个 "type": "llm" 该实例化成哪个 Python 类?如果 JSON 里写着 "version": "3" 而代码里只有 v1、v2 呢?
  • LLM 节点要调模型,模型的 provider 凭证从哪来?代码节点要跑沙箱,沙箱客户端谁给?HTTP 节点要防 SSRF,代理谁给?
  • 谁是起点?多分支图里第一个跑的节点凭什么是它?

心智模型(三个角色):

角色白话在本章的名字
图纸画布存下来的 JSON,只有结构,没有行为graph_config
外购的机架别人写好的调度器 + 节点行为,Dify 只是使用者graphon
自家装配线把 Dify 的资源接到外购机器上的那一层DifyNodeFactory

图纸怎么来的、存在哪张表里,见 编辑侧:画布画出什么、数据库存什么;建好图之后引擎怎么被 HTTP 请求驱动、事件怎么变成 SSE,见 一次运行的生命周期


2. 先划边界:哪些代码在克隆里,哪些不在

这一节必须放在最前面。不先划边界,你会在 api/core/workflow/nodes/ 里找 LLM 节点,然后怀疑人生——它不在那儿。

2.1 硬事实:graphon 是外部依赖

Dify 的后端依赖清单里明明白白钉着一个版本:

  • api/pyproject.toml:48 —— "graphon==0.7.0"
  • api/uv.lock:3018 —— name = "graphon" / version = "0.5.3" / source = { registry = "https://pypi.org/simple" }

这个克隆里没有 graphon 的源码目录find 全仓找不到名为 graphon 的包目录),但 api/ 下有 1600 多处 from graphon... 的导入。也就是说:Dify 后端已经把工作流内核当成第三方库在用。

2.2 分工线长什么样

这个克隆里能读到 graphon 0.5.3(PyPI,读不到源码)
┌──────────────────────────┐ ┌──────────────────────────────┐
│ 谁来跑、跑给谁看 │ │ 怎么跑 │
│ · 装配线 & 依赖注入 │ ── 注入 ─► │ · Graph / GraphEngine │
│ · 模型凭证 / 代码沙箱 │ │ · VariablePool / 运行态 │
│ · SSRF 代理 / 文件 / 记忆 │ ◄─ 事件 ── │ · 绝大多数内置节点的行为 │
│ · 老配置改写、留痕落库 │ │ · 节点自注册的注册表机制 │
└──────────────────────────┘ └──────────────────────────────┘

怎么读这张图:左边定义"资源",右边定义"行为";箭头向右是构造期的依赖注入,箭头向左是运行期的事件流。

2.3 读到哪儿为止(重要)

你想看的东西在克隆里吗该怎么办
GraphGraphEngineGraphEngineConfig只能从 Dify 的调用点反推参数契约(见 §3)
VariablePoolGraphRuntimeState同上;Dify 侧只写入、只读取
LLM / code / http_request / iteration / loop / question_classifier / human_input / tool / document_extractor / template_transform 等内置节点DifyNodeFactory 注入的 kwargs 反推它们的构造函数签名(§4.4)
ResponseStreamFilterfilter_graph_events只能读 Dify 的调用点和注释(api/core/workflow/workflow_entry.py:49-70
DifyNodeFactory 及全部注入物api/core/workflow/node_factory.pynode_runtime.py
agent / agent_v2 / datasource / knowledge_retrieval / knowledge_index 节点api/core/workflow/nodes/(§7 讲为什么是这五类)

一个能反证"内置节点真的不在这儿"的实证:api/core/workflow/nodes/ 下只有 agent/agent_v2/datasource/knowledge_index/knowledge_retrieval/trigger_plugin/trigger_schedule/trigger_webhook/ 八个包,其 __init__.py 只写了一句话——"Workflow node implementations that remain under the legacy core.workflow namespace"(api/core/workflow/nodes/__init__.py:1)。remain(留下)这个词就是边界本身。


3. 顶层全景:一次建图的装配顺序

3.1 五步流水线

graph JSON 运行身份 装配线 外购内核
┌──────────┐ ┌───────────────────┐ ┌───────────────┐ ┌──────────────┐
│ nodes/ │──►│ DifyGraphInitCtx │──►│ DifyNodeFactory│──►│ Graph.init() │
│ edges │ │ (租户/用户/来源) │ │ (+运行态) │ │ 逐节点造对象 │
└──────────┘ └───────────────────┘ └───────────────┘ └──────┬───────┘

┌────────────────────────▼───────┐
│ WorkflowEntry → GraphEngine │
│ + Layer 钩子 + 命令通道 │
└────────────────────────────────┘

怎么读:从左到右是严格的先后顺序,每一步的产物都是下一步的入参,不能颠倒。

3.2 每一步落到哪行代码

步骤做什么代码位置
校验 JSON 里有 nodes / edges 且都是 listapi/core/app/apps/workflow_app_runner.py:132-139_init_graph
打包运行身份:tenant / app / user / invoke_frombuild_dify_run_contextapi/core/app/entities/app_invoke_entities.py:68
包成显式初始化上下文DifyGraphInitContextapi/core/workflow/node_factory.py:87
造装配线(绑定运行态)DifyNodeFactory.from_graph_init_contextnode_factory.py:307
决定起点 + 建图(此处才真正逐个造节点对象)get_default_root_node_id:148Graph.initworkflow_app_runner.py:165-168
建引擎、挂 LayerWorkflowEntry.__init__api/core/workflow/workflow_entry.py:92-175

3.3 DifyGraphInitContext 为什么单独存在

它是一个 4 字段的冻结 dataclass:workflow_id / graph_config / run_context / call_depthnode_factory.py:95-98),只有一个方法 to_graph_init_params() 把自己翻译成 graphon 的 GraphInitParams

类的 docstring 把动机说得很直白(node_factory.py:88-93):Dify 正在逐步从生产调用点移除对 GraphInitParams 的直接构造,翻译逻辑先集中放在这里,等 graphon 暴露等价的显式 API 再撤。

这是一个很实用的"依赖收口"手法:外部包的构造器只在一个函数里被调用,将来换签名只改一处。

3.4 引擎装配:主引擎 vs 子引擎

WorkflowEntry.__init__ 做三件事(workflow_entry.py:99-175):

  1. 深度闸门——call_depth > dify_config.WORKFLOW_CALL_MAX_DEPTH 直接抛异常(:129-131)。
  2. GraphEngine——线程池参数全部来自 Dify 配置:GRAPH_ENGINE_MIN_WORKERS / MAX_WORKERS / SCALE_UP_THRESHOLD / SCALE_DOWN_IDLE_TIME:142-152)。
  3. 挂 Layer——ExecutionLimitsLayer(步数与时长上限)恒挂;DebugLoggingLayer 只在 dify_config.DEBUG 时挂;ObservabilityLayer 只在 OTel 开启时挂(:156-175)。Layer 的钩子语义是 执行期横切 那一章的内容。

子图(iteration / loop 内部)曾经走 Dify 侧单独的 _WorkflowChildEngineBuilder.build_child_engine,如今这套子引擎装配已被整个移除:迭代/循环容器在 generator/runner.py 里被合成进同一张图api/core/workflow/generator/runner.py:1280 起的容器拓扑合成),由同一个 GraphEngine 调度——不再有「子引擎只挂 child-safe Layer」这条分支,深度闸门(上面的 call_depth)转为防无限嵌套的兜底。

3.5 那层"兼容旧流式语义"的过滤

iter_dify_graph_engine_events(engine)workflow_entry.py:49-70)是一个小包装:

# 真实实现见 api/core/workflow/workflow_entry.py:65-69
yield from filter_graph_events(
engine.run(),
context=GraphEventFilterContext.from_engine(engine),
filters=[response_stream_filter or ResponseStreamFilter()],
)

它在干嘛:docstring 说得很清楚——graphon 0.5.0 起直接吐"原始变量流片段",调用方必须显式选择加入旧的"按 response 排序"的流式行为,而 Dify 对外暴露的正是旧语义(workflow_entry.py:53-64)。它还多收一个可选的 response_stream_filter 参数:暂停恢复的场景要把当初持久化的那个 filter 实例传回来,保证 paths_map 覆盖引擎已流出的全部内容(:61-64 的 docstring 写明了这个约束)。

两个值得注意的边界(都靠 grep 全仓验证):

  • iter_dify_graph_engine_events 全仓只有一个调用点WorkflowEntry.runworkflow_entry.py:182)。
  • 因此单节点调试也不过滤——single_step_run 直接返回 _run_node_with_layers(node) 的生成器(workflow_entry.py:294,实现在 :549),那里只会包一层 ObservabilityLayer,不走 response 流过滤。

4. 核心机制一:依赖注入式的节点工厂

4.1 它要解决的小问题

graphon 的 LLM 节点知道"怎么组 prompt、怎么处理流式",但它不该知道 Dify 的 provider 表、租户凭证缓存、ToolFile 表长什么样。反过来,Dify 知道这些,却不想重写一遍节点行为。

于是分工是:graphon 定义行为,Dify 在构造那一刻把资源塞进去。

4.2 直觉:装配线不是 if/else,是"每种型号配一箱零件"

# 示意,非源码
def create_node(cfg):
cls = resolve_class(cfg["type"], cfg["version"]) # 1. 挑型号
extra = PARTS.get(cfg["type"], lambda: {})() # 2. 取这型号专属零件箱(懒执行)
return cls(node_id=cfg["id"], data=cfg["data"], # 3. 装配
graph_init_params=..., graph_runtime_state=..., **extra)

重点看 PARTS.get(...)() 后面那对括号:零件箱是 lambda,只有命中的那一种会被执行。这决定了"造一个 code 节点时不会顺手去拉 LLM 凭证"。

4.3 真实的 create_node:五步

DifyNodeFactory.create_nodeapi/core/workflow/node_factory.py:405):

干什么行号
1老配置改写(human-input / tool 两类):385
2用 graphon 的 NodeConfigDictAdapter 宽松校验:386
3按 type + version 解析出节点类:389
4用解析出的类再校验一次 node data:393
5组零件箱 → 调构造函数:395-464

第 4 步是个容易忽略的巧思。代码注释解释了为什么要校验两次(:390-392):图配置一开始只按"宽松的共享 node data"校验,等解析出具体节点类后,再用该类声明的 validate_node_data 重新校验一遍,好让"工作流本地节点的 schema 保持显式",并让构造函数拿到具体类型的载荷(_validate_resolved_node_data:466-474)。

第 5 步末尾还有个细节:传给构造函数的不是 pydantic 模型,而是 resolved_node_data.model_dump(mode="python", by_alias=True):457)。by_alias=True 意味着字段别名被保留——单测 test_create_node_passes_alias_preserving_llm_data_to_constructorapi/tests/unit_tests/core/workflow/test_node_factory.py:894)专门盯着这条不许回退。

4.4 注入清单:每种节点拿到什么

这张表就是"Dify 与 graphon 的资源契约",全部出自 node_factory.py:432-493

节点类型注入的 kwargs背后是 Dify 的哪块
CODEcode_executorcode_limits代码沙箱 + 8 项 CODE_MAX_* 配置
TEMPLATE_TRANSFORMjinja2_template_renderermax_output_length同一个沙箱跑 Jinja2
HTTP_REQUESThttp_request_confighttp_clienttool_file_manager_factoryfile_managerfile_reference_factorySSRF 代理 + 超时/体积上限 + 文件落库
HUMAN_INPUTruntimefile_reference_factoryform_repository表单仓储与投递渠道
LLM见 §4.5(最长的一支)模型凭证、记忆、文件保存、召回附件
QUESTION_CLASSIFIERLLM 族子集 + template_renderer同上,砍掉召回与 Jinja2 渲染器
PARAMETER_EXTRACTORLLM 族最小子集同上,连 http_client 都不给
DOCUMENT_EXTRACTORunstructured_api_confighttp_clientUnstructured 服务地址与密钥
TOOLtool_file_managerruntime工具运行时解析与调用
AGENT见 §7.2(v1 / v2 两套完全不同)插件策略 或 Agent 后端
其它(answer/end/if-else/iteration/loop/…)无额外 kwargs纯粹的图内逻辑,不需要 Dify 资源

四个注入物值得单独点名:

  • 代码沙箱 DefaultWorkflowCodeExecutornode_factory.py:282)——只有两个方法:execute 转发给 CodeExecutor.execute_workflow_code_templateis_execution_error 告诉 graphon"哪种异常算用户代码错、哪种算系统错"。错误分类权在 Dify 手里
  • SSRF 代理 graphon_ssrf_proxynode_factory.py:19 导入)——实体是 GraphonSSRFProxyapi/core/helper/ssrf_proxy.py:394,单例在 :325),docstring 写明是"把 SSRF helper 适配到 Graphon 的 HttpClientProtocol",六个 HTTP 动词各转一遍并把响应翻译成 graphon 的 HttpResponse
  • 远程文件抓取 remote_fetcher.graphon_remote_file_fetcherapi/core/file/remote_fetcher.py:345,类在 :77)——它额外做一件 SSRF 代理不做的事:识别"其实是 Dify 自家签名 URL"的地址并短路成本地读取(_resolve_dify_signed_file_url:111)。
  • 文件引用工厂 DifyFileReferenceFactoryapi/core/workflow/node_runtime.py:165)——把 mapping 转成 File,顺手绑上 tenant 和 DatabaseFileAccessController,节点自己不碰租户隔离。

4.5 LLM 族:一个开关驱动的五路裁剪

_build_llm_compatible_node_init_kwargsnode_factory.py:605)用六个布尔开关服务三种节点。三个调用点的开关组合(:416-449):

开关LLMQUESTION_CLASSIFIERPARAMETER_EXTRACTOR
wrap_model_instance
include_http_client
include_llm_file_saver
include_prompt_message_serializer
include_retriever_attachment_loader
include_jinja2_template_renderer

三条只对 LLM 生效的额外逻辑:

  1. 默认 query 选择器——default_query_selector = system_variable_selector(SystemVariableKey.QUERY):559-560)。graphon 的 LLM 节点因此不必知道 Dify 的 sys.query 约定。
  2. 轮询包装——_wrap_model_instance_for_node:563):只有 LLM 类型且模型 schema 声明 support_polling 时才包成 DifyPreparedPollingLLM,否则一律 DifyPreparedLLM。注释交代得很清楚:"只有 graphon 的 LLM 节点消费轮询协议,分类器和抽取器即使模型支持轮询也保持旧包装"(:569-571)。
  3. 召回附件访问检查——_build_retriever_segment_access_checker:588)返回一个闭包,运行时才去 variable pool 里查 metadata._source == "knowledge"segment_id 匹配,只放行"确实来自本节点上下文变量"的分段(:596-608)。这是一个建图期构造、运行期求值的权限检查。

模型实例本身在 _build_model_instance_for_llm_node:612)里通过 fetch_model_configapi/core/app/llm/model_access.py:169)拿到;凭证与模型工厂则来自 build_dify_model_access(同文件 :117),后者会为当前租户建一套共享的 provider manager + ModelManager(带凭证缓存)。

记忆的处理最能体现"边界在哪":_build_memory_for_llm_node:622)→ fetch_memory:235)。它直查 Conversation 表,然后包成 TokenBufferMemory。注意 fetch_memory 的注释(:245-248):节点构造可能发生在没有 Flask app context 的路径上,所以它用 session_factory.create_session() 而不是 Flask-SQLAlchemy 的 db.engine 代理。这句注释是"建图会真的碰数据库"的铁证。

4.6 适配器族:node_runtime.py

api/core/workflow/node_runtime.py(934 行)是这条边界上最厚的一块垫片,每个类都实现 graphon 的某个 Protocol:

适配器行号把什么藏起来
DifyFileReferenceFactory:138租户 id 与文件访问控制器
DifyPreparedLLM:151docstring:向 graphon 节点隐藏完整的 ModelInstance API
DifyPreparedPollingLLM:284多暴露 graphon 的轮询协议(start_llm_polling / check_llm_polling
DifyPromptMessageSerializer:336prompt 消息的序列化形态
DifyRetrieverAttachmentLoader:350通过 Dify 持久化解析召回附件,返回图侧的文件引用
DifyToolFileManager:397会话作用域的解析(conversation id 用 getter 懒取)
DifyToolNodeRuntime:458工具运行时解析、调用、用量、图标;把 Tool 塞进不透明句柄
DifyHumanInputNodeRuntime:762表单仓储 + 投递渠道筛选

两处值得展开:

DifyToolNodeRuntime.get_runtime:472 返回的是 ToolRuntimeHandle(raw=_WorkflowToolRuntimeBinding(...))——一个不透明句柄。graphon 拿着它但不知道里面是什么;_WorkflowToolRuntimeBinding 的 docstring 直接写着"workflow-private runtime state stored inside the opaque graph handle"(:445-450)。里面除了 Tool 本体,还偷偷带了 conversation_idparent_trace_contexttrace_session_id——父子工作流的链路追踪信息就是靠这个句柄穿过 graphon 的:497-518)。

DifyHumanInputNodeRuntime:762 在构造时接了两个 getter 而不是两个值(node_factory.py:356-363):workflow_execution_id_getterconversation_id_getter。原因很实际——建图时这两个值可能还没写进 variable pool,必须延迟到用的时候才从 graph_runtime_state.variable_pool 里读。投递语义本身(谁收到、什么渠道)见 停下来等人


5. 核心机制二:注册表与版本解析

5.1 注册:一次 import 触发的自注册

# 真实源码 api/core/workflow/node_factory.py:110-114
@lru_cache(maxsize=1)
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")

_import_node_package:102)用 pkgutil.walk_packages 把整个包走一遍逐个 importlib.import_module注册机制本身(Node 基类如何收集子类)在 graphon 里,这个克隆读不到;能确认的只有:导入即注册,且 lru_cache(maxsize=1) 保证全进程只走一次。

get_node_type_classes_mapping:117)的 docstring 解释了这段引入副作用为什么放在 workflow 层而不是更底层(:118-124):因为只有这一层需要把 graphon 内置节点和 Dify 本地节点合成一张表,放低了就等于把注册表引导逻辑又塞回图原语里。

5.2 版本解析:命中 → 回退 latest → 报错

node_type + node_version

├─ 注册表里没有这个 type ─────────────► ValueError: No class mapping found

├─ mapping[version] 命中 ────────────► 用它

├─ 未命中,mapping["latest"] 存在 ───► 用 latest

└─ latest 也没有 ────────────────────► ValueError: No latest version class

对应 resolve_workflow_node_classnode_factory.py:138-170),常量 LATEST_VERSION = "latest":80

注意回退的方向node_class = matched_node_class or latest_node_class:137)——JSON 里写了未知版本(比如从新版 Dify 导出的 DSL 拿到老版跑)不会炸,会静默降级到最新实现。单测 test_falls_back_to_latest_class_when_version_specific_mapping_is_missingtest_node_factory.py:559)用 "version": "9" 验证了这条路径。

5.3 _LazyNodeTypeClassesMapping:可写的注册表视图

NODE_TYPE_CLASSES_MAPPING:229)不是一个 dict,而是 _LazyNodeTypeClassesMapping 实例(:178)——一个实现了 MutableMapping视图

  • 读的时候先比对 Node.get_registry_version(),版本没变就复用缓存快照(_snapshot:187-197)。
  • 支持 __setitem__ / __delitem__,但改动落在 _overrides / _deleted 两个旁路集合上,不污染真实注册表:204-216)。

它的用途基本是测试与临时替换:既保持了"注册表是唯一真相",又让调用方能像操作字典一样打补丁。

5.4 谁能当起点

起点类型集合写死在一处:

# 真实源码 api/core/workflow/node_factory.py:72-74
_START_NODE_TYPES: frozenset[NodeType] = frozenset(
(BuiltinNodeTypes.START, BuiltinNodeTypes.DATASOURCE, *TRIGGER_NODE_TYPES)
)

TRIGGER_NODE_TYPES 来自 api/core/trigger/constants.py:7,含 trigger-webhook / trigger-schedule / trigger-plugin。也就是说 "起点"有三族

谁在用讲在哪一章
start普通工作流 / 对话流本章 + 生命周期
datasourceRAG 知识流水线本章 §7
trigger-*Webhook / 定时 / 插件触发触发器与插件运行时

is_start_node_type:143)只是一次集合查询;get_default_root_node_id:148)在此之上做线性扫描:跳过 "type": "custom-note" 的便签节点,找到第一个 data.type 属于起点族的节点就返回它的 id,一个都没有就抛错(:155-175)。

它的 docstring 顺带解释了自己为什么住在 node_factory 而不是 graphon 的 graph_config schema 模块里:它依赖 is_start_node_type 定义的起点语义:149-154)。


6. 核心机制三:老配置的一次性改写

create_node 的第一步 adapt_node_config_for_graphapi/core/workflow/human_input_adapter.py:247)是一个纯函数:读 node_config["data"],按 type 分派改写,返回新 dict(:246-256adapt_node_data_for_graph:233)。只有两类节点会被改:

human-inputadapt_human_input_node_data_for_graph:189)——只规范化 delivery_methods[*].config.recipients 的形状(_normalize_email_recipients:344)。投递语义完全不在这儿,见 停下来等人

tool_adapt_tool_node_data_for_graph:259)——这块更有意思,是一次真正的老格式搬迁:

老形态(tool_configurations 里)改写后
{"type": "constant"/"mixed", "value": 标量}值搬进 tool_parameterstool_configurations 里压平成裸标量
{"type": "variable", "value": ["a","b"]}压平成模板串 {{#a.b#}}_flatten_legacy_tool_configuration_value:308
值是 model/app 选择器(dict)搬进 tool_parameters不压平,避免被图校验当成常量拍扁(:276-282

搬迁一律用 setdefault:281:291)——新格式已有的键优先,改写只补不覆盖。而且只要一轮扫描没发现任何老形态(found_legacy_tool_inputs 仍为 False),就原样返回,不做任何拷贝写回(:300-301)。


7. Dify 自留的节点族:为什么这五类没下沉

7.1 五个包,两种理由

节点类 / 类型直接抓取的 Dify 内部件
knowledge_retrieval/KnowledgeRetrievalNodeknowledge_retrieval_node.py:67,version "1"DatasetRetrieval(),构造函数里直接 new(:91
knowledge_index/KnowledgeIndexNodeknowledge_index_node.py:31IndexProcessor() + SummaryIndex():46-47
datasource/DatasourceNodedatasource_node.py:28DatasourceManager:50
agent/AgentNodeagent/agent_node.py:30,version "1"插件 agent 策略
agent_v2/DifyAgentNodeagent_v2/agent_node.py:77,version "2"Agent 后端 HTTP 客户端 + WorkflowAgentNodeBinding

关键观察:前三个包的节点,构造函数签名和 graphon 内置节点一模一样——只有 node_id / data / graph_init_params / graph_runtime_state一个注入 kwarg 都没有。查 node_init_kwargs_factoriesnode_factory.py:432-493)也确认没有它们的条目。

它们不需要注入,是因为它们本身就住在 Dify 里,可以直接 from core.rag.retrieval.dataset_retrieval import DatasetRetrievalknowledge_retrieval_node.py:17)。

由此可以归纳出这条边界的判据 (inferred,但由上表的构造函数签名与导入直接支撑):

  • 行为通用、只有资源是 Dify 专属 → 下沉到 graphon,资源靠注入(LLM、code、http_request、tool、human_input…)。
  • 行为本身就是 Dify 领域(数据集、文档索引、数据源插件、Agent 绑定关系) → 留在 Dify,直接调自家服务。

还有一个额外证据:KnowledgeIndexNodenode_type 不是 BuiltinNodeTypes 的成员,而是一个 Dify 自定义字符串常量 KNOWLEDGE_INDEX_NODE_TYPE = "knowledge-index"api/core/workflow/nodes/knowledge_index/__init__.py:3)。说明 graphon 的 NodeType 对外开放,Dify 可以往注册表里加它自己的类型

api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py:8 就是专门盯着这件事的守门测试:它在子进程里导入生产入口,然后断言 knowledge-retrieval / knowledge-index / datasource 三个类型解析出来的类,其 __module__ 必须以 core.workflow.nodes. 开头。

7.2 AGENT:唯一一个"同类型两代实现"的位置

agent/agent_v2/ 都声明 node_type = BuiltinNodeTypes.AGENT,靠 version() 区分:AgentNode 返回 "1"agent/agent_node.py:63),DifyAgentNode 返回 "2"agent_v2/agent_node.py:113)。

工厂在 _build_agent_node_init_kwargsnode_factory.py:549)里靠 issubclass(node_class, DifyAgentNode) 二选一,两套零件箱几乎没有交集:

v1(插件策略)v2(Agent 后端)
strategy_resolverPluginAgentStrategyResolveragent/plugin_strategy_adapter.py:10binding_resolverWorkflowAgentBindingResolveragent_v2/binding_resolver.py:34,直查 WorkflowAgentNodeBinding + Agent 两张表)
presentation_provider(图标,同文件 :26runtime_request_builderagent_v2/runtime_request_builder.py:153,带凭证)
runtime_supportagent/runtime_support.py:32agent_backend_client(按 AGENT_BACKEND_BASE_URL,可切 fake)
message_transformeragent/message_transformer.py:34event_adapter / output_adapter / type_checker / failure_orchestrator / session_store

v2 的零件全部是函数内 importnode_factory.py:551-552)——只有真的要造 v2 节点时才拉起 clients.agent_backend 那一整棵依赖树。

WorkflowAgentOutputAdapteragent_v2/output_adapter.py:29)注入时还带了一个 tool_file_rebacker=reback_tool_file_output,代码注释写明意图:从 ToolFile 行反查文件输出,让下游拿到的元数据以数据库为准,而不是沙箱自报node_factory.py:573-575)。


8. 变量与系统变量:节点被造出来之前必须就位的东西

8.1 四个保留命名空间

变量池里的前缀是四个裸常量(api/core/workflow/variable_prefixes.py:1-4):

前缀装什么
sys系统变量
env环境变量
conversation会话变量
ragRAG 流水线变量

系统变量的键集合是一个 16 项的 StrEnumSystemVariableKeyapi/core/workflow/system_variables.py:22-38,从 QUERYINVOKE_FROM)。有一处历史包袱值得记住:枚举成员叫 WORKFLOW_EXECUTION_ID,但它的字符串值是 "workflow_run_id":30),并且 _normalize_system_variable_values 会把外部传入的 workflow_execution_id 键改写成 workflow_run_id:67-69)。

8.2 引导顺序(建图之前)

build_system_variables(...) ← 造 sys.* 变量


build_bootstrap_variables( ← 给每类变量盖上正确的 node_id 前缀
system_variables=..., environment_variables=...)


add_variables_to_pool(pool, vars) ← 逐个 pool.add(selector, value)


add_node_inputs_to_pool(pool, root_node_id, inputs) ← 用户输入挂到起点节点名下


GraphRuntimeState(variable_pool=pool) → _init_graph(...)

对应 api/core/app/apps/workflow/app_runner.py:114-152;两个 pool 写入函数在 api/core/workflow/variable_pool_initializer.py:8add_variables_to_pool)和 :13add_node_inputs_to_pool)。

build_bootstrap_variablessystem_variables.py:109)做的事很朴素但关键:用 _with_selector:102)把每个变量的 selector 改写成 [前缀, 变量名],RAG 变量则按 belong_to_node_id 先聚合再挂到 rag 下(:122-135)。

add_node_inputs_to_pool 支持 aliasesvariable_pool_initializer.py:18),调用方传的是 get_compatible_start_aliases(...)app_runner.py:126-129)——同一份用户输入会被写到起点节点 id 和它的兼容别名下各一份,让老 DSL 里写死的选择器仍然取得到值。

8.3 构造期变量:为什么"预加载"必须存在

这是本章最容易被跳过、但最能说明"建图不是纯计算"的一段。

问题: LLM 节点的构造函数需要 memory,memory 需要 conversation_id,而 conversation_id 住在 variable pool 里。如果建图时池子里还没有它,节点就会被造成"没有记忆"。

解法: 在建图之前把这类"构造期才需要的变量"从草稿变量存储里捞出来塞进池子。

# 真实源码 api/core/workflow/system_variables.py:170-180
def get_node_creation_preload_selectors(*, node_type, node_data):
"""Return selectors that must exist before node construction begins."""
if node_type not in _MEMORY_BOOTSTRAP_NODE_TYPES or getattr(node_data, "memory", None) is None:
return ()
return (system_variable_selector(SystemVariableKey.CONVERSATION_ID),)

_MEMORY_BOOTSTRAP_NODE_TYPES:161-167)正是 LLM / 分类器 / 抽取器三兄弟。preload_node_creation_variables:183)负责去重、跳过池子里已有的、然后一次性 variable_loader.load_variables(...) 批量加载。

两个调用点都在建图/造节点之前

  • 单节点调试:workflow_entry.py:247-254,在 node_factory.create_node(node_config):346)之前。
  • 单 iteration / loop 调试:workflow_app_runner.py:347-358,而 Graph.init:384,上面那行注释直说 —— "init graph after constructor-time context has been loaded"。

这条注释也是本章 §3.1 那条"Graph.init 会逐个调 create_node"的最强旁证 (inferred,因为 Graph.init 的实现不在克隆里)。

8.4 隐式系统变量映射

inject_default_system_variable_mappingssystem_variables.py:212)是给单节点调试用的补丁:当节点是 LLM 且配了 memory 时,往变量映射里补一条 "<node_id>.#sys.query#" → ("sys", "query"):224-229)。

docstring 一句话讲清了归属:"Add workflow-owned implicit sys mappings that graphon should not know about"(:219)——这是 Dify 自己的约定,不该让 graphon 知道

8.5 两个旁支工具

模块干什么谁在用
graph_topology.py直接解析 Workflow.graph 的 JSON 建反向邻接表,回答"A 是不是 B 的上游"Agent v2 发布校验、composer 候选接口(文件头 docstring :1-7
file_reference.py编解码不透明文件引用 dify-file-ref:<base64url-json>Agent v2 文件输出与下载契约

WorkflowGraphTopologygraph_topology.py:16)注意一个细节:upstream_node_ids:54)最后要 visited & self._node_ids 求交——因为边可能引用已被删掉的节点 id(半删除的图),只返回真实存在的节点。

file_reference.py 的模块 docstring(:9-18)明确区分了严格与宽松两套 API:is_canonical_file_reference:79)是新契约用的严格校验器;parse_file_reference:49)和 resolve_file_record_id:91故意宽松,让历史行里存的裸 id 仍然读得出来,并警告"执行 canonical-only 的调用方不得拿宽松 helper 当校验器"。


9. 巧妙之处(可以带走的)

  1. 把外部包的构造器收口到一个方法。 DifyGraphInitContext.to_graph_init_paramsnode_factory.py:100)是全仓唯一构造 GraphInitParams 的生产路径,配 docstring 写明"等 graphon 出显式 API 就撤"。上游换签名只改一处。

  2. 零件箱懒执行。 node_init_kwargs_factoriesMapping[NodeType, Callable[[], dict]],取用时才 () 求值(:395:456)。造 code 节点不会去拉 LLM 凭证,也不会去查 Conversation 表。

  3. 两段式校验。 先用宽松共享 schema 过一遍图配置,解析出具体节点类后再用它自己的 schema 校验(_validate_resolved_node_data:466)。宽松那段保证整图能被解析,严格那段保证构造函数拿到真类型。

  4. 不透明句柄穿越边界。 ToolRuntimeHandle(raw=_WorkflowToolRuntimeBinding(...))node_runtime.py:589-595)让父子工作流的追踪上下文搭 graphon 的顺风车,而 graphon 完全不需要知道里面装了什么。

  5. getter 而非值。 human-input 运行时接的是 workflow_execution_id_getter / conversation_id_getter 两个闭包(node_factory.py:356-363),把"建图时还没有的值"推迟到用时才读。同样的手法出现在召回权限检查的闭包上(:588-610)。

  6. 错误分类权留在 Dify。 DefaultWorkflowCodeExecutor.is_execution_error:272)只有两行,却决定了"用户代码写错"和"沙箱挂了"在 graphon 眼里的区别。

  7. 降级要静默,缺失要炸。 版本未命中静默回退 latest(:137),但 run_context 里缺 _dify 键直接抛 ValueError_resolve_dify_context:362-369)。可恢复的容忍,不可恢复的立刻失败。


10. 边界与局限

  • 这个克隆读不到内核。 Graph.init 的遍历策略、GraphEngine 的调度算法、VariablePool 的选择器语义、ResponseStreamFilter 的过滤规则,全部在 graphon==0.5.3 里。本章所有关于它们的描述都只是从 Dify 的调用点和注释反推。

  • 建图会碰数据库和网络。 LLM 节点在 create_node 阶段就会 fetch 凭证、查 Conversation 表;agent v2 会解析绑定关系。建图不是纯函数,一张大图的 Graph.init 是有 I/O 成本的。

  • 版本回退可能悄悄换实现。 高版本 JSON 落到低版本运行时会静默用 latest,日志里没有告警(resolve_workflow_node_class:129-140)。

  • 老配置改写是单向的。 adapt_node_config_for_graph 只在读取路径上把老形态翻新,不会写回数据库。同一份老 DSL 每次运行都要重新改写一遍。

  • _import_node_packageexcluded_modules 参数是死代码。 定义在 :102,但两个调用点(:113:114)都没传,全仓也没有别的调用者。

  • 单节点调试与正式运行的语义不完全一致。 single_step_runworkflow_entry.py:192)不走 ResponseStreamFilter,只挂一个临时的 ObservabilityLayer_traced_node_run:604);run_free_node:408)更进一步,只支持 parameter_extractorquestion_classifier 两种类型,其余直接抛错(:427-428)。留痕落库的差异见 执行期横切


11. 代码地图

主题文件路径关键符号
边界事实api/pyproject.toml / api/uv.lockgraphon==0.5.3
装配线主体api/core/workflow/node_factory.pyDifyNodeFactorycreate_nodenode_init_kwargs_factories
显式初始化上下文api/core/workflow/node_factory.pyDifyGraphInitContextto_graph_init_params
节点注册与版本解析api/core/workflow/node_factory.pyregister_nodesget_node_type_classes_mappingresolve_workflow_node_classLATEST_VERSION_LazyNodeTypeClassesMapping
起点判定api/core/workflow/node_factory.py_START_NODE_TYPESis_start_node_typeget_default_root_node_id
LLM 族注入api/core/workflow/node_factory.py_build_llm_compatible_node_init_kwargs_wrap_model_instance_for_node_build_model_instance_for_llm_nodefetch_memory
代码沙箱注入api/core/workflow/node_factory.pyDefaultWorkflowCodeExecutor
协议适配器族api/core/workflow/node_runtime.pyDifyPreparedLLMDifyPreparedPollingLLMDifyToolNodeRuntimeDifyHumanInputNodeRuntimeDifyFileReferenceFactoryDifyToolFileManager
SSRF 代理适配api/core/helper/ssrf_proxy.pyGraphonSSRFProxygraphon_ssrf_proxy
远程文件抓取适配api/core/file/remote_fetcher.pyGraphonRemoteFileFetchergraphon_remote_file_fetcher
模型凭证接入api/core/app/llm/model_access.pybuild_dify_model_accessfetch_model_config
运行身份载荷api/core/app/entities/app_invoke_entities.pyDIFY_RUN_CONTEXT_KEYDifyRunContextbuild_dify_run_context
引擎组装 / 事件过滤api/core/workflow/workflow_entry.pyWorkflowEntryiter_dify_graph_engine_events_WorkflowChildEngineBuilder.build_child_enginesingle_step_run
顶层建图调用点api/core/app/apps/workflow_app_runner.py_init_graph_get_graph_and_variable_pool_for_single_node_run
RAG 流水线建图api/core/app/apps/pipeline/pipeline_runner.pyGraph.init:291
老配置改写api/core/workflow/human_input_adapter.pyadapt_node_config_for_graphadapt_node_data_for_graph_adapt_tool_node_data_for_graph
系统变量api/core/workflow/system_variables.pySystemVariableKeybuild_bootstrap_variablesget_node_creation_preload_selectorspreload_node_creation_variablesinject_default_system_variable_mappings
变量池写入api/core/workflow/variable_pool_initializer.pyadd_variables_to_pooladd_node_inputs_to_pool
变量命名空间api/core/workflow/variable_prefixes.pySYSTEM_VARIABLE_NODE_IDENVIRONMENT_VARIABLE_NODE_ID
图拓扑查询api/core/workflow/graph_topology.pyWorkflowGraphTopologyupstream_node_ids
不透明文件引用api/core/workflow/file_reference.pybuild_file_referenceis_canonical_file_referenceresolve_file_record_id
Dify 自留节点api/core/workflow/nodes/KnowledgeRetrievalNodeKnowledgeIndexNodeDatasourceNodeAgentNodeDifyAgentNode
Agent v2 零件api/core/workflow/nodes/agent_v2/WorkflowAgentBindingResolverWorkflowAgentOutputAdapterWorkflowAgentRuntimeRequestBuilder
边界守门测试api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.pytest_node_factory.py