DLAI-大模型高效笔记-全-
DLAI 大模型高效笔记(全)
001:引言 🎯
在本课程中,我们将学习如何高效地服务大型语言模型。我们将深入探讨使LLM服务能够同时处理大量用户请求并保持良好性能的关键技术细节。理解这些内容将帮助您为自己的应用选择合适的服务方案,并优化其延迟和吞吐量。
欢迎来到《高效服务大型语言模型》课程,本课程是与Predbase合作制作的。
我是Andrew,与我一同授课的是Travis,他是Predbase的联合创始人兼首席技术官,也是本课程的讲师。
感谢Andrew。我很高兴能在这里与您一起教授这门课程。在这门课程中,您将学习关于如何服务大型语言模型的深层技术细节。

具体来说,您将学习关键技术思想,例如如何缓存Transformer网络推理过程中的部分计算,以便在构建使用LLM的应用程序时使这一过程更高效。这种深入的理解将帮助您评估LLM应用的各项指标,例如首词元生成时间和吞吐量,或许还能帮助您为服务自定义LLM选择合适的供应商。
许多LLM开发团队最初都从使用云托管API开始,这是快速上手的好方法。但随着团队逐渐成熟,我遇到越来越多希望使用开源LLM的开发者,包括针对自有数据进行微调的LLM。

但当使用开源或微调模型时,您需要找到托管或服务它的方法。在本课程中,您将学习LLM如何被高效服务的细节,包括允许您处理来自多个用户的许多请求的技术。这种深入的理解将帮助您就如何服务您的LLM做出正确的决策。
是的,Andrew。我曾与一些开发者合作,他们从Hugging Face上的开源LLM开始,并用Flask这样的简单Web服务器将其封装起来,结果发现性能远不及他们从商业LLM API获得的水平,尽管他们使用了强大的GPU来服务它。😊
结果是一个系统一次只能缓慢地处理一个请求,大多数用户只是在等待接收响应。幸运的是,得益于Predbase等公司的努力,现在比以往任何时候都更容易高效地同时为许多客户服务您的LLM。
这之所以成为可能,是因为采用了诸如向量化等技术,该技术允许模型在单次操作中处理多组用户输入,甚至同时处理多个微调模型。此外,还有像KV缓存这样的技术,它通过在每个词元生成后将Transformer网络注意力计算的部分结果存储在内存中来加速推理,这样在生成后续词元的每一步中就不必重新进行这些计算。😊


通过同时实施这几项技术,您可以同时改善延迟(即用户从发出提示到收到响应所需的时间)和吞吐量(即服务器处理请求的速率)。最好的托管服务已经将这些技术落实到位,它们是使您构建的任何自定义应用在部署中表现良好的绝佳选择。
在本课程中,您将首先详细了解自回归大型语言模型如何一次一个词元地生成文本。您将实现Andrew刚才提到的KV缓存技术,并了解它如何大幅降低后续每个词元的延迟。

接下来,您将学习如何将多个提示批处理成单个张量,以便LLM可以同时处理多个输入。然后,您将把这个想法扩展到一个称为连续批处理的技术,该技术允许您在新请求到达和旧请求完成时动态更新批次。这使得像Predbase这样的LLM托管服务能够同时为许多客户提供服务,并保持良好的延迟和吞吐量。

之后,您将实现一个量化函数,通过将模型权重转换为较低精度的表示形式来减少其内存占用。
在最后两节课中,您将学习像LoRA这样的参数高效微调技术如何使得在运行时动态加载和集成专门的微调适配器到您的LLM成为可能。然后,我们将结合多个LoRA与连续批处理,以同时服务数百个微调模型,同时保持高吞吐量和低延迟。
在Predbase,我们使用这些技术来经济高效且可扩展地为客户服务许多微调模型。因此,正如您所看到的,本课程呈现了一套非常详细的技术主题。

我认为开发者拥有这些基础知识非常重要,这样您就可以在设计和构建应用程序时做出明智的决策。
没错。掌握了本课程的知识后,您在考虑应用程序性能时将更好地理解必须做出的权衡,并且将更有能力评估潜在供应商为您提供的服务以及他们的承诺是否现实。
这将帮助您为您的项目和公司做出最佳决策。

许多人为本课程的制作提供了帮助。来自Predbase,我要感谢Pierero Molinino、Jeffrey Angus、Mar Di Sala、Jeffrey Tang、Noah Yohida、Michael Oteger。来自DeepLearning.AI,Dialla Eadin和Tommy Nelson也为本课程做出了贡献。
我希望您喜欢这门关于高效服务LLM的课程,并且您获得的知识将高效地服务于您的开发需求。让我们进入下一个视频开始学习吧。😊

本节课总结

在本节课中,我们一起学习了《高效服务大型语言模型》课程的引言部分。我们了解了课程的目标,即深入探讨高效服务LLM的核心技术,如KV缓存、向量化/批处理、连续批处理、量化和LoRA。这些知识旨在帮助开发者优化应用性能、理解延迟与吞吐量的权衡,并为服务自定义LLM做出明智的架构与供应商选择决策。
002:文本生成 🧠

在本节课中,我们将介绍使用自回归语言模型进行文本生成的过程。你将学习如何从模型输出中逐个迭代地生成词元,并了解这个过程如何被划分为预填充和解码两个阶段,以及如何使用 KV 缓存 来优化注意力计算以加速生成。让我们开始吧。
加载模型与理解架构
首先,我们需要加载一个语言模型作为示例。我们选择使用 Hugging Face 上的 GPT-2 模型及其分词器。尽管如今有更多更新的模型,但 GPT-2 因其模型小、推理速度快,在需要低延迟(如自动补全)的生产任务中仍然非常实用。
以下是加载模型和分词器的代码:
import torch
from transformers import GPT2LMHeadModel, GPT2Tokenizer
model_name = "gpt2"
tokenizer = GPT2Tokenizer.from_pretrained(model_name)
model = GPT2LMHeadModel.from_pretrained(model_name)


对于 GPT-2 这类我们统称为大语言模型的 Transformer 模型,存在不同的架构类型。早期的编码器-解码器模型是首批出现的架构之一。编码器模型(如 BERT)将词元映射到嵌入空间,适用于分类和相似性搜索等任务。而 GPT-2 是一种仅解码器模型,它没有编码器。其工作原理是:将输入通过嵌入层,然后经过一系列由注意力机制和多层感知机组成的块进行处理,最终生成输出。这种仅解码器的结构在实践中会逐个生成词元,构成了我们所说的自回归语言模型,这也是现代大多数大语言模型的基础架构。
基础文本生成流程

现在我们已经实例化了模型,接下来探索如何让它生成文本。
我们从基础提示词开始:“the quick brown fox jumped over the”。首先,通过分词器处理这个输入,并以 PyTorch 格式返回结果。
prompt = "the quick brown fox jumped over the"
inputs = tokenizer(prompt, return_tensors="pt")
print(inputs)
输出包含两个张量:
input_ids:文本映射到词元 ID 的结果。attention_mask:一个全为 1 的张量,表示所有词元都应被关注。在后续讨论批处理时,我们会回到注意力掩码的概念。
现在,我们将输入传递给模型并查看输出。在推理时,我们使用 torch.no_grad() 来避免计算梯度,以节省内存。
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits
print(logits.shape) # 输出形状:(batch_size, sequence_length, vocab_size)
logits 是一个三维张量,形状为 (1, 7, 50257),分别代表批大小、输入序列长度和词汇表大小。
为了确定模型预测的下一个词元,我们取 logits 中对应序列最后一个位置(即下一个词元位置)的值,并找出其中概率最高的词元 ID。
# 获取下一个词元的 logits(取批次中第一个样本的最后一个词元位置)
next_token_logits = logits[0, -1, :]
# 选择概率最高的词元 ID(贪心解码)
next_token_id = torch.argmax(next_token_logits, dim=-1).item()
print(f"下一个词元 ID: {next_token_id}")
# 解码回文本
next_token = tokenizer.decode(next_token_id)
print(f"下一个词元: {next_token}")
运行后,模型预测的下一个词元是 “fence”。因此,完整的句子可能是 “the quick brown fox jumped over the fence”,这在语法和逻辑上都是合理的。
除了选择概率最高的词元(贪心解码),我们还可以查看概率最高的前 K 个候选词元。
# 获取概率最高的前10个词元
top_k = 10
top_k_values, top_k_indices = torch.topk(next_token_logits, k=top_k)
for i in range(top_k):
token_id = top_k_indices[i].item()
token = tokenizer.decode(token_id)
print(f"{i+1}: {token} (ID: {token_id})")
其他可能的词元包括 “edge”、“railing”、“wall” 等。在实际应用中,可以通过调整温度参数等解码策略来增加输出的多样性,而不是总是选择最可能的词元。
迭代生成与朴素方法的性能问题
对于自回归大语言模型,生成后续词元最直接的方法是:将前一步生成的词元 ID 拼接到原始输入中,形成新的输入,然后重复这个过程。
以下是生成下一个词元并更新输入的代码:
# 将新生成的词元 ID 拼接到 input_ids 后
new_input_ids = torch.cat([inputs[‘input_ids‘], torch.tensor([[next_token_id]])], dim=-1)
# 同样更新 attention_mask,为新词元添加一个 1
new_attention_mask = torch.cat([inputs[‘attention_mask‘], torch.tensor([[1]])], dim=-1)
print(f"新的 input_ids 形状: {new_input_ids.shape}")
现在,让我们定义一个函数来封装单次词元生成的过程,并测量生成多个词元所需的时间。
import time
def generate_token(input_dict):
"""接收包含‘input_ids‘和‘attention_mask‘的字典,生成下一个词元 ID。"""
with torch.no_grad():
outputs = model(**input_dict)
logits = outputs.logits
next_token_id = torch.argmax(logits[0, -1, :]).item()
return next_token_id
# 生成10个词元并计时
generated_tokens = []
durations = []
current_inputs = inputs.copy()
for step in range(10):
start_time = time.time()
next_id = generate_token(current_inputs)
durations.append(time.time() - start_time)
# 解码并记录词元
generated_tokens.append(tokenizer.decode(next_id))
# 为下一次迭代准备输入:拼接新词元
current_inputs[‘input_ids‘] = torch.cat([current_inputs[‘input_ids‘], torch.tensor([[next_id]])], dim=-1)
current_inputs[‘attention_mask‘] = torch.cat([current_inputs[‘attention_mask‘], torch.tensor([[1]])], dim=-1)
total_time = sum(durations)
print(f"生成10个词元总耗时: {total_time:.2f} 秒")
print(f"生成的文本: {prompt} {‘ ‘.join(generated_tokens)}")

