查看原文
其他

LLM Decoder DSL 的设想

孔某人 孔某人的低维认知
2024-08-22

1、前言

本文介绍一种新的设想,用于在LLM调用密集的场景中降低推理成本并改善KV Cache的利用率,并可以以此为基础提供decode阶段的其他增量功能。这种方式需要重新设计LLM API的调用方式,需要LLM供应商和应用开发者两边的配合,但我认为它对整个生态的价值是很显著的。

该方案的价值基于以下假设:

  • 【A1】中高质量的LLM应用在单次请求中会需要越来越多的LLM调用

  • 【A2】A1中说的大量LLM调用之间经常会共享相同的context

  • 【A3】LLM的推理成本在未来一段时间内无法降低到足够低,在上述条件下有着明显的通过其他手段降低LLM推理成本的需求。

  • 【A4】LLM API供应商自己发现这些重复的计算在工程架构上实现成本相对较高,且不好对命中部分缓存时进行定价。而基于LLM API的应用开发方则明确的知道哪些请求调用之间有可以共享的部分,可以将这些信息提供给LLM供应商。

这里【A2】并非普遍共识,【A4】可能有人会表示反对,但本文是基于这些成立的前提来讨论的。

2、相同context但任务不同的多次LLM调用

单从一般业务场景来想,处理的context相同的场景并不算多,一个典型的例子是论文的总结/QA场景:论文就是那些,无论是哪个用户使用,context中要放入的论文都是从一个固定的库来的。无论用户的问题有多大不同,对于LLM的调用来说,前面论文对应的公共token占比都是很大的,很适合缓存下来。

但本文要讨论的场景并非这种。

2.1、业务流程上的固定串联 与 Decoder DSL

为了描述本文讨论的应用内部调用模式,会举几个例子来说明。

首先考虑一个并非低延迟需求chatbot产品场景,包含如下产品feature:

  • 【S1】调用LLM输出本轮对话的反馈

  • 【S2】调用LLM对S1步骤输出的内容进行检查,看是否命中某些类型问题

  • 【S3】在S1的结果之上要求LLM生成几个用户后续可能的提问并显示给用户。

现实很多chatbot并不是这么做的,例如说S2用的不是LLM,S3用的不是LLM。不过这里为了举例,特意选择了一个简单的场景方便读者进行想象,请不要在这里需求上纠结。

在流程上S1、S2、S3的调用是固定触发的,S2和S3没有相互依赖,但它们都依赖S1的结果。对于应用开发者来说,这一组调用的context共享了之前的对话历史,S2和S3还共享了S1的结果。这对于开发者来说是很容易发现的。

但对于LLM供应商来说,是否容易发现这三个请求的context可以共享计算结果呢?并不容易,因为:

  • LLM API调用本来是一个无状态的服务,可以使用很简单的负载均衡策略。这会导致本来一组的3个请求可能会被分发到多个计算节点上。

    • 如果想要在各个请求之间共享部分计算结果,就需要把KV Cache从显存导出到外部cache存储中,在命中缓存的时候再从外部存储加载到显存中。如果计算成本很高而需要存储的结果很小时,这个方案是可以的。但KV Cache是相对较大的,明显划不来。

  • 即使考虑根据请求prompt的公共前缀进行路由,把接近的请求路由到同一个计算节点上,这也会有问题:

    • 请求之间的可共享性跟各节点的计算负载平衡往往不完全一致,这给负载平衡带来了不小的麻烦。

    • 即使已经解决了前面的问题,能够确保未来能共享context的请求也一定会路由到同一个计算节点上。那么仍然存在这个KV Cache的数据到底要保存多久、在哪里保存的问题。显存是宝贵的,如果不能很快的等到下一次请求,那么它应该被转移到成本更低的内存甚至磁盘上,但数据的来回交换的成本也很显著。而如何知道是否会有下一次接近的请求,且这个请求什么时候能够到来呢?在目前的分次调用的场景没有任何信息能够准确的给出提示,即使使用历史数据对其进行预测,也很难有一个预测准确率足够高的方案。当上层策略相对复杂时,这个预测会变得更加困难,在下一节会有一个例子。

这里最佳的方案就是API请求方直接把三个请求打包一起发过来,并指定了相互之间的依赖关系。LLM的decoder直接按照请求描述选择合适的方案进行计算,整个请求完成之后就可以简单地丢弃所有KV Cache,没有困难的预测问题,也不会有复杂的缓存系统、额外的数据存储成本和缓存不会被用到的风险。这种指定请求之间的依赖关系的方式以下称之为Decoder DSL,顾名思义它实际上是一种领域特化的编程语言。

参考guidance提出的一种语法,可以构建一个简单的Decoder DSL来描述3个顺序的调用:

