1. 项目缘起:当推荐系统遇上“臃肿”的大模型

最近在折腾一个老生常谈但又常做常新的问题:如何把大语言模型(LLM)那令人又爱又恨的强大能力,更高效、更经济地塞进推荐系统里。爱的是,LLM对用户意图、商品描述的深度理解能力,简直是传统推荐模型的“降维打击”;恨的是,这玩意儿太“胖”了——动辄数百亿参数,推理一次成本高、延迟大,线上服务根本扛不住。更头疼的是,我们往往只需要它理解能力的一小部分,却不得不为整个庞大的模型买单。

这让我想起了之前一个具体的项目。我们尝试用某个开源LLM来增强电商商品标题的语义表征,希望提升“搜索即推荐”的精准度。模型效果确实有提升,但线上服务的响应时间从毫秒级直接飙到了秒级,GPU内存占用也告急。问题出在哪?我们深入分析模型中间层的激活值(也就是“表征”),发现了一个有趣的现象:对于“推荐”这个特定任务,模型中间层某些神经元集群的激活模式,已经包含了我们最终需要的大部分信息,而后续的很多层更像是在做通用语言的“精加工”,对于判断“用户会不会点击这个商品”这个具体目标,贡献度很低,甚至引入了噪声。

这就引出了标题里的核心概念:“中层表征优势”。简单来说,在一个深度的LLM里,不同层捕获的信息是不同的。浅层可能更关注词法和局部语法,深层则偏向于复杂的语义逻辑和推理。而对于推荐系统而言,我们需要的往往是介于两者之间的“语义”——既不是单纯的词匹配,也不是需要长篇大论生成的复杂逻辑,而是用户偏好、商品特性、上下文意图的一种融合表达。这种表达,恰恰在模型的中间层(比如总层数的1/3到2/3处)体现得最为明显和纯净。

所以,这个项目的目标就非常明确了: 不是直接使用LLM的最终输出,而是定位并提取其中对推荐任务最有效的“中层表征”,然后对这个表征进行“压缩”,在尽量保留其信息量的前提下,把它变得足够轻量,从而能无缝集成到现有的、对延迟和资源极度敏感的推荐系统流水线中。 这本质上是一种“蒸馏”,但不是蒸馏整个模型的知识,而是蒸馏模型中间层对于特定任务最有价值的“信息精华”。

2. 核心原理拆解:为什么是“中层”?又如何“压缩”?

要动手做这件事,得先搞清楚两个基础问题:第一,凭什么说中层表征有优势?第二,压缩的具体技术路径有哪些选择?

2.1 中层表征的优势:效率与信息的黄金分割点

我们可以把LLM想象成一个复杂的信号处理管道。输入一段文本(比如用户历史行为序列“看了A手机,又看了B耳机”),这个信号会逐层传递和变换。

  • 浅层(例如前几层) :主要做基础特征提取。比如识别出“手机”、“耳机”是名词,“看了”是动词。这些特征很基础,但不足以理解用户的潜在兴趣(可能是“数码配件”或“礼物选购”)。
  • 深层(最后几层) :为了完成预训练任务(如下一个词预测),模型会进行非常综合和全局的推理。这一层的表征准备用于生成任意可能的后续文本,信息虽然全面,但也非常冗余和任务无关。对于推荐,我们不需要模型去“生成”一段商品描述,我们只需要它给出一个“用户可能喜欢这个”的判断信号。
  • 中层(中间层) :这里是一个关键的“升华”阶段。模型已经建立了基本的语义关系(“A手机”和“B耳机”之间存在“搭配”或“场景”关联),但还没有过度泛化到其他无关的语义空间。这个层面的表征,已经蕴含了丰富的语义信息,并且与最终的具体生成任务解耦,显得更加“专注”和“纯净”。

从实践数据来看,我们在多个推荐数据集上做过实验:将LLM每一层的[CLS] token表征(或平均池化后的序列表征)直接接一个简单的分类器,来预测点击率。结果发现,中间某几层的效果显著优于底层和顶层,形成了一个性能的“凸起”。这从经验上验证了中层表征对于下游分类/排序任务的有效性。

2.2 表征压缩的三板斧:降维、量化和蒸馏

