乐于分享
好东西不私藏

Dify Agent 节点扒源码:插件化架构藏得够深的

Dify Agent 节点扒源码:插件化架构藏得够深的

最近在搞 Dify 工作流的时候,想搞清楚 Agent 节点到底是怎么跑起来的。翻了半天文档,说得云里雾里。没办法,直接扒源码吧——基于 Dify 1.13.0 + langgenius-agent 0.0.34 插件。

看完之后只能说一句:这架构设计,藏得够深的。

先说结论

Agent 节点本身一行推理逻辑都没有

你没看错。Agent 节点只干三件事:解析参数、发 HTTP 请求、转换返回消息。所有真正的 agent 推理——LLM 调用、工具调用循环、ReAct 解析——全部跑在一个叫”插件 daemon”的独立进程里。

说白了,Dify 主进程就是个”传话的”,真正干活的在另一个进程。

整体调用流程

我画了个简化的流程图,先有个全局认知:

AgentNode._run()  │  ├─ 1. 从 daemon 拿策略声明(YAML 里定义了哪些参数)  ├─ 2. 从变量池解析参数值(model、tools、instruction 等)  ├─ 3. 生成工具凭证  ├─ 4. HTTP+SSE 调插件 daemon  │     └─ daemon 里跑 Python 代码(LLM 调用 + 工具循环)  ├─ 5. 把插件返回的消息转成工作流事件流  └─ 6. 输出最终结果

涉及的几个核心文件:

文件
干啥的
agent_node.py
Agent 节点主逻辑,约 750 行
entities.py
节点数据模型
plugin.py
插件策略桥接层
agent.py
跟 daemon 的 HTTP 通信
agent_factory.py
策略工厂,加载策略声明

参数不是硬编码的,是插件自己定义的

这个设计挺有意思。Agent 节点的参数不是写死在代码里的,而是插件通过 YAML 动态声明的。

比如 strategies/function_calling.yaml 长这样:

parameters:-name:modeltype:model-selectorscope:tool-call&llmrequired:true-name:toolstype:array[tools]required:true-name:instructiontype:stringrequired:true-name:contexttype:anyscope:array[object]required:false-name:querytype:stringrequired:true-name:maximum_iterationstype:numberrequired:truedefault:3

