数据截至 (上游 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 读到哪儿为止(重要)
| 你想看的东西 | 在克隆里吗 | 该怎么办 |
|---|---|---|
Graph、GraphEngine、GraphEngineConfig | 否 | 只能从 Dify 的调用点反推参数契约(见 §3) |
VariablePool、GraphRuntimeState | 否 | 同上;Dify 侧只写入、只读取 |
| LLM / code / http_request / iteration / loop / question_classifier / human_input / tool / document_extractor / template_transform 等内置节点 | 否 | 从 DifyNodeFactory 注入的 kwargs 反推它们的构造函数签名(§4.4) |
ResponseStreamFilter、filter_graph_events | 否 | 只能读 Dify 的调用点和注释(api/core/workflow/workflow_entry.py:49-70) |
DifyNodeFactory 及全部注入物 | 是 | api/core/workflow/node_factory.py、node_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 且都是 list | api/core/app/apps/workflow_app_runner.py:132-139(_init_graph) |
| ② | 打包运行身份:tenant / app / user / invoke_from | build_dify_run_context,api/core/app/entities/app_invoke_entities.py:68 |
| ③ | 包成显式初始化上下文 | DifyGraphInitContext,api/core/workflow/node_factory.py:87 |
| ④ | 造装配线(绑定运行态) | DifyNodeFactory.from_graph_init_context,node_factory.py:307 |
| ⑤ | 决定起点 + 建图(此处才真正逐个造节点对象) | get_default_root_node_id:148 → Graph.init,workflow_app_runner.py:165-168 |
| ⑥ | 建引擎、挂 Layer | WorkflowEntry.__init__,api/core/workflow/workflow_entry.py:92-175 |
3.3 DifyGraphInitContext 为什么单独存在
它是一个 4 字段的冻结 dataclass:workflow_id / graph_config / run_context / call_depth(node_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):
- 深度闸门——
call_depth > dify_config.WORKFLOW_CALL_MAX_DEPTH直接抛异常(:129-131)。 - 建
GraphEngine——线程池参数全部来自 Dify 配置:GRAPH_ENGINE_MIN_WORKERS/MAX_WORKERS/SCALE_UP_THRESHOLD/SCALE_DOWN_IDLE_TIME(:142-152)。 - 挂 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.run(workflow_entry.py:182)。- 因此单节点调试也不过滤——
single_step_run直接返回_run_node_with_layers(node)的生成器(workflow_entry.py:294,实现在:549),那里只会包一层ObservabilityLayer,不走 response 流过滤。