Dify Agent 节点扒源码:插件化架构藏得够深的
看完之后只能说一句:这架构设计,藏得够深的。
先说结论
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 |
|
entities.py |
|
plugin.py |
|
agent.py |
|
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 收到请求后干了这么几件事:
-
根据 X-Plugin-ID找到langgenius/agent插件 -
匹配到 strategies/function_calling.yaml -
找到对应的 strategies/function_calling.py -
实例化 FunctionCallingAgentStrategy,调用_invoke(parameters) -
插件执行 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
|
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"
效率对比
这个差距是实打实的:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
tools= 参数,不占 prompt token |
|
|
|
|
|
|
|
|
|
|
|
|
|
举个例子,假设需要 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 点:
-
插件化架构:主进程不执行推理逻辑,HTTP+SSE 跟 daemon 通信,所有策略跑在 daemon 里 -
策略即插件:每种 agent 策略是独立插件,YAML 声明参数,Python 实现逻辑,不用改核心代码就能扩展 -
参数动态化:参数由插件 YAML 定义,Dify 只负责从变量池取值和模板渲染 -
LLM 调用间接化:插件通过 SDK 回调主进程调 LLM,插件是编排器不是调用者 -
Context 参数有坑:当前只用于前端引用展示,没注入 LLM prompt,检索内容拼 instruction 会导致 cache 失效
说实话,这种插件化设计确实灵活——要加新的 agent 策略,写个插件就行,不用动 Dify 核心代码。但代价是调用链路变长了,调试起来要跨两个进程,排查问题不太方便。
如果你也在研究 Dify 的 Agent 机制,希望这篇能帮你少走点弯路。
如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~谢谢你看我的文章,我们,下次再见。
夜雨聆风