User: xxxxx<|im_end|>Assistant: xxxx<|im_end|>
User: xxxxx<|im_end|>Assistant: {{name=Answer, end=<|im_end|>}}
System: 上述Assistant的回答是否违反了xxxxx?<|im_end|>Critic: {{name=CriticResult, value=['Yes', 'No'], end=<|im_end|>}}
System: 用户接下来可能会有哪些问题?<|im_end|>Copilot: {{name=SuggestQuestionList, type=list, end=<|im_end|>}}

这只是示意的例子,并不太严格。这种template的方式只适合于context顺序递增的场景,但这个限制比较强。例如上面S2和S3环节其实可以并行进行,没有相互之间的依赖,这时候就需要一种描述能力更强的Decoder DSL了,可以参考python的风格写一个样例:

message_list = [{'role': 'user', 'context': 'xxxx'},{'role': 'assistant', 'context': 'xxxx'},{'role': 'user', 'context': 'xxxx'},]
answer_message = llm_chat(model_name, message_list, temperature=xxx, xxxx)message_list2 = message_list + [answer_message ]
yield { 'answer': answer_message }
StartNewJob: # 相当于启动了一个新线程进行执行 critic_message_list = message_list2 + [{'role': 'system', 'context': 'xxxx'}] critic_result = llm_chat(model_name, critic_message_list, result_value=['Yes', 'No']) yield { 'critic': critic_result }
StartNewJob: copilot_message_list = message_list2 + [{'role': 'system', 'context': 'xxxx'}] copilot_result = llm_chat(model_name, copilot_message_list, result_type=list) yield { 'copilot': copilot_result }
# 返回结果# LLM API会返回3条消息# 第1条是 answer 的结果# 第2,3条 是 critic 和 copilot 的结果,顺序不确定

虽然这看起来很像是应用中调用LLM的代码,但这是一段示意的Decoder DSL,要整体发给LLM API。代码中通过yield方式来指示阶段性的返回结果,对于流式输出场景也可以设计语法指定返回某个值的流式结果序列,例如:

result_stream = llm_chat(model_name, message_list, stream=True)result_buffer = ''for chunk in result_stream: result_buffer += chunk yield { 'field1': {'content': result_buffer, 'is_end': False }}yield { 'field1': {'content': result_buffer, 'is_end': True }}

2.2、更复杂的workflow样例

上一个小节的例子只有3次调用,并且核心主逻辑只有第一次的LLM调用,后面更多是一些辅助性的环节。实际业务中可能会有很复杂的场景,下面展示一个更复杂的逻辑的示例:

message_list = [{'role': 'user', 'context': 'xxxx'},{'role': 'assistant', 'context': 'xxxx'},xxxxx,{'role': 'user', 'context': 'xxxx'},]
StartNewJob: # 提取特征1 feature1_message_list = message_list + [{'role': 'system', 'context': 'xxxx'}] feature1_result = llm_chat(model_name, feature1_message_list)['context'] yield { 'feature1': feature1_result }
StartNewJob: # 提取特征2 # RAG等非纯粹LLM的调用也可以封装在DSL内 feature2_result = rag(model_name, message_list)['context'] yield { 'feature2': feature2_result }
# 从语义上也可以省略显示声明waitwait feature1_result, feature2_result
# 省略细节message_list3 = gen_input(message_list, feature1_result, feature2_result)
result = llm_chat(model_name, message_list3)yield { 'result': default_result }

这里同时启动两个特征计算任务,等到它们然后再生成最终结果。这种并行计算很多特征/中间结果然后再汇总处理的方式不少场景都需要,特别是在能够节省掉公共context部分的计算成本的时候,这样就可以不受限于单路LLM的输出速度,加速中间结果的生成。

如果业务场景希望尽可能压缩总响应时间,并且愿意为此多支付推理成本,可以使用我称为【workflow的投机执行】的方式,大概类似于下面的方式:

message_list = [xxxx]
StartNewJob: # 默认的处理逻辑 default_result = llm_chat(model_name, message_list) yield { 'default': default_result }
StartNewJob: # 提取特征1 feature1_message_list = message_list + [{'role': 'system', 'context': 'xxxx'}] feature1_result = llm_chat(model_name, feature1_message_list)['context'] yield { 'feature1': feature1_result }
StartNewJob: # 提取特征2 feature2_message_list = message_list + [{'role': 'system', 'context': 'xxxx'}] # RAG等非纯粹LLM的调用也可以封装在DSL内 feature2_result = rag(model_name, feature2_message_list)['context'] yield { 'feature2': feature2_result }
StartNewJob: wait feature1_result if feature1_result.contains('xxxx'): yield { 'feat1_found': 1 } default_result.kill() wait feature2_result message_list2 = gen_input2(message_list, feature2_result) # 为了篇幅这里省略 final_result = llm_chat(model_name, message_list2 ) yield {'result2': final_result } else: feature2_result.kill() final_result = default_result
wait final_resultyield { 'final': final_result }

