LLMOps: 生产环境下的大语言模型管理——基于大型语言模型(LLM)应用的模型领域自适应

131 阅读44分钟

在上一章中,我们讨论了模型部署的不同架构。本章将介绍如何对模型进行领域自适应。实践中,领域自适应常被称为“微调”,但微调实际上只是使模型在特定领域表现良好的多种方法之一。

本章将介绍几种模型适配方法,包括提示工程(prompt engineering)、微调(fine-tuning)和检索增强生成(Retrieval Augmented Generation,简称RAG)。我们还会探讨如何优化大型语言模型(LLM),使其能在资源受限的环境中运行,这通常需要进行模型压缩。最后,我们将讨论最佳实践和规模定律,帮助你判断LLM运行所需的数据量。

从头训练大型语言模型

从头训练大型语言模型的难度和资源消耗因应用而异。对于大多数应用来说,使用已有的开源或专有LLM更为合理。另一方面,没有比从零开始训练一个模型更能深入理解LLM工作原理的方法了。

从头训练LLM是一项复杂且资源密集的工作,需要完整的数据准备、模型架构选择、训练配置和监控流程。下面是一个结构化的训练流程介绍:

步骤1:选择任务
明确你为何构建该模型、模型所服务的领域以及其要完成的任务(如文本生成、摘要或代码生成)。确定成功标准,例如困惑度(perplexity)、准确率或其他领域特定的评估指标。

步骤2:准备数据
在将数据输入模型之前,预处理步骤确保输入格式适合模型高效处理。内容包括文本分词、噪声去除、格式规范化,有时还会将复杂结构简化为模型更易理解的组件。预处理还可能涉及特征选择,旨在聚焦最相关数据,提高模型效果。此步骤包括:

  • 收集大规模文本数据
    优质数据源包括书籍、文章、网站、科研论文、代码库,以及如法律或医疗领域等特定领域的专业文本。
  • 清洗数据
    剔除无用内容(如广告或格式杂质)并修正拼写错误。可借助 Hugging Face 等库完成。
  • 分词处理
    采用子词分词方法,如字节对编码(BPE)或 SentencePiece,类似BERT和GPT-3模型使用的方法。也可使用 Hugging Face 的 AutoTokenizer。分词对处理大词汇表和减少参数量至关重要。

步骤3:确定模型架构
根据数据量、资源和目标选择合适的模型规模。模型配置从几亿参数的小模型到数十亿甚至数万亿参数的超大规模模型不等。如第一章所述,你需要根据具体需求调整基础架构,比如改变层数、注意力机制,或加入专用组件(如针对知识密集型任务的检索增强机制)。图5-1展示了三种常见架构类型。

image.png

步骤4:搭建训练基础设施
训练大型模型通常需要跨多块GPU或TPU进行分布式训练,理想环境应具备大容量显存(16GB以上)和高速互联(如NVLink)。常用框架包括PyTorch的Distributed Data Parallel(DDP)和TensorFlow的MultiWorkerMirroredStrategy。拥有MLOps背景的人可能还熟悉DeepSpeed和Megatron-LM等库,它们专为优化大规模模型的内存和计算设计。

虽然机器学习模型训练中有许多优化器(如随机梯度下降SGD和Autograd),建议针对大型模型选用Adam或AdamW等优化器,并采用混合精度训练(比如FP16),以减少内存占用并加速训练。

步骤5:执行训练
对模型进行训练时,需要考虑多个方面:超参数如何设置?随机种子值是多少?你可以参考Andrej Karpathy在一个小时教学视频中的简单示例(示例5-1)了解从零开始训练LLM的基本实现。

示例5-1:Andrej Karpathy的从零训练LLM示例(授权使用)

import torch
import torch.nn as nn
from torch.nn import functional as F

# 定义超参数
batch_size = 16      # 并行处理的序列数量
block_size = 32      # 最大上下文长度
max_iters = 5000
eval_interval = 100
learning_rate = 1e-3
device = 'cuda' if torch.cuda.is_available() else 'cpu'
eval_iters = 200
n_embd = 64
n_head = 4
n_layer = 4
dropout = 0.0

torch.manual_seed(1337)

# 下载文本数据
URL = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
wget $URL
with open('input.txt', 'r', encoding='utf-8') as f:
    text = f.read()

# 统计文本中所有独特字符,构建词表
chars = sorted(list(set(text)))
vocab_size = len(chars)
stoi = { ch:i for i,ch in enumerate(chars) }  # 字符到索引映射
itos = { i:ch for i,ch in enumerate(chars) }  # 索引到字符映射
encode = lambda s: [stoi[c] for c in s]       # 编码函数:字符串->索引列表
decode = lambda l: ''.join([itos[i] for i in l])  # 解码函数:索引列表->字符串

# 训练集和验证集划分(90%训练,10%验证)
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]

# 获取小批量数据
def get_batch(split):
    data = train_data if split == 'train' else val_data
    ix = torch.randint(len(data) - block_size, (batch_size,))
    x = torch.stack([data[i:i+block_size] for i in ix])
    y = torch.stack([data[i+1:i+block_size+1] for i in ix])
    return x.to(device), y.to(device)

@torch.no_grad()
def estimate_loss():
    out = {}
    model.eval()
    for split in ['train', 'val']:
        losses = torch.zeros(eval_iters)
        for k in range(eval_iters):
            X, Y = get_batch(split)
            logits, loss = model(X, Y)
            losses[k] = loss.item()
        out[split] = losses.mean()
    model.train()
    return out

# 单个自注意力头
class Head(nn.Module):
    def __init__(self, head_size):
        super().__init__()
        self.key = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B,T,C = x.shape
        k = self.key(x)
        q = self.query(x)
        wei = q @ k.transpose(-2,-1) * C**-0.5
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
        wei = F.softmax(wei, dim=-1)
        wei = self.dropout(wei)
        v = self.value(x)
        out = wei @ v
        return out