生成的结果可能是:“the quick brown fox jumped over the fence and ran to the other side of the fence”。这再次证明了生成文本的连贯性。
然而,我们需要关注这种朴素方法的性能。由于每一步都将整个增长中的序列重新输入模型,计算量会随着序列变长而增加。让我们绘制每一步的耗时图来观察趋势。
import matplotlib.pyplot as plt

steps = list(range(1, len(durations)+1))
plt.plot(steps, [d*1000 for d in durations], marker=‘o‘) # 转换为毫秒
plt.xlabel(‘生成的词元序号‘)
plt.ylabel(‘单次生成耗时 (毫秒)‘)
plt.title(‘朴素方法下生成每个词元的耗时‘)
plt.grid(True)
plt.show()
图表显示,除了第一个词元可能因缓存未预热而稍慢外,后续每个词元的生成耗时随着输入序列变长而逐渐增加。这是因为 Transformer 模型中最大的计算瓶颈——注意力计算——的复杂度与输入序列长度成正比。
KV 缓存优化:预填充与解码阶段

注意力计算涉及为输入序列中的每个词元生成查询(Q)、键(K)、值(V)矩阵。在生成第一个词元后,一个关键的优化点出现了:在生成后续词元时,我们实际上只需要为新词元计算 Q、K、V。而对于所有先前词元的 K 和 V 值,它们可以被缓存起来重复使用,而无需重新计算。
这引出了 KV 缓存 的概念,它是 LLM 推理中最基础的优化之一,并将词元生成过程分离为两个阶段:
- 预填充阶段:处理整个初始提示词,生成第一个词元,并计算并缓存所有输入词元的 K 和 V 值。
- 解码阶段:基于缓存和最新生成的词元,逐个生成后续词元。每次只需为新词元计算 K 和 V,并与缓存拼接后进行注意力计算。
现在,我们修改生成函数以利用 past_key_values。
def generate_token_with_past(input_ids, attention_mask, past_key_values=None):
"""使用 KV 缓存生成下一个词元。"""
with torch.no_grad():
outputs = model(input_ids=input_ids,
attention_mask=attention_mask,
past_key_values=past_key_values,
use_cache=True) # 启用缓存
logits = outputs.logits
next_token_id = torch.argmax(logits[0, -1, :]).item()
# 返回新的词元 ID 和更新后的 past_key_values
return next_token_id, outputs.past_key_values
# 使用 KV 缓存重新生成10个词元并计时
generated_tokens_cached = []
durations_cached = []
current_input_ids = inputs[‘input_ids‘] # 初始提示词
current_attention_mask = inputs[‘attention_mask‘]
past_kv = None
for step in range(10):
start_time = time.time()
next_id, past_kv = generate_token_with_past(current_input_ids, current_attention_mask, past_kv)
durations_cached.append(time.time() - start_time)
generated_tokens_cached.append(tokenizer.decode(next_id))
# 关键变化:下一次输入仅是新生成的词元,而不是整个历史序列
current_input_ids = torch.tensor([[next_id]])
# 注意力掩码需要增长,以告知模型新词元的位置
current_attention_mask = torch.cat([current_attention_mask, torch.tensor([[1]])], dim=-1)
total_time_cached = sum(durations_cached)
print(f"使用 KV 缓存生成10个词元总耗时: {total_time_cached:.2f} 秒")
使用 KV 缓存后,总时间显著下降。让我们对比两种方法的单步耗时:
plt.plot(steps, [d*1000 for d in durations], marker=‘o‘, label=‘无缓存‘)
plt.plot(steps, [d*1000 for d in durations_cached], marker=‘s‘, label=‘有 KV 缓存‘)
plt.xlabel(‘生成的词元序号‘)
plt.ylabel(‘单次生成耗时 (毫秒)‘)
plt.title(‘KV 缓存优化效果对比‘)
plt.legend()
plt.grid(True)
plt.show()
图表清晰地展示了优化效果:在预填充阶段(生成第一个词元),两者耗时相近。但在解码阶段,使用 KV 缓存的方法耗时大幅降低并保持稳定,因为每次只处理一个新词元,避免了重复计算历史序列的注意力。这正是 LLM 推理系统核心的优化手段。
总结与展望
本节课中,我们一起学习了使用自回归语言模型进行文本生成的基础流程:
- 我们了解了如何加载模型、处理输入并获取下一个词元的预测。
- 我们分析了朴素迭代生成方法存在的性能问题,即随着序列变长,计算开销线性增长。
- 我们深入探讨了 KV 缓存 这一核心优化技术。它通过将生成过程分为预填充和解码两个阶段,缓存注意力机制中的键(K)和值(V),避免了大量冗余计算,从而极大地提升了生成效率。
KV 缓存是优化 LLM 文本生成的第一个重要里程碑。在此基础上,还有更多高级优化技术(如分页注意力)可以进一步优化内存和计算效率。不过,仅 KV 缓存就能带来绝大部分的收益。

在下一节课中,我们将把讨论提升一个层次,开始探讨批处理技术。批处理对于构建能够同时处理多个并发请求的服务系统至关重要,它将帮助我们提高系统的吞吐量。
003:L2-批处理 🚀
在本节课中,我们将学习如何将多个请求批量处理,以提升服务效率。我们将探讨吞吐量与延迟之间的权衡关系,并通过代码实践来理解批处理的具体实现。



在上一节课中,我们学习了如何高效地为单个请求生成文本。


本节中,我们将扩展这一概念,将多个请求批量处理,并观察这如何在处理更多请求(称为吞吐量)与快速响应任一请求(称为延迟)之间形成权衡。
让我们深入代码。首先,我们将使用与之前相同的依赖项:我们熟悉的GPT-2模型,以及在第一课中创建的相同生成工具函数。但这次,我们将扩展此函数,使其不仅支持单个输入,还能支持多个输入,并观察如何优化其性能。
在上一课中,我们讨论了如何为单个输入逐个生成下一个令牌。例如,对于输入“the quick brown f”,我们生成“jumped”,然后它成为下一步的输入。



现在输入变为“the quick brown Fo jumped over”,我们重复此过程直到决定停止。但在多请求或批处理上下文中,我们需要引入填充令牌,目的是使每个输入的维度保持一致,从而得到一个形状规则的张量(在本例中看起来像一个普通的二维矩阵)。因此,对于引入的这些新序列,例如“the rain”和“what comes up”,我们在左侧有这些填充令牌,其目的是确保矩阵的整体形状一致。
让我们从第一课的内容开始,快速检查是否能生成预期的输出。我们的输入仍然是“the quick brown Fo jumped over the”,输出是“fence and ran to the other side of the fence”。


很好,我们得到了预期的结果。现在,让我们看看如何将其扩展到多个输入。
我们需要做的第一件事是对模型和分词器进行一些小修改,以引入一个填充令牌。此外,我们需要定义填充的位置:是在输入序列的左侧(这样我们将有前导填充令牌)还是在右侧(这意味着我们将有尾随填充令牌)。这在一定程度上取决于模型,也取决于你是在进行训练还是推理。对于推理,通常希望进行左填充,因为我们将在输入的右侧追加令牌。自然地,我们希望填充左侧,以避免出现像“the quick brown fox pad pad pad jumped over the lazy dog”这样的序列。
现在,我们将不再将提示定义为一个字符串,而是创建一个包含三个提示的列表。和之前一样,我们的目标是为这三个序列中的每一个生成后续的自然令牌。

和之前一样,我们将把这些输入传递给分词器。幸运的是,Hugging Face的分词器类知道如何处理提示列表以及单个提示。我们将像以前一样返回PyTorch格式,但还会添加一个新参数padding=True,这将使用我们之前添加到分词器中的填充令牌来填充提示,使它们位于一个具有规则形状的单个张量中。
现在,让我们看看这次分词器输出了什么。
可以看到,现在我们有一个形状为(3, 7)的输入ID张量,其中3是批次大小,7是我们提供的所有输入中的最大序列长度。你会注意到,对于较短的序列(即后两个序列),它们以令牌50256开始,这是我们之前引入的填充令牌。因此,它们都被填充到长度为7,并在开头有前导填充。
在上一课中,我们简单提到了注意力掩码,但没有深入细节。但这次,注意力掩码不仅仅是全为1的向量。你会注意到,注意力掩码也包含零,具体来说,零对应于我们引入的填充令牌。这实际上是在告诉模型,它不应该关注填充令牌,换句话说,在思考这个令牌如何与其他令牌关联时,它不应该真正考虑填充令牌,而应该基本上忽略它们,以免影响我们想要生成的总体输出。

