上一篇博客我们立了个 flag:要把 AI 大模型做成一个「看得见、摸得着、玩得起来」的小东西。今天它来了。这篇文章不聊宏大叙事,只聊一个具体的、可以立刻打开浏览器玩起来的项目——《数语 · 猜数字 AI 对话》。它没有后端、没有构建工具、没有框架依赖,只有一个 HTML 文件、一个 CSS 文件和两个 JS 文件,却完整地跑通了大模型流式对话、结构化数据协议、游戏状态管理和沉浸式 UI 的全流程。
仓库地址:https://atomgit.com/2501_93821753/Shuyuai_Caishuzi.git
在这里插入图片描述

码道项目生成:
在这里插入图片描述


一、项目缘起:为什么要做「数语」?

在过去的几个月里,大语言模型的 API 变得越来越平民化。无论是 OpenAI 兼容协议还是各大云厂商的自研接口,本质上都是「你发给它一串消息,它还给你一段文本」。但很多时候,我们拿到 API 之后却不知道能做什么——写个聊天机器人?太普通了。做个问答工具?满大街都是。

我想做一个有状态的、有规则约束的、AI 只是其中一环而不是全部的产品。猜数字游戏就是这样一个绝佳载体:

  1. 规则清晰:1~100 之间随机选一个神秘数字,玩家有 10 次机会,AI 只能给「高了 / 低了」的方向提示。
  2. 有天然的状态机:游戏中 / 胜利 / 失败三种状态,剩余次数、可猜范围、猜测历史都需要持续维护。
  3. AI 需要"既聪明又克制":它必须知道答案,却又不能直接说出来,只能在规则允许的范围内给提示——这对系统提示词的设计提出了很高的要求。
  4. 交互天然是对话式的:自然的聊天界面就是最好的产品形态,不需要额外发明交互范式。

于是「数语」诞生了。它的名字有两层含义:一是"数字的语言",二是"数(shǔ)与 AI 对话"。项目的愿景很简单——让 AI 不只是一个答案生成器,而是一个懂得规则、懂得开玩笑、记得住上下文的游戏主持人。

二、整体设计:产品功能与用户体验

在动手写代码之前,我先想清楚了产品长什么样。一个猜数字 AI 游戏,需要同时展示哪些信息?我列了一张清单:

  • 聊天气泡区:玩家与 AI 的对话主战场,AI 的回复要有「人味」;
  • 剩余次数:用环形进度条直观呈现,比单纯的数字更有游戏感;
  • 可猜范围:根据历史记录实时收缩的 1~100 区间,比如猜了 50 被告知"低了",范围就变成 51~100;
  • 猜测历史:每条记录都带「低了 / 高了 / 命中」徽章,让玩家一眼看懂自己的推理轨迹;
  • 游戏状态徽章:游戏中 / 胜利 / 失败三种状态清晰可见;
  • 快捷数字:提供 50、25、75 等二分法常用的推荐数字,一键发送;
  • 重新开始:随时开启新一局,清空所有状态。

在产品设计上我还刻意加了一个"意外惊喜":玩家随时可以跟 AI 闲聊。比如你可以问"给我讲个冷笑话"“给个提示呗”,AI 会在不泄露答案的前提下陪你唠嗑,然后自然地把话题拉回游戏。这个设计让「数语」从"一个猜数字工具"升格为"一位有性格的数字主持人"。

UI 风格上,我选择了暗色玻璃拟态(Glassmorphism):半透明卡片、模糊背景、渐变光晕、圆角大按钮。不是为了炫技,而是因为暗色主题下聊天产品的对比度高、沉浸感强,玻璃拟态又能让状态面板、聊天区、输入区在同一个视觉平面上自然分层。

三、技术选型:为什么是 HTML5 + CSS3 + JavaScript

这个项目最"反直觉"的决定是:不用 React、不用 Vue、不用任何构建工具,甚至不用 npm。

理由其实很朴素:

  1. 零依赖、零构建:git clone 下来就能跑,打开就是全部。对于教学演示类项目,这是最友好的形态;
  2. 游戏规模有限:状态只有一份 gameState 对象,DOM 节点最多几十个,原生 DOM 操作完全 hold 得住,引入框架反而是负担;
  3. 浏览器原生能力已经足够:fetch + ReadableStream 足以实现 SSE 流式解析,IntersectionObserver、CSS 变量、@media 查询覆盖了所有交互与响应式需求;
  4. 可读性:所有逻辑集中在一个 app.js 里,注释清晰、函数职责单一,无论是入门者还是想复用的开发者,都能快速读明白。