# 多头自注意力
class MultiHeadAttention(nn.Module):
    def __init__(self, num_heads, head_size):
        super().__init__()
        self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
        self.proj = nn.Linear(n_embd, n_embd)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)
        out = self.dropout(self.proj(out))
        return out

# 前馈神经网络层
class FeedForward(nn.Module):
    def __init__(self, n_embd):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),
            nn.ReLU(),
            nn.Linear(4 * n_embd, n_embd),
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)

# Transformer块
class Block(nn.Module):
    def __init__(self, n_embd, n_head):
        super().__init__()
        head_size = n_embd // n_head
        self.sa = MultiHeadAttention(n_head, head_size)
        self.ffwd = FeedForward(n_embd)
        self.ln1 = nn.LayerNorm(n_embd)
        self.ln2 = nn.LayerNorm(n_embd)

    def forward(self, x):
        x = x + self.sa(self.ln1(x))
        x = x + self.ffwd(self.ln2(x))
        return x

# 简单的二元语言模型
class BigramLanguageModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.token_embedding_table = nn.Embedding(vocab_size, n_embd)
        self.position_embedding_table = nn.Embedding(block_size, n_embd)
        self.blocks = nn.Sequential(*[Block(n_embd, n_head=n_head) for _ in range(n_layer)])
        self.ln_f = nn.LayerNorm(n_embd)
        self.lm_head = nn.Linear(n_embd, vocab_size)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        tok_emb = self.token_embedding_table(idx)
        pos_emb = self.position_embedding_table(torch.arange(T, device=device))
        x = tok_emb + pos_emb
        x = self.blocks(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)

        if targets is None:
            loss = None
        else:
            B, T, C = logits.shape
            logits = logits.view(B * T, C)
            targets = targets.view(B * T)
            loss = F.cross_entropy(logits, targets)
        return logits, loss

    def generate(self, idx, max_new_tokens):
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -block_size:]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :]
            probs = F.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, idx_next), dim=1)
        return idx

model = BigramLanguageModel()
m = model.to(device)
print(sum(p.numel() for p in m.parameters()) / 1e6, 'M parameters')

optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)

for iter in range(max_iters):
    if iter % eval_interval == 0 or iter == max_iters - 1:
        losses = estimate_loss()
        print(
            f"step {iter}: "
            f"train loss {losses['train']:.4f}, "
            f"val loss {losses['val']:.4f}"
        )
    xb, yb = get_batch('train')
    logits, loss = model(xb, yb)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

context = torch.zeros((1, 1), dtype=torch.long, device=device)
print(decode(m.generate(context, max_new_tokens=2000)[0].tolist()))

现在你已经了解了一个简单的LLM是如何工作的,接下来我们将探讨如何结合不同的模型架构。

模型集成方法

集成是指将多个模型的结果结合起来,以获得优于任何单一模型的效果。每个模型都贡献独特的部分,相互平衡彼此的弱点,互补优势。对大型语言模型(LLM)进行集成,是提升性能、增强鲁棒性和提高模型可解释性的有效方法。虽然传统上集成更多用于较小的机器学习模型(如随机森林或小型自然语言处理模型),但由于LLM表现的多样性和专属性,集成对它们的重要性日益提升。

当然,集成LLM也有权衡。一大挑战是计算成本——并行运行多个大模型需要大量资源。显存占用和推理时间显著增加,有时难以满足实时、低延迟应用的要求。

另一挑战是部署复杂度。部署LLM集成需要协调多个模型,管理依赖关系,甚至集成专门的集成逻辑。不过,集成往往可以通过使用模型的量化版本、缓存预测结果或仅在特定条件(如模型置信度较低时)才启用集成来优化。这些技术将在第9章详细讨论。

对于大多数个人和企业来说,模型领域自适应是提升LLM准确度最常见且成本效益最高的方式。下面介绍几种有效集成LLM的方法,并附带示例代码。

模型平均与融合

一种简单方法是对多个模型的预测结果求平均。适用于模型各有所长的场景,比如一个模型擅长生成事实类文本,另一个更具创造性。平均它们的输出可以得到更均衡的回答。具体做法是计算每个模型的概率分布后取平均,作为最终预测。

示例5-2通过遍历模型列表,将各模型输出相加后除以模型数量,实现了预测的简单平均。

import torch

def average_ensemble(models, input_text):
    avg_output = None
    for model in models:
        outputs = model(input_text)
        if avg_output is None:
            avg_output = outputs
        else:
            avg_output += outputs
    avg_output /= len(models)
    return avg_output

其中,models是模型实例列表,input_text是输入文本。

加权集成

基于模型在特定任务上的表现,可以给某些模型更高权重。例如,模型A在摘要任务上表现更优,可以在集成中赋予其比模型B更大的权重。加权集成允许直接利用领域知识或经验评估。示例5-3中,weights是与models等长的权重列表。

def weighted_ensemble(models, weights, input_text):
    weighted_output = torch.zeros_like(models[0](input_text))
    for model, weight in zip(models, weights):
        output = model(input_text)
        weighted_output += output * weight
    return weighted_output

输出是权重加权后的组合,可根据需求调整各模型权重。

堆叠集成(两阶段模型)

堆叠集成方法将多个模型的输出输入到第二级模型(通常是更小更简单的模型),由后者学习有效融合这些输出。该元模型能捕捉LLM输出中的模式,尤其适合摘要、翻译等复杂任务,不同模型捕获输入不同细节。

示例5-4使用了SKlearn的逻辑回归模型作为元模型,训练阶段需利用LLM输出与对应标签。

from sklearn.linear_model import LogisticRegression
import numpy as np

def stacked_ensemble(models, meta_model, input_texts):
    model_outputs = []
    for model in models:
        outputs = [model(text).numpy() for text in input_texts]
        model_outputs.append(outputs)
    stacked_features = np.hstack(model_outputs)
    meta_model.fit(stacked_features, labels)  # 假设有训练标签labels
    return meta_model.predict(stacked_features)

多样化集成以增强鲁棒性

