Dify 中级实验(18):插件开发入门——如何把工作流变成 Agent 可调用的工具?
1. 业务场景
先讲一个我们实际遇到的场景。
一家公司的内部 CRM 系统没有现成的插件,销售团队的 AI 助手想查客户信息却查不了——模型只能闲聊,一问「客户 1001 的订单情况」,它就答「我无法访问你们的系统」。公司的 Agent 应用明明能力很强,却因为接不上内部系统,变成了一个高级聊天机器人。他们想过让开发团队写接口,但排期要三周;想过等官方插件,但内部系统大概率永远不会有官方插件。
我们第一次接这类需求时,第一反应也是「等官方插件,或者让开发排期写接口」。后来才想明白——内部系统大概率永远不会有官方插件,等插件等于放弃;好在 Dify 给了第三条路:任何工作流,发布即工具。
这不是个例。任何「Agent 要对接公司内部系统」的场景都是这个模式:查 CRM 客户、查 ERP 库存、查工单系统、查内部 Wiki——官方插件市场覆盖的是通用服务,公司内部系统只能自己造工具。好在 Dify 给了答案:任何工作流发布后都可以变成「自定义工具」,被 Agent 自动发现和调用。
2. 场景痛点
这个场景的痛点,在这家公司的销售 AI 助手项目上体现得最直接:
- Agent 接不上内部系统:模型能力再强,没有工具就查不了数据,Agent 对业务数据「睁眼瞎」,销售问什么它都答不上来。
- 等插件遥遥无期:内部系统几乎不会有官方插件,等插件等于放弃;找开发排期写接口,一个查询功能就要等几周。
- 工具参数不可控:就算接了接口,如果参数设计随意,Agent 会「自由发挥」——传错客户 ID、传错查询类型,查询结果全乱。
- 查询语义不诚实:查不到客户时,有的实现会返回一个「默认客户」的假数据糊弄过去——这在业务上是绝对不能接受的。
本质上,工具不在于复杂,而在于输入输出契约清晰——把查询语义做真、把参数枚举收窄,Agent 才能可靠地自主调用。
3. 方案:为什么是「工作流发布为自定义工具」
Dify 的扩展机制很直接:任何 Workflow 保存并发布后,都可以变成自定义工具,被 Agent/其他工作流调用。本实验就走通「构建 CRM 查询工作流 → 发布为工具 → 供 Agent 调用」的完整链路。
选它的理由:
- 零等待自建工具:不用等官方插件,不用排期开发,工作流搭好即发布即用;
- 输入输出契约天然清晰:工具的入参就是工作流的开始变量,用枚举(select)把参数收窄成合法值,Agent 没有自由发挥的空间;
- 查询语义可控:查不到就返回「未找到」语义(found=false + 空信息),绝不塞默认数据——下游 LLM 会引导用户核对,而不是编造答案。
这篇文章我们就用它搭一个「CRM 查询工具」工作流:输入
customer_id 和
query_type,查询客户基本信息/订单/工单,发布为工具后挂载给
Agent 自主调用。
4. 整体架构
链路很清晰:入口收两个参数 → 代码节点按 query_type 分发查询 → LLM 把结构化结果格式化成自然语言。4 个节点、3 条边——工具不在于复杂,而在于输入输出契约清晰;发布后 Agent 只要知道「传 customer_id 和 query_type,拿回文本」,就能自主调用。
5. 模块设计
5.1 开始节点
query_type 用下拉枚举,把 Agent
的「自由发挥」收窄成三个合法值——枚举约束是工具参数设计的第一原则:
- label: 客户ID
required: true
type: text-input
variable: customer_id
- label: 查询类型
options: [basic, orders, support]
required: true
type: select
variable: query_type5.2 模拟 CRM API(Code,核心)
真实 CRM
接口(GET /customers/{id})在实验里用代码节点模拟,重点是查询语义要真实——查不到就返回「未找到」,绝不塞默认客户数据:
def main(customer_id: str, query_type: str) -> dict:
import json, time
time.sleep(0.3) # 模拟真实 CRM API 延迟
cid = str(customer_id or "").strip()
qt = query_type or "basic"
customers = {
"1001": {"name": "张三", "level": "vip", "total_orders": 12},
"1002": {"name": "李四", "level": "normal", "total_orders": 3},
"1003": {"name": "王五", "level": "new", "total_orders": 0},
}
customer = customers.get(cid)
if not customer:
return {"found": "false", "data_type": qt,
"data_json": "{}",
"summary": "未找到客户 {}".format(cid)}
if qt == "orders":
data = [{"order_id": "ORD-1001-1", "product": "产品A", "amount": 299.0, "status": "已完成"}]
summary = "客户 {} 的订单列表({} 笔)".format(customer["name"], len(data))
elif qt == "support":
data = [{"ticket_id": "TK-9001", "title": "产品A 无法登录", "status": "处理中"}]
summary = "客户 {} 的支持工单({} 张)".format(customer["name"], len(data))
else:
data = customer
summary = "客户 {},等级 {},累计 {} 笔订单".format(
customer["name"], customer["level"], customer["total_orders"])
return {"found": "true", "data_type": qt,
"data_json": json.dumps(data, ensure_ascii=False), "summary": summary}要点:
found展平成 string"true"/"false"——下游 IF-ELSE 才能判断(boolean 类型在节点间不可见)data_json输出 JSON 字符串保结构,summary输出文本给 LLM——双形态输出是工具节点的通用约定
5.3 发布为工具
- 保存并发布这个 Workflow 应用
- 进入「工具 → 自定义工具 → 通过工作流创建工具」,选择刚发布的应用
- 工具自动生成参数面板(对应 start 变量),配置后即可在 Agent 的「工具」列表里挂载
⚠️ 发布为工具后,工具内部注册 ID(provider_id)是发布时生成的 UUID,不是应用 ID;子工作流重新导入并重新发布后,注册 ID 会变化,调用方必须同步更新。
6. 运行验证
| 场景 | 输入 | 预期 | 实测结果 |
|---|---|---|---|
| 查询基本信息 | customer_id=1001, query_type=basic | found=true,返回张三的等级与订单数 | 模拟 API 命中,LLM 正常格式化 ✓ |
| 查询订单 | customer_id=1001, query_type=orders | 返回订单列表 JSON + 摘要 | 订单分支触发 ✓ |
| 客户不存在 | customer_id=9999, query_type=basic | found=false,提示「未找到客户 9999」 | 未找到分支正确返回,不编造数据 ✓ |
| Agent 调用 | 在 Agent 应用挂载工具后说「查一下客户 1001」 | Agent 自动调用工具并组织回答 | Agent 识别意图并触发工具调用 ✓ |
7. 实战坑
| 坑 | 现象 | 修复 |
|---|---|---|
| 模拟 API 查不到也返回默认客户 | 问不存在的客户 ID,得到一份「默认客户」的假信息 | 兜底分支返回「未找到」语义(found=false + 空信息),让下游 LLM 引导用户核对 |
found 用 boolean 输出 |
下游 IF-ELSE 判断不到 | 展平为 string
"true"/"false" |
用 customer_id
当参数名但代码签名写别的 |
运行报
main() got an unexpected keyword argument |
code 节点 variables 的 variable 名必须等于
def main(...) 签名参数名(Dify 按名传参) |
| 工作流发布为工具后 provider_id 写死旧值 | 重新导入发布后调用方报「workflow provider not found」 | 每次重新发布后从工具列表动态查询最新 provider_id 并同步 |
| 真实 API 场景直接写 http-request 没接降级 | 外网 CRM 超时整条链路失败 | 生产接入时用 http-request + 超时重试 + 失败分支(见实验 17 容错架构) |
采坑点来自本实验 DSL 生成与运行验证的真实记录(代码模拟 API 约定、found 语义、按名传参、provider_id 变化)。
8. 实验文档及源码获取
- 实验文档(完整操作步骤):DIFY-19:插件开发入门.md
- 源码(可直接导入):dify102_19_CRM查询工具.yml
- DSL 目录:dify-102/dsl/
文章聚焦核心配置与采坑点;实验文档还包含正式插件开发(plugin.json/main.py/providers/tools 结构、凭据 secret-input、OpenAPI/Swagger 导入建工具、多工具组合插件包)的完整示例。
- Dify 中级实验(01):参数提取器实战——如何从自然语言中提取结构化数据?
- Dify 中级实验(02):问题分类器——智能路由引擎如何四路分发?
- Dify 中级实验(03):模板转换实战——如何用零 Token 完成文本加工?
- Dify 中级实验(04):迭代进阶——如何批量处理数据并守住性能边界?
- Dify 中级实验(05):并行执行——如何让多路任务同时跑?
- Dify 中级实验(06):变量聚合——如何确定性合并多路分支结果?
- Dify 中级实验(07):子工作流——如何把公共逻辑做成可复用积木?
- Dify 中级实验(08):代码节点进阶——如何用标准库处理文件与数据?
- Dify 中级实验(09):HTTP 节点进阶——如何搞定认证、分页与错误重试?
- Dify 中级实验(10):知识库深度调优——如何科学评估检索质量?
- Dify 中级实验(11):高级 RAG 流水线——如何搭建多路检索与精排?
- Dify 中级实验(12):Agent 深度配置——如何让智能体自主调用工具?
- Dify 中级实验(13):多 Agent 协作——如何编排多个智能体分工干活?
- Dify 中级实验(14):对话变量与状态管理——如何让工作流记住多轮对话的状态?
- Dify 中级实验(15):条件分支高阶策略——多条件路由如何避免分支爆炸?
- Dify 中级实验(16):错误处理与降级——工作流如何有尊严地失败?
- Dify 中级实验(17):调试监控与性能优化——响应慢和 Token 超支如何定位?
- Dify 中级实验(18):插件开发入门——如何把工作流变成 Agent 可调用的工具?
- Dify 中级实验(19):综合实战——如何把 19 个实验串成一条生产级流水线?
- Dify 中级实验(20):综合实战——自动化报告生成流水线如何从数据到周报一步到位?