[AIGC] 用dify来构建智能回复并优化其响应速度

3,885 阅读13分钟

前言

项目需要实现一个智能回复的ai,经过前期的调研我们决定用dify平台来实现。对比FastGPT,dify更完善一些,比如工作流编排更简易,模型添加更快捷等。

智能回复流程

根据项目需要,经过调研测试最终敲定如下流程:

智能应答流程.jpg

先对用户输入进行问题分类,如果是不需要回复的类型,则直接结束。

否则进入下个流程,对用户输入进行分析,提取其中的问题列表。如果没有问题则直接结束。

否则针对每个问题进行知识库检索,并进行重排。

最后将每个问题的结果进行整合输出。

预过滤

这一步并没有在dify中来做,直接在服务器拦截了,主要是处理长度非常短的消息,比如你好、嗯、好的等这类不需要处理的消息。这样可以减少智能回复应用的压力以及模型使用成本。

问题分类

我们对用户的历史消息进行分析,发现用户有很大一部分信息是打招呼、感谢、回复(好的)之类的语句,这类语句实际上没有必要进行智能回复,所以在流程一开始进行问题分类。

这部分使用LLM大语言模型来对用户输入进行分类,根据分类结果执行不同的分支。

这一步的主要目的是拦截部分消息,可以减少后面流程的压力以及模型使用成本。

问题提取

同样对用户历史消息分析发现,很多用户习惯发长文本,里面包含大量信息,甚至可能同时涵盖几个问题,当然也有可能里面没有任何有效问题。

所以这一步我们同样使用LLM大语言模型来分析用户输入,提取出消息中的所有问题并整合成列表。

这一步主要是进行信息处理,以便后面流程使用,另外还可以拦截部分消息。

知识检索

根据上一步的问题列表,对每个问题进行知识库检索+重排。

首先使用text-embedding模型对问题进行向量化,然后在知识库中检索得到TopK个最相似条目。

然后将问题和这些条目进行重排,这里有两种方式:权重重排和rerank重排(后者需要用到rerank模型),重排后取得分最高的一个条目

整合结果

最后将每个问题检索出的结果整合到一起,并根据需要进行格式化,输出即可。

智能回复1.0

根据上面的流程,很快搭建出了第一版应用,如图:

全流程单知识库1.0(问题分类).jpg

问题分类这里使用了dify自带的功能,而问题提取由于dify没有提供这个功能,所以是通过LLM这个功能来实现的,但是因为输出的是字符串,所以后面需要代码执行功能来处理成列表。

出于成本考虑,我们最开始在问题分类这里使用了免费的模型,期望可以拦截部分消息,后面使用收费模型处理。

代码执行优化

测试过程发现这个版本响应非常慢,虽然主要耗时不在代码执行这一步,但是这一步也有一两百毫秒,这一步仅仅是简单的进行json解析。

代码执行这里支持两种语言:python和js。而一开始这一步使用的是js代码来编写的。

考虑到工作流的每一步执行都是交给服务端来处理的,而dify的服务端是python语言,于是将代码执行改成python,重新编写代码。

重新测试发现执行时间压缩到几十毫秒,如果一个流程中代码执行环节比较多的话,这个时间还是很可观的。

耗时问题

整个流程耗时10秒左右,那么耗时主要在哪呢?

测试发现是问题分类这个环节,耗时达到4秒多,为此针对问题分类这个功能测试了目前主流的40多款模型,响应速度如下:

问题分类模型测试.jpg

对最快的前几款模型进行深度测试,综合考虑分类准确度和模型并发,最终选定gpt-3.5-turbo。

智能回复2.0

gpt-3.5-turbo是收费的,但是免费模型确实不堪大用,所以新一版流程如下:

全流程单知识库2.0(问题分类).jpg

将问题分类和问题提取模型全部换成gpt-3.5-turbo,问题分类环节速度降低至1.3s左右,整个流程速度降低至三四秒左右。

问题分类原理

整体耗时降低了一半左右,问题分类单个环节耗时降低了2/3,但是一次LLM模型调用需要1秒多还是有些长,为什么仅仅一个问题分类就需要这么长时间?

从dify源码入手,问题分类这个节点的源码在dify项目的api/core/workflow/nodes/question_classifier下的question_classifier_node.py

整个功能比较简单,就是进行一次LLM模型调用,关键是输入的prompt,在_run函数下有这样的代码:

prompt_messages, stop = self._fetch_prompt(
    node_data=node_data, context="", query=query, memory=memory, model_config=model_config
)

这里就是获取prompt,_fetch_prompt函数中的关键代码:

prompt_template = self._get_prompt_template(node_data, query, memory, rest_token)
prompt_messages = prompt_transform.get_prompt(
    prompt_template=prompt_template,
    inputs={},
    query="",
    files=[],
    context=context,
    memory_config=node_data.memory,
    memory=None,
    model_config=model_config,
)

首先获取prompt模板,然后填充模板得到最终的prompt,_get_prompt_template中的关键代码如下:

system_prompt_messages = ChatModelMessage(
    role=PromptMessageRole.SYSTEM, text=QUESTION_CLASSIFIER_SYSTEM_PROMPT.format(histories=memory_str)
)
prompt_messages.append(system_prompt_messages)
user_prompt_message_1 = ChatModelMessage(
    role=PromptMessageRole.USER, text=QUESTION_CLASSIFIER_USER_PROMPT_1
)
prompt_messages.append(user_prompt_message_1)
assistant_prompt_message_1 = ChatModelMessage(
    role=PromptMessageRole.ASSISTANT, text=QUESTION_CLASSIFIER_ASSISTANT_PROMPT_1
)
prompt_messages.append(assistant_prompt_message_1)
user_prompt_message_2 = ChatModelMessage(
    role=PromptMessageRole.USER, text=QUESTION_CLASSIFIER_USER_PROMPT_2
)
prompt_messages.append(user_prompt_message_2)
assistant_prompt_message_2 = ChatModelMessage(
    role=PromptMessageRole.ASSISTANT, text=QUESTION_CLASSIFIER_ASSISTANT_PROMPT_2
)
prompt_messages.append(assistant_prompt_message_2)
user_prompt_message_3 = ChatModelMessage(
    role=PromptMessageRole.USER,
    text=QUESTION_CLASSIFIER_USER_PROMPT_3.format(
        input_text=input_text,
        categories=json.dumps(categories, ensure_ascii=False),
        classification_instructions=instruction,
    ),
)
prompt_messages.append(user_prompt_message_3)
return prompt_messages

可以看到先获取system的prompt,然后附加了两轮对话。这个prompt和这两轮对话如下:

QUESTION_CLASSIFIER_SYSTEM_PROMPT = """
    ### Job Description',
    You are a text classification engine that analyzes text data and assigns categories based on user input or automatically determined categories.
    ### Task
    Your task is to assign one categories ONLY to the input text and only one category may be assigned returned in the output. Additionally, you need to extract the key words from the text that are related to the classification.
    ### Format
    The input text is in the variable input_text. Categories are specified as a category list with two filed category_id and category_name in the variable categories. Classification instructions may be included to improve the classification accuracy.
    ### Constraint
    DO NOT include anything other than the JSON array in your response.
    ### Memory
    Here is the chat histories between human and assistant, inside <histories></histories> XML tags.
    <histories>
    {histories}
    </histories>
"""  # noqa: E501

QUESTION_CLASSIFIER_USER_PROMPT_1 = """
    { "input_text": ["I recently had a great experience with your company. The service was prompt and the staff was very friendly."],
    "categories": [{"category_id":"f5660049-284f-41a7-b301-fd24176a711c","category_name":"Customer Service"},{"category_id":"8d007d06-f2c9-4be5-8ff6-cd4381c13c60","category_name":"Satisfaction"},{"category_id":"5fbbbb18-9843-466d-9b8e-b9bfbb9482c8","category_name":"Sales"},{"category_id":"23623c75-7184-4a2e-8226-466c2e4631e4","category_name":"Product"}],
    "classification_instructions": ["classify the text based on the feedback provided by customer"]}
"""  # noqa: E501

QUESTION_CLASSIFIER_ASSISTANT_PROMPT_1 = """
```json
    {"keywords": ["recently", "great experience", "company", "service", "prompt", "staff", "friendly"],
    "category_id": "f5660049-284f-41a7-b301-fd24176a711c",
    "category_name": "Customer Service"}
```
"""

QUESTION_CLASSIFIER_USER_PROMPT_2 = """
    {"input_text": ["bad service, slow to bring the food"],
    "categories": [{"category_id":"80fb86a0-4454-4bf5-924c-f253fdd83c02","category_name":"Food Quality"},{"category_id":"f6ff5bc3-aca0-4e4a-8627-e760d0aca78f","category_name":"Experience"},{"category_id":"cc771f63-74e7-4c61-882e-3eda9d8ba5d7","category_name":"Price"}],
    "classification_instructions": []}
"""  # noqa: E501

QUESTION_CLASSIFIER_ASSISTANT_PROMPT_2 = """
```json
    {"keywords": ["bad service", "slow", "food", "tip", "terrible", "waitresses"],
    "category_id": "f6ff5bc3-aca0-4e4a-8627-e760d0aca78f",
    "category_name": "Experience"}
```
"""

QUESTION_CLASSIFIER_USER_PROMPT_3 = """
    '{{"input_text": ["{input_text}"],',
    '"categories": {categories}, ',
    '"classification_instructions": ["{classification_instructions}"]}}'
"""

可以看到整体的信息量很大,所以这个环节才会比较耗时。

智能回复3.0

考虑到问题分类其实就是一次LLM模型调用,所以我不打算修改源码,用一个LLM环节来代替,并使用新的prompt。整个环节如下:

全流程单知识库3.0(llm分类).jpg

对于这个LLM环节中的prompt,我放弃了dify自带的,而是重新编写了一份,并反复测试优化,最终大概如下:

请根据你提供的分类列表(课程相关问题、打招呼),对用户输入的文本进行准确分类。仔细阅读用户输入的文本,并判断其最符合哪个分类。如果文本涉及多个分类,请选择最主要的分类。确保你的分类结果清晰、准确,并能反映用户输入文本的主题和内容。