采用多样化模型组合,如编码器-解码器架构与Transformer语言模型混合,对处理边缘案例或生成更全面回答十分有效。多样性带来互补优势,也使集成对单个模型的错误更具抵抗力。例如,当某模型易产生“幻觉”(即错误信息)时,其他模型可作为纠正或限制力量。

多样化集成还能实现专门化回答,比如融合一个小型事实型模型和一个生成型Transformer模型,既保证创造力又兼顾信息准确性。

多步解码与投票机制

在高延迟、高准确性场景中,可以采用投票机制生成文本,由多个模型对下一个词或短语进行投票。多数投票、加权投票或排名投票确保多个模型共识更高的词更可能被选中,异常词被过滤,显著提升生成文本的一致性和连贯性,特别适合复杂提示或要求精确语言的任务。

示例5-5演示了简单的多数投票实现:

from collections import Counter

def voting_ensemble(models, input_text):
   all_predictions = []
   for model in models:
       output = model.generate(input_text)
       all_predictions.append(output)

   majority_vote = Counter(all_predictions).most_common(1)[0][0]
   return majority_vote

其中,voting_ensemble通过多数投票选出模型输出中最常见的结果。如遇平局,可加入加权投票或随机选择逻辑。

组合能力(Composability)

在模型集成中,另一种流行的技术是组合能力。组合能力指的是灵活地混合和匹配不同模型或模型部分的能力。一些集成方法通过对多个模型的输出进行平均来组合结果,而另一些方法则将模型串联起来,使一个模型的输出成为另一个模型的输入。这样,你就可以避免使用一个庞大且全能的模型来完成复杂任务,而是用多个小型、专业化的模型来代替。

举个例子,假设我们有一个摘要模型、一个翻译模型和一个情感分析模型。与其训练一个能处理这三种任务的单一庞大模型,不如将这些单独模型按管道方式组合起来,让每个模型处理前一个模型的输出。这样的模块化方法便于适应和维护,因为每个模型可以独立微调,降低整体计算成本和开发时间(参见示例5-6)。

示例5-6. 组合模型管道

def compose_pipeline(input_text, models):
    """
    通过模型管道处理输入文本。
    models列表中的每个模型执行特定的转换。
    """
    for model in models:
        input_text = model(input_text)
    return input_text

# 定义翻译、摘要和情感分析模型
translated_text = translate_model("Translate this text to French.")
summarized_text = summarize_model(translated_text)
sentiment_result = sentiment_model(summarized_text)

这里的translate_modelsummarize_modelsentiment_model可以单独更新或替换,尤其当某个模型过时或需要重新调优时,组合优势明显。

组合能力的好处很多,例如提供模块化,能根据需要插拔不同模型,提高灵活性。组合允许通过添加或替换个别模型轻松扩展,使系统具备良好扩展性。同时,这种方式高效,因为可以单独优化各组件而不影响整个流程。

不过,组合能力也带来挑战。首先,一个组件的错误可能会向下游传播,放大不准确性。其次,每个阶段都会增加处理时间,可能影响实时应用。最后,保证多模型间回答的一致性需要精心协调和调优。

软性演员-评论家(Soft Actor–Critic,SAC)

这时,软性演员-评论家(SAC)技术派上用场。SAC对LLM尤其有利,因为它不仅追求最大化准确率,还能在创造力和连贯性等多个定性指标间取得平衡。SAC是一种强化学习方法,帮助集成在探索(尝试新策略)和利用(使用已有知识)之间找到平衡。其独特之处在于采用“软”奖励最大化,加入熵正则化,鼓励模型多样化输出,而非总是选最可预测的响应,这有助于语言任务生成更自然多样的回答。

在LLM集成中,SAC能微调模型间的互动,使它们更灵活地适应新信息和任务,避免过度依赖单一策略。它特别适合动态环境,如基于用户反馈调整响应,或开发通用模型。

对于LLM,你可以用SAC调整输出,使其最大化期望的奖励。例如,客户服务聊天机器人中,奖励可能基于用户满意度、回答简洁性和礼貌程度。SAC通过试错学习策略,迭代提升回答质量。

SAC由两个核心组件组成:演员网络(Actor)负责提出动作(即可能的回复),评论家网络(Critic)评估动作的价值,考虑即时和未来奖励。

实现SAC需要定义任务相关的奖励函数,搭建演员和评论家网络,并在多个回合中训练策略,示例5-7展示了其基本实现。

示例5-7. SAC实现

import torch
import torch.nn as nn
import torch.optim as optim

class Actor(nn.Module):
    def __init__(self):
        super(Actor, self).__init__()
        self.layer = nn.Linear(768, 768)  # 假设LLM输出尺寸
        self.output = nn.Softmax(dim=-1)

    def forward(self, x):
        x = self.layer(x)
        return self.output(x)

class Critic(nn.Module):
    def __init__(self):
        super(Critic, self).__init__()
        self.layer = nn.Linear(768, 1)

    def forward(self, x):
        return self.layer(x)

# 初始化演员、评论家及优化器
actor = Actor()
critic = Critic()
actor_optimizer = optim.Adam(actor.parameters(), lr=1e-4)
critic_optimizer = optim.Adam(critic.parameters(), lr=1e-3)

for episode in range(num_episodes):
    text_output = language_model(input_text)  # 生成回复
    reward = compute_reward(text_output)      # 计算奖励

    critic_value = critic(text_output)
    critic_loss = torch.mean((critic_value - reward) ** 2)
    critic_optimizer.zero_grad()
    critic_loss.backward()
    critic_optimizer.step()

    actor_loss = -critic_value + entropy_coefficient * torch.mean(actor(text_output))
    actor_optimizer.zero_grad()
    actor_loss.backward()
    actor_optimizer.step()

SAC有诸多优点,例如基于熵的探索确保回答多样性,适合创造性语言任务。它还能根据实时反馈调整回答,提高现实应用(如聊天机器人)的适应性。此外,可自定义奖励函数以调控行为,适用于多目标任务。

