ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用大语言模型做网页文字游戏:Agent 架构、提示词工程与流式输出实战

用大语言模型做网页文字游戏:Agent 架构、提示词工程与流式输出实战 “LPAI用 AI 做一款简单、好玩的网页文字游戏”——这个项目名字是我自己起的LPAI 就是“Little Personal AI”的缩写意思是做一个轻量的、个人化的 AI 文字游戏。整体思路很简单让大语言模型当游戏引擎玩家用自然语言输入指令AI 负责推进剧情、判断结果、维护状态网页只做展示和交互。这篇文章就把我从零到一把它做出来的完整过程拆开讲包括方案选型、提示词设计、状态管理、前后端联动以及中间踩过的各种坑给同样想用 AI 做交互应用的朋友一条可以直接抄的路线。先交代一下背景。我平时喜欢玩文字冒险游戏老式的 MUD、互动小说、跑团都接触过不少。这类游戏最大的魅力是“自由”但最大的痛点也是“自由”——传统文字游戏的剧情分支是写死的你要么选择一个选项要么输入一个命令系统能理解的东西极其有限。而大语言模型天然适合干这事它懂自然语言能理解意图能生成文本还能保持上下文。把 LLM 塞进文字游戏里理论上玩家想干什么都行不用再受限于预定义的选项。LPAI 就是奔着这个方向做的。这篇文章适合谁看如果你是做 AI 应用开发的想了解怎么把大模型接到真实产品里怎么处理流式输出、状态持久化、提示词稳定性这些问题这篇能给你一套完整的落地参考。如果你是个游戏爱好者想自己做个小游戏但不会写复杂逻辑这篇也能让你明白用 AI 做游戏的核心不是编程而是设计“规则”和“交互”。我尽量把每一步都写清楚包括为什么这么做、不这么做会出什么问题。1. 项目整体设计与方案选型1.1 为什么选择“LLM 即引擎”的架构先说一个最核心的决策游戏逻辑到底由谁承担。传统方案是自己写规则引擎。比如玩家输入“拿起剑”你需要在代码里解析意图、匹配物体、判断背包、更新状态每一步都要写逻辑。这套方案的缺点是工作量爆炸——一个稍微像样的互动游戏状态组合是天文数字你根本写不完所有可能性。LPAI 换了一个思路把游戏世界描述交给大模型让模型根据当前状态和玩家输入决定“这个世界发生了什么”。我的角色不是“实现每个动作”而是“定义世界的规则和边界”。比如我告诉模型你是一个游戏主持人玩家在一个废弃的太空站里你负责描述场景、处理玩家的行动、判定成功失败。然后玩家说“我想撬开那扇门”模型就会自己生成结果——可能成功可能失败可能触发警报完全不用我提前写这些分支。这个架构的好处很明显开发量小灵活度极高玩家体验是真正“自由输入”而不是“选 A/B/C”。但也有代价模型输出不稳定可能胡说八道可能无视规则需要做大量约束和校验。这个权衡在我看来是值得的因为它的下限很高——模型再乱也不会比“我输入了词组但系统完全听不懂”更糟糕。1.2 技术栈的选择逻辑技术栈我最终定为前端纯 HTML/JavaScript后端 Node.js ExpressLLM 通过 API 调用。为什么这么选逐一说。前端不搞框架。文字游戏的核心界面就是“一段历史消息 一个输入框”React/Vue 在这种场景下没有优势反而引入构建工具的复杂度。我直接用原生 HTML CSS 少量 JS一个文件搞定任意浏览器打开就能用。如果你想做得更美观后期再加框架也不迟核心逻辑和 UI 是解耦的。后端用 Node.js 是因为它对流式响应支持自然。大模型生成文本是逐个 token 往外蹦的如果你等全部生成完再一次性返回玩家要等好几秒体验很差。用 SSEServer-Sent Events或简单的 chunked response可以把模型吐出来的文字实时推到前端配合打字机效果体验接近 ChatGPT 那种流式输出。这一点用 Python 做也不是不行但 Node 的异步模型写起来更顺手。LLM 的选择上我建议优先考虑支持 OpenAI 兼容接口的模型不管是商业 API 还是本地部署的兼容性最好。LPAI 里我做了可配置的 baseURL 和 model 字段方便切换。实际测试中我用过通用对话模型和代码模型效果差异不大关键不在模型大小而在提示词设计——后面专门讲。1.3 为什么这个项目适合用 Agent 的方式做现在 AI 圈特别流行“Agent”的概念LPAI 本质上就是一个单 Agent 的文字交互应用。Agent 在这里的定义很朴素一个能感知输入、调用工具、决策行动、产生输出的循环。LPAI 的 Agent 循环大概是这样的接收玩家文字输入。拼接完整上下文世界观设定 当前状态 历史摘要 玩家输入。调用 LLM让模型输出一段结构化结果包含剧情文本、状态更新、判定结果。解析模型输出更新游戏状态把剧情文本推送回前端。回到步骤 1等待下一次玩家输入。这个循环里没有复杂的工具调用也不需要多 Agent 协作但它已经具备 Agent 的基础特征感知读输入、记忆历史摘要、决策LLM 生成、行动更新状态与输出文本。你在做更复杂的 AI 应用时比如让 AI 替你订机票、写邮件也是同样一套骨架只是换掉“行动”的具体实现。这也是为什么我说 LPAI 不只是一个游戏更是一个 AI 交互应用的最小可行范例。2. 核心细节解析与实现要点2.1 提示词工程让模型学会“当主持人”LPAI 里最重要的不是代码是提示词。我把它叫做“主持人系统提示词”份量最重。这个提示词不是简单说“你是游戏主持人请描述剧情”而是要把规则、输出格式、边界条件全都塞进去。我的系统提示词分成四层。第一层定义身份和世界观你是一个文字冒险游戏的主持人游戏背景是一次太空站探索任务玩家是唯一的幸存者。第二层定义任务目标根据玩家的行动描述结果推动剧情发展制造悬念和挑战。第三层定义交互规则尊重玩家的自主选择不要替玩家做决定不能让玩家角色无故死亡除非玩家主动作死要维持游戏的一致性——之前出现过的物品、角色、事件不能凭空消失或改变。第四层最重要输出格式必须是 JSON包含三个字段narrative给玩家看的剧情文本、state_update需要更新的游戏状态、events触发的事件列表比如获得物品、生命值减少。为什么强调 JSON 格式因为如果不规定输出结构模型会自由发挥一会输出纯文本一会输出带 Markdown 的文本你解析起来非常痛苦。JSON 相当于给模型的输出加了一道围栏把它的创造力限制在“内容”层面而不是“形式”层面。实测下来只要清晰说明 JSON 结构并给出示例主流模型基本都能稳定输出。偶尔出格式错误靠解析失败重试一次就能解决。提示词还有一个细节给一个示例输出哪怕只有一个字段也行。这招非常管用模型看到示例后模仿能力会显著增强。我建议系统提示词里至少放这样一个小示例{ narrative: 你走上前去门上的指示灯闪烁着微弱的红光。, state_update: {location: airlock, tension: 1}, events: [] }2.2 状态管理的艺术既要完整又要省 token文字游戏必须有状态比如玩家位置、生命值、物品、NPC 好感度。但状态到底怎么存是个值得琢磨的问题。最简单粗暴的方式是每次请求都把完整状态塞给模型让它在 JSON 里返回全部更新后的状态。缺点是 token 消耗巨大而且模型偶尔会遗漏某个字段导致状态悄悄丢失。玩家上一秒还拿着手电筒下一秒就没了这种体验很糟糕。我的方案是“服务端状态 增量更新”。游戏状态存在后端内存里正式产品可以存数据库每次请求时我把当前状态精简成一句摘要塞给模型而不是把所有字段堆给它。模型只需要在 state_update 里返回“变动了的东西”后端负责合并。比如当前状态是“位置airlock生命值80背包[手电筒]”我传给模型的不是这段原始结构而是一句话“当前玩家位置是气闸舱生命值 80身上带着一个手电筒”。模型理解了上下文输出 state_update: {tension: 2}后端就把 tension 从 1 更新到 2其他字段原样不动。这样省 token也避免模型误改无关字段。合并更新时需要处理一个问题有些字段是数组比如背包模型可能只返回新增物品但我需要的是“添加操作”而不是“覆盖操作”。我的做法是在 state_update 里约定如果字段是数组则默认追加如果要覆盖则显式加一个 clear 标记。规则有点绕但在代码里实现也就十几行效果好过让模型每次输出完整背包。2.3 上下文管理用摘要对抗长对话文字游戏玩久了对话轮次会非常多上下文窗口迟早会被撑爆。直接把所有历史消息都塞给模型不仅浪费 token而且模型容易“记错”——注意力分散在大量早期内容上反而忽略最新的关键信息。LPAI 用了一个简单有效的方案滑动窗口 历史摘要。每次请求只带最近的三轮对话更早的内容压缩成一段固定格式的摘要。摘要是怎么生成的也是让模型做——在每轮对话结束时我把当前轮次的剧情、玩家的关键行动、重要状态变化提炼成三四句话追加到 summary 字段里。这样做的效果是无论游戏进行了多少轮我传给模型的 tokens 基本恒定。模型既能通过摘要记住主线比如“玩家已经拿到了逃生舱钥匙”又能通过最近对话感知当下情境比如“玩家正被怪物追赶”两全其美。代价是摘要本身可能遗漏细节但它带来的稳定性收益远大于损失。2.4 流式输出的前端联动我前面提到用 SSE 做流式输出这里详细说一下体验细节。当后端接收到 LLM 返回的流式内容时数据是分块到达的。后端不能直接把 JSON 结构流式传给前端——因为 JSON 是在模型完整生成之后才能解析的如果一边生成一边传前端拿到的是一堆碎片没法渲染。这里的解法是“双轨输出”模型产出的 narrative 字段剧情文本流式转发给前端让玩家看到文字一个一个字蹦出来产生“AI 正在写作”的沉浸感。而 state_update 和 events 这两个结构化字段等模型生成完毕、后端解析完成后再一次性提交。也就是说前端先看到文字再看到状态变化顺序上是剧情描述 → 状态栏刷新。实际体验就像翻页小说加了一个即时的数值面板非常顺滑。前端的渲染我做了一个“打字机”效果收到一个 chunk就往消息区的当前段落追加一段文字并自动滚动到底部。实现不复杂关键在于处理中断——玩家在 AI 还没说完话时就输入了下一个指令旧请求要取消新请求要覆盖旧的状态。我用了 AbortController 来终止未完成的请求避免出现两条剧情同时输出的错乱。3. 实操过程与核心环节实现3.1 搭一个最小可运行的 Agent 核心先看后端最核心的一个函数——处理玩家输入并联动模型const express require(express); const app express(); app.use(express.json()); // 游戏状态正式场景应持久化 const gameState { location: 太空站入口, hp: 100, inventory: [身份卡], flags: { alarmRaised: false } }; // 系统提示词定义身份、规则、输出格式 const SYSTEM_PROMPT 你是一个文字冒险游戏的主持人游戏背景是废弃太空站。 你必须遵守以下规则 1. 根据玩家行为和当前状态推进剧情绝不代替玩家做决定。 2. 保持世界的一致性已出现的物品和事件不能凭空消失。 3. 输出必须是 JSON 格式包含 narrative、state_update、events 三个字段。 4. narrative 长度控制在 100-200 字之间用第二人称描写。 示例输出 {narrative: 你走上前去门上的指示灯闪烁着微弱的红光。, state_update: {tension: 1}, events: []} ; app.post(/api/act, async (req, res) { const { input, history, summary } req.body; const messages [ { role: system, content: SYSTEM_PROMPT }, { role: system, content: 当前状态摘要${JSON.stringify(gameState)} }, { role: system, content: 历史摘要${summary ?? 暂无} }, ...history.slice(-6), // 只携带最近六条消息 { role: user, content: input } ]; const response await fetch(process.env.LLM_API_URL, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.LLM_API_KEY} }, body: JSON.stringify({ model: process.env.LLM_MODEL, messages, temperature: 0.8 }) }); const data await response.json(); const content data.choices[0].message.content; // 解析 JSON失败则重试一次 let parsed; try { parsed JSON.parse(content); } catch (e) { return res.status(500).json({ error: 模型输出格式异常请重试 }); } mergeState(gameState, parsed.state_update); res.json({ narrative: parsed.narrative, state: gameState, events: parsed.events }); }); app.listen(3000, () console.log(LPAI backend running on 3000));这段代码是最小闭环已经能跑通“输入行动 → 模型生成 → 更新状态 → 返回剧情”的完整循环。mergeState 就是那个做增量合并的函数注意数组字段要追加而不是覆盖function mergeState(state, update) { if (!update) return; for (const [key, value] of Object.entries(update)) { if (Array.isArray(value)) { state[key] [...(state[key] || []), ...value]; } else { state[key] value; } } }3.2 让状态更新更可控增加“意图操作”但直接让模型返回 state_update 有一个隐患模型可能把玩家背包物品写丢比如玩家说“把手电筒扔掉”模型只返回 narrative 而忘记更新背包。这就要在提示词里加操作语义。我在 state_update 基础上增加了一个可选字段 intent_ops专门处理对数组和数值的操作比如{ narrative: 你把手电筒扔进了通风管道。, state_update: {}, intent_ops: { inventory: {op: remove, item: 手电筒}, hp: {op: add, value: -10} } }后端先处理 intent_ops再处理 state_update。这样模型不需要知道“背包当前长什么样”只需要告诉系统“删掉什么、加多少”就不会出现覆盖整个背包的问题。数值变更也由此变得稳定——玩家受到伤害、回血、获得经验都是显式的加减操作而不是直接赋值。这个设计是从实际踩坑里逼出来的。早期版本我让模型直接返回 hp: 80结果有一次模型把 100 写成 200玩家直接从满血变成了不死之身。改成操作语义后这种离谱错误就很少发生了因为“当前值是多少”由后端算模型只描述“产生了什么影响”。3.3 前端如何把流式输出做成“有手感”的交互前端的核心是渲染消息区和处理流式输出。我直接贴一个简化版的核心逻辑async function sendAction(input) { const controller new AbortController(); activeController controller; // 追加玩家消息到界面 appendMessage(player, input); const resp await fetch(/api/act/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ input, history: msgHistory, summary: currentSummary }), signal: controller.signal }); // 读取 SSE 流 const reader resp.body.getReader(); const decoder new TextDecoder(); let narrativeBlock appendMessage(ai, ); while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value); narrativeBlock.textContent text; // 滚动到底部 container.scrollTop container.scrollHeight; } // 流结束后取回结构化状态 const stateResp await fetch(/api/act/state); const stateData await stateResp.json(); renderStatePanel(stateData.state); }注意这里后端要把生成过程拆成两个接口/api/act/stream 负责流式返回剧情文本/api/act/state 负责返回解析后的状态。实际实现要保证这两个接口共享同一状态实例避免中间状态不一致。前端界面我故意做得很素——黑色背景、等宽字体、绿色文字有点老式终端的感觉。输入框默认聚焦回车即发送。界面要素只有三个消息区、状态栏生命值/位置/背包、输入框。对于文字游戏来说一个干净的界面反而能增强沉浸感复杂的 UI 会分散玩家注意力。3.4 玩起来有意思是关键随机性与正反馈光有技术不行游戏得好玩。这是我整个项目里反思最深的地方。最早版本我跑通技术流程后自己玩了两轮就腻了——因为模型只会描述场景玩家做什么都能成功没有任何张力。解决之道是给模型施加“戏剧性压力”。我在系统提示词里加了这几条不要轻易让玩家成功给每个行动设置合理的成功率失败也要导向有趣的结果。每隔 3-5 轮主动引入一个新事件警报声、新线索、角色登场、危机发生。玩家的选择要产生实际影响并在后续剧情中被提及或引用——让玩家感到自己的行动有意义。举一个实际例子。玩家说“我要搜索控制台”模型如果直接给“你找到了一张纸条”这就很无聊。更好的输出是“你在控制台的键盘夹缝里发现一张被揉皱的纸条但与此同时走廊传来脚步声越来越近。”这样一来玩家必须立刻做下一个决定继续读纸条还是找地方躲起来。游戏的节奏感和张力就出来了。想让模型稳定产出这种内容需要给提示词加“戏剧性规则”本质上就是调教模型像一个有经验的主持人而不是一个被动应答的秘书。要明确告诉它你的任务是制造有趣的决策而不是满足玩家的一切愿望。这个思维转变对 AI 游戏开发来说非常关键。3.5 后端部署的几个注意点LPAI 的后端可以部署在任何支持 Node.js 的环境我用的是 Docker 一个简单的 Linux 服务器配置大概是这样的FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install --production COPY . . EXPOSE 3000 CMD [node, src/index.js]部署时有两个坑值得说。第一个是环境变量管理API 密钥绝对不能写死在代码里我用 dotenv 加载 .env 文件这个文件不进 Git。第二个是进程守护Node 进程崩了得能自动重启我在容器里用了 PM2 做进程管理宿主机配置了 Docker 的 restart 策略。文字游戏用户量不大这样已经非常可靠。另外要特别注意 API 的流式连接超时问题。有些 LLM API 对连接时间有限制如果你的提示词很长模型生成时间又长连接可能被切断。我在后端设置了超时重试机制并且在前端做了提示“AI 正在思考中”避免玩家误以为卡死。4. 常见问题与排查技巧实录4.1 JSON 解析失败让模型“闭嘴”不乱说这是最容易遇到的问题。模型输出 JSON 时偶尔会在前后加额外的文字比如“好的这是你的结果{...}”。解析直接失败。我的排查策略有三层。第一层在后端捕获 JSON.parse 异常后先尝试用正则把第一个 { 到最后一个 } 之间的内容截出来再解析。这个办法能解决八成问题。第二层给模型 redo把上一次的输出拼上提示词“请仅输出 JSON不要包含任何其他文字”再调用一次。第三层在系统提示词里强调“不要输出 JSON 之外的任何字符”并在示例里明确展示纯 JSON 的样子。三层下来解析失败率基本降到了 1% 以下。剩余那 1% 就交给用户重试一次作为 AI 应用的容错设计完全可以接受。4.2 模型无视规则擅自控制玩家角色文字游戏最忌讳“NPC 式 AI”替玩家做决定。我早期测试时输入“我要逃跑”模型竟然直接写“你逃跑了”没有给玩家任何选择余地。这就像跑团 DM 把你的人物卡抢过来替你行动体验非常差。我加了两条很硬性的提示词约束“严禁替玩家做出选择或行动玩家的行动必须由玩家亲自发出指令。”以及“如果玩家没有明确行动你只能描述环境和氛围不能推进事件。”同时我让模型在 narrative 里输出时尽量以“你看到了……”“你感到……”这样的感知性描写为主把决策权留给玩家。还有一种更精细的调法给忽略规则的输出一个惩罚式反馈。如果检测到 narrative 里出现了“你做了某件事”的表述后端可以把这次输出标记为“规则违反”把错误信息反馈给模型让它重新生成。这需要额外的检测逻辑但也不算复杂我用字符串匹配加规则引擎勉强实现了效果不错。4.3 输出太长或太短节奏不对模型生成的剧情文本长度很难控制。太长了像流水账太短了像干巴巴的播报。我在提示词里写了“narrative 长度控制在 100-200 字之间”但模型有时候还是会抽风输出个 500 字的小作文。后来我用了一个后处理方案在后端对 narrative 做长度裁剪。超过 250 字就截断到最近的句号如果低于 50 字则把 history 里的上下文重新强调一下再次请求让模型基于上一段展开。这样虽然多花一次 API 调用但保证了玩家体验。当然长度控制也不是越短越好。如果一段剧情描述少于 50 字玩家根本没法建立画面感。关键阈值是 80-150 字这个长度足够描绘环境、给出信息、抛出钩子又不会让玩家失去耐心。我个人实测后定的目标值就是这个区间。4.4 历史摘要越滚越乱主线丢失游戏进行到二十轮以后摘要可能长达几百字而且越早的信息越模糊。玩家可能在第 5 轮捡到的钥匙到第 30 轮模型已经忘了导致明明有钥匙却写“门打不开”。针对这个问题我在摘要生成时让模型额外输出一个“关键物品清单”每轮都更新一次确保核心道具永远不会被遗忘。摘要变成两个部分情节摘要 关键物品/事件清单。后者虽然是纯列表但对维持一致性帮助巨大。还有一个更简单的兜底方案把玩家的背包和关键 flag 直接放进系统提示词的“当前状态摘要”里而且是硬编码拼进去的不走模型摘要。也就是说背包丢了什么东西系统是真实存着的模型只是“使用者”而不是“记忆体”。只要模型每次决策前都看到背包清单就不会出现“有钥匙不用”的蠢事。4.5 常见问题速查表现象可能原因解决方案模型输出带额外文字导致 JSON 解析失败提示词约束不够正则截取 重试 强化格式规则玩家输入后没有反应流式连接超时后端超时重试前端显示思考中状态状态字段变成 null增量更新合并出错检查 mergeState数组和对象要深拷贝剧情前后矛盾摘要丢失关键信息增加关键物品清单 硬编码状态摘要模型代替玩家做决定提示词未明确边界加入严禁行为条款 感知性描写约束游戏玩一会儿就无聊缺少事件推动力设置戏剧性压力规则3-5 轮引入新事件4.6 独家避坑心得最后分享几个不好归类的经验。第一LLM 的作用域要尽量窄。每一次调用模型只需要关心“当前回合发生了什么”不要让它管理全局进度、分支状态这类复杂信息那些应该放在后端的数据库或文件里。模型越专注输出越稳定。第二永远准备一个离线兜底方案。有一次 LLM API 服务商出故障我的游戏直接没法玩。后来我给后端加了一个“本地规则模式”当 API 调用失败时自动切换到一套关键词匹配的简易回应逻辑虽然笨但至少能让玩家继续玩。对于个人项目来说这种降级处理是必要的心态你不是在做一个依赖单一服务的产品而是在做一个有韧性的系统。第三别把“模型的创造力”当成“游戏的设计力”。模型能生成文本但它不知道什么好玩。游戏好不好玩取决于你在提示词里嵌入了多少设计思考。我花了大量时间在调整“戏剧性规则”上而不是在调模型参数上。真正让 LPAI 变好玩的关键修改是加了“不要轻易让玩家成功”这一条它让游戏从“记事本”变成了“冒险”。我在写这个项目的过程中最大的感受是用 AI 做应用核心能力已经从“写代码”变成了“写规则”。你需要清楚地定义AI 能做什么、不能做什么、输出结构是什么、状态如何流转、失败怎么处理。这些规则写好了代码反而是最轻松的部分。LPAI 算是我在 AI 应用开发上的一次完整练兵从模型调用、流式输出、状态管理到部署运维全走了一遍也让我对 AI 产品经理口中的“提示词策略”有了实打实的体感。如果你也想做点什么建议不要只停留在调 API 玩一玩试着把一个完整的小应用跑起来——哪怕是这种简单文字游戏做完之后你对 AI 应用的理解都会完全不同。
返回列表