最终优化后这个环节的速度提升到0.7秒左右,又降低了近一半耗时。同时对问题提取的prompt也进行了优化,版本如下:

请仔细阅读下面的用户输入,并从中提取出所有的问题。保留问题的原始形式和标点符号,然后以列表的形式输出,每个问题用双引号包围,问题之间用逗号隔开。例如:

["这是一个问题吗?","另一个问题是这样的?"]

最终整个流程单个问题的速度提升到2秒以内。

智能回复4.0

3.0版本的速度虽然大幅提高了,但是我感觉还不够,于是继续在流程上死磕。考虑上一版流程中问题分类和问题提取都是使用LLM模型来实现的,这样就需要发两次请求,那么能不能整合到一起呢?于是设计了第四版流程,如下:

全流程单知识库4.0(llm分类提取).jpg

这里可以看到只有一个LLM节点,问题分类和提取都在这个节点中完成。将两个prompt整合并反复测试优化,目前的版本是:

请根据以下分类列表对用户的输入进行分类,然后根据分类结果执行相应的操作:

如果分类是“打招呼”,则输出 []。

如果分类是“课程相关问题”或“已报名”,则从用户输入中提取所有问题,保留问题的原始形式和标点符号,然后以列表的形式输出,每个问题用双引号包围,问题之间用逗号隔开。

分类列表:

课程相关问题

已报名

打招呼

示例:

用户输入:“你好,很高兴见到你!”

输出:[]

用户输入:“这门课的考试时间是什么时候?还有作业截止日期是哪天?”

输出:["这门课的考试时间是什么时候?","还有作业截止日期是哪天?"]

整合后问题分类+提取这个节点总耗时降低至0.7秒,之前两个单独节点总耗时约1.3秒左右,又降低了一半。

最终整个流程在单个问题情况下耗时压缩到1秒左右。对比最初的版本,耗时降低至1/10,响应速度提升非常显著。

但是问题的分类和提取的准确性会有些许降低,这个就是如何去平衡准确和效率,第二版本的准确度是最高的,但是效率差一些,所以要根据项目的情况来选择。

模型选型

在确定这个流程后,根据公司的对接情况,又对OpenAI、百度千帆、阿里千问、腾讯混元这四家主要模型共十几款重新进行测试,包括速度、准确度、并发能力等:

  • 百度千帆:速度快(尤其新模型ERNIE-Speed-Pro-128K),但是效果差,易发散
  • 腾讯混元:速度慢,效果差,易发散
  • OpenAI:速度中等,效果可以
  • 阿里千问:速度中等,效果好(比OpenAI好一点),但是并发差

具体数据如下: 模型选型.jpg

最终决定使用gpt-3.5-turbo,并且考虑qwen2.5-14b-instruct或qwen2.5-72b-instruct备用(并发低可以通过多个千问账号在OneApi创建多个渠道,通过OneApi自带的负载均衡解决)

ERNIE-Speed-Pro-128K

这里提一下百度的ERNIE-Speed-Pro-128K,相比ERNIE-Speed-128K无论是速度上还是效果上都有很大的提升,尤其速度对于其他模型非常明显。在某些情况下可以考虑,比如dify自带的问题分类节点,效果还可以,速度很快。

经过测试,在第一版流程中的问题分类环节使用它,并且在问题提取环节使用优化后的prompt,整体速度提升明显。尤其是如果问题分类环节拦截50%左右的情况下,整体效率能够媲美第四版流程。

重排优化

最后再提一下知识库重排序,这里也是一个优化点。

知识库检索这个节点中是有两个步骤的(这部分比较复杂,我会单开一篇来细说)

  • 第一步先通过text-embedding将输入向量化,然后从知识库的向量数据库中获取topK问答
  • 第二步还需要进行重排再输出。这个重排序就是知识库检索节点中的召回设置

重排序有两种方式

  • 权重重排:第一步检索出的数据得分 和 通过JIEBA分词关键字得分,然后根据权重比例重新计算得分
  • rerank:使用rerank模型对第一步检索出的数据重排序

所以权重重排耗时要比rerank少,因为不需要进行模型请求。

如果text-embedding模型效果不理想,第一步得出的数据得分与实际相差较大,就需要使用rerank模型。

如果text-embedding模型效果好,第一步得出的数据排序基本正确,那么就可以使用权重重排。

比如在上面流程中,一开始由于text-embedding模型没确定,换来换去导致检索效果比较差,所以使用rerank模型(bce-reranker-base)进行重排,问题是rerank模型这一步耗时200ms左右。

后来固定使用bge-large-zh模型,发现本身效果不错,于是改成权重重排,这部分耗时很少。如果后续效果不理想,再换回rerank重排。

总结

智能应答优化暂时告一段落,后续会在真实场景中小范围跑上一段时间看看效果,并持续进行优化。后面有更多的优化的话我会再重新开一篇来说。

文中OpenAI相关模型都涉及到国内转国外,所以速度相比在外面直接访问要慢一些。