挑战也存在:奖励函数设计需针对具体任务细致权衡多目标,难度较大;SAC训练对超参数敏感,需大量调优;最重要的是,SAC计算开销较大,尤其是大模型环境下。

模型领域自适应

虽然大型语言模型(LLM)通常功能强大,但它们往往缺乏针对专业领域的特定知识或语境细节。例如,ChatGPT 可能理解日常医疗术语,但若未经额外微调,可能无法准确解读复杂的医学术语或法律细微表达。通过对LLM进行领域专用数据的微调,可以提升模型理解和生成该领域相关内容的能力,从而提高准确性和连贯性。

模型适配指的是对预训练模型进行精细调整,使其在特定任务或独特语境下表现更佳。这对于那些基于广泛通用数据预训练的LLM尤为重要。应用于法律、医疗或科学文本等专业领域时,领域适配有助于模型理解并生成更准确、相关且符合上下文的回答。

领域适配方法复杂度不一,从简单的领域特定数据集微调,到更高级的技术以适配特定领域的专业词汇、术语和风格细节。

总体来看,模型领域适配主要有三大核心优势:

  • 提升LLM在那些数据较少、代表性不足领域(如医学文本、法律文档)上的任务表现。
  • 减少每个新领域需大量数据收集与标注的需求,尤其对数据稀缺或采集成本高的领域尤为有用。
  • 使LLM更易被广泛用户使用,即便用户本身并非该领域专家。

举个例子说明。假设你的目标领域有独特词汇,如化学化合物或法律引用。你可以更新分词器和嵌入层,添加领域专用词元以提升模型表现,见示例5-8。

示例5-8. 添加领域专用词元

# 自定义词汇
custom_vocab = ["moleculeA", "compoundX", "geneY"]

# 向分词器添加新词元
tokenizer.add_tokens(custom_vocab)
model.resize_token_embeddings(len(tokenizer))

这种自定义分词器能将独特实体(如化学式或法律引用)识别为原子词元,确保模型将它们作为完整单元处理,而非拆分成子词。嵌入这些领域专用词元有助于模型更好地理解相关信息,并在复杂术语间保持一致性。

模型领域适配主要有三种技术路径:提示工程(prompt engineering)、检索增强生成(RAG)和微调(fine-tuning)。严格来说,RAG是动态提示工程的一种形式,开发者利用检索系统向已有提示补充内容,但由于RAG系统使用频繁,值得单独讨论。

与微调的一个关键区别是,微调需要访问模型权重,而云端专有LLM通常不开放这一权限。

提示工程(Prompt Engineering)

在提示工程中,我们通过定制给模型的提示或问题来获得更准确或更具洞察力的回答。提示的结构对模型理解任务的效果有极大影响,最终决定了模型的表现。由于大型语言模型(LLM)具有高度灵活性,提示工程已成为跨领域和多任务充分发挥模型能力的重要技能。

关键在于理解不同的提示结构会导致模型行为的差异。常见策略从简单的一次性示例(one-shot prompting)到更复杂的思维链提示(chain-of-thought prompting)不等,这些都能显著提升LLM的效果。下面介绍几种常见技术。

一次性示例提示(One-Shot Prompting)

一次性示例提示是指给模型提供一个示例提示及期望的输出类型。这是相对简单的做法,目的是通过清晰简洁的例子告诉模型你想要什么样的答案或操作。当任务简单且定义明确,不需要模型推断复杂模式时,一次性示例提示效果最佳。比如,让模型做翻译时,可以这样提示:

提示:将以下英文句子翻译成法语:“Hello, how are you?”
法语翻译:“Bonjour, comment ça va ?”

展示完例子后,再让模型翻译新句子:

提示:将以下英文句子翻译成法语:“Good morning, I hope you're doing well.”

对于更复杂的任务,一次性示例提示可能无法提供足够上下文来生成可靠或有意义的结果。

少量示例提示(Few-Shot Prompting)

少量示例提示则给模型提供多个期望输出的示例。这种方法适用于任务涉及识别模式或模型需要更多上下文才能良好表现的情况。这些示例帮助模型更好地理解输出应有的样子,并据此推断未知的例子。

少量示例提示在生成特定类型的回答(如特定风格或格式的文本)时尤其有效。示例越多,模型对任务内在结构的识别越准确。

例如,假设你让模型生成数学应用题,并提供几个例子展示如何根据给定数学式生成题目:

提示:根据以下数学算式创建应用题:
1. 3 + 5 = 8
“如果你有3个苹果,又摘了5个,总共有多少苹果?”
2. 10 – 4 = 6
“一家商店原有10个苹果,卖出了4个,店里还剩多少苹果?”

然后,要求模型生成新的题目:

提示:根据以下数学算式创建应用题:7 + 2 = 9

通过少量示例,模型更可能生成符合示例风格和逻辑的相关应用题。少量示例提示在文本摘要、翻译和问题生成等任务中非常有效。

思维链提示(Chain-of-Thought Prompting)

思维链提示鼓励模型逐步分解推理过程,使推理更明确、更易理解,而不仅仅给出最终答案。该方法对于需要逻辑推理、多步计算或解决问题的复杂任务尤为重要,比如数学推理、决策制定或任何中间步骤关键的场景。它能帮助模型避免错误或过度简化的假设,促使其在得出结论前全面评估任务的各个方面。

举例,假设你让模型解一道多步数学题。利用思维链提示,你引导模型逐步推理而非直接给出答案:

提示:让我们一步步解决这个问题:
8 × 6 等于多少?
步骤1:先拆分成小数:8 × (5 + 1)
步骤2:计算:8 × 5 = 40
步骤3:计算:8 × 1 = 8
步骤4:相加结果:40 + 8 = 48
所以,8 × 6 = 48

接着让模型解新题:

提示:让我们一步步解决这个问题:12 × 7 等于多少?

思维链提示帮助模型展示推理过程,确保不遗漏关键细节。

一种强大的策略是结合不同类型的提示以发挥各自优势。例如,先用少量示例提示为模型提供上下文和例子,再切换到思维链提示引导其推理过程。这种混合方法对于需要模式识别和逻辑推理的复杂任务效果尤佳。

