MiniOneRec 项目面试复习:从 Semantic ID 到 GRPO 的生成式推荐闭环

June 25, 2026 By 周健
...

这篇是我给 MiniOneRec 项目准备的面试复习稿。它不是论文笔记,也不是 API 使用说明,而是按面试表达来组织:先讲清楚问题,再把 SID 构造、SFT、GRPO、约束解码和指标防守串成一条完整链路。

我希望自己讲这个项目时,不只是说“我跑通了一个生成式推荐框架”,而是能把每一步的输入、输出、训练目标和风险讲清楚。

30 秒讲法

MiniOneRec 是一个生成式序列推荐项目。传统序列推荐通常对候选商品打分排序,这里把商品先编码成层次化 Semantic ID,再让 LLM 根据用户历史 SID 直接生成下一个商品 SID。

我复现和梳理的主线是:

Amazon 交互和商品元数据
-> Qwen 编码商品 title / description
-> RQ-VAE 生成三层 Semantic ID
-> 扩展 Qwen tokenizer,把 SID token 加入词表
-> 多任务 SFT:历史 SID 到下一 SID,Title 和 SID 双向对齐
-> GRPO:同一上下文生成多个候选,用命中奖励和排序奖励优化
-> 约束 Beam Search:只允许生成合法 SID
-> HR / NDCG / CC 离线评估

面试里我会主动强调两点。

第一,这不是简单把 item id 换成字符串。SID 的价值在于把商品文本语义压缩成 LLM 可以生成的离散 token,并且保留层级结构。

第二,生成式推荐最大的工程风险是合法性。LLM 如果生成了不存在的 item,HR/NDCG 再高也没有意义,所以训练和评估都要围绕合法 SID 空间做约束。

整体链路:把排序改成受约束生成

这个项目解决的是序列推荐:给定用户按时间排序的历史交互,预测下一件可能交互的商品。

传统路线是:

user history + candidate item -> score(candidate)

MiniOneRec 的路线是:

user history SID -> LLM generates next item SID

这带来三个变化:

问题 传统推荐 MiniOneRec 里的处理
商品表示 item id / embedding title + description 经 Qwen 编码,再量化成 Semantic ID
训练目标 对候选打分或 next-item classification causal LM 生成下一商品 SID
推理合法性 候选集天然合法 需要前缀约束,防止生成不存在的 SID

本地 Industrial 数据里,Industrial_and_Scientific.index.json 有 3686 个 item,去重后实际新增 560 个 SID 子 token;Office Products 有 3459 个 item,新增 600 个 SID 子 token。这个数字也提醒我:新增 token 数量不是完整 SID 组合数,而是各层 <a_*>/<b_*>/<c_*> 子 token 的去重数量。

SID 构造:从商品文本到层次离散码

SID 构造的第一步是把商品文本变成连续向量。代码读取 .item.json,拼接 title 和 description,tokenize 后送进 Qwen,然后对有效 token 做 masked mean pooling。

# /Users/zhoujian/Desktop/MiniOneRec/rq/text2emb/amazon_text2emb.py:94
encoded_sentences = tokenizer(
    batch_texts,
    max_length=args.max_sent_len,
    truncation=True,
    return_tensors='pt',
    padding=True
).to(accelerator.device)

outputs = model(input_ids=input_ids, attention_mask=attention_mask)
last_hidden = outputs.last_hidden_state
mask_expanded = attention_mask.unsqueeze(-1).expand(last_hidden.size()).float()
sum_embeddings = torch.sum(last_hidden * mask_expanded, dim=1)
sum_mask = torch.clamp(mask_expanded.sum(dim=1), min=1e-9)
mean_output = sum_embeddings / sum_mask

这里我会解释为什么用 mean pooling,而不是直接取最后一个 token。Qwen 是 decoder-only 模型,没有像 BERT 那样天然训练过的 CLS 句向量;最后一个 token 又容易被模板、截断或标点影响。masked mean pooling 至少保证 padding 不参与平均,得到一个稳定的商品级向量。

第二步是 RQ-VAE。它有三个部分:encoder、ResidualVectorQuantizer、decoder。

# /Users/zhoujian/Desktop/MiniOneRec/rq/models/rqvae.py:46
self.encode_layer_dims = [self.in_dim] + self.layers + [self.e_dim]
self.encoder = MLPLayers(layers=self.encode_layer_dims,
                         dropout=self.dropout_prob,bn=self.bn)

self.rq = ResidualVectorQuantizer(num_emb_list, e_dim,
                                  beta=self.beta,
                                  kmeans_init=self.kmeans_init,
                                  kmeans_iters=self.kmeans_iters,
                                  sk_epsilons=self.sk_epsilons,
                                  sk_iters=self.sk_iters,)

self.decoder = MLPLayers(layers=self.decode_layer_dims,
                         dropout=self.dropout_prob,bn=self.bn)

