1 引言
一个软件缺陷的危害程度,不只取决于它造成多大的偏差,还取决于使用者能否察觉。本文报告的缺陷属于最难察觉的一类:接口返回结构完整、数值合法、置信度正常的概率分布,而模型实际上从未读到候选的主要内容。
该缺陷位于 Laya 的 choice 原语。Laya 是 2026 年 9 月发布的非自回归类型化决策模型,以「33 ms 单次前向、比 Jev 快约 8 倍」为主要卖点,提供 noul、score、choice 三种原语。其文档已记录 head_max_len 预算的存在,并在高基数标签场景(BANKING77,77 个标签)下说明了每标签仅分到 3–4 token 的后果。但该记录是围绕「选项个数多」展开的;本文指出触发条件实为候选总长度,因此十个长候选——一个按官方护栏检查完全安全的配置——同样会触发压缩。
本文的贡献:
1. 给出缺陷的代码级机制与触发条件的精确表述(§3);
2. 以四条互相独立的证据验证其存在,其中包含一条可证伪的对照实验(§4);
3. 在真实评测数据上量化影响,包括内容丢弃率、关键证据丢失率与池级失效率(§5);
4. 评估四种补救措施,并证明官方推荐的方案在该场景下无效(§6);
5. 给出缺陷的适用边界与修复建议(§8、§9)。
2 背景:一条序列装下所有东西
Laya 的骨干是双向编码器(ModernBERT-large 或 mmBERT-base),一次决策被拼成一条序列:
[CLS] 题面 [SEP] [MASK] 候选0 [MASK] 候选1 … [MASK] 候选N [SEP] state [SEP]
每个候选前插一个 [MASK] 作为标记位,模型编码完整条序列后,从这些标记位取出向量送入决策头,得到每个候选的分数。该设计使任意候选数都只需一次前向,是其速度优势的来源;代价是题面、全部候选与 state 三者争夺同一个上下文。
序列被切成两段预算:head_max_len 归题面与全部候选,max_len − head_max_len 归 state。
max_len | head_max_len | ||
|---|---|---|---|
laya | |||
laya-multilingual | |||
laya-typed-decisions |
图 1 同一套预算规则在四种情形下的后果。①②为本文实测,③按公式推算,④为 noul 路径。多个短标签挤掉的是标签之间的区分度;少数长候选挤掉的是候选正文;而调大 head 之后挤掉的是问题本身。
3 缺陷机制
3.1 三步压缩
laya/common.py:64–75 的分配逻辑如下(laya 0.3.4):
opt_ids = []
for i in order:
opt_ids.append([tok.mask_token_id]
+ tok(" " + opts[i]...)["input_ids"][:48])
# 第 1 步:每候选硬截到 48
opt_budget = head_max_len - sum(len(o) for o in opt_ids)
if opt_budget < 16:
# 第 2 步:塞不下则等额压缩
per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))
opt_ids = [o[:per] for o in opt_ids]
opt_budget = head_max_len - sum(len(o) for o in opt_ids)
head_ids = head_ids[: max(8, opt_budget)]
# 第 3 步:题面取剩余
三步的顺序决定了受害者:候选先占,题面吃剩余,state 取序列尾部。
3.2 触发条件
压缩在 sum(候选长度) > head_max_len − 16 时触发。代入本文场景(10 个候选、每条约 227 Qwen token):
laya | (192−16)//10 − 1 = **16** token | |||
laya-multilingual | (256−16)//10 − 1 = **23** token |
两点须强调。其一,第 1 步的 48 token 硬上限是无条件的:任何长于 48 token 的候选都会被裁,与预算是否充足无关。其二,触发条件是总长度而非个数:10 个 227-token 的候选(480 > 176)与 77 个 4-token 的标签(308 > 240)触发的是同一段代码。
3.3 为何校验未能拦截
laya/agent.py:262 是唯一的守卫:
if len(markers) != len(render_options(q)):
raise ValueError("question %r options exceed head_max_len=%d" % (qid, head_max_len))
它比较的是标记个数与选项个数。压缩后每个候选仍保有其 [MASK] 标记位(per = max(4, …) 保证至少 4 个 token),标记数恒等于选项数,校验因此恒定通过。返回的 answers 结构中亦无任何字段记录截断事实。全库中仅有的运行期告警是设备回落 CPU 相关的三处 print,与输入裁剪无关。
这正是「静默」的准确含义:不是文档未记载,而是运行期无任何可观测信号。本文作者的评测脚本最初据此判定「完整候选可行」,即为该守卫所误导。
4 验证
单一证据不足以确立该缺陷:读代码可能误解调用路径,黑盒观察可能源于测试装置本身失效。本文采用四条互相独立的证据,其中第三条可证伪本文结论。
4.0 验证装置的构造
四条证据共用同一个探针。其设计目标是:把「区分性内容」精确地放在可见窗口之外,同时让窗口之内的内容在所有候选间完全一致。
构造方式是十个候选共享同一前缀、只在尾部相异:
前缀 = "根据现行规定,办理该项业务需要本人到场并提供相关证明材料,具体要求如下:"
# 36 字
候选 = {f"c{i}": 前缀 + f"第{i}种情况与本问题无关。" for i in range(10)}
候选["c3"] = 前缀 + "有效期是三年,到期前一个月可以续办。"
# 唯一含答案者
问题 = {"best": {"type": "choice", "instructions": "哪一段回答了这个问题?", "criteria": 候选}}
state = "问题:这个证件的有效期是几年"
前缀长度 36 字是按可见窗口大小反推的:laya-multilingual 的窗口为 (256−16)//10 − 1 = 23 token,中文约合 31 字,故 36 字的共享前缀保证窗口内不含任何区分性信息。
图 2 上:十个候选的构造。每一行都把前缀原文逐字写出,可以看到这 36 个字在十条候选间完全相同;右端彩色部分是各自不同的尾部,其中 c3(绿色)是唯一含答案的一条。红色虚线为可见窗口边界(23 token ≈ 31 字),它落在共享前缀的内部——因此窗口之内十条一字不差,十条的差异部分无一进入。下:这十个候选进入编码器后形成的单条序列(263 token):[CLS] 与题面占 11 个位置,随后是十个各 24 token 的候选块(1 个 [MASK] 标记位 + 23 个内容 token),最后是 state。十个 [MASK] 即十个 marker,模型从这些位置取向量送入决策头。
该构造有三个性质,分别服务于后续四环:
1.窗口内十条完全相同——若模型仅依赖窗口内容,其输出必然与「答案放在哪一格」无关(供环 B 检验);
2.前缀长度可调——缩短前缀即可让答案进入窗口,构成可证伪的对照(供环 C);
3.前缀长度可连续扫描——以此定位敏感度归零的位置,并与由 head_max_len 推算的窗口大小比对(供环 D)。
需要说明的是,该探针是合成的极端构造,用于确立机制的存在性与边界,而非估计真实数据上的影响——后者由 §5 在评测集上独立测量。
4.1 环 A:对真实推理路径插桩
将 laya.agent.build_sequence 替换为透传包装,在真实 system_one 调用期间捕获其实际返回值,再按标记切片解码:
拦截到 system_one 真实构造的序列,长度 263,10 个 marker
含答案的 c3 在序列中实际占 23 token → ' c3: 根据现行规定,办理该项业务需要本人到场并提供相关证明材料,具体'
答案句「有效期是三年,到期前一个月可以续办。」是否出现在其中:False
此环证明模型实际消费的序列不含答案,且所观察的是推理路径本身而非旁路复算。
4.2 环 B:黑盒的答案位移测试
将唯一含答案的候选由 3 号位移至 7 号位,两次返回的概率分布逐位相同(最大差 0.000000),argmax 不变:
答案在 c3 时: [0.0504, 0.1984, 0.1521, 0.0997, 0.0723, 0.0754, 0.0676, 0.0674, 0.1101, 0.1067]
答案挪到 c7 : [0.0504, 0.1984, 0.1521, 0.0997, 0.0723, 0.0754, 0.0676, 0.0674, 0.1101, 0.1067]
此环不依赖任何关于内部实现的假设。在 laya(英文 checkpoint)上同样成立,且暴露出更强的位置先验(c0 得 0.526)。
4.3 环 C:可证伪的对照
环 B 的「零变化」存在一个替代解释:测试装置本身失效,对任何输入都返回同一分布。为排除该解释,保持装置完全不变,仅将共享前缀由 36 字缩短至 3 字,使区分性内容落入可见窗口:
| 否 | 0.734300 |
同一套代码、同样的位移操作,在内容可见时输出变化 0.73。若此项对照未通过,本文结论即被推翻;它通过了,故环 B 的零变化只能归因于内容不可见。
4.4 环 D:边界扫描
逐步加长共享前缀(每次 4 字),记录位移答案所引起的最大概率变化:
图 3 敏感度随共享前缀加长而衰减,并在特定位置精确归零且此后恒为零:laya 在 12 字处归零(其可见窗口约 11 字),laya-multilingual 在 32 字处归零(可见窗口约 31 字)。归零点与由 head_max_len 推出的窗口大小一致,将定性判断转为定量确认。
曲线中段的非单调(如 multilingual 在 24 字处回升)源于切口按 token 而非按字划分,答案句的首字可能有一两个 token 残留在窗口边缘——这一现象本身亦与机制相符:敏感度正比于可见的残留量。
4.5 四环的独立性
A 为白盒,B、C、D 为黑盒;即便 A 的理解有偏差,B+C+D 仍独立成立。
5 影响量化
以下数据来自一个 250 池 × 10 候选的中文段落重排评测集,候选取自 C-MTEB/T2Reranking 的标注段落,平均 245 字(227 Qwen token),全集候选正文共 612,120 字。
图 4 左:内容丢弃量。中:正例中关键证据落在窗口外的比例。右:整池正例证据全部丢失的池比例。
5.1 内容丢弃量
laya | 95.1% | ||
laya-multilingual | 87.4% |
5.2 关键证据丢失率
「内容丢了多少」不等于「有用的东西丢了多少」。本文以可自动判定的代理指标度量后者:对每个正例候选,检查其与查询存在字符二元重合的部分是否落在可见窗口内。
laya | 216 | 46.9% | |
laya-multilingual | 134 | 29.1% |
5.3 池级失效
若一个池中全部正例的证据均被裁掉,则该池上任何排序都不可能基于内容作出。
laya | 65 / 250 | |
laya-multilingual | 30 / 250 |
5.4 逐条对照
点击:1342\| 回复: | 12汉论坛 >比亚迪汉dm低配,扬声器偶尔杂音很大声… | |
国庆喜提50噚 国庆放假出去逛街,看到益田假日广场 | 亨吉利宝珀刚好有一块蓝色50噚5000 0240 O52A… | |
家养的土鸡完全可以 | 吃饭店的剩饭剩菜。鸡不怕油和盐,吃剩饭剩菜没问题。 | |
它喜光,要放在光照好的地方,春秋冬三季都能接受全日光, | 但夏季中午要遮阴,光照充足才会长得更旺盛… |
中文网页段落普遍存在「开头是噪声、正文在中后段」的结构(论坛页眉的点击与回复计数、导航面包屑、铺垫性套话),而截断恰好只保留开头。第三例尤其说明问题:整段仅 34 字的直接答案,仍被切断在「完全可以」之后。
5.5 对下游指标的影响
将同一个字符二元重叠基线分别喂入全文与可见窗口:
laya | 0.481 |
laya-multilingual | 0.487 |
窗口内残存的排序信号仅略高于随机。作为参照,Laya 在同一评测集上 choice 原语的实测成绩为 0.492–0.569——高于上述词汇上界,说明它从残存窗口中提取的信息多于字面重合,但其可用信息量本身已被截断限定在很低的水平。
6 补救措施评估
head_max_len(官方推荐) | 无效,且多数情况下更差 | |
max_len,让候选 100% 可见 | 更差,跌破随机水平 | |
noul 逐对判断 | 有效 | |
6.1 调大预算为何无效
head_max_len 存于 checkpoint 的配置文件,system_one 每次调用现读,故使用时可直接修改。实测(同一批 100 池):
laya | 0.39 | ||||
laya | |||||
laya | |||||
laya-multilingual | 0.30 | ||||
laya-multilingual | |||||
laya-multilingual | |||||
laya-typed-decisions | |||||
laya-typed-decisions | 0.36 | ||||
laya-typed-decisions |
给到 2.7–5.4 倍的每候选 token 后,三个 checkpoint 中两个变差、一个自 0.30 升至 0.36,顺序翻转率普遍恶化。根本原因见图 1 第二行:头部扩张是从 state 抢占空间——查询的可见量由 754 token 降至 177,题面亦被 head_ids[: max(8, opt_budget)] 压至 8 token。
6.1.1 把 max_len 一并放开会怎样
上述实验只调整了 head_max_len。Laya 文档同时提到 max_len 可上调至 2048/4096/8192(mmBERT 与 ModernBERT 均支持 RoPE 外推)。若两者一并放开并绕过 48 token 硬上限,候选可以完整进入序列。实测(同一批 100 池):
laya | 0.390 | |||||
laya | ||||||
laya | ||||||
laya | ||||||
laya | 100% | 0.160 | ||||
laya-multilingual | 0.300 | |||||
laya-multilingual | ||||||
laya-multilingual | 100% | |||||
laya-multilingual | ||||||
laya-multilingual |
图 5 左:随着候选被读到的比例上升,top1 命中单调下降,并跌破 10 选 2 的随机水平 0.200。右:延迟随可见长度上升约 10 倍。
技术上参数完全生效:候选可见量由 16/23 token 提升至 400/212 token,覆盖原文 100%,无异常,代价是延迟上升约 10 倍。但效果为负:laya 自 0.390 单调降至 0.160,laya-multilingual 自 0.300 降至 0.190,两者均跌破随机水平。
这修正了一个容易得出的结论:问题不是上下文容量不足——上下文放开到 2048 即可容纳。问题在于训练分布:build_sequence 的 48 token 硬上限是无条件的,因此 Laya 在训练阶段从未见过长于 48 token 的候选;喂入 212–400 token 的候选属于分布外输入,标记位上的向量无法概括一段它从未学习过如何概括的长文本。
这一发现同时改变了修复难度的量级:调整 head_max_len 是配置项,而让长候选真正可用需要重新训练。它也解释了官方建议为何只在短标签场景成立——BANKING77 把每标签由 3 token 放宽到 4 token 仍在训练分布内,而长候选由 23 token 放宽到 212 token 已经出界。
6.2 noul 路径不受影响
noul 逐对判断时,待判文本位于 state(序列尾部),判据文本短(约 30 token),不挤占候选预算。实测:laya-multilingual 的 state 完整可见;laya(max_len 512)在长文本对上有轻度截尾(平均保留 94%,49% 的对完整可见)。
7 与同类系统的对比
同一批数据、同一套题面下,三种系统对「候选过长」的处理方式:
| Laya | 无任何信号 | |
| NanoJev | max_length 时抛出异常:候选路径为 N token,超过 max_length=1024;未截断输入 | |
| Jev |
NanoJev 的策略代价是算力(choice 延迟为其 noul 的 7.8 倍),但它拒绝而非静默降级。这一对比说明:静默截断是一项设计选择,而非该类架构的必然属性。
8 缺陷的适用边界
本文不主张 Laya 在所有场景下不可用。该缺陷的触发条件明确,且其官方示例所代表的典型负载不受影响。以 Laya README 的工单分诊示例为例,实测其预算占用:
风险按负载类型分级:
| 低 | ||
| 少量长候选(段落、文档、商品描述) | 高 | 本文情形,文档未覆盖,护栏失效 |
| 高 |
需特别指出:官方文档给出的护栏是「choice 题控制在 20 个候选以内」。按此检查,十个候选完全安全;而实际触发条件是候选总长度。护栏的表述与触发条件不匹配,是该缺陷难以被使用者预防的直接原因。
Router 模式(官方推荐入口)同样不能规避:Router.predict 内部即 agent.system_one,实测同一装置经 Router 调用时截断行为完全一致。
9 修复建议
按实施成本由低到高:
1.在响应中返回截断元数据。system_one 的返回值增加 truncation 字段,记录每个候选的原始与保留 token 数。成本最低,且使所有下游使用者可自行判断。
2.提供 strict 模式。Agent(strict=True) 时,若任一候选被裁则抛出异常,与 NanoJev 的行为一致。适合在 CI 与上线前校验中启用。
3.修正文档的护栏表述。将「候选数 < 20」改为「sum(候选 token 数) ≤ head_max_len − 16」,并提示 48 token 的无条件上限。
4.对长候选给出不同的推荐路径。现行文档对超预算场景统一建议调大 head_max_len;如 §6.1 所示,该建议在长候选场景下无效。宜按候选长度区分:短标签场景调大预算,长候选场景改用 noul 逐对或粗到细两级。
10 相关报告
该预算机制在Github Issue已被多次提出,但既有讨论全部围绕「选项个数多」:#18 提出按选项数自动放大 head;#106 为高基数 choice 增加候选召回;#102 报告按文档调大 head_max_len 后 BANKING77 仍仅 54.3%,改用粗到细两级后升至 60.8%。
最接近的是 brianluby/laya#1,它报告同一函数的 head_ids[: max(8, opt_budget)] 会静默截断题面,使 noul 题中写入题面的逐候选证据被整段丢弃,报告者称其审计的 2,059 道题中 1,806 道受影响。该问题与本文所述属同一根因在不同字段上的表现。
据本文检索(GitHub issue 搜索 rerank、passage choice、long options truncated),尚无公开报告覆盖「候选个数不多但每条很长」这一情形,亦无报告指出调大预算在该情形下有害。
11 局限
版本与范围:全部结论基于 laya 0.3.4 与三个公开 checkpoint,若后续版本修改 build_sequence 则需重测。
代理指标:§5.2 的「关键证据」以查询与候选的字符二元重合定义,这是可自动判定的近似,不等同于语义证据;它会漏掉纯语义相关而无字面重合的正例,故 46.9%/29.1% 应视为该类损失的下界估计。
语种:损伤量化基于中文数据。英文场景下同样的 token 预算可容纳更多字符,绝对损伤会低于本文数值,但机制与触发条件不变。
未评估的补救:粗到细两级方案本文未实测,其有效性引自上游 #102。
12 结论
Laya 的 choice 原语在候选总长度超出 head_max_len 预算时,会将每个候选静默压缩至 16–23 个 token,且运行期无任何可观测信号。本文以四条互相独立的证据确立该行为,其中包含一条可证伪的对照实验与一次将归零点定位到可见窗口大小的边界扫描。
在一个中文段落重排评测集上,该缺陷导致 87.4%–95.1% 的候选正文未被读取,29.1%–46.9% 的正例关键证据整段丢失,12.0%–26.0% 的候选池上模型无从判断。官方文档推荐的「调大 head_max_len」在该场景下无效且多数情况更差,因为头部扩张以挤占查询文本为代价;而把 max_len 一并放开、让候选完整可见后,成绩反而单调下降至随机水平以下——瓶颈不在上下文容量,而在训练分布(模型从未见过长于 48 token 的候选)。
该缺陷的触发条件是候选总长度而非个数,而官方护栏按个数表述——这是它难以被预防的直接原因。修复路径清晰且成本可控:返回截断元数据、提供 strict 模式、修正护栏表述、按候选长度区分推荐路径。