码道 · 成语接龙 AI 对话:从 Python 到纯前端的流式对话实战

项目仓库:https://atomgit.com/gcw_tMdJJ8WX/computer-second-test.git
用 HTML5 + CSS3 + JavaScript 将一段 Python 流式调用代码,改造成一个可以和 AI 实时对战成语接龙、还能边玩边学的完整 Web 应用。这篇文章完整还原了我的开发思路、代码转换过程、提示词工程细节以及踩过的坑。

一、缘起:为什么想做成语接龙 AI?
我一直觉得,把传统文化和 AI 结合起来是一件很有魅力的事情。成语接龙是中国人从小到大都玩过的文字游戏,规则简单:上一个成语的最后一个字,必须是下一个成语的第一个字。然而真要让一个 AI 来玩好这个游戏,难度其实远超想象——它不仅要"接得上",还要"接得准"、“接得美”,最好还能顺便讲讲这个成语的出处和含义,让玩家在游戏里学到东西。
恰好我手头有一段现成的 Python 参考代码,它做的事情很简单:调用一个 OpenAI 兼容格式的大模型接口,用流式(SSE)方式拿到输出。我的目标也很明确:把这个 Python 版本的"骨架",移植成一个纯前端的、可以真正运行在浏览器里的成语接龙 AI 对话应用。于是就有了「成语接龙 AI 对话」这个项目。
二、技术选型:三个文件的极简主义
在动手之前,我认真思考过技术栈。做 AI 对话界面,市面上有太多的框架可以选择:React、Vue、Svelte……但我最终选择了"裸奔"方案——只有三个文件:
computer-second-test/
├── index.html # 页面结构
├── css/
│ └── style.css # 国风深色主题样式
└── js/
├── config.js # API 配置 + 系统提示词(JSON 约束)
└── app.js # 核心逻辑:SSE 流式调用 + JSON 解析 + UI 渲染
这么选有四个理由。
第一,零依赖。 这个项目不需要 npm、不需要构建工具、不需要打包器。git clone 下来,双击 index.html 就能跑。对于想学习前端 AI 应用开发的人来说,这是门槛最低的打开方式。
第二,明确的教学价值。 这个项目的核心难点在于"Python 代码如何用 JavaScript 等价实现",如果引入 React 之类的框架,核心逻辑会被框架的复杂度稀释。保留最朴素的 DOM 操作,读者才能把注意力聚焦在"流式请求"和"JSON 解析"这两个真正值钱的点上。
第三,现代浏览器能力已经足够。 fetch + ReadableStream + TextDecoder 的组合,已经能优雅地处理 SSE 流式响应,完全不需要额外引入第三方库。原生能力够用,就别给自己增加维护成本。
第四,可部署性。 纯静态文件意味着可以丢到任意静态托管平台上(GitHub Pages、对象存储、Nginx 等),一个文件不落地就能全球访问。
事实证明这个选择是对的。整个项目大约 500 行代码,逻辑清晰,每一行都有它的存在价值。
三、读懂参考代码:Python 的流式请求
先看看我手里的 Python 参考代码,它是这一切的起点:
import os
import requests
import json
API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer uEohGiEZpY2kYQFnuyrWsxFS",
}
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": [...],
"stream": True,
"max_tokens": 2048,
...
})
for chunk in chunks:
print(chunk["choices"])
这段代码非常典型,它展示了大模型流式调用的标准姿势:
requests.post(..., stream=True):告诉 HTTP 库,响应体不是一次性读入内存,而是以"流"的形式逐块到达。这是流式对话的核心前提——大模型的输出是一段话,可能要好几十秒才能生成完,用户显然不愿意干等一个完整的响应,他们希望看到"打字机"一样逐字蹦出来的效果。response.iter_lines():按行迭代响应体。line.startswith(b"data:"):SSE(Server-Sent Events)协议的每一行数据都以data:前缀开头,非data:开头的行(例如空行、注释)直接跳过。b"data:[DONE]":这是 SSE 流的结束标记,遇到它说明生成完毕,优雅退出。json.loads(...):把每一条data:后的 JSON 字符串解析成对象,这个对象就是 OpenAI 兼容接口返回的"增量块"(chunk)。chunk["choices"]:每个 chunk 的choices数组里装着这一步生成的新内容。
理解透了这段 Python 代码,JavaScript 的移植方向就呼之欲出了——其实语言只是语法不同,协议层面的逻辑是一模一样的。
四、核心转换:从 requests 到 fetch + ReadableStream
这是整个项目最核心的工程点。我用一张表格先给出等价关系,再逐个拆解:
| Python | JavaScript 等价实现 |
|---|---|
requests.post(..., stream=True) | fetch(url, {method: "POST", headers, body}) |
response.iter_lines() | response.body.getReader() + TextDecoder 按行切分 |
line.startswith(b"data:") 筛选 | line.startsWith("data:") 筛选 |
b"[DONE]" 结束标记 | /^\[DONE\]/.test(dataStr) 判断 |
json.loads(...) | JSON.parse(...) |
chunk["choices"] | chunk.choices[0].delta.content(增量文本) |
4.1 请求构造的差异
Python 用 requests.post,JavaScript 放到了浏览器内置的 fetch 中:
const resp = await fetch(API_CONFIG.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + API_CONFIG.apiKey
},
body: JSON.stringify(payload),
signal: controller.signal
});
有一个细节值得注意:浏览器端必须显式设置 Content-Type 和 Authorization 请求头,并且当请求头不是"简单请求"(simple request)时,浏览器会自动先发送一个 OPTIONS 预检请求(CORS preflight)。我在测试中发现,这个 API 网关对预检请求会返回 204 No Content 并带上 Access-Control-Allow-Origin 等头,说明它是支持跨域的,这为纯前端方案扫清了最大的障碍。这一点我在后面"部署与安全"一节还会展开。
4.2 SSE 行切割与缓冲
Python 的 iter_lines() 帮我们按行切分好了,JavaScript 没有这么贴心的工具,需要自己动手:
const reader = resp.body.getReader();
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 });
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // 保留最后不完整的行
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
// ... 解析 JSON
}
}
这里最关键的技巧是 buffer 缓冲区。网络传输是分块的,一个完整的 SSE 行可能被切成好几块到达,也可能一个块里塞了好几行。如果不做缓冲,直接对每个 value 切分,就会频繁遇到"半行"导致解析失败。我的做法是:
- 把新到的字节解码后追加到
buffer; - 用
split("\n")切出完整的行; - 把最后一段没有换行符的"残行"放回
buffer,等下一批数据到来后再拼接处理。
这个模式在网络编程里叫"按行缓冲",是处理流式文本协议的通用套路,值得记住。
4.3 delta.content 与 reasoning_content 的陷阱
模型参数里我传了 thinking_budget: 2048,这意味着模型会先"思考",再"回答"。于是流式返回的 chunk 出现了两种字段:
{"choices":[{"delta":{"reasoning_content":"我们要……"}}]}
{"choices":[{"delta":{"content":"足","reasoning_content":""}}]}
开头很长一段时间的流内容,content 都是空字符串,只有 reasoning_content 在填充(那是模型的思考过程,相当于"内心OS")。如果直接把 delta.content 拼到聊天界面上,前端会有一段时间处于"收到了数据但界面没动静"的状态,非常容易误判为接口异常。
我的处理方式是只提取 content,忽略 reasoning_content:
const delta = chunk && chunk.choices && chunk.choices[0] && chunk.choices[0].delta;
if (delta && typeof delta.content === "string" && delta.content) {
onDelta(delta.content);
}
这样思考过程不会被展示给用户,等"正文"开始输出后,界面自然就动起来了。这也是很多带「深度思考」模式的大模型产品在前端渲染时的通用取舍:思考过程可以折叠展示,但绝不能污染正文字内容。
五、让 AI 返回结构化 JSON:系统提示词设计
这是整个项目里"技术含量"最高的部分之一。如果只是让 AI 输出普通文本,前端就得用正则去猜"哪个词是成语、哪个是拼音、哪个是释义",既不可靠又不可维护。我的解法是:在系统提示词里约定死输出格式,要求 AI 只返回一个合法的 JSON 对象,前端直接按字段渲染。
5.1 为什么必须用 JSON 约束
大模型默认输出的是自由文本,而我们的前端需要的是"成语 + 拼音 + 释义 + 出处 + 接龙状态"这样结构化的数据。如果靠解析文本,会有几个致命问题:
- 成语、拼音、释义没有明确边界,正则很难稳定分离;
- AI 偶尔会加"好的!"、"哈哈"这类寒暄词,进一步污染结构;
- 输入错误、AI 认输等不同状态无法用文本区分。
用 JSON 约定一套"协议",AI 的输出就变成了一份契约,前端渲染逻辑可以写得非常简洁、稳健。
5.2 JSON 返回结构设计
我在系统提示词中约定的返回结构如下:
{
"code": 0,
"data": {
"status": "success",
"valid": true,
"assistant_idiom": "足智多谋",
"assistant_pinyin": "zú zhì duō móu",
"meaning": "富有智慧,善于谋划。形容人善于料事和用计。",
"source": "元·无名氏《连环计》第一折:\u201c此人足智多谋,可与共事。\u201d",
"tip": "接龙以\u201c足\u201d开头,下一个字是\u201c谋\u201d。",
"game_over": false,
"last_char": "谋"
},
"message": "success"
}
各字段的职责划分是这样的:
| 字段 | 类型 | 作用 |
|---|---|---|
status | string | success 正常接龙 / warning 用户输入有误 / error 无法判断 |
valid | boolean | 用户上一条成语是否有效(是否真成语、是否符合接龙规则) |
assistant_idiom | string | AI 接出的成语,认输或无需接龙时为空字符串 |
assistant_pinyin | string | 拼音(空格分隔),供卡片展示 |
meaning | string | 释义,游戏的教育价值所在 |
source | string | 出处,拿不准时可为空 |
tip | string | 本轮讲解或引导提示 |
game_over | boolean | 是否结束本轮游戏 |
last_char | string | 本轮最后一字,用于校验下一轮 |
这个结构设计上我刻意做了三件事:一是把"状态"(status/valid/game_over)和数据(idiom/pinyin/meaning)分开,前端可以先判断状态再决定渲染策略;二是把"机器要用的"(last_char)和"人要看要学的"(meaning/source)都放在同一份数据里,后续如果要加"错题本""成语收藏"等功能,数据已经齐备;三是留了 code 和 message 两个通用字段,保持和传统 HTTP 接口返回风格统一,未来如果引入后端中转,也能无缝衔接。
5.3 提示词中的三条"硬约束"
为了让 AI 稳定输出合法 JSON,我的系统提示词里下了三条很关键的死命令:
- “你只能且必须返回一个合法的 JSON 对象,禁止输出任何多余文字、解释、Markdown 代码块标记(禁止使用```)或换行前缀。” —— 这一条直接堵死了 AI 最常见的两个坏习惯:说废话,以及用 Markdown 代码块把 JSON 包起来。
- “若你确实无法接到合适的成语,必须诚实认输(assistant_idiom 返回空字符串,game_over 设为 true)。” —— 这是"接龙游戏"和"聊天机器人"最大的区别:AI 必须学会认输,而不是绕开规则东拉西扯。
- “记住对话历史:严格要求首尾相接,不得重复使用已出现过的成语。” —— 禁止重复是游戏体验的核心保障,接龙最怕 AI 反复用同一个成语糊弄玩家。
事实证明,约束越具体、越可验证,大模型的遵循率越高。把字段含义写清楚、把非法行为点名道姓地禁止,比笼统地写"请返回规范的 JSON"有效得多。
六、前端渲染:从 JSON 到成语卡片
拿到 JSON 之后,前端要解决两个问题:解析容错和结构化渲染。
6.1 容错解析 extractJson
即便提示词约束再严格,生成模型偶尔还是会"越狱"——在 JSON 前后加一句寒暄,或者用 ``````json ````包裹。为此我写了一个具有兜底能力的解析函数:
function extractJson(text) {
let cleaned = text.trim();
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) cleaned = fence[1].trim();
const firstBrace = cleaned.indexOf("{");
const lastBrace = cleaned.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
cleaned = cleaned.slice(firstBrace, lastBrace + 1);
}
return JSON.parse(cleaned);
}
它的思路是"三连兜底":先剥掉 Markdown 代码块外壳,再截取第一个 { 到最后一个 } 之间的子串,最后才交给 JSON.parse。这样即使 AI 在 JSON 外面套了马甲,也基本能"剥壳取肉"。我在真实测试中也确实遇到过模型输出带转义引号的情况,这套兜底逻辑保证了极端情况下页面不会白屏,而会降级展示原始文本。
6.2 结构化渲染
渲染函数根据 status 和 game_over 走不同的 UI 分支:
- 成功接龙:渲染一张"成语卡片",包含 ✅ 接龙成功标签、超大成语文字(金色、字间距拉宽)、拼音、释义、出处;如果
game_over为 true,再追加一个 🏁 游戏结束标签。 - AI 认输:不渲染卡片,只渲染一句温和的认输提示,例如"AI 认输啦!换个成语再来一局吧 😄"。
- 用户输入有误:展示
warning分支的引导提示,比如"这是一个句子哦,让我们来玩成语接龙吧!请说出一个成语作为开始。"
对比纯文本方案,这个结构化方案带来的不仅仅是"好看",而是渲染逻辑的确定性:什么状态出什么界面,一目了然,维护起来极其舒服。
6.3 打字机效果
由于接口是流式的,我在接收增量时实时把内容写进一个临时消息气泡,实现"逐字蹦出"的打字机效果:
await chatCompletion(payload, (delta) => {
fullText += delta;
// 创建/复用当前 AI 气泡,把增量拼进去
bufferEl.textContent = fullText;
scrollToBottom();
});
流结束后,我把这个"半成品"气泡删掉,换成正式的成语卡片。这个"先流式预览、后结构化替换"的两段式渲染,兼顾了流式体验和最终效果,是我个人很喜欢的一个细节。
七、多轮记忆与游戏状态管理
成语接龙是典型的多轮游戏:这一轮的规则依赖上一轮的结果。因此对话记忆是刚需。
我用一个 history 数组维护完整的消息序列([{role, content}]),每次请求时把系统提示词 + 最近若干轮历史 + 当前用户输入拼接成 messages 一起发给模型:
function buildPayload(userText) {
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
...history.slice(-MAX_HISTORY_ROUNDS * 2)
];
messages.push({ role: "user", content: userText });
// ...
}
这里值得展开讲的是 MAX_HISTORY_ROUNDS = 10 这个设计。每轮对话会产生 2 条消息(user + assistant),如果不加限制,历史会无限膨胀,既浪费 token 又稀释模型注意力。我换算成"轮数"来裁剪:history.slice(-MAX_HISTORY_ROUNDS * 2) 表示只保留最近 10 轮(20 条消息)。这个上限经过实践是够用的——接龙失败或被判无效时,状态都写在最新一轮的 JSON 里,不需要回溯太远。
另外,"重新开始"按钮会清空 history 并清空界面,让玩家随时可以开一局新的。这是状态管理的最后一块拼图:三条状态渠道——历史数组(轮次记忆)、DOM(界面呈现)、进度校验(JSON 里的 last_char)——各司其职,互不干扰。
八、UI/UX:国风深色设计
作为一个文化属性很强的游戏,UI 绝不能是默认白底黑字。我选择了国风深色主题,并用 CSS 变量建立了一套完整的色彩体系:
:root {
--primary: #b45309; /* 赭石橙,主色 */
--bg-deep: #1c1917; /* 深褐黑,背景 */
--gold: #fbbf24; /* 鎏金,点缀 */
--success: #34d399; /* 状态色 */
--danger: #f87171;
...
}
在视觉上做了几处有"国风味道"的设计:
- 渐变标题:标题"成语接龙 AI 对话"使用从金色到暖白的渐变文字,配合毛笔感标题,呼应"龙"的意象;
- 毛玻璃 Header/Footer:
backdrop-filter: blur(12px)让吸顶的页头和页脚有一种"透过宣纸看东西"的朦胧感; - 成语卡片金框:AI 的成语大字用鎏金色、加宽字距、四周由淡金色的渐变背景托底,模拟"烫金名帖"的质感;
- 头像区分:用户 👤 气泡是赭石橙渐变,AI 🐉 气泡是深灰带金边,一眼可辨;
- 打字动画:思考中显示三点跳动动画,与
status-dot状态灯(就绪=绿、忙碌=金、异常=红)呼应,让用户始终知道系统在干什么。
在响应式适配方面,@media (max-width: 640px) 下会隐藏发送按钮的文字、收窄气泡宽度、去除页面边框,保证手机端体验依然完整。作为一个纯静态页面,这套 UI 做到了"零图片、纯 CSS",加载极快。
九、测试验证:Playwright 端到端
写代码容易,证明代码能跑难。我引入了 Playwright + Chromium 做真实浏览器端的端到端测试,验证的不仅仅是"页面能打开",而是真实调用 AI 接口后的完整链路。
测试脚本的核心流程是:打开页面 → 输入"画蛇添足" → 点击发送 → 等待 .idiom-card 出现 → 断言成语、拼音、释义都已渲染 → 再输入"谋事在人" → 等待第二个卡片出现 → 检查控制台无报错。
真实跑出来的结果:
第1轮 用户[画蛇添足] -> AI[足智多谋] ✅ 以"足"字开头,卡片渲染正常
第2轮 用户[谋事在人] -> AI[人山人海] ✅ 以"人"字开头,多轮记忆生效
纠错 用户[今天天气不错] -> AI 友好提示(非成语) ⚠️ 智能纠错生效
这里有一个让我印象深刻的测试插曲:我在第一次端到端测试时就发现,当 AI 返回 warning 状态时,tip 字段会在界面上重复渲染两遍——因为提示文案同时走了"通用 tip 渲染分支"和"异常状态渲染分支"。这是我光看代码很难发现的 bug,只有真实跑起来才能暴露。修复也很简单:在通用分支加一个 status !== "warning" && status !== "error" 的条件。这件事让我切身体会到:对 UI 应用来说,端到端测试不是可选项,而是必备项。
这个 bug 的发现和修复,也解释了为什么我在项目里坚持"能用真 API 测就绝不用 mock"。mock 能验证代码逻辑,但永远验证不了"模型真的按提示词的约定返回了"这件事,而这恰恰是这类 AI 应用的最大不确定性来源。
十、部署与安全
10.1 部署
项目是纯静态资源,部署选项非常多:
# 本地预览
python3 -m http.server 8080
# 或
npx serve .
推到仓库后,也可以直接静态托管到任意平台。我实际验证过两种情况都能正常调用 API:
- localhost 场景:CORS 预检返回
Access-Control-Allow-Origin: http://localhost:8080; - file:// 直接打开:浏览器发的
Origin: null也能通过预检。
这意味着哪怕用户不搭服务器、直接双击 HTML 打开,也能玩。这是纯前端方案独有的便利性。
10.2 一个必须强调的安全问题
我必须诚实地指出这个方案的安全边界:API Key 写在 js/config.js 里,部署到公网后任何访问者都能在浏览器开发者工具里看到这个密钥。 这是纯前端方案的先天缺陷,无法通过改代码根治。
如果这是一个生产项目,正确做法是:增加一层后端代理——前端请求自己的后端,后端持有密钥并转发请求到模型 API,这样密钥永不暴露。架构上只需要多一个 Node/Java/Go 的薄代理层,前端代码几乎不用改(改一下 API_CONFIG.url 指向代理即可)。
我之所以在这个演示项目里保留明文密钥,是为了保持"零后端、纯前端、可直接运行"的教学价值,但请大家务必牢记:生产环境永远不要把密钥交给浏览器。
十一、踩坑记录
最后把我开发过程中踩过的、认为有价值的坑集中复盘一下,希望后来者少走弯路。
坑 1:SSE 分包导致解析失败。 最初我拿到 value 就直接 split("\n") 解析,结果经常报 JSON 解析错误,因为一行 data: 被切成了两半。解法就是前面说的"按行缓冲":残行留在 buffer 里等下一批。这个坑几乎每个手写 SSE 解析的人都会踩。
坑 2:thinking_budget 造成"假死"假象。 模型启用推理模式后,会先输出一大段 reasoning_content,content 一直为空。如果前端把 delta.content 为空当作"没收到数据",很容易误判超时。认清这个机制后,我在 UI 上保留了"AI 正在思考"的打字动画,等待正文出现。
坑 3:AI 输出包裹 Markdown 代码块。 即便提示词里三令五申"禁止使用 ````````````,模型偶尔还是会包。所以我写了 extractJson 的三连兜底。对付大模型,永远要做防御性编程。
坑 4:warning 状态 tip 重复渲染。 就是第九章里提到的那个 bug。它提醒我:渲染逻辑里一旦出现"多个分支都会追加同一份数据"的写法,一定要问自己一句:这些分支会同时命中吗?
坑 5:CORS 与 file:// 的兼容。 开发前我担心纯前端能否直连 API,实测发现网关对任意 Origin(包括 null)都放行,这才让"双击即玩"成为可能。如果换了别的 API 网关,一定要先做一次 OPTIONS 预检,确认 Access-Control-Allow-Origin 的返回策略再决定架构。
十二、总结与展望
回看这个项目,从一段 20 行不到的 Python 参考代码,到一个能流畅对话、能正确接龙、能智能纠错、还有完整 UI 的 Web 应用,中间跨越的其实不完全是代码量,而是思路的转变:
- 协议思维:流式接口的迁移,本质是理解 SSE 协议,而不是背诵某个库的 API;
- 契约思维:让模型输出结构化 JSON,本质是和大模型建立一份"数据契约",前端从此不再是"猜"而是"读取";
- 用户体验思维:流式打字机、思考态动画、认输文案,每一个细节都在回答同一句话——用户此刻应该看到什么、感受到什么;
- 防御思维:模型不可控,所以解析要容错、渲染要兜底、测试要真实。
这个项目的可扩展空间也很大。比如:加入本地成语词库做"前端预检",让 AI 只负责锦上添花;增加积分制、计时制,把游戏性拉满;接入 TTS,让 AI 把成语朗读出来;以及给 AI 加上"难度档位",让它对小朋友放水、对高手上强度。
技术从来都只是工具,真正有意思的,是它被拿来创造的那一小片风景。希望这篇文章能给你一些启发,让你也想动手把一个"有点意思的想法"变成"一个真的能玩的东西"。
本文由「码道」整理分享。欢迎 Star、Fork,也欢迎在评论区交流你的 AI 应用开发心得。
更多推荐


所有评论(0)