当然,原生方案也有代价:所有状态渲染都要手动操作 DOM,代码量会比框架版本多 30% 左右;没有虚拟 DOM,频繁更新时需要留意性能。但对于这个体量,这些代价都可以接受。技术选型不是越重越好,而是与问题规模相匹配。

四、灵魂所在:把系统提示词改造成结构化 JSON 协议

如果这个项目有一点值得反复咀嚼的设计,那就是让 AI 每轮只输出一个合法的 JSON 对象。这是「数语」区别于普通 LLM 聊天 Demo 的关键。

4.1 为什么需要结构化输出?

默认情况下,大模型的输出是自由文本。它会说"我觉得可能是 37,因为你的范围在 30 到 50 之间……",但前端很难从这段话里稳定地提取出"result=high、attempts=3、rangeMin=31、rangeMax=50"这些结构化数据。

状态面板、历史记录、环形进度条都需要精确的数字来驱动。如果让前端用正则表达式从自然语言里抠数字,那代码会变成一个脆弱不堪的"文本考古现场"。

解决方案就一句话:把协议写进系统提示词,让 AI 自己承诺"每轮只吐 JSON"。

4.2 JSON 协议字段设计

我们为 AI 定义了如下返回协议:

{
  "reply": "给玩家的自然语言回复,含方向提示",
  "guess": 50,
  "result": "low | high | correct | null",
  "gameState": "playing | won | lose",
  "attempts": 3,
  "turnsLeft": 7,
  "rangeMin": 51,
  "rangeMax": 100,
  "answer": null,
  "history": [[50, "low"], [25, "high"]]
}

逐个字段说说设计意图:

  • reply:聊天气泡里展示的自然语言回复,是玩家唯一"直接看到"的内容,必须活泼、有游戏感;
  • guess / result:本轮猜测的数字与比较结果。result 只有三个取值——low(低了)、high(高了)、correct(命中),绝不允许 AI 自由发挥;
  • gameState:游戏状态机,playing / won / lose 三态。因为 AI 才是"知道答案的人",只有它才能判定胜负;
  • attempts / turnsLeft:已猜次数与剩余次数。由 AI 根据对话历史自行累加——这是把状态"外包"给大模型的做法,后面会详细讨论它的利弊;
  • rangeMin / rangeMax:当前可猜范围。这是二分的核心反馈,前端直接渲染成"神秘数字范围 51 ~ 100";
  • answer:仅在 won / lose 时公布真实答案,其他时候必须为 null——这是防止 AI"手滑泄露天机"的保险丝;
  • history:完整猜测记录数组,元素为 [数字, 结果] 的二元组,前端据此渲染历史徽章列表。

这个协议的设计原则是:前端需要的每一个渲染输入,都必须在 JSON 里有明确的对应字段;AI 能自由发挥的,只有 reply 这个文本字段。 结构化边界越清晰,渲染层就越简单、越稳定。

4.3 系统提示词的艺术:规则、格式与状态保持

写这个 SYSTEM_PROMPT 的过程,比写业务代码更费神。我把它拆成三个部分,每一部分都有明确的写作策略:

第一部分:游戏规则。用分点编号的方式把规则写死——“内心随机选定 1~100 的整数”“共 10 次机会”“游戏进行中绝不能直接说出答案”。大白话 + 编号,模型的遵循度最高。

第二部分:输出格式(最重要)。这里用了强调句式"必须严格遵守",并给出完整的 JSON schema 说明,逐字段标注取值范围与语义。为什么这段要写得像"法律条文"?因为大模型对格式指令的服从度直接决定了前端渲染的稳定性。实验中发现,如果只写"返回 JSON",模型偶尔会用 markdown 代码块包裹、偶尔会多说一句"好的!",所以提示词里还要明确禁止:“不允许有任何额外文字、解释、问候或 markdown 代码块包裹”。

第三部分:状态保持要求。这是最微妙的设计。我要求 AI"根据完整对话历史持续记忆神秘数字、已猜次数和猜测记录"。第一轮输入时返回初始状态(attempts=0、turnsLeft=10、范围 1~100);闲聊时保持状态不变;输入非法数字时提醒规则但不消耗次数。

