这一页回答什么
Phoenix 排序模型将数十条候选帖子拼入同一条序列,系统通过特定注意力掩码保证每条候选独立打分。本页说明三件事:2026-08 起的生产代码如何用段 ID 实现候选隔离;末尾层如何冻结历史状态以节省算力;该约束对召回用户塔产生的影响。
旧页面引用的
make_recsys_attn_mask(phoenix/grok.py)已随grok.py移除。当前系统不再生成显式的[L, L]掩码矩阵,改为由每个位置的段 ID 结合注意力算子内部逻辑共同判定可见性。
核心结论
- 候选帖子之间相互不可见:候选位置只能注意用户前缀、历史互动序列与该候选自身(
phoenix/xrex/pallas/ranker_attention_utils.py:50-56)。 - 用户前缀与历史互动序列采用双向注意力:所有有效的非候选位置共用段 ID
1,互相完全可见。当前实现与phoenix/README.md的掩码示意一致,旧文档所述的因果下三角掩码已不再使用(phoenix/xrex/models/recsys_model.py:2277-2288、phoenix/README.md:196-235)。 - 掩码由段 ID 编码:系统给用户前缀与历史互动序列分配段 ID
1,给候选帖子分配-1,给 padding 填充位分配0(ranker_attention_utils.py:11-13)。注意力算子按段 ID 推导历史区间与候选区间,并执行统一的掩码规则。 - 末尾网络层仅更新候选表征:旗舰排序配置指定
num_candidate_only_layers=2。在全部 8 层网络中,前 6 层计算全序列;后 2 层冻结历史状态,仅计算候选对冻结历史与候选自身的注意力(phoenix/xrex/configs/xrecsys.py:541、phoenix/xrex/models/transformer.py:789-945)。 - 所有候选共用相同位置编码:右锚定 RoPE 将所有候选的位置统一设置为历史序列终点,候选在序列内的排列顺序不影响最终打分(
recsys_model.py:260-281)。 - 候选排序分保持完全独立:单条候选帖子的排序分仅取决于用户特征、历史互动序列与该候选自身,与同批出现的其它候选无关,系统可直接缓存打分结果(
phoenix/README.md:277-281)。
为什么需要它
候选隔离保证单条候选帖子的得分不受同批其它候选影响。排序模型将 [用户前缀 | 历史 S | 候选 C] 拼接为单条序列输入 transformer。如果候选帖子之间存在交叉注意力,同一条帖子搭配不同候选组合时会得出不同分数,导致分值不稳定且无法建立缓存。候选隔离建立明确保证:在给定的用户特征与历史互动序列下,单条候选帖子的打分结果保持确定。
段 ID:掩码的表达方式
排序模型在组装输入序列后调用 attention_segment_ids 计算各位置的段 ID(phoenix/xrex/models/recsys_model.py:2277-2288):
is_candidate = idx >= candidate_start_offset
valid = padding_mask
if attend_padded_history:
valid = jnp.logical_or(padding_mask, jnp.logical_not(is_candidate))
segment_ids = jnp.where(valid, jnp.where(is_candidate, -1, 1), 0)- 候选起始偏移量之前的有效位置(用户前缀与历史互动序列)映射为
1。 - 候选起始偏移量及之后的有效候选位置映射为
-1。 - padding 填充位置映射为
0,不参与注意力计算。当attend_padded_history=True时,历史段内的 padding 同样被标记为1;开源代码默认设为False(recsys_model.py:1245)。
注意力算子随后调用 bounds_from_segment_ids 推导各行的历史区间 [history_lower, history_upper) 与候选区间 [candidate_lower, candidate_upper)(ranker_attention_utils.py:118-143),并由 history_candidate_mask 判定查询向量 query q 是否可以注意键向量 key k(ranker_attention_utils.py:16-56):
history_mask = kv_is_history & (q_is_history | q_is_candidate)
candidate_self_mask = q_is_candidate & kv_is_candidate & (q_pos == kv_pos)
return history_mask | candidate_self_mask掩码长什么样
掩码规则规定前缀与历史双向互见、候选仅看前缀历史与自身。下表中 P 代表用户前缀,H 代表历史互动序列,C 代表候选帖子;✓ 代表允许计算注意力,✗ 代表屏蔽:
被注意的 key →
│ P │ H0 H1 H2 │ C0 C1 C2 │
┌─────┼────┼───────────┼─────────────┤
P │ P │ ✓ │ ✓ ✓ ✓ │ ✗ ✗ ✗ │
├─────┼────┼───────────┼─────────────┤
H │ H0 │ ✓ │ ✓ ✓ ✓ │ ✗ ✗ ✗ │ ← 前缀+历史:双向
│ H1 │ ✓ │ ✓ ✓ ✓ │ ✗ ✗ ✗ │
├─────┼────┼───────────┼─────────────┤
C │ C0 │ ✓ │ ✓ ✓ ✓ │ ✓ ✗ ✗ │ ← 候选:看 P+H 和自己
│ C1 │ ✓ │ ✓ ✓ ✓ │ ✗ ✓ ✗ │
│ C2 │ ✓ │ ✓ ✓ ✓ │ ✗ ✗ ✓ │
└─────┴────┴───────────┴─────────────┘前缀与历史不能注意候选,因此历史隐状态在每一层均独立于候选帖子。这一单向隔离特性是下一节冻结历史机制的前提。
只更新候选的末尾层
末尾候选专用层通过跳过历史状态更新来节约算力。配置项 TransformerConfig.num_candidate_only_layers(phoenix/xrex/models/transformer.py:86)将网络末尾 N 层指定为候选专用层。旗舰排序配置 home_direct_packed 将该参数设为 2,并配套使用专用算子 candidate_only_attn_impl="cutedsl_ranker_candidate_only_attn"(xrecsys.py:541-542)。
flowchart TB
IN["输入 P + H + C"] --> L1["第 1 到 6 层<br/>全序列,按上面的掩码"]
L1 --> SPLIT["拆开:历史状态冻结"]
SPLIT --> HS["P + H 状态<br/>不再更新"]
SPLIT --> CS["C 状态"]
CS --> L7["第 7、8 层<br/>只算候选"]
HS -. "作为 K 和 V" .-> L7
L7 --> OUT["候选输出 → 动作头"]
关键实现机制(transformer.py:789-945;层栈机制见 phoenix/xrex/utils/layer_stack.py:281-303):
- 层栈先计算前
num_layers - N层,随后调用split_history_and_candidates将隐状态拆分为历史与候选两个独立张量(transformer.py:829-838)。 - 在候选专用层中,候选 query 仅与冻结的历史 K/V 及候选自身的 K/V 计算注意力。专用 CuTe DSL 算子的入参即为
candidate_q, history_k, history_v, own_k, own_v(phoenix/xrex/cutedsl/ranker_attention_candidate_only_fa4.py:371-424;模块接入见phoenix/xrex/models/recsys_attention.py:381-470、phoenix/xrex/models/attention.py:26)。 - 未启用专用算子时,系统对全序列执行标准层计算,再将非候选位置的输出替换为冻结的历史状态(
transformer.py:840-859)。 - 当配置
candidate_only_layers_update_user_token=True时,系统同步更新用户前缀 token;开源代码默认设为False(transformer.py:87、xrecsys.py:1058-1060)。
专用算子仅支持 SM90(H100)与 SM100(GB200/GB300)架构。其它 GPU 架构须将 candidate_only_attn_impl 设为 None,以使用回退路径(ranker_attention_candidate_only_fa4.py:32-37)。
位置编码:候选共用一个位置
排序模型通过统一位置编码消除候选在序列中的排列偏置。函数 right_anchored_rope_positions(recsys_model.py:260-281)执行如下规则:
- 历史序列采用右对齐:无论历史记录实际条数多少,最新一条记录始终落位于
history_end - 1。 - 所有候选帖子的位置统一设为
history_end(recsys_model.py:275)。
RoPE 位置编码因此不区分候选帖子的先后次序。配合注意力掩码隔离,候选帖子在输入序列中调换顺序不会改变模型打分。
训练时的序列打包
序列打包机制在提高训练吞吐的同时保持推理契约不变。训练流程通过 use_seqpack=True 将多个用户的样本序列打包进同一行矩阵,依靠变长注意力算子按 cu_seqlens 切分样本边界(phoenix/xrex/models/recsys_attention.py:153-190、:245-380)。旗舰配置同时启用 seqpack_candidate_packing,系统将候选单独集中存放,随后按 candidate_positions 提取对应表征(xrecsys.py:536、recsys_model.py:4394-4406)。打包机制仅作用于训练阶段以提升吞吐,不改变在线服务阶段的计算契约(phoenix/README.md:186-191)。
在哪里生效
| 模型 | 序列 | 段 ID | 候选隔离 |
|---|---|---|---|
| 排序模型 | P、H、C 三段 | 1 / -1 / 0 |
是;旗舰末尾 2 层只更新候选 |
| 召回用户塔 | P、H 两段,无候选 | 有效位置全为 1(phoenix/xrex/models/recsys_two_tower_model.py:130-142) |
不需要;前缀与历史双向注意 |
两个模型的注意力配置均指定 causal=False(xrecsys.py:1038、phoenix/xrex/configs/xrecsys_two_tower.py:908)。
设计决策
| 决策 | 理由 |
|---|---|
| 候选互不可见,只留自注意 | 单条候选的分不受同批其它候选影响,可缓存 |
| 前缀与历史双向注意 | 历史不再按时间单向读,每条历史都能看到全部上下文 |
用段 ID 而不是 [L, L] 矩阵 |
注意力核按区间判断,不必为 1000 多长的序列显式存一张掩码;也能直接支持打包训练 |
| 末尾层只更新候选 | 历史与候选无关,后几层不必重算历史,省算力 |
| 候选共用一个位置 | 候选顺序不进入打分 |
FAQ
Q:候选隔离和“批内打分一致”是什么关系?
A:若候选能互相注意,同一条帖子放进不同批次会算出不同分数。隔离后,在相同的用户与历史序列下,候选帖子的得分保持唯一确定,系统即可跨请求复用缓存(phoenix/README.md:277-281)。
Q:旧页面说用户和历史之间是因果注意力,现在呢?
A:早期 grok.py 演示版使用 jnp.tril 构建下三角掩码。生产版中用户前缀与历史共用段 ID 1,history_mask 对它们不施加先后限制,执行完全的双向注意力(ranker_attention_utils.py:50)。
源码锚点
phoenix/xrex/pallas/ranker_attention_utils.py:11-143—— 段 ID 常量、history_candidate_mask、bounds_from_segment_idsphoenix/xrex/models/recsys_model.py:2277-2288—— 排序模型的段 ID 构造phoenix/xrex/models/recsys_model.py:260-281—— 右锚定 RoPE 位置phoenix/xrex/models/transformer.py:789-945—— 只更新候选的末尾层phoenix/xrex/cutedsl/ranker_attention_candidate_only_fa4.py:371-424—— 候选专用注意力核phoenix/xrex/models/recsys_two_tower_model.py:130-142—— 召回用户塔的段 ID
相关页面
- Phoenix 排序模型 —— 使用候选隔离的排序模型
- Transformer 骨架 —— 掩码作用的 transformer
- Phoenix 召回:双塔检索架构 —— 不需要候选隔离的召回用户塔
- RecsysAggregatedModel(排序模型) —— 段 ID 在哪里构造
- 运营迷思 vs 源码真相 —— 候选隔离与“被大 V 挤掉”的说法
- 开源版 vs 线上真实算法:已知差异清单 —— 开源与线上的差异