码道 | AI成语大闯关:从Python到JavaScript的AI对话 游戏开发全记录
码道 | AI成语大闯关:从Python到JavaScript的AI对话游戏开发全记录
一篇关于如何用纯前端技术栈(HTML5 + CSS3 + JavaScript)构建一个由 DeepSeek AI 实时驱动的成语闯关游戏的技术博客。涵盖 AI 接口的流式调用、系统提示词 JSON 协议设计、游戏状态机、古风 UI 主题以及从 Python 参考代码到 JavaScript 的完整转换过程。


一、项目缘起:当成语遇上 AI
成语是中华文化的瑰宝,短短四个字背后往往藏着千年典故。然而,传统的成语学习方式——翻字典、背释义——实在乏味。我们想做一个游戏:让 AI 当主持人,按难度出题,玩家根据释义猜成语,答对晋级、答错学习,闯关过程中不知不觉积累成语知识。
这个想法的核心挑战在于:如何让一个纯前端页面(无后端)直接与 AI 大模型对话,并且让 AI 的输出可以被 HTML 精准渲染? 答案是两个关键词——流式 SSE 调用和系统提示词 JSON 协议。
项目最终交付的是一个零依赖、纯静态的网页应用,打开浏览器即可游玩,无需安装任何东西,也无需后端服务器。AI 接口调用直接在浏览器中通过 fetch 完成,流式响应通过 ReadableStream 实时解析,系统提示词约束 AI 始终输出结构化 JSON,前端据此渲染题目卡片、评判结果和结算面板。
二、技术选型:为什么是纯前端
在项目立项阶段,我们考虑过几种方案:
方案 A:前后端分离(Node.js 后端 + React 前端)。后端代理 AI 请求,前端只负责展示。优点是 API 密钥不暴露在前端,安全性好。缺点是需要部署后端服务,运维成本高,对于教学项目来说过于沉重。
方案 B:纯前端直连 AI 接口。浏览器直接 fetch 调用 AI API,密钥内嵌在配置文件中。优点是零部署、零依赖,一个静态文件服务器即可运行。缺点是密钥暴露(但这是教学项目,可接受),且需要处理 CORS 跨域问题。
方案 C:Serverless 函数代理。用 Vercel/Cloudflare Functions 做一层薄代理。介于 A 和 B 之间,但引入了平台依赖。
最终我们选择了方案 B,理由有三:
- 教学项目的核心目标是学习,代码越简单、链路越短,学生越容易理解每一行代码的作用。
- GitCode AI 接口已验证放行 CORS,浏览器直连没有跨域障碍。
- 零部署意味着零运维,学生只需
python3 -m http.server就能本地运行,降低使用门槛。
技术栈确定为:HTML5 + CSS3 + 原生 JavaScript(ES6+),不使用任何框架或构建工具。这不是因为框架不好,而是因为在这个项目中,原生 JS 完全够用,且代码透明可读——每一行逻辑都摆在明面上,没有编译步骤、没有 node_modules 黑箱。
三、AI 接口调用:从 Python 到 JavaScript
3.1 Python 参考代码
GitCode 平台提供的参考实现是 Python 版的,使用 requests 库进行流式调用:
import os
import requests
import json
API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer zA2e1L1La_3q_KWWRFkuLw-6",
}
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"))
chunks = query({
"model": "deepseek-ai/DeepSeek-V4-Flash",
"messages": [
{
"role": "user",
"content": "告诉我一个有关宇宙的有趣事实?"
}
],
"stream": True,
"max_tokens": 2048,
"temperature": 0.6,
"top_p": 0.95,
"frequency_penalty": 0,
"thinking_budget": 2048
})
for chunk in chunks:
print(chunk["choices"])
这段代码的核心逻辑是:发送一个 POST 请求,设置 stream=True,然后逐行读取 SSE(Server-Sent Events)格式的响应。每行以 data: 开头,后面跟着一个 JSON 对象;当遇到 data: [DONE] 时表示流结束。Python 的 requests.iter_lines() 天然支持逐行迭代,yield 让这个函数变成一个生成器,调用方可以逐块处理。
3.2 JavaScript 转换:fetch + ReadableStream
JavaScript 没有 requests 库,但 fetch API 配合 ReadableStream 可以实现完全等价的流式读取。转换后的核心代码位于 js/api.js:
const resp = await fetch(AI_CONFIG.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + AI_CONFIG.apiKey
},
body: JSON.stringify({
model: AI_CONFIG.model,
messages: messages,
stream: true,
max_tokens: AI_CONFIG.maxTokens,
temperature: AI_CONFIG.temperature,
top_p: AI_CONFIG.topP,
frequency_penalty: 0,
thinking_budget: AI_CONFIG.thinkingBudget
}),
signal: controller.signal
});
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let fullText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nlIndex;
while ((nlIndex = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nlIndex).trim();
buffer = buffer.slice(nlIndex + 1);
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") break;
const chunk = JSON.parse(payload);
const delta = chunk.choices?.[0]?.delta;
if (delta?.content) {
fullText += delta.content;
}
}
}
逐行对照 Python 与 JavaScript 的实现:
| Python | JavaScript | 说明 |
|---|---|---|
requests.post(..., stream=True) | fetch(url, { method: "POST" }) + resp.body.getReader() | 发起流式请求 |
response.iter_lines() | reader.read() 循环 + buffer.indexOf("\n") 切行 | 逐行读取 SSE |
line.startswith(b"data:") | line.startsWith("data:") | 过滤 SSE 数据行 |
line.strip() == b"data: [DONE]" | payload === "[DONE]" | 检测流结束标记 |
json.loads(line.decode("utf-8").lstrip("data:")) | JSON.parse(line.slice(5).trim()) | 解析 JSON 数据块 |
chunk["choices"] | chunk.choices[0].delta | 提取增量内容 |
yield 生成器 | onText / onDone 回调 | 逐块传递数据 |
JavaScript 版本还额外处理了 Python 版本没有的两个问题:
第一,reasoning_content 思考字段的过滤。 DeepSeek-V4-Flash 模型在流式输出时会先产生 reasoning_content(思考过程),然后才输出 content(正式回答)。Python 版只是 print(chunk["choices"]),把所有内容都打印出来。但在我们的游戏中,思考过程不应该展示给玩家——玩家只需要看到题目和评判结果。因此 JavaScript 版做了区分:
if (delta.reasoning_content) {
if (onThink) onThink(true); // 通知 UI 显示"思考中"动画
}
if (delta.content) {
if (onThink) onThink(false); // 通知 UI 隐藏"思考中"动画
fullText += delta.content; // 只累积正式内容
}
第二,AbortController 请求取消。 Python 版没有取消机制。但在浏览器中,用户可能在 AI 还在响应时点击了其他按钮(比如换题、重新开始),此时需要中止正在进行的请求。JavaScript 版通过 AbortController 实现:
const controller = new AbortController();
this.activeController = controller;
// ... fetch 中传入 signal: controller.signal
// 取消时:
this.activeController.abort();
3.3 SSE 流式解析的缓冲区管理
这是整个转换中最容易出错的地方。Python 的 requests.iter_lines() 是一个高层抽象——它自动处理了 TCP 数据包边界与 SSE 行边界不一致的问题。你拿到的每一行都是完整的 SSE 行。
但 JavaScript 的 reader.read() 返回的是原始字节块,一个块可能包含半行、一行或多行。比如:
// 第一次 read() 返回:
data: {"choices":[{"delta":{"content":"你好"}}]}\n
data: {"choices":[{"delta":{"content":"世界"}}]}\n
data: {"choi
// 第二次 read() 返回:
ces":[{"delta":{"content":"!"}}]}\n
data: [DONE]\n
如果不做缓冲区管理,直接按 \n 切分,第三个 data: 行就会被截断,JSON.parse 会失败。
解决方案是维护一个 buffer 字符串,每次 read() 的数据追加到 buffer 末尾,然后从 buffer 中取出所有完整的行(以 \n 结尾),剩余的不完整部分留在 buffer 中等待下一次 read():
buffer += decoder.decode(value, { stream: true });
let nlIndex;
while ((nlIndex = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nlIndex).trim();
buffer = buffer.slice(nlIndex + 1);
// 处理完整的 line...
}
decoder.decode(value, { stream: true }) 中的 stream: true 参数也很关键——它告诉 TextDecoder 这是一个流式解码,可能存在不完整的多字节字符(比如 UTF-8 中文字符占 3 字节,如果第一次 read 恰好在字符中间截断,decoder 会暂存不完整的字节,等下次 read 时拼接)。如果不加这个参数,中文字符可能会出现乱码。
四、系统提示词工程:JSON 协议驱动前端渲染
4.1 为什么需要 JSON 协议
这是本项目最核心的设计决策。用户的需求明确提到:“需要将系统提示词改成本项目所需要的 json 数据返回格式,让 HTML 更好的显示。”
如果让 AI 自由输出自然语言,前端要从中提取题目、选项、答案等信息会非常困难——你需要写复杂的正则或二次 AI 调用来解析。而且自然语言输出的格式不稳定,每次都不一样,前端渲染逻辑无法固定。
解决方案是:通过系统提示词约束 AI,让它每次只输出一个合法的 JSON 对象。前端拿到 JSON 后,直接按字段渲染——hint 渲染到题目卡片,score 渲染到分值标签,explanation 渲染到解析区域。格式固定、解析可靠、渲染精准。
4.2 JSON 协议设计
我们定义了四种消息类型,每种对应游戏中的一个阶段:
出题(question)——AI 作为主持人,根据当前关卡难度出一道成语题:
{
"type": "question",
"level": 1,
"hint": "比喻做了多余的事,反而弄巧成拙",
"tip": "4个字,首字拼音首字母为 h",
"deadline": 30,
"score": 10,
"answer": "画蛇添足"
}
hint 是给玩家看的释义提示,tip 是额外线索(字数、首字母等),deadline 是限时秒数,score 是本关分值,answer 是正确答案——但这个字段只用于后台裁判比对,绝不展示给玩家。系统提示词中明确要求:“hint 不得泄露答案用字”。
评判(judge)——玩家提交答案后,AI 作为裁判判断对错:
答对时:
{
"type": "judge",
"correct": true,
"answer": "画蛇添足",
"right_answer": "",
"explanation": "语出《战国策·齐策二》,比喻做多余的事反而弄糟事情",
"retry": false
}
答错时:
{
"type": "judge",
"correct": false,
"answer": "马马虎虎",
"right_answer": "画蛇添足",
"explanation": "语出《战国策·齐策二》...",
"retry": true
}
retry 字段告诉前端是否允许重试。当重试次数超过上限时,前端会提示玩家"换一题"。
结算(end)——通关后 AI 给出整局总结:
{
"type": "end",
"message": "恭喜通关!你的成语功底令人钦佩!",
"total_score": 180,
"max_level": 10,
"title": "一代宗师"
}
错误兜底(error)——AI 无法正常完成任务时:
{
"type": "error",
"message": "本次出题失败,请点击重试"
}
4.3 系统提示词的实现
系统提示词位于 js/config.js,是一段精心编排的多行字符串:
systemPrompt: [
"你是「AI成语大闯关」游戏的主持人、出题人与裁判,玩家是闯关者。",
"",
"铁律:",
"1. 你每次回复只能输出一个合法 JSON 对象,不允许输出 JSON 之外的任何文字、标点或 markdown 代码块,更不能使用```json包裹。",
"2. 输出必须能被 JSON.parse 正常解析。",
"",
"玩家消息分五类,据此回复:",
"- 「开始游戏」或「new_game」:按第1关出题。",
"- 「下一关」或「next_level」:按下一关(更高难度)出题。",
"- 「换一题」或「retry」:保持当前关卡,重新出一题,题目不得与已出过的重复。",
"- 「结束」「结算」「光荣结算」:输出 end 结算 JSON。",
"- 其余消息一律视为玩家提交的成语答案,进行评判。",
// ... 出题、评判、结算的 JSON 格式定义 ...
"每一轮玩家消息开头都附带【游戏状态】JSON,包含当前关卡、累计积分、连击、已出成语列表、待答题目。你据此出题避免重复、裁决对错。牢记:只输出 JSON。"
].join("\n")
提示词的设计遵循几个原则:
铁律先行。前两条规则是硬约束——只输出 JSON、必须可解析。放在最前面,确保 AI 在生成任何内容之前就明确格式要求。
指令分类清晰。玩家的消息被分为五类,每类对应一种回复行为。AI 不需要猜测用户意图,只需按分类规则响应。
JSON 模板示例。每种回复类型都给出了完整的 JSON 结构示例,包括字段名、类型和取值范围。AI 只需"填空",不需要自己设计结构。
游戏状态注入。每次玩家消息开头都附带 【游戏状态】 JSON,包含当前关卡、积分、连击、已出成语列表。这让 AI 能够避免重复出题、正确裁决答案、保持上下文一致性。
4.4 JSON 解析的容错策略
即使有系统提示词的约束,AI 仍然可能偶尔输出不符合预期的内容——比如在 JSON 前后多加了一句解释文字,或者用 markdown 代码块包裹了 JSON。因此前端需要一个容错的 JSON 提取函数:
function extractJson(text) {
if (!text) return null;
let cleaned = text.trim();
// 尝试提取 markdown 代码块中的内容
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (fence) cleaned = fence[1].trim();
// 提取第一个 { 到最后一个 } 之间的内容
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start < 0 || end <= start) return null;
const candidate = cleaned.slice(start, end + 1);
try {
const obj = JSON.parse(candidate);
return obj && typeof obj === "object" ? obj : null;
} catch (e) {
return null;
}
}
这个函数的三层容错策略:
- 先尝试匹配 markdown 代码块(` ```json … ````),如果有就提取块内内容。
- 如果没有代码块,就找到第一个
{和最后一个},提取它们之间的子串——这样即使 AI 在 JSON 前后加了多余文字,也能正确提取。 - 如果提取后仍然无法
JSON.parse,返回null,前端据此显示错误提示并允许重试。
在实际测试中,DeepSeek-V4-Flash 模型对 JSON 协议的遵守度非常高,绝大多数时候直接输出裸 JSON,extractJson 的容错机制很少被触发。但作为防御性编程,这层保护是必要的。
五、游戏状态机:让 AI 与前端协同
5.1 状态定义
游戏的核心是一个有限状态机(FSM),定义在 js/game.js 中。状态之间的迁移确保了游戏流程的严谨性:
| 状态 | 含义 | 可迁移到 |
|---|---|---|
idle | 未开始 | asking |
asking | 等待 AI 出题 | answering / error |
answering | 玩家作答中,倒计时运行 | judging / 超时 |
judging | 等待 AI 评判 | answering(重试) / cleared / conclude / error |
cleared | 本关通过,等待点击"下一关" | asking |
conclude | 最后一关通过,等待点击"光荣结算" | asking(结算请求) |
stuck | 答错次数耗尽 | asking(换题) |
end | 结算完成 | idle(再来一局) |
每个状态决定了 UI 上哪些按钮可用、输入框是否激活、哪些区域可见。比如在 asking 状态下,输入框和提交按钮都是禁用的(因为题目还没出来),"思考中"动画显示;在 answering 状态下,输入框激活、提交按钮可用、倒计时运行。
5.2 消息历史与上下文窗口
AI 需要知道游戏的历史才能正确出题和评判——比如不能重复出已经出过的成语,需要记住当前是第几关。我们通过维护一个消息历史数组来实现:
this.history = [{ role: "system", content: AI_CONFIG.systemPrompt }];
// 每次交互:
this.history.push({ role: "user", content: userContent });
// AI 回复后:
this.history.push({ role: "assistant", content: text });
但消息历史会不断增长,而 AI 接口有 token 上限。为了控制上下文长度,我们采用了滑动窗口策略:
windowedMessages() {
if (this.history.length <= this.maxHistory) return this.history;
return [this.history[0]].concat(this.history.slice(-(this.maxHistory - 1)));
}
maxHistory 设为 14。当历史超过 14 条消息时,保留第一条(系统提示词,必须始终在)和最近 13 条(最近的交互上下文)。这样既保证了 AI 有足够的上下文信息,又控制了 token 消耗。
5.3 游戏状态快照注入
每次向 AI 发送消息时,都会在消息开头附加一个游戏状态快照:
ask(content) {
const userContent = "【游戏状态】" + this.stateBlock() + "\n" + content;
this.history.push({ role: "user", content: userContent });
// ...
}
stateBlock() {
const block = {
level: this.level,
score: this.score,
combo: this.combo,
total_correct: this.totalCorrect,
total_wrong: this.totalWrong,
used: this.usedIdioms,
current: null
};
if (this.currentQuestion) {
block.current = {
answer: this.currentQuestion.answer,
tip: this.currentQuestion.tip,
score: this.currentQuestion.score
};
}
return JSON.stringify(block);
}
这个快照让 AI 始终知道:当前第几关、累计多少分、连击几次、已经出过哪些成语、当前待答题目是什么。AI 据此可以避免重复出题、正确评判答案、在结算时给出准确的累计分数。
六、UI 渲染:古风国潮主题
6.1 设计理念
成语是中华传统文化的一部分,UI 风格应该与之呼应。我们选择了古风国潮主题——宣纸底色、朱红点缀、金色装饰、墨色文字,整体氛围像一幅展开的卷轴。
CSS 变量定义了主题色系:
:root {
--paper: #f7f0e1; /* 宣纸底色 */
--paper-deep: #efe3cb; /* 深宣纸 */
--ink: #2b2a26; /* 墨色 */
--ink-soft: #5c574b; /* 淡墨 */
--cinnabar: #b23a2f; /* 朱红 */
--cinnabar-deep: #8f2c23; /* 深朱红 */
--gold: #c9a227; /* 金色 */
--jade: #3e7a5e; /* 翠玉 */
--line: #d9cbaa; /* 边线 */
}
6.2 题目卡片设计
题目卡片是游戏中最核心的视觉元素。它模仿了古代试卷的样式——宣纸纹理底色、左侧朱红金渐变装饰条、顶部关卡标签:
.question-card {
padding: 26px 28px 22px;
background:
linear-gradient(180deg, rgba(255,255,255,0.75), rgba(255,255,255,0.35)),
repeating-linear-gradient(0deg, transparent, transparent 26px,
rgba(178,58,47,0.05) 26px, rgba(178,58,47,0.05) 27px);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: 0 6px 22px var(--shadow);
animation: fadeUp 0.35s ease;
}
.question-card::before {
content: "";
position: absolute;
left: 0; top: 12px; bottom: 12px;
width: 4px;
border-radius: 4px;
background: linear-gradient(180deg, var(--gold), var(--cinnabar));
}
repeating-linear-gradient 在卡片背景上创造了隐约的横线纹理,模拟宣纸上的格线。左侧的 ::before 伪元素是一条从金色渐变到朱红的装饰条,像古代公文上的批注标记。
6.3 评判结果的颜色编码
答对和答错使用不同的视觉编码——答对用翠玉色(绿色系),答错用朱红色(红色系),与古风主题保持一致:
.feed-item.judge-correct {
border-left: 4px solid var(--jade);
background: rgba(62,122,94,0.08);
}
.feed-item.judge-wrong {
border-left: 4px solid var(--cinnabar);
background: rgba(178,58,47,0.07);
}
6.4 思考中动画
当 AI 正在生成回复时,页面显示一个三点闪烁动画,配合"主持人思考中…"的文字提示:
.thinking-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--cinnabar);
animation: blink 1.2s infinite ease-in-out;
}
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1); }
}
三个圆点依次闪烁(通过 animation-delay 错开),节奏感像呼吸一样自然。
6.5 响应式适配
虽然这是一个以桌面端为主的游戏,但我们也做了基本的移动端适配:
@media (max-width: 520px) {
.brand-title { font-size: 18px; }
.question-hint { font-size: 18px; }
.stat-value { font-size: 17px; }
}
在窄屏设备上缩小标题和正文字号,确保内容不溢出、按钮可点击。
七、跨域问题与部署
7.1 CORS 验证
浏览器直连 AI 接口最大的顾虑是 CORS(跨域资源共享)。如果接口服务器不允许跨域,浏览器会拦截请求。我们在开发前专门做了预检验证:
curl -s -i -X OPTIONS "https://api-ai.gitcode.com/v1/chat/completions" \
-H "Origin: http://localhost:8080" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
响应中包含了关键的 CORS 头:
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Cookie,Authorization,Origin,content-type,...
GitCode AI 接口正确放行了跨域请求,这意味着浏览器可以直接 fetch 调用,无需后端代理。
7.2 本地运行
项目是纯静态文件,任何静态文件服务器都可以运行:
# Python 方式(最简单)
cd ruanjian6chengyudachangguan
python3 -m http.server 8080
# 浏览器访问 http://localhost:8080
# Node 方式
npx serve -l 8080 .
注意:不能直接用 file:// 协议打开 HTML 文件,因为 fetch 调用 AI 接口需要 http:// 或 https:// 协议的 Origin 头,file:// 协议下 CORS 预检会失败。
八、测试验证
8.1 浏览器冒烟测试
我们使用 Playwright 编写了自动化冒烟测试,覆盖了游戏的核心流程:
- 页面加载:验证标题为"AI成语大闯关",无控制台错误。
- 开始闯关:点击"开始闯关"按钮,等待题目卡片出现(
#questionCard不再 hidden)。 - 题目渲染:验证
#questionHint有内容(AI 出题成功,JSON 协议解析正常)。 - 提交答案:在输入框中填入"画蛇添足",点击提交。
- 评判结果:等待
.feed-item.judge-correct或.feed-item.judge-wrong出现(AI 评判成功,结果渲染正常)。
测试结果:全部通过。从点击开始到出现题目大约需要 3-8 秒(取决于 AI 响应速度),从提交答案到出现评判大约需要 2-5 秒。整个流程顺畅,无 JavaScript 错误。
8.2 协议冒烟测试
除了浏览器端测试,我们还通过 curl 直接验证了 AI 接口的流式 SSE 格式:
curl -s -N -X POST "https://api-ai.gitcode.com/v1/chat/completions" \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-ai/DeepSeek-V4-Flash","messages":[...],"stream":true,...}'
确认了 SSE 行格式为 data: {json},每个 chunk 的 choices[0].delta 包含 reasoning_content(思考)和 content(正文)两个字段,流结束标记为 data: [DONE]。这与 api.js 的解析逻辑完全匹配。
九、踩坑记录与经验总结
9.1 reasoning_content 字段
最初我们不知道 DeepSeek-V4-Flash 模型会输出 reasoning_content 字段。在第一次测试中,发现 AI 的回复前面有一大段"思考过程"文字,导致 JSON 解析失败。排查后才发现流式 chunk 中 delta 有两个字段:reasoning_content(思考)和 content(正文)。系统提示词要求 AI 只输出 JSON,但思考过程是模型层面的行为,不受提示词控制。
解决方案是在 api.js 中过滤掉 reasoning_content,只累积 content。同时利用 reasoning_content 的存在来驱动"思考中"动画——当有 reasoning_content 时显示动画,当 content 开始输出时隐藏动画。
9.2 SSE 缓冲区截断
前文提到的缓冲区管理问题是最隐蔽的 bug。在本地测试中,由于网络速度快、数据块大,每次 read() 通常返回完整的行,问题不容易暴露。但在网络较慢或响应内容较长时,一个 SSE 行可能被拆分到两个 read() 块中,导致 JSON.parse 失败。
解决方案就是前文描述的 buffer + indexOf("\n") 切行策略。关键细节是 TextDecoder.decode(value, { stream: true })——stream: true 参数确保跨块的多字节字符(如中文)不会被截断。
9.3 系统提示词的 JSON 约束力度
最初版本的系统提示词只是说"请输出 JSON 格式",但 AI 偶尔会在 JSON 前后加上解释文字,或者用 markdown 代码块包裹。后来我们把约束升级为"铁律"——“不允许输出 JSON 之外的任何文字、标点或 markdown 代码块”,并加上了"输出必须能被 JSON.parse 正常解析"的硬性要求。升级后,AI 的输出格式遵守度大幅提升。
同时,extractJson 函数作为兜底防线,即使 AI 偶尔违反约束,也能通过花括号提取策略正确解析。
9.4 消息历史膨胀
随着游戏进行,消息历史不断增长。如果不做控制,第 10 关时历史可能已经包含 30+ 条消息,每条都带着游戏状态快照,token 消耗会超出接口限制。滑动窗口策略(保留系统提示词 + 最近 13 条)有效控制了上下文长度,同时保证了 AI 有足够的信息进行出题和评判。
9.5 答案比对策略
玩家输入的答案可能包含空格、标点、大小写差异。比如正确答案是"画蛇添足",玩家可能输入"画蛇添足 “(尾部空格)或"画蛇添足。”(带句号)。我们在提交前做了清洗:
const answer = raw.replace(/[\s\p{P}\p{S}]/gu, "").toLowerCase();
使用 Unicode 属性转义(\p{P} 匹配所有标点,\p{S} 匹配所有符号)去除空格和标点,然后转小写。这样"画蛇添足。“和"画蛇添足"都会被规范化为"画蛇添足”。同时要求答案长度必须是 4(四字成语),不符合的会提示重新输入。
但同音不同字仍然算错——“画蛇添足"和"化蛇添足"是不同的。这个判断交给 AI 裁判,系统提示词中明确要求"同音不同字算错”。
十、项目结构与文件职责
最终的项目结构如下:
ruanjian6chengyudachangguan/
├── index.html 主页面(单页应用骨架)
├── css/
│ └── style.css 古风国潮主题样式(330行)
├── js/
│ ├── config.js API配置 + 系统提示词(JSON协议定义)
│ ├── api.js 流式SSE调用 + JSON提取(124行)
│ ├── game.js 游戏状态机:关卡/积分/连击/倒计时(256行)
│ └── ui.js DOM渲染:题目卡/作答区/消息流/结算(232行)
├── docs/superpowers/specs/
│ └── 2026-09-18-ai-chengyu-challenge-design.md 设计文档
├── blog/
│ └── 码道-AI成语大闯关开发全记录.md 本博客文章
└── README.md 使用说明与部署文档
每个文件的职责清晰分离:
- config.js 只管配置——API 地址、密钥、模型参数、系统提示词、称号映射。修改配置只需改这一个文件。
- api.js 只管通信——发送请求、解析 SSE 流、提取 JSON。不关心游戏逻辑。
- game.js 只管状态——维护关卡、积分、连击、消息历史、倒计时。不直接操作 DOM。
- ui.js 只管渲染——读取 game.js 的状态,操作 DOM 元素。不维护业务状态。
这种分层让代码易于理解和维护。比如要换一个 AI 模型,只需改 config.js;要调整 UI 样式,只需改 ui.js 和 style.css;要修改游戏规则(比如增加道具系统),只需改 game.js。
十一、总结与展望
11.1 项目成果
"AI成语大闯关"成功实现了一个纯前端、零依赖、由 AI 实时驱动的成语闯关游戏。核心技术创新包括:
- Python 到 JavaScript 的完整流式调用转换——用
fetch+ReadableStream替代requests.iter_lines(),正确处理了 SSE 缓冲区截断和多字节字符解码问题。 - 系统提示词 JSON 协议——通过精心设计的提示词约束 AI 输出结构化 JSON,前端据此精准渲染,实现了 AI 与 HTML 的无缝对接。
- 游戏状态机 + 消息历史管理——滑动窗口策略控制上下文长度,状态快照注入保证 AI 上下文一致性。
- 古风国潮 UI 主题——宣纸底色、朱红点缀、金色装饰,视觉风格与成语文化内涵呼应。
11.2 可改进的方向
项目还有几个可以进一步优化的方向:
选择题模式。当前是输入式作答(玩家自己输入成语),难度较高。可以增加选择题模式——AI 返回四个选项,玩家选择正确答案。这需要在 JSON 协议中增加 options 字段,UI 增加选项按钮区域。
成语学习模式。除了闯关,可以增加一个自由学习模式——玩家输入一个成语,AI 返回释义、出处、例句、近义词、反义词等。这不需要闯关逻辑,只需扩展 JSON 协议。
本地排行榜。使用 localStorage 存储历史最高分和通关记录,增加长期激励。
PWA 离线支持。将页面注册为 Progressive Web App,安装到手机桌面。虽然 AI 调用需要联网,但页面本身的资源可以缓存,加载更快。
多模型支持。config.js 中预留了模型配置,可以轻松切换到其他 OpenAI 兼容的模型,比如 Qwen、GLM 等,让学生对比不同模型的出题质量。
11.3 教学价值
这个项目对软件技术课程的教学价值在于,它在一个不大的代码量(约 950 行 JavaScript + 330 行 CSS + 90 行 HTML)中涵盖了多个关键技术点:
- 异步编程:
async/await、Promise 链、AbortController - 流式数据处理:ReadableStream、TextDecoder、缓冲区管理
- API 设计与调用:RESTful 接口、SSE 协议、CORS 跨域
- 提示词工程:系统提示词设计、JSON 协议约束、上下文管理
- 状态机设计:有限状态机、状态迁移、事件驱动
- DOM 操作与 UI 渲染:元素创建、事件绑定、动画
- CSS 主题设计:CSS 变量、渐变、伪元素、响应式
- 错误处理与容错:try-catch、降级策略、防御性编程
每一个技术点都不是孤立的——它们在一个真实可运行的项目中相互配合,学生可以在浏览器中直接看到每一行代码的效果。这比在课堂上单独讲每个概念要直观得多。
11.4 结语
"AI成语大闯关"证明了一件事:在 2026 年,构建一个 AI 驱动的应用不需要庞大的技术栈和复杂的架构。一个静态网页、一个 AI 接口、一段精心设计的系统提示词,就足以创造一个有趣且有用的产品。
关键不在于用了多少框架、多少库,而在于你是否真正理解了每一个环节的原理——从 HTTP 请求到 SSE 流解析,从提示词设计到 JSON 协议,从状态机到 DOM 渲染。当你理解了原理,你就能用最简单的工具做出最优雅的东西。
这正是"码道"的精神——以代码为道,以理解为径。
项目仓库:https://atomgit.com/gcw_oUt54D9W/ruanjian6chengyudachangguan
技术栈:HTML5 + CSS3 + JavaScript(零依赖)
AI 模型:DeepSeek-V4-Flash(GitCode AI 开放接口)
班级:软件技术六班
更多推荐


所有评论(0)