这里有三个值得分享的坑:

  1. AI 的计数器可能不可靠。把次数累加完全托付给模型,意味着极端情况下它可能数错。我们做了兜底:前端在 applyState 时对 turnsLeft、rangeMin、rangeMax 全部做了 clamp(钳制到合法区间),即使 AI 返回了离谱值,页面也不会崩。
  2. 务必给 turnsLeft 的语义定义清楚:“上限 10,答对或失败后为 0”。有些模型会把"剩余次数"理解成"已用次数",语义含糊会导致 UI 恰好倒过来。
  3. 闲聊想拉回游戏需要显式提示。一句"把话题自然拉回游戏",比十行具体的闲聊示例更有效。

五、从 Python 到 JavaScript:一次 SSE 流式解析的移植

项目的 AI 调用逻辑最初是一份 Python 参考代码,基于 requests 库流式读取响应。把它移植到浏览器,是整个开发过程中技术含量最高的部分。

5.1 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]" or line.strip() == b"data: [DONE]":
            return
        yield json.loads(line.decode("utf-8").lstrip("data:").rstrip("/n"))

它的核心思想很清晰:

  1. stream=True 让 requests 保持连接、边收边读;
  2. 逐行遍历响应体,只处理以 data: 开头的行(SSE 事件的标准前缀);
  3. 遇到 data:[DONE] 就结束流;
  4. 其余每行去掉 data: 前缀后 json.loads,得到完整的 SSE 事件对象。

大模型 SSE 流的一个特点是:每个事件里往往只携带一小段增量文本,放在 choices[0].delta.content 里。真正面向用户的完整回复,是把所有增量拼接起来的结果。所以我不能把每个事件直接展示给用户,而是先把增量累积成 fullContent,流结束时再做一次整体处理。

5.2 浏览器侧的等价格改造:fetch + ReadableStream

Python 的 iter_lines() 在浏览器里没有直接等价物。浏览器端的标准做法是:

