给游戏 Agent 接口时,隐藏答案会从哪条路漏出去?

0 阅读21分钟

给游戏 Agent 接口时,隐藏答案会从哪条路漏出去?

截图没给答案,回执却泄了底

迷宫里的宝箱还没进入视野,Agent 却直接走向了正确房间。先别急着把它记为探索能力:游戏可能只把截图交给了 observe(),却在动作回执里附送了宝箱坐标。

这是一个构造的接入失败案例,不是本次实测。它暴露的具体问题是:玩家输入的边界覆盖整段交互,不能只审查截图或一个观察函数。

下文沿用个人研究稿中的 GameEval 名称,讨论一份拟议的 Web 游戏评测接口。它是作者设计草案,尚非本文实现的 SDK,也不是对同名产品或行业标准的介绍。我们只解决一个问题:宿主要保留隐藏状态来核对规则,怎样避免这些状态提前成为 Agent 的解题提示?

一份合法的诊断信息,也可能是一份额外提示

诊断信息,也可能包含隐藏变量

Gymnasium 的 Env.step() 把 observation 与 info 分开返回。官方说明明确指出,info 可以包含诊断信息,甚至包含观察中隐藏的变量。原文短语是 “variables that are hidden from observations”。这说明 info 的用途与 observation 不完全相同,不代表 Gymnasium 自动替应用隔离了二者,也不代表每个环境都泄漏答案。1

假设 Web 游戏的接入层借用了这种返回形式,随后为了方便排障,把整个返回对象序列化给 LLM。即使 observation 本身只有截图,隐藏信息仍可能随 info 进入上下文。

原始 GameEval 草案也有类似的待收紧位置:ActionReceipt.observedEffect 和 GameEvent.payload 都允许任意 JSON。这样的类型适合容纳不同游戏的数据,却没有回答“哪些字段允许玩家看”。下面是作者构造的数据流;坐标及字段仅用于解释问题,未经游戏运行:

环节玩家本来获准获得的内容危险的额外内容
observe()关闭的门、可见走廊、公开任务说明无
act(wait) 回执动作已接受、已结束observedEffect.hiddenChest: {x: 8, y: 3}
events()画面中已显示的得分变化未探索房间的完整生成事件
后续推理根据可见线索决定向哪走已经知道宝箱位置

最后一步选择正确方向,无法再单独归因于理解画面。评测器仍然可以记录同一宝箱坐标来判断取物是否正确;问题发生在它被接入层送回玩家的那一刻。

反馈也要按目的判断。界面公开显示的分数可以是许可输入;用于训练的稠密奖励、隐藏目标距离、失败断言的 expected 值,需要逐项声明。不能因为都叫“反馈”,就把它们混入一条纯视觉成绩。

顺着一次等待动作追消息去向

把隐藏坐标当成一种数据,不要只当成一个敏感字段名。宿主生成地图时持有它,规则检查用它判断取物,调试日志用它解释失败,这些环节都有合理用途。真正需要限制的是哪个消费者能看到这份数据。玩家接入层如果从调试记录直接拼消息,宿主合法持有的信息就改变了受测玩家的知识。给字段加内部标签并不能替代去向检查。

假设宝箱坐标没有直接回传,回执却写“距离目标还差三步”。玩家依旧得到画面没有提供的线索。再假设回执写“当前动作更接近目标”,它也可能形成可连续查询的方向反馈。这里不主张一律禁止距离或奖励,而是要求轨道声明清楚:允许这些信息,测的是额外反馈后的策略能力;宣称仅凭画面,就不能把这类输入当普通排障信息。

反例同样重要。玩家已经打开房间并在画面看见宝箱,公开事件随后给出同一位置,不必自动认定越界。需要比较的是消息生成时点与当时获准公开的信息。一个字段可以在探索之前保密、探索之后公开。仅按字段名拉黑,会删掉合法反馈,也会漏掉换了名字的提示。本文建议按事件语义和可见条件制定规则,这是待实现设计,并非库已提供的机制。

