1.背景
1.1 序言
大模型的响应速度(首包和全文),直接影响用户使用体验。现阶段来看,想要有较高的响应性能,我们可以选择小尺寸模型,但意味着复杂场景下效果得不到保障;可以让大模型“吐”更少的字,但意味着不能完成更多的任务。对于大模型来说,速度、效果、功能似乎是一个“不可能三角”。
本文主要分享我在实现大模型场景过程中,对于任务型应用,在保证一定效果的前提下,站在开发者角度如何提升响应速度和完成更多任务的几个思路。
任务型应用,主要指帮助用户完成特定任务,例如预约餐馆、预定机票、给出规划等,与用户的交互大多是结构化的,我们通常要求它输出结构化数据以便进行下一步处理。相应地,生成式对话应用我们称之为“闲聊”,其主要目的是进行自然的、开放式的互动,根据给定的对话历史和上下文信息生成连贯的自然语言回复,而不是完成具体任务。
先说结论,后文再根据实际场景案例再做详细说明:
- prompt约束输出结构,减少输出token;
- 分解任务,大小尺寸模型分工;
- 流输出,截取信息异步/并发处理;
- 提前约定,以短代号映射长结果;
1.2 场景案例描述
以基于大模型做一个行程规划推荐应用的场景为例,用户通过ASR语音输入“帮我规划五天的浙江行程,杭州玩两天吧”,希望得到:
- 每天要游览的地点名;
- 每个地点的简短介绍;
- 游玩城市与天数计划;
- 地点的poi信息(Point of Interest,泛指互联网电子地图中的点类数据,基本包含名称、详细地址、经纬度坐标、地点类别四个属性,以及营业时间、人均消费,图片示例等信息);
- 前后地点的行驶距离与时间;
- json结构化结果,以便解析在页面上展示;
分析这个场景需求,需要大模型输出的任务有根据用户意图,生成推荐的地点名、地点的介绍、对应的游玩城市与天数。需要调用外部API获取的有,地点poi信息、前后地点的行驶距离与时间。简单设计后约定客户端服务端这样进行交互:
a.客户端乘客语音输入“帮我规划五天的浙江行程,杭州玩两天吧”,调用服务端接口;
# 请求 { "query": "帮我规划五天的浙江行程,杭州玩两天吧", "history": [] }
b.服务端内部根据用户意图,输出一天的行程地点、游玩计划的JSON结构化数据。例如以下返回结果表示当天游览城市为杭州,一共规划2天杭州、1天绍兴、2天宁波。这一天去到地点A-E五个地方,且包含该地点的poi信息。
{ "plan": { "杭州": 2, "绍兴": 1, "宁波": 2 }, "city": "杭州", "locs": [ { "name": "地点A", "time": "上午", "address": "xxxx", "longtitude": 120.0000, "latitude": 30.00000, "pic_url": "http://xxx.jpg" }, { "name": "地点B" }, { "name": "地点C" }, { "name": "地点D" }, { "name": "地点E" } ] }
c.客户端获取到结果,渲染第一天的行程展示。
d.同时,根据游玩城市和天数,客户端将历史行程添加为参数继续调用服务端接口,获取下一天不重复的行程。
# 请求 { "query": "帮我规划五天的浙江行程,杭州玩两天吧", "history": [] }
e.以此类推,直到遍历完城市和天数意图。
2.优化思路详解与示例
2.1 思路1:prompt约束输出结构,减少输出token
全文响应时间和输出token数量是正相关的。我们可以仔细考虑大模型输出的数据结构,在prompt里加以约束与few shot,让它输出核心必要的字符,减少冗余信息的输出,来减少大模型的全文响应时间。
举个极端的反例,就“帮我规划五天的浙江行程,杭州玩两天吧”这个问题,让大模型输出游玩计划,以及一天的行程地点,返回这样的结构:
{ "plan": [ { "city": "杭州", "days": 2 }, { "city": "绍兴", "days": 1 }, { "city": "宁波", "days": 2 } ], "city":"杭州", "locs":{ "上午": "西湖", "中餐": "知味观", "下午": "宋城景区", "晚餐": "老头儿油爆虾", "住宿": "杭州君悦酒店" } }
使用token计算器模型服务灵积-Token计算器 (aliyun.com),上述文本包含122tokens。这样的结果存在很多冗余信息(空格符和换行符也占token的!)有一些字符完全没有必要让大模型输出。
我们来改造一下,在prompt里约定大模型类似这样返回:
杭州:西湖,知味观,宋城景区,老头儿油爆虾,杭州君悦酒店\n plan:杭州2,绍兴1,宁波2
“杭州”这个key表示当前城市,value地点顺序即为依次游览的地方(没有广告费哈,通义千问给的)。plan中输出城市与对应游览天数,把不必要的换行和空格可以去掉,这样基本上不存在太多冗余信息。
因为我们在prompt里约束了返回的结构,也能很方便的解析出依次游览的5个地点以及游玩城市及其对应的天数等这些所需要的内容,再进行计算只有33tokens了,没有损失输出信息的同时大大减少输出token量,也提高了不少响应性能。当然我举的反例比较极端和夸张,但这样的思路是完全可以移植到其他场景中的。
减少输出token,是提升全文响应性能最直接的方式之一,可以提前在prompt中进行约束,让尽量少的输出token可以包含尽量多的信息。
2.2 思路2:分解任务,大小尺寸模型分工
示例一,我们来继续看行程规划的需求,还需要有对于这个地点的简短介绍。在上文中,我们基于qwen-plus输出一天行程地点和整体计划以保证效果。如果现在增加一个输出地点介绍的任务,让qwen-plus来做未免“大材小用”,还会平添大几十token输出,响应速度变得更慢。
输出地点介绍这样的简单任务我们基于qwen-turbo就能完成。即qwen-plus规划输出地点后,再基于qwen-turbo生成该地点的简单一句话简介。
做个实际对比,相同的prompt下分别调用qwen-plus和turbo,各自设定seed与temperature,让每次输出内容尽量一致。可以看到qwen-turbo在输出token较多的情况下响应时间还比qwen-plus快了0.4秒,效果上两者都可接收。
prompt:用一句话介绍这个景点或餐饮店:宋城景区,不超过20字
模型 |
qwen-turbo |
qwen-plus |
输出 |
宋城景区:重现宋代风貌,融合历史、文化与娱乐的旅游胜地。 |
宋城景区:穿越千年,梦回大宋,感受宋代风情。 |
调用20次平均输出tokens |
18.3 |
16.0 |
调用20次平均响应时间 |
0.81s |
1.20s |
并且用qwen-turbo输出景点一句话介绍可以异步执行,不会影响到qwen-plus输出规划地点这一主线任务的时间。
游玩计划也可以用qwen-turbo来完成,但可能出现的情况是,用户输入“帮我规划两天云南行程”,turbo输出计划:在昆明玩两天,但plus输出在丽江的一天行程,这样就前后矛盾了。因此为了保证一致性,最终还是决定将游玩计划和当天行程地点一同使用qwen-plus输出。
示例二,在利用qwen-vl提取图片信息场景中(如下图所示),如果调用一次VL大模型一次性返回所有字段的结构化信息,如果遇上一些项目栏中文字很长,那么整体返回的时间就会非常长。
我们可以考虑根据字段的长度合理拆分任务,并发调用多次VL大模型,每次让大模型输出的字符各不相同,最后再把结果拼凑起来,这样响应速度则只和输出字数最多的那一栏(申诉说明)有关。当然这样的方式会使得输入token翻数倍,需要基于实际情况做最终取舍。
system_prompt_1 = ''' #任务要求 帮我识别出图片中的运单号,最终只输出运单号, 没有运单号则输出无,不要再输出其他内容 ''' system_prompt_2 = ''' #任务要求 你是一个图片识别专家,请提取图中的信息, 按顺序输出申诉人姓名,申诉角色,申诉编号,申诉日期,用逗号隔开 ''' system_prompt_3 = ''' #任务要求 你是一个图片识别专家,请提取图中的信息, 只输出申诉说明,不要再输出其他内容 ''' system_prompt_4 = ...
总之,在需要大模型完成多个任务、输出多种内容时,可以考虑合理分解任务,大小尺寸模型分工实现,但前提是分工不能产生前后内容冲突。
2.3 思路3:流输出,截取信息异步/并发处理
在上面两个思路介绍中,行程规划场景我们已经设计好了大模型要完成的任务、要输出的数据格式,我们还需要调用外部的API接口,传入参数地名来获取该地的poi信息。那是否需要等待大模型将5个地点都给出来后再调用查询呢?
并不需要。我们让输出行程地点任务的大模型流式输出,在循环接收响应的同时不断进行正则匹配等方法即时提取出地点地名,便可以通过异步/并发的手段去查询poi接口以及调用qwen-turbo输出一句话介绍。
如下图所示流程,在得到所有结果并完成json组装后,就可以按顺序将seq 1、seq 2、seq 3...立即返回给客户端,在屏幕上渲染展示给用户,减少等待时间,提高了用户体验。
这个思路可以抽象为,我们将大模型的流式输出,加上其他自定义逻辑,转换成另一种流式输出返回给用户。
2.4 思路4:提前约定,以短代号映射长结果
这个优化思路主要应用于让大模型分类、做选择题、输出列表结果等场景,在prompt中我们向大模型提供若干个候选集,基于约束来让大模型从候选集选出一个结果。
思路被启发到的是以前项目中使用到的Protocol Buffers(protobuf)数据序列化格式,这个数据格式的思想是说我们提前约定好一个数据结构(.proto文件),第一个字段是经度,第二个字段是纬度,第三个字段是速度等等,传输的时候就不用再longtitude=xxx、latitude=xxx,直接根据预定义的结构编号读写数据,省去了许多动态解析的开销,因此在各种需要高效、跨平台的数据交换和存储场景中都表现出色。
示例一:让大模型做一道选择题:
#角色 你是一位语言专家,请结合上下文语境,从备选项中选择一个最适合将空白处补足的语句。 结果不允许编造,只能从备选项中返回一个,只用返回序号和语句,不必再回复其他信息。 如果没有合理的,请返回无法选择。 #问题 xxxxxxxxx(___补足处____)xxxxxxxxxx #备选项 1.xxxxxxxxx 2.xxxxxxxxx 3.xxxxxxxxx 4.xxxxxxxxx 5.xxxxxxxxx 6.xxxxxxxxx 7.xxxxxxxxx 你的回答:
大模型最终会输出“2.xxxxxxx”。而我们使用流式输出,获取到前两个tokon"2."时,就已经可以通过正则匹配出序号"2",从而得到后面的完整地址了(因为候选集是我们本地组装出来的),不必再等待最后的结果,或者你在prompt中限制只输出序号1/2/3/4也行。
import dashscope import re response = dashscope.Generation.call(model=model, messages=messages, temperature=0.01, stream=True, incremental_output=False, result_format='message') # 流式非增量输出 for res in response: content = res.output.choices[0].message.content # 匹配无法选择 if '无' in content: return '无法选择' # 匹配数字 pattern = r"(\d+)\." match = re.search(pattern, content) if match: number = match.group(1) # 输出:2 return number
示例二,在信息提取判断场景,需要对内容进行多个维度的判断,输出yes或no以及no的原因,比如以下prompt片段:
#任务 你是简历筛选专家,你需要仔细分析【输入简历】,严格依照【筛选标准】 对列举的每条标准依次进行判断是否符合。 #筛选标准 1.学历要求:xxx 2.工作经验:xxx 3.技术技能:xxx 4.语言能力:xxx ... ...
如果在输出中,我们把每个筛选维度的中文当作key输出,那会平白多出好几十个字,完全没有这个必要,因为可以用代号+字典的方式解决。我们再把以下提示词片段加到上面的prompt后面,这样输出的json结果中,key为标准对应的序号1/2/3/4,我们在本地代码中再把全称等给解析出来,节省了大几十个汉字的输出。
返回格式 结果以JSON格式返回,y表示符合,n表示不符合。 如果为n,紧跟n后给出判断依据,但不要编造依据!此外不必再返回其他内容。 #返回示例,按照筛选标准对应的序号作为key 1: y 2: n(要求至少3年相关领域工作经验,但申请者仅有1年经验) 3: y 4: y ...
核心思路是,如果可以通过少量的输出就能得到完整的结果,那就不必等待完整的输出。
3.总结
本文基于实际场景,分享了作为开发者提高大模型响应性能的四个实用方法。这些思路具有广泛的适用性,适用于多种场景。核心理念总结为:减少输出token、选择合适尺寸的模型以及采用流式输出。
来源 | 阿里云开发者公众号
作者 | 舟谨