现在,让我们回到生成下一个令牌的问题,给定这三个已批处理到单个输入ID张量中的输入序列。在处理批处理输入时,我们想引入的一个新概念是位置ID。这些位置ID在这种情况下特定于Hugging Face Transformer的实现,但本质上只是告诉模型输入序列中每个令牌的顺序位置。因此,这只是一个从0到n的列表(n个令牌),但对于批量推理,我们需要将其填充,将序列开头的填充令牌设为零,这样它们就不会对递增序列产生影响。我们将首先这样做。
你可以看到,这里填充令牌有一些额外的1,但在我们经过填充令牌后,序列才真正开始。接下来,我们将使用与之前相同的with torch.no_grad()将其传递给模型,但现在我们还将包含这些位置ID,然后输出将再次是逻辑值。

之前,为了选择下一个令牌ID,我们取了第一个批次,取了最后一个序列元素,然后取了所有词汇可能性,然后计算了argmax。但这次,我们将改变这个最后的逻辑值计算,改为在所有批次上进行选择。然后,我们不取全局argmax,而是希望返回一个下一个令牌ID的向量,每个批次一个。为此,我们将在这个维度上取argmax。因此,我们基本上将保留批次维度。如果我们尝试将dim更改为不同的值而不是1,我们实际上是在说序列维度的下一个令牌或类似的东西,这不是我们想要的。所以这确保了我们不会交叉处理,意外地跨不同批次计算argmax。因此,每个批次元素都有一个对应的下一个令牌ID。让我们运行它。
如果我们打印出这些下一个令牌ID,可以看到,正如预期的那样,我们有三个:第一个序列对应这个令牌,第二个序列对应那个令牌,第三个输入序列对应第三个令牌。最后,我们可以将这些令牌ID转换为字符串。

我们得到了什么?我们得到了“fence”、“on”和“B”。如果你还记得原始序列是:“the quick brown fo jumped over the fence”、“The rain in Spain falls on.”和“what comes up must.”。所以,虽然这些不完全符合我们预期的陈词滥调,但它们实际上在语法上都是正确的,并且是你期望会自然完成那些原始句子的合理内容。
现在,我们将尝试将所有内容整合在一起,生成不止一个令牌,而是像之前一样在循环中生成n个令牌,使用一个辅助函数generate_tokens_from_past,但这次它将是generate_batch_tokens_with_past。
这实际上是我们第一课中使用的实现,当时我们只使用批次中的一个元素,并且取全局argmax。所以这次,让我们改变它,以便我们处理批次中的所有元素,而不仅仅是第一个,并且我们将计算下一个令牌ID,而不是单个下一个令牌ID。在维度1上计算,然后像以前一样返回下一个令牌ID以及我们的过去键值。
现在我们已经有了从特定批次生成下一个令牌的函数,让我们继续构建一个函数,该函数将为某个限制(例如max_tokens=10)生成所有令牌,即我们想要为批次中的每一行生成的令牌数量。
我们要做的第一件事是定义这个列表generated_tokens,它最初只是三个空列表,对应我们的三个提示批次。接下来,我们将使用注意力掩码来生成位置ID,排除对应于填充令牌的注意力掩码中的零元素。然后,我们将扩展我们的批次以包含这些位置ID,以及我们最初想要传递给模型的所有其他关键字参数。
现在,和以前一样,我们将迭代我们想要生成的令牌数量。我们过程中的第一步将只是为当前批次的每个批次元素生成下一组令牌ID。现在,让我们开始有趣的部分:根据之前的输入和我们刚刚生成的新令牌集构建下一个批次的输入。和以前一样,我们将使用上一个批次生成的输入ID作为下一个批次的输入ID。这里我们使用此过程的KV缓存版本,在所谓的预填充步骤中,我们在最开始丢弃原始批次的输入。对于位置ID,我们想做类似的事情,我们取位置ID的最后一个元素并将其加一,然后我们实际上将丢弃序列中的所有先前元素。所以我们将只取最后一个元素,但保留完整的批次维度。因此,最终我们将得到一个形状为(batch_size, 1)的张量。这将告诉我们提供的这组下一个令牌的位置ID是什么。unsqueeze只是一个PyTorch辅助函数,它将帮助我们获得我们想要的精确形状。对于注意力掩码,我们将做与之前完全相同的事情,我们将取之前的注意力掩码并将其扩展一。但这里有一个稍微不同的附加注意事项:我们实际上需要附加一个形状等于批次维度形状的全1向量,而不仅仅是附加一个1到注意力掩码。最后,我们的过去键值只是我们从上一个令牌生成的过去键值,所以那部分没有变化。
现在,让我们继续将我们在这次特定迭代中生成的下一个令牌ID向量转换为字符串列表,每个批次一个。然后,对于批次中的每个元素,我们将把刚刚生成的新令牌附加到该列表中。最后,我们将把所有令牌连接成一个字符串,而不仅仅是一个列表,并将其作为辅助函数的最终输出返回。
此时,我们有一个名为generate_batch的辅助函数,它接受从分词器输出的输入字典和我们想要生成的新令牌的最大数量,因此我们可以像调用任何其他函数一样调用它,看看最后会发生什么。
现在,让我们尝试打印出generate_batch函数实际生成的内容。为此,我们将做一些有趣的事情:我们将用红色渲染生成的令牌,以便在视觉上将它们与原始输入区分开来。
你可以看到,对于我们的三个序列中的每一个,我们都生成了看起来非常可理解的内容。在这一点上,我们可以相当有信心地认为模型正在有效地进行批处理。特别是因为我们的第一个序列与我们在单个批次中执行此操作时生成的输出完全相同,我们知道通过引入这种批处理,我们最终仍然得到相同的输出,因此在添加这些额外序列到批次的过程中没有引入交叉污染。
批处理的一个既定目标是提高系统的吞吐量,即在多个请求同时到达的情况下,我们在一定时间内可以生成的令牌数量。在本节课的这一部分,我们将探讨批处理对延迟(生成每个令牌所需的时间)以及整体吞吐量的影响,并观察吞吐量和延迟之间存在根本的权衡关系。
为了说明这一点,让我们从一个仅处理延迟优化系统的示例开始,其中每次收到请求时,我们都将贪婪地处理它,不进行批处理。我们用不同的颜色表示时间线上特定输入从空闲到被处理的时段。因此,“the quick brown f”到达,我们立即处理它。下一个请求到达,它需要在原始输入仍在处理时空闲一段时间,然后同样,它立即被贪婪地拾取,第三个请求也是如此。这旨在优化我们的系统以降低延迟,我们试图最小化任何单个请求的等待时间。在这里,我们看到我们的延迟平均为每个请求1.2秒,但我们的吞吐量总体上仅为每秒1个请求,因为我们没有进行任何批处理,所以每个序列最终都必须等待轮到它。
但在一个批处理的世界里,我们可以思考如何在延迟和吞吐量之间进行权衡,以优先考虑吞吐量而非延迟。我们可以做的一件事是,实际上选择等到一定数量的请求到达或达到某个时间限制后再处理请求。在这种情况下,我们实际上稍微损害了延迟,因为最初发出请求的用户必须等待一段时间。但我们在特定时间间隔内处理的请求总数实际上在增加。在这种情况下,我们现在能够每秒处理1.2个请求,而之前我们只能每秒处理1个请求。这是一个很好的例子,说明优先考虑批处理可以帮助我们获得更好的吞吐量,但会牺牲我们可以交付给用户的延迟。
现在,我们想通过实验研究这种延迟与吞吐量的影响,并试图理解等待不同批次大小对总吞吐量(我们每秒能够生成的令牌数量)和平均延迟(平均生成每个令牌所需的秒数)的影响。

首先,为我们的实验设置定义一些常量。在本例中,我们将为想要生成的令牌数量定义一个常量,即10。然后,我们将定义一些数据结构来测量持续时间(每个样本处理所需的时间)、吞吐量(每个实验样本的吞吐量)和延迟(每个样本的平均延迟)。
现在,让我们继续设置我们想要测试的实验样本,这些将是我们要探索的不同批次大小。在本例中,我们将尝试不同的2的幂,基本上从1到128,看看这对吞吐量和延迟有什么影响。
为了运行我们的实验,我们将首先遍历列表中的每个批次大小,并在进行过程中进行一些简单的调试,我们将在每一步打印出批次大小。接下来,我们将为每个批次生成令牌并记录持续时间。所以这里的第一步是:当前时间是什么?第二步,我们将从一组大小为batch_size的提示中形成一个批次。我们将说for i in range(batch_size),我们将从原始的三个提示列表中抓取一个提示,并按顺序取它们。这里的模运算确保我们可以为任何给定的i值抓取一个提示。这只是为了确保我们发送到批次的提示有一些多样性,而不是一遍又一遍地说同一个。然后,我们将像之前一样通过分词器发送它们,进行填充并返回PyTorch格式。生成批次,因此我们将最终输出生成为一个字符串,然后最后记录整个过程花费的持续时间(秒)。
接下来,我们将继续记录该特定批次大小的吞吐量和平均延迟的观察结果。首先,我们想计算我们总共生成了多少个令牌,即batch_size * max_tokens。然后可以通过取令牌数量除以持续时间(秒)来计算吞吐量。这里的平均延迟可以通过取持续时间(秒)除以max_tokens得出。这里唯一的区别是,令牌数量值不参与平均延迟计算。最后,我们将这些值附加到我们的列表中,以便稍后用于可视化。
让我们运行这个,看看我们得到了什么。如果你只是目测,可以看到随着批次大小的增加,延迟开始时很低,然后随着时间的推移开始上升,吞吐量也开始上升。你可能记得我们希望更高的吞吐量(更高的吞吐量是好的),我们希望更低的延迟。因此,我们实际上观察到了我们预期的权衡:获得越来越好的吞吐量开始逐渐降低我们的延迟。
现在,让我们定义一个函数来绘制这种关系。我们将向render_plot函数传递一些东西:批次大小(将是X轴)、吞吐量和延迟(将是我们的两个重叠Y轴),然后我们只为这些不同的轴传递一些标签。
让我们运行它。从图中可以看到,红色的吞吐量开始时相当低,然后随着我们继续提供越来越大的批次大小而开始线性增加,延迟也开始上升,尽管相对于吞吐量上升得慢一些,直到我们开始达到一个点,延迟变得如此之高,而吞吐量无法再跟上步伐,以至于你可以合理地认为继续使用更大的批次大小没有任何好处,因为延迟的权衡太严重了。但对于这个范围内的几乎所有内容,你都可以说这里有一个非常合理的权衡,你知道吞吐量在增加,延迟虽然也在增加但仍然很低,这真的取决于你的个人用例和判断,关于什么是你试图优化的正确批次大小。
所以,批处理简而言之就是这样:你希望获得更好的吞吐量,并愿意牺牲一定程度的潜在延迟,以提高系统多个用户或同时进入系统的多个请求的整体服务质量。
在下一课中与我一起学习连续批处理,这是批处理的一种优化,试图解决延迟增加而吞吐量也增加的问题,试图在保持高吞吐量的好处的同时,最小化生成下一个令牌的延迟。


