用 Claude 玩文字冒险:AI 创意交互应用
展示 Claude 在交互式文字游戏中的应用,是 AI 多模态对话的有趣案例但工作应用有限。
展示 Claude 在交互式文字游戏中的应用,是 AI 多模态对话的有趣案例但工作应用有限。
前几天,我参加了朋友 Lucia 和 Malin 组织的一场 AI 黑客松。活动主题是 mech interp(机制可解释性),但我几乎不懂 PyTorch,所以打算从 API 层而不是模型层入手。
我经常思考认知架构(比如 Soar 和 ACT-R)。它有点像 GOFAI(传统人工智能)研究的延续,灵感来自认知科学。它也和 GOFAI 一样,从未产出过什么真正有用的东西。但我常常会想:我们能否用受认知架构启发的 harness 来支撑 LLM,从而突破它们的局限?
Claude Code 这样的 LLM Agent,基本上属于“意外诞生”的认知架构:它们由实践者而非理论家设计和构建,但彼此之间存在不少共性——都需要某种管理记忆、工具使用、任务议程等内容的方式。也许,在一种更加“有原则”、吸收了认知科学成果的基础上构建 Agent,能得到性能更好的架构。
于是,我坐在那里想了一阵子,该如何把 Soar 的架构改造成适用于 LLM Agent 的形式。我画出了一套大致方案,但紧接着就想到:我要怎么证明它比 baseline 表现更好?我需要一个 eval,也就是一项测试任务。
数学题?太容易一次性完成。聊天机器人?交互太多了,我想要的是一种无需人工干预、时间跨度很长的任务。Coding Agent?又太过自由,而且需要使用太多工具。然后我想到:文字冒险游戏!在这里,你面对的是一个风格化、具有层级结构、完全通过文本访问的世界,其中包含长期目标、谜题、物理空间探索以及对环境的发现。甚至连文字冒险游戏的数据模型,都很像基于框架的知识表示系统。而且网上还有海量现成的游戏。
《Anchorhead》是 Michael S. Gentry 创作的一款受洛夫克拉夫特启发的文字冒险游戏,我多年前玩过。想要通关,大概需要数百个回合,横跨游戏内的好几天。它的游戏世界也非常庞大、非常开放。换句话说:这是一项完美的长时程任务。
于是我开始动手折腾。frotz 解释器可以在命令行中运行,还有一个叫 dfrotz 的“简陋”界面。它去掉了 ncurses 那些花哨的东西,只提供一种极其精简的命令行体验。看起来是这样的:
$ dfrotz games/anchor.z8
...
Outside the Real Estate Office day one
ANCHORHEAD
An interactive gothic by Michael S. Gentry
(Type HELP or ABOUT for some useful information.)
Release 5 / Serial number 990206 / Inform v6.15 Library 6/7
Outside the Real Estate Office
A grim little cul-de-sac, tucked away in a corner of the claustrophobic tangle
of narrow, twisting avenues that largely constitute the older portion of
Anchorhead. Like most of the streets in this city, it is ancient, shadowy, and
leads essentially nowhere. The lane ends here at the real estate agent's office,
which lies to the east, and winds its way back toward the center of town to the
west. A narrow, garbage-choked alley opens to the southeast.
>go southeast
Alley day one
Alley
This narrow aperture between two buildings is nearly blocked with piles of
rotting cardboard boxes and overstuffed garbage cans. Ugly, half-crumbling brick
walls to either side totter oppressively over you. The alley ends here at a
tall, wooden fence.
High up on the wall of the northern building there is a narrow, transom-style
window.
写一个小型 Python wrapper,通过 stdin 和 stdout 驱动解释器并不困难:
class Interpreter:
"""Manages the dfrotz Z-machine interpreter process."""
p: Popen
def __init__(self):
log("Starting dfrotz.")
p: Popen = Popen(
["dfrotz", "-m", GAME],
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
)
log(f"Started dfrotz with PID={p.pid}.")
# Set stdout/stderr to non-blocking mode.
for stream in [p.stdout, p.stderr]:
assert stream is not None
fd = stream.fileno()
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
self.p = p
def read(self) -> str:
assert self.p.stdout is not None
b: bytes | None = self.p.stdout.read()
if b is not None:
t: str = b.decode("utf-8")
return t
else:
return ""
def write(self, t: str) -> None:
assert self.p.stdin is not None
self.p.stdin.write(t.encode("utf-8"))
self.p.stdin.flush()
# Give the interpreter time to respond. Not ideal!
time.sleep(0.1)
现在,我们可以用 Python 玩这款游戏了:发送命令,获取游戏输出。接下来,我们需要与之对应的另一部分:一个玩家。
class Player(ABC):
"""
Interface for game-playing agents.
"""
@abstractmethod
def cycle(self, text: str) -> str:
"""
Send the game's output to the agent, and return the next command to execute.
"""
pass
最简单的 harness 基本上什么都没做:直接把 LLM 与游戏之间的交互当成一段聊天记录。LLM 读取解释器生成的游戏输出,写下一些推理 token,再写出一条命令,通过 stdin 发送给解释器。
SYSTEM_PROMPT: str = """
Hello Claude. Your task is to play an adventure game. I've hooked up
your output to the dfrotz (dumb frotz) interpreter.
The structure of your output is fairly freeform. The first line that
starts with `>` (and only the first line!) is interpreted as a game
command, everything else is uninterpreted commentary, e.g. you may
write:
We should go north to explore the church.
>go north
Maybe we can use the silver key there.
If you write multiple `>` lines in one response, all but the first
will be ignored.
Have fun! 😊
"""
class SimplePlayer(Player):
"""
The simplest game-playing agent: keep the entire game history in-context.
"""
client: Anthropic
history: list[tuple[EntryType, str]]
def __init__(self):
self.client = Anthropic()
self.history = []
def cycle(self, text: str) -> str:
self.history.append((EntryType.GAME, text))
system = trim(SYSTEM_PROMPT)
messages: list[MessageParam] = []
for entry_type, entry_text in self.history:
role: str
match entry_type:
case EntryType.GAME:
role = "user"
case EntryType.COMMAND:
role = "assistant"
messages.append(
{
"role": role,
"content": entry_text,
}
)
message: Message = self.client.messages.create(
max_tokens=512,
model=MODEL,
system=system,
messages=messages,
)
log(f"Tokens: {message.usage.input_tokens}")
response: str = message.content[0].text
log_claude(response)
lines: list[str] = [line for line in response.split("\n") if line]
cmd: str = [line for line in lines if line.startswith(">")][0][1:]
self.history.append((EntryType.COMMAND, response))
return cmd
它的效果还算不错。Haiku 4.5 大部分时间只会在游戏地图里四处闲逛,但 Sonnet 4.5 和 Opus 4.5 都能相当顺利地解开游戏的第一个谜题——闯进房地产中介办公室,找到宅邸的钥匙。Claude 大约需要 200 个回合才能进入游戏内的第二天。
我原本以为它会这样失败:随着上下文不断变长,注意力被摊得越来越薄,模型开始搞不清游戏世界的空间结构、自己的目标和任务状态,继而产生幻觉、原地打转,诸如此类。
和往常一样,我还是把问题想得太聪明了。真正导致它失败的原因是:credits 用完了。等进行到第二天时,每个回合都要消耗数万枚输入 token。这样可不行!我们得想办法省钱。
好吧,那就试试一种不那么消耗 Claude credits 的方案。我们只向 Claude 展示最近五个回合——这相当于感知工作记忆——再为它提供一种简单的语义记忆:一个字符串列表,它可以通过工具调用向其中添加条目,也可以删除条目。
这样确实把 token 用量降了下来。
问题在于时间视野太窄。使用最简单的 harness 时,Claude 大约 10 个回合就能闯进房地产中介办公室,而且会在游戏一开始就这么做。换成新的 harness 后,Claude 会先在镇上到处闲逛,记下大量笔记,然后才回到房地产中介办公室。接着,它还要花大约 40 个回合,在那些垃圾桶旁边笨手笨脚地反复尝试,最后才终于成功闯进办公室。
拿到房子的钥匙后,下一步是前往大学与你的丈夫 Michael 会合,然后一起回家。使用最简单 harness 的 Claude 大约需要 100 个回合才能找到房子,中间还会顺便在镇上闲逛一阵,并在第 150 回合左右进入第二天。
而使用 memory harness 的 Claude,光是拿到房子的钥匙就花了大约 250 个回合。之后,它又花了数百个回合在镇上兜圈子,不断积累重复的记忆,最后甚至还没找到房子,就撞上了回合数上限。
《Anchorhead》是一款流程很长、地图很宽广的游戏。从一开始,你就可以彻底忘掉剧情,在镇上的大部分区域到处闲逛。想知道某次 Agent 运行最终能否取得进展,需要等待很长时间。于是我想:我需要一个更小的游戏。
毫不意外,Claude 可以自己创作游戏。NixOS 的 Inform 7 package 当时坏了——不过 Mikael 最近已经修好了——所以我只能使用 Inform 6。我先做了一个很简单的密室逃脱游戏,.inf 代码不到 100 行,任何版本的 Claude 都能在不到 10 个回合内通关。然后,我让它生成了一个规模更大、包含多个房间的盗窃游戏。
这一个就有趣多了。它足够短,所以 Claude 只用最简单的 harness 就能通关。我又尝试了另一种 harness:Claude 只能访问游戏历史中最近五个回合,同时拥有一块可读写的记忆草稿区。结果相当有意思。
第一,Claude 只会不断向自己的记忆中添加内容,从来不会删除记忆。我原以为它会更积极地精简和编辑自己的草稿区。
第二,Claude 对一个作为误导线索的房间产生了执念:一座带井的花园。它不停地兜圈子,尝试把绳子绑到井上,再顺着绳子爬下去。由于只能看到有限的游戏历史,它直到发现自己最近写入记忆的约 20 条内容,全都与各种下井尝试有关时,才意识到自己卡住了。然后,我看着 Claude 离开花园,解开最后一道谜题,却在距离胜利仅剩两个回合时撞上了回合数上限。
顺带一提:我很好奇,如果一款游戏是由同一模型的另一个实例创建的,那么模型会不会更擅长玩这款游戏?它或许能注意到文本中一些极其细微的相关性,从而推断出“如果是自己,会设计出什么样的谜题和障碍”。
最终,我放弃了“小世界”方案,因为这些游戏太过风格化、太线性,也不够有趣。《Anchorhead》虽然更难驾驭,但也更加自然。
我还有很多想法,希望通过测试它们,更好地理解不同 harness 实现会如何影响性能。但我时间不够,所以就先写到这里,把这些想法列成待办事项:
领域专用记忆(Domain-Specific Memories): Claude 的笔记把任务、地点等各种信息全都混在了一起。也许更好的做法是设置彼此独立的记忆:一份 todo list、一份关于地点及其连接关系的记忆,等等。这与 Soar 的方案很接近。
自动地理建模(Automatic Geography): 与上一点相关,harness 可以检查游戏输出,逐步构建房间及其连接关系图,再把它格式化后放入上下文。这样 Claude 就不必再通过工具手动记录这些信息。
手动地理建模(Manual Geography): 自动地理建模方案存在一些缺点。如果不与 Z-machine 解释器集成,实现它需要投入一些工作——例如,从 dfrotz 输出中解析当前位置,追踪命令历史,从中找出 go south 这样的标准移动命令——但它又无法做到 100% 确定,因此迷宫和动态房间(比如电梯)会把系统弄糊涂。所以,与其自动处理,我们也可以给 Claude 提供一个类似 link(room, direction, other_room) 的工具。
情景记忆(Episodic Memory): 这感觉有点像作弊,但每次运行结束后,可以把会话 transcript 展示给 Claude,让它总结:它完成了什么、是如何完成的,以及在哪里失败、为什么失败。总结中还可以包含一份简短攻略,说明如何抵达“最后一个成功状态”。这样一来,未来的运行就能节省重新熟悉情况所需的时间。
repository 就在这里。