示例:

提示:以下是一些创意描述物体的例子:
1. “一棵高大的橡树,枝叶茂密,向外伸展,在草地上投下大片阴影。”
2. “一块小巧圆润的鹅卵石,边缘光滑,颜色柔和淡雅。”
现在,请描述这个物体:“一辆生锈的旧自行车。”让我们一步步来分析。

这种结合方法帮助模型先学习几个例子,再针对对象独特特征进行推理,从而生成详尽连贯的描述。

检索增强生成(Retrieval-Augmented Generation,RAG)

检索增强生成(RAG)是将预训练语言模型与外部知识源结合的强大技术之一。它利用检索方法提升生成模型处理复杂查询的能力,或提供更准确、基于事实的回答。RAG模型融合了信息检索与文本生成的优势,特别适合需要调用大型外部语料库或数据库知识的任务。

RAG通过从知识库或搜索引擎检索相关文档或信息片段,辅助生成过程。这使得模型能引用真实世界的数据,突破预训练模型本身知识的限制。

典型的RAG模型分两个阶段:
第一,检索阶段,检索系统从知识库、搜索引擎或数据库中获取相关文档或文本片段;
第二,生成阶段,LLM基于输入查询和检索到的文本片段生成输出。

RAG使模型能够有效应对复杂问题,进行事实核查,并动态引用广泛的外部信息。例如,基于RAG的问答系统可以先检索相关文档或维基百科条目,再基于这些资料生成更准确的答案。

示例5-9演示了如何实现一个简单的基于RAG的模型:

from transformers import RagTokenizer, RagRetriever, RagSequenceForGeneration

tokenizer = RagTokenizer.from_pretrained("facebook/rag-token-nq")
retriever = RagRetriever.from_pretrained("facebook/rag-token-nq")

# 加载RAG模型
model = RagSequenceForGeneration.from_pretrained("facebook/rag-token-nq")

question = "What is the capital of France?"

inputs = tokenizer(question, return_tensors="pt")

retrieved_docs = retriever.retrieve(question, return_tensors="pt")

# 使用RAG模型和检索到的文档生成答案
outputs = model.generate(
    input_ids=inputs['input_ids'],
    context_input_ids=retrieved_docs['context_input_ids'],
    context_attention_mask=retrieved_docs['context_attention_mask']
)

answer = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(answer)

RAG在开放域问答等场景尤为有用,这类任务要求模型访问并引用最新或高度专业的信息。

语义内核(Semantic Kernel)

语义内核是一个旨在简化将LLM集成到需要动态知识、推理和状态跟踪的应用中的框架。它特别适合构建复杂、模块化的AI系统,可与外部API、知识库或决策流程交互。语义内核致力于构建更灵活的AI系统,支持多种任务,而不仅限于文本生成。开发者可以轻松组合不同组件——如嵌入、提示模板和自定义函数——形成协同工作的一体化系统。

该框架支持异步处理,适用于管理长时间运行任务或调用外部服务,可结合如思维链提示这类需多步推理的模型使用。语义内核支持维护和检索语义记忆,帮助模型记住过去交互或先前检索的信息,以生成更连贯的结果。最后,它可集成外部函数和API,方便将模型推理与现实数据结合。

示例5-10展示了如何利用语义内核构建模块化助手,实现以下功能:

  • 从知识库检索历史信息
  • 调用外部API获取实时数据(如股价)
  • 处理自然语言指令
  • 通过链接多个AI功能执行复杂推理任务

示例5-10. 语义内核使用示例

from semantic_kernel import Kernel
from semantic_kernel.ai.openai import OpenAITextCompletion
from semantic_kernel.memory import MemoryStore
from semantic_kernel.plugins import AzureTextPlugin

kernel = Kernel()
kernel.add_ai("openai", OpenAITextCompletion(api_key="your-openai-api-key"))

# 设置语义记忆
memory = MemoryStore()
kernel.add_memory("semantic_memory", memory)

# 定义简单的思维链函数
def chain_of_thought(input_text: str) -> str:
    response = kernel.run_ai("openai", "text-davinci-003", input_text)
    return f"Thought Process: {response}"

kernel.add_function("chain_of_thought", chain_of_thought)

user_input = "How does quantum computing work?"

reasoned_output = kernel.invoke("chain_of_thought", user_input)
print("Reasoning Output:", reasoned_output)

kernel.add_plugin("external_api", AzureTextPlugin(api_key="your-azure-api-key"))
external_output = kernel.invoke("external_api", "fetch_knowledge", user_input)
print("External Output:", external_output)

你还可以集成外部功能(如调用Azure API)以丰富模型回答并维护跨交互的状态,使语义内核成为构建复杂AI驱动应用的理想选择。

总结来说,RAG通过整合外部知识源增强生成模型的事实性回答能力,而语义内核提供了一个灵活的框架,用于构建具备高级推理和有状态交互的模块化AI系统。如果你想改变模型行为,则应使用微调方法。

微调(Fine-Tuning)

相比从头训练需要大量数据和算力,微调允许你用更少资源将已训练好的模型适配到新任务。通过基于特定数据或行为调整模型参数,微调使大型语言模型(LLM)在专业应用中更高效,无论是处理某行业的专业术语,还是调整模型的风格和语气。注意,微调会改变模型权重,因此你必须能访问模型权重——无论是直接通过模型检查点,还是像OpenAI通过微调API间接提供的权限。

微调包含多种策略,用以将预训练模型适配至专业任务,提高效率,确保符合用户期望。自适应微调(adaptive fine-tuning)、适配器(adapters)和参数高效方法等技术,可以在降低资源需求的同时,定制LLM以满足特定领域需求。微调不仅关注提升任务准确率,也致力于调整模型行为,确保输出符合伦理、效率和用户友好性。无论是复杂领域专用模型,还是通用助理,微调都能让模型更智能、高效,更贴合实际需求。

