像素酒馆V1.4:基于LLM状态机与提示词工程的TRPG跑团系统设计 最近把一个自己维护的在线 AI 酒馆项目升级到了 V1.4这次更新的主题是“互动跑团”。很多朋友看到版本号第一反应是“又改了 UI 吧”但实际上从常规的 AI 角色扮演聊天切换到一张完整的 TRPG 跑团桌改动面比想象中大得多。角色要立得住、剧情要接得上、行动要有判定、状态要能持久化这不是单纯换一套主题能解决的问题。本文会从开发者的视角把像素酒馆 V1.4 的互动跑团能力拆开讲清楚它做了哪些架构级的调整、状态和上下文是怎么处理的、更新日志里为什么能沉淀出“100 多项优化”。同时也给出一些可以复用的设计思路和示例代码方便想自己做 AI 跑团应用、或者正在做 LLM 对话类产品的同学参考。如果你是第一次听说“酒馆”这类项目也不用担心前两节会先把基础概念补上。1. 项目背景与核心概念1.1 什么是像素酒馆像素酒馆是一个运行在浏览器里的“AI 角色扮演空间”。你可以把它理解成一个带有人物卡、世界设定、对话历史和剧情分支管理的前端客户端。用户导入一张角色卡AI 就扮演卡片里定义的人物用户再配置一个世界设定AI 就能在这个设定下展开剧情。这类项目的通用工作方式是前端把角色设定、历史对话、用户输入拼装成消息列表调用大模型 API拿到模型生成结果后渲染到聊天面板。像素风格的美术与交互设计让整个“房间”更像一个复古 RPG 的冒险场景。而从工程角度看它就是一套围绕 LLM API 封装出来的状态管理 提示词管理 界面渲染系统。它解决的核心问题是如何让一个无状态的对话模型在相对长的时间内记住人物性格、任务目标和世界规则并稳定地产出符合预期的内容。1.2 从聊天到跑团V1.4 要解决什么在早期版本里AI 的角色扮演更接近“陪你聊剧情”。用户说一句AI 回一句整体没有回合制、没有完整状态面板也没有基于规则的判定机制。这样的体验胜在自由但当用户想跑一场正规的 TRPG 团时问题就暴露了玩家攻击一只怪物AI 可能随口说“你赢了”没有伤害计算也没有怪物剩余血量。战斗持续几轮之后AI 会忘记玩家还剩多少血、身上有哪些道具。玩家移动、探索、对话、使用道具时缺少统一的规则约束导致剧情前后矛盾。对话一旦变长早期内容被挤出上下文窗口所有设定都可能被“遗忘”。V1.4 的互动跑团本质上是在原有的“角色扮演聊天系统”之上增加了一套轻量级的游戏状态机。AI 不再只是“扮演角色”还承担了游戏主持人GMGame Master的职责描述场景、执行判定、更新角色状态、推进剧情。为了做到这一点项目必须重新设计数据结构、提示词模板和上下文管理策略。1.3 核心概念一览概念说明在跑团中的作用角色卡描述角色性格、背景、说话风格的结构化文本定义玩家扮演的角色外观和言行预设一套完整的系统提示词决定 AI 的角色和回复风格把 AI 设定成“GM”或“旁白”世界书按关键词触发注入的背景知识库在进入特定场景时补充地图、组织、人物背景上下文窗口模型单次请求能接收的最大 Token 数决定历史对话和状态能携带多少信息系统提示词消息列表里 rolesystem 的内容是告诉 AI“你是 GM必须按规则判定”的关键结构化输出让模型返回可解析的 JSON 或 Markdown 块模型把“剧情文本”和“状态变更数据”分开返回状态机维护游戏状态的程序模块持久化玩家 HP、MP、道具、任务、敌人状态GM/NPC游戏主持人和非玩家角色AI 在跑团模式中的身份2. 技术架构与运行原理2.1 整体架构像素酒馆的整体架构并不复杂大致可以分为三层前端层负责角色卡管理、聊天界面、跑团状态面板、设置页。它可以是纯静态网页也可以是 SPA 应用。代理层可选。为了保护 API Key、处理跨域和限流通常会用一个轻量后端做请求转发。模型层兼容 OpenAI 格式的大模型 API也可以扩展到其他兼容接口的模型服务。一次完整的请求链路可以这样描述用户输入行动文本 ↓ 前端组装 system history user 消息 ↓ 后端代理转发至 LLM API ↓ 模型返回剧情描述 状态变更 JSON ↓ 前端解析并更新游戏状态 ↓ 聊天面板和状态面板同步渲染这套链路里最核心的不是网络请求而是“组装消息”和“解析返回”两个环节。它们直接决定了 AI 能不能稳定地扮演 GM。2.2 核心模块划分开发这类项目时代码最好不要全部堆在一个页面里。按职责拆分模块后期维护会轻松很多。常见的模块划分如下角色卡模块负责导入、校验、导出角色卡解析卡内的姓名、设定、示例对话等字段。世界书模块根据用户当前输入或场景关键词命中对应条目并注入到系统提示词中。上下文管理模块负责滑动窗口、历史摘要、消息截断避免请求超过 Token 限制。跑团状态模块维护玩家、敌人、任务、地图、回合数等结构化状态。解析模块从模型输出中提取剧情文本和状态更新片段并做容错。API 适配层统一不同模型服务的请求格式、错误码、限流策略。2.3 为什么需要“跑团状态机”普通聊天应用只需要保存消息列表但跑团需要保存“游戏世界事实”。比如玩家当前所在位置玩家和每个敌人剩余血量玩家背包里的道具当前主线任务和支线任务进度战斗是否正在进行。如果这些信息只靠模型从历史对话里“回忆”结果就是不可控的。更可靠的做法是程序自己维护一份 JSON 状态每次请求前把状态序列化后拼进系统提示词让模型基于这份状态做出判定模型返回时再通过结构化输出把变更后的状态回传给程序。这样即使上下文窗口里有大量剧情文本游戏状态也不会丢失。3. 环境准备与运行方式3.1 运行环境要求如果你只是想体验像素酒馆最基础的条件就是一台能联网的电脑或手机以及一个现代浏览器。推荐使用 Chrome、Edge 或 Firefox 的最新版本因为跑团面板里有大量 DOM 更新和本地缓存操作旧版浏览器容易出现兼容问题。如果你准备在本地二次开发则需要准备Node.js 16 或更高版本不同项目要求不同以实际 README 为准npm、pnpm 或 yarn 中的任意一个包管理器可选Docker用来快速构建前端静态资源可选一个符合 OpenAI 接口规范的模型服务地址和 API Key。版本不需要完全照抄本文重点是把环境跑通再看项目自己的要求。3.2 获取项目并启动这里以常见的前端项目启动方式为例。如果你拿到了像素酒馆的源码仓库典型步骤如下git clone https://example.com/pixel-tavern.git cd pixel-tavern npm install npm run dev启动后终端会输出一个本地访问地址通常是http://localhost:5173或类似端口。用浏览器打开后先到设置页填写模型 API 地址和密钥再导入或创建一张角色卡就可以开始对话。如果你更习惯用 Docker 部署可以这样操作docker build -t pixel-tavern . docker run -p 8080:80 pixel-tavern这里需要注意的是对外提供在线服务时不要把 API Key 明文写进前端静态资源。更稳妥的做法是使用后端代理转发请求或在服务端配置环境变量。3.3 配置文件示例下面是一个典型的配置文件示例。它演示了跑团模式下需要关注的配置项{ appName: pixel-tavern, apiBase: https://api.example.com/v1, model: gpt-4o-mini, temperature: 0.8, maxTokens: 2048, contextWindow: 8192, sessionStorage: indexeddb, feature: { trpgMode: true, structuredOutput: true } }参数说明apiBase模型服务的 API 地址示例地址不可直接使用需要替换成你自己可用的服务地址。model模型名称具体值由你的模型服务决定。temperature采样温度。跑团叙事建议设置在 0.7 到 0.9 之间太低会让回复枯燥太高容易偏离设定。maxTokens单次回复的最大 Token 数。contextWindow模型上下文窗口大小上下文管理器会根据这个值决定保留多少历史消息。trpgMode跑团模式开关。structuredOutput结构化输出开关。开启后系统提示词会要求模型返回带 JSON 块的文本。3.4 项目目录结构参考一个清晰的前端工程目录可能长这样pixel-tavern/ ├─ public/ ├─ src/ │ ├─ api/ │ │ └─ llmClient.ts │ ├─ core/ │ │ ├─ contextManager.ts │ │ ├─ gameState.ts │ │ └─ parser.ts │ ├─ modules/ │ │ ├─ characterCard.ts │ │ └─ worldBook.ts │ ├─ panels/ │ │ ├─ ChatPanel.vue │ │ └─ StatePanel.vue │ ├─ main.ts │ └─ config.ts └─ package.json目录职责很明确api层负责和模型服务通信core层负责上下文、状态和解析逻辑panels层是同 UI 组件config.ts管理用户配置。这样拆分之后即使界面完全重写核心游戏逻辑依然可以复用。4. 互动跑团系统的核心设计4.1 使用结构化状态保存游戏世界跑团模式的第一步是定义一份程序可读、模型也能看懂的“游戏状态 JSON”。之前提到过状态应该由程序维护而不是让模型凭记忆输出。下面是一个简化示例{ players: [ { name: 林澈, hp: 20, maxHp: 20, mp: 6, maxMp: 6, attack: 5, defense: 3, skills: [火球术, 侦查], inventory: [治疗药水, 铜钥匙] } ], enemies: [ { name: 狼人, hp: 12, attack: 4, defense: 1 } ], location: 阴雾森林北侧, quests: [寻找失踪的商队], turn: 1, combatActive: true }这份 JSON 的价值在于它可以用极少的 Token 量描述“当前世界发生了什么”。把这份状态 JSON 序列化后拼进系统提示词模型就知道了玩家的血量、位置、敌人状态和任务进度。这里有一个设计要点不要把完整的技能描述、道具说明全塞进状态 JSON否则上下文会被大量占满。状态 JSON 只保存“事实”具体规则和效果说明可以放到世界书里按需触发。4.2 系统提示词模板设计跑团模式能否成立很大程度上取决于系统提示词的质量。下面是一个简化的 GM 提示词模板你是一位 TRPG 游戏主持人GM请严格遵循以下规则 1. 当前游戏状态如下 {gameStateJson} 2. 每回合开始时先用一段不超过 80 字的文字描述场景再等待玩家行动。 3. 当玩家提出行动时需要判断行动是否可行。如果行动涉及攻击、技能、道具、移动要给出判定依据和结果。 4. 如果行动改变了游戏状态必须输出一个 JSON 块格式如下 json { narrative: 剧情描述文本, stateUpdate: { players: [...], enemies: [...], location: ..., quests: [...] } }不要跳过判定不要替玩家做决定。保持像素酒馆的复古冒险氛围语言简洁有画面感。这个模板的关键是“固定输出格式”。模型发挥不稳定是常态但如果我们在系统提示词里要求它输出固定结构再在代码层做解析兜底稳定性就会大大提高。 ### 4.3 调用模型与解析返回内容 下面给出一个前端调用模型接口的示例。这里使用 fetch 直接请求 OpenAI 兼容接口 javascript // 文件路径src/api/llmClient.ts async function requestGMReply(userInput, gameState, history) { const systemPrompt buildGamePrompt(gameState); const messages [ { role: system, content: systemPrompt }, ...history.slice(-20), { role: user, content: userInput } ]; const resp await fetch(config.apiBase /chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer config.apiKey }, body: JSON.stringify({ model: config.model, temperature: config.temperature, max_tokens: config.maxTokens, messages }) }); const data await resp.json(); return data.choices[0].message.content; }请求发出后模型返回的内容通常是“剧情描述 一个 JSON 块”。接下来需要解析这个混合文本。这里给出一个容错解析函数// 文件路径src/core/parser.ts function parseGMOutput(rawText) { const jsonMatch rawText.match(/json\n([\s\S]*?)\n/); if (!jsonMatch) { // 没有 JSON 块时把整段文本当作剧情描述 return { narrative: rawText.trim(), stateUpdate: null }; } try { const parsed JSON.parse(jsonMatch[1]); return { narrative: parsed.narrative || rawText.trim(), stateUpdate: parsed.stateUpdate || null }; } catch (e) { // 解析失败时不打断对话只展示原文 return { narrative: rawText.trim(), stateUpdate: null }; } }解析之后需要根据stateUpdate更新游戏状态。这里使用一个简化实现// 文件路径src/core/gameState.ts function applyStateUpdate(gameState, stateUpdate) { if (!stateUpdate) { return gameState; } const next JSON.parse(JSON.stringify(gameState)); if (stateUpdate.players) { next.players stateUpdate.players; } if (stateUpdate.enemies) { next.enemies stateUpdate.enemies; } if (stateUpdate.location) { next.location stateUpdate.location; } if (stateUpdate.quests) { next.quests stateUpdate.quests; } if (typeof stateUpdate.turn number) { next.turn stateUpdate.turn; } return next; }示例代码的思路是“宁可保守也不能让状态错乱”。如果模型返回的状态字段缺少某个数组程序就保留旧值。这样即使某次模型只返回了剧情文本也不会导致玩家血量被清空。4.4 长对话记忆与上下文裁剪跑团对话非常容易超长。一个冒险团可能连续跑两个小时产生几万 Token 的历史记录。如果每次请求都把全部历史发给模型费用和延迟都无法接受。常见的做法是“最近 N 轮 历史摘要”// 文件路径src/core/contextManager.ts function buildMessages(history, gameStateSummary) { const recent history.slice(-20); if (history.length 20) { return recent; } return [ { role: system, content: 以下是之前的冒险摘要请作为背景参考${gameStateSummary} }, ...recent ]; }这里有一个容易被忽略的点历史摘要是“背景”不是“当前事实”。真正的当前事实必须通过写进系统提示词里的gameStateJson来保证。否则模型会优先参考最近剧情然后可能把玩家已经受伤的状态忘掉。如果 Token 仍然不够可以再把摘要进一步压缩定期把早期对话发送给模型让模型生成一段更简洁的“章节回顾”再存入本地缓存。这个回顾可以按剧情章节、战斗场次或现实时间切分灵活度比较高。4.5 解析失败与重试策略模型输出不可控是常态所以稳定性设计比功能设计更重要。具体来说需要处理以下几种情况异常情况推荐处理方式模型没有输出 JSON 块把整段文本当作剧情展示不更新状态JSON 块格式错误捕获解析异常保留原始剧情JSON 字段与状态结构不匹配丢弃对应字段保留其他字段API 请求超时使用指数退避重试最多重试 3 次单次回复 Token 超限减小maxTokens或提示用户拆分输入重试逻辑参考async function callWithRetry(fn, retries 3) { for (let i 0; i retries; i) { try { return await fn(); } catch (e) { if (i retries - 1) { throw e; } await sleep(500 * Math.pow(2, i)); } } }指数退避的核心是避免在模型服务端过载时继续加压。第一次失败等 0.5 秒第二次等 1 秒第三次等 2 秒。如果是限流错误这个策略能明显提高成功率。5. V1.4 升级亮点与 100 多项优化解读官方更新说明里提到 V1.4 累积了 100 多项优化。由于本文不是更新公告的复读机这里从工程实现角度把这类大规模优化拆成几个方向帮助大家理解“升级到底在升什么”。5.1 用户体验类优化跑团应用的界面信息密度比普通聊天要高很多。除了聊天文本还需要展示状态面板、任务列表、骰子结果、背包图标等。所以用户体验优化通常会集中在首屏加载速度优化减少白屏时间聊天气泡与状态面板的联动渲染移动端布局适配尤其是横竖屏切换像素字体渲染优化避免锯齿角色状态变更时的视觉反馈例如掉血数字浮动本地缓存策略优化重新打开页面能恢复上一次会话。这类优化数量最多也最容易让用户感知到“变流畅了”。5.2 稳定性与容错优化跑团模式的稳定性压力远大于普通聊天模式。普通聊天里 AI 一次回复不符合预期用户只会觉得“怪”跑团模式如果状态错乱整个局就废了。因此版本升级会重点加强模型返回 JSON 结构校验请求超时与重试机制连续请求防抖避免用户重复点击本地存储写入失败时的降级方案长对话超限前的自动提示。只有这些底层逻辑足够稳跑团体验才敢放给普通用户使用。5.3 模型适配优化不同模型对“结构化输出”的支持程度不同。有的模型很听话你让它输出 JSON 它就输出 JSON有的模型则更喜欢混着 Markdown 一起输出。适配层需要解决统一各家模型服务的请求格式自动识别模型上下文窗口大小根据模型能力决定是否开启结构化输出对不支持 JSON 输出的模型使用正则或二次提示做兜底。这部分优化在更新日志里不会太显眼但直接关系到用户能不能把项目接到自己的模型上。5.4 跑团玩法优化跑团模式本身也有大量迭代空间例如骰子系统支持1d20、2d63这类骰子表达式战斗结算根据攻击、防御计算伤害并同步到状态面板任务追踪把当前任务和目标拆分展示NPC 档案与角色卡联动自动记录 NPC 对玩家的态度场景切换根据位置变化自动切换背景和可交互元素。5.5 工程与安全优化最后是开发者和部署者最关心的部分API Key 从明文配置改为服务端环境变量管理增加请求日志脱敏避免把用户消息原样打出导入导出用户数据时增加 JSON Schema 校验增加简单限流防止单用户异常请求耗尽配额提供数据迁移脚本让旧版本角色卡能平滑升级。需要说明的是上述方向是这类应用升级时常见的工作分类并不代表 V1.4 的更新说明中一定逐条包含这些内容。如果你想了解精确的优化清单最好的方式是查看项目的 Release Notes 和 Git 提交记录。6. 常见问题与排查思路跑团模式上线后用户反馈最多的问题通常集中在“模型不按格式输出”和“状态不更新”两大类。下面整理一份排查表问题现象可能原因解决思路请求返回 401API Key 错误或未配置检查配置文件、环境变量确认模型服务地址正确返回内容没有 JSON模型未遵守系统提示词调低温度增加 few-shot 示例开启结构化输出跑团状态不更新解析模块没有提取到 stateUpdate查看原始响应日志手动测试 JSON 解析函数对话太长导致请求失败历史消息超出上下文窗口减少保留轮数启用历史摘要清理世界书命中条目浏览器直接请求 API 报跨域API 服务允许跨域或未走代理使用后端代理转发请求或配置服务端 CORS 白名单界面卡顿严重渲染了过多历史消息节点使用虚拟滚动、分页加载历史记录只渲染可视区域跑团过程中模型忘记设定系统提示词太长被截断压缩状态 JSON把详细的规则移到世界书按需触发多人操作同一个状态冲突本地状态没有同步机制引入 WebSocket 或服务端状态同步加操作锁调试这类问题时最有效的方法是打开浏览器开发者工具切换到 Network 面板查看实际发送出去的消息数组。重点检查三部分system消息里的gameStateJson是否完整历史消息是否被正确裁剪模型返回的原始内容到底是什么。很多所谓“AI 失忆”问题在 Network 面板里一眼就能看出原因并不是模型真的变笨了而是程序压根没把状态发给它。此外建议在解析模块加一个“原始输出”日志入口。当用户报告状态不更新时先让用户导出日志你直接在本地跑一遍parseGMOutput()函数通常就能复现问题。7. 最佳实践与工程建议7.1 提示词工程建议跑团模式下提示词设计需要遵循“状态优先、规则其次、叙事最后”的顺序。第一把当前游戏状态 JSON 放在系统提示词靠前的位置确保模型在生成回复时能看到。第二把不可协商的规则写成固定条款例如“不要替玩家做决定”“战斗必须基于攻击和防御计算”。第三再把风格要求放在最后例如“语言简洁”“保留像素复古感”。还要注意不要指望模型每次都能稳定输出 JSON。即便系统提示词写得很清楚模型也可能在某一次突然不遵守。所以在代码层必须提供“无 JSON 也能继续运行”的兜底路径。宁可在某个回合不更新状态也不能把整个对话流程卡死。7.2 安全与合规边界这类 AI 对话应用涉及到密钥管理、用户数据和模型服务合规有几点必须重视API Key 不要出现在前端代码、浏览器 localStorage 或公开仓库里。推荐使用后端代理、环境变量或类似方案保管。用户输入的文本和模型输出不能直接完整打印到日志。必要时要对姓名、联系方式、地理位置等信息做脱敏处理。上线前要检查模型服务的使用条款确认允许的调用场景、速率限制和内容审核要求。涉及用户数据删除、存储、导出时要明确告知用户数据的保存位置和保留周期。在任何生产环境变更前先在测试环境验证并使用最小权限原则配置服务账号。7.3 性能与可维护性建议从工程维护角度看跑团模式的核心逻辑一定要和 UI 分离。也就是说状态管理、上下文裁剪、解析器不应该依赖具体的前端框架。这样做的原因是Vue、React、小程序、桌面端随时可能切换但“游戏状态维护”和“模型返回解析”的逻辑是稳定的抽成独立模块后可以无缝复用。另外建议为状态结构增加一个version字段。当后续版本调整状态结构时可以对旧数据进行迁移{ version: 1, players: [] }这看起来只是一个很小的设计但在长期迭代中能避免大量兼容性问题。7.4 版本升级时的数据迁移像素酒馆升级到 V1.4 后用户手里的旧角色卡和旧对话记录不一定能直接兼容。此时需要提供导入导出数据的校验与迁移逻辑。建议做法是导出数据时携带版本号和生成时间导入时先校验版本号旧版本数据通过映射函数转换到新结构转换失败时给出明确错误提示而不是静默丢弃。数据迁移脚本应该独立于主业务逻辑单独编写测试用例覆盖“空字段”“旧字段”“缺失字段”等情况。8. 总结与下一步学习方向这次 V1.4 升级真正难的地方不是把聊天界面改好看而是让 AI 从“陪你说话”变成“帮你维持一个游戏世界”。两个版本之间背后是数据结构、提示词模板、上下文管理策略和容错机制的全面调整。如果你也想做一个类似的跑团或角色扮演应用建议优先解决两个问题一是如何用结构化状态保存游戏事实二是在模型不稳定输出时如何优雅降级。UI 特效和背景音乐可以后面再加但游戏状态一旦混乱整局跑团就会失去可信度。接下来可以继续研究的方向包括长上下文记忆压缩把早期对话交给模型生成章节摘要再按需加载。多 Agent 跑团AI 同时扮演 GM、多个 NPC并分别维护不同记忆。多模态辅助让场景描述自动生成像素风背景图。服务端同步在多人联机跑团时用 WebSocket 实时同步角色状态与剧情推进。如果这篇内容对你有帮助可以收藏备用。跑团系统里的状态同步、上下文裁剪和结构化输出都是很值得深入琢磨的点。欢迎在评论区聊聊你实现 AI 跑团时踩过的坑尤其是模型“不听话”的时候你是怎么让它回到规则里的。