审查一次等待动作,要收集它之后的异步消息。正常回执没有坐标,下一轮工具结果追加“生成宝箱成功”,也会泄漏;玩家报错后自动给“检查房间八号”的恢复建议,可能暴露相同真值。沿最终进入模型的顺序阅读,比只审一个接口返回值更容易发现拼装泄漏。完整转录让每条提示的来源可追溯。

对可疑输入先问:它是否由许可观察推导;是否包含宿主才知道的额外状态。不能从模型走对了路反推必然作弊,它可能猜中;也不能从走错了路反推没有泄漏,提示可能没被利用。接口边界和策略成绩应分开评价。

先改回传消息,再扩大接口

回传时明确挑字段

第一版接入不必为所有游戏统一状态模型。更值得先交付的是:从宿主结果生成玩家消息时,明确挑选字段,而不是把整个对象展开后删除几个已知敏感字段。

例如,下面是作者建议的消息投影,不是已验证的库代码:

const playerReceipt = {
  actionId: receipt.actionId,
  accepted: receipt.accepted,
  endTick: receipt.endTick,
  releaseInputsConfirmed: receipt.releaseInputsConfirmed
};
// receipt 中的 observedEffect、诊断详情和断言结果留在宿主。

这些字段也必须经过各自语义审核:actionId 只关联调用,不能编码地图 seed 或隐藏坐标;accepted 只表示执行器是否接受,不能只在附近存在隐藏机关时为真;endTick 只在协议允许公开时可见,也不应夹带内部工作信息。白名单收紧的是输出范围,不会自动保证字段值没有旁路含义。

同样的挑选规则要应用于观察、动作回执和事件。新增事件默认留在宿主,维护者声明玩家语义、公开时点和样例后才加入;随后核真正序列化结果中的嵌套对象、数组与日志包装层。异常对象也不能原样转发。否则一次报错里的对象转储,就可能绕开正常路径的筛选。

运行清单要保留 build hash、策略版本、观察权限、动作能力和时间模式,供事后解释与重放。它不必完整返回给玩家。特别是地图可由种子重建时,提前交付 seed 也可能暴露隐藏布局;种子可以留在宿主,玩家只持有不含地图含义的 run ID。这个风险取决于地图生成规则是否可获得,不能断言所有 seed 都泄漏信息。

此处最重要的产物是一份实际发给模型的消息样本,而非接口声明。把所有工具回执、追加事件、错误消息和恢复提示一起导出,才能看到 Agent 真正收到的内容。

白名单要约束字段值和失败分支

字段表应同时写用途、产生位置、公开条件和玩家看到的示例。不满足公开条件时,不能只清空数值却暴露其他差异。例如隐藏对象存在时返回空对象,不存在时连键都没有,结构本身也可能提供线索。要比较完整可见消息,而不是仅比较坐标。这里都是构造反例,不意味着某实际游戏采用这些编码。

错误分支更容易遗漏。成功回执经过投影,异常却带原始宿主对象,正常路径的工作便被绕开。玩家可以收到公开错误类别,例如动作不受支持或本轮执行未完成;详细断言、内部变量和开发堆栈留给宿主诊断。错误应多具体,由评测轨道决定,但须与正常回执遵守同一边界。

恢复提示也属于输出。宿主为让测试继续而建议“重置并去右侧房间”,若方向来自隐藏目标,就改变了测试。可以给协议恢复动作,或将运行标为需要重开,不能为了省调试时间悄悄给导航。恢复后是否还能与原成绩比较,须由记录说明,不是继续累积成完整探索成绩。

本文新增的是下面可运行的有限 Python 投影与自检,尚未实现完整 GameEval 接入或证明投影完备性。正式规则仍应从最终玩家样本反查,不能凭这个例子的白名单就扩大事件范围。

投影还应在实际发送位置核对。工具执行结束与模型上下文拼装可能是两段代码;前一段已筛字段,后一段又附上调试摘要,仍然改变输入。可以沿同一actionId关联宿主结果、投影结果和最终消息,定位差异在哪一层产生。这里只建议保留最小可复查链路,不要求为草案建复杂监控平台。

拆出三个对象,还没有切断读取路径

换个对象名,不会切断读取路径

原草案把能力分为 PlayerPort、HostControl、OraclePort:玩家观察与行动,宿主重置与推进,判定器读取真值并检查规则。这个分工有用,但 TypeScript 接口只描述正常流程的调用形状;还要另列玩家实际可达的求值、源码文件、调试及其他能力。只审核角色表,解释不了完整浏览器能力是否绕过观察限制。

如果实现时把 window.player 与 window.oracle 同时放进页面,又把任意 page.evaluate 工具交给 Agent,玩家仍可能绕过 PlayerPort。

Playwright 官方文档说明,evaluate 中可以使用页面的 window 和 document,并将结果带回调用端。2 因而在上述明确假设下,下面这段作者示意调用仍有读取路径:

// 构造示例:仅当该对象确实被实现为页面全局对象时成立。
await page.evaluate(() => window.oracle.inspectTruth());

这不是在声称 evaluate 能读取所有闭包、跨域页面或宿主内存。它说明的是:把判定器换个对象名,并不能阻止已经拥有页面求值能力的玩家访问这个页面全局对象。

浏览器控制留给受信任宿主

要测“这个 Agent 能否仅凭画面完成任务”,可让受信任的宿主保留浏览器控制权,只给玩家暴露截图、允许的输入动作及经过筛选的回执。玩家拿不到通用求值、源码读取、调试协议或内部文件工具。宿主仍可用 evaluate 收集真值,但结果进入独立的评测记录,不进入玩家下一轮消息。两份记录可按同一 run ID 关联;审核者能关联真值,不代表运行中的玩家要读到它,玩家只收到轨道许可的结束反馈。

如果受测玩家拥有任意代码执行和完整浏览器控制,单靠工具名称白名单就不够:还需隔离进程能力、文件与通信访问。对于自行托管且信任程度较高的固定策略,较小的工具集合可能已适合首版;不能把这种工程取舍表述成通用安全隔离已完成。没有隔离证据的首版只能声明受信任接入层消息范围,不能给开放代码执行玩家安全保证。

根据玩家实际能力确定边界

文件与通信能力按实际部署核对。玩家能读宿主真值日志时,页面工具白名单仍保护不了信息;测试结束后才开放归档时,实时输入和事后诊断又是不同阶段。先确定玩家何时能读哪类文件,再考虑隔离方案,比抽象宣称三个接口已隔离更可验证。

另一种合法轨道允许读取DOM文字或公开状态,未必坚持纯截图。关键是把能力写入清单,让成绩按相同权限比较。将DOM辅助轨道与纯视觉轨道直接排进同一排行榜,会把输入差异藏进模型能力差异。先说明能力,再解释成绩,才有可讨论的实验对象。

能力清单也要附实际工具参数。例如公开动作仅允许指定按键和等待,通用浏览器脚本却可自由执行,两者虽都叫操作游戏,范围相差很大。检查的是工具实际允许做什么,不是工具显示名称。具体封装方案须针对实现验证,本文不会把这一清单当成已经完成的权限控制。

用两个看起来相同的状态检查这条边界

公开输入相同,隐藏状态不同

可以从一个很小的对照开始,尚不需要测通关率。以下是在真实游戏上待执行的方案;本文新增有限 Python 数据对照,没有运行游戏、Agent 或真人试玩:

选两个测试初态 A、B,只让未探索房间里的宝箱位置不同。保证画面、公开说明、已探索历史、许可事件和辅助状态一致,可从全新短运行缩小历史范围。执行相同短等待动作,确认隐藏变量没有连带改变公开得分或动画。动作结束点及转录范围提前固定,包括是否收超时/重连提示和何时停止,不能一边等待、一边自行探路。

此时分别保存完整的玩家输入转录与宿主真值记录。若玩家回执出现不同宝箱坐标、隐藏距离或未公开生成事件,就已经找到了具体越界字段;无需等模型利用它,更不能用“模型看起来没有作弊”代替修复。

修复后重放这一对照,预期是:真值记录仍能区分两个位置,玩家消息只保留许可内容。run ID、传输时间等元数据允许项须事前列明并解释与隐藏布局无关,保留原始转录再生成差异视图;新差异先追产生路径,不能为输出相同临时忽略。

这只是一个受控样例的通道检查。若后来合法可见结果分叉,玩家消息可以不同,强求相同反而可能遗漏公开事件;先确认差异进入许可观察的时点再判断。通过这一对照既不证明所有旁路已封住,也不证明 Agent 有人类水平的探索能力。

最小例:保留宿主真值,只投影许可消息

将下面完整代码保存为 player_message_projection.py,运行 python3 player_message_projection.py。只有标准库,输入都是内存字典。fake-corridor 不是浏览器截图,宝箱/seed/动作 tick/错误和分数都由作者构造,没有真实游戏事件、Agent 或真人。

这份有限轨道只接受一次固定 wait 的四个回执值、一种许可分数事件和两种公开错误。public_contract 是作者预设的受信任执行/可见性记录,不来自玩家自报或事件自带 public 标志。真实接入要另外证明这些值的产生位置与公开条件,不能把此字典当通用授权服务。程序从宿主 receipt 明确挑字段并核类型/本例许可值,事件只选声明可见的 score,错误只返回许可类别与协议恢复动作;任意 payload、内部日志、异常详情和导航恢复提示留在宿主。

"""Finite author-designed projections, not GameEval SDK or browser isolation."""
from copy import deepcopy
import json

RECEIPT_FIELDS = ("actionId", "accepted", "endTick", "releaseInputsConfirmed")
ERRORS = {"timeout": "restart_run", "unsupported_action": "choose_supported_action"}


def public_contract(score=None, error=None):
    # Trusted, invented executor/visibility records, not player-controlled flags.
    return {"observation": "fake-corridor", "visible_score": score, "error_code": error,
            "receipt": {"actionId": "action-1", "accepted": True, "endTick": 20,
                        "releaseInputsConfirmed": True}}


def host_fixture(chest, seed, contract):
    truth = {"hiddenChest": chest, "seed": seed, "targetDistance": chest[0]}
    return {
        "truth": truth, "receipt": {**contract["receipt"], "observedEffect": deepcopy(truth)},
        "events": [{"kind": "hidden_spawn", "public": True, "payload": deepcopy(truth)}]
                  + ([{"kind": "score_changed", "tick": 20,
                       "payload": {"score": contract["visible_score"], "debug": deepcopy(truth)}}]
                     if contract["visible_score"] is not None else []),
        "error": ({"code": contract["error_code"], "message": "expected " + str(chest),
                   "debug": deepcopy(truth)} if contract["error_code"] else None),
        "logs": ["host chest=" + str(chest)], "recovery_hint": "go to " + str(chest),
    }


def project(host, contract):
    receipt = {key: host["receipt"][key] for key in RECEIPT_FIELDS}
    if (type(receipt["actionId"]) is not str or type(receipt["accepted"]) is not bool
            or type(receipt["endTick"]) is not int
            or type(receipt["releaseInputsConfirmed"]) is not bool
            or receipt != contract["receipt"]):
        raise ValueError("receipt_contract_mismatch")
    events = []
    for event in host["events"]:
        if event["kind"] != "score_changed" or contract["visible_score"] is None:
            continue  # unknown event names and their public flags grant no permission
        score = event["payload"].get("score")
        if type(score) is not int or score != contract["visible_score"] or event["tick"] != 20:
            raise ValueError("event_contract_mismatch")
        events.append({"kind": "score_changed", "tick": 20, "score": score})
    raw_error, allowed_error = host["error"], contract["error_code"]
    if (raw_error["code"] if raw_error else None) != allowed_error or (
            allowed_error is not None and allowed_error not in ERRORS):
        raise ValueError("error_contract_mismatch")
    return {"observation": contract["observation"], "receipt": receipt, "events": events,
            "error": {"code": allowed_error} if allowed_error else None,
            "recovery": ERRORS[allowed_error] if allowed_error else None}


def encoded(message):
    return json.dumps(message, sort_keys=True, separators=(",", ":"))