在本节课中,我们一起学习了如何将多个请求批量处理以提升服务效率。我们探讨了吞吐量与延迟之间的权衡关系,并通过代码实践实现了批处理生成文本。我们还通过实验观察了不同批次大小对吞吐量和延迟的影响,理解了在实际应用中需要根据具体场景进行权衡选择。
004:连续批处理 🔄
概述
在本节课中,我们将学习一种名为连续批处理的技术。该技术通过利用语言模型逐词生成文本的特性,旨在同时实现高吞吐量和低延迟的推理效果。

回顾同步批处理
上一节我们介绍了同步批处理,它通过将多个请求打包成一个批次同时处理来提高吞吐量,但代价是增加了延迟。

同步批处理的核心流程如下:
- 收集多个在不同时间到达的请求。
- 将它们打包成一个批次。
- 整个批次作为一个整体,从头到尾处理完毕。
然而,这种方法存在一个关键问题:即使批次中某些请求只需要生成少量词元,它们也必须等待耗时最长的请求完成,从而导致延迟增加。
引入连续批处理
本节中,我们来看看连续批处理如何解决上述问题。其核心思想源于自回归语言模型逐词生成的特性。每个词元的生成都可以被视为一个独立的操作。
连续批处理的工作方式如下:
- 动态入队:当新请求到达时,系统会判断是否将其加入当前正在处理的批次。
- 动态出队:当批次中的某个请求完成(例如达到生成长度上限或遇到停止词)时,可以立即将其移出批次,并换入一个等待中的新请求。
这种让元素在批次中动态进出的机制,就是连续批处理的核心。此外,我们还需要区分预填充和解码阶段:
- 预填充:处理用户输入的提示词,生成第一个词元。此阶段计算量较大。
- 解码:基于已生成的词元,连续生成后续词元。
在连续批处理系统中,通常会尝试将多个解码请求保持在一个批次中,以减少填充开销,从而优化系统的吞吐量和延迟。
通过这种方式,我们能够获得接近两全其美的效果:延迟非常低(甚至低于逐个处理请求的情况),而吞吐量则与同步批处理相当。如果请求生成的词元数量差异很大,连续批处理甚至能带来更高的吞吐量。

动手实现连续批处理
现在,让我们从零开始实现连续批处理过程。

首先,导入必要的库并加载模型,与之前一样,我们使用Hugging Face的GPT-2模型。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
我们定义一些常量和请求队列。队列中的每个请求是一个元组,包含提示字符串和需要生成的词元数量。

QUEUE_SIZE = 32
BATCH_SIZE = 8


request_queue = []
for i in range(QUEUE_SIZE):
prompt = "The quick brown fox jumped over the"
num_tokens = 100 if i % BATCH_SIZE == 0 else 10
request_queue.append((prompt, num_tokens))
以下是请求队列前几个元素的示例:
('The quick brown fox jumped over the', 100)('The quick brown fox jumped over the', 10)('The quick brown fox jumped over the', 10)- ...
接下来,我们先将请求队列按批次大小切分,以便进行同步批处理作为基准对比。
batches = [request_queue[i:i+BATCH_SIZE] for i in range(0, len(request_queue), BATCH_SIZE)]
然后,我们运行同步批处理并记录总耗时。这个过程会比较慢,因为批次中即使只有一个请求需要生成100个词元,其他所有请求也必须等待它完成。
import time
from tqdm import tqdm

start_time = time.time()
for batch in tqdm(batches):
prompts = [item[0] for item in batch]
max_tokens = max([item[1] for item in batch])
# 调用同步批处理生成函数(假设已定义)
generate_batch(prompts, max_new_tokens=max_tokens)
total_duration_sync = time.time() - start_time
print(f"同步批处理总耗时:{total_duration_sync:.2f}秒")

现在,开始实现连续批处理算法。核心逻辑是一个循环,只要请求队列不为空或当前缓存批次中还有请求未完成,就持续运行。
def continuous_batching_loop(request_queue, batch_size):
cached_batch = None # 当前正在处理的批次缓存
processed_count = 0
while request_queue or (cached_batch and len(cached_batch) > 0):
# 1. 计算批次剩余容量
batch_capacity = batch_size - (len(cached_batch) if cached_batch else 0)
# 2. 如果还有容量且队列有请求,则添加新请求(进行预填充)
if batch_capacity > 0 and request_queue:
new_requests = request_queue[:batch_capacity]
# 对新请求进行预填充,生成初始缓存
new_batch = init_batch(new_requests)
request_queue = request_queue[batch_capacity:]
# 3. 合并新批次与原有缓存批次
if cached_batch is None:
cached_batch = new_batch
else:
cached_batch = merge_batches(cached_batch, new_batch)
# 4. 对当前缓存批次执行一步解码(生成下一个词元)
if cached_batch:
cached_batch = generate_next_token(cached_batch)
# 5. 过滤已完成请求
cached_batch, completed_indices = filter_batch(cached_batch)
processed_count += len(completed_indices)
return processed_count


在上述循环中,我们使用了几个关键辅助函数:
init_batch: 对新请求进行预填充,初始化其键值缓存。merge_batches: 将新批次合并到现有缓存批次中,可能需要填充以对齐序列长度。generate_next_token: 对当前批次执行一次前向传播,生成下一个词元。filter_batch: 检查并移除批次中已完成的请求,并清理不必要的填充。
最后,我们使用相同的请求队列运行连续批处理,并比较耗时。
start_time = time.time()
processed = continuous_batching_loop(request_queue.copy(), BATCH_SIZE)
total_duration_continuous = time.time() - start_time
print(f"连续批处理总耗时:{total_duration_continuous:.2f}秒")
print(f"性能提升:{total_duration_sync / total_duration_continuous:.2f}x")
总结
本节课中,我们一起学习了连续批处理技术。
- 我们首先回顾了同步批处理在提升吞吐量时带来的高延迟问题。
- 接着,我们探讨了连续批处理的核心思想:利用LLM逐词生成的特性,动态管理批次,让请求可以随时加入或离开处理批次。
- 然后,我们动手实现了一个简单的连续批处理循环,包括预填充、合并、解码和过滤等关键步骤。
- 实验表明,在处理生成长度差异大的请求时,连续批处理能显著降低端到端延迟,同时保持高吞吐量。

连续批处理是实现低延迟流式输出的关键技术之一。在后续课程中,我们将超越批处理,探讨量化、低秩适配等其他提升大模型服务效率的方法。
005:量化技术 🧮





在本节课中,我们将学习量化技术的工作原理。量化是一种压缩大型模型的方法,使其能够在消费级硬件上运行。我们将了解如何将模型参数压缩到每个参数8位,并在推理时即时反量化。
概述
大型语言模型的权重需要大量内存。通过量化,我们可以将非常大的模型压缩,使其能够在普通硬件上运行。本节将介绍量化的基本概念,并实现一个算法,将模型参数量化为8位,在推理时进行反量化。
浮点数表示与内存开销

上一节我们介绍了模型的内存占用问题。本节中,我们来看看这些内存开销从何而来。首先,我们需要了解浮点数的标准表示格式。
标准用于表示实数的格式是FP32,即每个数字32位。这种格式在计算应用中非常普遍,但对于像大型语言模型这样拥有数亿甚至数十亿个浮点值的深度学习模型来说,其占用空间相对较大。
在FP32格式中,一个浮点值由三个组成部分构成:
- 符号位:1位,表示正负。
- 指数:8位,对应数值的范围或量级。
- 尾数(或有效数字):23位,可以看作是数值的小数部分或分数部分,代表了该格式能表示的数值精度。
深度学习应用的一个有趣之处在于,根据具体任务(如训练或推理),模型可能并不需要那么高的精度。通常,我们只需要知道数值的大致量级。因此,我们可以思考如何用更少的比特来表示大致相同的信息。
从FP32向下的第一步是考虑用一半的比特数(16位)能捕获多少信息。这引出了业界流行的两种数据类型:
- FP16:标准的16位浮点表示,指数5位,尾数10位。
- Bfloat16 (BF16):一种较新的格式,指数8位(与FP32动态范围相同),尾数仅7位。这意味着我们精确捕获数值小数部分的能力大大降低,但由于其能表示更大的数值范围,因此在许多深度学习训练和推理场景中非常实用。
如果硬件支持,我们还可以探索更小的格式,如FP8(指数5位,尾数2位),以节省内存开销。
量化:压缩与重构
与使用越来越小的浮点表示法不同,量化的核心思想是压缩。我们更感兴趣的是如何将数据压缩成一种紧凑形式,并附带一些元数据,以便在前向传播过程中即时重构。这样我们可以在内存开销上获得巨大收益,只需付出少量的计算成本。
以下是量化技术的一种:零点量化。
假设我们有一个包含五个浮点数的张量。我们首先计算该张量的一些元数据(如最小值和最大值),然后将其压缩到0到255的范围内(仅使用无符号整数值),并存储起来供后续使用。
让我们编写一个函数来实现这个量化步骤。

