只涂答题卡,不写作文:Laya 源码实测手记

这话不是修辞。它真的不生成任何 token——usage.output_tokens 恒为 0。文本进来,它把题面和一个 [MASK] 判分点拼成一条序列,前向一次,读 [MASK] 位置上的分数。分数最高的选项,就是答案。

Laya 实测手记 · 配图

Laya 是个很小的 Python 包。它做的事,一句话能说完:

给它一段文本和一道选择题,它不写答案,它涂答题卡。

这话不是修辞。它真的不生成任何 token——usage.output_tokens 恒为 0。文本进来,它把题面和一个 [MASK] 判分点拼成一条序列,前向一次,读 [MASK] 位置上的分数。分数最高的选项,就是答案。

没有解码循环,没有采样,没有 max_new_tokens。所以也就没有"模型胡说"这回事——因为根本没有"说"这一步。

我花了半天把源码读完、权重下下来、在这台 4060 上把主要路径全部实跑了一遍。下面是最值得说的几件事,其中有四条是文档里不会告诉你的。


一、它怎么"涂卡"

一条完整的输入长这样:

[CLS] choice question: Which department should handle this request? [SEP]
[MASK]billing: invoices, payments, refunds [MASK]technical: bugs, outages
[MASK]sales: pricing [MASK]other: everything else [SEP]
{"body": "Hi, we were billed twice for March..."} [SEP]

注意三件事:

每个选项前面都有一个 [MASK]。 这些不是装饰,判分点就落在这里——模型在这些位置各吐一个分数,取 softmax。所以"选项的位置"和"选项的分数"严格一一对应。

题面里的 {message}、{body} 不会被替换。 它们只是给模型看的语义提示(训练时见过这种写法)。真正的 state 永远独立拼在选项之后。别指望用占位符省 token。

只有三种题。 choice(分类)、score(序数)、noul(二值)。这不是"目前支持三种",而是从训练目标出发只能有三种——各自对应一种严格适当的评分规则。score 的特别之处是输出期望档位:给四档 ["calm","concerned","annoyed","angry"],它可能返 1.84,意思是"落在 concerned 偏 annoyed 一带"。刻意保留了序数信息,下游可以直接拿来做阈值。


二、四条实测打脸

① 选项一多,它不报错,只是悄悄把标签删了

这是我最想写下来的一条。

head_max_len 管着选项那一段的 token 预算(英文 checkpoint 默认 192)。选项多了怎么办?代码里有这么一行:

per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))

它把每个选项均分后截断。 关键在于那个 max(4, ...) 是硬下限——所以无论你有多少个选项,每个至少保 4 个 token,其中 1 个还是前导 [MASK],真正留给文本的只剩 3 个。

实测的均摊曲线:

  • 5 个选项以内 → 每项 26 token
  • 8 个 → 22 token
  • 20 个 → 8 token
  • 30 个 → 5 token
  • 40 个以上 → 4 token(触底,文本只剩 3 个)
从 8 个选项起就开始削了。 到 30 个以上,每个标签只剩 3 个 token。

我把 120 个选项喂进去,把序列里前三个判分点解出来看:

opt0 @  6 → "[MASK] label_000"
opt1 @ 10 → "[MASK] label_001"
opt2 @ 14 → "[MASK] label_002"

原描述是 label_000: dddddd...(26 token),描述整段没了。

而模型仍然收到 120 个选项、仍然输出一个合法标签、confidence 照算。你拿到的是一个看着完全正常的错误答案。

② 那道"防线"拦的不是截断

代码里确实有一道断言(agent.py:287):

if len(markers) != len(render_options(q)):
    raise ValueError("question %r options exceed head_max_len=%d" % (qid, head_max_len))

我一直以为它是用来拦"选项被截断"的。不是。

选项被压到 per=4 时,判分点数量依然等于选项数,这个断言毫无反应。它真正拦的是另一种情况:选项多到序列总长撞破 max_len,尾部判分点被 build_sequence 最后一句

return ids[:max_len], [m for m in markers if m < max_len]

静默过滤掉,数量才对不上。

实测边界(英文 checkpoint,max_len=512):

  • 127 个选项以内 → 不报错,全部静默截断到每项 3 个 token
  • 128 个及以上 → ValueError: ... options exceed head_max_len=192
而且这个边界会随题干长度前移:把 instructions 从 1 个词拉长到 60 个词,首个抛错点从 128 掉到 127。

从 8 个起悄悄截断,从 128 个起才炸。中间那 120 档,是无人看守的地带。

顺带记一句:choice 的选项数,我建议不要超过 20。真要更多,走官方的 predict_shortlist(先 embedding 粗筛,再做一次前向),或者自己把题拆成两级。

③ 两种题型的 confidence 是两个不同的量

返回字典里,三种题型都带一个 confidence,字面意思像是同一个东西。不是。

choice 和 score 用的是归一化香农熵:

confidence = 1 - H(p) / log k

而 noul 那一支,代码里明明白白写着另一行:

"confidence": round(max(float(p[1]), 1.0 - float(p[1])), 4),   # 最大概率

我用同一批题测了一下:

  • noul 题 → 返回值 0.7988,但它的熵置信度只有 0.2757(最大概率 0.7988)
  • choice 题 → 返回值 0.1204,熵置信度 0.1203(最大概率 0.4047)
看 noul 那行:两个量差了 0.52。 同样一个分布,一个说"我有八成把握",一个说"我其实挺虚"。

所以不要跨题型比 confidence。 你把 noul 和 choice 的置信度塞进同一个阈值闸门,就是在比较两个不同的物理量。真要统一,自己按熵重算(noul 只有两档,p 就是 [1-p1, p1]),然后重新校准阈值。

