1. 为什么生成式推荐值得折腾从判别式到生成式的范式迁移推荐系统这十年基本是判别式模型的天下。从早期的逻辑回归、GBDT到后来的WideDeep、DeepFM再到DIN、DIEN这类序列建模核心思路一直没变给定用户历史行为预测下一个物品的点击概率然后按分数排序取Top-K。这套范式跑得很成熟但天花板也越来越明显——它本质上是在做“打分排序”而不是在“理解意图”。生成式推荐换了个思路把推荐问题重新定义为序列生成问题。用户的历史交互序列当作prompt模型自回归地生成下一个可能交互的物品token序列。这个转变带来的好处是模型不再只是拟合一个打分函数而是学习整个物品空间的分布结构天然具备更强的泛化能力和多任务统一能力。MiniOneRec就是在这个背景下出现的一个开源生成式推荐框架。它的定位很清晰不是工业级推荐系统而是一个可复现、可拆解、可魔改的研究级框架。整个pipeline从SID构建开始经过监督微调SFT再到强化学习微调RL把生成式推荐的核心环节都串起来了。适合谁看如果你已经做过传统推荐想了解生成式推荐到底怎么落地或者你做大模型微调想看看推荐场景下SFT和RL怎么配合——这个框架都值得跑一遍。我花了大概两周时间把MiniOneRec从环境搭建到RL微调完整跑通中间踩了不少坑也积累了一些文档里不会写的经验。下面按SID构建、SFT、RL三个阶段拆开讲每个阶段都会说清楚“为什么这么做”和“实际怎么做”。2. SID构建生成式推荐的地基怎么打2.1 SID到底是什么为什么不能直接用Item IDSID全称Semantic ID语义标识符。传统推荐里每个物品有一个唯一的Item ID比如商品库里有1000万个SKU那Item ID就是从0到9999999的整数。这种ID的问题是它没有任何语义信息ID12345和ID12346之间可能毫无关系但ID12345和ID9999999之间反而可能是同类商品。生成式模型要生成Item ID如果直接用这种无意义的整数模型需要记住每个ID对应的物品是什么参数量全花在死记硬背上泛化能力极差。SID的思路是先用物品的内容特征标题、图片、类目等学一个语义向量然后对这个向量做量化得到一串离散的码字比如a_12b_34c_56。这串码字就是SID它既保留了语义相似性又适合生成式模型逐token生成。提示SID的层级数码本数量和每层的码本大小是两个关键超参。层数太多会导致序列过长生成效率低层数太少则语义区分度不够。实践中3层、每层256或512个码字是比较常见的配置。2.2 基于RQ-VAE的SID构建实操MiniOneRec用的是RQ-VAEResidual Quantized VAE来构建SID。整个流程分三步第一步提取物品内容特征。把每个物品的文本描述用预训练语言模型比如BERT或Sentence-BERT编码成768维或1024维的向量。如果物品有图片也可以用视觉编码器提取视觉特征然后拼接或融合。第二步训练RQ-VAE做残差量化。RQ-VAE的核心是“残差量化”第一层码本量化原始向量得到第一个码字和量化误差第二层码本量化这个误差得到第二个码字和新的误差以此类推。这样每一层都在修正上一层的残差最终用多个码字逼近原始向量。# RQ-VAE核心逻辑示意简化版 import torch import torch.nn as nn class RQVAE(nn.Module): def __init__(self, input_dim768, codebook_size256, num_levels3, latent_dim32): super().__init__() self.encoder nn.Linear(input_dim, latent_dim) self.codebooks nn.ModuleList([ nn.Embedding(codebook_size, latent_dim) for _ in range(num_levels) ]) self.decoder nn.Linear(latent_dim, input_dim) def forward(self, x): z self.encoder(x) residual z codes [] quantized torch.zeros_like(z) for codebook in self.codebooks: # 找最近邻码字 dist torch.cdist(residual, codebook.weight) idx dist.argmin(dim-1) codes.append(idx) q codebook(idx) quantized quantized q residual residual - q x_recon self.decoder(quantized) return x_recon, codes, z, quantized训练时的损失函数包含重构损失和码本对齐损失# 损失函数 recon_loss nn.MSELoss()(x_recon, x) codebook_loss sum( nn.MSELoss()(q.detach(), residual_before) nn.MSELoss()(q, residual_before.detach()) for q, residual_before in zip(quantized_list, residual_list) ) commitment_loss nn.MSELoss()(z.detach(), quantized) total_loss recon_loss codebook_loss 0.25 * commitment_loss第三步生成SID序列。训练完成后每个物品过一遍RQ-VAE取每层的argmin索引拼成SID。比如3层256码本SID就是a_45b_128c_7这样的形式。所有物品的SID构成一个码表后续SFT和RL都基于这个码表。2.3 SID构建的避坑经验坑一码本坍缩。训练RQ-VAE时经常出现大部分物品都映射到少数几个码字其他码字成了“死码”。原因是码本初始化不好或者commitment loss权重太低。解决办法用k-means初始化码本或者加一个码字使用率的正则项。坑二层级间信息冗余。如果第一层已经量化得很准后面几层的残差就很小导致后面几层的码字区分度低。实践中可以给每层的重构损失加不同权重让前面层负责粗粒度、后面层负责细粒度。坑三SID冲突。不同物品可能得到完全相同的SID序列尤其是物品库很大但码本较小时。需要统计冲突率如果超过1%就要考虑增大码本或增加层数。我实测下来3层256码本在10万物品规模下冲突率大约0.3%可以接受。3. 监督微调让大模型学会生成SID3.1 SFT的数据构造与格式设计SFT阶段的目标是让一个预训练语言模型学会“给定用户历史交互序列生成下一个物品的SID”。数据构造的核心是把用户行为序列转成指令格式。MiniOneRec的SFT数据格式大致是这样的### Instruction: 用户历史交互物品SID序列a_12b_34c_56 a_78b_90c_11 a_23b_45c_67 请预测下一个可能交互的物品SID。 ### Response: a_89b_12c_34这里有几个设计决策需要解释为什么用Instruction格式而不是直接拼接因为Instruction格式可以统一多任务。比如除了“预测下一个物品”还可以加“预测用户可能喜欢的类目”“生成推荐理由”等任务共用同一个模型。MiniOneRec目前主要做next-item prediction但格式留了扩展空间。为什么SID要用特殊token包裹因为SID的每一层码字需要作为独立的token参与生成。如果直接用数字模型会把它当成连续数值处理但实际上码字之间是离散的、无序的。用a_12这种特殊token模型能明确知道这是一个离散码字且不同层之间用不同前缀区分。序列长度怎么定用户历史序列太长会导致训练效率低太短则信息不足。MiniOneRec默认取最近20个交互物品每个物品SID 3层加上指令部分总序列长度大约200-300 token。这个长度在单卡A100上batch size可以开到32以上。3.2 LoRA微调实战为什么选LoRA而不是全参微调MiniOneRec的SFT默认用LoRALow-Rank Adaptation做微调。原因很直接全参微调一个7B模型需要至少4张A100 80G而LoRA只需要1张A100 40G就能跑起来。对于研究复现来说LoRA的门槛低太多了。LoRA的核心思想是在原始权重旁边加一个低秩分解的增量# LoRA层实现示意 class LoRALayer(nn.Module): def __init__(self, original_layer, rank8, alpha16): super().__init__() self.original_layer original_layer self.rank rank self.alpha alpha in_dim original_layer.in_features out_dim original_layer.out_features self.lora_A nn.Linear(in_dim, rank, biasFalse) self.lora_B nn.Linear(rank, out_dim, biasFalse) nn.init.kaiming_uniform_(self.lora_A.weight) nn.init.zeros_(self.lora_B.weight) def forward(self, x): original_out self.original_layer(x) lora_out self.lora_B(self.lora_A(x)) * (self.alpha / self.rank) return original_out lora_out关键参数选择参数推荐值说明rank8-32越大容量越强但显存和过拟合风险也越大alpha16-64通常设为rank的2倍target_modulesq_proj, v_proj注意力层的Q和V矩阵性价比最高lora_dropout0.05-0.1防止过拟合learning_rate1e-4到3e-4比全参微调大一个量级我实测下来rank16、alpha32、target_modules包含q_proj和v_proj在MiniOneRec的SFT任务上效果已经接近全参微调。如果显存允许把k_proj和o_proj也加上会更好但提升幅度不大。3.3 SFT训练过程中的关键细节数据配比。如果只用next-item prediction任务模型容易过拟合到热门物品。MiniOneRec的做法是加入负采样每个正样本配4-8个负样本负样本从用户未交互物品中随机采样。但注意负样本的SID也要构造完整的Response只是标签不同。学习率调度。LoRA微调建议用cosine decaywarmup比例设0.03-0.05。我试过constant learning rate前期loss下降快但后期震荡明显cosine decay更稳。梯度累积。如果单卡batch size只能开到8可以用梯度累积到32或64。注意梯度累积时loss要除以累积步数否则等效学习率会变大。评估指标。SFT阶段主要看两个指标Next-item prediction的准确率生成的SID和真实SID完全匹配的比例和SID层级匹配率只匹配第一层或前两层的比例。完全匹配率通常不高10%-20%但层级匹配率能到60%以上说明模型学到了粗粒度的语义。注意SFT阶段不要过度追求完全匹配率。生成式推荐的价值在于泛化而不是死记硬背。如果完全匹配率过高比如超过40%很可能是过拟合了RL阶段反而效果不好。4. 强化学习微调让生成结果更符合推荐目标4.1 为什么SFT之后还需要RLSFT本质上是在做“模仿学习”给定用户历史模仿真实的下一个交互。但推荐系统的目标不只是“预测准确”还包括多样性、新颖性、长期收益等。SFT学到的模型倾向于生成热门物品的SID因为热门物品在训练数据中出现频率高。RL阶段的目标是引入推荐目标的直接优化。MiniOneRec用的是策略梯度方法把生成模型当作策略根据生成的SID对应的物品是否被用户交互来给奖励。4.2 RL微调的核心流程第一步定义奖励函数。最直接的奖励是“生成的物品是否在用户真实交互列表中”是则1否则0。但这样太稀疏可以加一些辅助奖励def compute_reward(generated_sid, target_sid, item_popularity): # 完全匹配奖励 exact_match 1.0 if generated_sid target_sid else 0.0 # 层级匹配奖励部分匹配给部分分 level_match sum( 1 for g, t in zip(generated_sid, target_sid) if g t ) / len(target_sid) # 多样性奖励惩罚过于热门的物品 diversity_bonus 1.0 / (1.0 item_popularity.get(generated_sid, 0)) reward 0.5 * exact_match 0.3 * level_match 0.2 * diversity_bonus return reward第二步采样与策略更新。对每个用户历史用当前策略生成K个候选SID比如K8计算每个候选的奖励然后用REINFORCE算法更新策略# REINFORCE核心逻辑 for batch in dataloader: histories, targets batch # 采样K个候选 candidates model.generate(histories, num_return_sequences8) rewards [compute_reward(c, t) for c, t in zip(candidates, targets)] # 归一化奖励 rewards (rewards - rewards.mean()) / (rewards.std() 1e-8) # 策略梯度损失 log_probs model.compute_log_probs(histories, candidates) loss -(log_probs * rewards).mean() loss.backward() optimizer.step()第三步KL散度约束。RL微调容易跑偏生成一些奇怪的SID序列。需要加一个KL散度约束让RL后的策略不要偏离SFT策略太远kl_loss nn.KLDivLoss()( F.log_softmax(rl_logits, dim-1), F.softmax(sft_logits, dim-1) ) total_loss policy_loss beta * kl_lossbeta通常设0.01-0.1太小约束不够太大RL学不到东西。4.3 RL微调的实操经验与避坑坑一奖励稀疏导致训练不动。如果只用exact match做奖励大部分样本奖励为0梯度信号极弱。解决办法是用层级匹配奖励做shaping或者用reward model做稠密奖励。坑二模式坍缩。RL训练几轮后模型可能只生成少数几个高奖励的SID多样性急剧下降。解决办法是加熵正则项鼓励策略保持一定的随机性entropy -(F.softmax(logits, dim-1) * F.log_softmax(logits, dim-1)).sum(dim-1) total_loss policy_loss beta * kl_loss - entropy_coef * entropy.mean()坑三训练不稳定。RL的方差很大需要调小学习率比SFT小一个量级比如1e-5到5e-5并且用梯度裁剪max_grad_norm1.0。坑四评估困难。RL阶段不能只看准确率还要看多样性、覆盖率等指标。MiniOneRec建议同时跟踪Top-1准确率、Top-10召回率、生成结果的熵衡量多样性、以及不同物品的覆盖数量。提示RL微调建议在SFT收敛后再开始不要SFT还没训好就上RL。我试过SFT只训了1个epoch就上RL结果模型直接崩了生成的SID全是乱码。SFT至少训到验证集loss不再下降再开始RL。5. 常见问题速查与排查技巧5.1 SID构建阶段问题排查问题现象可能原因排查方法解决方案码本坍缩大部分物品映射到少数码字码本初始化差或commitment loss权重低统计每个码字的使用频率k-means初始化码本提高commitment loss权重SID冲突率高码本太小或层数太少统计完全相同的SID数量增大码本到512或1024或增加层数到4层重构损失降不下去编码器容量不足或latent_dim太小检查重构向量和原始向量的余弦相似度增大latent_dim到64或128加深编码器层级间信息冗余前面层量化太准后面层残差太小统计每层码字的熵给每层重构损失加不同权重前面层权重低一些5.2 SFT阶段问题排查问题现象可能原因排查方法解决方案Loss震荡不收敛学习率太大或batch size太小打印每个step的loss降低学习率到1e-4增大梯度累积步数完全匹配率过高过拟合训练集对比训练集和验证集匹配率加dropout减小rank加负采样生成结果全是热门物品训练数据热门偏差统计生成结果的物品分布加多样性奖励对热门物品降采样显存不够batch size太大或序列太长用nvidia-smi监控显存减小batch size用梯度检查点用4-bit量化5.3 RL阶段问题排查问题现象可能原因排查方法解决方案奖励不上升奖励太稀疏或学习率太小打印平均奖励和梯度范数用层级匹配做reward shaping增大学习率模式坍缩熵正则太弱统计生成结果的熵增大entropy_coef到0.01-0.05训练崩溃生成乱码KL约束太弱或学习率太大检查KL散度值增大beta到0.1降低学习率到1e-5评估指标波动大采样方差大多次评估取平均增大评估样本数用temperature0.7采样5.4 独家避坑技巧技巧一SID构建时保留原始向量。训练完RQ-VAE后把每个物品的原始语义向量也存下来。后续如果发现某些SID冲突或质量差可以快速定位是哪些物品方便手动调整。技巧二SFT数据做去重。用户行为序列中经常有重复物品比如用户反复查看同一个商品。训练前把连续重复的物品去重只保留第一次出现能显著提升训练效率。技巧三RL阶段用课程学习。先只用exact match奖励训练几轮等模型能稳定生成正确SID后再加入多样性奖励和层级奖励。这样比一开始就用复杂奖励更稳定。技巧四定期保存checkpoint。RL训练不确定性很大可能某一轮突然崩掉。每100步保存一次checkpoint崩了可以回滚到之前的状态。技巧五用wandb或tensorboard记录所有指标。不要只看loss要把奖励、熵、KL散度、准确率、多样性都记录下来。很多问题在loss上看不出来但在其他指标上很明显。6. 从跑通到跑好一些个人体会MiniOneRec这个框架最大的价值是把生成式推荐的完整链路串起来了而且代码结构比较清晰适合魔改。但“跑通”和“跑好”之间差距很大。我第一遍跑的时候SFT完全匹配率只有8%RL之后反而降到了5%。排查了很久才发现是SID构建阶段码本坍缩了大部分物品的SID第一层都是同一个码字模型根本学不到区分度。重新用k-means初始化码本后SFT匹配率到了15%RL之后稳定在18%左右。另一个体会是RL阶段不要贪多。我一开始同时加了exact match、层级匹配、多样性、新颖性四个奖励结果模型直接学崩了。后来改成先只优化exact match稳定后再逐步加其他奖励效果反而更好。RL微调本质上是在一个高维空间里做随机搜索奖励函数太复杂会让搜索方向变得模糊。最后说一个实际部署时的考虑SID的生成速度比传统推荐打分慢很多因为要自回归生成多个token。如果要做在线服务需要做约束解码比如限制每层只能从Top-50码字中选或者用投机采样加速。MiniOneRec目前还是研究框架没做这些工程优化但思路是通的。这个框架后续还可以扩展的方向包括多模态SID融合文本和图像、跨域推荐不同域的SID共享码本、以及和传统推荐模型做ensemble。如果你也在折腾生成式推荐建议先从MiniOneRec的SFT阶段跑通理解SID的生成逻辑再往RL走。不要一上来就端到端中间任何一个环节出问题都很难定位。