首先,计算张量的最小值和最大值。然后,我们需要计算两组元数据值:缩放因子(scale) 和零点(zero_point)。
- 缩放因子用于将数值量化到0-255的范围内。
- 零点是在量化时从原始值中减去的值,在反量化时再加回来。
计算公式如下:
scale = (max_val - min_val) / (2^n_bits - 1),对于8位量化,n_bits=8,所以分母是255。
zero_point = min_val
接下来,我们将这个缩放函数应用到输入张量上:
- 对张量中的每个值,减去零点(即最小值)。
- 然后除以缩放因子。
这将确保t_quant中的每个值都在0到255的范围内。
最后,我们执行一个简单的钳位操作来处理任何舍入误差,确保只处理此范围内的值。同时,我们需要保存这些状态值,以便在后续的反量化步骤中用于还原。

一个重要提示是:与使用越来越小的浮点表示法不同,我们不能直接在计算中使用量化后的张量。因为仅这些整数值不足以表示信息,我们还需要元数据来重构它们,以获得我们试图表示的真正数值。
最后,我们将张量转换为无符号整数8位格式(torch.uint8),从而将张量的内存占用减少到原来的1/4。然后返回量化后的张量和其状态。
def quantize(t: torch.Tensor):
# 计算最小值和最大值
min_val, max_val = t.min(), t.max()
# 计算缩放因子和零点
scale = (max_val - min_val) / (2**8 - 1) # 8位量化
zero_point = min_val
# 应用量化变换
t_quant = torch.clamp(torch.round((t - zero_point) / scale), 0, 255)
# 转换为uint8类型以节省内存
t_quant = t_quant.to(torch.uint8)
# 返回量化后的张量和状态(缩放因子,零点)
return t_quant, (scale, zero_point)
让我们运行一个快速测试,看看量化函数对模型中随机张量的影响。我们打印其形状和一小部分值,可以看到这些值大多是介于-1和1之间的浮点数。调用量化函数后,打印量化状态中的值样本,以及t_quant的最小值和最大值(如果一切正常,应分别为0和255)。结果符合预期,所有值都是0-255范围内的无符号整数。

反量化:恢复原始数据
接下来,我们编写一个函数来逆转量化步骤,这应该简单得多。

函数dequantize接受两个参数:量化张量t_quant和存储的状态(包含缩放因子和零点)。我们从中提取这两个值,然后进行一个简单的计算:首先将张量转换回浮点格式,然后乘以缩放因子以恢复到原始范围,最后加上零点偏移,使得最小值变回原始张量的最小值。

def dequantize(t_quant: torch.Tensor, state):
scale, zero_point = state
# 反量化:先扩展范围,再加回零点
t_dequant = t_quant.float() * scale + zero_point
return t_dequant
现在,我们可以调用这个函数,打印反量化后的张量值,看到它回到了-1到1的预期范围内。但让我们仔细看看这些值与原始值有多接近。
在反量化过程中,我们将浮点张量转换为0-255的整数张量,然后又转换回原始范围的张量。由于这是一种有损压缩,我们预计会在原始张量和反量化张量之间看到一些误差。最小值和最大值应该没有差异,但其他值会有所不同,误差大小通常取决于该数字能否合理地映射到0-255的无符号整数空间。
现在,我们来测量这些张量之间的绝对误差,感受一下对于这个特定张量,我们的反量化与基线相差多远。可以看到,误差在绝对值上通常很低,在某些情况下大约有两位小数的误差。虽然不严重,但肯定会对模型性能产生影响。
量化整个模型并评估效果
现在,让我们尝试将这种量化技术应用到整个模型上。在此之前,我们首先查看模型对特定请求的输出,以便与量化后得到的响应进行比较。
我们使用之前课程中的辅助函数,传入未量化的模型和分词器,对输入“the quick brown fox jumped over the”生成10个令牌。我们得到了一个合理的输出:“the quick brown fox jumped over the fence and ran to the other side of the fence”。我们记下这个结果,看看量化过程会对模型输出产生什么影响。
首先,我们编写一个函数来量化整个模型,而不仅仅是单个参数。
- 创建一个状态字典,用于存储缩放和零点元数据,以便在反量化步骤中重建全精度模型参数。
- 遍历模型中的每个命名参数,以便使用该名称作为状态字典中的键。
- 确保每个参数的
requires_grad设置为False,因为uint8量化的数据类型不可微分。 - 量化数据,然后将该数据存储在参数中,同时在状态字典中记录该参数名称对应的状态。
- 返回量化后的模型和状态字典。

运行量化后,我们可以查看该模型的内存占用。很好,现在变成了137 MB,而不是之前的500多MB。我们确实获得了大约4倍的总占用减少。
你可能会注意到,我们也以状态字典的形式产生了一些新的开销。那么,这个状态字典贡献了多少开销呢?我们计算一下,对于这个拥有超过1亿个参数的模型,状态字典只增加了大约1KB的开销,这在生产环境中是可以接受的。


在生产环境中,你可能希望只在计算需要时即时反量化每一层,以避免一次性反量化整个模型,从而抵消量化在减少内存开销方面的所有效果。但这需要更多的工作。因此,我们在这里编写一个额外的辅助函数dequantize_model,它接收量化模型和状态字典,然后通过再次遍历命名参数、从状态字典中提取状态、使用量化数据及其状态运行反量化步骤,然后用新的反量化状态重新填充参数数据,从而基本上逆转量化过程。

运行反量化函数后,我们计算这个新反量化模型的内存占用,看到它回到了大约510 MB,所以我们的反量化符合预期,模型再次处于FP32格式。

现在,我们再次调用之前用于生成响应的函数,但这次使用反量化后的模型,看看输出是什么。输出是:“the quick brown fox jumped over the fence. the fox jumped over the fence.” 前几个字符相同,但之后开始出现分歧。输出仍然是合理的,即使有点重复,但语法正确,模型并没有退化到无法理解的程度。当然,如果反量化损失足够大,这种情况是可能发生的。
总的来说,你可以看到量化和反量化确实对整体模型输出和质量有影响。因此,存在不同的量化技术,旨在尽可能获得最大的量化效益,同时使模型性能的下降最不明显。
总结

本节课中,我们一起学习了量化技术。我们了解了浮点数表示的内存开销,并深入探讨了量化作为一种压缩方法的核心思想。我们实现了零点量化算法,将模型参数压缩到8位,并成功进行了反量化。通过实践,我们看到量化能显著减少模型内存占用(约4倍),同时会对模型输出质量产生一定影响,但通过精细的量化策略可以控制这种影响。量化是高效服务大型语言模型的关键技术之一。
006:低秩适应(LoRA) 🧩

