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

图执行引擎:通用引擎与 Dify 之间那层"翻译官"

Day08 说真正跑图的是通用引擎 GraphEngine(graphon 包),Day09 说造 LLM 节点时会"把 ModelInstance 包成适配器"。今天把这层适配器讲透——主角是 core/workflow/node_runtime.py。弄清三件事:①为什么通用引擎不能直接用 Dify 的 ModelInstance/工具/文件,必须隔一层适配器;②DifyPreparedLLM 怎么把 Day05 那个"胖胖的 ModelInstance"收窄成引擎节点只认的精简协议;③工具、文件、轮询式长任务的运行时适配器各自解决什么。理解这一层,你就理解了"通用引擎 + 业务适配"这种大型系统最常见的拼装方式。这也是阶段3 的收官。

📍 你在 20 天里的位置(阶段3:工作流 · D08-10 收官)
D06 Provider/实体 D07 Prompt/生成 D08 工作流总览 D09 节点体系 D10 图执行引擎 S4 RAG S5 工具/Agent S6 收官
💡 先用两个类比兜住今天 类比一:通用引擎 GraphEngine一台标准化的自动化流水线设备——它只认"标准接口的机械臂"。Dify 的 ModelInstance/工具/文件是"非标准零件",得套一个转接头(适配器)才能插上。DifyPreparedLLM 就是"把 Dify 模型转成引擎标准插口"的转接头。类比二:适配器像翻译官——引擎讲"引擎语"(LLMProtocol 协议),Dify 讲"Dify 语"(ModelInstance 那套方法)。翻译官站中间,引擎说"给我 invoke_llm",它转身用 Dify 的方式去调,再把结果翻回引擎听得懂的形式。引擎从头到尾不需要学 Dify 语。
L01

痛点:通用引擎凭什么认识 Dify 的东西

🤔 痛点Dify 把图执行做成了独立的通用引擎 graphon.graph_engine.GraphEngine(Day08 见过),目标是"能跑任意图,不绑定 Dify"。可矛盾来了:图里的 LLM 节点最终得调模型、工具节点得调工具、有的节点要读文件——这些能力全是 Dify 的(ModelInstance 来自 Day05、工具来自 ToolManager)。如果让通用引擎直接 import Dify 的 ModelInstance,引擎就被 Dify 绑死了,"通用"二字白谈;可要是引擎啥都不认识,节点又干不了活。怎么让"通用引擎"用上"Dify 的具体能力",还不互相绑死?
💡 本质:面向协议 + 适配器隔离答案是依赖倒置:引擎不依赖 Dify 的具体类,只定义一组协议LLMProtocolToolNodeRuntimeProtocolFileReferenceFactoryProtocol 等,是 Protocol 接口)。Dify 侧在 node_runtime.py 里写一批适配器类实现这些协议,内部转调 Dify 的真实能力。引擎只跟协议打交道,Dify 的实现可以随便变——两边靠"协议"这层薄薄的契约解耦。
L02

适配器层:node_runtime.py 里的一排翻译官

core/workflow/node_runtime.py 就是这批适配器的集中地。每个类都实现引擎的某个 Protocol:

适配器实现的引擎协议职责
DifyPreparedLLMLLMProtocolModelInstance 收窄成引擎认的 LLM 接口 node_runtime.py:150
DifyPreparedPollingLLMLLMPollingCapableProtocol额外支持"轮询式"长任务 LLM node_runtime.py:285
DifyToolNodeRuntimeToolNodeRuntimeProtocol把 Dify 的 ToolManager 接到工具节点 node_runtime.py:459
DifyFileReferenceFactoryFileReferenceFactoryProtocol把配置里的文件引用还原成 Dify 的 File 对象 node_runtime.py:137
DifyPromptMessageSerializerPromptMessageSerializerProtocolPromptMessage 的序列化 node_runtime.py:337
大白话Day09 的 create_node 给 LLM 节点"配料"时塞进去的,就是这些适配器(比如 wrap_model_instance=True 造的其实是 DifyPreparedLLM)。节点在引擎里跑,手里拿的是"翻译官",通过翻译官去用 Dify 的真本事。节点只会讲"引擎语",翻译官负责把它翻成 Dify 能听懂的调用。
L03

DifyPreparedLLM:把"胖 ModelInstance"收窄

Day05 的 ModelInstance 方法很多(六种 invoke、算 token、拿 schema…)。但引擎里的 LLM 节点只需要其中一小撮。DifyPreparedLLMapi/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,明示"我在实现协议里声明的这个方法"。签名对不上会被类型检查抓出来——契约有强制力。
💡 接口隔离:给对方它需要的最小面不是"能给的都给",而是"它要什么给什么"。节点只需要"能调 LLM",就只给它 LLMProtocol 这么大的面。暴露面越小,耦合越小,将来 ModelInstance 怎么重构都不影响引擎。这跟 Day09 的"参数提取节点只给它需要的料"是同一条原则的两次体现。
L04

轮询式 LLM:长任务怎么"发起→等→取"

有些模型调用是长任务(如深度推理、批处理),不能一个请求干等到底。DifyPreparedPollingLLMapi/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 说的"工作流支持暂停/恢复,运行时状态要能完整保存"。长任务 + 可恢复,是工作流引擎比"一次性脚本"复杂的根本原因。
L05

工具运行时:把 ToolManager 接到工具节点

工具节点要调外部工具(搜索、画图、自定义 API)。DifyToolNodeRuntimeapi/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 上下文,让嵌套调用的链路追踪能连起来。企业级观测的细节都收在适配器里。
大白话三个适配器(LLM/工具/文件)长得像一家人:都是"引擎来要东西 → 我转调 Dify 的对应管理器 → 把结果和异常翻译成引擎认的形式 → 交回去"。学会一个,另外两个一眼就懂。这就是好架构的手感——同一种模式,反复出现
L06

引擎装配全景:回看 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))
通用引擎 + Dify 适配层:全景 外圈:Layer 层(熔断/额度/观测/日志) GraphEngine(graphon · 通用引擎) 只懂图论:并行调度 / 按边推进 / 发事件 节点 只讲"引擎语" 适配器 PreparedLLM ToolRuntime Dify 能力 ModelInstance(D05) ToolManager 节点讲引擎语 → 适配器翻译 → 调 Dify 真本事 引擎/Dify 互不 import,只靠 Protocol 契约相连
图注:引擎在中间只懂图论,适配器在边界当翻译官,Dify 能力在右边。层包在最外圈。
💡 设计取舍:拆出通用引擎,值不值?把图执行拆成独立的 graphon 引擎,代价明显:多了一整层适配器(node_runtime.py 这一堆翻译官)、多了协议定义、调试要跨两个代码库。收益是:引擎可独立测试/演进/复用,Dify 的模型/工具怎么重构都不碰引擎,反之亦然。对一个要长期迭代、团队分工的大型产品,"用一层适配换清晰边界"通常划算——但对小项目就是过度设计。架构没有绝对好坏,只有匹配不匹配规模。
L07

阶段3 收官 + 今日小结

📝 真实值:一个 LLM 节点在引擎里跑完整一圈 引擎推进到 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(...) 全景
阶段3 收官 · 明日预告 Day 11:模型运行时(阶段2)+ 工作流引擎(阶段3)都通关了!从明天起进阶段4——RAG(检索增强)。工作流里那个"知识检索"节点背后,文档是怎么切块、向量化、存进向量库、又怎么被检索回来拼进 prompt 的。你会看到 Day06 的 Embedding 模型、Day07 的 context 拼接在这里落地。
← Day 09 节点体系 Day 11 · 阶段4 RAG →