def main():
    contract = public_contract()
    a, b = host_fixture([8, 3], 101, contract), host_fixture([2, 9], 202, contract)
    saved_a, saved_b = deepcopy(a), deepcopy(b)
    player_a, player_b = project(a, contract), project(b, contract)
    assert a["truth"] != b["truth"] and encoded(player_a) == encoded(player_b)
    assert a == saved_a and b == saved_b
    print("host_truth_equal=false player_messages_equal=true")
    print("player=" + encoded(player_a))
    checks = 0
    for name, field, value in (("hidden_action_id", "actionId", "chest-8-3"),
                               ("hidden_acceptance", "accepted", False),
                               ("hidden_tick", "endTick", 8),
                               ("wrong_type", "accepted", 1)):
        changed = deepcopy(a)
        changed["receipt"][field] = value
        try:
            project(changed, contract)
        except ValueError as error:
            assert str(error) == "receipt_contract_mismatch"
            print("reject_" + name + ": " + str(error))
            checks += 1
        else:
            raise AssertionError("bad receipt accepted")
    timeout = public_contract(error="timeout")
    ea, eb = host_fixture([8, 3], 101, timeout), host_fixture([2, 9], 202, timeout)
    assert encoded(project(ea, timeout)) == encoded(project(eb, timeout))
    print("errors_with_private_details: same_public_code_and_protocol_recovery")
    print("public_error=" + encoded(project(ea, timeout)))
    checks += 1
    for name in ("logs", "truth", "recovery_hint"):
        appended_a, appended_b = {**player_a, "extra": a[name]}, {**player_b, "extra": b[name]}
        assert encoded(appended_a) != encoded(appended_b)
        print("unsafe_append_" + name + ": paired_messages_differ")
        checks += 1
    score5, score6 = public_contract(score=5), public_contract(score=6)
    visible_a = project(host_fixture([8, 3], 101, score5), score5)
    visible_b = project(host_fixture([2, 9], 202, score6), score6)
    assert encoded(visible_a) != encoded(visible_b)
    assert visible_a["events"][0]["score"] == 5 and visible_b["events"][0]["score"] == 6
    print("legitimate_visible_score_change: different_messages_allowed")
    bad_event = host_fixture([8, 3], 101, score5)
    bad_event["events"][1]["payload"]["score"] = 8
    try:
        project(bad_event, score5)
    except ValueError as error:
        assert str(error) == "event_contract_mismatch"
        print("reject_event_value: " + str(error))
        checks += 1
    else:
        raise AssertionError("bad event accepted")
    assert "hidden_spawn" not in encoded(player_a) and "seed" not in encoded(player_a)
    print("unknown_event_and_seed: host_only_for_this_fixture")
    print("browser_runs=0 game_runs=0 agent_runs=0 all_channel_isolation=NOT_PROVEN")
    print(f"self_checks={checks} boundary_counterexamples_passed")


if __name__ == "__main__":
    main()

本机 Python 3.11.6 实际运行,退出码 0;下面是构造输入经程序序列化的真实 stdout,不是实际发送给模型的游戏转录:

host_truth_equal=false player_messages_equal=true
player={"error":null,"events":[],"observation":"fake-corridor","receipt":{"accepted":true,"actionId":"action-1","endTick":20,"releaseInputsConfirmed":true},"recovery":null}
reject_hidden_action_id: receipt_contract_mismatch
reject_hidden_acceptance: receipt_contract_mismatch
reject_hidden_tick: receipt_contract_mismatch
reject_wrong_type: receipt_contract_mismatch
errors_with_private_details: same_public_code_and_protocol_recovery
public_error={"error":{"code":"timeout"},"events":[],"observation":"fake-corridor","receipt":{"accepted":true,"actionId":"action-1","endTick":20,"releaseInputsConfirmed":true},"recovery":"restart_run"}
unsafe_append_logs: paired_messages_differ
unsafe_append_truth: paired_messages_differ
unsafe_append_recovery_hint: paired_messages_differ
legitimate_visible_score_change: different_messages_allowed
reject_event_value: event_contract_mismatch
unknown_event_and_seed: host_only_for_this_fixture
browser_runs=0 game_runs=0 agent_runs=0 all_channel_isolation=NOT_PROVEN
self_checks=9 boundary_counterexamples_passed