本节将深入探讨多种微调关键策略,从自适应微调到前缀调优(prefix tuning)及参数高效微调(PEFT),各自满足不同需求且保持高效。

自适应微调(Adaptive Fine-Tuning)

自适应微调是更新模型参数,使其更好地处理特定数据集或任务的过程。它通过在更贴合目标任务的新数据上训练模型,调整权重,使模型捕捉更多领域知识,同时不忘原有的通用理解。

比如,你有一个基于通用网络文本预训练的LLM,想让它专注于医疗文本、法律术语或客户服务。自适应微调可以帮你实现。
示例提示:

问题:心脏病发作的症状有哪些?  
答案:心脏病发作的症状包括胸痛、呼吸急促、恶心和出冷汗。

适配器(Adapters:单一、并行与分层并行)

适配器是高效微调的强大方法。它不是重新训练整个模型,而是加入小型、任务特定的模块,同时保持原模型参数冻结。这极大降低了计算负担。适配器适合多任务微调,让模型保持通用能力同时适应具体上下文。

适配器方法包括:

  • 单一适配器:为一个任务添加一个适配器,其余模型保持不变。
  • 并行适配器:多个适配器并行训练,分别应对不同任务,模型主体参数冻结。
  • 分层并行适配器:复杂用例下,多个不同规模的适配器协同工作,支持更复杂任务和更高性能,且不增加模型架构负担。

例如对模型同时做文本摘要和情感分析微调,可并行添加两个适配器,分别针对不同任务,保持模型一般知识不变。

行为微调(Behavioral Fine-Tuning)

行为微调旨在调整模型行为以符合特定预期,如生成更合乎伦理、更礼貌或更友好的输出。在现实应用中,模型需与人类价值观保持一致,尤其涉及敏感话题或影响用户决策时。通过在体现期望行为的数据上微调,模型能学习遵守行为准则或伦理规范。此方法对客服聊天机器人、医疗助理等直接面向用户的模型尤其重要。

例如,一个心理健康咨询聊天机器人,可以用强调共情的对话数据微调,保证回复既有帮助又富有同理心。示例:

用户:我今天心情很低落。  
模型(行为微调后):听到你这样我很难过。感到难受时和别人聊聊很重要,你愿意多说说吗?

行为微调确保模型不仅回答准确,更体现恰当语气和伦理。

前缀调优(Prefix Tuning)

前缀调优是一种微调模型行为的技术,无需大幅改变模型结构或核心权重。它只调整模型输入前添加的一小段可调序列——前缀,模型根据该前缀调整输出以适应特定任务。

该方法资源消耗低于传统微调,支持专门化适配且无需重新训练全模型。例如,微调生成诗歌的模型时,前缀可包含诗歌的风格或语气:

前缀:请以莎士比亚风格写一首浪漫诗。  
输入:‘夜空被橙色渲染。’

仅调整前缀,使输出符合莎翁风格,其余模型参数不变。

参数高效微调(Parameter-Efficient Fine-Tuning,PEFT)

PEFT设计用于用极少资源微调大模型。传统微调需修改全部参数,耗时且昂贵。PEFT技术(如低秩适配LoRA、量化LoRA qLoRA)只调整模型权重中的小规模低秩部分,节省内存和计算资源,同时保持性能。它们适合极大模型(如GPT-3、GPT-4),避免昂贵的全量微调。

LoRA通过权重更新的低秩近似减少需微调参数数量,提升效率且不损准确率。qLoRA基于LoRA,加入量化技术进一步降低存储需求,适合大规模部署。

在资源受限环境中部署LLM时,可用LoRA仅调整处理特定任务(如摘要、问答)的权重,实现更快更新、降低计算开销。

指令微调与基于人类反馈的强化学习(Instruction Tuning and Reinforcement Learning from Human Feedback,RLHF)

指令微调是对模型进行微调,使其能够更精确且可靠地遵循明确的指令。这在需要模型根据用户指令或提示持续执行特定任务时尤为有用。

基于人类反馈的强化学习(RLHF)则是通过人类对模型输出的反馈,帮助模型不断改进。这个反馈循环让模型更好地符合用户期望,提高回答的相关性、连贯性和整体质量。

RLHF常用于微调对话代理或其他交互式系统,确保模型输出不仅准确,还能贴合上下文,提供有帮助且恰当的回应。

举例来说,针对虚拟助手,首先用指令微调确保模型能直接回答问题;然后利用RLHF收集用户对回答帮助性的反馈,进一步调整模型以改善对话表现。

示例指令微调提示:

请直接回答以下问题:法国的首都是什么?  
输出:法国的首都是巴黎。

示例RLHF提示:

如何更换汽车机油?  
输出:更换汽车机油包括放掉旧机油、更换机油滤清器,并添加新机油。您需要分步骤指导吗?

通过RLHF,模型能够持续学习和提升,更好地满足现实用户需求。

微调与提示工程的选择

如果无法访问模型权重修改权限,只能用提示工程来适配模型到特定领域。但如果两者都可用,应如何选择?

首先考虑成本,微调计算开销昂贵。通常几小时调试即可得到较好提示,而一次微调实验可能花费数千美元。根据OpenAI(本文撰写时)的最新价目,GPT-4o微调费用约为每百万token 2.5万美元。

微调需要预先支付成本,提示工程则像房贷,随着提示长度增加,每次请求成本增加,但推理成本本身无差异。再者,LLM领域变化快,投资回报期通常较短。若微调和提示工程在10年内性能和成本相当,但模型生命周期仅2年,预付10年微调显然不划算,此时提示工程更经济。

即便你能访问权重、成本不成问题,且只关注性能,也应理解微调与提示工程解决的问题不同。提示工程改变模型接收的上下文,增加信息量。检索增强生成(RAG)相当于大规模的动态提示工程,通过系统基于输入动态生成提示。微调则改变模型行为。图5-2的四象限示意了这一点。

例如,假设模型已具备所需知识,但你希望它用特定XML格式输出答案,而非常规聊天格式。此时,微调模型比通过提示工程提供大量示例效果更佳。