④ preload 与懒加载:差 1000 倍

这条不算"打脸",但数字大到值得单独写。

Router 会把请求路由到三个 checkpoint 之一(english / multilingual / typed-decisions,判据是脚本检测 + 语言)。默认 max_loaded=1,也就是同时只留一个模型在显存里。

同一批 state(en→hi→en→zh→hi→en),两种配置:

  • 先 preload([...]) → 切一次语言 65 – 140 毫秒
  • 不 preload(懒加载)→ 切一次 70,000 – 82,000 毫秒
  • 差距:约 500 – 1000 倍
懒加载那 73 秒花在读 842 MB safetensors + 重建模型 + 搬上 GPU。官方 README 说重建是"CPU 7.4 秒 / T4 10.3 秒"——在我这台 4060 上,一次就 38 到 82 秒。

顺手一个坑:Router.__init__ 的 preload 形参类型是 bool,实现是 if preload: self.preload()——不带参数,于是 names 取成全部三个。所以 Router(preload=["english"]) 不报错,但那个列表被彻底忽略,它会安静地跑去下载另外 842 MB。正确写法是构造之后再单独调方法:

router = Router()
router.preload(["english"])      # 只预热指定的一两个


三、它适合什么,不适合什么

适合:一次要判很多条规则、且每条都有明确档位选项的场景。客服工单分诊、内容审核、提示词护栏、模型分流——官方自带的四套预设正好对应这四类。这类任务的特点是:规则清晰、选项有限、要的是逐条可解释的判定,而不是一段生成文本。

尤其适合:同一个 state 要判多条规则。因为一次前向能把所有题一起算完——1 问 80 毫秒,10 问还是 80 毫秒,固定开销占绝对主导,批量几乎是白送的。从 1 问到 50 问,吞吐从 12.4 q/s 涨到 233.4 q/s,延迟只涨 2.7 倍。

不适合:需要生成文本的任务(它压根不生成);选项超过 20 的分类(见上文 ①②);以及指望置信度自动兜底的场景——官方实测里,英文 checkpoint 读高棉语准确率 0.000、置信度 0.952,模型能自信地和错误地活着。


四、一个反直觉的好消息

我原以为"英文 checkpoint 读不了非拉丁文"。实测下来,这话说得太绝对了。

同一句退款诉求,丢给 english checkpoint:

  • 印地语 → billing ✓,置信度 0.37
  • 日语 → billing ✓,置信度 0.33
  • 中文 → billing ✓,置信度 0.82
  • 俄语 → billing ✓,置信度 1.00
四种全答对了。 因为 ModernBERT 的词表本身带多语子词,中文、俄语这类大语种它能猜个八九不离十。

真正危险的不是"答错",是答对了但理由不成立——你不知道哪一次是运气。所以判据不该是"它能不能读",而是"这是不是它的母语"。这也是 Router 的设计意图:它不看模型自述,只看脚本检测——一个便宜、可解释的规则,换掉一个昂贵且不可靠的自我评估。

同一批输入走 multilingual,8 种语言 8/8 全对(含中文、印地语、日语),单次 61–73 毫秒。


五、复现材料

我把实跑脚本都留下了,一共 8 个,覆盖加载、路由、延迟、预设、边界、错误路径、测试套件:

  • s2_basic.py — 加载与 quickstart
  • s3_router.py / s3b_router_latin.py — 路由判定与拉丁文边界
  • s4_latency.py / s4b_preload.py — 批量延迟、preload 对照、生命周期
  • s5_presets_errors.py — 四套预设与错误路径
  • s5b_truncation.py — 上文 ①②③ 的全部证据
  • s7_tests.py — 仓库自带 8 个测试(285 项断言通过)
两个环境坑也顺手记一下,省得别人再踩:

tokenizer 不在快照根目录,在 /tokenizer。直接 AutoTokenizer.from_pretrained(snap) 会报"需要安装 sentencepiece"——这个报错是误导的,原因只是路径不对。

Windows 上 test_download.py 必挂 5 个。 是上游的路径分隔符 bug:一边用 / 拼、一边用 \ 拼,断言在 Windows 上不可能通过。跟下载没关系。

另外一个有意思的细节:tests/test_local_e2e.py 需要 /{laya, laya-multilingual, laya-typed-decisions}/ 这样的目录结构。用 Windows 目录联接(junction)搭会失败,报错是"does not contain rl_agent_config.json",看着像权重没下全——实际是 HF 快照内的相对符号链接经 junction 后按新路径解析,指到了不存在的目录。用硬链接展平才行。


一句话收尾

Laya 的思路很干净:把"判断"从"生成"里剥出来。

不生成文本,就没有解析、就没有幻觉、就没有 token 成本;换来的是必须把问题写成选择题——而这件事,恰恰是很多业务规则最自然的表达方式。

但它的边界也很清楚:选项数、题型、语言,这三样都得在它划定的圈里。而这三样,恰好都是不太会报错的——它不炸给你看,它只是安静地把标签删掉、把置信度换成另一个量。

所以用它的关键,不在于会不会调 API,而在于知道它的沉默意味着什么。

暂无表态

想参与讨论或点赞?登录后使用完整功能

讨论回复(0)

暂无回复,登录后可参与讨论
合作

智谱 GLM-5 已上线

在智谱开放平台 BigModel.cn 打造 AI 应用。新一代旗舰模型 GLM-5 在推理、代码、智能体综合能力达到开源模型 SOTA。

领取 2000万 Tokens