
1. 为什么要把推荐系统塞进一个 Transformer推荐系统这玩意儿做过的人都懂它本质上是一条流水线召回、粗排、精排、重排每一层各司其职层层过滤。这套架构从工业界大规模落地到现在少说也跑了十来年稳定、可控、好维护但问题也越来越明显——每一层都是独立优化的层与层之间的信息损耗和目标不一致成了天花板。召回阶段通常用双塔模型做向量检索追求的是高召回率把几千个候选从百万级池子里捞出来粗排阶段做一次轻量级打分把几千缩到几百精排阶段上重模型把几百缩到几十最后重排考虑多样性和业务规则。每一层都有自己的损失函数、自己的特征工程、自己的训练流程。你调好了召回粗排可能吃不消你优化了精排召回的分布又变了。这种级联误差和目标割裂是推荐系统从业者心里长期的痛。OneTrans-V2 这个项目标题一出来我第一反应就是终于有人把这件事往前推了一大步。它的核心思路非常直接——用统一的 Transformer 架构把召回、粗排、精排三个阶段合并到一个模型里端到端训练。不是简单的多任务学习而是真正意义上的结构统一一个 Transformer 主干多个输出头共享底层表示各自完成不同阶段的打分和检索任务。这件事为什么值得做因为 Transformer 本身就是一个极强的序列建模器它天然适合处理用户行为序列、物品特征序列、上下文特征序列。传统推荐系统里每个阶段都要单独做特征交叉、单独做序列建模重复计算不说还容易过拟合。OneTrans-V2 把这些重复劳动合并了底层共享上层分化既省计算又提效果。适合谁来读这篇内容如果你是在做推荐系统、搜索排序、广告预估的算法工程师或者是对大规模深度学习系统架构感兴趣的后端/平台开发者这篇内容会非常有参考价值。即使你暂时没做过推荐只要你对 Transformer 的应用边界感兴趣也能从中看到一种“统一建模”的思路。我下面会从整体设计、核心细节、实操落地、问题排查几个角度把 OneTrans-V2 这套方案拆开来讲。不是论文复述而是从一个一线从业者的视角讲清楚它为什么这么设计、怎么落地、踩过哪些坑。2. OneTrans-V2 的整体架构与设计思路2.1 统一主干加多阶段输出头的核心设计OneTrans-V2 的架构可以用一句话概括一个共享的 Transformer 编码器接三个不同阶段的输出头。这三个头分别对应召回、粗排、精排但它们不是独立训练的而是联合优化。具体来说输入层会把用户特征、物品特征、上下文特征、用户行为序列全部 token 化然后拼接成一个长序列。这个序列进入多层 Transformer 编码器每一层都做自注意力加前馈网络。编码器的输出是一个高维表示序列接下来分三条路走召回头把用户表示和物品表示分别映射到低维向量空间用内积或余弦相似度做检索。这个头通常用双塔结构但塔的底层是共享的 Transformer 编码器。粗排头用一个轻量级的 MLP 或单层交叉网络对候选集做快速打分。它的输入是编码器输出的用户表示和物品表示拼接后的向量。精排头用更复杂的交叉结构比如多层交叉网络或注意力池化做精细打分。它的输入除了编码器输出还会额外接入一些高阶特征交叉。这种设计的好处是底层表示是共享的三个头共享同一个“世界观”。召回阶段学到的用户兴趣表示粗排和精排也能用精排阶段学到的精细偏好也能反哺召回。这比三个独立模型各自为战要高效得多。但这里有个关键问题三个阶段的目标不一样召回追求的是“别漏”精排追求的是“排得准”。如果直接联合训练梯度可能会互相打架。OneTrans-V2 的解法是梯度缩放和损失加权后面会详细讲。2.2 为什么选择 Transformer 而不是其他结构推荐系统里常用的结构有几种双塔 MLP、DeepFM、DIN、DIEN、多任务学习网络等。OneTrans-V2 选择 Transformer 作为统一主干理由很充分。第一Transformer 的注意力机制天然适合处理用户行为序列。用户的历史点击、购买、浏览序列本质上是一个变长序列注意力机制可以自动学习哪些行为对当前预测更重要。DIN 虽然也做了注意力但它是局部注意力Transformer 是全局的表达能力更强。第二Transformer 可以统一处理异构特征。推荐系统的特征非常杂有离散的类别特征、连续的数值特征、变长的序列特征。传统做法是每种特征单独处理再拼接Transformer 可以把所有特征都 token 化统一用注意力做交叉。这减少了特征工程的负担。第三Transformer 的扩展性好。模型层数、头数、隐藏维度都可以灵活调整方便在不同算力预算下做权衡。召回阶段可以用浅层 Transformer精排阶段用深层共享底层参数。第四Transformer 和向量检索天然兼容。召回阶段需要把用户和物品映射到向量空间做近似最近邻搜索Transformer 的输出可以直接拿来做这件事不需要额外的转换。当然Transformer 也不是没有缺点。它的计算复杂度是序列长度的平方级如果用户行为序列很长推理延迟会很高。OneTrans-V2 在这方面做了一些优化比如序列截断、稀疏注意力、缓存机制等后面会展开。2.3 三阶段联合训练的目标函数设计联合训练的核心难点在于损失函数的平衡。召回、粗排、精排三个阶段的样本空间、正负样本比例、目标指标都不一样。如果简单地把三个损失加起来模型会偏向某个阶段。OneTrans-V2 的做法是分阶段采样加动态加权。具体来说召回阶段用全量物品做负采样正负比通常是 1:100 到 1:1000损失函数用 sampled softmax 或对比学习损失。粗排阶段用召回后的候选集做负采样正负比 1:10 到 1:50损失函数用交叉熵。精排阶段用粗排后的候选集正负比 1:5 到 1:20损失函数用交叉熵或排序损失。三个损失分别计算然后加权求和。权重不是固定的而是根据训练阶段动态调整。早期训练时召回损失权重大一些让底层表示先学好后期训练时精排损失权重大一些让上层精细化。这里有个实操细节梯度回传时要做梯度裁剪和缩放。因为召回阶段的负样本多梯度幅度大如果不做缩放会淹没粗排和精排的梯度。OneTrans-V2 用了梯度归一化让每个阶段的梯度范数保持在一个合理范围内。注意联合训练时三个阶段的 batch size 可能不一样。召回阶段通常用大 batch精排阶段用小 batch。如果强行统一 batch size要么召回效果下降要么精排显存爆炸。OneTrans-V2 的做法是用梯度累积模拟大 batch召回阶段累积多步精排阶段累积少步最后一起更新。3. 核心细节解析与实操要点3.1 特征 token 化与序列构建的细节OneTrans-V2 的第一步是把所有输入特征 token 化。这一步看起来简单但细节非常多直接影响到后续注意力的效果。用户特征包括用户 ID、年龄、性别、地域、注册时长、活跃度等。物品特征包括物品 ID、类目、品牌、价格、发布时间、统计特征等。上下文特征包括时间、设备、网络、页面位置等。行为序列包括用户最近点击、购买、收藏的物品序列以及对应的行为类型和时间戳。每个特征都要映射成一个 embedding 向量然后加上位置编码。这里有几个关键点离散特征用 embedding 表维度通常 16 到 64根据特征基数调整。基数大的特征如物品 ID用大维度基数小的用小维度。连续特征做分桶离散化然后也走 embedding。分桶的边界用等频分桶或等距分桶根据数据分布选择。行为序列要加行为类型 embedding 和时间间隔 embedding。行为类型点击、购买、收藏本身是离散的时间间隔是连续的都要编码进去。位置编码用可学习的位置 embedding而不是正弦位置编码。因为推荐系统的序列顺序很重要可学习的位置编码更灵活。序列构建时要把所有 token 拼成一个长序列。通常的顺序是用户特征 token、上下文特征 token、行为序列 token、物品特征 token。物品特征放在最后是因为精排阶段需要重点关注当前候选物品。实操心得序列长度不要超过 512否则注意力计算太慢。如果行为序列很长可以截断到最近 100 到 200 个行为或者用滑动窗口。截断时优先保留最近的行为因为推荐系统里近期行为更重要。3.2 注意力机制的选择与优化OneTrans-V2 用的是标准的多头自注意力但在几个地方做了优化。第一注意力头数不是越多越好。推荐系统的特征维度通常不高头数太多会导致每个头的维度太小表达能力下降。实测下来4 到 8 个头比较合适隐藏维度 128 到 256。第二注意力 dropout 要调好。推荐系统容易过拟合注意力 dropout 通常设 0.1 到 0.3。太高了模型学不动太低了过拟合。第三用相对位置编码代替绝对位置编码。推荐系统的序列长度变化很大绝对位置编码在长序列上泛化差。相对位置编码只关注 token 之间的相对距离更鲁棒。第四对行为序列做时间衰减。越久远的行为注意力权重应该越低。OneTrans-V2 在注意力分数上加了一个时间衰减项公式大概是attention_score (Q * K^T) / sqrt(d) - lambda * log(1 delta_t)其中 delta_t 是当前时间与行为时间的间隔lambda 是衰减系数。这样模型会自动降低旧行为的权重。第五用稀疏注意力降低计算量。如果序列长度超过 256可以用局部注意力加全局注意力的混合模式。局部注意力只看相邻的 token全局注意力只看几个关键的 token如用户 ID、当前物品 ID。这样计算量从 O(n^2) 降到 O(n * sqrt(n))。3.3 三阶段输出头的具体结构召回头、粗排头、精排头的结构差异很大这里分别讲。召回头通常是双塔结构用户塔和物品塔。用户塔的输入是 Transformer 编码器中用户相关 token 的输出物品塔的输入是物品相关 token 的输出。两个塔各自过一个 MLP映射到同一个向量空间然后用内积算相似度。训练时用对比学习损失正样本是用户真实交互过的物品负样本是随机采样的物品。注意召回头的物品塔和精排头的物品表示最好共享底层参数但上层可以分开。因为召回需要的是粗粒度的向量表示精排需要的是细粒度的打分两者需求不同。粗排头是一个轻量级打分网络。输入是用户表示和物品表示的拼接过两层 MLP输出一个分数。粗排头的参数量要控制通常不超过总参数的 10%否则推理延迟太高。精排头是最复杂的。它除了用 Transformer 编码器的输出还会额外做特征交叉。常见的做法是把用户表示、物品表示、上下文表示拼接后过几层交叉网络如 DCN-V2 的交叉层然后再过 MLP 输出分数。精排头的参数量可以大一些因为候选集已经很小了。三个头的输出维度也不一样召回头输出 64 到 128 维向量粗排头输出 1 个分数精排头输出 1 个分数。训练时三个头的损失分别计算然后加权求和。3.4 训练数据管道的搭建要点OneTrans-V2 的训练数据管道比传统推荐系统复杂因为要同时服务三个阶段的采样需求。数据管道通常分三层离线存储层用列式存储如 Parquet保存用户特征、物品特征、行为序列。每天全量更新一次增量部分用流式写入。采样层召回阶段用全量物品做负采样粗排阶段用召回候选做负采样精排阶段用粗排候选做负采样。三个采样器独立运行但共享底层特征存储。批处理层把采样后的样本拼成 batch做特征 token 化、序列截断、padding。这一步通常用多进程加速因为 token 化比较耗时。实操心得数据管道的瓶颈通常在特征读取和 token 化。建议把常用特征缓存在内存里token 化用 C 或 Rust 实现比 Python 快 5 到 10 倍。另外padding 要用 mask 标记不要让模型学到 padding 位置的信息。4. 实操过程与核心环节实现4.1 环境准备与依赖安装OneTrans-V2 的实现依赖几个核心库深度学习框架PyTorch 或 TensorFlow、向量检索库Faiss 或 ScaNN、特征处理库Feast 或自定义。我下面以 PyTorch 为例讲一下环境搭建。首先装 PyTorch版本建议 1.13 以上因为要用到一些新的注意力算子。然后装 Faiss用于召回阶段的向量检索。再装一些辅助库numpy、pandas、pyarrow、tqdm。pip install torch1.13 pip install faiss-cpu # 如果有 GPU用 faiss-gpu pip install numpy pandas pyarrow tqdm如果要用混合精度训练还要装 apex 或 torch.cuda.amp。混合精度可以省 30% 到 50% 的显存训练速度也能提升。注意Faiss 的 GPU 版本和 CPU 版本 API 略有不同建议先用 CPU 版本调试确认逻辑正确后再换 GPU。另外Faiss 的索引类型选择很重要后面会讲。4.2 模型定义与参数初始化模型定义分三部分Transformer 编码器、三个输出头、损失函数。我下面给一个简化的 PyTorch 实现框架。import torch import torch.nn as nn class OneTransV2(nn.Module): def __init__(self, vocab_sizes, hidden_dim256, num_layers4, num_heads8): super().__init__() # 特征 embedding 层 self.embeddings nn.ModuleList([ nn.Embedding(vocab_size, hidden_dim) for vocab_size in vocab_sizes ]) # 位置编码 self.position_embedding nn.Embedding(512, hidden_dim) # Transformer 编码器 encoder_layer nn.TransformerEncoderLayer( d_modelhidden_dim, nheadnum_heads, dim_feedforwardhidden_dim*4, dropout0.1 ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) # 召回头 self.recall_user_tower nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 128) ) self.recall_item_tower nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 128) ) # 粗排头 self.coarse_head nn.Sequential( nn.Linear(hidden_dim*2, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) # 精排头 self.fine_head nn.Sequential( nn.Linear(hidden_dim*2, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) def forward(self, feature_ids, position_ids, attention_mask): # 特征 embedding x sum(emb(feature_ids[:, i]) for i, emb in enumerate(self.embeddings)) x x self.position_embedding(position_ids) # Transformer 编码 x self.encoder(x, src_key_padding_maskattention_mask) # 分离用户表示和物品表示 user_repr x[:, 0] # 假设第一个 token 是用户 item_repr x[:, -1] # 假设最后一个 token 是物品 # 三个头的输出 recall_user self.recall_user_tower(user_repr) recall_item self.recall_item_tower(item_repr) coarse_score self.coarse_head(torch.cat([user_repr, item_repr], dim-1)) fine_score self.fine_head(torch.cat([user_repr, item_repr], dim-1)) return recall_user, recall_item, coarse_score, fine_score参数初始化很关键。Embedding 层用正态分布初始化标准差 0.02。Transformer 层用 Xavier 初始化。输出头的最后一层用零初始化让初始输出接近 0训练更稳定。实操心得如果特征基数很大比如物品 ID 有百万级Embedding 层会占很多显存。可以用哈希 embedding 或者低秩分解来压缩。哈希 embedding 把大基数特征映射到固定大小的桶里省显存但会损失一些精度。低秩分解把 embedding 矩阵拆成两个小矩阵相乘压缩率更高。4.3 训练循环与梯度处理训练循环的核心是分阶段采样加联合更新。每个 step 要做的事从召回采样器取一批样本算召回损失。从粗排采样器取一批样本算粗排损失。从精排采样器取一批样本算精排损失。三个损失加权求和反向传播更新参数。这里有个细节三个采样器的 batch size 可能不一样。召回阶段用大 batch如 4096精排阶段用小 batch如 256。如果直接一起更新梯度尺度会不一致。OneTrans-V2 的做法是梯度累积召回阶段累积 16 步精排阶段累积 1 步最后一起更新。optimizer.zero_grad() # 召回损失累积 16 步 for i in range(16): recall_batch recall_sampler.next() recall_loss compute_recall_loss(model, recall_batch) (recall_loss / 16).backward() # 粗排损失累积 4 步 for i in range(4): coarse_batch coarse_sampler.next() coarse_loss compute_coarse_loss(model, coarse_batch) (coarse_loss / 4).backward() # 精排损失1 步 fine_batch fine_sampler.next() fine_loss compute_fine_loss(model, fine_batch) fine_loss.backward() # 梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step()梯度裁剪的 max_norm 通常设 1.0 到 5.0。太小了学不动太大了容易震荡。实测下来1.0 比较稳。注意联合训练时三个阶段的损失权重需要调。我试过固定权重 1:1:1效果一般。后来改成动态权重前 10 个 epoch 召回权重大0.5:0.3:0.2中间 10 个 epoch 均衡0.33:0.33:0.34最后 10 个 epoch 精排权重大0.2:0.3:0.5。这样效果最好。4.4 召回阶段的向量检索实现召回阶段需要把物品向量建索引然后做近似最近邻搜索。Faiss 是最常用的库索引类型选择很关键。IndexFlatIP精确内积搜索适合物品数少于 10 万。速度快但内存占用大。IndexIVFFlat倒排索引加扁平量化适合百万级物品。需要训练聚类中心搜索时只查部分倒排列表。IndexIVFPQ倒排索引加乘积量化适合千万级物品。内存占用小但精度有损失。OneTrans-V2 通常用 IndexIVFFlat因为推荐系统的物品数一般在百万级精度要求高。索引构建流程import faiss import numpy as np # 物品向量假设有 100 万个物品每个 128 维 item_vectors np.random.rand(1000000, 128).astype(float32) # 归一化用内积算余弦相似度 faiss.normalize_L2(item_vectors) # 构建索引 d 128 # 向量维度 nlist 1000 # 聚类中心数 quantizer faiss.IndexFlatIP(d) index faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT) index.train(item_vectors) index.add(item_vectors) # 搜索 query np.random.rand(1, 128).astype(float32) faiss.normalize_L2(query) k 100 # 返回 top 100 distances, indices index.search(query, k)nlist 的选择有个经验公式nlist sqrt(N)N 是物品数。100 万物品nlist 设 1000 左右。搜索时 nprobe 设 10 到 50nprobe 越大精度越高但越慢。实操心得Faiss 索引要定期重建因为物品向量会随着模型更新而变化。建议每天重建一次用最新的模型推理物品向量。重建时用多线程加速Faiss 支持 OpenMP。5. 常见问题与排查技巧实录5.1 训练不收敛或损失震荡怎么办联合训练最容易出的问题就是损失震荡。我踩过的坑包括梯度尺度不一致、学习率太大、batch size 太小、损失权重不合理。排查思路按优先级来第一检查梯度范数。在每个阶段分别打印梯度范数如果某个阶段的梯度范数比其他阶段大一个数量级说明梯度尺度不一致。解法是梯度归一化或调整损失权重。第二检查学习率。联合训练的学习率通常比单阶段训练小因为梯度噪声更大。建议从 1e-4 开始试如果震荡就降到 1e-5。第三检查 batch size。召回阶段的 batch size 不能太小否则负采样不够对比学习效果差。建议至少 1024。第四检查损失权重。如果某个阶段的损失一直不下降可能是权重太小。可以动态调整权重让每个阶段的损失下降速度差不多。问题现象可能原因排查方法解决方案损失震荡梯度尺度不一致打印各阶段梯度范数梯度归一化或调整权重损失不下降学习率太小尝试增大学习率从 1e-4 调到 1e-3召回效果差负采样不够检查负样本数量增大负采样比精排过拟合模型太大检查训练/验证损失加 dropout 或减层数推理延迟高序列太长检查序列长度分布截断序列或稀疏注意力5.2 召回率和精排 AUC 互相拖累怎么破这是联合训练最典型的问题召回率上去了精排 AUC 下来了或者反过来。根本原因是两个阶段的目标冲突。召回阶段希望用户和物品的向量表示尽可能分散这样检索时不容易漏精排阶段希望表示尽可能精细这样打分才准。如果共享底层表示两个目标会打架。OneTrans-V2 的解法是部分共享加部分独立。底层 Transformer 的前几层共享后几层分开。召回用浅层输出精排用深层输出。这样底层学通用表示上层学阶段特定表示。具体来说如果 Transformer 有 6 层前 3 层共享后 3 层分成两个分支召回分支和精排分支。粗排可以用召回分支的输出也可以用独立分支。实操心得共享层数不是固定的要根据数据量和任务难度调。数据量大、任务简单共享层可以多一些数据量小、任务复杂共享层少一些。我试过 2 层共享加 4 层独立效果比全共享好。5.3 线上推理延迟超标的优化手段OneTrans-V2 的推理延迟主要来自三部分Transformer 编码、三个头的计算、向量检索。优化要分阶段做。Transformer 编码优化序列截断把行为序列截到最近 100 个延迟能降 50%。稀疏注意力用局部加全局注意力延迟降 30%。量化用 INT8 量化延迟降 40%精度损失 1% 以内。缓存用户表示可以缓存因为用户特征变化不快。缓存命中率通常 70% 以上。三个头优化召回头物品向量可以离线算好线上只算用户向量。粗排头用更小的 MLP或者用矩阵分解代替。精排头候选集已经很小延迟不是瓶颈。向量检索优化用 GPU 版 Faiss比 CPU 快 10 倍。减少 nprobe从 50 降到 10延迟降 80%召回率降 2%。用 PQ 量化内存降 90%延迟降 50%。优化手段延迟降幅精度损失实施难度序列截断50%1-2%低稀疏注意力30%0.5%中INT8 量化40%1%中用户表示缓存60%0%低GPU Faiss90%0%低PQ 量化50%2-3%中5.4 特征穿越和数据泄漏的排查联合训练时特征穿越和数据泄漏很容易发生因为三个阶段的样本时间范围可能重叠。特征穿越是指用了未来信息。比如精排阶段用了召回阶段之后才产生的特征。排查方法是检查每个特征的时间戳确保特征时间早于样本时间。数据泄漏是指训练集和验证集有重叠。联合训练时召回、粗排、精排的样本可能来自同一批用户如果划分不当验证集的信息会泄漏到训练集。解法是按用户划分而不是按样本划分。注意时间序列数据一定要按时间划分训练集和验证集不能用随机划分。随机划分会导致未来信息泄漏离线指标虚高线上效果差。排查工具方面可以用特征重要性分析如果某个特征重要性异常高可能是泄漏了。还可以用时间交叉验证看模型在不同时间段的稳定性。6. 一些实操后的个人体会OneTrans-V2 这套方案我前后调了几个月最大的感受是统一建模的方向是对的但落地难度比想象中大。三个阶段的联合训练不是简单地把损失加起来而是要仔细处理梯度、采样、特征共享这些细节。另一个体会是不要一上来就全量联合训练。可以先单独训练召回再单独训练精排最后联合微调。这样收敛更快也更容易排查问题。我试过直接从零联合训练前几个 epoch 损失几乎不降后来改成预训练加微调效果好很多。还有一点离线指标和线上效果不一定一致。联合训练时离线 AUC 可能涨了但线上 CTR 没涨甚至跌了。原因可能是离线样本分布和线上不一致或者模型过拟合了离线数据。建议离线指标只做参考最终以线上 AB 测试为准。最后分享一个小技巧用模型蒸馏加速推理。把 OneTrans-V2 作为教师模型蒸馏一个浅层学生模型线上用学生模型推理。学生模型的延迟可以降到教师模型的 30%精度损失控制在 2% 以内。这个在算力紧张的场景下非常实用。