本地 Laya 玩家开箱只得几百分,同环境 Jev 可以得几万分。本文用三组受控实验把「接口适配问题」与「模型能力问题」分离,给出决定性的根因与可行的对策。
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 序列化 | 已验证全部正确 | 无需改 |
要区分「模型不会」和「我们没把信息喂对」,关键是构造一个信息绝对充足的场景:如果在这种场景下模型依然无法区分,那问题就与适配无关。
固定同一个中局盘面(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。
| 变体 | checkpoint | 题干入模型 | 选项入模型 | 选择 | top | 熵 | 启发式排名 |
|---|---|---|---|---|---|---|---|
| A 现状(9 项) | typed | 190 → 22 | 82 → 26 | p1 | 0.172 | 0.982 | 6 / 9 |
| B 极简(9 项) | typed | 190 → 39 | 23(完整) | p2 | 0.162 | 0.967 | 1 / 9 |
| C 判别力(2 项) | typed | 190(完整) | 22(完整) | p8 | 0.541 | 0.995 | 9 / 9 |
| A 现状(9 项) | english | 190 → 21 | 82 → 19 | p1 | 0.212 | 0.964 | 6 / 9 |
| B 极简(9 项) | english | 190 → 21 | 23 → 19 | p1 | 0.447 | 0.815 | 6 / 9 |
| C 判别力(2 项) | english | 190 → 145 | 22(完整) | p2 | 0.507 | 1.000 | 1 / 9 |
C 变体是全部证据里最硬的一条。它给足了信息:题干 190 token 完整进入(typed 顶格 209 token 的 head 预算),两个选项各 22 token 完整进入,没有任何截断。候选只有两个,差距悬殊到「肉眼可辨」。
结果:typed checkpoint 选走了最差的 p8,熵 0.995 已近乎完全均匀;english checkpoint 熵直接等于 1.000,即输出分布与均匀分布不可区分——它命中 p2 纯属二选一的运气。
换句话说:把它放在一个「闭着眼睛都该选对」的局面里,它依然在抛硬币。这正是「模型没有棋力」的判定标准。
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,约等于抛硬币。整个模型在这套输入上没有可用的判别信号。
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(完整) | —— 盘面本身反而没被截断 |
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(均匀值)附近时,模型实际上是在选项编号之间按微小噪声取最大值——这与随机落点没有实质区别,宏观表现就是几百分。
为了让结论站得住,下面每一项都做了验证,确认不是原因:
| 假设 | 验证方式 | 结果 |
|---|---|---|
| 请求/响应协议没对齐 | 用 engine.js 构造真实请求打本地服务,逐字段核对页面消费项 | 字段全兼容 |
| state 被截断,模型看不到盘面 | 实测 state token 数与可用 room | 259 / 764,完整 |
| 选项 key 映射错,答非所问 | 核对 probabilities 的键与 criteria 的键 | 一一对应 |
| 选错了 checkpoint | typed-decisions 与 english 各跑一遍完整对照 | 两个都无判别力 |
| instructions 展平方式引入失真 | C 变体中题干完整进入模型,仍无判别力 | 不是主因 |
| 候选枚举有 bug,漏掉好落点 | 9 个候选与启发式枚举结果一致 | 枚举正常 |
| 推理没跑在 MPS 上 / 权重加载不全 | 加载日志与一次性 72ms 前向 | 正常 |
| 服务层降级逻辑掩盖了错误 | 日志中无 head_max_len 溢出降级触发 | 未触发 |
同样的请求格式、同样的 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 复刻的是协议,不是能力。协议层面的同构让我们能零成本接上它,但也正因为它是「同一个提问方式」,能力差距会被完整暴露出来。
| 路径 | 做法 | 成本 | 预期天花板 |
|---|---|---|---|
| ① 微调 唯一能真正解决 |
用 harness 批量产出的 (state, 候选, 结果) 日志构造训练集,以启发式或事后最优落点作标签,对 Laya 做 SFT。官方有 T4 四小时模板 | 需跑数千局生成数据 + 一次训练 | 可以做到接近启发式基线(193.9 行)的水平 |
| ② 适配层优化 成本低,收益小 |
压缩题干与候选文案(B 变体方案),让两者都落进 256 token;或减少候选数量 | 半天 | 实测熵 0.982→0.967,棋力提升有限 |
| ③ 重新定位 务实 |
承认 Laya 是「本地研究对照基线」而非 Jev 的替代:免费、离线、72ms、无 key。主力仍走 Jev | 零 | 不影响现有玩法,保住双后端的研究价值 |
不要用「Laya 比 Jev 弱」来收尾。真正的结论是:这个项目的 prompt 形态是按 Jev 这类服务端大模型设计的,它的信息量(900+ token 的题干与候选)从根本上超出了 421M 本地模型 256 token 的判别预算。要本地化,就得让模型重新学这套表示,而不是指望它开箱理解。
# 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_sequence | head 预算分配与选项截断策略 |
laya/agent.py:752 _encode_state | 逐问题组装序列,超预算直接抛 head_max_len 错误 |
laya/agent.py:869 _decode_answers | argmax 决策、温度校准、confidence 与 answer_confidence 的差异 |
engine.js:313 describePlacement | 候选落点的 8 字段词化,是 82 token 的来源 |
engine.js:411 buildQuestions | placement 的题干(question + 6 条 priorities),190 token 的来源 |
eval/laya_prompt_diag.py | 本次新增:prompt 预算诊断 |
eval/laya_abc_diag.py | 本次新增:A/B/C 根因对照实验 |
max_len=1024, head_max_len=256(官方微调 7313 步 / 1.96h)max_len=512, head_max_len=192