图执行引擎:通用引擎与 Dify 之间那层"翻译官"
Day08 说真正跑图的是通用引擎 GraphEngine(graphon 包),Day09 说造 LLM 节点时会"把 ModelInstance 包成适配器"。今天把这层适配器讲透——主角是 core/workflow/node_runtime.py。弄清三件事:①为什么通用引擎不能直接用 Dify 的 ModelInstance/工具/文件,必须隔一层适配器;②DifyPreparedLLM 怎么把 Day05 那个"胖胖的 ModelInstance"收窄成引擎节点只认的精简协议;③工具、文件、轮询式长任务的运行时适配器各自解决什么。理解这一层,你就理解了"通用引擎 + 业务适配"这种大型系统最常见的拼装方式。这也是阶段3 的收官。
GraphEngine 像一台标准化的自动化流水线设备——它只认"标准接口的机械臂"。Dify 的 ModelInstance/工具/文件是"非标准零件",得套一个转接头(适配器)才能插上。DifyPreparedLLM 就是"把 Dify 模型转成引擎标准插口"的转接头。类比二:适配器像翻译官——引擎讲"引擎语"(LLMProtocol 协议),Dify 讲"Dify 语"(ModelInstance 那套方法)。翻译官站中间,引擎说"给我 invoke_llm",它转身用 Dify 的方式去调,再把结果翻回引擎听得懂的形式。引擎从头到尾不需要学 Dify 语。痛点:通用引擎凭什么认识 Dify 的东西
graphon.graph_engine.GraphEngine(Day08 见过),目标是"能跑任意图,不绑定 Dify"。可矛盾来了:图里的 LLM 节点最终得调模型、工具节点得调工具、有的节点要读文件——这些能力全是 Dify 的(ModelInstance 来自 Day05、工具来自 ToolManager)。如果让通用引擎直接 import Dify 的 ModelInstance,引擎就被 Dify 绑死了,"通用"二字白谈;可要是引擎啥都不认识,节点又干不了活。怎么让"通用引擎"用上"Dify 的具体能力",还不互相绑死?LLMProtocol、ToolNodeRuntimeProtocol、FileReferenceFactoryProtocol 等,是 Protocol 接口)。Dify 侧在 node_runtime.py 里写一批适配器类去实现这些协议,内部转调 Dify 的真实能力。引擎只跟协议打交道,Dify 的实现可以随便变——两边靠"协议"这层薄薄的契约解耦。适配器层:node_runtime.py 里的一排翻译官
core/workflow/node_runtime.py 就是这批适配器的集中地。每个类都实现引擎的某个 Protocol:
| 适配器 | 实现的引擎协议 | 职责 |
|---|---|---|
DifyPreparedLLM | LLMProtocol | 把 ModelInstance 收窄成引擎认的 LLM 接口 node_runtime.py:150 |
DifyPreparedPollingLLM | LLMPollingCapableProtocol | 额外支持"轮询式"长任务 LLM node_runtime.py:285 |
DifyToolNodeRuntime | ToolNodeRuntimeProtocol | 把 Dify 的 ToolManager 接到工具节点 node_runtime.py:459 |
DifyFileReferenceFactory | FileReferenceFactoryProtocol | 把配置里的文件引用还原成 Dify 的 File 对象 node_runtime.py:137 |
DifyPromptMessageSerializer | PromptMessageSerializerProtocol | PromptMessage 的序列化 node_runtime.py:337 |
create_node 给 LLM 节点"配料"时塞进去的,就是这些适配器(比如 wrap_model_instance=True 造的其实是 DifyPreparedLLM)。节点在引擎里跑,手里拿的是"翻译官",通过翻译官去用 Dify 的真本事。节点只会讲"引擎语",翻译官负责把它翻成 Dify 能听懂的调用。DifyPreparedLLM:把"胖 ModelInstance"收窄
Day05 的 ModelInstance 方法很多(六种 invoke、算 token、拿 schema…)。但引擎里的 LLM 节点只需要其中一小撮。DifyPreparedLLM(api/core/workflow/node_runtime.py:150)就是那层"只暴露必要的"包装:
# api/core/workflow/node_runtime.py:150
class DifyPreparedLLM(LLMProtocol):
"""Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes."""
def __init__(self, model_instance: ModelInstance, request_metadata=None):
self._model_instance = model_instance # ① 把真身藏起来,只留自己当门面
self._request_metadata = request_metadata
@property
def provider(self) -> str:
return self._model_instance.provider # ② 引擎要什么,就转发什么
@override
def invoke_llm(self, *, prompt_messages, model_parameters, tools, stop, stream):
return self._model_instance.invoke_llm( # ③ ★核心:转调 Day05 的 invoke_llm
prompt_messages=list(prompt_messages),
model_parameters=dict(model_parameters),
tools=list(tools or []), stop=list(stop or []),
stream=stream, request_metadata=self._request_metadata, # 补上 Dify 的追踪元数据
)
类注释直说了"hides the full ModelInstance API from graphon nodes"——目的就是藏。节点看到的是精简的 LLMProtocol(provider/model_name/invoke_llm/get_num_tokens 等几样),看不到 ModelInstance 上 embedding/rerank/tts 那些跟它无关的方法。invoke_llm 转调★这一句就是 Day05 和 Day10 的接头:节点调 prepared_llm.invoke_llm(...) → 适配器转调 model_instance.invoke_llm(...) → 进 Day05 的 _round_robin_invoke(负载均衡/冷却)→ 真正请求模型。工作流里的 LLM 调用,最终全汇入 Day05 那一个出海口。request_metadata适配器还悄悄补上 Dify 的请求元数据(用于计费/追踪)。引擎节点不知道这东西存在——这正是适配器的价值:把"业务附加信息"在边界处补齐,不污染引擎接口。@override 标注每个方法都标 @override,明示"我在实现协议里声明的这个方法"。签名对不上会被类型检查抓出来——契约有强制力。LLMProtocol 这么大的面。暴露面越小,耦合越小,将来 ModelInstance 怎么重构都不影响引擎。这跟 Day09 的"参数提取节点只给它需要的料"是同一条原则的两次体现。轮询式 LLM:长任务怎么"发起→等→取"
有些模型调用是长任务(如深度推理、批处理),不能一个请求干等到底。DifyPreparedPollingLLM(api/core/workflow/node_runtime.py:285)在普通适配器上加了"轮询"能力:
# api/core/workflow/node_runtime.py:285
class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol):
"""Prepared workflow LLM adapter that exposes Graphon's polling protocol."""
def __init__(self, model_instance, request_metadata=None):
super().__init__(model_instance, request_metadata=request_metadata)
model_type_instance = model_instance.model_type_instance
if not isinstance(model_type_instance, LargeLanguageModel): # ① 前提:必须是 LLM
raise TypeError("Polling wrapper requires a large-language-model instance.")
plugin_model_runtime = model_type_instance.model_runtime
if not isinstance(plugin_model_runtime, PluginModelRuntime): # ② 前提:必须是插件化模型
raise TypeError("Polling wrapper requires a plugin-backed model runtime.")
self._plugin_model_runtime = plugin_model_runtime
@override
def start_llm_polling(self, *, prompt_messages, model_parameters, tools, stop, json_schema):
return self._plugin_model_runtime.start_llm_polling(...) # ③ 发起:拿一个"任务凭据"
@override
def check_llm_polling(self, *, plugin_state):
return self._plugin_model_runtime.check_llm_polling(...) # ④ 查询:拿凭据问"好了没"
继承普通适配器它 extends DifyPreparedLLM——普通的 invoke_llm 全继承下来,只新增轮询两方法。要短任务走 invoke_llm,要长任务走 start/check,同一个对象两副能力。start → check 模式轮询就三步:start_llm_polling 发起并拿到一个 plugin_state(任务凭据);引擎隔一会儿用这个凭据调 check_llm_polling 问"完成没/结果呢";没完就接着等。像"取餐号":先下单拿号,再凭号查餐好没。两道 isinstance 前提轮询能力依赖"插件化模型运行时"(Day06 讲的插件进程)。所以构造时先确认这确实是个插件后端的 LLM,否则直接 TypeError——把"不支持轮询的模型误走轮询"挡在最前面。为什么单独一个类不是所有模型都支持轮询。用一个子类隔离,Day09 的工厂就能按"这个模型支不支持轮询"(_supports_plugin_llm_polling)决定包成普通版还是轮询版——能力按需装配。plugin_state 必须是可序列化、能存下来的——这也呼应 Day08 说的"工作流支持暂停/恢复,运行时状态要能完整保存"。长任务 + 可恢复,是工作流引擎比"一次性脚本"复杂的根本原因。工具运行时:把 ToolManager 接到工具节点
工具节点要调外部工具(搜索、画图、自定义 API)。DifyToolNodeRuntime(api/core/workflow/node_runtime.py:459)把 Dify 的 ToolManager 适配成引擎的工具协议。看它的 get_runtime:
# api/core/workflow/node_runtime.py:478(get_runtime 节选)
def get_runtime(self, *, node_id, node_data, variable_pool, node_execution_id=None) -> ToolRuntimeHandle:
try:
tool_runtime = ToolManager.get_workflow_tool_runtime( # ① ★转调 Dify 的 ToolManager
self._run_context.tenant_id, self._run_context.app_id, node_id,
self._build_tool_runtime_spec(node_data),
self._run_context.user_id, self._run_context.invoke_from, variable_pool)
except ToolNodeError:
raise # ② 已知工具错,原样上抛
except Exception as exc:
raise ToolRuntimeResolutionError(str(exc)) from exc # ③ 未知错 → 包成引擎认的错
conversation_id = ... get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID)
return ToolRuntimeHandle( # ④ 包成引擎的"工具句柄"
raw=_WorkflowToolRuntimeBinding(tool=tool_runtime, conversation_id=conversation_id, ...))
转调 ToolManager和 LLM 适配器一个套路:引擎问它要"工具运行时",它转身用 Dify 的 ToolManager.get_workflow_tool_runtime 真正解析出工具(带上租户/应用/用户等 Dify 上下文),再包成引擎认的 ToolRuntimeHandle 交回去。异常翻译★注意错误处理:已知的 ToolNodeError 原样抛,未知异常统一包成 ToolRuntimeResolutionError(引擎认识的错误类型)。适配器不光翻译"调用",也翻译"异常"——不让 Dify 内部的杂错泄漏给引擎。从变量池取上下文工具可能需要"当前会话 id"等,这些从 Day08 的变量池里取(get_system_text(variable_pool, ...))。适配器是"引擎世界"和"Dify 世界"数据交换的关卡。trace_context 追踪若是"工作流即工具"(一个工作流被当工具调),还会串上父级 trace 上下文,让嵌套调用的链路追踪能连起来。企业级观测的细节都收在适配器里。引擎装配全景:回看 Day08 的那句 GraphEngine(...)
现在回看 Day08 WorkflowEntry.__init__ 里造引擎那段(api/core/workflow/workflow_entry.py:213),你能看懂每个零件了:
# api/core/workflow/workflow_entry.py:213
self.graph_engine = GraphEngine(
workflow_id=workflow_id,
graph=graph, # 结构化图(Day08)
graph_runtime_state=graph_runtime_state, # 含变量池的账本(Day08)
command_channel=command_channel, # 急停通道(Day08)
config=GraphEngineConfig(min_workers=..., max_workers=...), # 并发worker池(Day08)
child_engine_builder=self._child_engine_builder, # 嵌套子工作流
)
# 挂各种 Layer:熔断 / 额度 / 观测 / 调试(Day08)
self.graph_engine.layer(ExecutionLimitsLayer(...))
self.graph_engine.layer(LLMQuotaLayer(tenant_id=tenant_id))
阶段3 收官 + 今日小结
llm_1 节点(Day09 的 create_node 已把 DifyPreparedLLM(ModelInstance) 注入好)。→ 引擎调节点的 run() → 节点从 Day08 的变量池取上游输出、按 Day07 思路拼出 prompt_messages → 调手里的 self._llm.invoke_llm(prompt_messages=..., stream=True)(它是 DifyPreparedLLM)→ 适配器转调 model_instance.invoke_llm(...)(Day05)→ 进 _round_robin_invoke 挑 key(Day05 负载均衡)→ 真正请求模型 → 流式结果一段段回来 → 节点把每段包成引擎事件 yield → 引擎再 yield 给 Day08 的 run() → task_pipeline 转 SSE → 前端。从 Day05 到 Day10,六天的知识在这一次节点执行里全部咬合。👶 小白:为啥不让引擎直接调 ModelInstance,非要中间加一层?不是多此一举吗?
👨🏫 老师:恰恰相反,这层是"值钱"的。想象引擎直接 import ModelInstance:那引擎就永远绑死 Dify 了,Dify 改一下 ModelInstance 引擎就得跟着改,引擎也没法拿去跑别的系统的图。加一层适配器后,引擎只认协议(Protocol)——它说"给我一个能 invoke_llm 的东西",至于那东西背后是 Dify 的 ModelInstance 还是别的什么,引擎不关心。这就是"依赖接口而非实现":多写一个转接头,换来引擎和 Dify 各自能独立演进、独立测试、独立复用。系统越大、活得越久,这层隔离越划算。你以后设计模块边界,记住这个手感:让双方都只依赖一份薄薄的契约。
🧠 今天你应该能回答
- 通用引擎为什么不能直接用 Dify 的 ModelInstance?(会被绑死,失去通用性)
- 引擎和 Dify 靠什么解耦?(引擎定义 Protocol,Dify 写适配器实现它)
- DifyPreparedLLM 干什么?(把胖 ModelInstance 收窄成引擎认的 LLMProtocol,转调 invoke_llm)
- 轮询式 LLM 的模式?(start 拿凭据 → check 查结果,支持长任务/可恢复)
- 工具适配器除了转调还翻译什么?(异常:未知错包成引擎认的错误类型)
- "通用引擎 + 适配层"的取舍?(多一层换清晰边界/独立演进,匹配大型产品)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/dify
# 1. 一排适配器
grep -n "^class Dify" api/core/workflow/node_runtime.py # 看这层有哪些翻译官
# 2. LLM 适配器(收窄 + 转调)
sed -n '150,197p' api/core/workflow/node_runtime.py # DifyPreparedLLM
sed -n '219,235p' api/core/workflow/node_runtime.py # invoke_llm 转调
# 3. 轮询式长任务
sed -n '285,337p' api/core/workflow/node_runtime.py # DifyPreparedPollingLLM
# 4. 工具运行时 + 回看引擎装配
sed -n '478,530p' api/core/workflow/node_runtime.py # DifyToolNodeRuntime.get_runtime
sed -n '213,228p' api/core/workflow/workflow_entry.py # GraphEngine(...) 全景