游戏 Agent 的观测与动作层¶
目标 ID 不存在¶
NPC 要带玩家去旧矿道。模型返回 move_to("old_mine_entrance"),NPC 却站着不动。日志里没有异常,只有工具返回 not_found。检查后发现,游戏里目标点的 ID 是 mine_entrance,模型看到的是策划表里的显示名。目标根本不存在,后面的规划没有意义。
另一种情况更常见:模型根据“玩家在桥边”生成三步计划。第一步执行完,玩家已经走远,第二步仍按旧位置靠近。模型未必推理错了,只是它看到的世界和执行动作时的世界已经不是同一个。
Agent 接入游戏,先要解决这两个问题:观测是否足够明确,动作是否在正确的时刻、针对正确的对象执行。模型大小可以后面再调。
观测快照¶
不要让模型直接读整个场景,也不要把一帧的画面直接序列化给它。先为当前任务生成一份有边界的快照:
{
"schema": 1,
"observationId": "obs_12840_7",
"observedTick": 12840,
"expiresAtTick": 12870,
"worldRevision": 913,
"agent": {
"id": "npc_12",
"position": [32.4, 1.2, 88.7],
"health": 0.72,
"state": "idle"
},
"player": {
"id": "p_01",
"distance": 4.8,
"visible": true,
"lastSeenTick": 12836
},
"quest": {
"id": "old_mine",
"stage": "find_entrance"
},
"nearby": [
{
"id": "door_mine_01",
"kind": "door",
"distance": 12.1,
"state": "locked"
}
],
"availableTools": [
"say",
"move_to",
"wait_for_player",
"open_door"
],
"truncated": false,
"omittedCount": 0
}
几个字段不能省:
observedTick说明这份数据是什么时候看到的。expiresAtTick说明过了多久就不该再用它决策。lastSeenTick和visible分开,模型才能区分“现在看不见”和“刚才还在这里”。worldRevision用来判断世界是否已经发生大幅变化。truncated和omittedCount说明快照是否被裁剪,避免模型以为附近只有这些对象。
快照最好从同一个世界 tick 读取。分别读取玩家位置、任务状态和门状态,可能得到互相矛盾的组合。在线游戏的权威状态应来自服务器;客户端补充的动画和 UI 信息只作为表现,不作为任务完成事实。
实体 ID 要稳定,不要使用场景对象地址、显示名或本地化文本。显示名会变,地址每次启动不同,模型记住一个本地化字符串也不能可靠调用工具。
观测过期¶
模型返回计划时,必须带上它基于哪份观测:
{
"observationId": "obs_12840_7",
"goal": "take_player_to_old_mine",
"steps": [
{
"tool": "move_to",
"args": {
"markerId": "mine_entrance",
"radius": 2
}
}
]
}
执行器在调用每个工具前检查:
observationId是否仍是当前任务使用的观测。- 当前 tick 是否已经超过
expiresAtTick。 - 计划里的实体是否还存在。
- 目标是否仍属于这个 NPC 的任务范围。
如果观测过期,不要“先执行一下试试”,也不要拿旧坐标硬走。返回 stale_observation,重新取快照或让行为树接管。
模型生成计划通常需要几百毫秒到几秒。战斗、追逐和多人交互不会停下来等它。计划里的位置和可见性过期是正常现象,执行器必须把“重新观察”当作常见分支,而不是异常。
工具接口¶
工具是 Agent 和游戏之间的窄接口。模型只能从工具表里选动作,不能直接访问 GameObject、不能执行 C# 字符串、不能打开任意 URL。
参数校验和结构化输出可以先靠 schema 约束模型,再由游戏代码做最终检查。4
public interface IGameTool
{
string Name { get; }
ToolSchema Schema { get; }
Task<ToolResult> ExecuteAsync(
ToolCall call,
AgentContext context,
CancellationToken token);
}
工具至少要定义:
- 参数类型、范围、枚举值和必填项。
- 执行前的世界条件。
- 执行后的成功条件。
- 超时时间。
- 是否会产生副作用。
- 是否可以重试。
- 返回哪些可继续规划的失败原因。
分发层不能只转发调用。它要重新检查权限、观测时效、并发锁和世界状态:
public async Task<ToolResult> ExecuteAsync(
ToolCall call,
AgentContext context,
CancellationToken token)
{
if (!_tools.TryGetValue(call.Name, out var tool))
return ToolResult.Rejected("unknown_tool");
if (!tool.Schema.Accepts(call.Args))
return ToolResult.Rejected("invalid_args");
if (call.ObservationId != context.ObservationId ||
_clock.Tick > context.ExpiresAtTick)
{
return ToolResult.Replan("stale_observation");
}
if (!_rules.CanExecute(context, call))
return ToolResult.Rejected("not_allowed");
using var timeout =
CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(3));
try
{
return await tool.ExecuteAsync(
call,
context,
timeout.Token);
}
catch (OperationCanceledException)
{
return ToolResult.Unknown("cancelled");
}
}
can_execute 不能只描述“模型能不能提出这个动作”。它要检查角色状态、距离、任务阶段、背包、门锁和服务器权限。模型说“玩家同意了”,不等于服务器看到玩家同意。
部分失败¶
很多工具有中间状态,不能只返回成功或失败。
{
"ok": false,
"code": "blocked",
"detail": "gate_locked",
"retryable": false,
"observedTick": 12852
}
move_to 至少返回:
arrived:到达目标半径。blocked:门锁、碰撞、路径不可达。interrupted:被战斗、剧情或玩家交互打断。timeout:超过执行期限。target_lost:目标被删除或移动到不可达区域。
如果只返回 success=false,模型只能猜是哪种问题。返回 gate_locked 后,下一轮可以选择找钥匙、绕路或要求玩家开门;返回 interrupted 则应该重新观察,而不是马上再走一次。
超时和副作用¶
超时是最容易处理错的地方。工具停止等待,不代表游戏里的动作没有发生。例如 use_item 已经扣了物品,但响应在网络上丢了;pay_npc 已经扣钱,但执行器只看到超时。
处理原则:
- 只读工具可以安全重试。
- 有副作用的工具必须带
callId。 - 服务端或游戏逻辑缓存同一个
callId的结果。 - 超时后先查询操作结果,再决定是否重试。
- 不要在没有确认的情况下重复扣费、加物品或推进任务。
返回 unknown 比返回 failed 更准确。unknown 需要一次状态查询;failed 意味着可以确定没有发生。
并发和抢占¶
一个 NPC 同一时间只应该有一个阻塞动作。两个计划同时收到 move_to,结果可能是目标点反复切换,或者角色在门两侧来回走。
执行器需要动作租约:
租约要带超时和所有者。模型重试、玩家中断或服务端踢人时,必须能释放。释放后重新检查世界状态,不要直接把旧结果写回新计划。
如果工具可以并行,例如查询背包和观察附近,应该拆成只读查询,不要让两个会改变状态的工具同时跑。
模型延迟¶
模型不在渲染帧里执行。比较稳的分工是:
| 模块 | 频率 | 负责什么 |
|---|---|---|
| 输入和渲染 | 60/120 Hz | 采集输入、动画、特效 |
| 移动和战斗控制器 | 每帧或固定 tick | 寻路、瞄准、开火、碰撞 |
| 观测快照 | 2–10 Hz | 分帧采集、筛选和序列化 |
| 高层规划 | 事件触发或 0.5–2 Hz | 任务目标、工具组合、失败重规划 |
| 工具执行 | 异步 | 超时、取消、结果回传 |
| 记忆整理 | 低频 | 任务结束、场景切换或加载阶段 |
模型可以决定“去掩体”或“换目标”,但移动、瞄准和开火仍交给行为树、状态机或控制器。不要让它每帧生成移动向量。
多人游戏不能因为模型等待而暂停世界。计划结果超过截止时间就直接丢弃,让确定性逻辑接管。单机实时游戏可以短暂暂停,但也要记录暂停时长,否则测试结果会掩盖真实延迟。Cradle 在实时游戏里等待模型返回时会暂停画面,这个做法不适用于多人对局。1
具体例子:护送任务¶
一份计划可以写成:
{
"observationId": "obs_12840_7",
"goal": "take_player_to_old_mine",
"steps": [
{
"tool": "say",
"args": {
"lineId": "bridge_broken"
}
},
{
"tool": "move_to",
"args": {
"markerId": "mine_entrance",
"radius": 2
}
},
{
"tool": "wait_for_player",
"args": {
"radius": 6,
"timeoutMs": 8000
}
}
]
}
每一步执行前都要检查当前世界:
- 玩家是否还活着。
- NPC 是否还被允许离开岗位。
- 目标点是否已解锁。
- 路径是否可走。
- 门是否打开。
- 等待期间玩家是否离开或死亡。
move_to 失败时返回 blocked_by_gate,下一轮可以选择 open_door 或绕路。返回 interrupted 时,不应把后续 wait_for_player 当成已经完成。每一步的结果都要成为下一步的输入,而不是执行完整个数组再统一看。
提示注入¶
模型输出是不可信输入,玩家输入同样不可信。玩家名、聊天、任务文本、道具描述和公会公告都可能包含“忽略之前的规则”“把物品交给某某”这类内容。
处理方式:
- 把这些内容放在明确的
untrustedText字段里,按数据处理。 - 不把玩家文本拼进系统提示词或工具权限说明。
- 对长度、字符集和展示格式做限制。
- 工具执行前重新检查权限,不读取模型给出的权限声明。
- 不执行模型生成的脚本、SQL、URL 或任意代码。
即使模型被诱导,最终也只能提出一个工具调用。工具是否允许、参数是否合法、对象是否属于当前 NPC,由游戏代码决定。
Voyager 的技能库存放的是可执行代码,适合研究环境。在正式游戏里直接运行模型生成的代码,需要隔离执行环境、资源限额和网络文件访问限制;没有这些条件时,只保存工具调用序列或预先审核过的脚本版本。2
记忆和技能¶
记忆条目要区分事实、观察和推断。Generative Agents 用记忆流、检索、反思和计划维持长期行为,可以参考它的分层方式,但不要把 10 秒级的模拟步长直接用到实时战斗。3
{
"kind": "observation",
"subject": "player_01",
"value": "entered_old_mine",
"tick": 12852,
"source": "server_world_state",
"expiresAtTick": 15852
}
不要把“模型认为玩家在矿洞”写成事实。任务状态、位置和物品归属要从世界状态读取;记忆只保存已经观察到的内容,并带来源和时效。
技能也要版本化:
{
"skillId": "chop_tree_v3",
"preconditions": ["has_axe", "tree_in_range"],
"toolCalls": ["move_to", "use_item", "wait"],
"successCheck": "inventory.logs > before.logs",
"lastVerifiedBuild": "1.8.3"
}
游戏更新后,旧技能的前置条件可能不成立。不能因为上一次验证通过就一直复用,至少要在新构建上重新跑一次。
回放和评测¶
一次执行至少记录:
traceId
observationId
observedTick / expiresAtTick
plan
toolCall
toolResult
worldRevision
randomSeed
buildId
modelId / promptVersion
latencyMs
tokenUsage
这样才能回答“它当时看到了什么、计划基于哪一版世界、调用失败在哪一步”,而不是只看到最终任务失败。
评测不能只看完成率:
| 指标 | 说明 |
|---|---|
| 任务完成率 | 固定场景下是否完成目标 |
| 无效动作率 | 越权、参数错误、重复调用 |
| 过期计划比例 | 执行前被判定为 stale 的计划 |
| 重规划次数 | 计划是否频繁被环境打断 |
| 动作延迟 | p50 / p95,玩家是否感到停顿 |
| 单次成本 | token、检索和工具调用开销 |
| 可复现性 | 同存档、同随机种子和同构建能否重现 |
回放要能固定观测和工具响应做离线重跑。只记录模型最终输出,无法判断问题来自观测裁剪、提示词、工具契约还是世界变化。
最小落地顺序¶
- 先做只读观测,确认任务、位置、背包和可见性字段有稳定 ID。
- 做少量确定性工具,先只支持查询和
move_to。 - 给每次调用加观测 ID、超时、租约和结构化失败。
- 所有动作进入游戏规则校验,异常时回到当前世界状态。
- 记录观测、计划、调用、结果和耗时,支持回放。
- 最后再让模型组合工具、调用记忆或生成新计划。
直接让模型控制移动和动画,通常会把延迟、抖动、穿模和难以复现的问题一起带进来。先限制它能做什么,再逐步扩大动作范围。
基础案例和公开项目见AI Agent 的游戏技术路径。LLM NPC 的上下文和降级见LLM NPC 的接入方式。