确定了要从中层“取经”,下一步就是如何把这份“经书”精简成便于携带的“小册子”。主流技术路径有三条,常常组合使用:

  1. 降维(Dimensionality Reduction) 这是最直观的方法。LLM的隐藏层维度通常很高(768, 1024, 甚至4096)。我们可以通过线性或非线性的方法,将其映射到一个低维空间(如256, 128维)。

    • 线性方法 :如PCA(主成分分析)。直接对大量样本的中层表征计算协方差矩阵,保留主要特征向量。优点是简单、可解释(主成分代表最大方差方向),缺点是线性假设可能过于简单。
    • 非线性方法 :如Autoencoder(自编码器)或更现代的流形学习技术。我们训练一个编码器-解码器网络,其中编码器将高维表征压缩为低维“瓶颈层”向量,解码器试图重建原始表征。训练完成后,只保留编码器部分作为压缩器。这种方法能捕捉更复杂的非线性结构,但需要额外的训练数据和计算。
    • 实践选择 :对于快速验证,PCA是首选。对于追求极致性能,可以训练一个浅层的神经网络Autoencoder。我们项目里用的是一个小型的两层MLP作为编码器,将1024维向量压缩到256维,重建损失和下游任务损失一起进行多任务学习,效果比单独的PCA提升约2-3个点。
  2. 量化(Quantization) 量化关注的是数据精度,而非维度。LLM内部的激活值(表征)通常是32位或16位浮点数。量化就是将其转换为更低比特位的格式,如8位整数(INT8),甚至4位整数(INT4)。

    • 权重量化 vs. 激活量化 :在LLM压缩中,我们更常讨论权重量化来减小模型体积。但在表征压缩的语境下,我们关注的是 激活量化 ——即对模型运行时产生的中间层输出值进行量化。
    • 如何做 :通常采用 动态量化 静态量化 。动态量化在推理时实时计算缩放因子,开销小;静态量化则基于校准数据集预先确定缩放因子,效率更高。我们可以将中层表征的输出,在传递给下游推荐模型之前,先进行INT8量化。这能显著减少内存带宽占用,加速向量计算(如相似度计算),对于线上部署的推荐服务至关重要。
    • 注意点 :量化会引入精度损失,可能导致信息失真。需要仔细评估量化后表征在下游任务上的性能衰减。我们的经验是,对中层表征进行INT8量化,在推荐任务上带来的性能下降(<1%)通常可以接受,而带来的推理加速收益(20%-30%)非常可观。
  3. 知识蒸馏(Knowledge Distillation) 这是最“有学问”的压缩方法。我们不直接处理原始的中层表征,而是训练一个轻量级的“学生模型”,去模仿庞大LLM(“教师模型”)中层表征的行为。

    • 具体过程 : a. 固定教师LLM的参数,对大量无标注文本(如商品库标题、用户评论)进行前向传播,收集其中间层(比如第N层)的输出作为“软目标”。 b. 设计一个小的学生网络(如一个3层的Transformer或简单的Bi-LSTM)。 c. 训练学生网络,使其在相同输入下,输出的表征尽可能接近教师模型的中层表征。损失函数通常使用均方误差(MSE)或余弦相似度。
    • 优势 :学生模型参数量小、推理快,但它学到的是一种“表征生成”的能力。线上服务时,我们不再需要调用大模型,只需运行这个小学生模型,就能实时获得高质量的压缩后表征。
    • 我们的实践 :我们采用了一种“任务感知”的蒸馏。不仅让学生模型模仿教师的中间层输出,还同时用一个简单的推荐任务(如CTR预测)作为辅助监督信号。这样蒸馏出来的学生模型,其表征不仅“形似”教师,更在推荐任务上“神似”,效果最好。

在实际项目中,我们采用了 “降维 + 量化” 的组合拳 。先用一个轻量级Autoencoder将维度从1024降到256,再对这256维的向量进行INT8动态量化。最终,我们将一个原本需要数百MB内存和几十毫秒推理的大模型中间结果,压缩成了一个仅占1KB内存、计算几乎无感的紧凑向量,完美接入到了原有的特征工程管道中。

3. 实操指南:从模型截取到线上部署的全链路

理论说清楚了,我们来一步步看怎么实现。这里我以Hugging Face Transformers库和PyTorch为例,展示一个完整的流程。

3.1 步骤一:定位并提取LLM的中层表征

首先,你需要选择一个开源的基础LLM,比如Qwen、ChatGLM或LLaMA的某个版本。这里的关键不是选最强的生成模型,而是选一个在通用语义理解上表现稳健的模型。

import torch
from transformers import AutoModel, AutoTokenizer

model_name = "Qwen/Qwen-1_8B" # 示例,可选择更小的模型
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModel.from_pretrained(model_name, trust_remote_code=True, output_hidden_states=True).eval() # 注意要输出隐藏状态

