跳转至

游戏 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,结果可能是目标点反复切换,或者角色在门两侧来回走。

执行器需要动作租约:

acquire lease(agentId)
  → validate observation
  → execute tool
  → record result
  → release lease

租约要带超时和所有者。模型重试、玩家中断或服务端踢人时,必须能释放。释放后重新检查世界状态,不要直接把旧结果写回新计划。

如果工具可以并行,例如查询背包和观察附近,应该拆成只读查询,不要让两个会改变状态的工具同时跑。

模型延迟

模型不在渲染帧里执行。比较稳的分工是:

模块 频率 负责什么
输入和渲染 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、检索和工具调用开销
可复现性 同存档、同随机种子和同构建能否重现

回放要能固定观测和工具响应做离线重跑。只记录模型最终输出,无法判断问题来自观测裁剪、提示词、工具契约还是世界变化。

最小落地顺序

  1. 先做只读观测,确认任务、位置、背包和可见性字段有稳定 ID。
  2. 做少量确定性工具,先只支持查询和 move_to。
  3. 给每次调用加观测 ID、超时、租约和结构化失败。
  4. 所有动作进入游戏规则校验,异常时回到当前世界状态。
  5. 记录观测、计划、调用、结果和耗时,支持回放。
  6. 最后再让模型组合工具、调用记忆或生成新计划。

直接让模型控制移动和动画,通常会把延迟、抖动、穿模和难以复现的问题一起带进来。先限制它能做什么,再逐步扩大动作范围。

基础案例和公开项目见AI Agent 的游戏技术路径。LLM NPC 的上下文和降级见LLM NPC 的接入方式。