Root Cause Analysis · Tetris × Laya

Laya 只得几百分:根因诊断

本地 Laya 玩家开箱只得几百分,同环境 Jev 可以得几万分。本文用三组受控实验把「接口适配问题」与「模型能力问题」分离,给出决定性的根因与可行的对策。

2026-09-28 laya 0.3.21 ModernBERT-large 421M typed-decisions / english Mac MPS
01

结论

根因

Laya 开箱模型对本项目的棋局表示没有判别力,而不是接口没接通。

决定性证据:只给模型两个候选(启发式最优 −7.64 vs 最差 −16.29,差距相当于 11 行的价值),题干与选项都完整进入了模型,它依然给出 top=0.541、熵=0.995 的近似抛硬币输出,并选走了最差的那一个。

第二位的因素确实存在——head_max_len=256 的预算把题干从 190 token 压到 22 token(评分标准 priorities 全部丢失)、把每个候选从 82 token 压到 26 token(决定棋力的字段被拦腰砍断)。但受控实验表明:即便修掉截断,熵只从 0.982 降到 0.967,几乎不改善。所以它是真实的缺陷,却不是主因。

层次问题对棋力的影响可修复性
主因 模型缺语义先验:421M 编码器从未见过「俄方块盘面 → 落点决策」这类任务,也没有见过这套 state 词化 决定性——输出近乎均匀分布 只有微调能解
次因 Prompt 规模与 head_max_len 预算冲突:题干 + 候选需要的 head 远超 256 token 实测影响很小(熵 0.982→0.967) 可修(压缩文案)
非问题 协议适配层:请求/响应字段、选项 key 映射、state 序列化 已验证全部正确 无需改
02

实验设计

要区分「模型不会」和「我们没把信息喂对」,关键是构造一个信息绝对充足的场景:如果在这种场景下模型依然无法区分,那问题就与适配无关。

固定同一个中局盘面(seed 42,先落 5 块,第 6 块决策),从 9 个合法落点里挑出启发式最高与最低的两个,只把这两个给模型选:

启发式最优  p2  -7.64
启发式最差  p8  -16.29
分差        8.65  ≈ 11 行的价值(Dellacherie 尺度:消一行仅 +0.76)

三组变体逐步放宽信息供给:

变体候选选项文本目的
A 现状9 个8 字段对象(与线上完全一致)复现线上行为
B 极简9 个压成一句短自然语言,消除截断隔离「截断」的影响
C 判别力2 个(最优/最差)极简文本,题干与选项均完整测模型能力上限
判读基准

归一化熵:1.0 = 完全均匀(模型没有任何信息),0 = 完全确定。9 选项均匀时 top=0.111;2 选项均匀时 top=0.5。C 变体的关键判据就是 top 是否显著高于 0.5。

03

决定性证据

3.1 全量结果

变体checkpoint 题干入模型选项入模型 选择top熵启发式排名
A 现状(9 项)typed 190 → 2282 → 26 p10.1720.9826 / 9
B 极简(9 项)typed 190 → 3923(完整) p20.1620.9671 / 9
C 判别力(2 项)typed 190(完整)22(完整) p80.5410.9959 / 9
A 现状(9 项)english 190 → 2182 → 19 p10.2120.9646 / 9
B 极简(9 项)english 190 → 2123 → 19 p10.4470.8156 / 9
C 判别力(2 项)english 190 → 14522(完整) p20.5071.0001 / 9

3.2 读这张表

C 变体是全部证据里最硬的一条。它给足了信息:题干 190 token 完整进入(typed 顶格 209 token 的 head 预算),两个选项各 22 token 完整进入,没有任何截断。候选只有两个,差距悬殊到「肉眼可辨」。

结果:typed checkpoint 选走了最差的 p8,熵 0.995 已近乎完全均匀;english checkpoint 熵直接等于 1.000,即输出分布与均匀分布不可区分——它命中 p2 纯属二选一的运气。

换句话说:把它放在一个「闭着眼睛都该选对」的局面里,它依然在抛硬币。这正是「模型没有棋力」的判定标准。

归一化熵:六种组合全部逼近 1.0(= 完全均匀) 越接近 1.0,说明模型输出越像在选项间随机分配概率 0.0 0.5 1.0 完全均匀 .982 .967 .995 .964 .815 1.000 typed 检查点 english 检查点 A B C A B C
红色为 C 判别力变体——信息最充足的那组,混得最差。B 变体(橙色)虽然仅靠二选一场景看不出优势,但在 9 选项场景下把熵从 0.982 拉到 0.967,说明消除截断确有一点作用,只是远不足以翻盘。
04

