《游戏服务端编程实践》21.4 AI 驱动的游戏逻辑与测试

梳理 AI 在 NPC 行为、内容生成、配置生成、测试用例生成和客服辅助中的落点,给出结构、范围、引用与人工抽检的分层校验流程,结合幻觉数值直接上线的事故,说明模型输出为何不能作为可信输入,并实现可测试的 Go 配置校验器,讨论对局回放与确定性结算的边界。

一、先划清职责边界

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 生成的测试用例由模型自己判定通过,测试失去独立性。
  • 对局回放只存最终战报,不存随机种子、输入序列和配置哈希。
  • 人工抽检只看看样例,高风险字段没有全量复核,灰度也没有回滚阈值。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist