给游戏 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 值,需要逐项声明。不能因为都叫"反馈",就把它们混入一条纯视觉成绩。
顺着一次等待动作追消息去向
把隐藏坐标当成一种数据,不要只当成一个敏感字段名。宿主生成地图时持有它,规则检查用它判断取物,调试日志用它解释失败,这些环节都有合理用途。真正需要限制的是哪个消费者能看到这份数据。玩家接入层如果从调试记录直接拼消息,宿主合法持有的信息就改变了受测玩家的知识。给字段加内部标签并不能替代去向检查。
假设宝箱坐标没有直接回传,回执却写"距离目标还差三步"。玩家依旧得到画面没有提供的线索。再假设回执写"当前动作更接近目标",它也可能形成可连续查询的方向反馈。这里不主张一律禁止距离或奖励,而是要求轨道声明清楚:允许这些信息,测的是额外反馈后的策略能力;宣称仅凭画面,就不能把这类输入当普通排障信息。
反例同样重要。玩家已经打开房间并在画面看见宝箱,公开事件随后给出同一位置,不必自动认定越界。需要比较的是消息生成时点与当时获准公开的信息。一个字段可以在探索之前保密、探索之后公开。仅按字段名拉黑,会删掉合法反馈,也会漏掉换了名字的提示。本文建议按事件语义和可见条件制定规则,这是待实现设计,并非库已提供的机制。
审查一次等待动作,要收集它之后的异步消息。正常回执没有坐标,下一轮工具结果追加"生成宝箱成功",也会泄漏;玩家报错后自动给"检查房间八号"的恢复建议,可能暴露相同真值。沿最终进入模型的顺序阅读,比只审一个接口返回值更容易发现拼装泄漏。完整转录让每条提示的来源可追溯。
对可疑输入先问:它是否由许可观察推导;是否包含宿主才知道的额外状态。不能从模型走对了路反推必然作弊,它可能猜中;也不能从走错了路反推没有泄漏,提示可能没被利用。接口边界和策略成绩应分开评价。
先改回传消息,再扩大接口

第一版接入不必为所有游戏统一状态模型。更值得先交付的是:从宿主结果生成玩家消息时,明确挑选字段,而不是把整个对象展开后删除几个已知敏感字段。
例如,下面是作者建议的消息投影,不是已验证的库代码:
javascript
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^ 因而在上述明确假设下,下面这段作者示意调用仍有读取路径:
javascript
// 构造示例:仅当该对象确实被实现为页面全局对象时成立。
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、内部日志、异常详情和导航恢复提示留在宿主。
python
"""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,不是实际发送给模型的游戏转录:
text
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
-
Gymnasium:Env API,
step的 info 定义。同步核对官方gymnasium/core.py固定提交b415c420377c3a3fee3b7617e4d5f48c8d9d3b31第104--106行;这是借鉴的接口语义,非 GameEval 官方实现。 ↩ -
Playwright:Evaluating JavaScript,Introduction 与 Different environments。同步核对官方文档源码
docs/src/evaluating.md固定提交7ad3fba1aad9471c7e46d67a11b0e710a5d77ea8第8--12行;该能力只支持文中明确限定的页面全局对象示例。 ↩