FastGPT & Dify 应用案例及工作流开发总结
AI+Workflow是当前比较流行的Agent应用开发方式,本博客之前重点对比了:Dify vs FastGPT两个开源项目,也演示了Coze的案例,本文继续分享这两款产品的开发实践,借此对拖控件编排工作流这种低代码开发方式进行阶段性总结,读者顺便也能体会到它们之间的差异。
特别强调的是,这几款产品迭代得很快,文章仅作参考而非为了评价它们的优劣。同样优秀开源项目还有:bisheng(支持autogen和lanchain)和 ragflow(提供更丰富的RAG方式)等,你如果了解 langchain 的话,它也有 workflow 的开发方式可供选择。
言归正传!
图形界面仍然是我们大部分用户首选的人机交互方式,例如,下图是我用FastGPT实现的一个下载器前端:
目前AI在实现智能生成UI、自动提参的交互能力上还比较弱,一方面,开发应用需要先对图形界面(GUI)进行布置,这受限于平台自身提供的UI组件;另一方面,AI更广泛的应用场景是作为其他软件的辅助形式,所谓副驾驶,例如代码编辑器中的插件,聊天软件中的机器人,而软件是各自独立的,多数软件接入第三方AI的形式以兼容OpenAI的GPT接口作为标准,文本交互为主。要在一个接口实现多种功能,命令行界面(CLI)是一种很好的形式——在消息文本中插入特定格式的参数,从而让程序去执行不同的流程,例如通过命令调用不同的模型。
我希望可以分别用到这两者:同一个应用,在自身平台上可以用GUI,而在第三方客户端可以用CLI,事实上,CLI使用起来更顺手,很大程度上也可以突破平台自身图形界面的限制。
例如利用CLI,我在别的聊天软件使用上面的下载代理应用:
FastGPT和Dify的应用都可以对外提供接口,FastGPT的接口与GPT兼容,而Dify则需要转换(可以用dify2openai这个项目)。
通过CLI,我希望实现以下需求:
- 灵活控制对话的上下文条数。避免多余信息的干扰,同时也是为了节省tokens。
- 在一个应用中灵活调用其他应用参与会话。应用统一入口,避免复制粘贴,也方便话题管理。
这二者对一个频繁使用AI Agent的人来说很重要,而恰恰是FastGPT和Dify自身前端所缺乏的,也没有为第三方客户端直接实现。
FastGPT开发流程
考虑到客户端以及消息中其他代码的兼容性,我把CLI指令主要放在消息头部和尾部,其中头部--<cmd> <Message> ~~<context_count>
,头部cmd
代表调用不同的应用,尾部context_count
代表指定上下文长度,Message
是用户问题,其中也可以包含指令,由应用各自定义。
首先要在输入中提取这些指令,并返回指令以及去除指令之后的用户消息,这个程序可能为多个应用所调用,所以先创建插件。
创建命令拾取插件
插件也是工作流,和工作流应用不同的是以入参开始,以出参结束。入参即用户输入,出参则是头部指令、尾部指令以及去除指令后的用户问题。中间用fastgpt的代码沙盒(目前只能用JavaScript)对文本进行处理,为什么不用更简单的 if else
判断器?因为用户可能在指令前后插入了别的字符,例如空格、@符号之类的,代码可以做更宽的约束,使用正则表达式限制cmd
指令由中英文构成,长度不超过5字符,并且只能出现在头25个字符中,length
限制长度为不超过两位的数字,这是提取的代码:
function main({ask}) {
// 提取前25个字符
let first25Chars = ask.substring(0, 25);
// 匹配 \s--(A-Z0-9a-z){1,5}\s 的正则表达式
let match = first25Chars.match(/--[A-Za-z0-9]{1,5}\s/);
// 初始化 isMatch 和 prompt
let cmdBegin = "";
let prompt = ask;
// 如果匹配到
if (match) {
cmdBegin = match[0].trim(); // 去掉前后的空白字符
// 只替换第一个匹配到的字符块
let matchIndex = ask.indexOf(match[0]);
prompt = ask.substring(0, matchIndex) + ask.substring(matchIndex + match[0].length);
}
// 匹配文本末尾的 \s~~\d{1,2}\s*
let endMatch = ask.match(/\s~~(\d{1,2})\s*$/);
// 初始化 cmdEnd
let cmdEnd = 0;
// 如果匹配到
if (endMatch) {
cmdEnd = parseInt(endMatch[1], 10); // 提取数字并转换为整数
// prompt = prompt.replace(endMatch[0], '');
matchIndex = prompt.indexOf(endMatch[0]);
prompt = prompt.substring(0, matchIndex) + prompt.substring(matchIndex + endMatch[0].length);
}
// 创建返回的JSON对象
let returnJson = {
"prompt": prompt.trim(),
"cmdBegin": cmdBegin,
"cmdEnd": cmdEnd
};
// 返回构建的JSON对象
return returnJson;
}
创建命令分类插件
提取指令之后就可以用对指令执行不同的流程了,同样作为一个插件,这个插件要实现根据指令调用不同的应用以及传入不同长度的上下文,这在FastGPT中是可以做到的,因为FastGPT有一个应用调用
的内置插件,接受引用变量形式输入的参数。
这次需要四个入参,除了上面插件输入的三个,还需要历史记录,这两个插件将在工作流应用引用,历史记录由应用自身提供。现在需要把这几个参数转为应用调用
接受的参数,由于FastGPT并没有文档详细说明它们接受的式,因此用数据抓取。总之,了解它的输入形式后,就可以对那几个入参进行代码处理了,代码如下:
function main({ask, context,cmdBegin,cmdEnd}){
let appId = "64efb4621d65f7a23a749fc4"; // 默认值,假设大多数情况下是这个ID
let appName = "GPT 4"; // 默认值
if(cmdBegin.length > 0) {
switch (cmdBegin) {
case '--ty':
appId = "666eee666259473f9add732c";
appName = "Tavilly简询";
break;
case '--xw':
appId = "666eed726259473f9add714c";
appName = "SearXNG新闻";
break;
}
}
let appChoice = {
"id": appId,
"name": appName,
"logo": ""
};
let returnJson = {
"prompt": ask,
"app_choice": appChoice,
"memory": []
};
if (cmdEnd > 0 && context.length>0) {
let contextSlice = [];
if (context[0].obj === "System") {
contextSlice.unshift(context[0]); // 如果是,添加到数组前端
context.splice(0);
}
cmdEnd = Math.min(cmdEnd, context.length);
for (let i = context.length-cmdEnd; i < context.length; i++) {
if (context[i].value.length>0 && Array.isArray(context[i].value)) {
context[i].value = context[i].value.map(value => {
if (value.type === "text" && value.text.length>0) {
value.text.content = value.text.content.replace(/(\s~~\d{1,2})\s*$/, '');
let first25Chars = value.text.content.substring(0, 25);
let match = first25Chars.match(/\s*--[A-Za-z0-9]{1,5}\s/);
if (match) {
let matchIndex = value.text.content.indexOf(match[0]);
value.text.content = value.text.content.substring(0, matchIndex) + value.text.content.substring(matchIndex + match[0].length);
}
}
return value;
});
contextSlice.push(context[i]);
}
}
returnJson.memory = contextSlice;
}
return returnJson;
}
根据末尾参数截取指定条数的历史记录,根据头部参数构建应用选择所需要的参数,其中appId
在新版FasGPT中要通过浏览器控制台获取,F12
打开控制台,访问https://your_domain/api/core/app/list
这个链接,查看其响应数据。指令和应用的对应关系自定义,以上代码只是示例。
然后将代码返回的参数传递给应用调用,应用将产生回复和新的上下文(历史记录)给这个插件的输出:
创建聚合应用
按照下面的工作流进行配置即可:
使用GPT-4o作为默认的无指令模型,这样应用前端才支持图片和语音输入,默认聊天记录按自己的喜好,例如这个应用默认不带聊天记录,另创建一个相同配置但默认带历史记录的的应用用于给第三方客户端使用。
这就是FastGPT上所需要做的,需要了解代码开发的基本概念。那Dify上如何实现上述需求呢?
Dify开发流程
Dify实现上述流程比较麻烦,因为没有内置的应用调用
的插件,Dify可以将工作流发布为插件,如果要实现类似的插件,需要:
- 将所有应用都转为工作流插件。
- 每创建一个工作流插件(应用),都需要在此
仿应用调用
插件中添加一个调用。
或者直接让每个应用暴露API,让工作流的HTTP模块来访问它们,这样才能实现统一应用入口。
还有麻烦的一点:Dify并没有将历史记录开放为环境中访问的变量,也没有在模型调用组件时变量设置“记忆”,而只接受一个数字常量,从根本上无法通过客户端自由指定上下文长度。事实上,Dify许多内置工具都不支持变量参数。
所以,只能折中一下,只配置较少的CLI参数,以支持常用的功能。
总结工作流开发方式
我认为这里存在两个开发团队对工作流这种开发模式的不同理解,FastGPT团队更倾向于使其符合面向服务的架构(SOA),应用可以相互调用,单个功能组件也支持更灵活的变量传入。而Dify团队倾向于让非技术背景的应用开发者少用到代码,因此也更积极的在平台集成第三方工具。
为了了解工作流开发的特点,我举个不是那么恰当的类比:在 .Net Entity Framework 框架中有Database First、Model First、Code First三种开发模式。
AutoGen、 CrewAI、TaskWeaver这些Agent编程框架有点类似于Code First,对于已有的工具,工作流比Code Agent效率高,对于未有的工具,用AI帮助代码创建比拖基础控件填参数效率高。而Code Agent的动态灵活性可能是工作流所不具备的,甚至可以在对话过程中让AI写代码或自己提供代码让平台去运行,并将其创建为智能体的工具,这就很有想象空间了。
工作流这种有点类似于Model First,一个个组件关系设计优先,一眼清晰明了,适用于快速验证原型,低门槛上手,但另一方面:
- 为了功能更加强大,组件将会越设计越多,配置越来越复杂,最后学习它的文档甚至可能超过掌握一门编程框架,不相信这点的不妨去看下Zapier文档和n8n 文档,区别在于学习编程是边际效用递增,而低代码平台是边际效用递减。
- 个人认为对于复杂的应用,低代码开发效率更低。面向对象编程中,调用某个对象只需要写出这个对象名称(IDE可以自动补全)和参数值就行,而低代码需要:拖控件到合适位置、多端连线、挨个选取(或写入)值,增添了很多步骤,当然低代码不是零代码,当低代码平台集成链式编程也可以提高效率。另外之前也提到过视觉元素对工作记忆的低效占用,画布元素一多,感觉思维就乱了,得重新捋一遍。
那Database First呢?
工作流就如同传送带,一个简单的工作流就只能一根线从头走到尾,涉及到交叉、回流、堆叠就麻烦了,面向服务的架构是一种很好的解决方式,相当于共用的管道,提供数据模型也是,相当于集中仓储。
Dify和FastGPT都支持全局变量,但Dify不支持在流程中对环境变量赋值(还是常量化了),而FastGPT支持两种全局变量,一种显示给前端用户设置的,一种是隐式的,它们都可以在流程中访问和修改。
Dify也注意到了工作流开发的某些缺点,所以它有一个迭代
模块和变量聚合器
模块,很大程度上解决数组(堆叠)的问题。
说了那么多工作流开发的缺点,为什么它还是比较流行?架不住视觉友好易上手。我以为一个好的开发框架能够结合上面三种模式的优点,开发者应尝试不同的方式并把它们结合起来。
文末附上面几个应用的导出配置:fastgpt+dify应用配置.zip