encoder 把 Qwen embedding 压到 latent 空间;ResidualVectorQuantizer 分层量化 latent;decoder 只在训练时用于重建原 embedding,逼迫离散 code 保留语义信息。上线或评估时并不是拿 decoder 把 SID 还原成商品。

残差量化的核心逻辑很短:

# /Users/zhoujian/Desktop/MiniOneRec/rq/models/rq.py:43
x_q = 0
residual = x
for quantizer in self.vq_layers:
    x_res, loss, indices = quantizer(residual, use_sk=use_sk)
    residual = residual - x_res
    x_q = x_q + x_res
    all_indices.append(indices)

第一层先对 z 找最近 code,第二层再量化剩余 residual,第三层继续补残差。最后得到类似 <a_104><b_118><c_176> 的层次 SID。

RQ-VAE loss 由重建损失和量化损失组成:

# /Users/zhoujian/Desktop/MiniOneRec/rq/models/rqvae.py:74
if self.loss_type == 'mse':
    loss_recon = F.mse_loss(out, xs, reduction='mean')
loss_total = loss_recon + self.quant_loss_weight * quant_loss

量化层里还有两个常被追问的点:codebook loss、commitment loss,以及 STE。

# /Users/zhoujian/Desktop/MiniOneRec/rq/models/vq.py:89
commitment_loss = F.mse_loss(x_q.detach(), x)
codebook_loss = F.mse_loss(x_q, x.detach())
loss = codebook_loss + self.beta * commitment_loss

# preserve gradients
x_q = x + (x_q - x).detach()

面试回答可以这样收束:codebook_loss 让码本向数据分布移动,commitment_loss 约束 encoder 输出靠近选中的 code;而 x + (x_q - x).detach() 是 Straight-Through Estimator,让前向使用硬量化结果,反向近似把梯度传回 encoder。

SFT:先学会合法表达,再学推荐

RQ-VAE 输出的是字符串形式的 SID,但模型真正处理的是 tokenizer 分配的新 token id。项目里先从 index.json 收集实际出现过的 SID 子 token,再扩展 tokenizer 和模型 embedding。

# /Users/zhoujian/Desktop/MiniOneRec/sft.py:41
def get_new_tokens(self):
    if self.indices is None:
        self._load_data()

    self.new_tokens = set()
    for index in self.indices.values():
        for token in index:
            self.new_tokens.add(token)
    self.new_tokens = sorted(list(self.new_tokens))
    return self.new_tokens
# /Users/zhoujian/Desktop/MiniOneRec/sft.py:149
if sid_index_path and os.path.exists(sid_index_path):
    token_extender = TokenExtender(
        data_path=os.path.dirname(sid_index_path),
        dataset=os.path.basename(sid_index_path).split('.')[0]
    )
    new_tokens = token_extender.get_new_tokens()
    if new_tokens:
        tokenizer.add_tokens(new_tokens)
        model.resize_token_embeddings(len(tokenizer))

这里的面试重点是:不能直接把 RQ-VAE 的整数 code 当成 Qwen token id。Qwen 原词表里的 token id 早就有自己的子词含义,而且不同层的 42 含义也不同。所以要包装成 <a_42>、<b_42> 这种带层级前缀的 token。

SFT 不是单任务。sft.py 把三类数据拼成一个 ConcatDataset:

# /Users/zhoujian/Desktop/MiniOneRec/sft.py:193
train_data1 = SidSFTDataset(...)
train_datasets.append(train_data1)
train_data2 = SidItemFeatDataset(...)
train_datasets.append(train_data2)
train_data3 = FusionSeqRecDataset(...)
train_datasets.append(train_data3)
train_data = ConcatDataset(train_datasets)

三类任务的职责不同:

数据集 训练内容 作用
SidSFTDataset 历史 SID -> 下一 SID 主推荐任务
SidItemFeatDataset title -> SID,SID -> title 建立自然语言和 SID 的双向对齐
FusionSeqRecDataset 历史 SID -> 下一 item title 保留商品文本语义,避免只学 SID 共现

监督微调的 loss 只算 response,不算 prompt。代码里把 prompt 部分 label 设成 -100:

# /Users/zhoujian/Desktop/MiniOneRec/data.py:453
golden_tokens = self.tokenizer.encode(target_item, bos=False, eos=True)
input_prompt_len = len(tokens)
tokens = tokens + golden_tokens
attention_mask = [1] * len(tokens)
labels = [-100] * input_prompt_len + tokens[input_prompt_len:]

这点很容易被问。我的回答是:prompt 是 instruction 和用户历史上下文,模型不应该被训练去复读它;真正要学的是“给定 prompt 后生成正确 SID 或 title”。如果 prompt 也参与 loss,固定模板会稀释推荐目标。

GRPO:把推荐指标塞回训练目标

