前阵子我让一个聊天模型帮我写一段文字冒险游戏的开头,最初几轮很惊艳:石厅、火把、铁门,氛围感拉满。可到第十轮,问题来了——它忘了我的背包里有一把钥匙,还让我去一间已经搜过的房间再搜一遍。这几乎是我见过所有 LLM 叙事 demo 的共同命运:开场很自由,中段开始失控,聊得越久,玩家越像在跟一个喝多了的游戏主持人玩。
后来我看到了一个叫 CaLLMar 的项目,标题写着:play a text-based adventure game in an LLM chat。它把文字冒险游戏直接放进 LLM 聊天窗口里。换句话说,过去我们通过命令行输入go north、take key,现在是直接对模型说“我推开那扇铁门”。这个交互方式看似很自然,但真正难的不是让模型能写出剧情,而是如何让一场故事在几百轮对话里不会崩溃。
这篇文章想用 CaLLMar 作为引子,聊聊 LLM 文字冒险游戏背后的状态管理、上下文设计、一致性约束和工程化思路。这些经验不只适用于做游戏,也适用于任何需要长对话和动态生成的 LLM 应用。
1. 为什么文字冒险游戏会出现在 LLM 聊天里
1.1 我们熟悉的文字冒险游戏,难点从来不是文本
传统的文字冒险游戏,核心是一个高效的状态机。房间是节点,物品是条件,动作是边。文本只是给这些节点和边穿上外衣。玩家输入go north,程序解析方向,检查当前房间有没有北边的出口,有就改变玩家位置,没有就打印“那边没有路”。整条链路里,LLM 并不是必需品。
这也是为什么很多人看到“在 LLM 聊天里玩文字冒险”的第一反应是:这不就是让模型即兴写小说吗?看起来门槛更低,但恰恰相反,传统文字冒险最难的那部分——状态判定、事件推进、物品管理——并没有因为换成 LLM 而消失,只是从看得见的代码转移到了看不见的对话上下文里。
CaLLMar 这类项目真正有趣的地方,不在于把文本冒险“翻译”成了 LLM 生成,而在于它试图在自由生成之上,重新建立起一套可以被检查、被保存、被回放的游戏状态层。没有这一层,它只是一个会跑题的故事生成器,而不是一个游戏。
1.2 LLM 带来的变化:从指令解析到自由生成
过去写一个“走进房间”的交互,你需要枚举所有可能的说法:go north、north、n、walk north。现在使用 LLM,玩家可以说:
我沿着走廊往北走,经过一幅落满灰尘的壁画时,顺手摸了一下墙上松动的砖块。
模型能理解这句话里包含两个动作:移动和调查。这是传统规则引擎很难做到的自然语言理解能力。
但自由生成也带来了新问题。模型可能为了让故事更有趣,擅自让玩家捡起了一根木棍,尽管玩家什么都没说。它也可能在下一轮又忘掉这根木棍,或者让玩家在同一个房间里反复触发同一段描述。自由度越高,不可控的地方就越多。
所以,LLM 文字冒险的设计重点,不是把动作指令喂给模型,而是围绕模型搭一个保护壳:输入要约束,输出要校验,状态要显式更新。这也是 CaLLMar 这类项目给所有 LLM 应用的一个启示:模型负责“表达”,程序负责“事实”。
1.3 CaLLMar 在解决什么问题:让 LLM 聊天变成可回放的交互故事
从项目标题来看,CaLLMar 想做的事情可以拆成两个关键词:一个是 text-based adventure game,一个是 LLM chat。这两者的结合意味着,它把游戏入口直接放在聊天窗口里。玩家不需要安装客户端,不需要理解游戏命令语法,只需要像聊天一样说出自己想做什么。
这种形态天然适合把“动态叙事”做成产品。但要让聊天成为一场“游戏”,还需要满足几个条件:玩家动作要产生持续影响,世界状态要能在轮次间保留,故事要有分支和结局,玩家离开后还能回来继续。这本质上已经不是 Prompt 工程问题,而是应用工程问题。
所以我说,CaLLMar 这类项目真正的价值,不在“让 LLM 生成了一段好文字”,而在于“让 LLM 生成的文字被纳入了一个可管理、可验证、可持久化的交互系统”。这个判断会贯穿整篇文章。
2. 一个看似简单的场景,藏着 LLM 应用的基本功
如果你第一次接触 CaLLMar,可能会觉得这东西无非是写一段 System Prompt,然后循环调用聊天接口。确实,最小版本十分钟能跑通。但一旦你想让玩家真的玩下去,就会撞上一连串问题:模型忘记物品、状态自相矛盾、上下文越拉越长、玩家卡在重复场景。
这些问题的根源,其实是同一个:你把游戏状态错误地放在了一堆自然语言里,而没有把它当作结构化数据来管理。
2.1 你需要管理的不是剧情,而是状态
一个文字冒险游戏,不论表面文本多华丽,底层跑的都是状态。玩家在哪个房间,背包里有什么,哪扇门已经打开,哪个 NPC 现在对玩家是什么态度,当前主线任务推进到哪一步。
如果用传统程序写,我们会为这些字段创建变量。但在 LLM 聊天里,第一版原型最容易掉进一个陷阱:让模型把状态“记住”。你会把所有历史对话塞进上下文,指望模型自己维护一致性。一开始效果还行,对话超过二十轮后,它就开始犯迷糊。
更好的做法是把状态抽离出来,单独维护一份结构化的game_state,每次玩家动作后,让模型返回一个机器可读的状态更新。剧情文本是给人看的,状态更新是给程序用的。两者分开,游戏才不会散架。
一个最小状态模型大概长这样:
| 状态字段 | 示例 | 说明 |
|---|---|---|
| 玩家位置 | 石厅 | 当前所在房间 ID |
| 背包 | [黄铜钥匙, 干粮] | 玩家携带的物品列表 |
| 已探索区域 | [石厅, 地窖] | 防止模型重复描述没去过的新鲜感 |
| 任务进度 | 找到出口(2/4) | 四步任务已经完成两步 |
| 房间内物品 | 地窖:生锈的铁锹 | 当前房间可见物品 |
| NPC 关系 | 守门人:友好 | 影响对话选项 |
这份状态不一定要大而全,但必须是“显式的”。也就是说,在每一轮交互结束时,它都要被更新、校验、持久化。
2.2 上下文窗口是最大的敌人
文字冒险游戏天然是长文本场景。玩家每输入一次动作,模型要回复一段叙述,几十轮下来,对话历史轻松超过几千 tokens。如果直接把所有历史都放进下一次请求,很快就会撞到上下文窗口上限。
更麻烦的是,即便没撞上限,太长的上下文也会稀释模型的注意力。故事开头提到的某个细节,到了第 50 轮可能已经不在有效注意力范围内。于是玩家说“我在大厅注意到一幅画”,模型却回答“你说的是哪幅画?”
常见的做法是分三层处理历史:
- 保留最近几轮完整对话,用于维持当下语境。
- 更早的内容压缩成一两句话的摘要,存入系统提示。
- 不能丢失的关键事实,放进结构化状态,不用自然语言描述。
比如:
之前发生的事: 你已经在废弃城堡中探索了两层,找到了通往地下室大厅的铁门。守门人告诉你,只有拿到地窖钥匙才能通过。你现在准备调查大厅东侧的书架。摘要不是简单把历史丢给模型总结一次,而是每一轮或每几轮增量更新,避免重复总结整个故事。
2.3 一致性提问:“刚才那盏灯还在吗?”
在普通 LLM 对话里,前后矛盾只是让用户觉得模型不太聪明。但在文字冒险游戏里,前后矛盾会直接摧毁游戏体验。
想象一个场景:第 10 轮,玩家点燃了壁炉旁的油灯,房间变亮。第 20 轮,玩家回到同一个房间,模型的描述却是“四周一片漆黑,什么也看不清”。玩家会很困惑:我刚才不是点过灯吗?
解决这个问题不能靠“让模型记得更牢”。更可靠的方法是让模型在生成叙述的同时,返回一个状态变更对象,再由程序校验并按规则合并到game_state。比如模型返回:
{ "narrative": "你点亮了油灯,昏黄的光照亮了整个石厅。", "state_change": { "room.lit": true } }程序读取state_change后写入game_state。下一轮生成时,把room.lit: true放进系统提示里的状态卡。这样即使模型临时发挥,状态也不会漂移。
2.4 规则与护栏:自由度越高,越需要边界
LLM 的自由生成能力是卖点,也是风险。玩家可能输入任意内容,比如“我直接飞上屋顶”,模型如果顺着玩家的意思写,整个世界的规则就废了。还有一类输入更需要警惕:玩家尝试诱导模型绕过内容限制,或者要求生成不适合公开环境的内容。
所以一个能长期使用的 LLM 文字冒险游戏,至少要有两层护栏。
第一层是游戏规则校验。不是所有动作都能由模型自由决定。位置移动要检查连通性,拾取物品要检查物品是否在场,攻击行为要检查武器是否在背包。程序可以先用一套确定性规则做初步判断,再交给模型生成细节。
第二层是内容安全过滤。如果游戏面向不特定人群,最好接入内容安全服务,对玩家输入和模型输出都做检查。这不仅是合规问题,也直接影响产品口碑。
我在这类项目上最深的体会是:没有边界的自由,很快会变成无聊。模型一旦可以凭空变出任何物品,谜题和探索就失去了意义。规则不是限制创意,规则是让创意有意义的容器。
3. 最小可运行的 CaLLMar 式实现思路
下面进入实操层面。我会用一个尽可能简单的通用方案,讲清楚 CaLLMar 式 LLM 文字冒险游戏的最小闭环。这里不绑定某个具体模型和框架,重点看流程。
3.1 从命令行到聊天的交互流程
整个游戏循环可以抽象成 5 步:
- 接收玩家输入。
- 把当前状态卡和最近对话历史组装成消息序列。
- 调用 LLM 接口,让模型返回一段叙述和一个可选的状态变更。
- 程序解析状态变更,合并到全局状态,并进行规则校验。
- 输出叙述,回到第 1 步。
这个循环和普通聊天机器人的区别,就在于第 4 步。普通聊天机器人直接拿着模型回复继续下一轮,而游戏必须经过“状态更新”这一步。
3.2 设计系统提示词:给模型一张“世界状态卡”
要让模型不在状态上自由发挥,最直接的办法是把当前世界状态显式写进系统提示。不要指望模型从历史消息里“推断”出当前状态,它没有持续记忆,它只有你给它的内容。
一种常见写法:
你是城堡探险文字冒险游戏的世界模拟器。 当前世界状态: { "location": "石厅", "inventory": ["黄铜钥匙"], "room_items": ["草垫", "木箱"], "torch_lit": true, "npc": { "守门人": "在铁门外等候" } } 规则: - 你只回应用户的动作,不要替玩家做出决定。 - 如果动作会改变世界,请在 JSON 的 state_change 字段里给出变更。 - 不要允许玩家在没有条件的情况下进入新区域。 - 叙述控制在 2 到 4 段以内。 - 输出格式为 JSON:{"narrative": "...", "state_change": {...}}关键点在于,把“世界状态”和“游戏规则”分开写,让模型知道哪些是当前事实,哪些是不可违背的边界。状态会每轮更新,但规则保持不变。
如果你担心模型输出 JSON 不稳定,可以让模型先输出纯文本,再由另一个程序或模型提取结构化字段。不过更工程化的做法是约束系统提示,并在代码里做好重试和解析容错。
3.3 用状态机约束 LLM 的自由发挥
很多人低估了一个简单状态机的价值。举例来说,如果游戏世界定义玩家当前只能在“石厅”和“地窖”之间移动,那么模型在state_change里把玩家位置改成“塔楼”时,程序应该拒绝这次变更,而不是默默接受。
这段校验逻辑不复杂,但它是游戏不失控的关键:
VALID_LOCATIONS = {"石厅", "地窖", "走廊"} def validate_location_change(new_state): loc = new_state.get("location") if loc is not None and loc not in VALID_LOCATIONS: return False, f"你无法直接到达 {loc}。" return True, None这类规则可以一点点积累:哪些物品可以捡起,哪些门需要钥匙,哪些 NPC 只在特定房间出现。用确定性代码管理边界,让 LLM 在边界内自由发挥,是最平衡的方案。
3.4 一段最小示例
下面是一个最小的 Python 示意,帮助你理解调用流程。这里把真正的 LLM 调用隐藏在了call_llm函数里,你可以把它替换成任何模型 API 的封装。
import json state = { "location": "石厅", "inventory": [], "room_items": ["生锈的铁钥匙"], "flags": {} } def build_system_prompt(state): return f""" 你是文字冒险游戏引擎。 当前世界状态: {json.dumps(state, ensure_ascii=False)} 规则: - 不要替玩家做动作。 - 如果世界状态变化,在 state_change 中返回。 - 输出 JSON:{{"narrative": "...", "state_change": {{}}}} """ def call_llm(messages): # 示意:把你的 LLM API 调用放在这里,返回字符串 pass def extract_json(raw): # 示意:解析模型输出中的 JSON,这里要处理各种前缀后缀 return json.loads(raw) def merge_and_validate(state, state_change, user_input): # 先合并,再检查已知规则 new_state = {**state, **state_change} return new_state while True: user_input = input("> ") if user_input.lower() in {"quit", "exit"}: break messages = [ {"role": "system", "content": build_system_prompt(state)}, {"role": "user", "content": user_input} ] raw = call_llm(messages) parsed = extract_json(raw) narrative = parsed.get("narrative", "") state_change = parsed.get("state_change", {}) state = merge_and_validate(state, state_change, user_input) print(narrative)实际落地时,你还需要处理模型输出里混着解释文字、JSON 格式错误、空state_change、网络超时等问题。但最小闭环的结构就是上面这样:生成、解析、更新、校验。
4. 从玩一次到能长期玩下去:工程化清单
一个 demo 能跑通,和一场游戏能玩到结局,之间差了很长的工程距离。下面这些点,不是可选项,而是长期可玩的基础设施。
4.1 存档与恢复:不能让玩家从头再来
文字冒险游戏天然适合中断后继续玩。只要把game_state保存下来,玩家下次回来就能接着上次的位置继续。
存档建议至少包含:
{ "state": { ... }, "history_summary": "之前发生的事...", "recent_messages": [], "updated_at": "..." }state是核心,history_summary帮助新会话快速重建上下文,recent_messages用来维持最近几轮的连续性。恢复时,先用存档重建系统提示和最近消息,再让玩家继续输入。
你的存档结构也决定了后续能不能做分支、多结局、竞速玩法。所以不要在第一步就把状态写死在提示词里。
4.2 上下文瘦身:摘要、滚动窗口与索引
对话越长,每轮消耗的 token 越多,响应也越来越慢。有三种思路可以组合使用。
第一个思路是滚动窗口。只保留最近 N 轮对话,比如最近 10 轮完整消息。更早的对话直接丢弃,不参与生成。优点是实现简单,缺点是完全丢掉旧信息。
第二个思路是增量摘要。每 5 轮或 10 轮,把窗口内对话总结成一两句“发生过的事”,放在系统提示里。这样模型至少知道故事大方向。
第三个思路是把关键事实结构化。玩家背包、NPC 状态、地点标志都放进state字段,而不是依赖自然语言摘要。这比摘要可靠得多。摘要适合“剧情氛围”,状态字段适合“硬事实”。
4.3 成本、超时和重试策略
每轮游戏都是一次真实的模型调用,玩家玩一小时,可能产生上百次请求。如果只用贵模型,成本会非常可观。实际项目里通常会用较轻量的模型处理简单叙事,或者把部分固定对话缓存起来。
另外必须做超时和重试。LLM 接口偶尔会不稳定,玩家点击后如果长时间没有反馈,体验会非常差。建议在客户端先快速确认“正在生成”,同时在后端设置超时上限和重试次数。
还要防止玩家连点导致的重复动作。常见做法是给每个会话加一个操作锁,正在处理上一次输入时,拒绝接受新输入,或者丢弃重复请求。
4.4 LLM 应用为什么不需要一上来就上 agent 框架
这两年聊 LLM 应用,很容易被引导到 agent、RAG、MCP、编排框架这些词上。但 CaLLMar 这类文字冒险游戏,恰恰是一个“不需要太重框架”的典型场景。
它的核心本来就是状态机加生成器。你把状态写成 JSON,把规则写进程序,让模型在规则内生成文本。这比把一个 agent 框架塞进去要简单得多,也更可控。
我并不是说 agent 框架没有用,而是想提醒一点:选型要看问题复杂度。如果问题只需要“记住状态、生成文本、校验动作”,那直接用朴素的循环就够了。框架的价值在于解决复杂问题,而不是给简单问题增加排场。
5. 容易踩坑的定位与排查顺序
即便你照着上面的思路写了一个版本,实际玩起来还是会遇到各种怪问题。下面是我觉得最容易踩的几个坑,以及一个通用的排查顺序。
5.1 现象一:角色失忆
你上一轮已经把“黄铜钥匙”放进了背包,下一轮描述房间时,模型却说“这里没有能打开铁门的东西”。
先查状态,game_state里到底有没有钥匙。如果状态里有,但模型看不到,说明系统提示词没有把最新状态注入进去。再查历史,是不是最近几轮被截断,导致上下文里没有提及钥匙。最容易被忽视的是:模型已经在叙述里提到“你拿起钥匙”,但程序没有把这条变更写回state_change,导致状态里仍是摸不到钥匙。解决办法只有一个:所有物品变更必须走结构化状态,不能只靠自然语言叙述表达。
5.2 现象二:玩家重复同一个动作,模型不断换皮
玩家说“我仔细检查书架”,模型描述了一大段,但什么都没发生。下一轮玩家又说“我把书架移开”,模型又问了一遍“书架看起来很普通”。这种循环往往因为程序没有对同一动作给出稳定反馈。
这时候可以引入“动作失败信息”和冷却机制。比如玩家已经检查过书架,状态里记录searched_bookshelf: true,下一次同样的动作直接触发“你已经检查过这里,没有更多发现”。这比让模型每次重新演绎更有游戏感。
5.3 现象三:状态偷偷飘走
模型输出里写着“你从地上捡起了短剑”,但state_change里没有inventory的变更。等下一轮模型又默认你还没有短剑,玩家就会很困惑。
解决思路是把“叙述”和“事实”分层。程序只信任state_change字段里的结构化变更,叙述文本即便写得再精彩,也不是状态写入的依据。如果叙述里提到了一个物品,但没有对应的state_change,通常会选择忽略这个物品。反过来,state_change里如果要给玩家一件物品,最好在叙述里有对应描述,否则玩家体验会很突兀。
5.4 排查链路:从输入到模型再到状态
遇到问题不要反复调试 prompt,先按下面顺序定位:
- 看现象:是失忆、重复、还是状态错误。
- 查输入:玩家这句话是否被正确传入?有没有被截断?
- 查状态:
game_state在上一轮结束时的值是什么?是否正确写入? - 查提示词:系统提示里的状态卡是不是最新?规则有没有覆盖这种情况?
- 查模型输出:
state_change是否合理?是不是模型编造了世界规则? - 查上下文:历史窗口是不是太长导致裁剪?摘要是不是丢了关键信息?
- 查参数:
temperature是否过高导致发散?max_tokens是否过短导致叙述被截断?
这七步走完,大部分问题都能定位。不要第一步就改 prompt,先确认状态层没有坏。
6. 这类项目真正的长期价值
CaLLMar 看起来只是一个好玩的小项目,但它所在的赛道上,踩中的是 LLM 应用最核心的一组问题:长对话中的一致性、自由生成与规则约束的平衡、状态与文本的分层管理。这些问题做任何复杂 LLM 应用都会遇到。
6.1 不只是游戏,而是让 LLM 成为世界模拟器
文字冒险游戏本质上是在运行一个微型世界。玩家探索、交互、改变世界状态。最有趣的是,玩家可以用自然语言做任意动作,模型来解释这个动作在这个世界中的后果。这种机制可以迁移到很多场景:
- 技能培训模拟:新员工在模拟环境里练习客户沟通,NPC 由 LLM 扮演,状态记录客户情绪。
- 历史教育体验:学生进入一个历史场景,与虚拟人物对话,通过完成事件学习背景。
- 桌游助手:游戏主持人用 LLM 快速生成场景和 NPC,但把规则判断和剧情约束保留在自己手里。
这些场景共享同一个内核:自由生成的叙事 + 可编程的状态 + 规则边界。CaLLMar 把这个内核用最直观的文字冒险形式演示了出来。
6.2 对普通开发者意味着什么
如果你对 LLM 应用开发感兴趣,做一个文字冒险游戏是很好的练手项目。它不像客服机器人那样无聊,也不像完整 agent 系统那样复杂,但能逼你认真考虑状态、上下文、错误处理和成本问题。
做完之后,你会对“让模型接入业务逻辑”这件事有更实际的理解。你会知道模型只是一个组件,它负责生成“看起来合理的东西”,而你要负责把它变成“真正对的东西”。
6.3 给同类型 LLM 应用的一条建议
不要一上来就追求宏大叙事。先做一个 30 分钟能玩完的单场景,把核心循环跑顺:房间切换、物品拾取、与 NPC 对话、达成一个目标。然后再逐步加入更多区域、谜题角色和多结局。
过程中最重要的一件事:把生成交给模型,把一致性交给自己。只要状态层是可靠的,模型哪怕偶尔把形容词写飘了,玩家也能接受;如果状态层不稳,再华丽的文字也会让玩家觉得这是一场没有规则、没有记忆的临时演出。
下一次再看到类似 CaLLMar 的项目,建议你亲自跑一遍,然后动手改一版。你会发现,真正让你投入的,不是让它“说出一段好故事”,而是看着它在几百轮对话之后,依然记得石厅里那盏亮着的油灯。