image.png

值得指出的是,通过微调改变模型行为可能带来一个意想不到的后果:模型可能会停止执行它以前能够完成的任务。比如,你使用GPT-3.5-turbo生成关于产品的博客文章,效果不错,但文章技术含量不高。一位AI工程师建议用公司内部“产品聊天”频道中讨论产品技术特性的消息对模型进行微调。微调后,当你让模型“生成一篇关于产品某功能X的500字博客文章”时——这是模型之前还能做得相当不错的任务(只是技术深度不足)——模型却回答:“我太忙了。”微调改变了模型的行为。在这种情况下,基于RAG的方法,通过搜索“产品聊天”数据并向旧模型构建描述功能X的提示,效果会好得多。

专家混合(Mixture of Experts)

自适应微调和参数高效微调(PEFT)通过选择性更新参数或利用指令微调来优化大型语言模型的适配。另一种较新的技术——专家混合(MoE)——则从架构模块化和条件计算的角度进行优化。与微调训练后修改模型参数不同,MoE通过利用多个“专家”——即大模型内部的小型专业子网络(见图5-3)——改变模型结构本身。

推理时,门控系统根据输入只选择并激活少数几个专家,意味着模型每次只使用部分容量来处理查询。好处是:你可以构建一个拥有数万亿参数的超大模型,但计算成本保持较低,因为每次只运行其中一小部分。

这与更新模型以更好处理新任务的自适应微调或参数高效微调不同。MoE让模型内部实现专业化,部分专家擅长某些任务或数据类型,其他专家则专注于不同领域。模型学会动态路由输入,使其在多个领域灵活应用,无需每次都重新训练整个模型。

不过,MoE也有不足。如果门控系统未能均匀分配任务,少数专家承担大部分工作,导致其余专家资源浪费、效率下降。此外,训练MoE模型更复杂,需特殊软硬件支持才能获得速度和成本优势。

image.png

专家混合模型(MoE)如GShard、DeepSeek等,通过将大型语言模型拆分成许多较小的专家子网络,并且针对每个输入词元选择性激活少数几个专家,从而改变了LLM的扩展方式。这种能力的关键在于门控网络——一个小型模块,它利用每个词元的隐藏状态为所有专家计算评分。在GShard中,这些评分经过softmax函数,转换为覆盖所有专家的概率分布。每个词元选择评分最高的两个专家,并将该词元的表示以对应门控概率加权后发送给这两个专家。这种将任务路由到两个专家的做法提升了模型的表达能力,但也增加了通信开销,因为词元需发送到不同设备上的多个专家。

而Switch Transformer简化了这一过程,采用硬路由策略。硬路由要求门控为每个词元仅选择得分最高的单个专家,这意味着每个词元只激活一个专家,从而减少跨设备通信和内存占用。门控输出为一个one-hot向量,标明哪个专家负责处理,顶级单一路由大幅降低了加速器间传输的数据量。

所有MoE模型面临的一个共同挑战是负载均衡。若无约束,门控往往将大部分词元集中分配给少数热门专家,导致部分专家过载而其他专家空闲。这种“专家崩溃”浪费了模型容量并延缓训练收敛。为解决此问题,训练中会向目标函数加入负载均衡损失项。该损失通过计算每个专家分配到的词元比例和门控概率总量,再计算它们的变异系数,对分布不均匀进行惩罚,迫使门控网络更均匀地分配词元。这样可以保持所有专家都处于忙碌状态,充分利用模型参数预算。

每个专家都有固定容量,即每批次最多处理一定数量的词元,以适应内存限制。若词元路由过多,超出部分要么被丢弃,要么重新路由。尽管丢弃词元能防止内存溢出,但也可能导致部分输入数据在训练时被忽略,这是一种需要细致调优的权衡。

在反向传播时,梯度只在激活了对应词元的专家间流动。未参与处理该词元的专家不接收梯度更新。因此,尽管模型参数庞大,但实际计算和内存使用因梯度稀疏而大幅减少,这也是MoE高效扩展的原因之一。

MoE训练过程较为复杂且可能不稳定。研究人员采用多种方法提升稳定性,比如对门控权重进行精心初始化,避免早期极端输出;对门控输出应用dropout,防止网络过度自信地依赖少数专家;使用梯度裁剪保持更新稳定;负载均衡损失不仅提升利用率,还帮助训练期间稳定路由决策。

总体而言,MoE为构建超大规模、可扩展且适应性强的模型提供了另一种路径,但它通常是微调方法的补充,而非替代方案。

资源受限设备的模型优化

针对资源有限的设备进行模型优化,确保模型能在低功耗硬件(如移动设备或边缘设备)上流畅运行。

压缩技术有助于减少模型的计算和内存占用,同时保持性能。这对于在边缘设备上部署大型语言模型(LLM)或优化其在云端的运行时性能尤为重要。常见的LLM压缩技术包括:

提示缓存(Prompt Caching)
提示缓存指存储之前针对常见提示计算出的响应,避免重复运行整个模型,而是快速返回缓存结果。此方法适合客户支持聊天机器人、知识检索系统等频繁查询相同或相似提示的场景,有助于加速推理,减少重复计算,降低高流量系统成本。

键值缓存(Key–Value Caching)
键值缓存针对基于Transformer的LLM优化注意力计算,尤其适用于序列或流式输入数据。它缓存前向传播过程中计算的键(K)和值(V)张量,后续词元可复用这些预计算张量,减少计算冗余。

键值缓存对加速自回归生成(如GPT系列模型)和提升长文本生成的响应速度非常有效。

量化(Quantization)
量化通过降低模型权重的数值精度(例如从32位浮点降至8位甚至4位),显著减少内存需求,加快推理速度,同时对模型准确率影响不大。量化技术包括:

  • 静态量化:在运行前对权重和激活进行量化。
  • 动态量化:运行时动态对激活进行量化。
  • 量化感知训练(QAT) :训练时考虑量化影响,量化后能获得更好的准确率。