SFT 之后模型已经会生成 SID,但 SFT 的 token-level CE 不等价于 HR/NDCG。GRPO 的作用是让同一个用户上下文下的多个候选做组内比较,把命中和排序信号转成 reward。

rl.py 里有两类基础 reward:

# /Users/zhoujian/Desktop/MiniOneRec/rl.py:160
def ndcg_rule_reward(prompts, completions):
    ...
    if completion.strip("\n\"") == targets[i].strip("\n\""):
        flag = True
        lis.append(0.0)
    else:
        lis.append(ndcg_rewards[i % num_generations])
# /Users/zhoujian/Desktop/MiniOneRec/rl.py:186
def rule_reward(prompts, completions):
    ...
    if completion.strip("\n\" ") == targets[i].strip("\n\" "):
        rewards.append(1.0)
    else:
        rewards.append(0.0)

rule_reward 对应的是命中,近似 HR;ndcg_rule_reward 关心目标 SID 在组内候选里的位置,近似排序质量。reward_type="ranking" 时两者组合使用。

为什么 GRPO 不需要 critic?因为它用同一个 prompt 的多个 completion 做相对比较。Trainer 先让每个样本重复 num_generations 次:

# /Users/zhoujian/Desktop/MiniOneRec/minionerec_trainer.py:109
indexes = [
    idx
    for idx in torch.randperm(self.num_samples, generator=self.generator).tolist()
    for _ in range(self.repeat_count)
]

然后在组内归一化 reward,得到 advantage:

# /Users/zhoujian/Desktop/MiniOneRec/minionerec_trainer.py:963
mean_grouped_rewards = rewards.view(-1, self.num_generations).mean(dim=1)
std_grouped_rewards = rewards.view(-1, self.num_generations).std(dim=1)
mean_grouped_rewards = mean_grouped_rewards.repeat_interleave(self.num_generations, dim=0)
std_grouped_rewards = std_grouped_rewards.repeat_interleave(self.num_generations, dim=0)
advantages = (rewards - mean_grouped_rewards) / (std_grouped_rewards + 1e-4)

最后用当前 policy 的 logprob、advantage 和 reference model 的 KL penalty 更新:

# /Users/zhoujian/Desktop/MiniOneRec/minionerec_trainer.py:1044
per_token_logps = self._get_per_token_logps(model, input_ids, attention_mask, logits_to_keep)
ref_per_token_logps = inputs["ref_per_token_logps"]
per_token_kl = torch.exp(ref_per_token_logps - per_token_logps) - (ref_per_token_logps - per_token_logps) - 1

per_token_loss = torch.exp(per_token_logps - per_token_logps.detach()) * advantages.unsqueeze(1)
per_token_loss = -(per_token_loss - self.beta * per_token_kl)

KL penalty 的讲法要简单:RL 可能为了 reward 过度改变输出分布,破坏 SFT 学到的 SID 格式和语义对齐。KL 是把当前 actor 拉回 reference/SFT model 附近的约束项。beta 太大,模型几乎不学;太小,容易格式漂移或 reward over-optimization。

约束 Beam Search:不是生成后过滤

生成式推荐最怕的是模型输出非法 item。MiniOneRec 的做法不是生成后过滤,而是在生成过程中限制下一 token 只能来自合法前缀。

evaluate.py 先从 info_file 读合法 SID,构建前缀到 allowed token 的映射:

# /Users/zhoujian/Desktop/MiniOneRec/evaluate.py:87
hash_dict = dict()
for index, ID in enumerate(prefixID):
    ID.append(tokenizer.eos_token_id)
    for i in range(prefix_index, len(ID)):
        if i == prefix_index:
            hash_number = get_hash(ID[:i])
        else:
            hash_number = get_hash(ID[prefix_index:i])
        if hash_number not in hash_dict:
            hash_dict[hash_number] = set()
        hash_dict[hash_number].add(ID[i])

推理时,ConstrainedLogitsProcessor 把非法 token 的 logits 置为 -inf:

# /Users/zhoujian/Desktop/MiniOneRec/LogitProcessor.py:45
scores = torch.nn.functional.log_softmax(scores, dim=-1)
mask = torch.full_like(scores, float('-inf'))
...
prefix_allowed_tokens = self._prefix_allowed_tokens_fn(batch_id, hash_key)
...
mask[batch_id * self._num_beams + beam_id, prefix_allowed_tokens] = 0
scores = scores + mask

这也是我面试里会重点讲的工程细节。后处理过滤会带来两个问题:一是候选可能不够 Top-K,二是排序已经被非法候选污染。过程约束直接把搜索空间限制在合法 SID 前缀树里,更适合离线评估和线上候选生成。

指标:HR、NDCG 和 CC 要一起看

MiniOneRec 的评估不能只看 HR/NDCG,还要看 CC。

