LLM服务提效实操:20分钟搞定成本直降40%

大家好,我是你们的老朋友,也是在跨境圈摸爬滚打多年的老兵。今天咱们不聊市场风口,不谈流量转化,咱们聊聊大模型(LLM)背后那些能帮咱们省钱提效的“硬核”技术。你是不是也发现,现在用AI聊天机器人,比如国内的通义千问,或者国际上的Claude这类产品,每次提问后,总是要等那么一小会儿第一个字才蹦出来,然后后面文字就像“下饺子”一样,一个接一个地往外冒,速度还挺快?
这背后的秘密,咱们今天就来好好拆解一下。说到底,所有的大语言模型,本质上都是在做一件事——预测下一个最可能出现的“词”。
一个完整的生成过程是这样的:大模型首先会把你输入的整个问题(也就是“提示词”或“Prompt”)通读一遍,然后吐出第一个词(“Token”)。接着,它会把这个新吐出的词和之前所有的输入再一起读一遍,然后预测下一个词,如此循环往复,直到模型觉得生成结束。
这个生成过程,可不是件轻松活。每生成一个词,就意味着要让你的输入通过数以亿计,甚至千亿级的参数进行一次复杂的计算。特别是在咱们跨境业务中,面对海量用户请求时,如何让这些大模型既高效又稳定地提供服务,成了技术大牛们必须攻克的难题。而今天我们要讲的“连续批处理”(Continuous Batching),就是其中一个至关重要的优化法宝,它能最大限度地提升处理效率,让咱们的AI服务跑得更快、更稳。
想要深入理解“连续批处理”为何如此强大,咱们就得从大模型处理文本的基本原理,也就是它的“心脏”——注意力机制说起。
注意力机制:大模型的“读心术”
大模型处理文本,其实是把文字拆分成一个个小块,咱们管它叫“Token”,你可以理解成一个个单词或词组的碎片。对于每一个Token序列,模型都会预测下一个Token应该是什么。
模型内部很多操作是“Token级”的,意思是每个Token都是独立处理的,它的输出只依赖于自己的内容。比如层归一化或矩阵乘法就是这样。但光这样不行啊,咱们的句子中词语之间是有联系的,需要让它们“交流互动”起来。这时候,“注意力机制”就登场了,它就是让不同Token之间相互“看见”并“影响”对方的唯一桥梁。理解了它,就理解了大模型如何建立词语间的关联。
咱们来举个例子,假设你给模型的初始提示词是“我 对这个 项目 很有 信心”,这被拆成了7个Token:[ <bos>, 我, 对, 这个, 项目, 很有, 信心 ]。其中<bos>是一个特殊符号,告诉模型这是新对话的开始。
每个Token在模型里都对应一个特定长度的向量。所以,这7个Token就组成了一个形状像[1, 7, d]的张量(你可以把它想象成一个数据表格)。这里的1代表我们一次只处理一个句子(批次大小),7是Token序列长度,d是每个Token向量的维度。
输入张量接着会被投射成三个新的张量:Q(查询)、K(键)、V(值),它们都是形状像[1, n, A]的。咱们可以把这理解成,模型在问:“我(Q)想知道,之前哪些词(K)对我现在(Q)的理解最重要,它们的重要程度(V)是多少?”这在下面左边的图里有直观展示。
接着,Q和K进行相乘,用来衡量不同Token之间的相似度或相关性,生成一个形状像[1, n, n]的张量。这就是为什么咱们常说,注意力机制的计算成本跟序列长度n的平方成正比。计算QK^T大概需要O(n²d)次的运算。右边的图展示了这一过程。
然后,咱们会对这个QK^T的结果应用一个“注意力掩码”(Attention Mask),来控制哪些Token可以相互作用。看下面这张图,这个掩码是一种“因果掩码”(Causal Mask),意思就是,每个Token只能看到它自己和它之前的Token,不能“偷看”后面的Token。这很符合逻辑嘛,就像咱们说话一样,过去的事情影响现在,但现在的事情不能改变过去。这个掩码非常关键,它决定了模型里所有Token的交互规则。
最后,应用完掩码后,咱们对结果做一次Softmax操作(可以理解为把相关性分数归一化),再乘以V张量,就得到了注意力机制的输出。整个过程的概览,看下面这张图就能一目了然。
为了让咱们的讲解更聚焦核心,后面我会简化一下注意力机制的图示。在连续批处理中,Q、K、V这三者持有的Token数量可能会不一样,因为咱们会把不同阶段(预填充和解码)的请求混在一起处理。为了更通用地表示,咱们就只关注Q、K、V的长度可能不同。
有一点很重要:V和K的长度总是保持一致的。所以为了简化,咱们后面只会展示K。注意力掩码的形状也和QK^T相同,因为它也是点对点地应用到分值上的。因此,咱们不再专门画出注意力分值,而是直接用注意力掩码来代替。输入x也省略,因为它直接投射成了Q、K、V。
这样,咱们就得到了一个更简洁的注意力机制图示,只剩下Q、K和注意力掩码:
从这个简化图中,咱们也能更好理解注意力掩码怎么“读”。咱们一行一行地看,每行代表一个Token的注意力计算。绿色的格子表示“真”(True),意味着该行对应的Token能被该列的Token影响。白色则表示“假”(False),不允许交互。比如,第三行对应“对”这个Token,它的“我”这一列是绿色的,说明“我”会影响“对”的计算;而“很有”这一列是白色的,说明“很有”不会影响“对”的计算。这就是因果掩码的妙用:后面的Token不能影响前面的Token。
模型的最后一层会为每个输入Token输出一个预测值。在咱们生成文本的场景里,咱们只关心最后一个Token(比如“信心”)预测的下一个Token是什么(比如“将会”)。
咱们刚才描述的这个过程,也就是把一整个输入序列跑一遍,经过多层注意力计算,然后预测下一个Token,这叫做“预填充”(Prefill)阶段。为啥叫预填充呢?因为它会生成一些中间结果,这些结果可以被缓存起来,方便后续使用。有了这个缓存,后续生成新Token的阶段就叫“解码”(Decoding),会比最初的预填充快得多。咱们接下来就看看这是为啥。
如果咱们天真地继续生成,下一个Token“将会”的计算过程看起来是这样的:
你看,为了计算新Token“将会”的注意力分数,咱们还得重复计算前面所有Token(图中灰色部分)的K和V投影,但这些咱们之前已经算过一次了!这不是白白浪费算力嘛。那怎么避免这种浪费呢?
KV缓存:大模型的“记忆宫殿”
这里咱们马上就能发现一个关键点:新生成的Token,比如“将会”,它并不会影响前面那些Token([ <bos>, 我, 对, 这个, 项目, 很有, 信心 ])的注意力计算。
这正是因果掩码的作用:因为“将会”在时间上晚于所有之前的Token,所以它不会改变前面Token的注意力计算结果。在文本生成中,因果注意力是最常见的,所以咱们就以此为例。
既然咱们只需要预测“将会”这个Token的下一个Token,那么注意力机制的计算就可以大大简化,只针对这个新Token进行计算。更重要的是,前面那些Token的K和V状态,咱们在之前的预填充阶段就已经算出来了。如果能把它们存起来,就完全不需要重复计算了!
这就是“KV缓存”的精髓所在:它是一个列表,存储了在生成过程中创建的所有K和V状态。有了它,生成第n+1个Token的计算成本就能从O(n²)大幅降低到O(n),因为咱们省去了重复计算K和V投影的开销,付出的代价只是额外占用一些内存。
看上图,现在只有白色的Token(新生成的“将会”)需要计算K和V。相比于为8个Token都计算一次,现在只算1个,是不是一下子就节省了大量算力?
咱们来具体算算KV缓存需要多大的内存。对于一个拥有L个注意力层、H个注意力头,每个头维度为A的模型来说,存储一个Token所需的总缓存大小是2 * L * A * H(2是因为要同时存K和V)。比如,Llama-2-7B模型有L=32层,H=32个头,A=128的头维度,那么它为每个Token、每个层就需要2 × 32 × 128 = 8,192个数值。如果用float16精度存储,每个Token将占用2 * A * H * 2字节,也就是16KB的内存。这个数字看起来不大,但如果你的序列长度很长,用户并发量很大,那累计起来的内存消耗可是个大数字!
KV缓存不光在解码阶段(生成新Token)有用,在预填充阶段处理大量输入Token时,它同样能发挥巨大作用,特别是当咱们的初始提示词特别长,以至于GPU内存无法一次性装下时。
分块预填充:超长提示词的应对之道
前面咱们的例子里,初始提示词只有7个Token。但在实际业务中,比如做跨境内容审核、文档总结,或者你用一些高级AI工具,把整个产品说明书、甚至是一个代码仓库作为上下文提供给模型时,提示词的长度可能瞬间暴增。这时候,为了处理这海量的n个Token,所需的激活内存可能会超出GPU的承载能力。
面对这种情况,咱们就不能一次性把整个提示词都扔给模型进行预填充了。咱们得把它“化整为零”,分批处理。这就是“分块预填充”(Chunked Prefill)的思路,它可是实现高效推理的关键一环。
咱们假设GPU内存很紧张,每次只能处理m=4个Token。如果咱们有一个n=7个Token的初始提示词,那就需要分成⌈n/m⌉ = 2块(7除以4向上取整等于2)。咱们用下图来演示这个过程:
这一切都得益于KV缓存。在第一次分块预填充时,咱们把生成的KV状态存储起来。在第二次分块预填充时,咱们把之前存储的KV状态“拼接”到新的KV状态前面。注意力掩码也得跟着调整。从视觉上看,就像是把一次完整的预填充从中间截断成了两段。
核心要点是:KV缓存让咱们能够分批、逐步地处理超长提示词,而不会丢失任何上下文信息。虽然这里只演示了分成两块,但分块预填充可以灵活适应任何内存限制,想怎么分就怎么分。有了这个“大杀器”,咱们就离理解“连续批处理”的完整面貌不远了。
连续批处理:大模型服务系统的“加速引擎”
在咱们之前的例子里,咱们都只考虑了批次大小为1的情况,也就是一次只为一个请求生成Token。但在咱们的实际业务场景中,不管是做AI客服、内容生成还是数据分析,咱们往往需要同时为海量的用户请求提供大模型服务。为了最大化“吞吐量”(也就是每秒生成的Token数量),最有效的方法就是同时处理多个请求,也就是“批处理”。
最直接的批处理方法,就是在输入张量上增加一个批次维度。但是,这种方法有个硬性要求:所有请求的序列长度必须相同,因为张量得是规整的矩形。为了做到这一点,咱们通常会在较短的序列前面“填充”(Padding)一些特殊Token,直到所有序列都达到批次中最长的那个长度。同时,每个请求的注意力掩码也得相应调整。下图展示了填充后的样子:
图中橙色的就是填充Token <pad>。然后咱们就可以像以前一样进行前向传播,只是多了一个批次维度。这种方法叫做“静态批处理”,对于长度一致的请求很高效,但一旦长度不一,就会造成巨大的浪费。下图展示了四步生成过程:一步预填充(顶部)和三步解码(每条“前向传播”线下方)。
图中<eos>表示“序列结束”,告诉模型这个请求的生成已完成。静态批处理的弊端在于:如果批处理中的某个请求率先生成了<eos>Token,那么它后续生成的所有Token都是无用功。而且这种浪费会一直持续,直到批次中最长的那个请求也生成完毕。当然,咱们可以把已经完成的请求从批次中移除,从而节省一些计算和内存,但这并不能从根本上解决问题,因为咱们的核心目标是提升吞吐量。
咱们可以更进一步,把那些完成了的请求替换成正在等待处理的新请求。这种策略叫做“动态调度”或“动态批处理”。动态调度确实很棒,它能在保持吞吐量的同时,确保每次前向传播生成的Token都是有用的。但因为它仍基于传统的批处理方式,依然存在一个致命缺陷:在替换请求时,需要大量的填充。
这是因为新插入的请求需要进行预填充(处理整个提示词),而批次中其他请求还在逐个Token地解码。所以,新插入请求所需的填充量几乎等于它本身的Token长度。
当批次大小增加、初始提示词又很长时,这个问题会变得更糟。填充的开销会随着批次大小和提示词长度呈二次方增长。试想,如果一个批次中有B个正在解码的请求,其中一个完成了,咱们动态插入一个有n个初始Token的新请求,那可就需要(n-1) * (B-1)个填充Token啊!比如,B=8,n=100,那就需要99 * 7 = 693个填充Token!这白白浪费了多少算力?更别提一些实用的优化技术(如CUDA graphs或torch.compile)要求张量形状是静态的,这迫使咱们将所有请求填充到固定最大长度,进一步加剧了填充的浪费。
说到这里,咱们的核心问题已经很明确了——“填充”。而填充问题的根源,就在于咱们为了批处理而额外增加的那个批次维度。那么,最理想的解决方案,就是彻底抛弃这个批次维度,从根本上重新思考批处理的方式。
如果咱们没有了批次维度,那把多个请求批处理在一起的唯一方法,就是把它们“拼接”起来:
但问题来了,咱们肯定不希望请求0的Token和请求1的Token相互“串戏”,产生不该有的影响啊!幸运的是,咱们有办法控制Token之间的交互——那就是“注意力掩码”!
新媒网跨境获悉,具体实现方式如下:
尽管图中使用了不同深度的绿色来区分注意力掩码的不同部分,但它本质上仍然是布尔掩码:绿色表示“真”,白色表示“假”。这种将多个请求拼接在一起的批处理方式,被称为“非齐次批处理”或“锯齿形批处理”(因为序列长度不规整,像锯齿一样)。它的好处显而易见:在增加吞吐量的同时,彻底消除了填充Token的浪费!上图中,咱们将两个完整的请求拼接在一起,但在实际操作中,只要GPU内存允许,咱们可以拼接任意多个。唯一限制就是m,也就是单批次能容纳的Token总数,这取决于你GPU的内存大小。
“非齐次批处理”是“连续批处理”的核心组件之一。为了最大限度地提高吞吐量,咱们可以将预填充(Prefill)和解码(Decoding)阶段的请求混合起来处理,大致遵循以下算法:
- 咱们会尽量让批次内的Token总数达到咱们设定的内存预算
m。 - 首先,咱们把所有处于解码阶段的请求都加进批次,每个请求只占用1个Token(因为它只生成一个新Token)。
- 然后,用处于预填充阶段的请求来填补剩余空间。得益于“分块预填充”的灵活性,我们可以根据需要将长提示词进行拆分。
动态调度是“连续批处理”的最后一块拼图:一旦有请求完成生成,咱们就立即把它从批次中移除,并用等待处理的新请求(可能需要分块预填充)来替换它。这种“非齐次批处理”与“动态调度”的珠联璧合,就是如今现代化大模型服务系统背后的技术基石——“连续批处理”。
总结:实战中的大模型高效秘籍
各位朋友,大家看,连续批处理并非单一的魔法,它是多种高效技术的精妙融合,旨在最大化大模型服务中的吞吐量:
- KV缓存:避免重复计算历史Token的表示,节省了大量算力。
- 分块预填充:巧妙应对超长可变长度的提示词,突破内存限制。
- 非齐次批处理与动态调度:彻底告别填充浪费,让GPU时刻保持满负荷运转。
通过移除批次维度,并巧妙利用注意力掩码来控制Token之间的交互,连续批处理使得预填充和解码阶段的请求可以在同一个批次中混合处理,从而大大提升了处理多个并发请求的效率。这就是为什么像ChatGPT这样的服务能够高效地处理成千上万的并发用户。
在咱们这个系列的下一篇文章里,新媒网跨境会带大家深入了解通过“分页注意力”(Paged Attention)来实现KV缓存的高效管理。如果你还想了解其他关于连续批处理的话题,欢迎在评论区留言告诉我们!
致谢:感谢Arthur Zucker为本文提供的初始图示概念,以及Arthur Zucker, Luc Georges, Lysandre Debut, Merve Noyan和Pedro Cuenca提供的宝贵审阅意见。
新媒网(公号: 新媒网跨境发布),是一个专业的跨境电商、游戏、支付、贸易和广告社区平台,为百万跨境人传递最新的海外淘金精准资讯情报。
本文来源:新媒网 https://nmedialink.com/posts/llm-inference-boost-cut-40-cost-in-20-min.html


粤公网安备 44011302004783号 