剪枝(Pruning)
剪枝通过移除模型中不重要的权重、神经元或整个层,减小模型规模和计算复杂度。结构化剪枝按一定规则系统性删除组件(如层中神经元),非结构化剪枝基于权重重要性移除单个权重。

蒸馏(Distillation)
模型蒸馏通过训练一个较小的“学生”模型,模仿较大“教师”模型的行为来实现。学生模型学习教师模型的输出,包括预测概率(logits)或中间层特征表示。

有效开发大型语言模型的经验教训

训练大型语言模型(LLM)是一个复杂的过程,需要精准的策略来平衡效率、成本和性能。该领域的最佳实践在不断演进。本节介绍一些尚未涵盖的优化方法,包括规模定律、模型大小与数据量的权衡、学习率优化、过拟合陷阱以及创新技术如推测采样等。

规模定律(Scaling Law)

规模定律(示例5-11)描述了模型性能如何随数据量、模型规模或计算资源的增加而提升。研究表明,性能提升通常遵循可预测的曲线,超过某些阈值后收益递减。关键在于优化模型规模和训练数据之间的平衡。通常模型规模和训练数据同时翻倍,效果优于单独翻倍其中之一。同时要注意,若数据规模未相应扩展,模型可能出现训练不足或参数过剩的问题。

示例5-11. 规模定律绘图

import matplotlib.pyplot as plt
import numpy as np

# 模拟规模定律数据
model_sizes = np.logspace(1, 4, 100)  # 模型规模从10^1到10^4
performance = np.log(model_sizes) / np.log(10)  # 模拟性能提升

# 绘制规模定律曲线
plt.plot(model_sizes, performance, label="Scaling Law")
plt.xscale("log")
plt.xlabel("模型规模(对数刻度)")
plt.ylabel("性能")
plt.title("大型语言模型的规模定律")
plt.legend()
plt.show()

Chinchilla模型

Chinchilla模型(示例5-12)挑战了追求模型越来越大的传统思路。它们优先通过增加训练数据量来提升性能,同时保持模型规模不变。这种方法以更低成本达到相当甚至更好的效果。在固定计算预算下,较小规模但训练数据更多的模型,性能优于大型但训练数据有限的模型。

示例5-12. Chinchilla模型训练示意

model_size = "medium"  # 固定模型规模
data_multiplier = 4    # 数据集规模增4倍

model = load_model(size=model_size)
dataset = augment_dataset(original_dataset, multiplier=data_multiplier)

train_model(model, dataset)
evaluate_model(model)

学习率优化

选择合适的学习率对高效训练至关重要。最佳学习率能让模型更快收敛,避免梯度消失或震荡等问题。训练开始时应逐步提升学习率以稳定收敛,随后平滑降低学习率以获得更好最终结果。示例5-13演示了使用余弦退火学习率调度器的代码示例。

示例5-13. 学习率优化

print(f"Epoch {epoch}, Learning Rate: {scheduler.get_last_lr()}")
from torch.optim.lr_scheduler import CosineAnnealingLR
import torch

model = torch.nn.Linear(10, 2)
optimizer = torch.optim.Adam(model.parameters(), lr=0.1)

# 余弦退火学习率调度器
scheduler = CosineAnnealingLR(optimizer, T_max=50)

# 训练循环
for epoch in range(100):
    # 前向传播、计算损失、反向传播...
    optimizer.step()
    scheduler.step()

过拟合

过拟合指模型对训练数据过度拟合,导致泛化能力差。如果发现验证集损失上升而训练损失下降,或模型在测试集上的预测过于自信但错误,说明可能过拟合。早停(early stopping)和正则化等技术能有效缓解。

正则化通过在损失函数中加入惩罚项,防止模型学习过于复杂的训练数据关系。早停技术(示例5-14)监控验证集性能指标(如准确率或损失),当指标停滞或恶化时停止训练。

示例5-14. 早停

from pytorch_lightning.callbacks import EarlyStopping

# 定义早停机制
early_stopping = EarlyStopping(monitor="val_loss", patience=3, verbose=True)

trainer = Trainer(callbacks=[early_stopping])
trainer.fit(model, train_dataloader, val_dataloader)

推测采样(Speculative Sampling)

推测采样是一种加速推理阶段自回归解码的方法。它利用一个较小且更快的模型来预测多个词元候选,然后由更大的模型进行验证。这对于需要低延迟生成的应用非常有用,比如实时对话代理。

理解不同的训练策略和潜在陷阱对于优化大型语言模型(LLM)至关重要。规模定律和Chinchilla模型等技术指导高效利用计算资源进行训练,而学习率优化和推测采样则提升训练和推理的效率。同时,避免过拟合确保模型能很好地泛化到真实世界数据。吸取这些经验教训,将打造出更稳健且具成本效益的LLM。

总结

本章介绍了优化LLM部署的关键方面。从领域适配的方法,如提示工程、微调和检索增强生成(RAG),到高效模型部署策略,涵盖了适配LLM以满足特定任务和资源限制所需的基础知识。每种方法都有其独特优势,使开发者能够根据组织需求和技术限制调整模型的行为、知识或输出。历史上,AI/ML领域术语和技术不断更迭。到本书出版时,你可能会听到如“上下文工程”等新词汇,而这些基础我们在本书中已有涉及。无论使用何种术语,关键在于构建符合LLMOps系统核心目标的系统:可靠性、可扩展性、稳健性和安全性。

本章还探讨了如何通过量化、剪枝和蒸馏等技术优化资源受限环境下的LLM,强调了在计算成本与性能之间取得平衡的重要性。

参考文献

  • Karpathy, Andrej. “Let’s Build GPT: From Scratch, in Code, Spelled Out”, YouTube, 2023年1月17日。
  • Kimothi, Abhinav. “3 LLM Architectures”, Medium, 2023年7月24日。
  • Microsoft. “Introduction to Semantic Kernel”, 2024年6月24日。
  • Wang, Zian (Andy). “Mixture of Experts: How an Ensemble of AI Models Decide As One”, Deepgram, 2024年6月27日。