码道 · 字境漫游——当 AI 学会用 JSON 讲故事:一个文字角色扮演网页应用的诞生
项目链接:https://atomgit.com/2601_96960179/ruanjian6ban_demo_0918
码道 · 字境漫游——当 AI 学会用 JSON 讲故事:一个文字角色扮演网页应用的诞生
从一段 Python 流式请求脚本出发,我用纯前端技术造了一个由大模型实时驱动的文字角色扮演游戏。这篇文章记录了「字境漫游」从灵感到落地的全过程:架构设计、Python 到 JavaScript 的翻译、系统提示词工程、结构化 JSON 输出协议、流式增量渲染,以及三轮真实联调验证中的思考与踩坑。
效果示例:
一、缘起:深夜的一个念头
事情要从一个很普通的晚上说起。当时的我正在研究 GitCode 平台开放的 AI 大模型推理接口,手里攥着一份官方给的最小调用示例,语言是 Python,核心逻辑不超过二十行:
def query(payload):
response = requests.post(API_URL, headers=headers, json=payload, stream=True)
for line in response.iter_lines():
if not line.startswith(b"data:"):
continue
if line.strip() == b"data:[DONE]":
return
yield json.loads(line.decode("utf-8").lstrip("data:"))
接口返回的是标准的 OpenAI 兼容的 SSE 流式数据,模型是 deepseek-ai/DeepSeek-V4-Flash。我盯着屏幕上逐行滚过的 data: 帧,忽然意识到一件事:这二十行代码背后,站着一个可以在几秒钟之内生成一千字高质量中文内容的"写作引擎"。
于是那个晚上我萌生了一个念头:能不能让大模型不是回答问题,而是替我当一个游戏主持人(Game Master),实时地把玩家带进一个全新的世界?不是那种接一句答一句的聊天机器人,而是一个真正能"演故事"的互动小说引擎——主角是你,NPC 是 AI,旁白是 AI,裁判也是 AI,甚至给你递选项、结算属性的,还是 AI。
这个念头最终长成了一个具体的项目,我给它起名「字境漫游」——在文字的领域里漫游,一个 AI 文字角色扮演对话网页应用。
二、项目定位:不是聊天机器人,是一场游戏
在动手之前,我先想清楚了一件最重要的事:我要做的不是"带人设的客服",而是一款有明确游戏框架的 RPG。
这两者的差别非常大。传统聊天机器人是"对话驱动"的,用户问、AI 答,用户不问,对话就停在那里。而角色扮演游戏是"剧情驱动"的:每一轮 AI 都要推进故事,都要给出选择,玩家被剧情推着往前走,不可能停在原地。
基于这个定位,我给项目定了五条核心设计原则:
- 剧情永远向前:AI 每一回合都必须推进故事,不能原地打转;
- 决定权在玩家:AI 可以替全世界做决定,但绝不能替玩家做决定;
- 选择要有质量:每一轮必须给出三个差异化明显的行动选项;
- 数值要真实:受伤扣血、消耗体力、拾取物品,这些都要在界面上看得见;
- 故事要有终点:主线完成或人物死亡时,要有一个体面的结局。
这五条原则后来全部写进了系统提示词,成为约束模型的"游戏规则"。
三、技术选型:为什么是纯前端
技术选型是项目立项后的第一个决策。候选方案有三个:
方案 A:Python 后端 + 网页前端。 这是很多人会走的路线——Flask 或 FastAPI 起一个服务,后端负责转发 AI 请求,前端负责展示。好处是逻辑清晰,坏处是用户要装 Python 环境、要起服务、要管 CORS,对"打开就能玩"这个目标极不友好。
方案 B:Node.js 中间层。 思路和 A 类似,只是换了个运行时,至少不需要 Python 环境,但依然要部署一个服务。
方案 C:纯前端直连。 浏览器直接用 fetch 请求大模型接口,所有逻辑都写在 HTML、CSS、JavaScript 三个文件里,扔到任何一个静态服务器上就能跑。
我最终选了方案 C。理由非常现实:项目的核心诉求是"演示"和"传播",零依赖、零构建、零后端,是让一个项目能被最快看到效果的方式。你甚至不需要克隆仓库,只要把 index.html 双击打开……好吧,双击打开会碰到浏览器跨域限制,但放到任意静态服务器(一分钟内就能起好)就完全没问题。
事实上,GitCode 的 AI 推理接口在浏览器端有着非常友好的 CORS 支持,fetch 可以直接跨域访问,这让方案 C 从"理想主义"变成了"工程师的现实选择"。
技术上只有三个文件(外加一个样式表),互相之间职责清晰:
ruanjian6ban_demo_0918/
├── index.html # 页面骨架:剧本选择界面 + 游戏舞台
├── css/style.css # 沉浸式深色主题,玻璃拟态 + 光晕粒子
└── js/
├── config.js # 全局配置:API 参数、剧本库、系统提示词
├── api.js # 流式请求封装(由 Python 版翻译而来)
└── app.js # 游戏状态机、渲染管线、数值结算
四、翻译的第一道坎:把 Python 的流式请求搬进浏览器
项目的技术核心,是把官方那份 Python 流式请求脚本准确翻译成 JavaScript,并且翻译得足够健壮,能扛住真实网络环境下各种"脏"数据。
Python 版本的思路很清晰:requests 打开长连接,iter_lines() 逐行吐出服务端推送的数据,每行以 data: 开头,以 [DONE] 结束。翻译成浏览器语言,对应关系是这样的:
| Python | JavaScript |
|---|---|
requests.post(url, stream=True) | fetch(url) + response.body.getReader() |
iter_lines() | 按 \n 分割读取到的字节流 |
line.startswith(b"data:") | line.startsWith("data:") |
json.loads(...) | JSON.parse(...) |
data:[DONE] 结束标记 | 同样的字符串判断 |
第一版我很快就写了出来,但紧接着我就意识到一个Python 代码里不会暴露、只有真实网络环境才会教育你的问题:TCP 包的边界不等于行的边界。
服务端推送的是一段连续的字节流,它可能在"一行的中间"被切断,也可能一次送来好几行。Python 的 iter_lines 帮你把这个细节藏起来了;而 JavaScript 的流式 API 只给你原始的 Uint8Array,分帧这件事必须自己做。我的处理方式是在读取循环里维护一个 buffer:
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); // 按行拆帧
buffer = lines.pop() || ""; // 最后一段可能不完整,留到下一轮
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") continue; // 结束标记
try {
const chunk = JSON.parse(payload);
const delta = chunk.choices[0].delta;
if (delta && delta.content) onContent(delta.content);
} catch (_) { /* 残缺帧直接跳过 */ }
}
}
这个 split + pop 的组合是整个流式解析的骨架:split("\n") 把当前缓冲区切成若干完整行,最尾巴上那段"可能只有半行"的数据先塞回 buffer 等下一轮。配合 TextDecoder,无论是 UTF-8 多字节字符被拦腰截断,还是 SSE 帧粘在一起,都能正确处理。
还有两个容易被忽视的细节。第一个是 response.ok 检查:接口在鉴权失败、参数错误时会返回非 200 状态码,我一开始偷懒没检查,导致报错信息极其难懂。第二个是收尾处理:数据流结束时,buffer 里可能还残留最后一帧未解析,需要在循环结束后再补一次解析,否则会丢掉模型的最后一句话。这两个细节后来都在联调中发现并修复了。
五、系统提示词工程:让 AI 学会用 JSON 讲故事
接口通了,但真正让这个项目从"练手 demo"变成"能玩的作品"的,是系统提示词的设计。
我做的第一个决定是:AI 的每一轮输出都必须是一个严格 JSON 对象,而不是一段自由文本。 这是整个项目的分水岭。如果让 AI 自由发挥,前端就只能傻乎乎地把一大段文字贴到页面上,那这个项目就把自己降级成了一个"套了皮肤的聊天机器人"——这是我最不愿意看到的结果。
而 JSON 输出的好处是巨大的:
- 结构化:旁白是旁白、台词是台词、选项是选项,各自渲染,主次分明;
- 可计算:
player字段里的数值变化可以被程序直接结算,游戏机制才能成立; - 可控制:字段缺失、类型错误都可以在前端兜底处理,而不是看运气。
最终我设计的 JSON 协议长这样:
{
"narration": "场景旁白与氛围描述,每次 1~3 句话",
"speaker": "正在说话的角色名,无对话时为 null",
"dialogue": "该角色的台词,无对话时为 null",
"emotion": "说话角色的情绪,如 愤怒/喜悦/惊讶,无对话时为 null",
"system_message": "特殊事件提示或状态变化说明,无则为 null",
"choices": ["3 个差异化行动选项"],
"player": { "hp": -8, "energy": -5, "items": ["获得的物品名"] },
"is_over": false
}
设计这个协议时我专门琢磨了三个细节:
细节一:player 字段传增量,不传绝对值。 这是我在设计阶段踩过的思维陷阱。最初的方案是让 AI 返回玩家"当前"的完整状态(hp 100、energy 50……),但模型很可能记错或编造数值,而且多轮对话下上下文里塞满了重复的状态信息,还占了宝贵的 token 预算。改成"只传本轮增量"之后,模型只需要告诉前端这轮扣了多少血、加了什么物品,累加由前端完成,数值永远对得上。
细节二:不要用 Markdown 代码块包裹 JSON。 很多模型偏好输出 ```json ... ````,但这种包裹会给前端解析添麻烦。我在系统提示词里明确写了"禁止输出 JSON 之外的任何字符、禁止使用 Markdown 代码块包裹"。同时,前端parseTurn函数里还是保留了一个sanitizeRaw`,把偶尔漏出来的 ```剥掉——模型是概率系统,永远要对它做一层防御。
细节三:情绪要映射到视觉。 emotion 字段不仅仅是文字标签,我还做了情绪到颜色的映射表:愤怒是红色、喜悦是绿色、哀伤是紫色、恐惧是暗红……这样角色说话时,气泡的左边框和情绪标签会染上对应的颜色,"情绪"就从文字变成了画面。
为了让模型老老实实遵守协议,我把整个系统提示词写成了"游戏规则守则"的样式——开场给身份设定(你是 Game Master 与实时故事引擎),中间给硬性规则(用第二人称、每次 1~3 句话、绝不替玩家做决定、每次给 3 个选项、属性结算规则、结局机制),最后给出唯一合法输出格式的定义。规则用祈使句、动词开头,比一堆"应该""可以"的软性描述有效得多。
六、剧本设计:四个彼此独立的世界
工具链就绪之后,我开始设计"世界观"。一个角色扮演应用不能只有一个故事,那样玩家的新鲜感一次就耗尽了。我设计了四个风格迥异的剧本,放在 config.js 的 WORLDS 数组里,每个剧本都有一句话可以概括的气质:
- 武侠江湖(⚔️):夜雨客栈、蒙面客、华山论剑,恩怨情仇的中国古典武侠——目标用户是所有读过金庸古龙的人;
- 星际迷航(🚀):失联的殖民船、克苏鲁星云、AI 辅助驾驶——满足科幻控对"深空恐惧"的偏好;
- 奇幻大陆(🐉):碎裂的龙晶、七枚龙泪、地脉迷宫——经典的西方奇幻配方;
- 都市奇谈(🌆):午夜的电台、37 年前的寻人启事、旧钟楼——在都市背景里玩超自然悬疑。
每个剧本字段都精心设计过:icon 用于卡片上的视觉符号,accent 是主题色(武侠偏红、星际偏蓝、奇幻偏紫、都市偏青),tagline 是两行美术字,desc 是世界背景的引子,opening 是开局那一段足以把人拉进故事的开场白。
为了让开局有代入感,opening 我一句一句地斟酌过。比如武侠剧本的开场:
夜雨如丝,木檐下的灯笼在风中摇晃,客栈大堂只剩你与那蒙面客。他放下茶杯,低声道:「令尊的事……我已知晓。三日后便是华山论剑,仇人亦会到场。你,敢去吗?」
开场白里加了省略号的停顿、第二人称的"你"、以及一个立即抛出钩子的"敢去吗",就是要让玩家在开局第一屏就被"推"进剧情。这四个剧本不仅仅是演示素材,它们本身承担着验证系统提示词泛化能力的任务——同一个提示词,要能同时驾驭武侠、科幻、奇幻、悬疑四种文风,这本身就是对提示词质量的检验。
七、流式增量渲染:让"写"的过程被看见
接下来是前端最核心的活儿:渲染。既然拿到了流式数据,就该让它以"正在被写出来"的方式呈现在玩家眼前——旁白一个字一个字地浮现,对话在气泡里一句句地生长。这个过程我称之为「流式增量渲染」。
写这个功能之前,我先自己想清楚了它和"一次性渲染"的本质区别:流式过程中,你必须处理"半截 JSON"。模型写到一半,narration 字段还没写完,choices 数组刚开个头——这时候整个 JSON 显然是解析不出来的。
我的方案是「轮询增量 + 最终结算」双阶段:
阶段一(流式过程中): 每收到一小段文本就追加到原始缓冲区,然后用 requestAnimationFrame 节流,每隔一帧尝试解析一次当前缓冲区的 JSON。如果能解析出来,就做增量拼接——拿新解析出的 narration 长度和已经渲染的长度做差,只把多出来的那截字符补到界面节点上;dialogue 同理。这样既保证了"打字机"式的逐渐显现,又不会重复渲染,而且节流还能避免每个数据帧都做一次全量 JSON 解析的性能浪费。
渲染的顺序也有讲究:narration 累积的同时,如果检测到 dialogue 出现了非空内容,我就把对话气泡"弹"出来——气泡的左边框先染上情绪色,然后 speaker 标签和 emotion 情绪标签先定位,台词文本再逐字填入。这样玩家眼里看到的画面是:先有旁白铺底,再"蹦"出一个带名字带情绪的对话气泡,最后台词一字字出现——非常接近电影里的字幕逻辑。
阶段二(流结束后): 拿到完整文本后做一次最终解析,一次性结算这轮"写完后才该发生的事":生成三个选项按钮、结算属性变化、展示系统消息、判断是否触发结局。
这里还有一个让体验加分的细节:player 减少的属性用绿色小字浮现在回合底部,格式是"◆ -8 生命 ◆ -5 精力 ◆ 获得「生锈的钥匙」"。玩家每做一个选择,都能立刻看到自己为此付出的代价或得到的回报——数值反馈是游戏感的重要来源。
八、容错设计:模型是概率系统,前端必须免疫
任何一个和大模型打过交道的人都明白一个朴素真理:模型是概率系统,它想怎么抽风就怎么抽风。 所以在开发过程中,我把"容错"当成一项和功能并列的硬需求,而不是事后补丁。我一共做了四层防御:
第一层:状态锁。 全局用一个 busy 标志位锁住输入——AI 回复期间,选项按钮、输入框、发送按钮全部禁用,杜绝"连点导致并发请求、消息历史错乱"的问题。busy 还会改变输入框占位符:“AI 正在书写命运的篇章……”,这算是把等待变成了沉浸感的一部分。
第二层:兜底选项。 如果模型这轮输出怎么解析都是非法 JSON,前端不会崩溃,而是把原文当作旁白显示,然后强行塞给用户三个通用兜底选项:“继续探索”“仔细观察四周”“询问更多细节”——游戏继续,玩家几乎无感。
第三层:增量防御。 renderIncremental 在解析失败时直接 return,不渲染任何东西,绝不让半截 JSON 污染界面。等到流结束再交给 settleTurn 做最终结算。
第四层:增量结算的幂等性。 属性结算(applyPlayerDelta)只在回合结束时调用一次,且物品入包时做了去重判断——同一个物品不会因为模型偶尔重复返回而进两遍背包。
有了这四层防御,这个应用在真实调用中表现得相当皮实:三轮联调测试里,模型返回的 JSON 结构全部符合协议,偶尔的小瑕疵也都被前端静默消化了。
九、视觉设计:给文字一个"场"
我是一个相信"视觉是叙事的一部分"的人。文字角色扮演应用的界面,绝对不能是一块白板上糊几行字——那会让玩家毫无沉浸感。所以在样式上我花了不少心思,主题定为「深夜书房里的一盏灯」:
- 底色:深蓝紫的夜空渐变,配合三层漂浮的模糊光晕(
orb),形成一种"读者坐在深海般寂静的空间里"的观感; - 玻璃拟态:顶栏、气泡、选项按钮全部采用半透明磨砂玻璃质感,背景光晕透过来,层次感立刻就出来了;
- 衬线字体:对话气泡里的角色名 (
speaker) 用了宋体系衬线字("Songti SC", "Noto Serif SC"),和正文的无衬线体拉开层次——角色说话用的是"古书的嗓门"; - 呼吸动画:光晕缓慢漂移(
drift22 秒循环)、卡片悬停浮起、按钮带弹跳反馈(pop)、剧情块淡入——所有动画都保持在 0.2~0.5 秒区间,克制而连贯; - 打字光标:AI 正在生成时,旁白文本尾部会有一个闪烁的「▍」光标,让人明确感知"故事正在被写出来"。
我特别满意的是情绪色的映射——它是功能(emotion 字段)和视觉(EMOTION_STYLES 映射表)的一次优雅结合:愤怒 #ff7b72、喜悦 #8fe0a8、惊讶 #ffd88a……超过十五种情绪各自有专属的颜色,角色一动气,整个气泡的左边框就先"红了脸"。
十、从测试到交付:三轮联调教会我的事
代码写完只是开始,真正的考验在测试。我用 Node 写了一个联调脚本,把 config.js 和 api.js 原样加载进 vm 上下文,模拟浏览器环境跑了三轮端到端测试,每一轮都像一次小型答辩:
第一轮:开局测试。 只发系统提示词和剧本开场白,让模型"开局"。结果非常理想:流式片段共 226 段,最终输出的 JSON 八个字段一应俱全,三个选项风格各异,player 字段为空对象(此时确实没有任何数值变动)。
第二轮:多轮对话测试。 我把第一轮返回的完整 JSON 原样塞回 messages 历史,让玩家"选择了第一个选项",验证模型能否带着上下文继续推进。结果稳定:字段结构保持,三个新选项出现。
第三轮:战斗数值测试。 我设计了一个"拔剑冲向山贼"的极端场景,专门考验模型的数值结算能力。模型返回了 {"hp":-8,"energy":-5,"items":[]}——完美符合"增量"协议。这一轮是最让人安心的:它证明 AI 真的读懂了"hp 是增量"这个规则,并且会在剧情需要的时候严格执行。
三轮测试通过的当晚,我又做了一件更认真的事:用 Playwright 在真实 Chromium 浏览器里完整跑了一遍用户旅程——打开页面、选择「武侠江湖」、等待 AI 开局、点击选项、推进到第二轮。结果:API 两次请求全部 HTTP 200,控制台零报错,旁白、对话、情绪标签、系统消息、三选项、状态面板全部按预期渲染。当自动化测试脚本把"第二轮对话中情绪标签从『低沉』自动变成『肃然』“的截图摆在面前时,我知道这个项目"稳了”。
十一、踩坑实录:那些差点让我放弃的 Bug
写这个项目的十几个小时里,有价值的坑大概有四个,每一个都值得记一笔:
坑一:流式收尾丢字。 这是流式解析最常见的坑。第一次联调时我发现模型回复的最后一句话总是缺几个字——原因就是数据流结束时 buffer 里残留的半帧数据没人处理。修复方式是循环结束后再补一轮解析。教训:凡是"流",就一定有"尾",收尾必须显式处理。
坑二:git init 在错误目录。 严格说这不是代码 bug 而是工程教训。我最初在 /workspace(项目父目录)执行了 git init,把环境目录和项目目录一起提交了,导致提交内容混乱。教训:先 pwd 看准目录再初始化仓库,commit 之前务必 git status 和 git ls-files 核对文件清单。
坑三:选项连发导致消息历史错乱。 早期版本没有 busy 状态锁,玩家在 AI 回复期间狂点选项按钮,会导致多个请求并发、历史消息顺序错乱。教训:UI 的可用性边界,要用状态机去锁,而不能指望用户"手稳"。
坑四:JSON 解析的循环引用。 这个并不是本项目踩的,但我在设计 renderIncremental 的增量拼接时反复提醒自己要避免用"整块替换"的暴力方案——那会带来光标的跳动和视觉闪烁。教训:流式渲染的核心不是"写",而是"只写新增的那一点"。
十二、回望与前瞻:一个项目能走到多远
「字境漫游」目前已经是一个完成度相当高的作品:四大剧本、流式渲染、结构化输出、属性结算、结局机制、容错降级、移动端适配,仓库里躺着的是一份可以随时演示、随时传播的网页应用。但回望整个项目,我认为它最大的价值不在于功能的堆叠,而在于它验证了一个在今天格外重要的工程命题:大模型接口和前端工程之间,可以建立一条"结构化协议"的通道。
过去我们接入大模型,习惯性地把它当"文本生成器":丢一段 prompt 进去,接收一段文本出来。但「字境漫游」的做法是:先用一套严格定义的 JSON 协议向模型"招标",再用流式解析配合增量渲染把协议"落地"成体验。 模型从"说话者"变成了"数据的生产者",前端从"展示文本"变成了"解读结构、渲染体验"。这种协作模式,其实可以迁移到大量应用场景:AI 驱动的表单填写、AI 驱动的数据看板、AI 驱动的流程图生成……只要协议定义得足够清晰,大模型的想象力就能被装进任意一个应用程序的骨架里。
至于这个项目本身,我脑子里已经列了长长的 roadmap:
- 多剧本扩展:剧本目前只是一层配置,新增一个世界只需在
WORLDS数组里加一个对象,我已经打算把更多题材(克苏鲁、宫斗、侦探、日式奇幻)添进去; - 存档系统:用
localStorage把消息历史和玩家属性存到本地,下次打开还能继续上次的冒险; - 多结局成就:在系统提示词里引入"成就徽章"字段,让玩家收集一个个隐藏结局;
- 角色捏脸:允许玩家自定义自己的角色名、性别、初始属性,把"你"变成"我的那个角色";
- 音效与动效:打字音效、雨夜白噪音、翻页效果,把沉浸感再拉高一档。
十三、结语:文字是一个永远不过时的世界
在动辄谈论"多模态"“3D”“元宇宙"的今天,做一个纯文字的应用,多少显得有些朴素。但我恰恰认为,文字是想象力密度最高的媒介——一行"夜雨如丝,木檐下的灯笼在风中摇晃”,每个读者的脑海里都会浮现出各自不同的画面;而这份"个人化"的想象空间,正是游戏感和沉浸感最珍贵的来源。
「字境漫游」把这份朴素发挥到了极致:它没有一张插画、没有一段音效,只有一个会讲故事的 AI、三个会发光的选择按钮,和一双愿意相信故事的眼睛。当模型用流式输出把第二回合的开头写到屏幕上时,那不只是"返回了一段文本"——那是一场冒险的开始。
如果你也对"让 AI 讲故事"这件事感兴趣,欢迎打开我的仓库页面,把 index.html 扔进任意静态服务器,选一个世界,按下开始。愿你在字境之中,也遇见一个值得奔赴的远方。
*本文首发于码道,记录「字境漫游」项目的设计与实现。技术栈:HTML5 + CSS3 + JavaScript;AI 引擎:GitCode 平台 deepseek-ai/DeepSeek-V4-Flash,SSE 流式输出。项目链接:https://atomgit.com/2601_96960179/ruanjian6ban_demo_0918
码道 · 字境漫游——当 AI 学会用 JSON 讲故事:一个文字角色扮演网页应用的诞生
从一段 Python 流式请求脚本出发,我用纯前端技术造了一个由大模型实时驱动的文字角色扮演游戏。这篇文章记录了「字境漫游」从灵感到落地的全过程:架构设计、Python 到 JavaScript 的翻译、系统提示词工程、结构化 JSON 输出协议、流式增量渲染,以及三轮真实联调验证中的思考与踩坑。
一、缘起:深夜的一个念头
事情要从一个很普通的晚上说起。当时的我正在研究 GitCode 平台开放的 AI 大模型推理接口,手里攥着一份官方给的最小调用示例,语言是 Python,核心逻辑不超过二十行:
def query(payload):
response = requests.post(API_URL, headers=headers, json=payload, stream=True)
for line in response.iter_lines():
if not line.startswith(b"data:"):
continue
if line.strip() == b"data:[DONE]":
return
yield json.loads(line.decode("utf-8").lstrip("data:"))
接口返回的是标准的 OpenAI 兼容的 SSE 流式数据,模型是 deepseek-ai/DeepSeek-V4-Flash。我盯着屏幕上逐行滚过的 data: 帧,忽然意识到一件事:这二十行代码背后,站着一个可以在几秒钟之内生成一千字高质量中文内容的"写作引擎"。
于是那个晚上我萌生了一个念头:能不能让大模型不是回答问题,而是替我当一个游戏主持人(Game Master),实时地把玩家带进一个全新的世界?不是那种接一句答一句的聊天机器人,而是一个真正能"演故事"的互动小说引擎——主角是你,NPC 是 AI,旁白是 AI,裁判也是 AI,甚至给你递选项、结算属性的,还是 AI。
这个念头最终长成了一个具体的项目,我给它起名「字境漫游」——在文字的领域里漫游,一个 AI 文字角色扮演对话网页应用。
二、项目定位:不是聊天机器人,是一场游戏
在动手之前,我先想清楚了一件最重要的事:我要做的不是"带人设的客服",而是一款有明确游戏框架的 RPG。
这两者的差别非常大。传统聊天机器人是"对话驱动"的,用户问、AI 答,用户不问,对话就停在那里。而角色扮演游戏是"剧情驱动"的:每一轮 AI 都要推进故事,都要给出选择,玩家被剧情推着往前走,不可能停在原地。
基于这个定位,我给项目定了五条核心设计原则:
- 剧情永远向前:AI 每一回合都必须推进故事,不能原地打转;
- 决定权在玩家:AI 可以替全世界做决定,但绝不能替玩家做决定;
- 选择要有质量:每一轮必须给出三个差异化明显的行动选项;
- 数值要真实:受伤扣血、消耗体力、拾取物品,这些都要在界面上看得见;
- 故事要有终点:主线完成或人物死亡时,要有一个体面的结局。
这五条原则后来全部写进了系统提示词,成为约束模型的"游戏规则"。
三、技术选型:为什么是纯前端
技术选型是项目立项后的第一个决策。候选方案有三个:
方案 A:Python 后端 + 网页前端。 这是很多人会走的路线——Flask 或 FastAPI 起一个服务,后端负责转发 AI 请求,前端负责展示。好处是逻辑清晰,坏处是用户要装 Python 环境、要起服务、要管 CORS,对"打开就能玩"这个目标极不友好。
方案 B:Node.js 中间层。 思路和 A 类似,只是换了个运行时,至少不需要 Python 环境,但依然要部署一个服务。
方案 C:纯前端直连。 浏览器直接用 fetch 请求大模型接口,所有逻辑都写在 HTML、CSS、JavaScript 三个文件里,扔到任何一个静态服务器上就能跑。
我最终选了方案 C。理由非常现实:项目的核心诉求是"演示"和"传播",零依赖、零构建、零后端,是让一个项目能被最快看到效果的方式。你甚至不需要克隆仓库,只要把 index.html 双击打开……好吧,双击打开会碰到浏览器跨域限制,但放到任意静态服务器(一分钟内就能起好)就完全没问题。
事实上,GitCode 的 AI 推理接口在浏览器端有着非常友好的 CORS 支持,fetch 可以直接跨域访问,这让方案 C 从"理想主义"变成了"工程师的现实选择"。
技术上只有三个文件(外加一个样式表),互相之间职责清晰:
ruanjian6ban_demo_0918/
├── index.html # 页面骨架:剧本选择界面 + 游戏舞台
├── css/style.css # 沉浸式深色主题,玻璃拟态 + 光晕粒子
└── js/
├── config.js # 全局配置:API 参数、剧本库、系统提示词
├── api.js # 流式请求封装(由 Python 版翻译而来)
└── app.js # 游戏状态机、渲染管线、数值结算
四、翻译的第一道坎:把 Python 的流式请求搬进浏览器
项目的技术核心,是把官方那份 Python 流式请求脚本准确翻译成 JavaScript,并且翻译得足够健壮,能扛住真实网络环境下各种"脏"数据。
Python 版本的思路很清晰:requests 打开长连接,iter_lines() 逐行吐出服务端推送的数据,每行以 data: 开头,以 [DONE] 结束。翻译成浏览器语言,对应关系是这样的:
| Python | JavaScript |
|---|---|
requests.post(url, stream=True) | fetch(url) + response.body.getReader() |
iter_lines() | 按 \n 分割读取到的字节流 |
line.startswith(b"data:") | line.startsWith("data:") |
json.loads(...) | JSON.parse(...) |
data:[DONE] 结束标记 | 同样的字符串判断 |
第一版我很快就写了出来,但紧接着我就意识到一个Python 代码里不会暴露、只有真实网络环境才会教育你的问题:TCP 包的边界不等于行的边界。
服务端推送的是一段连续的字节流,它可能在"一行的中间"被切断,也可能一次送来好几行。Python 的 iter_lines 帮你把这个细节藏起来了;而 JavaScript 的流式 API 只给你原始的 Uint8Array,分帧这件事必须自己做。我的处理方式是在读取循环里维护一个 buffer:
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); // 按行拆帧
buffer = lines.pop() || ""; // 最后一段可能不完整,留到下一轮
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") continue; // 结束标记
try {
const chunk = JSON.parse(payload);
const delta = chunk.choices[0].delta;
if (delta && delta.content) onContent(delta.content);
} catch (_) { /* 残缺帧直接跳过 */ }
}
}
这个 split + pop 的组合是整个流式解析的骨架:split("\n") 把当前缓冲区切成若干完整行,最尾巴上那段"可能只有半行"的数据先塞回 buffer 等下一轮。配合 TextDecoder,无论是 UTF-8 多字节字符被拦腰截断,还是 SSE 帧粘在一起,都能正确处理。
还有两个容易被忽视的细节。第一个是 response.ok 检查:接口在鉴权失败、参数错误时会返回非 200 状态码,我一开始偷懒没检查,导致报错信息极其难懂。第二个是收尾处理:数据流结束时,buffer 里可能还残留最后一帧未解析,需要在循环结束后再补一次解析,否则会丢掉模型的最后一句话。这两个细节后来都在联调中发现并修复了。
五、系统提示词工程:让 AI 学会用 JSON 讲故事
接口通了,但真正让这个项目从"练手 demo"变成"能玩的作品"的,是系统提示词的设计。
我做的第一个决定是:AI 的每一轮输出都必须是一个严格 JSON 对象,而不是一段自由文本。 这是整个项目的分水岭。如果让 AI 自由发挥,前端就只能傻乎乎地把一大段文字贴到页面上,那这个项目就把自己降级成了一个"套了皮肤的聊天机器人"——这是我最不愿意看到的结果。
而 JSON 输出的好处是巨大的:
- 结构化:旁白是旁白、台词是台词、选项是选项,各自渲染,主次分明;
- 可计算:
player字段里的数值变化可以被程序直接结算,游戏机制才能成立; - 可控制:字段缺失、类型错误都可以在前端兜底处理,而不是看运气。
最终我设计的 JSON 协议长这样:
{
"narration": "场景旁白与氛围描述,每次 1~3 句话",
"speaker": "正在说话的角色名,无对话时为 null",
"dialogue": "该角色的台词,无对话时为 null",
"emotion": "说话角色的情绪,如 愤怒/喜悦/惊讶,无对话时为 null",
"system_message": "特殊事件提示或状态变化说明,无则为 null",
"choices": ["3 个差异化行动选项"],
"player": { "hp": -8, "energy": -5, "items": ["获得的物品名"] },
"is_over": false
}
设计这个协议时我专门琢磨了三个细节:
细节一:player 字段传增量,不传绝对值。 这是我在设计阶段踩过的思维陷阱。最初的方案是让 AI 返回玩家"当前"的完整状态(hp 100、energy 50……),但模型很可能记错或编造数值,而且多轮对话下上下文里塞满了重复的状态信息,还占了宝贵的 token 预算。改成"只传本轮增量"之后,模型只需要告诉前端这轮扣了多少血、加了什么物品,累加由前端完成,数值永远对得上。
细节二:不要用 Markdown 代码块包裹 JSON。 很多模型偏好输出 ```json ... ````,但这种包裹会给前端解析添麻烦。我在系统提示词里明确写了"禁止输出 JSON 之外的任何字符、禁止使用 Markdown 代码块包裹"。同时,前端parseTurn函数里还是保留了一个sanitizeRaw`,把偶尔漏出来的 ```剥掉——模型是概率系统,永远要对它做一层防御。
细节三:情绪要映射到视觉。 emotion 字段不仅仅是文字标签,我还做了情绪到颜色的映射表:愤怒是红色、喜悦是绿色、哀伤是紫色、恐惧是暗红……这样角色说话时,气泡的左边框和情绪标签会染上对应的颜色,"情绪"就从文字变成了画面。
为了让模型老老实实遵守协议,我把整个系统提示词写成了"游戏规则守则"的样式——开场给身份设定(你是 Game Master 与实时故事引擎),中间给硬性规则(用第二人称、每次 1~3 句话、绝不替玩家做决定、每次给 3 个选项、属性结算规则、结局机制),最后给出唯一合法输出格式的定义。规则用祈使句、动词开头,比一堆"应该""可以"的软性描述有效得多。
六、剧本设计:四个彼此独立的世界
工具链就绪之后,我开始设计"世界观"。一个角色扮演应用不能只有一个故事,那样玩家的新鲜感一次就耗尽了。我设计了四个风格迥异的剧本,放在 config.js 的 WORLDS 数组里,每个剧本都有一句话可以概括的气质:
- 武侠江湖(⚔️):夜雨客栈、蒙面客、华山论剑,恩怨情仇的中国古典武侠——目标用户是所有读过金庸古龙的人;
- 星际迷航(🚀):失联的殖民船、克苏鲁星云、AI 辅助驾驶——满足科幻控对"深空恐惧"的偏好;
- 奇幻大陆(🐉):碎裂的龙晶、七枚龙泪、地脉迷宫——经典的西方奇幻配方;
- 都市奇谈(🌆):午夜的电台、37 年前的寻人启事、旧钟楼——在都市背景里玩超自然悬疑。
每个剧本字段都精心设计过:icon 用于卡片上的视觉符号,accent 是主题色(武侠偏红、星际偏蓝、奇幻偏紫、都市偏青),tagline 是两行美术字,desc 是世界背景的引子,opening 是开局那一段足以把人拉进故事的开场白。
为了让开局有代入感,opening 我一句一句地斟酌过。比如武侠剧本的开场:
夜雨如丝,木檐下的灯笼在风中摇晃,客栈大堂只剩你与那蒙面客。他放下茶杯,低声道:「令尊的事……我已知晓。三日后便是华山论剑,仇人亦会到场。你,敢去吗?」
开场白里加了省略号的停顿、第二人称的"你"、以及一个立即抛出钩子的"敢去吗",就是要让玩家在开局第一屏就被"推"进剧情。这四个剧本不仅仅是演示素材,它们本身承担着验证系统提示词泛化能力的任务——同一个提示词,要能同时驾驭武侠、科幻、奇幻、悬疑四种文风,这本身就是对提示词质量的检验。
七、流式增量渲染:让"写"的过程被看见
接下来是前端最核心的活儿:渲染。既然拿到了流式数据,就该让它以"正在被写出来"的方式呈现在玩家眼前——旁白一个字一个字地浮现,对话在气泡里一句句地生长。这个过程我称之为「流式增量渲染」。
写这个功能之前,我先自己想清楚了它和"一次性渲染"的本质区别:流式过程中,你必须处理"半截 JSON"。模型写到一半,narration 字段还没写完,choices 数组刚开个头——这时候整个 JSON 显然是解析不出来的。
我的方案是「轮询增量 + 最终结算」双阶段:
阶段一(流式过程中): 每收到一小段文本就追加到原始缓冲区,然后用 requestAnimationFrame 节流,每隔一帧尝试解析一次当前缓冲区的 JSON。如果能解析出来,就做增量拼接——拿新解析出的 narration 长度和已经渲染的长度做差,只把多出来的那截字符补到界面节点上;dialogue 同理。这样既保证了"打字机"式的逐渐显现,又不会重复渲染,而且节流还能避免每个数据帧都做一次全量 JSON 解析的性能浪费。
渲染的顺序也有讲究:narration 累积的同时,如果检测到 dialogue 出现了非空内容,我就把对话气泡"弹"出来——气泡的左边框先染上情绪色,然后 speaker 标签和 emotion 情绪标签先定位,台词文本再逐字填入。这样玩家眼里看到的画面是:先有旁白铺底,再"蹦"出一个带名字带情绪的对话气泡,最后台词一字字出现——非常接近电影里的字幕逻辑。
阶段二(流结束后): 拿到完整文本后做一次最终解析,一次性结算这轮"写完后才该发生的事":生成三个选项按钮、结算属性变化、展示系统消息、判断是否触发结局。
这里还有一个让体验加分的细节:player 减少的属性用绿色小字浮现在回合底部,格式是"◆ -8 生命 ◆ -5 精力 ◆ 获得「生锈的钥匙」"。玩家每做一个选择,都能立刻看到自己为此付出的代价或得到的回报——数值反馈是游戏感的重要来源。
八、容错设计:模型是概率系统,前端必须免疫
任何一个和大模型打过交道的人都明白一个朴素真理:模型是概率系统,它想怎么抽风就怎么抽风。 所以在开发过程中,我把"容错"当成一项和功能并列的硬需求,而不是事后补丁。我一共做了四层防御:
第一层:状态锁。 全局用一个 busy 标志位锁住输入——AI 回复期间,选项按钮、输入框、发送按钮全部禁用,杜绝"连点导致并发请求、消息历史错乱"的问题。busy 还会改变输入框占位符:“AI 正在书写命运的篇章……”,这算是把等待变成了沉浸感的一部分。
第二层:兜底选项。 如果模型这轮输出怎么解析都是非法 JSON,前端不会崩溃,而是把原文当作旁白显示,然后强行塞给用户三个通用兜底选项:“继续探索”“仔细观察四周”“询问更多细节”——游戏继续,玩家几乎无感。
第三层:增量防御。 renderIncremental 在解析失败时直接 return,不渲染任何东西,绝不让半截 JSON 污染界面。等到流结束再交给 settleTurn 做最终结算。
第四层:增量结算的幂等性。 属性结算(applyPlayerDelta)只在回合结束时调用一次,且物品入包时做了去重判断——同一个物品不会因为模型偶尔重复返回而进两遍背包。
有了这四层防御,这个应用在真实调用中表现得相当皮实:三轮联调测试里,模型返回的 JSON 结构全部符合协议,偶尔的小瑕疵也都被前端静默消化了。
九、视觉设计:给文字一个"场"
我是一个相信"视觉是叙事的一部分"的人。文字角色扮演应用的界面,绝对不能是一块白板上糊几行字——那会让玩家毫无沉浸感。所以在样式上我花了不少心思,主题定为「深夜书房里的一盏灯」:
- 底色:深蓝紫的夜空渐变,配合三层漂浮的模糊光晕(
orb),形成一种"读者坐在深海般寂静的空间里"的观感; - 玻璃拟态:顶栏、气泡、选项按钮全部采用半透明磨砂玻璃质感,背景光晕透过来,层次感立刻就出来了;
- 衬线字体:对话气泡里的角色名 (
speaker) 用了宋体系衬线字("Songti SC", "Noto Serif SC"),和正文的无衬线体拉开层次——角色说话用的是"古书的嗓门"; - 呼吸动画:光晕缓慢漂移(
drift22 秒循环)、卡片悬停浮起、按钮带弹跳反馈(pop)、剧情块淡入——所有动画都保持在 0.2~0.5 秒区间,克制而连贯; - 打字光标:AI 正在生成时,旁白文本尾部会有一个闪烁的「▍」光标,让人明确感知"故事正在被写出来"。
我特别满意的是情绪色的映射——它是功能(emotion 字段)和视觉(EMOTION_STYLES 映射表)的一次优雅结合:愤怒 #ff7b72、喜悦 #8fe0a8、惊讶 #ffd88a……超过十五种情绪各自有专属的颜色,角色一动气,整个气泡的左边框就先"红了脸"。
十、从测试到交付:三轮联调教会我的事
代码写完只是开始,真正的考验在测试。我用 Node 写了一个联调脚本,把 config.js 和 api.js 原样加载进 vm 上下文,模拟浏览器环境跑了三轮端到端测试,每一轮都像一次小型答辩:
第一轮:开局测试。 只发系统提示词和剧本开场白,让模型"开局"。结果非常理想:流式片段共 226 段,最终输出的 JSON 八个字段一应俱全,三个选项风格各异,player 字段为空对象(此时确实没有任何数值变动)。
第二轮:多轮对话测试。 我把第一轮返回的完整 JSON 原样塞回 messages 历史,让玩家"选择了第一个选项",验证模型能否带着上下文继续推进。结果稳定:字段结构保持,三个新选项出现。
第三轮:战斗数值测试。 我设计了一个"拔剑冲向山贼"的极端场景,专门考验模型的数值结算能力。模型返回了 {"hp":-8,"energy":-5,"items":[]}——完美符合"增量"协议。这一轮是最让人安心的:它证明 AI 真的读懂了"hp 是增量"这个规则,并且会在剧情需要的时候严格执行。
三轮测试通过的当晚,我又做了一件更认真的事:用 Playwright 在真实 Chromium 浏览器里完整跑了一遍用户旅程——打开页面、选择「武侠江湖」、等待 AI 开局、点击选项、推进到第二轮。结果:API 两次请求全部 HTTP 200,控制台零报错,旁白、对话、情绪标签、系统消息、三选项、状态面板全部按预期渲染。当自动化测试脚本把"第二轮对话中情绪标签从『低沉』自动变成『肃然』“的截图摆在面前时,我知道这个项目"稳了”。
十一、踩坑实录:那些差点让我放弃的 Bug
写这个项目的十几个小时里,有价值的坑大概有四个,每一个都值得记一笔:
坑一:流式收尾丢字。 这是流式解析最常见的坑。第一次联调时我发现模型回复的最后一句话总是缺几个字——原因就是数据流结束时 buffer 里残留的半帧数据没人处理。修复方式是循环结束后再补一轮解析。教训:凡是"流",就一定有"尾",收尾必须显式处理。
坑二:git init 在错误目录。 严格说这不是代码 bug 而是工程教训。我最初在 /workspace(项目父目录)执行了 git init,把环境目录和项目目录一起提交了,导致提交内容混乱。教训:先 pwd 看准目录再初始化仓库,commit 之前务必 git status 和 git ls-files 核对文件清单。
坑三:选项连发导致消息历史错乱。 早期版本没有 busy 状态锁,玩家在 AI 回复期间狂点选项按钮,会导致多个请求并发、历史消息顺序错乱。教训:UI 的可用性边界,要用状态机去锁,而不能指望用户"手稳"。
坑四:JSON 解析的循环引用。 这个并不是本项目踩的,但我在设计 renderIncremental 的增量拼接时反复提醒自己要避免用"整块替换"的暴力方案——那会带来光标的跳动和视觉闪烁。教训:流式渲染的核心不是"写",而是"只写新增的那一点"。
十二、回望与前瞻:一个项目能走到多远
「字境漫游」目前已经是一个完成度相当高的作品:四大剧本、流式渲染、结构化输出、属性结算、结局机制、容错降级、移动端适配,仓库里躺着的是一份可以随时演示、随时传播的网页应用。但回望整个项目,我认为它最大的价值不在于功能的堆叠,而在于它验证了一个在今天格外重要的工程命题:大模型接口和前端工程之间,可以建立一条"结构化协议"的通道。
过去我们接入大模型,习惯性地把它当"文本生成器":丢一段 prompt 进去,接收一段文本出来。但「字境漫游」的做法是:先用一套严格定义的 JSON 协议向模型"招标",再用流式解析配合增量渲染把协议"落地"成体验。 模型从"说话者"变成了"数据的生产者",前端从"展示文本"变成了"解读结构、渲染体验"。这种协作模式,其实可以迁移到大量应用场景:AI 驱动的表单填写、AI 驱动的数据看板、AI 驱动的流程图生成……只要协议定义得足够清晰,大模型的想象力就能被装进任意一个应用程序的骨架里。
至于这个项目本身,我脑子里已经列了长长的 roadmap:
- 多剧本扩展:剧本目前只是一层配置,新增一个世界只需在
WORLDS数组里加一个对象,我已经打算把更多题材(克苏鲁、宫斗、侦探、日式奇幻)添进去; - 存档系统:用
localStorage把消息历史和玩家属性存到本地,下次打开还能继续上次的冒险; - 多结局成就:在系统提示词里引入"成就徽章"字段,让玩家收集一个个隐藏结局;
- 角色捏脸:允许玩家自定义自己的角色名、性别、初始属性,把"你"变成"我的那个角色";
- 音效与动效:打字音效、雨夜白噪音、翻页效果,把沉浸感再拉高一档。
十三、结语:文字是一个永远不过时的世界
在动辄谈论"多模态"“3D”“元宇宙"的今天,做一个纯文字的应用,多少显得有些朴素。但我恰恰认为,文字是想象力密度最高的媒介——一行"夜雨如丝,木檐下的灯笼在风中摇晃”,每个读者的脑海里都会浮现出各自不同的画面;而这份"个人化"的想象空间,正是游戏感和沉浸感最珍贵的来源。
「字境漫游」把这份朴素发挥到了极致:它没有一张插画、没有一段音效,只有一个会讲故事的 AI、三个会发光的选择按钮,和一双愿意相信故事的眼睛。当模型用流式输出把第二回合的开头写到屏幕上时,那不只是"返回了一段文本"——那是一场冒险的开始。
如果你也对"让 AI 讲故事"这件事感兴趣,欢迎打开我的仓库页面,把 index.html 扔进任意静态服务器,选一个世界,按下开始。愿你在字境之中,也遇见一个值得奔赴的远方。
*本文首发于码道,记录「字境漫游」项目的设计与实现。技术栈:HTML5 + CSS3 + JavaScript;AI 引擎:GitCode 平台 deepseek-ai/DeepSeek-V4-Flash,SSE 流式输出。
更多推荐





所有评论(0)