在本节课中,我们将学习如何通过低秩适应(LoRA)技术,高效地定制大型语言模型,使其适应特定任务和数据,而无需修改原始模型的大部分参数。
概述
为了充分发挥大型语言模型的潜力,我们需要根据特定数据和任务对其进行定制。低秩适应是一种参数高效的微调技术,它允许我们仅通过添加和更新一小部分新参数来定制模型,而无需改变原有的模型权重。这大大降低了微调模型的存储和部署成本。接下来,我们将从零开始实现LoRA,并展示仅添加少量参数如何显著影响模型的输出。
微调的基本概念
上一节我们提到了模型定制,本节我们来深入探讨微调。在微调中,我们的目标是调整模型的权重,使其更好地适应特定任务或数据集。
在传统的全参数微调中,模型中的每一个参数(即权重)都会在反向传播过程中被更新。这意味着,当我们保存微调后的模型时,实际上保存了所有模型参数的完整副本。因此,在部署这些微调模型时,每个模型都需要一个全新的独立部署实例。
然而,还有另一种方法可以针对特定任务定制模型。
低秩适应(LoRA)原理
低秩适应(LoRA)在模型的某些层(通常是注意力计算相关的层,有时甚至是所有层)中引入一组新的参数。当输入通过该层时,除了经过原始权重 W 的处理,还会经过这组新参数的处理。
这组新参数由两个我们称为 A 和 B 的张量组成,它们具有所谓的“低秩”形状。具体来说:
- 矩阵
A的输入维度与原始权重W的输入维度相同。 - 矩阵
B的输出维度与原始权重W的输出维度相同。 - 但
A和B的内部维度(即秩r)要小得多。
当 A 和 B 相乘时,其结果的输入输出形状与 W 相同,但有效参数量只是 W 的一小部分(通常约为1%)。这意味着,如果我们只更新这些新引入的低秩矩阵,就相当于找到了一种方法,仅修改约1%的参数即可微调模型以适应特定任务。
从服务部署的角度看,这非常有利。因为将微调模型加载到内存中的成本很低,我们甚至可以在运行时动态地、按需地在内存中加载或卸载这些LoRA适配器。这就是我们本节课及后续课程将要探索的核心思想。
动手实现LoRA
为了深入理解LoRA的工作原理,我们将不使用现成的Hugging Face Transformers库,而是从一个简单的玩具模型开始,逐层剖析。
首先,导入必要的依赖并设置随机种子以确保结果可复现。
import torch
import torch.nn as nn
torch.manual_seed(42) # 设置随机种子
接下来,创建一个用于探索LoRA的测试模型。这是一个非常简单的语言模型,仅包含三层:
- 嵌入层:将输入词元ID映射到隐藏空间。
- 线性层:将嵌入向量投影到另一个嵌入空间。
- 语言模型头层:生成最终的逻辑值,用于预测下一个词元。
class ToyModel(nn.Module):
def __init__(self, hidden_size):
super().__init__()
self.embed = nn.Embedding(10, hidden_size) # 词汇表大小为10
self.linear = nn.Linear(hidden_size, hidden_size)
self.lm_head = nn.Linear(hidden_size, 10) # 输出维度为词汇表大小
def forward(self, x):
x = self.embed(x)
x = self.linear(x)
x = self.lm_head(x)
return x
# 初始化模型,隐藏层维度设为1024
model = ToyModel(hidden_size=1024)
现在,为模型创建一些虚拟输入。我们将使用一个简单的词汇表,将词元ID映射为颜色名称。
# 创建输入:一个批次,序列长度为8,词元ID从0到7
input_ids = torch.tensor([[0, 1, 2, 3, 4, 5, 6, 7]])
# 简单的词汇表(词元ID到颜色的映射)
vocab = ["red", "orange", "yellow", "green", "blue", "indigo", "violet", "pink", "brown", "magenta"]
定义一个生成函数,用于获取模型的下一个预测词元。
def generate(model, input_ids):
with torch.no_grad():
logits = model(input_ids)
next_token_id = logits[:, -1, :].argmax(dim=-1).item()
return vocab[next_token_id]
# 生成一个词元
print(f"原始模型预测的下一个词元: {generate(model, input_ids)}")
运行后,模型可能会输出类似“magenta”的结果。由于权重是随机初始化的,这个输出本身没有特定含义,但它是确定性的。
为线性层引入LoRA参数
我们的目标是在玩具模型的线性层中引入低秩参数,观察其是否能改变模型的输出。
首先,生成一个模拟输入到线性层的张量。
# 模拟线性层的输入:批次大小=1,序列长度=8,隐藏层维度=1024
x = torch.randn(1, 8, 1024)
现在,定义LoRA计算中所需的两个低秩矩阵 A 和 B。这里,我们设置秩 r = 2。
hidden_size = 1024
rank = 2
# 初始化LoRA矩阵A和B
lora_A = torch.randn(hidden_size, rank) # 形状: (1024, 2)
lora_B = torch.randn(rank, hidden_size) # 形状: (2, 1024)
获取原始线性层的权重 W,并验证LoRA矩阵相乘后的形状与 W 一致。
W = model.linear.weight # 形状: (1024, 1024)
W_approx = lora_A @ lora_B # 形状: (1024, 1024)
print(f"原始权重W的形状: {W.shape}")
print(f"LoRA近似W_approx的形状: {W_approx.shape}")
比较原始参数和LoRA参数的参数量。
num_params_W = W.numel()
num_params_lora = lora_A.numel() + lora_B.numel()
print(f"原始权重W的参数数量: {num_params_W}")
print(f"LoRA参数(A+B)的数量: {num_params_lora}")
print(f"LoRA参数占比: {num_params_lora / num_params_W * 100:.2f}%")
可以看到,LoRA参数的数量远小于原始权重,占比非常小。
接下来,完整运行一次LoRA计算。
# 计算原始线性层的输出
base_output = x @ W.T # 或者使用 model.linear(x),这里为演示矩阵运算
# 计算LoRA路径的输出
lora_output = (x @ lora_A) @ lora_B
# 合并输出
combined_output = base_output + lora_output
print(f"合并输出的形状: {combined_output.shape}")
创建LoRA层抽象
为了更方便地修改模型,我们将上述过程封装成一个 LoRALayer 类。
class LoRALayer(nn.Module):
def __init__(self, base_layer, rank):
super().__init__()
self.base_layer = base_layer
self.rank = rank
hidden_size = base_layer.in_features
# 初始化LoRA参数A和B
self.lora_A = nn.Parameter(torch.randn(hidden_size, rank))
self.lora_B = nn.Parameter(torch.randn(rank, hidden_size))
def forward(self, x):
# 原始层输出
base_out = self.base_layer(x)
# LoRA路径输出
lora_out = (x @ self.lora_A) @ self.lora_B
# 合并
return base_out + lora_out
使用这个类来替换模型中的原始线性层。
# 用LoRALayer替换原始线性层
model.linear = LoRALayer(model.linear, rank=2)
# 再次生成词元,观察输出是否改变
print(f"引入LoRA后模型预测的下一个词元: {generate(model, input_ids)}")
运行后,模型预测的词元很可能从“magenta”变成了另一个颜色(如“indigo”)。这表明引入的低秩参数确实影响了模型的整体输出。
总结与展望
本节课我们一起学习了低秩适应(LoRA)的核心原理与实现。我们了解到,LoRA通过向模型层中注入可训练的低秩矩阵 A 和 B,使得仅需更新极少量的参数(通常<1%)就能有效定制模型。我们从零开始实现了LoRA层,并验证了它能成功改变模型的输出行为。
在实际应用中,我们可以进一步微调这些LoRA参数,以更精确地引导模型生成期望的输出。此外,我们还可以将此技术扩展到模型的更多层中。

在下一节也是最后一节课中,我们将探讨多LoRA推理的概念。在生产环境中,用户通常会训练许多不同的LoRA模型并需要同时服务它们。我们将研究如何将LoRA与之前学过的连续批处理技术相结合,构建一个能够同时高效服务多个微调模型的灵活系统,同时保持高吞吐量和低延迟的优势。
007:多LoRA服务 🚀
在本节课中,我们将结合LoRA与连续批处理技术,创建一个端到端的服务系统,该系统能够同时服务多个微调模型,并一次性处理多个请求。




概述
上一节我们介绍了LoRA,它的一大优势是内存占用非常小。这带来了一个令人兴奋的可能性:我们能否在同一个基础模型骨干上,同时加载许多这样的小型微调LoRA适配器?我们可以设想许多有用的应用场景,例如:
- 为数据的不同部分(如每个代码仓库的代码补全)训练不同的模型。
- 为工作流中的各种任务训练不同的模型,例如构建基于代理的客户支持自动化系统。
- 构建一个无服务器平台,支持许多拥有自己微调模型的租户。
在这些场景中,最终结果都是拥有许多共享同一个预训练基础模型骨干、且需要并发服务的微调模型。如果采用简单粗暴的方式,我们可能会为每个微调模型部署一个独立的服务,这将非常昂贵;或者构建一个每次只能处理单个适配器、并需要不断在内存中换入换出的系统,这将非常缓慢。