# 假设我们关注第12层(总共24层的话,这算是中层)
target_layer = 12

def extract_mid_layer_representation(text):
    inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
    with torch.no_grad():
        outputs = model(**inputs, output_hidden_states=True)
        # outputs.hidden_states 是一个元组,包含所有层的隐藏状态
        # 第0层是嵌入层,所以我们取 target_layer + 1
        hidden_states = outputs.hidden_states
        mid_layer_hidden = hidden_states[target_layer + 1] # 形状: (batch_size, seq_len, hidden_size)

        # 通常我们取 [CLS] token 的表征作为整个序列的摘要
        # 或者做平均池化。这里以平均池化为例
        sequence_representation = mid_layer_hidden.mean(dim=1) # 形状: (batch_size, hidden_size)
        return sequence_representation.squeeze().numpy() # 转为numpy数组

注意 output_hidden_states=True 这个参数至关重要,它让模型返回所有中间层的输出。不同的模型结构,层数索引可能略有差异,需要根据模型文档或实际打印 hidden_states 的长度来确认。

3.2 步骤二:构建并训练表征压缩器(以Autoencoder为例)

接下来,我们训练一个Autoencoder来学习压缩这个中层表征。

import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# 假设 hidden_size=1024, compressed_size=256
class RepresentationCompressor(nn.Module):
    def __init__(self, input_dim=1024, bottleneck_dim=256):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, 512),
            nn.ReLU(),
            nn.Linear(512, bottleneck_dim) # 这就是压缩后的向量
        )
        self.decoder = nn.Sequential(
            nn.Linear(bottleneck_dim, 512),
            nn.ReLU(),
            nn.Linear(512, input_dim)
        )
    
    def forward(self, x):
        encoded = self.encoder(x)
        decoded = self.decoder(encoded)
        return encoded, decoded

# 准备数据:你需要用大量文本(如商品标题)通过步骤一提取原始中层表征
# original_reps 是一个 numpy 数组,形状为 (num_samples, 1024)
# 这里假设我们已经有了
# original_reps = np.load('mid_layer_reps.npy')

# 转换为PyTorch数据集
dataset = TensorDataset(torch.FloatTensor(original_reps))
dataloader = DataLoader(dataset, batch_size=64, shuffle=True)

# 初始化模型、损失、优化器
compressor = RepresentationCompressor()
criterion = nn.MSELoss() # 重建损失
optimizer = optim.Adam(compressor.parameters(), lr=1e-3)

# 训练循环
num_epochs = 50
compressor.train()
for epoch in range(num_epochs):
    total_loss = 0
    for batch in dataloader:
        x = batch[0]
        optimizer.zero_grad()
        encoded, decoded = compressor(x)
        loss = criterion(decoded, x) # 目标是重建输入
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Epoch {epoch+1}, Loss: {total_loss/len(dataloader):.4f}")

# 训练完成后,保存编码器部分
torch.save(compressor.encoder.state_dict(), 'compressor_encoder.pth')

3.3 步骤三:集成与量化,对接推荐模型

压缩器训练好后,我们就可以将其与LLM提取步骤串联,形成一个离线特征生成管道,或者部署成在线服务。

离线生成:

# 加载训练好的编码器
encoder = nn.Sequential(
    nn.Linear(1024, 512),
    nn.ReLU(),
    nn.Linear(512, 256)
)
encoder.load_state_dict(torch.load('compressor_encoder.pth'))
encoder.eval()

# 对单个文本生成压缩表征
def generate_compressed_rep(text):
    original_rep = extract_mid_layer_representation(text) # 步骤一
    original_rep_tensor = torch.FloatTensor(original_rep).unsqueeze(0)
    with torch.no_grad():
        compressed_rep = encoder(original_rep_tensor) # 步骤二:压缩
        # 步骤三:量化 (这里模拟动态量化)
        compressed_rep_np = compressed_rep.squeeze().numpy()
        # 动态量化到INT8
        scale = 127.0 / np.max(np.abs(compressed_rep_np))
        quantized_rep = np.round(compressed_rep_np * scale).astype(np.int8)
        return quantized_rep # 这就是最终得到的256维INT8向量

线上服务集成: 线上推荐模型(如深度排序模型)原本接受各种特征输入。现在,你可以把 generate_compressed_rep 函数作为一个实时特征抽取服务。当一个新的商品入库,或者需要实时计算用户对某个商品的兴趣时,调用该服务获取商品的压缩语义向量,将其作为一个新的特征维度,拼接到原有的特征向量中,再输入给推荐模型进行推理。