A/B 的真值保留不同,输入合同相同,玩家 JSON 完全相同;原宿主字典也没有被删除或修改。错误样例两边携带不同 expected/调试详情,投影只留下相同 timeout 类别和 restart_run;重开属于本例的协议恢复,不指向任何隐藏房间,也不表示真实失败运行可以继续累计原分数。

九项自检覆盖 ID/accepted/tick 的隐藏编码、类型错配、错误详情与恢复投影、投影后追加日志/真值/原导航提示,以及公开分数值不符。unknown hidden_spawn 即使自带 public=true 也不取得许可。合法公开分数从 5 变成 6 时,消息可不同,程序因此并非永远输出常量。这个正例仍依赖作者事先给出的许可记录,未验证画面真的显示了分数。

注意 unsafe_append_* 显示的是故意在最终组装时追加宿主数据后,A/B 消息重新出现差异。它证明本地投影函数不能替另一段发送代码守边界。真实日志/工具结果/错误堆栈若绕过这个入口,程序不会阻止;受测方能运行任意 evaluate、读文件或通信时,也不受这个 Python 字典约束。例子不验证执行时间侧信道、未知事件的全部语义或浏览器能力隔离,最后明确 all_channel_isolation=NOT_PROVEN。

当后续要加入地图坐标、路径查询或自动闪避动作时,应新建明确声明权限的评测轨道。坐标可能消除感知难点,自动闪避可能代替决策;这类轨道适合某些策略或规则测试,但成绩必须随输入与动作权限一起解释。

让 Oracle 掌握充分真值,能帮助定位程序问题;让玩家只接收许可消息,才能解释它的行为来自哪些信息。GameEval 草案的下一步,应先把这条回传路径做成可检查的样本,再讨论覆盖更多游戏。

给对照写出失败与合法差异

内部随机数若影响公开消息形式,也要说明它是不是许可输入,不把所有差异都归为噪声。

对越界字段记录首次出现的调用、宿主来源和玩家接收位置。修复坐标后再查派生距离、方向及错误解释,避免只堵直接值。反过来,玩家输入一致但真值记录也丢了区别,不能宣布改进:评测器可能已失去规则核对依据。目标是宿主保留充分真值,玩家投影受限,不是两边都不知道答案。

通过结论应具体:在指定构建、权限、初态对和动作序列下,未观察到超出声明的玩家消息差异。它不覆盖其他地图、事件或工具,更不证明策略利用画面。后续可换隐藏变量和异常分支扩大样例,每次都要有可解释条件,不能增加通关次数代替消息检查。

一份小交付因此包含权限清单、最终消息样本、独立宿主真值及受控对照。分别说明玩家获准知道什么、实际知道什么、隐藏答案如何用于判定,以及检查覆盖哪条路径。四者对应起来,再看动作轨迹,才有资格讨论它为何走向那个房间。

来源与核对范围

首次核对:2026-10-03;本次核对:2026-10-13。实际打开当前 Gymnasium/Playwright 官方文档,并重读原固定源码/文档及当前版本:本文相关 API 语义仍相容,Gymnasium 当前 core 文件已变化,不把旧行号当当前行号。原研究包提供作者接口草案;新增有限 Python 投影已本地运行,真实消息链与隔离对照仍待实施。没有运行游戏评测,没有真人体验数据,也没有由本文得出的成功率或隔离安全结论。

Footnotes

  1. Gymnasium:Env API,step 的 info 定义。同步核对官方 gymnasium/core.py 固定提交 b415c420377c3a3fee3b7617e4d5f48c8d9d3b31 第104–106行;这是借鉴的接口语义,非 GameEval 官方实现。 ↩

  2. Playwright:Evaluating JavaScript,Introduction 与 Different environments。同步核对官方文档源码 docs/src/evaluating.md 固定提交 7ad3fba1aad9471c7e46d67a11b0e710a5d77ea8 第8–12行;该能力只支持文中明确限定的页面全局对象示例。 ↩