const resp = await fetch(API_URL, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${API_KEY}`,
  },
  body: JSON.stringify(payload),
});

const reader = resp.body.getReader();       // 拿到 ReadableStream
const decoder = new TextDecoder("utf-8");
let buffer = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  let lines = buffer.split("\n");
  buffer = lines.pop();                     // 最后一段可能是不完整的行,留到下一轮

  for (const raw of lines) {
    const line = raw.trim();
    if (!line.startsWith("data:")) continue;
    const data = line.slice(5).trim();
    if (data === "[DONE]") return;
    try {
      const json = JSON.parse(data);
      const delta = json.choices?.[0]?.delta?.content || "";
      if (delta) onDelta(delta);
    } catch (e) {
      /* 忽略无法解析的中间行 */
    }
  }
}

这里有三处移植时必须注意的细节:

  1. 分块边界:reader.read() 返回的 Uint8Array 是任意切分的,一行 SSE 数据可能被切成两半。所以必须维护一个 buffer,用 split("\n") 后把最后一段留到下一轮继续拼——这就是"增量解码"的核心技巧。
  2. TextDecoder 的流模式:decoder.decode(value, { stream: true }) 必须传 { stream: true },否则多字节 UTF-8 字符(比如中文)恰好跨越两个 chunk 时会被错误截断成乱码。这个坑非常隐蔽,但一旦对了,中文回复就永远不出现"�"。
  3. [DONE] 的判定:参考代码里同时兼容了 data:[DONE] 和 data: [DONE] 两种写法,因为不同网关的空格处理习惯不同。移植时我用 data === "[DONE]"(先 trim() 再去前缀),等价且更稳健。

5.3 流式 UI 的体验细节

有了流式解析,前端就能做"打字机效果"了——AI 回复逐字出现,而不是等全部生成完才一次性显示。这在产品层面极大提升了"AI 在思考"的真实感。我还加了三点跳动动画的"typing"气泡:请求发出后立刻出现,流结束时移除,完美填补了网络等待期的视觉空白。

六、前端架构与状态管理

虽然没有框架,但代码结构仍然遵循清晰的分层:

Shuyuai_Caishuzi/
├── index.html          # 页面结构(状态面板 + 聊天区)
├── css/
│   └── style.css       # 暗色玻璃拟态主题样式
├── js/
│   ├── config.js       # AI 接口配置(URL / Key / 模型 / 系统提示词)
│   └── app.js          # 游戏逻辑:流式请求、JSON 解析、状态渲染
└── README.md

config.js 与 app.js 的职责分离是刻意的:

  • config.js:只放配置——API 地址、密钥、模型名、采样参数(temperature、top_p、max_tokens、frequency_penalty、thinking_budget)以及最核心的 SYSTEM_PROMPT。换模型、换 Key、改提示词,都只动这一个文件,完全不碰业务代码;
  • app.js:纯业务逻辑,用一个 IIFE 包裹避免全局污染,"use strict" 开启严格模式。

状态管理上,我维护了一份前端镜像状态 gameState:

let gameState = {
  state: "playing",
  attempts: 0,
  turnsLeft: 10,
  rangeMin: 1,
  rangeMax: 100,
  answer: null,
  history: [],
};

每次 AI 返回 JSON 后,applyState(data) 会把 AI 的输出吸收进这份镜像,再做防御性钳制(clamp),最后统一 renderPanels() 刷新所有面板。这样形成了一条单向数据流:

用户输入 → 拼接 messages → 流式请求 AI → 解析 JSON → applyState(清洗+钳制) → renderPanels → 渲染 DOM

所有 UI 更新都收敛在 renderPanels() / renderHistory() 两个函数里,排查问题时思路非常清晰。

messages 数组则是发给 AI 的完整对话上下文,遵守 OpenAI 兼容协议的惯例:system 提示词永远在第一位,之后是 user / assistant 交替。注意一个细节:我们 push 给 assistant 的完整内容是 fullContent(AI 的原始输出),而 UI 上展示的是解析出来的 reply,两者并不相同——这是"协议数据"与"展示数据"分离的典型例子。

七、UI/UX:暗色玻璃拟态的设计语言

视觉部分我倾注了不少心思,简单讲讲设计语言:

  • 配色:深空蓝灰底色(#0f1420 一类),主色用蓝紫渐变(#6c8cff → #a78bfa),点缀暖色徽章(低了=蓝、高了=红、命中=金),既符合"数字游戏"的科技感,又保证状态可读性;
  • 玻璃拟态:卡片用 backdrop-filter: blur() + 半透明背景 + 1px 高光描边,让面板产生"悬浮在背景之上"的层次感;背景里再铺一层径向渐变光晕,视觉不单调;
  • 环形进度条:用 SVG <circle> 的 stroke-dasharray / stroke-dashoffset 实现剩余次数消耗动画,RING_LEN = 326.7 是圆周长的精确值,保证进度从满到空平滑变化;
  • 聊天气泡:AI 与玩家的气泡左右分列,头像用「语」「玩」两个中文字代替图标,简洁又有辨识度;AI 的回复里还可以带一个 result-tag 徽章(“📉 低了 / 📈 高了 / 🎯 猜中啦!”),这是渲染层根据 result 字段自动附加的;
  • 响应式:桌面端左右两栏(侧边状态面板 + 聊天区),窄屏下自动堆叠为上下布局,@media 断点控制在移动端也能玩。

一个细节值得一提:聊天区更新后必须手动 scrollToBottom(),否则流式输出时滚动条会一直钉在顶部,玩家就看不到最新内容了。这类"小体验点"写进代码只需要一行,但忘了它整个聊天体验都会很别扭。

八、开发中遇到的坑与解决方案

回顾整个开发过程,把踩过的坑和对应的解法整理如下,希望后来者少走弯路:

坑 1:file:// 协议下请求被 CORS 拦截。 直接双击 index.html 打开,fetch 会被浏览器拦截,控制台报跨域错误。解法:用 python3 -m http.server 8080 或 npx serve . 起本地服务器,并在 README 里显著标注。这是一个"教程里没人讲、但 80% 新手会撞上"的坑。

坑 2:AI 偶尔输出不是纯 JSON。 即使系统提示词三令五申"只输出 JSON",模型仍可能偶发地包裹 ```````json ````代码块,或者前后带一点杂音。解法:前端 parseAIJson 做多层容错——先剥离代码块围栏,再截取首个 { 到末尾 } 之间的内容,最后才 JSON.parse。三重保险之后,解析成功率接近 100%。万一还是失败,就展示原始文本便于排查,并且把这条消息从 messages 里弹出去,避免脏上下文污染后续对话。

坑 3:分块边界导致 SSE 半行。 前面讲过,靠 buffer 留尾处理。这是流式解析的必修课。

坑 4:AI 状态计数漂移。 模型偶尔会把 attempts 数错(尤其是玩家大量闲聊之后)。解法:前端做不到"纠正 AI",但可以把 turnsLeft 交给前端主导——注意我们的实现里 applyState 会用 clampNumber 把 turnsLeft 钳制在 [0, 10],范围钳制在 [1, 100],绝不让一个脏数据打穿 UI。

坑 5:游戏结束后还能继续发消息。 如果不加控制,玩家胜利后输入框依然可用,AI 会陷入"我该说什么"的尴尬。解法:sendMessage 的 finally 里检测 gameState.state !== "playing" 就禁用输入框与快捷数字,引导玩家点「重新开始」。

九、游戏逻辑与完整玩法串讲

把视角切回玩家,完整体验是这样流动的:

  1. 打开页面即自动开始新一局,AI 通过首条消息宣告"我已悄悄选定一个神秘数字";
  2. 玩家输入 50,AI 流式回复"嗯……这个数字有点大哦"并返回 result: "high",状态面板把范围收缩到 1~49,环形进度走了 1/10,猜测记录出现一条"50 · 高了";
  3. 连续二分,第 6 次猜中,AI 返回 gameState: "won" 并公布 answer,徽章变成"🎉 胜利",输入框锁定;
  4. 中途随时可以冲 AI 喊"给个提示嘛",AI 会若无其事地聊两句再拉回正题——但绝不会直接说出答案。

二分法策略下,运气最差的人也能在 7 次内猜中,10 次机会其实是宽裕的。但如果你一开始就 1、2、3……挨个数上去,AI 会毫不留情地嘲笑你。这就是游戏张力所在。

十、安全与部署注意事项

作为纯前端项目,有一个绕不开的隐患:API Key 直接暴露在 js/config.js 里,任何打开页面的人都能看到。README 里我郑重地写了安全提示:

  • 在 GitCode AI 控制台为 Key 配置浏览器域名白名单,只允许你的站点域名调用;
  • 更稳妥的做法是改造为后端代理架构:前端请求自己的后端,由后端持有 Key 转发请求;
  • 永远不要把生产 Key 提交到公开仓库。

对教学 Demo 来说,前端直连可以接受,但安全意识必须从第一天建立。这也是我在 README 里单独开一节写安全提示的原因。

部署方面,因为是纯静态站点,任意静态托管(GitHub Pages、Vercel、对象存储静态网站等)都能直接上线,没有任何服务器成本。

十一、不足与未来展望

诚实地说,这个项目还有不少可以改进的地方:

  1. 状态可靠性的终极方案:目前把计数器托付给模型,虽然有 clamp 兜底,但理想做法是前端权威计次——前端自己维护次数和范围,通过提示词把"权威字段"通知给 AI,让 AI 只负责 reply 和 result。这是下一步最值得做的重构;
  2. 后端代理:把 Key 收口到一个小型 Node/Python 服务,顺便还能做并发限流与账单统计;
  3. 更多游戏变体:比如"猜历史人物"“猜成语”“猜城市”,把「数语」的平台能力抽象成"结构化 JSON 对话游戏引擎",换一个 SYSTEM_PROMPT 就是一个新游戏;
  4. 多轮记忆的持久化:目前上下文只存在于页面的 messages 数组里,刷新即失。可以接 localStorage 保存对局,甚至把历史战绩做成排行榜;
  5. 语音交互:用 Web Speech API 让玩家用嘴猜数字,AI 用语音回复——沉浸感直接翻倍。

每一个方向都不复杂,但都能带来体验质变。

十二、写在最后

做「数语」这个项目,我最深的体会是:大模型 API 的价值不在于"生成文本",而在于"被巧妙地约束和编排"。一份精心设计的系统提示词,能让 AI 从"话痨百晓生"变成"严守规则的游戏主持人";一份结构清晰的 JSON 协议,能把模型的无限自由裁量压缩进少数几个字段的合法取值里;而 SSE 流式解析,则让这一切以打字机的节奏呈现在玩家眼前。

从 Python 参考代码到浏览器原生实现,从系统提示词的艺术到玻璃拟态的打磨,从状态机的严谨到容错设计的周全——这个项目把 AI 应用开发里几乎所有基础技能都串了一遍:协议设计、流式处理、状态管理、防御式编程、UI/UX、安全意识和文档工程。

如果你也想动手做一个"AI 不是主角但缺它不可"的小项目,不妨从克隆这个仓库开始,把 SYSTEM_PROMPT 改成你自己的规则,看看它会长出什么有趣的模样。

数语项目开源地址:https://atomgit.com/2501_93821753/Shuyuai_Caishuzi

欢迎 Star、提 Issue、或者告诉我你用它改出了什么新游戏。码道漫漫,我们下一篇见。


「数语 · 猜数字 AI 对话」——让 AI 成为你今晚的游戏搭子。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