代码阅读笔记 · 基于 laya 0.4.12 源码、HF 模型卡与 typed-decisions 数据集卡

Laya 拆解:一个带动态选项的 BERT 分类器

TL;DR

本页要回答的问题:laya 到底是什么模型,它的"RL 训练"和"超过 teacher"站不站得住?

1. 一次请求在模型里走了什么路

用 README 里的退款工单当例子:一段 state 加 3 个问题(选部门、打紧急度、判断是否要流失)。点「下一步」逐步看每个阶段的输入、操作和产物。

① 请求
state + questions
↓
② 序列化:每题一行
build_sequence()
↓
③ ModernBERT 编码器
28 层 · d=1024 · 双向
↓
④ +题型 embedding → 决策头
2 层 TransformerEncoder
↓
⑤ 在 [MASK] 处取向量打分
gather → scorer MLP
↓
⑥ 选项内 softmax ÷ 温度
按题型解码
↳ 分支
⑦ act 头
[CLS] + 置信度统计
↓
⑧ 输出 typed answers
JSON

向量流向图 · 以 row 0(department,K=3)为例,3 行在 batch 里并行、互不交互;高亮部分对应当前步骤
一行序列编码成 L 个 1024 维列向量,最后只取 3 个 [MASK] 列打分,[CLS] 列给 act 头。 展开说明 ▸
  • token 行:一行序列 = [CLS] + 问题指令 + 每个选项前一个 [MASK] + state。图里每个小方块代表一段 token,实际长度 L ≤ 512。
  • 编码器 → h:每个 token 变成一个 1024 维列向量,28 层双向注意力让每列都看到全部 L 列,所以 [MASK] 列里混进了「本选项文字 + 问题 + state」的信息。
  • + type_emb:按题型取一个 1024 维向量,广播加到每一列(不是拼接),告诉决策头这是 choice / score / noul。
  • 决策头 → h':2 层 TransformerEncoder 再做一次全局交互,输出形状不变,仍是 L × 1024。
  • gather:按 marker_pos 只取 3 个 [MASK] 列,叠成 m ∈ ℝ^{K×1024};其余列(问题、选项文字、state)到这里就丢掉了。
  • scorer:同一个 MLP 分别作用在每个 [MASK] 列上,各出 1 个标量,得到 K 个 logits;所以 K 可以是任意值。
  • softmax:只在本题 K 个选项内归一化,先除以按题型拟合的温度 T。
  • act 分支:[CLS] 列(1024 维)与 4 个由概率算出的统计量拼接成 1028 维,过 MLP 输出 2 类 logits;统计量来自 detach 后的概率,梯度不回传到打分路径。

一句话总结:只有 [MASK] 列和 [CLS] 列被读出,其他位置只是给它们提供上下文。

数值类示意(logits、概率、marker 位置)是为了说明形状和流程编的,不是真实推理结果;张量维度和模块结构对应 laya/common.py 中的 build_head 和 DecisionModel.forward。

对应源码:DecisionModel.forward 核心几行编码 → 加题型 embedding → 头部 2 层 → gather [MASK] → scorer → 屏蔽多余选项
h = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
h = h + self.type_emb(qtype)[:, None, :]
for layer in self.head.layers:
    h = layer(h, src_key_padding_mask=pad)
idx = marker_pos.clamp(min=0)[:, :, None].expand(-1, -1, h.size(-1))
m = torch.gather(h, 1, idx)
logits = self.scorer(m).squeeze(-1).float()
logits = logits.masked_fill(~marker_mask, -1e4)

2. 它本质上就是分类

走完上面的流程可以看到,laya 的输出就是一个 K 选 1 的 softmax,训练目标也是一个分布。和普通分类器相比,它只在一个地方有区别:类别不是固定的输出层,而是以文本形式写进输入,所以换一套选项不用重训。这个思路就是 BERT 时代的 cross-encoder 多选(比如 SWAG 用的 AutoModelForMultipleChoice),区别只是 laya 把所有选项塞进同一条序列。

固定分类头LayaLLM 生成
标签空间训练时写死请求时用文本给出任意
计算方式一行一次前向每个问题一行,state 每行重复编码自回归逐 token
输出固定 logitsK 个 [MASK] 位置的 logits文本,需要解析
主要局限换标签就要重训选项挤在 192/256 token 预算里,选项多了就分不清;有位置偏置慢、贵

它带来的工程价值是真实的:T4 上单题 39.5 ms,比调 LLM API 快一个数量级,而且输出天然是概率。但从建模上讲没有新东西,宣传里的 "System 1 decision engine"、"non-autoregressive" 都是包装。

3. 软标签从哪来

训练和评测用的都是 typed-decisions 数据集。下面按数据集卡片的描述画出构造流程。

采样隐因子
程序随机
主题、语气、严重程度、差异类型……
→
渲染成 state
某个 LLM
文本类内容写成自然语言,结构化内容保持 JSON
→
teacher 标 3 次
约 4B 级 LLM · T=0.7
每次对每个问题输出一个分布
→
3 次取平均 = gold
软标签
训练 1,200 条,测试 400 条(各 5 题)