指标 含义 面试解释
HR@K Top-K 是否包含真实 item 看覆盖和召回
NDCG@K 命中后是否排得靠前 看排序质量
CC invalid candidate count 看生成结果是否合法

calc.py 里如果预测 item 不在合法集合中,就会累加 CC:

# /Users/zhoujian/Desktop/MiniOneRec/calc.py:60
for i in range(len(sample)):
    if sample[i] not in item_dict:
        CC += 1
        print(sample[i])
        print(target_item)
    if sample[i] == target_item:
        minID = i
        break

我会这样解释实验现象:本地复盘材料中,GRPO 相比 SFT 把 NDCG@20 从 0.0937 提升到 0.1012,HR@1 从 0.0514 提升到 0.0666,但 HR@20 从 0.1648 小幅下降到 0.1604。这说明 RL 更偏向把正确答案推到前面,但 Top-20 覆盖略有损失。

这不是“RL 一定全面提升”的故事,而是一个 reward 和评估指标之间的 trade-off。当前 ranking reward 更关注组内命中和位置,num_generations 较小时 reward 也会稀疏:如果目标 SID 没出现在候选组里,这组就没有有效正信号。

如果面试官追问怎么改,我会按优先级答:

  1. 先保证 SFT 和 RL 使用同一份 tokenizer、SID 映射、info.txt 和评估脚本。
  2. 记录每个 checkpoint 的 HR/NDCG/CC,按验证集目标 early stop。
  3. 提高 num_generations,降低 all-zero reward group 比例。
  4. 如果业务更重视 HR@20,就加入 coverage/recall/listwise reward,而不是只优化头部排序。
  5. 网格调 learning_rate 和 KL 的 beta,避免 RL 更新过猛。

贡献边界要讲清楚

这个项目的生成式推荐框架和核心思想来自 MiniOneRec 开源工作。面试里不能把论文或开源框架贡献说成原创。

我的表达会收在“复现、适配、改造和实验闭环”上:

  • 打通 Amazon 数据处理、文本向量化、SID 构造、SFT、GRPO/RL、约束评估的端到端流程。
  • 适配本地单卡脚本和 Qwen2.5-0.5B 实验路径,降低调试成本。
  • 梳理 data/、rq/、sft.py、rl.py、minionerec_trainer.py、evaluate.py、calc.py 的输入输出。
  • 理解并排查 SID/tokenizer 不一致、非法生成、reward 稀疏、RL 后 HR@20 下降等问题。

这套说法更稳。它能证明我真正读过代码、跑过链路、理解训练和评估风险,而不是只把开源 README 跑了一遍。

高频追问速答

为什么 SID 不能直接用 item id?

item id 没有语义结构,LLM 很难从 token 本身泛化。SID 通过商品文本 embedding 和残差量化,把相似商品压到相近的层次离散空间里,既能作为生成 token,又保留一部分语义关系。

RQ-VAE 的 decoder 上线用不用?

不用。decoder 是训练时的重建约束,目的是让 SID 保留原 embedding 信息。推荐阶段使用的是 item 到 SID 的映射和合法 SID 前缀树。

collision 和 CC 是一回事吗?

不是。collision 是多个 item 拥有同一个完整 SID,说明编码不唯一;CC 是模型生成了不在合法 item/SID 集合里的候选,说明生成结果非法。前者是 SID 构造质量问题,后者是解码合法性问题。

为什么 prompt label 要设为 -100?

因为 causal LM loss 会忽略 -100。这样模型只学习 response 部分,也就是正确 SID/title,不会把大量固定 instruction 和用户输入当作要复读的目标。

GRPO 为什么会让 HR@20 下降?

RL 优化的是定义好的 reward,不保证所有离线指标同步提升。当前 reward 更偏 exact match 和组内排序,可能把正确答案推到更靠前的位置,但损失一部分 Top-20 覆盖。

线上会怎么做?

我不会直接让大模型在全量 item 空间自回归生成。更现实的方案是传统召回先给候选集,小模型或量化 LLM 在候选空间内做约束生成/重排,再配合 KV Cache、batch trie 查询和 continuous batching 控制 P95 延迟。

最后怎么记

MiniOneRec 可以记成一句话:

先把商品文本压成 LLM 能生成的 Semantic ID,
再用 SFT 学会格式和语义对齐,
再用 GRPO 把推荐指标作为 reward 拉进训练,
最后用前缀约束保证生成结果一定落在合法 item 集合里。

面试时,真正能拉开差距的不是背出 RQ-VAE、SFT、GRPO 这些名词,而是能说清楚每个模块为什么存在:SID 解决 item 表示,SFT 解决生成格式和语义对齐,GRPO 解决 token loss 和推荐指标不一致,约束解码解决生成合法性,HR/NDCG/CC 共同决定评估是否可信。

← 返回首页