流程描述:

  • 同时启动计算 default_result 、feature1 、feature2

  • feature1结果出来后,如果命中条件,则取消 default_result 的继续计算,等待feature2的结果,再触发result2的计算

  • 如果未命中条件,则取消对于feature2的计算

目前真的需要投机执行的业务场景并不多,一般是极端延迟敏感的场景才需要,如通过电话与客户沟通的机器人。

2.3、总结

以上2个例子这种方式靠传统方式调用LLM API一样可以实现,但当公共的context部分很大的时候,节省掉重复的推理费用就变得非常重要。需要1M token context的场景可能就很需要这种API调用方式。

对于像这样稍微复杂一点的逻辑,如果还是使用传统的单次调用方式,在LLM服务端也很难可靠地预测下一个请求会不会有、以及到底什么时候能到达。

从调用的角度来说,Decoder DSL和基于Session的API很类似。但正如前面所提到的,之所以要把请求逻辑一起发送给LLM API,是为了消除LLM 服务端预测下一个请求到底会什么样的种种问题。所以从这个角度上来说,Session API也解决不了这个问题,就是需要把整个逻辑一次性发给服务端。

3、LLM供应商和应用开发者 为什么会接受Decoder DSL

前面已经提到这个功能会影响到LLM供应商和应用开发者,想要让他们接受这种变化就需要足够的好处。那么这个设计对他们有哪些好处呢?

对于LLM供应商

  • 能够收集到更多应用层的业务逻辑。这些数据可以成为未来功能演进方向的指导

  • 能够利用业务workflow中的后续环节,来获取涉及多次LLM调用的特定场景的更高质量合成数据。

  • 可以针对workflow的逻辑,提供多次LLM调用的结果合成为一次微调模型调用的【跨多次调用的智能微调服务】

  • 长期来说,可以降低应用层的成本,对于增加整个上层生态的规模是正向的。


对于应用层开发者

  • 主要是推理成本的降低,一旦有API供应商或者开源推理方案提供了这种功能,推理成本的压力就会把应用开发者推向这边。

    • 特别是对于共享较大context的多次LLM调用场景,现在的方案相对于这种方案在LLM推理费用很难承受。

    • 以目前LLM供应商的开发能力,指望在现有API接口上做出重复计算消除并能把这部分费用节省让利给应用层开发者在1-2年内是不太现实的。

  • 虽然会把一部分业务逻辑暴露给LLM供应商,但面对成本问题的时候没得选,要么接受,要么就得放弃这种技术路线甚至场景。

  • 在有了这种DSL之后,能够更加期待LLM供应商提供的DSL优化服务。获得更优化的DSL实现或者微调模型后,能够直接替换调用处,不必修改代码主逻辑。

Decoder DSL可以只看成一个开始,在DSL中可以组合进计算器、RAG、简单搜索甚至Python runtime等其他功能。甚至最终达到在DSL中直接并行解码出一个树结构或图结构,并能对这种结构化数据结构进行直接处理。最终LLM API就变成一个更完整的计算语言的runtime。

4、数据库 存储过程

存储过程见 https://en.wikipedia.org/wiki/Stored_procedure

DB的存储过程是一个很上古的概念,因为这是在MySQL流行之前才普及的技术,现在更多在一些基于传统商业数据库(如Oracle等)的方案中才使用的。现在跳这一轮LLM浪潮的人连传统SQL数据库的功能都未必熟悉,更别说这种高级特性了。

知道存储过程的读者应该在第2节就有浓浓的既视感了,其实挺像的,或者说上述Decoder DSL做的事情相对于存储过程来说仍然只有一小部分。毕竟存储过程看名字就能猜到是存储在数据库服务端的,而上述的Decoder DSL仍然是默认每次由用户端发送到服务器端的,没有讨论说是否应该存储在LLM服务端等等问题。

LLM API服务本质上还是无状态的服务,距离数据库这种强有状态的系统的设计还是有不小的差别的,从本文的角度上来说展开这方面的信息并非核心。不过我建议对于Decoder DSL的设计有兴趣的同学去了解一下之前的存储过程的主要功能和设计。虽然现在LLM API从本质上还是无状态的,但未来长期很可能会变成有状态的通用语义执行环境。

交流与合作

如果希望和我交流讨论,或参与相关的讨论群,或者建立合作,请私信联系,见 联系方式

希望留言可以知乎对应文章下留言

本文于2024.3.27首发于微信公众号与知乎。

知乎链接 https://zhuanlan.zhihu.com/p/689394945

个人观点,仅供参考
修改于
继续滑动看下一个
孔某人的低维认知
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存