前言
随着一众国内外大模型免费开放API,零成本构建一些性能不是那么强的大模型应用成为了可能。作为Digital IDE的作者,锦恢在完成学业的同时也需要负责维护一些自己的开源项目。
在维护一个产品的时候,为了获得更好的用户反馈,我们往往会建立一个 QQ 群来和用户直接互动。
不过随着时间的推移,群内会有很多的问题变得重复,或者很多问题都可以从我们提供的官方文档中找到答案。当然,我们知道,大部分的人都是懒得去翻官方文档的。
于是我就想着能不能开发一个用于进行问题解决的 QA 机器人,这样就可以解放我的双手了,让我每天有更多时间和朋友打星际争霸。通过 5 天的开发,我上线了我的 QA 机器人,我取名为 Tiphereth,是卡巴拉生命之树最中心的节点,希伯来文意为“美丽、荣耀”。目前已经在群内稳定运行了一个月。
项目目前已经开源,欢迎各位点个 star:Lagrange.RagBot
这篇文章,我就分享一下我在开发 QA 机器人中的心得,以及其中最核心的模块“意图识别”模块是如何基于深度学习和单元测试的进行迭代的。
项目架构 & 流程讲解
因为这个项目是一个将机器人接入 QQ 的项目,涉及到比较多的环节,项目整体有一定的复杂性。本着先感性再理性的认知原则,所以我打算先讲讲系统的整体的比较粗略的架构,先让我的观众大概知道 QA 机器人的整理架构和大致的一个原理。
项目的整体架构如下图所示:
其中最上层的 Lagrange.Core 负责整个 QQ 的鉴权和底层通信,包括登录注册,和 QQ 的服务器直接发起安全连接和信息发送接受。此处感谢 Lagrange.Core 项目组 的无私奉献,也希望大家可以去点个 star。
中间层的 Lagrange.onebot 则是整个 QQ 的后端框架,负责编写对于不同的群聊和私聊的逻辑函数,这个项目完全由我开发完成,官方文档在这里 Lagrange.onebot 文档。
在逻辑层面,当一个用户在在群内发言后,onebot 层在将消息转换为更加规整的文本信息后,会交给意图识别模块,也就是 Intent Recogition 进行意图分类,该模块会将用户输入的一段话分类成如下几个类别之一:
·bug 询问
· usage 询问
· 表达情感
· others
对于99%的群内的聊天内容,都属于 others,当意图识别模块将 others 识别返回给 onebot 后,onebot 就会自动拒绝回答当前的消息。这么做会让我们的 QA 机器人显得很乖,不会胡乱插嘴。这在客服机器人和 QA 机器人的开发中是非常重要的。实际上,因为深度学习上数据不足造成的 out of distribution 问题,我在系统上还加上了我今年提出的一个深度学习可信计算的算法,阁下暂时不需要了解它的原理,理解它需要阁下去复习高等数学和多元统计分析的知识。阁下只需要知道,这个模型能返回当前意图分类的不确定性。下图是我对 Tiphereth 进行的一个意图识别测试。
可以看到,它很好地完成了意图的识别,并返回了对应的不确定度,不确定度如果太高了(>0.3),那么,系统也会拒绝回答用户的问题。这样就能很好地解决 out of distribution 的问题。
如果意图识别模块返回的是内容是 bug 询问,那么 onebot 层就会去询问大模型,从而获得对应的回答结果。不过这么做太简单了,对于我们自己的项目或者公司内部的技术文档,大模型肯定不知道其中的技术。因此,我们需要引入知识库来让大模型“更了解我们的内部资料”。具体做法为,将我们的内部资料切成一个个小块扔进向量数据库中,等到用户询问相关问题时,使用用户的询问语句去向量数据库的语义空间中找出 topk 接近的小块,将小块作为 prompt 再去询问 大模型,从而得到具备内部知识库的回答。而且最棒的是,我们的程序在询问大模型时是可以获取到从向量数据库中返回的切块的来源的(比如来源于某个网址),这样我们的 QA 机器人在回答时就能像 new bing 一样返回答案的参考链接了,这对于需要专业信息咨询的用户而言,是非常重要的:
对于其他的意图,用户完全可以自己去接入一些其他的第三方服务,比如 google 搜索, wiki 知识库等等。总之,我设计的这套 QA 机器人框架目前可以满足我自己的需求,如果阁下对于当前框架的设计有其他建议,欢迎在评论区提出。只有在工业界受过足够多的迭代升级,才能获得效果更好的 QA 机器人。
TDD 意图识别开发
好啦,接下来,非常有必要讲讲开发中非常重要的一些细节。
QA 机器人的设计边界
我在一年前,其实开发过一款基于大模型的聊天机器人,当时我对这类产品的定位还没有一个清晰的认知,算是乐趣 > 实用价值。一年后的现在,我认识到以目前的深度学习技术,想要开发一个 QA 机器人,最重要的并不是把机器人的回答做得更加像人,OpenAI 的首席执行官 Sam 在今年 4月份 的一次演讲中也提到了:
我不看好任何视图通过大模型向外部提供情绪价值的项目,这些项目终将以失败告终;我们推动大模型的方向永远是让它成为一个解放大脑,提升生产力的工具。
事实上,在 Chatgpt 推出的一年多之前,就发生过类似的项目,该项目后来因为 openai 的阻力而破产。因此,我们设计的 QA 机器人不应该把“说话像猫娘”作为提升方向,而是应该给它设计更加明确的使用场景,提前设计好它能做什么,它不能做什么。在软件工程学中,这被我们称为一款软件的设计边界(design boundary)。
那么 QA 机器人的设计边界是什么呢?在我的场景中,就是根据用户的问题,回答有关我的开源软件使用方面的问题(usage intent),或者回答有关软件可能存在的 bug 相关的问题(bug intent)。
而对于这样有明确设计边界的问答系统,意图识别模块基本是必不可少的。虽然大模型自身也能在一定程度上进行意图识别,但是大模型的返回结果的稳定性极差,且条件执行效果也非常差(目前最好的 GPT4,条件执行的准确率也只有 86%)。想要做到比较低的错误率且可控性极佳,方便让程序的其他模块更加精细地控制 QA 机器人的行为,一个意图识别模块几乎是必不可少的。
意图识别的开发
在讲解 TDD(test driven development,测试驱动的开发) 的意图识别之前,首先需要给意图识别的 IPO 进行简单的定义。在我们的场景中,意图识别的输入是一段话,负责输出意图的分类结果,当前意图是属于 usage,bug,expression 还是 others,相当于是一个文本四分类问题。
为了预防数据不足导致的 out of distribution 的问题,我们还需要给模型的输出增加一个不确定率,这个过程采用了最新的可信计算技术 EDL(证据神经网络,evidential deep learning)结合了我在今年提出的新的改进方法完成。有关 EDL 的具体原理,欢迎阁下去阅读我的往期博客:EDL(Evidential Deep Learning) 原理与代码实现 。
有的非资深深度学习工程师可能对于网络到底要搭建多少层,一直没有一个感性的认知,我虽然也不是资深的深度学习工程师(毕竟咱还没有毕业,笑),但是关于如何构造网络的深度大小,我还是有点经验的。最简单的方法就是先使用 t-SNE,将数据在特征空间上进行可视化。此处我采用了网易开源的 BCEmebedding 作为中英混合文本的嵌入层,简单说就是这玩意儿能把一段话变成一个 512 维向量。那么我们先把训练集全部扔进去,然后使用 t-SNE 在二维平面上进行可视化这些 512 维向量:
我们比较关心的是 usage, bug, expression 和 others 这几个量,那么我根据上图右上角的标签,去找这四个类别对应的颜色的散点。我们需要通过这四个类别是不是足够好分,来判断我们需要构造多么复杂的模型,越是复杂的模型,能做出的决策边界可以越复杂,最简单的单层线性模型只能用切蛋糕的方式,通过给上面的图“划几刀”的方式来直接划定楚汉两界。比如,在线左边的就是 usage,在右边的就是 bug。
我们的例子中,上面的图中四个类别对应的散点并不算非常难以区分,结合业界对于意图模型的通用做法,我最终采取了单层 MLP + ELU 激活函数的方式进行网络构造。
假设我们通过预训练的得到了上述的模型(详细请参考 Lagrange.RagBot 的 ./notebook/experiment.ipynb 文件中的实验结果),loss 损失函数下降得非常舒服:
说明训练收敛了。
我们可以写个程序和对应的 test suite 来测试一下准确率,这其实就相当于深度学习模型在测试集上的准确率:
test_suite = [ # input 代表输入值,expect 代表模型输出的目标值 { 'input': '如何使用 digital ide 这个插件?', 'expect': 'usage' }, # 此处的 expect 使用逗号进行分隔,代表只要符合其中的某一个就行。这是因为很多时候,问题的意图并不明显,很可能某几个值都满足要求 { 'input': '我今天打开 vscode,发现 自动补全失效了,我是哪里没有配置好吗?', 'expect': 'usage,bug' }, { 'input': 'path top.v is not a hdlFile 请问报这个错误大概是啥原因啊', 'expect': 'usage,bug' }, { 'input': '我同学在学习强国看到小麦收割了,然后就买相应的股就赚了', 'expect': 'others' }, { 'input': '我平时写代码就喜欢喝茶', 'expect': 'others' }, { 'input': '请问报这个错误大概是啥原因啊', 'expect': 'usage,bug' }, { 'input': '感觉现在啥都在往AI靠', 'expect': 'others' }, { 'input': '别人设置的肯定有点不合适自己的', 'expect': 'others' }, { 'input': '在企业里面最大的问题是碰见傻逼怎么办?', 'expect': 'others' }, { 'input': '几乎完全不喝牛奶2333', 'expect': 'others' }, { 'input': 'command not found: python', 'expect': 'usage,bug,others' }, { 'input': '兄弟们有没有C语言绘图库推荐', 'expect': 'usage' }, { 'input': '我早上开着机去打论文 回来发现我电脑切换到Linux了', 'expect': 'usage,bug,others' }, { 'input': '我在Windows下遇到的只要问题就是对于C程序,包管理和编译管理器偶尔会不认识彼此但除此之外,都很安稳(win11除外)', 'expect': 'usage,others' }, { 'input': '我的反撤回还能用', 'expect': 'others' }, { 'input': '因为这是养蛊的虚拟机,放了些国产垃圾软件,得用国产流氓之王才能镇得住他们', 'expect': 'others' }, { 'input': '你咋装了个360', 'expect': 'others' }, { 'input': '???', 'expect': 'expression' }, ] for test in test_suite: embd = model.embed_documents([test['input']]) embd = torch.FloatTensor(embd) with torch.no_grad(): evidence, prob = enn_model(embd) e = evidence alpha = e + 1 S = alpha.sum(1) b = e / S u = out_dim / S pre_label = prob.argmax(1) name = engine.id2intent[pre_label[0].item()] ok = '√' if name in test['expect'] else '×' print(name, test['expect'], ok, u)
输出:
usage usage √ tensor([0.0501]) bug usage,bug √ tensor([0.0773]) bug usage,bug √ tensor([0.0758]) others others √ tensor([0.1678]) others others √ tensor([0.0887]) bug usage,bug √ tensor([0.0902]) others others √ tensor([0.0453]) others others √ tensor([0.0424]) others others √ tensor([0.1416]) others others √ tensor([0.1441]) bug usage,bug,others √ tensor([0.1615]) usage usage √ tensor([0.0562]) others usage,bug,others √ tensor([0.0820]) others usage,others √ tensor([0.0798]) others others √ tensor([0.1282]) others others √ tensor([0.1034]) others others √ tensor([0.0967]) expression expression √ tensor([0.0802])
可以看到,全都答对了。
但是此时,并不能直接参与部署的,请听我娓娓道来。
QA 机器人是需要不断迭代的
这里需要阁下了解一个关于 QA 机器人开发的基本流程, QA 机器人的开发(其他很多深度学习应用也一样)一定是一个不断迭代的过程,因为你的无法在一开始就制作出一个能够 cover 所有问题的数据集,在 QA 机器人上线测试的过程中,用户一定会不断地提出问题,或是闲聊,这些数据是非常非常珍贵的!所以! QA 机器人能跑,能说人话了,只是第一步,最最重要的是需要设计出一套机制,能够收集用户数据,并周期性地自动使用这些数据去更新模型,从而最终趋于完美。
如果阁下运行了我的项目,会发现在项目的根目录下,出现了一个 logs 文件夹,它里面就存储了我的系统自动保存的用户的对话和模型将该对话识别成的意图。
在 ./notebook/clear-logs.ipynb 中,给出了一个例程,该例程会自动将 logs 下识别意图不是 others 的对话内存展示出来,你可以自己进行干预,重新审核这些非 others 的意图是否正确,如果不正确,它的意图应该是什么。然后基于这些信息,可以快速更新我们的训练集。新增的数据会自动录入到 ./config/qq.story.yml 中(这个文件我并没有开源,因为里面涉及到了用户的个人隐私),然后我们重新训练模型即可。因为模型只有一层,所以就算使用 CPU,也能在超短时间内瞬间训练完成。
迭代后的测试
在重新训练完成后,我们需要知道更新参数后的模型是不是还能把之前的问题答对,我将这一部分逻辑写成了单元测试。启动项目后,在项目中输入 npm run test 即可开启单元测试:
开发者只需要根据单元测试的反馈,就能大致了解到重新训练后的模型的性能,从而做出判断,到底是继续上线,还是继续添加数据,让不满足条件的测试样例通过。
很多做深度学习的工程师都没有做单元测试的习惯,这是非常不好的。因为实际场景下,是需要保证模型在特定例子上一定能通过,测试集的正确率并不能反应这一点。
如此,反复迭代,就能够在最终得到一个很乖的AI啦!在我稳定运行的1个月以来,大概迭代到第二代时,意图识别模块就足够稳定了。对了,每次训练完后,你还可以用 curl 工具先将模型重新加载到服务中:
curl -X POST http://127.0.0.1:8081/intent/reload-embedding-mapping
返回:
{"code":200,"data":"load model from ./model/intent.enn.pth","msg":200}
这么做最大的好处就是不需要重启服务,然后我们可以使用单个例子来测试上线的模型:
curl -X POST -H "Content-Type: application/json" -d '{"query": "今天吃不吃疯狂星期四?"}' http://127.0.0.1:8081/intent/get-intent-reco gition
输出:
{"code":200,"data":{"id":6,"name":"others","uncertainty":0.2645169198513031},"msg":200}
可以看到,输出结果为 others,不确定度也非常高,代表当前的语句是闲聊。当然,你也可以拿更多的例子来测试。
这样,一个稳定的意图识别模块就搞定了,至于后面如何结合向量数据库和大模型给出带有参考链接的回答,这就不属于本篇文章的内容了。感兴趣的朋友可以参考 ./bot/services/intent.ts 这个文件的内容。
各位,玩得开心点!
总结
本文简单讲解了一下我的 QA 机器人的设计与开发经验,特别是关于 TDD 模式下的意图识别模块的迭代。除了意图识别,也就是文本分类,对于其他的需要基于深度学习进行的精准任务而言,这套开发流程也是具有一定的参考价值的,特别是对于深度学习同行而言,一定不要想着一个很鲁棒的算法+数据集就能一招解千愁,一个优秀的深度学习应用一定是要在测试中反复迭代升级的,毕竟,对于深度学习而言,算法 > 数据 > 算法。
作者:锦恢