但在本节课的这一部分,我们将探讨如何在不牺牲延迟或吞吐量的情况下,在单个部署中高效地同时服务数十个这样的微调模型。
实验设置:构建多LoRA模型接口
为了说明这一点,让我们建立一个非常简单的实验,尝试同时服务多个微调LoRA。我们将使用几种不同的技术来并行处理这些多个LoRA。
首先,像之前一样导入依赖。我们仍然使用一个简单的PyTorch模型,暂时不使用像Hugging Face Transformers这样的库。
接下来,开始定义用于测试的接口。我们将定义一个名为AbstractMultiLoRAModel的抽象基类。
这个类需要一个初始化器。我们将定义模型的层。您可能会注意到,这与上一节课玩具模型中的层非常相似:有一个嵌入层、一个线性层和一个最后的LM头。这里我们使用10作为隐藏层大小,而不是1024。这是因为在CPU上工作,如果使用更大的隐藏层大小,计算基础线性层的前向传播会成为瓶颈,而不是多LoRA推理部分。通过使用非常小的线性层,我们可以真正聚焦于多LoRA推理的优势。
接下来,定义一个辅助函数来实现多LoRA的API约定。这里的约定是:对于批次中的特定元素i,其输出应等于该批次元素的输入乘以我们想为该批次元素使用的特定LoRA权重A和B。您可以想象用户发送请求时说“我想使用这个特定的微调模型”或“我将使用那个特定的微调模型”。因此,对于每一个对应于此处的i的请求,我们可能希望使用不同的LoRA。这就是最简单的目标。
这里需要注意这些不同张量的形状预期:
- 输入
X的形状为(batch_size, sequence_length, in_features)。 - LoRA张量
A和B的形状为(num_loras, in_features, rank)和(num_loras, rank, out_features)。这里多了一个维度,即我们存储在内存中的LoRA总数。 - 我们还有一个名为
lora_indices的查找张量,它本质上是一个形状为(batch_size,)的向量,它将特定的批次元素映射到我们想要使用的LoRA索引。
最后,实现前向传播函数。它将与上一节课玩具模型中的前向传播函数非常相似,但我们将调用self.linear_lora_helper函数,而不是self.linear。我们的前向函数不仅接收输入X,还将接收我们正在使用的LoRA集合以及我们想为此批次使用的LoRA索引。
方法一:循环实现
我们的第一次尝试将使用一个非常简单的循环。现在定义一个名为LoopedMultiLoRAModel的子类,它继承自我们的抽象多LoRA模型。我们将重用相同的构造函数/初始化器和相同的前向函数,但之前定义为未实现的linear_lora函数现在将被实现。
具体来说,我们将首先计算基础线性层的输出,这是LoRA计算的标准部分。然后,我们将遍历这些LoRA索引,拉出对应批次大小维度的批次索引。这可以看作是一个扁平化的字典。接着,我们将从LoRA查找表中拉出该特定LoRA索引处的LoRA权重A和B。最后,应用我们上面寻找的计算:该特定批次元素的输出等于该批次元素的输入乘以该批次元素的LoRA A,再乘以LoRA B。为批次的每个元素执行此操作后,返回最终输出Y。
为了实际使用这个模型,我们只需要对原始的生成过程进行一些小的修改。首先,使用与上一节课相同的玩具分词器,以及相同的输入集。设置一个随机种子以确保过程每次运行都是确定性的。现在,使用与之前相同的生成函数。这里需要注意的重要一点是,我们不必对上一节课使用的生成函数进行任何更改,因为我们传入的是一组不透明的关键字参数,所以我们将透明地将其传递给模型。因此,即使这次我们将向模型传递新的参数,我们也不必更改此函数。
初始化我们的模型实现。现在,实现多LoRA推理过程的外层循环。首先定义几个常量:批次大小(初始设为1)、我们将存储在内存中的LoRA数量(64)、隐藏维度(10)和LoRA秩(2)。接着,随机初始化一些LoRA权重。然后生成10个步骤的输出。我们想定义一个映射张量,将当前批次元素映射到特定的LoRA。接下来,调用我们的生成令牌函数。因为此函数接受任意关键字参数,我们可以按需传递它们。我们的目标是查看每次使用随机的LoRA索引集是否真的会影响输出。换句话说,在不同迭代之间更改LoRA是否会改变输出?如果是,那么我们可以相当确信我们正在使用不同的LoRA,过程基本有效。如果每次迭代输出都相同(因为我们没有改变输入),那么我们可能做错了什么。
运行后,我们看到每次迭代都输出了不同的令牌,这表明我们确实在每次迭代中有效地交换了LoRA。
基准测试与性能分析
现在,是时候对我们的多LoRA系统进行基准测试了。我们的目标是测量当批次大小增加时,生成单个令牌的平均延迟,其中批次内的每个元素可能随机选择不同的LoRA适配器。
我们从第2课和第3课观察到,将多个请求批处理在一起是提高LLM推理系统吞吐量的关键技术之一。因此,我们这个多LoRA推理系统的目标将是,即使在单个批次中同时面对数十个LoRA时,也能保持我们从连续批处理中看到的强大批处理效果。
让我们从为基准测试定义更多常量开始:固定序列长度为8,固定词汇表大小为10,运行500个样本以确保获得稳健的基准,最大批次大小为64。
现在定义我们的基准测试函数。该函数的目的是在所有样本上运行完整的基准测试套件,并返回一组观察结果,用于测量和可视化,以确定事情是否按预期工作。
我们将记录平均延迟列表。接下来,从1迭代到最大批次大小加1。在每个批次大小内,我们将观察一组不同的延迟,目标是在最后对它们进行平均。重复以下过程500次:生成一组随机的输入ID和一组随机的LoRA索引,生成单个令牌并测量该过程的端到端延迟,记录持续时间。最后,计算所有样本的平均延迟,将其附加到最终观察结果中。为了调试,我们将在每个批次大小结束时打印出该批次大小的平均延迟,然后简单地返回最终的平均延迟列表。
启动基准测试过程。我们可以看到,随着批次大小的增加,这个平均延迟值也在增加。这告诉我们这里的扩展效果并不理想,理论上,即使我们使用了连续批处理等所有最佳技术,随着批次大小的增加,我们最终也会受到这个过程的瓶颈。这是我们需要留意的第一个危险信号。
让我们实际看看这个趋势。不幸的是,我们观察到延迟随着批次大小(以及批次中LoRA的数量)线性增加。这意味着尝试同时托管多个LoRA基本上消除了批处理的优势,我们还不如一次处理一个输入。
方法二:向量化实现
我们能做得更好吗?回想一下,在我们的实现中,我们遍历批次的每个元素,一次应用一个LoRA。这自然破坏了批处理吞吐量的主要驱动力之一,即向量化,或者说能够在单个操作中处理多个输入。如果我们将LoRA计算向量化,就可以摆脱需要迭代批次每个元素并逐个应用LoRA的做法。
在下一个实验中,我们将通过做两件事来向量化LoRA计算:
- 使用PyTorch的
index_select辅助函数,将所有LoRA权重收集到一个张量中。 - 对整个输入张量一起应用LoRA计算,而不是一次做一个LoRA。
现在定义一个新的类,它也继承自AbstractMultiLoRAModel,称为GatheredMultiLoRAModel。与循环模型类似,它从其父类继承了初始化器和前向函数,但它有一个新的linear_lora函数实现,有两处不同:它调用index_select从LoRA集合中拉出适当的LoRA A和B张量;一旦我们有了适当的LoRA A和B张量,我们就可以进行一个单一的向量化矩阵乘法步骤,计算X @ A @ B,然后返回输出Y。
现在使用这个新类重新初始化我们的模型,并运行与之前相同的基准测试步骤,但这次使用这个新的模型实现。您应该已经注意到,事情比以前快了很多。虽然我们仍然看到延迟增加,但远没有之前那么严重。但这只是我们肉眼观察输出得出的印象。让我们实际可视化一下以确认。
这次,我们将把循环模型和收集模型的实现一起绘制在同一张图上,以便并排比较。果然,循环实现与收集实现之间的扩展特性存在显著差异。虽然循环实现导致延迟严格线性增加,但收集实现虽然也导致延迟随时间增加,但开始看起来更接近次线性,甚至在某些方面像对数增长。
由此我们可以推断,通过使用向量化,即使在像这样的CPU上(而不是GPU实现),我们已经看到了向量化对多LoRA推理的强大好处。
总结与未来优化方向
本节课中,我们一起学习了如何构建一个多LoRA服务系统。我们从最简单的循环实现开始,发现其延迟随批次大小线性增长,失去了批处理的优势。接着,我们通过使用index_select进行向量化,显著改善了性能,延迟增长变得平缓。



那么,我们还能走得更远吗?并不完全。还有其他方法可以进一步优化:
- 此特定实现仅支持批次中单一秩的LoRA,而实践中用户可能希望一起使用具有不同秩的不同LoRA。
- 我们没有实现任何对不需要LoRA适配器的批次元素的支持,这在实际中也可能很常见。
- 每次LoRA计算前通过
index_select操作创建新张量并非没有开销。 - 我们在批次元素级别操作,而不是在我们可能称之为“段”的级别操作。在许多情况下,特别是在前缀步骤中,可能有大块的批次元素使用相同的LoRA,理想情况下,我们希望利用这一事实来进一步减少内存拷贝和向量化过程的需求。
- 最后,我们是在纯PyTorch中实现这一切。理想情况下,我们可以将其实现为一个融合了收集和LoRA计算的CUDA内核,这正是像vLLM这样的系统中使用的,并由像Punica这样的框架实现,它们实现了称为批处理收集矩阵向量乘法(BgMV)以及分段收集矩阵向量乘法(SGMV)的内核。这些是超越纯PyTorch所能做到的优化。
008:使用Lorax进行生产级LLM推理 🚀
在本节课中,我们将学习如何将前几课讨论的核心概念(如KV缓存、连续批处理、多LoRA推理)应用于一个现代、生产级的开源LLM推理服务器——Lorax。我们将通过实际代码演示,展示如何利用这些技术高效地部署和调用大语言模型。
导入依赖与初始化客户端


首先,我们需要导入必要的Python库并初始化Lorax客户端以连接到推理服务器。
我们将使用asyncio来异步处理请求,并使用pydantic来定义数据模式。接着,从lorax包中导入客户端。
import asyncio
from pydantic import BaseModel
from lorax import Client, AsyncClient

初始化客户端需要提供推理服务器的端点URL和用于身份验证的请求头。
# 示例:连接到Predibase托管的Lorax端点
endpoint_url = "https://api.predibase.com/v1/llm/deployments/your-deployment-id"
headers = {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json"
}
client = Client(endpoint_url, headers=headers)
async_client = AsyncClient(endpoint_url, headers=headers)
基础请求:同步生成
让我们从一个简单的同步请求开始,向服务器询问“什么是深度学习?”,并限制生成32个新令牌。
import time
prompt = "What is deep learning?"
max_new_tokens = 32
start_time = time.time()
response = client.generate(prompt, max_new_tokens=max_new_tokens)
end_time = time.time()
print(f"模型回复: {response.generated_text}")
print(f"总耗时: {end_time - start_time:.2f} 秒")
这个调用会一次性返回完整的响应。耗时包括了模型推理时间和网络延迟。

深入理解:Prefill与Decode阶段
在第一节课程中,我们讨论了Prefill(首次令牌生成)和Decode(后续令牌生成)的概念。Prefill阶段由于需要计算完整的注意力机制而较慢,Decode阶段则因KV缓存而更快。现在,我们通过流式请求来观察这一现象。
我们将调用generate_stream端点,逐个令牌地接收响应,并记录每个令牌的生成延迟。
from lorax.types import Response

durations = []
prompt = "What is deep learning?"
max_new_tokens = 32
start_time = time.time()
for response in client.generate_stream(prompt, max_new_tokens=max_new_tokens):
token_time = time.time()
durations.append(token_time - start_time)
start_time = token_time # 为下一个令牌重置计时起点
# 打印非特殊令牌
if response.token.text not in ['<s>', '</s>']:
print(response.token.text, end='', flush=True)
# 计算指标
time_to_first_token = durations[0] if durations else 0
decode_durations = durations[1:] # 排除第一个令牌(Prefill)
throughput_tokens_per_sec = len(decode_durations) / sum(decode_durations) if decode_durations else 0
print(f"\n\n首令牌延迟: {time_to_first_token:.3f} 秒")
print(f"解码吞吐量: {throughput_tokens_per_sec:.1f} 令牌/秒")



运行后,你会看到令牌逐个流出。首令牌延迟(约0.5秒)明显长于后续令牌的延迟(约0.01-0.1秒),这验证了Prefill/Decode的性能差异。


