用LLM构建文字冒险游戏:从状态管理到最小原型

📅 2026/8/27 7:50:29
用LLM构建文字冒险游戏:从状态管理到最小原型
之前在浏览 Hacker News 的时候看到一个很有意思的项目叫 CaLLMar。它的定位很简单却很吸引人在 LLM 聊天窗口里直接玩一个文字冒险游戏text-based adventure game。也就是说你不需要写一堆 if-else 分支也不需要事先设计几十个关卡只要把“游戏主持人”这个角色交给大模型它就能实时生成场景、判定动作、推进剧情。这类项目之所以值得关注是因为它把 LLM 从“问答机器人”变成了“交互引擎”。本文会从 CaLLMar 的核心思路出发拆解在 LLM 聊天中实现文字冒险游戏所需的状态管理、提示词设计、结构化输出等关键环节并给出一套可运行的最小原型代码方便你在本地快速复现。无论你是 LLM 应用开发者、独立游戏爱好者还是想了解 Agent 类应用设计思路的后端工程师这篇文章都适合往下看。1. CaLLMar 是什么把 LLM 聊天变成游戏世界1.1 文字冒险游戏的前世今生文字冒险游戏是一种非常古老的游戏形态。早期的 Zork、MUDMulti-User Dungeon游戏完全依靠文本描述来构建世界玩家通过输入go north、take sword、unlock door这类命令推动游戏进程。传统实现方式中游戏逻辑由规则引擎驱动每个房间、物品、动作都是预先定义好的数据玩家只能按照作者设计的路径走。这种模式有几个明显限制场景数量有限内容扩展成本高。玩家输入需要严格解析自由文本很难处理。剧情分支爆炸式增长作者难以维护。1.2 CaLLMar 的思路让 LLM 当游戏主持人CaLLMar 的核心想法很简单把传统规则引擎替换成大语言模型 LLM。模型不只是理解玩家输入还要负责场景描绘、NPC 对话、物品判定、剧情生成等全部职责。你可以把它理解成“一个不知疲倦、想象力丰富的游戏主持人”。玩家输入自然语言 ↓ LLM理解意图、生成场景、更新状态 ↓ 输出文本描述 游戏状态这样做的好处是显而易见的玩家可以用自然语言自由输入例如“我先把火把扔进房间里再用剑撬开门”模型能理解复杂动作链。世界不再需要预设每个分支模型会替你补全合理剧情。同一个 LLM 可以承载风格完全不同的游戏只需替换提示词和初始世界设定。CaLLMar 这个名字看起来像是Call LLM的组合也有人理解为“呼叫一个 LLM 主持人”。项目本身不复杂但它背后代表的方向——把 LLM 当作动态内容生成器与状态裁决器——正是当前 LLM Agent、AI 原生应用设计中很核心的思路。1.3 为什么说这是 LLM 应用的理想落地场景从技术角度讲文字冒险游戏非常适合作为 LLM 应用练习项目原因是状态空间可控房间、物品、生命值这些数据完全可以用结构化 JSON 表示。交互模式自然对话本身就是游戏界面。判定规则开放不需要模型输出数学上精确的答案只要“合理即可”。趣味性强哪怕原型功能不完整玩家也能在文本中感受到乐趣适合用来做朋友间的演示。2. LLM 文字冒险游戏的核心设计难点把 CaLLMar 的思路落地成代码要解决的不只是“调 API”。下面这几个问题几乎决定了项目能不能稳定跑起来。2.1 游戏状态怎么存LLM 本身是“无状态”的。每次调用 API 时它只根据你传入的消息生成回复不会记得上一次调用发生了什么。这意味着你必须在外部维护游戏状态。什么是游戏状态最小集合包括当前场景 ID 或场景描述。玩家背包中的物品列表。玩家生命值、金币等属性。已经触发的关键事件例如“门已经被打开过”。游戏是否结束。实现时你可以把状态定义为一个字典每次请求时序列化成 JSON 传给模型模型返回结果后再从 JSON 中解析出新的状态。{ scene: castle_gate, inventory: [rusty_key], health: 80, turns: 12, flags: { gate_unlocked: false } }2.2 上下文和记忆怎么管理文字冒险游戏天然会产生大量历史对话。如果每次都把全部历史发送给模型很快会超出上下文窗口限制还会增加成本。常见方案有两种滑动窗口只保留最近 N 轮对话。摘要记忆每进行若干轮让模型把前面的剧情浓缩成一段摘要和历史一起作为新上下文。在原型阶段建议采用“完整状态 最近几轮动作”的折中方案。游戏状态是最新的事实基准对话历史只是为了让模型了解动作连贯性而状态可以替代大部分历史记忆。2.3 输出稳定性怎么保证这是最容易踩坑的地方。如果提示词只写“用 JSON 返回结果”模型偶尔会返回带解释的文本、Markdown 代码块甚至直接返回null。你需要在提示词中明确输出结构并在代码层面做好兜底解析。更可靠的方式是使用 OpenAI 兼容接口的response_format参数或 Function Calling 功能在 API 层面强制 JSON 输出。不同服务支持程度不一样如果服务不支持只能在提示词和解析层做增强。2.4 游戏性与安全边界LLM 生成内容不可控可能出现以下情况模型让角色秒杀玩家游戏体验崩塌。模型生成不符合年龄适宜要求的场景。模型忘记之前的设定出现前后矛盾。因此系统提示词中要写清楚规则玩家死亡需要玩家做出极端危险动作不能替玩家做决定输出内容要适合全年龄。下一步你可以根据实际体验逐步补充规则。3. 环境准备与技术选型在开始写代码之前先把运行环境准备好。本项目不绑定具体框架核心依赖只有一个 Python 环境和 HTTP 请求库。3.1 环境要求项建议操作系统Windows / macOS / Linux 均可Python 版本3.9 及以上LLM API任意兼容 OpenAI Chat Completions 格式的服务请求库requests调用方式命令行交互如果你本地有 Ollama、LM Studio 等本地推理工具也可以把接口地址指向本地服务这样可以避免 API 费用。需要注意本地小模型的输出稳定性和指令遵循能力通常弱于云端大模型文章代码在接入本地模型时可能需要对提示词做额外调整。3.2 获取 API 配置本文示例代码会从环境变量中读取配置环境变量说明示例LLM_API_KEYAPI 密钥sk-xxxxLLM_BASE_URL接口基础地址https://api.openai.com/v1LLM_MODEL模型名称gpt-4o-mini或自定义模型名如果你使用的服务不是 OpenAI 官方的只需修改LLM_BASE_URL指向服务商提供的兼容地址即可。格式上要做到“以实际服务为准”不要假设所有服务都实现了同一套参数。4. 实战实现一个 CaLLMar 风格的最小原型接下来我带你从头实现一个可运行的文字冒险游戏原型。它没有图形界面启动后在终端输入指令就能在 LLM 生成的虚拟世界里探索。4.1 项目结构llm-adventure-demo/ ├── main.py # 程序入口游戏主循环 ├── llm_client.py # 封装 LLM API 调用 ├── prompts.py # 系统提示词与输出模板 └── requirements.txt # 依赖声明4.2 添加依赖在项目目录下创建requirements.txtrequests2.31.0安装依赖pip install -r requirements.txt4.3 编写 LLM 调用模块文件路径llm_client.pyimport os import requests def chat(system_prompt: str, user_message: str, temperature: float 0.7) - str: 调用 OpenAI 兼容的 Chat Completions 接口返回模型生成的文本。 api_key os.environ[LLM_API_KEY] base_url os.environ.get(LLM_BASE_URL, https://api.openai.com/v1) model os.environ.get(LLM_MODEL, gpt-4o-mini) url f{base_url}/chat/completions headers { Authorization: fBearer {api_key}, Content-Type: application/json, } payload { model: model, messages: [ {role: system, content: system_prompt}, {role: user, content: user_message}, ], temperature: temperature, } resp requests.post(url, headersheaders, jsonpayload, timeout30) resp.raise_for_status() data resp.json() return data[choices][0][message][content]这段代码做了三件事读取配置、发起请求、返回模型输出。如果接口地址或鉴权方式不同只需要修改base_url和headers的构造逻辑。4.4 设计系统提示词文件路径prompts.pySYSTEM_PROMPT 你是一个文字冒险游戏主持人负责生成场景、判定玩家动作并维护游戏状态。 规则 1. 每回合只输出一个 JSON 对象不要输出任何解释或 Markdown。 2. JSON 格式如下 { text: 描述当前场景和动作结果控制在 50-100 字, state: { scene: 当前场景ID, inventory: [物品1, 物品2], health: 100, turns: 0, flags: {} }, game_over: false } 3. 玩家没有明确指定动作时默认玩家站着不动给出场景描述。 4. 当玩家生命值小于等于 0 时game_over 设为 true并给出结局描述。 5. 不要替玩家做决定只描述动作产生的后果。 6. 同一场景内不要重复出现完全一样的描述尽量让剧情推进。 def build_user_prompt(state: dict, action: str) - str: 把当前游戏状态和玩家输入组装成发送给模型的 user message。 import json return f当前游戏状态 {json.dumps(state, ensure_asciiFalse, indent2)} 玩家输入 {action} 请根据玩家输入更新状态并返回 JSON。这里的关键点在于我们把状态塞进了user message要求模型按指定 JSON 结构返回。这一步是状态管理的核心模型不再依靠上下文“猜”状态而是直接读取明确的事实。4.5 编写游戏主循环文件路径main.pyimport json import os from llm_client import chat from prompts import SYSTEM_PROMPT, build_user_prompt INITIAL_STATE { scene: castle_gate, inventory: [], health: 100, turns: 0, flags: {}, } def parse_response(content: str) - dict: 解析模型返回的 JSON 文本兼容可能出现的代码块包裹。 content content.strip() if content.startswith(): content content.removeprefix(json).removeprefix() content content.removesuffix() try: return json.loads(content) except json.JSONDecodeError: # 解析失败时退回一个安全状态避免整个程序崩溃 return { text: 模型返回异常游戏状态未改变。, state: None, game_over: False, } def game_loop() - None: state INITIAL_STATE print(欢迎来到 LLM 文字冒险世界输入 help 查看帮助输入 quit 退出。) print(你站在一座古老城堡的大门前周围雾气弥漫。) while True: try: action input( ).strip() except (EOFError, KeyboardInterrupt): print(\n游戏结束。) break if not action: continue if action.lower() in (quit, exit): print(你离开了冒险世界。) break if action.lower() help: print(输入你希望角色执行的动作例如 go north、take sword、talk to guard。) continue state[turns] 1 user_message build_user_prompt(state, action) try: raw chat(SYSTEM_PROMPT, user_message) result parse_response(raw) except Exception as exc: print(f[调用 LLM 出错] {exc}) continue text result.get(text, 没有收到描述。) print(text) new_state result.get(state) if new_state: state new_state state[turns] state.get(turns, 0) if result.get(game_over): print( 游戏结束 ) break # 简单状态展示 print(f状态: 场景{state.get(scene)} 生命{state.get(health)} 物品{state.get(inventory)}) if __name__ __main__: game_loop()主循环的逻辑很清晰从终端读取玩家输入。把当前状态和输入发给模型。解析模型返回的 JSON。更新状态。根据game_over判断是否结束。解析失败时程序不会崩溃而是返回一条提示信息。这条兜底逻辑虽然粗糙但在原型阶段足够用。4.6 运行与验证设置环境变量后运行程序export LLM_API_KEYsk-xxxx export LLM_BASE_URLhttps://api.openai.com/v1 export LLM_MODELgpt-4o-mini python main.pyWindows 下使用set LLM_API_KEYsk-xxxx set LLM_BASE_URLhttps://api.openai.com/v1 set LLM_MODELgpt-4o-mini python main.py运行后的交互效果大致如下欢迎来到 LLM 文字冒险世界输入 help 查看帮助输入 quit 退出。 你站在一座古老城堡的大门前周围雾气弥漫。 look around 大门被厚重的铁锁锁住旁边的石墙上有一道细小的裂缝。 状态: 场景castle_gate 生命100 物品[] search the crack 你在裂缝里摸到一把锈迹斑斑的钥匙把它放进了背包。 状态: 场景castle_gate 生命100 物品[rusty_key] use rusty_key 你用生锈的钥匙勉强拧开了锁铁门发出沉重的声响缓缓打开。 状态: 场景castle_hall 生命100 物品[rusty_key]需要注意的是模型输出具有随机性实际效果可能不完全一致。如果体验不好可以调整temperature或修改提示词风格。4.7 完整的可运行模板如果你只是想快速跑通下面是一份合并后的最小可运行代码仍建议按 4.2-4.5 拆分文件维护文件路径demo_onefile.pyimport json import os import requests SYSTEM_PROMPT 你是一个文字冒险游戏主持人。只输出 JSON不要输出解释。 JSON 结构 {text: 场景描述和动作结果, state: {scene: 场景, inventory: [], health: 100, turns: 0}, game_over: false} state {scene: castle_gate, inventory: [], health: 100, turns: 0} def call_llm(state, action): api_key os.environ[LLM_API_KEY] base_url os.environ.get(LLM_BASE_URL, https://api.openai.com/v1) model os.environ.get(LLM_MODEL, gpt-4o-mini) payload { model: model, messages: [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: json.dumps({state: state, action: action}, ensure_asciiFalse)}, ], } resp requests.post(f{base_url}/chat/completions, headers{Authorization: fBearer {api_key}}, jsonpayload, timeout30) resp.raise_for_status() return resp.json()[choices][0][message][content] def main(): global state print(LLM 文字冒险 demo 已启动。) while True: action input( ).strip() if action.lower() in (quit, exit): break state[turns] 1 raw call_llm(state, action) raw raw.strip().removeprefix(json).removeprefix().removesuffix() result json.loads(raw) print(result[text]) if result.get(state): state.update(result[state]) if result.get(game_over): break if __name__ __main__: main()5. 常见问题与排查思路LLM 文字冒险项目在开发过程中会遇到不少看似“玄学”的问题其实大部分都有明确原因。下面列出我实践中最常出现的几类。问题现象常见原因解决思路模型返回内容不是 JSON提示词约束不够强在提示词中强制“只输出 JSON”使用response_format{type: json_object}解析失败后重试一次游戏状态越玩越乱状态没有写入系统提示词每次请求都把完整状态序列化后发给模型不要依赖历史对话推断状态上下文越拉越长历史消息无限累积用滑动窗口或摘要机制尽量把游戏状态结构化减少历史依赖模型“替玩家做决定”提示词没有边界在 system prompt 中明确写“不要替玩家做决定”本地模型总卡在同一句话小模型指令遵循能力弱使用更大模型把输出模板写得更详细开启聊天模板中的 JSON ModeAPI 调用超时网络问题或模型响应过慢设置合理 timeout切换更快的模型加入重试逻辑玩家可以无脑通过所有关卡缺少失败机制在提示词中补充战斗、陷阱、体力消耗等判定规则下面挑两个典型问题深入讲一下。5.1 模型返回非 JSON这个问题在早期最容易遇到。即使提示词写了“只输出 JSON”模型偶尔在长上下文后还是会输出额外的话甚至输出 JSON 但放在了 Markdown 代码块里。我的建议是在解析层做三层兜底去掉首尾空白直接json.loads。如果失败去掉代码块标记后再解析。仍然失败让模型“刚才的回复格式不对请只输出 JSON”重试一次。如果项目要求严格最好使用官方支持的 Function Calling 或 JSON Mode。但需要注意不是所有兼容服务都可靠地实现了这些能力要在部署前做回归测试。5.2 游戏状态被模型“改飞”原型阶段常出现的情况是玩家明明没有拿到剑背包里却多了剑或者玩家从 A 场景走到 B 场景后A 场景的物品还留在背包里逻辑上说不通。根本原因是模型对状态的理解不一致。你要在每个请求中给它完整、明确的状态并在 prompt 里注明“只有玩家明确获得或失去物品才允许修改 inventory。”此外把状态字段命名设计得足够清晰也很重要。不要用items这种模糊字段而是用inventory、equipped_weapon、flags这样含义明确的字段。6. 从 Demo 到可发布工程化建议如果你的目标不只是跑通 demo而是想做成一个能分享给别人玩的产品下面这些建议值得提前考虑。6.1 状态持久化与多会话支持命令行 demo 的状态放在内存里程序一关就没了。对外发布时你需要把状态存到数据库或 Redis每个玩家或每个会话对应一条状态记录。会话 ID - 游戏状态 JSON前端页面或聊天机器人通过session_id关联上下文。这样可以支持多个玩家同时在线互不干扰。6.2 强化状态校验LLM 返回的状态 JSON 不一定是合法的。你可以在进入解析后增加一层校验REQUIRED_FIELDS (scene, inventory, health, turns) def validate_state(state: dict) - bool: if not isinstance(state, dict): return False for field in REQUIRED_FIELDS: if field not in state: return False return isinstance(state[inventory], list) and isinstance(state[health], int)如果校验失败可以丢弃模型返回的state沿用上一回合状态。这样至少不会出现“模型把血量变成字符串”之类的问题。6.3 用提示词版本管理替代硬编码游戏设定、规则、输出格式应该从代码中抽离出来。建议把提示词放成独立文件甚至支持多个不同剧本。prompts/ ├── fantasy_zh.yaml ├── detective_zh.yaml └── base_rules.yaml这样同一个主程序可以支持完全不同的游戏主题。你只需要切换提示词和初始状态就能从“城堡冒险”变成“宇宙飞船逃生”。6.4 引入 Agent 与工具调用能力当游戏需要更复杂的交互时可以让 LLM 调用工具。例如use_item(item, target)让物品产生固定效果。attack(target)执行战斗规则而不是完全靠模型自由发挥。query_map()从地图数据中查询场景连接关系。这类设计把“模型生成”和“规则计算”分离。模型负责叙事规则负责判定最终体验会更稳定。这也是当前 LLM Agent 开发中比较推荐的模式不要把规则都塞给模型能用代码表达的逻辑尽量用代码表达。6.5 成本与性能优化每一轮对话都会消耗 token。对一个文字冒险游戏来说长文本输出会很快烧掉预算。你可以从这几个方向控制成本使用便宜且足够好的小模型例如gpt-4o-mini或国产中杯模型。限定输出长度在提示词中写“场景描述控制在 80 字以内”。对历史消息做截断避免每次发送整段历史。缓存常见动作的结果例如“look”“inventory”这类固定指令可以不调用大模型直接由本地逻辑渲染。6.6 安全与内容治理LLM 生成内容存在不可控性。上线前需要加一层内容过滤在系统提示词中设定安全边界。对模型输出做敏感词过滤。保留人工举报渠道。定期对典型输入做回归测试防止模型在某次更新后输出风格失控。不要以为提示词里写了“全年龄向”就万事大吉。实际项目中输出内容监控和用户举报机制是必备的。7. 总结与下一步CaLLMar 这个项目给我的启发是LLM 应用不必追求复杂的流程编排一个精心设计的提示词加上正确的状态管理就能做出有趣的交互体验。本文从概念拆解、核心设计、最小原型到工程化建议完整走了一遍 LLM 文字冒险游戏的实现路径。你现在已经掌握了如何维护游戏状态、如何与 LLM 进行结构化交互、如何处理常见输出异常。如果你打算继续深入我建议按以下顺序逐步进阶先跑通本文 demo替换成你自己的剧本设定。加入状态持久化和多会话支持。把部分规则从提示词中抽离成 Python 代码。接入 Function Calling让游戏支持物品合成、战斗计算等复杂系统。把命令行入口替换成 Web 页面或聊天机器人。文字冒险游戏只是 LLM 互动叙事的一个开始。同样的思路完全可以迁移到互动教学、剧情生成、角色扮演、AI 跑团等场景。希望这篇教程能帮你打开一扇门做出属于你自己的 LLM 原生应用。