根因剖析

4.1 主因:模型缺语义先验

Laya 是 ModernBERT-large(421M)加两层 head 的非自回归判别模型。它的 typed-decisions 检查点来自官方 7313 步、1.96 小时的微调,数据是通用 typed-decisions 基准——不包含俄罗斯方块,也不包含本项目的 state 词化。

关键认知是:判别式模型没有推理能力可以「借」。它不像大语言模型那样能从常识推出「消行是好事、造洞是坏事」。它的全部判断力来自训练分布里见过的模式。一旦把它们放进训练分布之外的状态表示(board_rows_top_to_bottom 这样的 JSON 数组、{"where": "columns 7-8", ...} 这样的富结构候选),它给出的就是接近均匀的分数——这正是熵 0.99+ 的含义。

顺带一个反面证据:连只有 4 个选项、文本完整、语义直白的 strategy 问题("The stack is low, flat, and has no holes..."),熵也高达 0.998;二选一的 next_piece_fits 给出 0.5228,约等于抛硬币。整个模型在这套输入上没有可用的判别信号。

4.2 次因:prompt 规模与 head 预算的结构性冲突

Laya 的输入结构由 laya/common.py: build_sequence 决定:

[CLS] <type> question: instructions [SEP] [MASK]opt0 [MASK]opt1 ... [SEP] state [SEP]
              ↑ 这里必须在 head_max_len 内塞完 ↑

题干和所有选项共享一个固定预算 head_max_len(typed-decisions 为 256)。超预算时的降级策略是等比例压缩每个选项:

opt_budget = head_max_len - sum(len(o) for o in opt_ids)
if opt_budget < 16:
    per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))
    opt_ids = [o[:per] for o in opt_ids]
head_ids = head_ids[: max(8, opt_budget)]      # 剩下的才给题干

本项目的真实请求命中了两条压缩路径:

