AI本地部署游戏开发

先约束,再决策:Laya 网格世界的两轮调试

第一次它对着空气挥剑 29 个回合,第二次它在两个格子之间来回走到第 75 回合。两轮失败的病因完全不同,而最后通关靠的不是让模型变聪明。

接上一篇 在 4GB 显存上跑 Laya。那篇的结论是模型需要硬规则兜底;这篇是把它真的做出来、并且跑通的过程。

结果先说

最后一次运行:第 35 回合到达右下角目标,剩余 70 HP,界面上的决策来源标注为 LAYA。

Laya 网格世界第 35 回合到达目标,剩余 70 HP,底部日志显示 up、right、down 的动作序列
第 35 回合到达目标,剩余 70 HP。底部日志里可以数出 up 7 次、right 3 次、down 9 次,第 24 回合受相邻敌人攻击 -10 HP,第 25 回合击败相邻敌人。

但在这之前有两轮失败,而且两轮的病因完全不同:

  • 第一轮,它对着空气挥剑挥了 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 标识表示选中的是哪个后端,但它只说明选择,不证明调用完成——还要结合动作日志确认。另外改了代码之后记得关掉旧窗口再启动,否则旧进程会继续跑已经加载在内存里的旧代码,这个坑我踩过一次。

一句话总结

先约束动作能不能执行,再验证动作能不能推进,然后才轮到模型在剩下的选项里做判断。把这个顺序做对,一个小模型也能把活干完;顺序做反,再大的模型也只会高置信度地原地打转。