性能优化:连续批处理
连续批处理能同时处理多个请求,显著提升系统总吞吐量。我们将模拟同时发送三个不同长度的请求,并观察它们的响应是如何交错输出的。
首先,定义一个工具函数,用不同颜色打印文本以区分不同请求的输出。
def format_text(text, color_code):
"""用指定颜色格式化文本。"""
return f"\033[{color_code}m{text}\033[0m"

# 颜色代码:31=红,32=绿,34=蓝
colors = [31, 32, 34]

接下来,我们使用异步客户端并发地发送三个请求。
async def run_async_request(prompt, max_new_tokens, color_idx):
"""异步运行单个生成请求。"""
color_code = colors[color_idx % len(colors)]
start_time = time.time()
async for response in async_client.generate_stream(prompt, max_new_tokens=max_new_tokens):
token_time = time.time()
if response.token.text not in ['<s>', '</s>']:
colored_text = format_text(response.token.text, color_code)
print(colored_text, end='', flush=True)
end_time = time.time()
total_duration = end_time - start_time
# 注意:在异步流中精确计算首令牌延迟和吞吐量更复杂,此处简化
print(f"\n[颜色 {color_code}] 请求完成,总耗时: {total_duration:.2f}秒")

async def main():
prompts = [
("What is deep learning?", 100), # 长请求
("Explain quantum computing.", 10), # 短请求
("What is the capital of France?", 10) # 短请求
]
tasks = []
for i, (prompt, max_tokens) in enumerate(prompts):
task = run_async_request(prompt, max_tokens, i)
tasks.append(task)
# 并发执行所有任务
await asyncio.gather(*tasks)
# 运行异步主函数
asyncio.run(main())
你会看到红、绿、蓝三色文本交错出现,这表明服务器正在并发处理这些请求。所有请求的首令牌延迟相近,而生成100个令牌的请求获得了更高的吞吐量,这体现了连续批处理的优势。

高级功能:多LoRA推理与动态适配器加载
Lorax支持多LoRA推理,允许在同一个基础模型上动态加载不同的微调适配器,而无需重新部署。这极大地提升了服务的灵活性和资源利用率。
首先,定义一个使用特定适配器ID生成文本的辅助函数。
def run_with_adapter(prompt, adapter_id, max_new_tokens=64):
"""使用指定的LoRA适配器生成文本。"""
print(f"\n--- 使用适配器 '{adapter_id}' 处理请求 ---")
for response in client.generate_stream(prompt, max_new_tokens=max_new_tokens, adapter_id=adapter_id):
if response.token.text not in ['<s>', '</s>']:
print(response.token.text, end='', flush=True)
print() # 换行

现在,我们可以用不同的适配器执行不同的任务。
1. 句子补全任务
prompt_template = """
You're provided with an incomplete passage. Please read the passage and finish it with an appropriate response.
Passage: {context}
Ending:
"""
context = "Numerous people are watching others on a field. Trainers are playing frisbee with their dogs. The dogs"
prompt = prompt_template.format(context=context)
adapter_id = "predibase/hellaswag-processed" # 在Hellaswag数据集上微调的适配器
run_with_adapter(prompt, adapter_id)
2. 新闻摘要任务
prompt_template = """
You are given a news article below. Please summarize it.

Article: {article}
Produce the summary.
"""
article = "Former Vice President Walter Mondale was released..."
prompt = prompt_template.format(article=article)
adapter_id = "predibase/cnn" # 在CNN数据集上微调的摘要适配器

run_with_adapter(prompt, adapter_id)

3. 命名实体识别任务
prompt_template = """
Extract named entities from the following text. Output a JSON with 'person', 'organization', 'location', 'misc' lists.
Text: {text}
"""
text = "Only France and Britain backed Fischer's proposal."
prompt = prompt_template.format(text=text)
adapter_id = "predibase/conllpp" # 在CoNLL-2003数据集上微调的NER适配器

run_with_adapter(prompt, adapter_id)

每个请求都指定了不同的adapter_id,Lorax会在运行时动态加载对应的LoRA权重,在同一个基础模型(如Mistral 7B)上执行特定任务。



并发多适配器推理


更强大的是,Lorax可以同时对多个不同的适配器请求进行连续批处理。我们将并发运行上述三个任务。
async def run_async_with_adapter(prompt, adapter_id, color_idx):
"""异步运行带适配器的生成请求。"""
color_code = colors[color_idx % len(colors)]
async for response in async_client.generate_stream(prompt, max_new_tokens=64, adapter_id=adapter_id):
if response.token.text not in ['<s>', '</s>']:
colored_text = format_text(response.token.text, color_code)
print(colored_text, end='', flush=True)

async def main_multi_adapter():
# 定义三个任务:句子补全、摘要、NER
tasks_config = [
(prompt1, "predibase/hellaswag-processed"),
(prompt2, "predibase/cnn"),
(prompt3, "predibase/conllpp")
]
tasks = []
for i, (prompt, adapter_id) in enumerate(tasks_config):
task = run_async_with_adapter(prompt, adapter_id, i)
tasks.append(task)
print("开始并发多适配器推理...")
await asyncio.gather(*tasks)
print("\n\n所有适配器请求完成。")
asyncio.run(main_multi_adapter())
输出中,代表不同适配器的彩色文本会交织在一起,这证明Lorax成功地将三个不同微调模型的请求放入同一个批次进行并行处理。


结构化生成:结合模式约束与微调
为了确保LLM的输出能被下游自动化系统可靠解析,我们可以使用结构化生成技术。它通过JSON Schema强制模型输出特定格式。
首先,使用Pydantic定义期望的输出模式。
from pydantic import BaseModel
from typing import List



class NEROutput(BaseModel):
person: List[str]
organization: List[str]
location: List[str]
misc: List[str]
# 将Pydantic模型转换为JSON Schema
json_schema = NEROutput.schema()
print(json_schema)
然后,在生成请求中指定response_format参数。

text = "Only France and Britain backed Fischer's proposal."
prompt = f"Extract named entities from: {text}"

# 使用基础模型 + 结构化生成(无微调)
response = client.generate(
prompt,
max_new_tokens=128,
response_format={"type": "json_object", "schema": json_schema}
)

try:
output_dict = json.loads(response.generated_text)
print("结构化输出:", output_dict)
except json.JSONDecodeError:
print("输出不是有效的JSON。")

结构化生成能保证输出格式,但无法保证内容的正确性(例如,可能仍将“France”错误分类为“organization”)。
为了同时获得正确的格式和内容,我们需要结合微调。
# 使用微调后的NER适配器 + 结构化生成
response = client.generate(
prompt,
max_new_tokens=128,
adapter_id="predibase/conllpp", # 使用微调过的适配器
response_format={"type": "json_object", "schema": json_schema}
)


try:
output_dict = json.loads(response.generated_text)
print("结合微调的结构化输出:", output_dict)
except json.JSONDecodeError:
print("输出不是有效的JSON。")
现在,输出不仅符合JSON Schema,其中的实体分类(如“France”作为地点)也更可能正确。这展示了结构化生成与模型微调结合的力量。
总结与资源
本节课中,我们一起学习了如何利用Lorax这一生产级LLM推理服务器,实践了多项关键优化技术:
- Prefill/Decode与KV缓存:通过流式请求观察了首令牌延迟与后续令牌吞吐量的差异。
- 连续批处理:演示了如何并发处理多个请求以提升系统总吞吐量。
- 多LoRA推理:展示了如何动态加载不同的微调适配器,并在同一批次中处理针对不同任务的请求。
- 结构化生成:探索了如何使用JSON Schema约束输出格式,并结合模型微调来确保输出内容的准确性。


这些功能使得Lorax成为一个强大且灵活的工具,用于高效部署和服务大型语言模型。
进一步学习资源:
- Lorax GitHub仓库:获取开源代码并自行部署。
- Predibase平台:提供托管版的Lorax服务、模型微调工具和Serverless推理,包含免费额度。
- 相关博客与文档:深入了解结构化生成、微调等主题的细节。

希望本课程能帮助你构建高效、可扩展的LLM应用。欢迎加入社区继续探讨!
009:总结 🎯
在本节课中,我们将一起回顾并总结整个课程的核心内容,了解如何构建一个高效的语言模型服务栈,并展望未来的实践方向。
感谢您加入这次关于高效语言模型服务的深入探讨。至此,您已经实现了一个支持低延迟、高吞吐量并能扩展到多个微调模型的语言模型服务栈的基础。但这仅仅是一个开始。
从基础到生产系统 🚀
上一节我们介绍了服务栈的核心实现,本节中我们来看看如何将这些技术应用于实际生产环境。
我们在纯PyTorch环境中,仅使用CPU就完成了所有这些工作。然而,在Prebase,我们创建了一个名为Lorx的开源语言模型推理系统。
以下是Lorx系统的核心特点:
- 它结合了本课程所学的技术。
- 它集成了更多优化手段。
- 其目标是构建最高效的、用于生产环境部署语言模型的系统。
实践与社区 🤝
如果您想亲自尝试Lorx,希望您能查看本课程的可选课程,了解如何将这些概念付诸实践。
您可以在GitHub上查看LX项目以了解更多信息。如果您喜欢本课程的内容,请考虑加入我们的开发者社区。
我期待您未来能为Lorx乃至更广泛的语言模型推理生态系统做出贡献。


本节课中我们一起学习了高效语言模型服务栈的构建基础,并了解了如何通过Lorx这样的生产级系统将理论转化为实践。从纯PyTorch实现到集成了多种优化的开源系统,我们看到了将大型语言模型高效、可靠地投入使用的完整路径。希望这些知识能帮助您在未来的项目中更好地部署和服务语言模型。


浙公网安备 33010602011771号