← 全部项目

山海镜 ShanHaiMirror

受约束的 LLM 决策系统:LLM 负责选择与表达,代码负责计算与安全;LLM 可以整体失败,而系统不失败

  • 个人
  • AI
  • 后端
  • 前端
  • 游戏
  • 大

2026-07 – 至今进行中

背景问题

让 LLM 在实时系统里参与决策,难点不在「调用」,而在于它会超时、会返回非法 JSON、会给出破坏平衡的组合,还可能连续几层针对同一个弱点把玩家逼死。

山海镜是一个 UE 5.5 俯视角动作游戏原型:每打完一层,AI 导演根据玩家这一层的打法,调整下一层的规则结构(刷怪配比、弹药掉落等),并用一句台词解释原因。我要回答的问题是:怎样把一个不可靠的组件放进实时系统,而系统本身保持可靠——LLM 可以整体失败,而系统不失败。

架构

点击或聚焦节点,查看组件说明与连接关系

组件说明

① OBSERVE · 行为快照
战斗中由 EventBus 累积行为,层结束时定稿为 FFloorBehaviorSnapshot。
② ANALYZE · 画像纯函数
ProfileAnalyzer 无副作用、无引擎依赖、不碰随机数,相同输入必定得到相同画像,可单测、可离线。
③ CONSTRAIN · 预算与候选集
DirectorCore 构建上下文:挑战预算、已剔除违反公平性的规则、上层决策历史。约束在这一步收敛。
④ CHOOSE · LLM 选择 Intent
IAIProvider 在给定约束内产出 Intent,只有标签和等级,没有数值;多个实现逐级降级。
DirectorService 决策网关
Java 17 + Spring Boot 3:持有 key、拼 prompt、调 LLM;Resilience4j 熔断(刻意不重试)、Bucket4j 限流(429 是设计内的降级路径)、指纹分桶缓存。
⑤ VALIDATE · 四道护栏
Schema、预算、互斥、公平性四道护栏全量校验;任一失败就降级到本地规则表,并记录原因。
FLocalProvider 本地降级
规则表实现,永远可用,是降级链路的终点;断网、后端停机、返回非法 body 时游戏照常可玩。
⑥ RESOLVE · 数值在此产生
查表把(规则, 等级)映射成具体倍率,产出 FDirectorDecision。LLM 永远接触不到数值,由类型系统保证。
⑦ APPLY · 规则生效
FloorGenerator 消费决策:刷怪权重、全局规则、掉落表;报告卡展示画像、调整项与台词。

关键设计决策与取舍

数值只在护栏之后产生,由类型系统保证

LLM 的输出类型 FDirectorIntent 里只有标签和等级(例如 Rule.Ammo / medium),没有任何数值;数值在决策链路的第 ⑥ 步由 C++ 查表产生,写入 FDirectorDecision,而这个类型只有 DirectorCore 能构造。越权的决策在编译期就构造不出来,不依赖 prompt 或代码评审去约束。

不信任 LLM 的输出是前提:四道护栏 + 逐级降级

约束在第 ③ 步就已收敛:LLM 拿到的候选集本身是安全的。即便如此,第 ⑤ 步仍全量校验 Schema、预算、互斥、公平性四道护栏。任何一步失败都降级到本地规则表 FLocalProvider,并记录降级原因。本地 Provider 单独就是一个完整可玩的游戏,LLM 是可拔插的增强层,不是依赖。

决策网关:熔断但刻意不重试,429 是设计内的降级路径

API key 不能进客户端,所以 LLM 调用放在 Java 17 + Spring Boot 3 的决策网关 DirectorService。上游超时 10 秒小于客户端的 12 秒,一次重试最坏要 20 秒,那时客户端早已放弃——重试的结果没人接收,只是白烧一次调用。所以熔断(Resilience4j)但不重试,失败时让客户端降级。限流用 Bucket4j,超限返回 429,保护的是 LLM 配额。

拒绝把护栏搬上服务端

这是最容易想到、也最该拒绝的做法:护栏在服务端,后端不可达时就没人校验本地 Provider 的输出,破坏「断网可玩」;C++ 和 Java 双写同一套校验逻辑必然漂移;单机游戏里客户端本来就是权威。信任边界不在那里,技术上可行不等于应该做。

被真实流量推翻的缓存

网关按「画像指纹」分桶缓存,同一语境最多保留 3 条台词轮换。初版规定攒满 3 条才命中:单测全绿,但真实游玩 3 局一次都没命中过——一局只发 2 次决策请求。改为边用边攒后,3 局的命中率从 0% 变成 33%。

后来把每次请求的原始字段落库,在历史流水上重放不同的指纹方案,又从一个反常的数据点挖出分桶函数的边界缺陷:满分 100 被单独分成一桶。修复后指纹数从 15 降到 12,模拟命中率从 28.1% 提升到 46.9%。教训是:验证缓存必须用真实的访问模式。

我的角色

独立开发(原 2 人团队,美术成员退出后由我承担全部工作)

负责全部程序:UE 5.5 C++ 客户端(战斗、行为画像、导演核心与护栏)、Java 决策网关、Vue 3 决策回放器,以及测试与文档。

结果与指标

  • 三端 231 个单元测试全部通过:C++ 63、Vue 84、Java 84(其中 9 个需要 Docker)
  • 三条降级路径均实测:物理拔网线、后端停机、后端返回 200 但 body 非法,都降级到本地规则表,整局完整可玩
  • 缓存命中时决策耗时从 3.6 s 降到 2 ms;决策耗时 P50 2.15 s、P99 4.83 s(Micrometer → Prometheus)
  • DeepSeek 单次决策往返 3.8–5.0 s,被层间的导演报告卡完全吸收,不阻塞任何战斗帧
  • 决策回放器已上线:用真实对局导出的决策日志,展示「LLM 想改什么 → 护栏拦没拦 → 实际改了什么」
  • 如实标注:指纹的桶宽 20、候选 3 条这两个参数仍待用更多数据校准