节点运行的时候,_generate_agent_parameters() 方法按三种模式解析参数:

  • variable 模式:直接从变量池取值
  • mixed 模式:模板渲染,支持 {{#node.output#}} 语法
  • constant 模式:当常量处理

其中有几个特殊类型需要额外处理:

  • model-selector:解析模型实例,注入模型 schema 和对话记忆
  • array[tools]:解析工具配置、运行时参数和凭证,构建 AgentToolEntity
  • string/any:从变量池取值或模板渲染

解析完之后,参数经过 convert_parameters_to_plugin_format() 转换,以 agent_strategy_params 字段 POST 给插件 daemon。

完整调用链路

光看流程图不够,我把整个调用链路拆成 6 个阶段讲清楚。

阶段 1:插件注册

插件安装后,daemon 读取 manifest.yaml 和 strategies/*.yaml,注册策略元信息。

前端通过 AgentService.list_agent_providers() 拿到策略列表,用户选完之后保存两个字段:

agent_strategy_provider_name:"langgenius/agent"agent_strategy_name:"function_calling"# 或 "ReAct"

阶段 2:运行时加载策略

agent_node.py 调 agent_factory.py

strategy = get_plugin_agent_strategy(    tenant_id=self.tenant_id,    agent_strategy_provider_name=self.node_data.agent_strategy_provider_name,    agent_strategy_name=self.node_data.agent_strategy_name,)

工厂类通过 HTTP 从 daemon 拿策略声明,返回 PluginAgentStrategy 对象。

阶段 3:参数生成

agent_parameters = strategy.get_parameters()                    # 从 YAML 拿参数定义parameters = self._generate_agent_parameters(...)               # 从变量池解析值credentials = self._generate_credentials(parameters=parameters) # 生成工具凭证

阶段 4:HTTP 调用 daemon

# PluginAgentStrategy._invoke()initialized_params = self.initialize_parameters(params)params = convert_parameters_to_plugin_format(initialized_params)yieldfrom manager.invoke(    tenant_id=self.tenant_id,    agent_provider=self.declaration.identity.provider,    agent_strategy=self.declaration.identity.name,    agent_params=params,)

请求体结构:

{"user_id":"...","conversation_id":"...","context":{"tool_credentials":{...}},"data":{"agent_strategy_provider":"agent","agent_strategy":"function_calling","agent_strategy_params":{"model":{"provider":"openai","model":"gpt-4o","entity":{...}},"tools":[{"identity":{...},"parameters":{...}}],"instruction":"你是一个助手...","query":"用户问题","maximum_iterations":3}}}

阶段 5:Daemon 里跑插件代码

Daemon 收到请求后干了这么几件事:

  1. 根据 X-Plugin-ID 找到 langgenius/agent 插件
  2. 匹配到 strategies/function_calling.yaml
  3. 找到对应的 strategies/function_calling.py
  4. 实例化 FunctionCallingAgentStrategy,调用 _invoke(parameters)
  5. 插件执行 LLM 调用 + 工具循环,SSE 流式返回结果

这里有个关键细节:插件不直接调 OpenAI SDK

LLM 调用走的是 Dify 插件 SDK 的封装:

self.session.model.llm.invoke(    model_config=LLMModelConfig(...),    prompt_messages=prompt_messages,    stream=stream,    tools=prompt_messages_tools,)

self.session.model.llm.invoke() 内部通过 gRPC/SSE 回调 Dify 主进程,主进程根据模型供应商配置调 OpenAI/Anthropic 等 API,结果再流式返回给 daemon。

所以整个链路是:插件 daemon → Dify 主进程 → LLM API → Dify 主进程 → 插件 daemon。插件是策略编排器,不是 API 调用者。

阶段 6:消息转换

agent_node.py 的 _transform_message() 按消息类型转换:

插件消息类型
转成的工作流事件
TEXT StreamChunkEvent

 → 输出 text
JSON
提取 execution_metadata → 用量统计
IMAGE/BLOB
转为 File 对象 → 输出 files
LOG AgentLogEvent

(推理过程、工具调用日志)
VARIABLE StreamChunkEvent

 → 输出到指定变量

Function Calling vs ReAct:差距比想象的大

这两个策略参数几乎一样,唯一的参数差异是 model-selector 的 scope

  • Function Calling:tool-call&llm(要求模型支持 tool-call)
  • ReAct:llm(任意 LLM 都行)

但实现上的差距,那可就大了。

工具调用的触发方式完全不同

Function Calling 直接用模型原生的 tool call API:

chunks = self.session.model.llm.invoke(    model_config=model_config,    prompt_messages=prompt_messages,    stream=stream,    tools=prompt_messages_tools,  # 原生工具定义)ifself.check_tool_calls(chunk):    tool_calls.extend(self.extract_tool_calls(chunk) or [])

ReAct 呢?纯文本解析。靠 prompt 引导模型输出特定格式:

# 不传 tools,工具列表写进 system prompt# 模型输出: Action: {"action": "tool_name", "action_input": {...}}react_chunks = CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)

Prompt 构建差异巨大

Function Calling 极简,instruction 直接当 system message:

@propertydef_system_prompt_message(self) -> SystemPromptMessage:return SystemPromptMessage(content=self.instruction)

ReAct 用了一个复杂的模板 prompt,大概是这么个结构:

Respond to the human as helpfully and accurately as possible.{{instruction}}You have access to the following tools:{{tools}}Use a json blob to specify a tool by providing an action key (tool name)and an action_input key (tool input).Follow this format:Thought: [your reasoning]Action: {"action": "tool_name", "action_input": {"param": "value"}}Observation: [tool output]... (repeat)FinalAnswer: [your response]Begin! Use "Action:" only when calling tools. End with "FinalAnswer:".

光一个输出解析器就 280 行代码,解析各种边界情况。

多轮对话历史管理也不一样

Function Calling 用原生的 ToolPromptMessage,标准结构化格式:

current_thoughts.append(AssistantPromptMessage(    content=response,    tool_calls=[AssistantPromptMessage.ToolCall(id=..., name=..., arguments=...)]))current_thoughts.append(ToolPromptMessage(    content=str(tool_response),    tool_call_id=tool_call_id,    name=tool_call_name,))

ReAct 用纯文本拼接 scratchpad,所有历史迭代压缩到一个 AssistantPromptMessage

for unit in agent_scratchpad:    assistant_message.content += f"Thought: {unit.thought}\n\n"    assistant_message.content += f"Action: {unit.action_str}\n\n"    assistant_message.content += f"Observation: {unit.observation}\n\n"

效率对比

这个差距是实打实的:

维度
Function Calling
ReAct
模型要求
必须 support tool-call
任意 LLM
工具定义
原生 tools= 参数,不占 prompt token
写在 system prompt 里,占 token
每轮 Token 消耗
低,结构化紧凑
高,冗长 prompt + 越来越长的 scratchpad
工具调用并行
支持一次返回多个 tool_call
每轮只能 1 个 action
解析可靠性
高,结构化输出
依赖文本解析,容易翻车

举个例子,假设需要 3 次工具调用:

  • Function Calling(理想情况):2 次 LLM 调用搞定——1 次并行返回 3 个 tool_calls,1 次最终回答
  • ReAct:至少 4 次 LLM 调用——3 次串行工具调用 + 1 次最终回答,而且每轮 prompt 越来越长

所以我的建议很明确:能用 Function Calling 就用 Function Calling,ReAct 是给不支持函数调用的模型兜底用的。

一个值得吐槽的设计:Context 参数

扒代码的时候发现一个挺离谱的事。

两个策略里,context 参数从来没进入 prompt_messages。LLM 完全看不到它。

context 的唯一用途是生成前端引用展示:

ifisinstance(params.context, list):yieldself.create_retriever_resource_message(        retriever_resources=[...for ctx in params.context...],    )

那检索内容怎么传给 LLM?靠 instruction 或 query 参数的变量模板:

instruction: "根据以下参考资料回答问题:{{#知识检索1.output#}}"

agent_node._generate_agent_parameters() 在调插件前做模板渲染,检索内容嵌进 instruction 纯文本,最终作为 system message 传给 LLM。

这就有个 Prompt Cache 效率问题:

[System]  "你是一个助手。以下是参考资料:[每次不同的检索内容]..."  ← 缓存每次都 miss[User]    "用户问题"

检索结果拼在 system message 里,每次内容不同导致 prefix cache 全部失效。

理想做法应该是这样:

[System]    "你是一个助手,根据参考资料回答问题"     ← 固定,可缓存[User]      "参考资料:[检索结果]\n\n问题:xxx"      ← 变动部分放 user

context 参数从命名上看,本应该解决这个问题。但当前代码只服务前端 UI 引用展示,不服务 LLM。希望后续版本能优化这块。

写在最后

扒完这套代码,对 Dify 的 Agent 架构设计有了新的认识。

核心设计可以归纳为 5 点:

  1. 插件化架构:主进程不执行推理逻辑,HTTP+SSE 跟 daemon 通信,所有策略跑在 daemon 里
  2. 策略即插件:每种 agent 策略是独立插件,YAML 声明参数,Python 实现逻辑,不用改核心代码就能扩展
  3. 参数动态化:参数由插件 YAML 定义,Dify 只负责从变量池取值和模板渲染
  4. LLM 调用间接化:插件通过 SDK 回调主进程调 LLM,插件是编排器不是调用者
  5. Context 参数有坑:当前只用于前端引用展示,没注入 LLM prompt,检索内容拼 instruction 会导致 cache 失效

说实话,这种插件化设计确实灵活——要加新的 agent 策略,写个插件就行,不用动 Dify 核心代码。但代价是调用链路变长了,调试起来要跨两个进程,排查问题不太方便。

如果你也在研究 Dify 的 Agent 机制,希望这篇能帮你少走点弯路。

如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~谢谢你看我的文章,我们,下次再见。