内容原始实际入模型丢失了什么
题干(question + 6 条 priorities) 190 token 22 token 6 条评分标准全部丢失,连问句本身都没读完
每个候选落点 82 token 26 token 只剩 {"where": "columns 7-8", "lines_cleared": "none", "holes_created —— holes_created 的值都没进来,height_change、stack_height_after、surface_after、wells_after 全丢
state(盘面) 259 token 259 token(完整) —— 盘面本身反而没被截断
真实请求下 head 预算如何砍掉关键信息 head_max_len = 256 token,题干与全部选项共享 0 50 100 150 200 token 190 22 原始 入模型 82 26 原始 入模型 题干(含 6 条评分标准) 单个候选落点 这两处截断 发生了,但修掉 也救不回棋力
实测(typed-decisions,真实 9 候选请求):题干从 190 token 压到 22,候选从 82 压到 26。B 变体完整保留了候选文本,熵仅从 0.982 降到 0.967——截断是真实缺陷,但不是主因。

4.3 决策机制:为什么「分布平」直接等于「棋力低」

Laya 的 choice 答案是纯 argmax 的,没有任何采样或后处理:

# laya/agent.py: _decode_answers
z = logits[r, :k] / t_scale
p = np.exp(z - z.max()); p = p / p.sum()
answers[qid] = {..., "choice": keys[int(p.argmax())], ...}

所以分布的平坦度不是「模型犹豫一下」,而是决策依据的质量本身。当 9 个选项的概率落在 0.11(均匀值)附近时,模型实际上是在选项编号之间按微小噪声取最大值——这与随机落点没有实质区别,宏观表现就是几百分。

05

已排除的可能

为了让结论站得住,下面每一项都做了验证,确认不是原因:

假设验证方式结果
请求/响应协议没对齐用 engine.js 构造真实请求打本地服务,逐字段核对页面消费项字段全兼容
state 被截断,模型看不到盘面实测 state token 数与可用 room259 / 764,完整
选项 key 映射错,答非所问核对 probabilities 的键与 criteria 的键一一对应
选错了 checkpointtyped-decisions 与 english 各跑一遍完整对照两个都无判别力
instructions 展平方式引入失真C 变体中题干完整进入模型,仍无判别力不是主因
候选枚举有 bug,漏掉好落点9 个候选与启发式枚举结果一致枚举正常
推理没跑在 MPS 上 / 权重加载不全加载日志与一次性 72ms 前向正常
服务层降级逻辑掩盖了错误日志中无 head_max_len 溢出降级触发未触发
06

为什么 Jev 能行

同样的请求格式、同样的 state、同样的 criteria,Jev 能得几万分,说明这套协议没问题——问题出在协议两端模型的能力与训练分布上。

Jev(TypeSafe System One)Laya(开源复刻)
定位厂商服务端模型社区复刻,Apache 2.0 自托管
规模未公开(服务端)421M 编码器 + 2 层 head
对本任务已对齐「结构化 state + 富 criteria → 决策」这一形态通用 typed-decisions 基准,无游戏/规划语义
输入预算无 head_max_len 这类硬上限head 固定 256 token,题干与选项互相挤占
实测表现几万分几百分(等价随机)

这解释了为什么替换 Jev 不是「换个后端」那么简单:Laya 复刻的是协议,不是能力。协议层面的同构让我们能零成本接上它,但也正因为它是「同一个提问方式」,能力差距会被完整暴露出来。

07

三条对策路径

路径做法成本预期天花板
① 微调
唯一能真正解决
用 harness 批量产出的 (state, 候选, 结果) 日志构造训练集,以启发式或事后最优落点作标签,对 Laya 做 SFT。官方有 T4 四小时模板 需跑数千局生成数据 + 一次训练 可以做到接近启发式基线(193.9 行)的水平
② 适配层优化
成本低,收益小
压缩题干与候选文案(B 变体方案),让两者都落进 256 token;或减少候选数量 半天 实测熵 0.982→0.967,棋力提升有限
③ 重新定位
务实
承认 Laya 是「本地研究对照基线」而非 Jev 的替代:免费、离线、72ms、无 key。主力仍走 Jev 零 不影响现有玩法,保住双后端的研究价值

建议的下一步

  1. 先做一次低成本验证:把 state 从 JSON 数组改写成更口语化的散文(或 ASCII 盘面图),只跑 C 判别力测试。若熵仍逼近 1.0,则「表示形式」这一线也无救,可以直接跳到微调。
  2. 再决定是否微调:微调需要把启发式当训练标签,这会触及 ADR 0001「决策路径禁止代码估值」的边界。我的判断是——训练期用标签 ≠ 运行时把估值喂进决策路径,属于 imitation learning 的标准做法,但应当在 ADR 里补一条明确记录,而不是悄悄做。
  3. 顺便修掉截断:无论走哪条路,让选项文本落进 head 预算都是对的(候选压到 26 token 内、题干精简到 40 token 内),否则微调出来的模型也会被同一个坑拖累。
一句话提醒

不要用「Laya 比 Jev 弱」来收尾。真正的结论是:这个项目的 prompt 形态是按 Jev 这类服务端大模型设计的,它的信息量(900+ token 的题干与候选)从根本上超出了 421M 本地模型 256 token 的判别预算。要本地化,就得让模型重新学这套表示,而不是指望它开箱理解。

08

复现与附录

复现命令

# 1) 生成带启发式分的真实请求
node -e '...见 eval/laya_abc_diag.py 头部注释...'   # 写入 /tmp/laya-req2.json

# 2) prompt 预算诊断(题干/选项各被压到多少 token)
USE_TF=0 HF_HUB_OFFLINE=1 <venv-python> eval/laya_prompt_diag.py typed-decisions

# 3) A/B/C 三组对照(含两个 checkpoint)
USE_TF=0 HF_HUB_OFFLINE=1 <venv-python> eval/laya_abc_diag.py typed-decisions
USE_TF=0 HF_HUB_OFFLINE=1 <venv-python> eval/laya_abc_diag.py english

关键代码位置

文件作用
laya/common.py:106 render_options对象型 criteria 被 json.dumps 成富文本(本项目的候选落点走这条路径)
laya/common.py:135 build_sequencehead 预算分配与选项截断策略
laya/agent.py:752 _encode_state逐问题组装序列,超预算直接抛 head_max_len 错误
laya/agent.py:869 _decode_answersargmax 决策、温度校准、confidence 与 answer_confidence 的差异
engine.js:313 describePlacement候选落点的 8 字段词化,是 82 token 的来源
engine.js:411 buildQuestionsplacement 的题干(question + 6 条 priorities),190 token 的来源
eval/laya_prompt_diag.py本次新增:prompt 预算诊断
eval/laya_abc_diag.py本次新增:A/B/C 根因对照实验

本次实测环境