先约束,再决策:Laya 网格世界的两轮调试
第一次它对着空气挥剑 29 个回合,第二次它在两个格子之间来回走到第 75 回合。两轮失败的病因完全不同,而最后通关靠的不是让模型变聪明。
接上一篇 在 4GB 显存上跑 Laya。那篇的结论是模型需要硬规则兜底;这篇是把它真的做出来、并且跑通的过程。
结果先说
最后一次运行:第 35 回合到达右下角目标,剩余 70 HP,界面上的决策来源标注为 LAYA。
但在这之前有两轮失败,而且两轮的病因完全不同:
- 第一轮,它对着空气挥剑挥了 29 个回合。
- 第二轮,它在相邻的两个格子之间来回走到第 75 回合。
两次都不是模型装坏了,也不是显存不够。
环境长什么样
一个 10×10 的网格世界,tkinter 画的界面。两条竖墙各留一个缺口,三个敌人,角色要从起点走到右下角目标。绕墙必须走缺口,所以最短路径是 S 形的。相邻的敌人每回合扣 10 HP,200 回合没到达算失败。
模型调用在后台线程里跑,界面由主线程更新。整个调试过程没有修改 Laya 本体,只在环境侧做约束。
项目里有两个可切换的后端:MockDecisionClient 用环境内的规则评分选动作,LayaClient 走真实的 laya.predict。这个区分在后面很关键。
第一轮:没有敌人,也照样选 fight
现象很直白:角色停在左上角不动,HP 一直是 100,第 29 回合的截图里 fight 概率约 81.9%,日志反复打印 fight | 没有相邻敌人。
一个容易走偏的读法是把这条日志理解成“模型调用失败了”。实际上调用是成功的——模型确实返回了动作,只是这个动作在当前局面下什么也不会发生。环境认识到了“没有相邻敌人”,但它在决策之前没有排除 fight。
原版每回合无条件把 up、down、left、right、fight、run 六个动作全交给模型。模型的活儿是“从这六个里挑一个听起来合理的”,它挑 fight 其实并不冤——问题是这六个里本来就有四个不该出现在选项里。
修复是在环境侧生成候选,而不是在模型侧加说明:
def decision_criteria(self):
criteria = {}
for action, (dx, dy) in self.moves.items():
nxt = (self.player[0] + dx, self.player[1] + dy)
if self.legal(nxt):
criteria[action] = f'移动到 {nxt}'
if self.adjacent():
criteria['fight'] = '消灭一个上下左右相邻的敌人,减少伤害'
return criteria
legal() 顺手排掉了越界、撞墙和踩到敌人格子的移动,adjacent() 决定 fight 是否进入候选。回归检查很简单:起点只能提供 down 和 right;靠近敌人时才出现 fight;敌人和障碍占的格子不能作为目的地。
这一版之后,空挥剑的问题消失了。然后立刻出现了新问题。
第二轮:动作都合法了,但它在绕圈
第一次修复后角色开始动了,但日志里持续交替出现 right 和 left。第 75 回合的截图显示它还在地图上方,HP 90——说明它动了很多回合,净位移几乎为零。
这一轮的病因和第一轮刚好相反:第一轮是候选里混进了不可执行的动作,第二轮是候选全部可执行,但没有一个保证“更接近目标”。
第一次修复时我做了件看起来很聪明的事:把绕开障碍后到目标的剩余步数写进每个动作的描述里,希望模型看到“这样做还要 12 步,那样做只要 9 步”就自然会选对。没用。
模型可以完全忽略描述里的数字,挑一个读起来也合理的动作。这跟上一篇里那个 82.32% 是同一个毛病——文字偏好不等于程序约束。写进描述里的约束,模型有权不理。
最终修复:把“推进”变成候选的准入条件
真正的修法是把“是否推进”从描述里搬到候选生成里。用广度优先搜索算当前位置和下一格的真实路径距离,只把严格缩短距离的移动交给模型:
def distance(self, start):
"""计算绕过障碍的剩余步数。"""
queue, seen = [(start, 0)], {start}
for pos, distance in queue:
if pos == self.goal:
return distance
for dx, dy in self.moves.values():
nxt = (pos[0] + dx, pos[1] + dy)
if self.legal(nxt) and nxt not in seen:
seen.add(nxt)
queue.append((nxt, distance + 1))
return None
def decision_criteria(self):
"""导航辅助:只提供缩短真实路径的移动,Laya 仍选择移动或战斗。"""
criteria = {}
current_distance = self.distance(self.player)
for action, (dx, dy) in self.moves.items():
nxt = (self.player[0] + dx, self.player[1] + dy)
if self.legal(nxt):
distance = self.distance(nxt)
if distance is not None and (current_distance is None or distance < current_distance):
criteria[action] = f'移动到 {nxt};绕障碍到目标还需 {distance} 步(比当前更近)'
if self.adjacent():
criteria['fight'] = '消灭一个上下左右相邻的敌人,减少伤害'
# 自由逃跑可能回退并形成循环;导航模式不提供任意逃跑。
return criteria
这里有个细节值得单独说:比较的必须是绕障碍之后的路径长度,不是直线距离,也不是横纵坐标差。如果按直线距离判断,绕墙时那个“暂时远离目标但必须走”的转向会被误判成退步,候选里就只剩反向的移动,反而制造出新的循环。
为什么这一版能收敛
两个单调性把它锁住了:
- 每次移动都让剩余路径步数严格下降,所以角色不可能回到这张地图上的旧位置,
right/left那种往返循环在结构上就不成立。 - 每次有效 fight 都减少一个敌人,敌人数量有限且不会再生,所以战斗也不能无限重复。
- 消灭敌人会改变最短路径,所以下一回合重新计算候选就够了,不需要额外维护什么状态。
需要说清楚的是,这个论证只在当前这张固定地图上、且模型确实返回了合法候选时成立。如果以后加入会移动的敌人、随机障碍,或者允许真正的逃跑策略,“推进”的条件要重新定义,这套过滤得跟着改。
那个 100% 是怎么来的
成功截图底部写着 可执行动作概率(已归一化):down: 100.0%。
看着像模型对通关有百分之百的把握,其实不是。当时只剩一个候选动作(右边是墙、上方距离变大、没有相邻敌人),归一化之后单个候选必然是 100%。这个数字说明的是“没得选”,不是“很有信心”。
跟上一篇那个 82.32% 对照着看:同样是接近满格的概率,一个是唯一选项的算术结果,一个是六选一里的强烈偏好。概率的数值必须结合候选数量来读,否则两个完全不同的东西会被看成同一个东西。
Mock 通关,不等于真实模型通关
项目里有 Mock 后端,它用环境内的评分(内置 BFS 采一条最短路线)选动作。用 Mock 跑,通关是必然的——因为它自己就会寻路。
真实 Laya 走的是 predict,把状态和候选描述交给模型,拿回一个 choice。它不继承 Mock 的任何评分、也不具备寻路能力。
所以“Mock 跑通了”只能证明环境、界面、回合结算、动作过滤这套流程能跑,不能替代真实模型的行为测试。这两件事混在一起,很容易得出“已经做完了”的错误结论。
LayaClient 在接入上做了三个必要的兜底:延迟加载模型、校验返回的动作确实在候选里(否则抛 ValueError)、把概率重新归一化。它失败时会显式报错,不会悄悄退回 Mock——不静默降级这一点很重要,否则你永远不知道自己看的是规则演示还是模型决策。
谁负责什么
这次调试真正理清的是分工,而不是调出了什么参数:
| 层 | 负责 | 不负责 |
|---|---|---|
| 硬规则(环境侧) | 动作能不能执行、是否真的推进、循环与收敛的保证 | 战斗还是赶路更划算 |
| Laya | 在 2 到 3 个合格候选里做语义选择 | 判断动作是否合法、是否在接近目标 |
| 程序 | 结算移动与战斗、更新状态、记录日志 | 决策 |
模型仍然在每个回合真实地做决策——只是它做决策的空间被压到了“全都合法的两三个选项”里。这正是上一篇最后那套分层结构的具体样子,也是它唯一站得住的位置。
没验证的部分
- 真实模型只完成了一次完整通关,没有做批量通关率统计。一次成功不等于稳定,这个结论的强度就是这样。
- 100 组随机候选测试用的是 Mock,不是真实模型。
- 会移动的敌人、随机地图、逃跑策略都没有测,上面那个收敛论证对它们不成立。
怎么复现
& G:\laya\.venv\Scripts\python.exe `
G:\laya-project\laya-grid-world\main.py --backend laya
不加 --backend laya 时默认走 Mock。真实模式不需要提前跑别的脚本,点“决策”时由项目加载模型。
窗口上的 LAYA 标识表示选中的是哪个后端,但它只说明选择,不证明调用完成——还要结合动作日志确认。另外改了代码之后记得关掉旧窗口再启动,否则旧进程会继续跑已经加载在内存里的旧代码,这个坑我踩过一次。
一句话总结
先约束动作能不能执行,再验证动作能不能推进,然后才轮到模型在剩下的选项里做判断。把这个顺序做对,一个小模型也能把活干完;顺序做反,再大的模型也只会高置信度地原地打转。