我拉了训练集第一条数据看,gold 的取值是 0.283333、0.433333 这种,乘以 3 后都是 0.05 的整数倍。所以每次标注更像是 teacher 在 JSON 里直接写出来的概率(verbalized probability),而不是 next-token 的 logprob。这一点是我根据数值推断的,数据集卡片没有明说。

真实样本:一条 agent trace 的 gold5 个问题上,3 次标注的 argmax 没有一题完全一致,gold 很平
action   : continue 0.283 | human_review 0.433 | observe 0.250 | stop 0.033
needs_review (noul): false 0.45 | true 0.55
outcome  : failure 0.083 | harmful 0.000 | partial 0.493 | success 0.423
risk     (score): 0:0.467 | 1:0.417 | 2:0.100 | 3:0.017
urgency  (score): 0:0.317 | 1:0.483 | 2:0.167 | 3:0.033
label_agreement: 5 个问题的 argmax_agree(3 次 argmax 是否全相同)均为 false

数据集卡片自己给了几条参考线,读 laya 的成绩时必须对照着看:

0.470
Prior:只按训练集标签频率猜,不看输入
0.735
teacher 自一致率:新采一次 teacher,和其余几次的平均比
0.766
laya 微调后,宣传为"超过 teacher 天花板"
0.362
laya 基础 checkpoint 零样本,接近随机的 0.318
数据集卡片原话:"Scores well above 0.735 mean a model is learning the teacher's quirks." 也就是说,分数明显超过 0.735,说明模型在学 teacher 的毛病。laya 把 0.766 当卖点,按出题人的标准恰恰是过拟合 teacher 的信号。

4. Loss:RL 项其实是在用采样估计一个已知的梯度

代码里的 loss 是两项相加(laya/train.py 的 rlcd_loss)。记模型输出的 logits 为 ℓ,teacher 的目标分布为 t:

L = L_RL + L_CE L_CE = −Σᵢ tᵢ · log softmax(ℓ)ᵢ ← 软标签交叉熵,就是普通蒸馏 L_RL: 1. 采 S=4 份噪声 ε ~ N(0, σ²),σ 从 0.4 退火到 0.1,减去均值 2. z = ℓ.detach() + ε,q = softmax(z) 3. R = Σ tᵢ log qᵢ + 0.75 · spherical(q, t) [− RPS,仅 score 题] 4. A = (R − mean R) / std R 5. L_RL = −mean( A · log N(z | ℓ, σ²) )

对 ℓ 求导,高斯 log 密度的梯度正好是 ε/σ²,于是:

∇ℓ L_RL = −(1 / Sσ²) · Σₛ Aₛ · εₛ

这就是 evolution strategies(零阶梯度估计):往几个随机方向扰动 logits,看 reward 变好还是变坏,以此估计 ∇E[R]。你的理解是对的,它就是在用采样做估计。问题在于这里根本不需要估计:

下面这个小实验用真实样本的 action 题做 target,按代码里的公式采 4 个样本,比较 ES 估计的梯度方向和精确梯度方向。每点一次「重新采样」就是一次训练 step 里 RL 项拿到的梯度。

精确梯度 ∇ℓ R(灰)对比 4 样本 ES 估计(赭),各自按最大绝对值归一化
设置:target t = [0.283, 0.433, 0.250, 0.033],当前 logits ℓ = [0.5, 0.2, 0.1, −0.3],σ = 0.4,S = 4,w_sph = 0.75

作者自己的消融(issue #887,单张 T4、一个 seed)也是同样的结论:

0.362
基础 checkpoint,不微调
0.7715
--loss rlcd(默认,CE + RL)
0.790
--loss soft-ce(只用交叉熵)
≈
两者 ECE 差不多(0.150 对 0.157)

训练完后,还会在预留的最多 400 条校准样本上,按题型各拟合一个温度(LBFGS,限制在 [0.1, 10])。这一步是标准的 temperature scaling,不改变 argmax,只改变置信度。

5. 结论:工程封装扎实,算法叙事偏营销

值得参考

  • [MASK] 打分 + 选项写进输入:做一个 schema 可变的快速判别器,这个结构简单好用。
  • 软标签 + 温度校准:输出能直接当置信度用,做门控和拒答。
  • 推理工程:长度分桶、CUDA graphs、ONNX/TensorRT 导出、选项乱序做稳定性检查。

不值得照搬

  • RLCD:对可导目标做零阶估计,消融里还更差。用软标签 CE 就够了,有序题可以加一个可导的 RPS。
  • 评测口径:gold 来自 4B teacher、温度 0.7 下的 3 次采样,噪声大;超过 0.735 的分数不代表更正确。
  • 基础 checkpoint:预训练数据和流程都没公开,零样本接近随机,必须做领域微调才能用。

如果我们要做类似的东西,关键投入应该在 teacher 的质量上:换更强的模型、多采样、加人工抽检,用 logprob 而不是口头概率。另外要正视这个架构的结构性短板:选项数受 token 预算限制、state 按问题数重复编码、选项有位置偏置。