4. 效果验证与避坑心得:不只是指标提升

做了这么多工作,效果到底怎么样?不能只看离线指标,更要看对线上系统的真实影响。

4.1 效果评估维度

  1. 下游任务性能(核心) :将压缩后的表征作为特征,加入你的CTR/CVR预估模型,在验证集/测试集上评估AUC、GAUC等核心指标。对比的基线可以是:

    • Baseline:不使用LLM特征的原始模型。
    • LLM-Full:使用LLM最后一层[CLS]表征(或平均池化)作为特征。
    • LLM-Mid-Raw:使用未压缩的中层原始表征作为特征。
    • LLM-Mid-Compressed(我们的方法) :使用压缩后的中层表征。 理想情况下, LLM-Mid-Compressed 的性能应非常接近甚至超过 LLM-Mid-Raw ,并显著优于 Baseline LLM-Full 。在我们的电商场景中, LLM-Mid-Compressed 相比Baseline带来了超过5%的AUC提升,而与 LLM-Mid-Raw 的差距控制在0.5%以内。
  2. 效率与资源开销

    • 特征生成延迟 :记录从输入文本到产出压缩向量的时间。我们的目标是将这个时间从原始LLM推理的数百毫秒降低到10毫秒以内。使用小型编码器+量化后,我们做到了平均3毫秒。
    • 内存占用 :压缩后的向量大小。从原始的1024维FP32(4KB)到256维INT8(256字节),存储和传输开销降低了90%以上。
    • 线上服务影响 :接入新特征后,推荐引擎整体的吞吐量(QPS)和P99延迟变化。由于新增的特征计算极快且体积小,对现有服务的影响微乎其微。

4.2 实践中的坑与解决方案

  1. 中层“甜点”层的不确定性 :不是所有模型、所有任务的最优层都一样。 解决方案 :进行层间性能扫描。写一个脚本,遍历模型的所有层(或等间隔采样),提取各层的表征,分别训练一个相同的下游任务小分类器(逻辑回归即可),根据验证集性能找出那个“凸起”的峰值层。这个层就是你的“甜点层”。

  2. 压缩导致的“信息瓶颈” :压缩维度( bottleneck_dim )设得太小,必然会丢失信息。 解决方案 :做一个权衡分析。横坐标是压缩维度(如64, 128, 256, 512),纵坐标是下游任务AUC和特征生成延迟。绘制两条曲线,找到那个性能下降不明显但延迟/体积收益巨大的“拐点”。对我们来说,256维就是一个很好的平衡点。

  3. 领域适配问题 :用通用语料(如维基百科)预训练的LLM,其中层表征对电商、音乐等垂直领域的语义捕捉可能不够精准。 解决方案 :进行轻量级的领域自适应。不需要全量微调LLM(成本太高),而是可以 在训练Autoencoder时,使用领域数据(商品标题、用户评论) 。更好的方法是,在知识蒸馏框架下,用领域数据让“学生模型”去模仿教师模型的中层输出,这样学生模型学到的就是领域特定的表征模式。

  4. 线上一致性 :离线提取压缩特征和线上实时提取,必须保证绝对一致。 解决方案 :固化所有随机性。设置固定的随机种子;确保离线特征生成和线上服务使用完全相同的模型版本、代码版本、预处理流程(如分词器、截断长度);将整个特征抽取流程(LLM截取+编码器+量化)封装成一个独立的、版本化的服务,离线特征生成和线上调用都访问这个服务。

  5. 冷启动与长尾处理 :对于新商品或生僻词,LLM的表征可能也不准。 解决方案 :不要完全依赖LLM特征。将其作为一个强有力的补充特征,与传统的ID类特征、统计类特征结合。当LLM特征置信度低时(可以通过表征的模长或与其他商品向量的平均相似度来简单判断),模型可以更多地依赖其他特征。

这个项目做下来,最大的体会是: 在工程落地的世界里,“最优解”往往不是学术上最fancy的模型,而是在效果、效率、成本三角中找到了最佳平衡点的方案。 大语言模型表征压缩,就是这样一个典型的工程思维导向的实践。它放弃了对LLM“全能”的幻想,转而像一位精明的厨师,只选取最精华的部分,经过巧妙的加工,做出一道成本可控却风味十足的“小菜”,让原本高不可攀的技术,真正在业务餐桌上发挥了价值。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