一、先划清职责边界
AI 在游戏服务端最适合扮演“候选生成器”,而不是“最终裁决者”。它可以快速产出 NPC 对话、任务草案、数值配置和测试用例,但生成物必须经过确定性校验才能进入线上。把模型输出直接当成可信输入,等于把一整个不可控的依赖放进了结算链路。
边界可以用一句话概括:AI 负责扩大候选空间,服务端负责收窄到可执行集合。哪些字段必填、数值范围多少、引用是否存在的判定,必须由代码而不是提示词保证。提示词可以降低错误率,但不能替代校验。
二、五个落点
| 落点 | AI 能做什么 | 服务端必须做什么 |
|---|---|---|
| NPC 行为 | 生成对话、意图候选、行为树片段 | 校验意图合法性、限制状态迁移、保留兜底逻辑 |
| 内容生成 | 任务文案、关卡草案、掉落主题 | 检查敏感内容、引用完整性、数值范围 |
| 配置生成 | 批量产出 NPC、掉落、活动参数 | schema 校验、范围校验、人工抽检、灰度 |
| 测试生成 | 构造异常路径、边界输入、回放脚本 | 用确定性 oracle 判定结果,不能自证正确 |
| 客服辅助 | 摘要工单、推荐回复、检索知识库 | 权限控制、事实核验、人工确认后发送 |
五个落点的共同点是“生成容易、验收难”。NPC 对话可以离线生成再审核;配置可以批量生成后跑校验器;测试用例可以生成,但必须由独立的判定逻辑决定通过与否。只要验收依赖模型自己,质量就无法收敛。
三、生成配置的三层校验
第一层是结构校验:字段是否存在、类型是否正确、有没有未知字段。第二层是范围校验:等级、生命、攻击、权重是否落在设计区间。第三层是引用与交叉校验:掉落物品是否存在、NPC 引用的技能是否已配置、活动时间是否与赛季重叠。三层都通过,才进入人工抽检和灰度。
| 层级 | 检查内容 | 失败示例 | 责任方 |
|---|---|---|---|
| 结构 | 必填字段、类型、未知键 | 缺少 hp,level 写成字符串 | 解码器与 schema |
| 范围 | 上下界、特殊值 | level=101、权重为 0 | 校验函数 |
| 引用 | ID 存在、唯一性、跨表一致 | 掉落引用不存在的物品 | 配置服务 |
| 抽检 | 语义合理、体验符合预期 | 数值合法但难度失衡 | 策划与运营 |
| 灰度 | 小流量观察、可回滚 | 上线后经济产出异常 | 发布系统 |
范围校验不能只写“大于零”。攻击力允许为 0 的 NPC 和不允许为 0 的 NPC 是两类设计;仇恨范围可以为 0 表示不主动攻击,也可能是模型漏填。校验器要能区分“显式写 0”和“字段缺失”,否则会把缺字段当成合法默认值放过去。
四、失败场景:幻觉数值直接上线
某次活动配置由模型批量生成,评审只看了前几条样例就放行。上线后,一个 NPC 的掉落权重被写成 100000,而校验脚本只检查了“权重为正整数”;另一个 NPC 的 hp 缺失,解码后变成 0,战斗一开打就秒死。两个问题都不是模型“算错”,而是生成物没有经过结构化验收。
更麻烦的是排查顺序。运营先怀疑战斗公式,程序再怀疑客户端,最后才有人把配置和线上日志对上。由于配置没有版本化的校验报告,无法快速回答“这份配置在发布前通过了哪些检查”。回滚也只能整包回退,连带撤掉同批次的其他活动。
修复要落在流水线上:生成物先过 schema 和范围校验,再跑引用检查,输出一份带字段路径的报告;报告不通过就不允许发布。人工抽检从“看几条”改成“按风险抽样”,高风险字段全部复核。灰度阶段监控经济产出、战斗时长和异常掉落,发现偏离就自动回滚。核心原则是:模型可以生成,但不能自己宣布合格。
五、AI 辅助测试:回放、异常路径与模糊测试
AI 可以快速写出大量测试输入,但测试的判定标准必须独立。对局回放适合验证“同样输入得到同样结果”;异常路径生成适合覆盖断线、重复请求、超时和非法状态迁移;模糊测试适合在协议解析、配置解析和序列化边界上找崩溃。
| 手段 | AI 的作用 | 判定依据 | 不能替代什么 |
|---|---|---|---|
| 对局回放 | 生成操作序列、构造边界局面 | 固定种子与版本下的确定性结果 | 服务端权威结算 |
| 异常路径 | 枚举断线、重试、乱序 | 状态机不变量与错误码 | 真实网络故障 |
| 模糊测试 | 变异协议与配置输入 | 不 panic、不越界、可恢复 | 语义正确性 |
| 回归用例 | 从事故日志提炼场景 | 修复前失败、修复后通过 | 人工根因分析 |
模糊测试特别适合 AI 生成的配置。把模型输出当作变异种子,交给解析器和校验器跑一轮,能发现类型错误、超长字符串和非法枚举。但“没崩溃”不等于“配置正确”,语义问题仍要靠范围、引用和人工评审。
六、对局回放与确定性结算
可回放的前提是可复现:相同的初始状态、相同的输入序列、相同的随机种子和相同的代码版本,必须得到相同结果。服务端要把这些要素记录下来,而不是只存一份最终战报。否则回放只能“看个大概”,无法用于判定争议或复现 bug。
| 记录项 | 用途 | 常见缺口 |
|---|---|---|
| 对局 ID 与版本 | 定位代码与配置 | 只记录版本号,不记录配置哈希 |
| 随机种子 | 复现随机结果 | 使用全局随机源,种子未落盘 |
| 输入序列 | 重放玩家操作 | 只存客户端上报,不存服务端校验后输入 |
| tick 与时间 | 对齐帧循环 | 依赖墙钟,回放时节奏不同 |
| 结算快照 | 对比关键节点 | 只存最终结果,无法定位偏差 |
AI 可以基于回放生成“如果当时这样操作”的对照实验,帮助策划理解平衡性;但结算权威仍在服务端。不要让模型参与最终伤害、掉落或排名的判定,否则同一个对局在不同时间可能得到不同结论,争议无法收敛。
七、Go 实现:配置校验器
下面的 aitest.go 把校验拆成两步:Parse 负责 JSON 结构、未知字段和尾随数据,Validate 负责必填、唯一性和数值范围。presenceIssues 单独检查必填键是否出现,因为 attack=0 和 aggro_range=0 是合法值,单靠 Go 零值无法区分“没写”和“显式写 0”。
// file: ch21/aitest/aitest.go
// tier: L1
// verify: go test ./ch21/aitest/
// Package aitest 演示 AI 生成配置进入服务端之前的最后一道门:先做 JSON
// 结构与未知字段校验,再做必填项、唯一性和数值范围校验。模型输出是输入,
// 不是已经被信任的配置。
package aitest
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"strings"
"unicode/utf8"
)
const (
MaxNPCs = 200
MaxDrops = 500
MaxVersionRunes = 64
MaxNameRunes = 32
MaxLevel = 100
MaxHP = 1_000_000
MaxAttack = 100_000
MaxAggroRange = 50
MaxDropWeight = 10_000
)
// Config 是 AI 生成的关卡配置子集。真实项目字段更多,但校验分层的原则相同:
// 结构由解码器负责,语义由 Validate 负责,二者都不能省。
type Config struct {
Version string `json:"version"`
NPCs []NPC `json:"npcs"`
Drops []DropItem `json:"drops"`
}
type NPC struct {
ID string `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
HP int `json:"hp"`
Attack int `json:"attack"`
AggroRange float64 `json:"aggro_range"`
}
type DropItem struct {
ItemID string `json:"item_id"`
Weight int `json:"weight"`
}
// Issue 是一条可定位的校验结论。Field 使用 JSON 路径,方便直接回写给
// 生成器或展示在审核界面上,而不是只给一句 “配置不合法”。
type Issue struct {
Field string
Reason string
}
func (i Issue) String() string {
return i.Field + ": " + i.Reason
}
// Parse 解码并校验一份配置。JSON 解码失败时返回单个 $ 路径的问题;
// 解码成功后继续收集全部语义问题,避免审核者每修一处就重跑一次。
func Parse(data []byte) (Config, []Issue) {
var cfg Config
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
return Config{}, []Issue{{Field: "$", Reason: "invalid json: " + err.Error()}}
}
var trailing any
if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
return cfg, []Issue{{Field: "$", Reason: "trailing data after config"}}
}
return cfg, mergeIssues(presenceIssues(data), Validate(cfg))
}
// presenceIssues 检查必填键是否真的出现。0 是合法的 attack 和 aggro_range,
// 也是 Go 的零值,所以仅靠 Validate 无法区分“没写”和“显式写 0”。
func presenceIssues(data []byte) []Issue {
var raw struct {
Version *json.RawMessage `json:"version"`
NPCs *[]map[string]json.RawMessage `json:"npcs"`
Drops []map[string]json.RawMessage `json:"drops"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil
}
var issues []Issue
require := func(field string) {
issues = append(issues, Issue{Field: field, Reason: "required"})
}
if raw.Version == nil {
require("$.version")
}
if raw.NPCs == nil {
require("$.npcs")
} else {
for i, npc := range *raw.NPCs {
for _, key := range []string{"id", "name", "level", "hp", "attack", "aggro_range"} {
if _, ok := npc[key]; !ok {
require(fmt.Sprintf("$.npcs[%d].%s", i, key))
}
}
}
}
for i, drop := range raw.Drops {
for _, key := range []string{"item_id", "weight"} {
if _, ok := drop[key]; !ok {
require(fmt.Sprintf("$.drops[%d].%s", i, key))
}
}
}
return issues
}
// mergeIssues 让结构问题和语义问题共用同一个字段路径。一个字段只保留
// 第一条结论,避免“缺字段”同时被报成“超出范围”。
func mergeIssues(schema, semantic []Issue) []Issue {
seen := make(map[string]bool, len(schema)+len(semantic))
out := make([]Issue, 0, len(schema)+len(semantic))
for _, issue := range append(schema, semantic...) {
if seen[issue.Field] {
continue
}
seen[issue.Field] = true
out = append(out, issue)
}
return out
}
// Validate 只做纯函数校验,不读文件、不访问网络,因此可以直接被单元测试、
// CI 和发布前的审核接口复用。
func Validate(cfg Config) []Issue {
var issues []Issue
add := func(field, reason string) {
issues = append(issues, Issue{Field: field, Reason: reason})
}
version := strings.TrimSpace(cfg.Version)
if version == "" {
add("$.version", "required")
} else if utf8.RuneCountInString(version) > MaxVersionRunes {
add("$.version", fmt.Sprintf("must be at most %d runes", MaxVersionRunes))
}
if len(cfg.NPCs) == 0 {
add("$.npcs", "at least one npc required")
} else if len(cfg.NPCs) > MaxNPCs {
add("$.npcs", fmt.Sprintf("must contain at most %d npcs", MaxNPCs))
}
seenNPC := make(map[string]int, len(cfg.NPCs))
for i, npc := range cfg.NPCs {
path := fmt.Sprintf("$.npcs[%d]", i)
id := strings.TrimSpace(npc.ID)
if id == "" {
add(path+".id", "required")
} else if prev, ok := seenNPC[id]; ok {
add(path+".id", fmt.Sprintf("duplicate of index %d", prev))
} else {
seenNPC[id] = i
}
name := strings.TrimSpace(npc.Name)
if name == "" {
add(path+".name", "required")
} else if utf8.RuneCountInString(name) > MaxNameRunes {
add(path+".name", fmt.Sprintf("must be at most %d runes", MaxNameRunes))
}
if npc.Level < 1 || npc.Level > MaxLevel {
add(path+".level", fmt.Sprintf("must be between 1 and %d", MaxLevel))
}
if npc.HP < 1 || npc.HP > MaxHP {
add(path+".hp", fmt.Sprintf("must be between 1 and %d", MaxHP))
}
if npc.Attack < 0 || npc.Attack > MaxAttack {
add(path+".attack", fmt.Sprintf("must be between 0 and %d", MaxAttack))
}
if math.IsNaN(npc.AggroRange) || math.IsInf(npc.AggroRange, 0) ||
npc.AggroRange < 0 || npc.AggroRange > MaxAggroRange {
add(path+".aggro_range", fmt.Sprintf("must be between 0 and %d", MaxAggroRange))
}
}
if len(cfg.Drops) > MaxDrops {
add("$.drops", fmt.Sprintf("must contain at most %d drops", MaxDrops))
}
seenDrop := make(map[string]int, len(cfg.Drops))
for i, drop := range cfg.Drops {
path := fmt.Sprintf("$.drops[%d]", i)
itemID := strings.TrimSpace(drop.ItemID)
if itemID == "" {
add(path+".item_id", "required")
} else if prev, ok := seenDrop[itemID]; ok {
add(path+".item_id", fmt.Sprintf("duplicate of index %d", prev))
}
if itemID != "" {
seenDrop[itemID] = i
}
if drop.Weight < 1 || drop.Weight > MaxDropWeight {
add(path+".weight", fmt.Sprintf("must be between 1 and %d", MaxDropWeight))
}
}
return issues
}
Issue 使用 JSON 路径而不是自然语言描述,审核界面可以直接定位到字段,生成器也能根据路径做定向修复。mergeIssues 让同一字段只保留第一条结论,避免“缺字段”又被报成“超出范围”。校验函数不读文件、不访问网络,因此可以在 CI、发布接口和本地脚本里复用。
八、测试覆盖哪些边界
测试覆盖了六类行为:
- 合法配置通过,包含中文名称、零攻击和零仇恨范围。
- 等级、生命、攻击、仇恨范围和掉落权重越界时,返回对应字段的问题。
- 缺少数值字段时被拒绝,不会把零值当成合法默认值。
- NPC ID 与掉落物品 ID 重复、为空时被拒绝。
- 非法 JSON、未知字段、类型错误和尾随数据在解析阶段被拒绝。
- 边界值(等级 100、生命 1、攻击上限、权重上限)可以通过。
这些测试只验证校验器,不验证模型质量。真实流水线还要加引用校验、配置版本对比和灰度监控;模型输出再漂亮,也必须先过这道门。
九、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch21/aitest && go vet ./ch21/aitest/ && go test ./ch21/aitest/ -count=1
ok gspractice/ch21/aitest 0.405s
gofmt -l 没有输出说明格式一致;go vet 通过;测试覆盖合法配置、越界、缺字段、重复 ID、结构错误和边界值。校验器通过不代表配置一定合理,它只负责把明显不合法的输入挡在发布之前。
小结
AI 在游戏服务端的价值是扩大候选空间,不是替代确定性结算。NPC 行为、内容、配置、测试和客服都可以借助模型提效,但生成物必须经过结构、范围、引用和人工抽检。幻觉数值一旦绕过校验直接上线,轻则数值失衡,重则经济系统崩溃。AI 辅助测试要依赖独立的判定标准,对局回放要记录种子、输入和版本。Go 校验器用字段路径报告问题,能同时服务 CI、审核界面和生成器修复。
常见错误与边界
- 模型生成的配置不经校验直接上线,把“看起来合理”当成“已经验证”。
- 校验只检查字段非空,不检查类型、范围、唯一性和跨表引用。
- 用 Go 零值判断缺字段,导致
attack=0或aggro_range=0被误判为合法。 - 把模型输出当成可信输入,解析器没有限制大小、深度和未知字段。
- 让模型参与伤害、掉落或排名结算,同一对局无法稳定复现。
- AI 生成的测试用例由模型自己判定通过,测试失去独立性。
- 对局回放只存最终战报,不存随机种子、输入序列和配置哈希。
- 人工抽检只看看样例,高风险字段没有全量复核,灰度也没有回滚阈值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。