码道:从一段 Python 流式代码到完整的“成语接龙 AI 对话“项目
码道:从一段 Python 流式代码到完整的"成语接龙 AI 对话"项目
这是一篇项目实战复盘。我把一个看似"简单得不能再简单"的想法——让 AI 陪你玩成语接龙——从一段流式调用的 Python 脚本,一步步打磨成了一个包含流式输出、结构化 JSON 渲染、国风界面、本地持久化的纯前端项目,并完整地交付到了 GitCode 仓库。本文以「码道」为记,既有技术细节,也有踩坑记录,希望能给正在学习前端与大模型 API 交互的同学一些启发。
一、为什么要做这个项目
在接触大模型开放接口之前,我曾经觉得"和 AI 对话"是一件很遥远的事情。直到我在 GitCode 的开发者社区看到 AI 大模型开放接口(OpenAI 兼容格式)的那一刻,我突然意识到:让一个聪明的大模型进入我的网页,其实只需要一次 HTTP 请求。
成语接龙是一个极具中国特色的文字游戏。两个人轮流说出成语,下一个成语必须以上一个成语的最后一个字开头,允许同音字,重复即输。这个游戏规则简单、文化底蕴深厚,但对 AI 来说却是一个"硬约束"很强的任务:它必须理解汉字、必须记得成语、必须严格遵守首字匹配规则,还必须不重复使用。这些约束恰好可以直接写进提示词里,让模型"戴着镣铐跳舞"。于是,"成语接龙 AI 对话"这个项目就诞生了。
项目的定位非常明确:不引入任何前端框架,不搭建任何后端服务,用最原生的 HTML5 + CSS3 + JavaScript,把一个可以实时对话的 AI 小游戏呈现出来。这个"极简主义"的选择,让任何拿到仓库的人都可以直接打开 index.html 运行,也让我能够把注意力集中在"如何与大模型高效交互"这一核心问题上。
二、技术选型:为什么是纯前端
当我确定了项目形态后,第一个问题就是:技术栈怎么选?
有两条路摆在面前。第一条路是走"现代工程化"路线:用 React 或 Vue、引入 Vite 构建、配一套完善的开发环境。第二条路是走"零依赖"路线:手写 HTML、CSS、JavaScript,一个静态文件服务器就够用。
我最终选择了第二条路,原因有三个。
第一,这个项目的复杂度不需要框架。成语接龙的交互本质上只有一个聊天窗口、一个接龙状态栏、一个输入框。几十个 DOM 操作就能覆盖全部需求,引入框架属实是杀鸡用牛刀。更重要的是,没有构建步骤意味着任何人拿到代码就能跑起来,这对一个"展示型"项目来说价值巨大。
第二,我想让大家看到最纯粹的 SSE 流式交互是怎么写的。在 React 里,流式数据的处理会被封装成 hook,很多底层细节反而被藏起来了。而在原生 JavaScript 中,fetch、ReadableStream、TextDecoder 这些 API 会一层层地展现在你面前,学习价值更高。
第三,零依赖意味着零风险。没有 node_modules、没有版本冲突、没有环境问题,部署到任何静态托管平台(GitCode Pages、Vercel、GitHub Pages)都是"拖过去就能用"。
事实证明这个决定是对的。整个项目最终只有四个核心文件,结构清晰:
├── index.html # 页面结构
├── css/
│ └── style.css # 国风主题样式
├── js/
│ ├── config.js # 接口配置与系统提示词
│ └── app.js # 核心交互逻辑
└── README.md # 项目说明
三、第一块拼图:读懂一段 Python 流式代码
项目的起点,是参考代码里一段用 Python 写的流式调用脚本。我们先把它拆开看一遍,因为它是整个前后端桥接方案的"说明书"。
import os
import requests
import json
API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer {os.environ.get('API_KEY')}",
}
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"])
这段代码的精髓有三处。
其一,流式(Stream)本质。 当请求里带上 "stream": true,服务器就不会再等着生成完整个回答再一次性返回,而是走 SSE(Server-Sent Events)协议,把回答按 token 一个个"推送"出来。每一个数据块都长这样:
data: {"id":"...","choices":[{"index":0,"delta":{"content":"意"}}]}
data: {"id":"...","choices":[{"index":0,"delta":{"content":"气"}}]}
...
data: [DONE]
每行以 data: 开头,行尾是换行符,最后以一个 [DONE] 标记收尾。iter_lines() 就是逐行读取这个数据流的。
其二,yield 生成器让"边收边处理"成为可能。 调用方在 for chunk in chunks 循环里每取到一个数据块就立刻处理,内存里永远只保留一个块,这就是流式处理的标准姿势。
其三,内容藏在 choices[0].delta.content 里。 每个 SSE 数据块的最外层是 choices 数组,数组里每一项的 delta 字段携带本次增量内容。把所有删除的 delta.content 拼接起来,就是这个问题的完整回答。
有了这份"说明书",我在浏览器里复刻它就有了清晰的路线图。
四、核心改造:Python 流式代码的 JavaScript 翻译
浏览器端没有 requests,没有 iter_lines(),但原生自带一套等价的武器库。我把两者做成了一张对应关系的对照表,一目了然:
| Python 实现 | JavaScript 实现 |
|---|---|
requests.post(..., stream=True) | fetch(url, { method: "POST", body }) |
resp.iter_lines() 逐行迭代 | res.body.getReader() + TextDecoder 按 \n 切行 |
line.startswith(b"data:") | line.trim().startsWith("data:") |
data:[DONE] 结束判断 | payload === "[DONE]" 提前 return |
json.loads(line[5:]) | JSON.parse(line.slice(5).trim()) |
chunk["choices"] | chunk.choices[0].delta.content |
真正的实现者只有一个函数,我把它命名为 requestAI,它把"发起请求、解析流、回调增量、返回全文"四件事全部接手:
async function requestAI(messages, onDelta) {
const res = await fetch(CONFIG.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${CONFIG.apiKey}`,
},
body: JSON.stringify({
model: CONFIG.model,
messages: [{ role: "system", content: CONFIG.systemPrompt }].concat(messages),
stream: true,
max_tokens: CONFIG.maxTokens,
temperature: CONFIG.temperature,
top_p: CONFIG.topP,
}),
});
if (!res.ok) {
throw new Error(`接口错误 HTTP ${res.status}`);
}
const reader = res.body.getReader(); // 相当于 iter_lines
const decoder = new TextDecoder("utf-8");
let buffer = "";
let full = "";
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 line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") return full; // 流式结束标记
try {
const chunk = JSON.parse(payload);
const content = chunk.choices?.[0]?.delta?.content || "";
if (content) {
full += content;
if (onDelta) onDelta(full); // 实时回调给 UI
}
} catch (e) { /* 数据块中的非 JSON 行直接忽略 */ }
}
}
return full;
}
这里有几个极易踩坑的细节,值得单独拿出来说。
第一个坑:TextDecoder 的"半字"问题。 SSE 是按行流的,但网络层的 read() 返回的二进制块并不会恰好切在字符边界上。一个 UTF-8 汉字占 3 个字节,如果一个汉字的字节被拆到两个网络块里,直接 decoder.decode(value) 就会输出乱码。解决办法就是 TextDecoder("utf-8", { stream: true })——它会在内部保留未完成的字节序列,等到下一个数据块到达时再拼接解码。同时,我维护了一个 buffer,每次把新数据追加进去,按 \n 切出完整的行处理,最后一行不完整就留在 buffer 里等下一轮,这个"留尾巴"的写法跟 Python 的 iter_lines() 行为是一致的。
第二个坑:reasoning_content 与 content。 我接入的这个模型(deepseek-ai/DeepSeek-V4-Flash)在思考型模式下,delta 里会同时出现两个字段:reasoning_content(模型的内心独白、思考过程)和 content(真正要展示给用户的回答)。如果稀里糊涂地把 reasoning_content 也拼进 full 里,前端解析 JSON 就会彻底失败。所以我的代码里只取了 chunk.choices?.[0]?.delta?.content,把思考过程挡在门外。这个发现源于我第一次抓包时的困惑——输出的第一屏全是"我们""只需要""返回"这类断断续续的话,我一度以为模型疯了。
第三个坑:[DONE] 的形态。 结束标记可能是 data: [DONE],数据块之间还有一些聚簇或心跳性质的额外字段。我的循环里对每一行都做了 startsWith("data:") 检查,非数据行(比如空行)直接跳过,保证了解析的健壮性。
五、系统提示词设计:让 AI 说"人话"变"机器话"
如果说流式解析解决了"怎么把 AI 的回答接进来",那么系统提示词就决定了"AI 到底会说什么"。成语接龙需要的是结构化数据,而不是一段散文。如果让 AI 自由发挥,它可能输出"好耶,让我来接!意气风发!寓意是……"这种充满语气词的内容,前端就得靠正则去大海捞针。
我决定:把"输出格式"作为整个提示词的核心约束,要求 AI 只输出一个严格的 JSON 对象。
最终的系统提示词围绕四个层次来写:
第一层,角色与规则。 明确告诉模型"你是成语接龙 AI",并给出游戏硬规则:接的成语首字必须与上家末字相同(允许谐音但必须说明)、只能使用真实流传的四字成语、不得重复使用、玩家随时输入新成语等于开启新局。
第二层,输出强约束。 用一句"你只能输出一个 JSON 对象,禁止输出任何多余文字、解释、代码块标记或结尾标点",把自由度直接拉满。对于不少大模型来说,"一定要输出 JSON"这句话是远远不够的,往往会包一层 ```json 代码块标记或者加一句"好的,我的回答如下:"。所以我在提示词里用了"必须""禁止""唯一"这类强硬词汇,还专门强调了"可被 JSON.parse 直接解析"。
第三层,字段定义。 JSON 的结构要足够详细,让前端"开箱即用"。我设计了一套字段:
{
"status": "ok",
"idiom": "意气风发",
"pinyin": "yì qì fēng fā",
"link_char": "意",
"link_type": "同字",
"warning": "",
"meaning": "形容精神振奋,气概豪迈。",
"source": "宋·陆游《剑南诗稿》",
"next_char": "发"
}
itch 是后话,这里先看重中之重:link_char 和 next_char 分别告诉前端"AI 用哪个字接的"和"下一轮你要接哪个字",这两个字段让前端不需要懂汉语拼音就能完成接龙状态的管理;warning 是给谐音接龙留的"坦白窗口";source 让每一次接龙都顺便涨一点文化知识。
第四层,错误分支。 玩家不可能每次都说成语,他会输"你好""不知道""呵呵"甚至个人名。所以我要求模型在无法接龙时输出另一个结构的 JSON,把"为什么接不了"和"给个示范"分开:
{
"status": "error",
"message": "「呵呵」不是成语,无法接龙。",
"suggestion": "可以试试「突发奇想」:以「想」字开头。"
}
再加上一条 help 分支用于玩家询问规则,最终,前端只需要 switch (data.status) 就能处理全部情况——这就是"协议先行"的威力:先定义好消息的格式,再让大模型按协议讲话,前端就不需要任何容错的脏活。
实践下来,大部分时候 AI 都能输出严格合法的 JSON;偶尔会出现个别字段缺失或换行符残留,我在前端做了三道兜底:先用正则 /\{[\s\S]*\}/ 提取最长的花括号片段,再尝试 JSON.parse,失败则把换行符替换成空格后重试,仍失败才提示"AI 返回内容无法解析"。这层"防御式解析"让我对模型输出的任何风格都有了底气。
六、前端实战:把 JSON 变成有温度的界面
数据结构定好了,剩下的就是纯粹的"前端功夫"。这个项目的视觉基调我定为国风:朱红配鎏金、宣纸底色、楷体字族,让"成语接龙"这种传统文化主题从第一眼就建立起气质。
整个界面分为四个区域:
侧栏包含了三张卡片:玩法规则、当前接龙字、接龙小技巧。其中"当前接龙字"是全局状态的核心外显——它是一枚朱红色的大方印章,上面刻着一个大字(比如"发"),提示玩家这一轮该接哪个字。每次 AI 回答成功,这枚印章的字就会被 next_char 替换,整个对局的"节拍器"就在这了。
聊天区按照经典的对话式布局实现:右侧是玩家的朱红气泡,左侧是 AI 的米白气泡。AI 的气泡里除了文字,还会渲染一张"成语卡片",卡片上是大号加粗的成语本体、带声调的拼音、两个小徽章("✓ 接龙成功"+"同字/谐音")、接龙关系("合 → 同字")、释义、出处,以及一条"下一轮请接『X』字开头的成语"的分隔线——这条分隔线再配上 X 字的红色小印章,玩家的下一步行动一目了然。
输入区是一个圆角输入框加一枚"出招"按钮,回车即可发送。为了让对局更连贯,AI 的回复是"流式出现"的:请求发出后,气泡里先出现"AI 正在思索下联 · · ·"的动画,收到第一个 token 后动画消失,随后整个回复仍以"一次成型"的卡片形出现。这里我做了个取舍:流式接收期间前端只做累积,等整段收完再一次性解析渲染卡片。因为 JSON 的字段是有先后顺序的,如果边收边渲染,会出现"拼音先出来、成语还没出完"的撕裂感。近实时渲染虽然炫酷,但对这个场景来说,完整的卡片体验更好。
折叠的原始 JSON。为了便于调试和展示技术细节,每条 AI 消息底部都带一个"查看原始 JSON"的按钮,点击可以展开模型返回的完整原始文本。这个设计让玩家(和开发者)都能直观感受到"卡片背后其实是一段 JSON"。
响应式布局上,我用 CSS Grid 做了 250px + 1fr 的两栏结构,当视口宽度小于 860px 时自动降为单栏,侧栏卡片横向排布,聊天区高度自适应,手机上也能顺畅玩完一整局。
七、状态管理与持久化:一局游戏如何"续命"
成语接龙是一个多轮游戏,一轮的结束(AI 的 next_char)是下一轮的开始(玩家的接龙字)。这个"回合状态"必须妥善管理。我的方案是在内存里维护一个全局状态对象,同时把两个关键数据镜像到 localStorage:对话历史(idiom.history)和当前接龙字(idiom.lastChar)。
对话历史的存取我封装成了一个 STORE 对象,它的 history 字段在 getter 中做 JSON.parse、在 setter 中做 JSON.stringify,对业务代码来说就像一个普通数组,但每次读写都自动落盘。历史记录中,每条消息有三种形态:user 文本、带思考动画的占位消息、以及最终携带 answer(解析后的 JSON)与 raw(原始文本)的 AI 消息。页面加载时,loadState() 会先看历史里有没有内容:有则逐条渲染并恢复接龙字,没有则随机从开局成语库里挑一个成语"开题布阵"。
开局成语库是我精心挑选的二十个"好接"的成语:一心一意、马到成功、龙飞凤舞、胸有成竹、海阔天空……它们的末字(意、功、舞、竹、空)都很容易找到后续接龙对象,能保证玩家开局不至于马上卡壳。另外,我还加了"新开局"和"清空"两个按钮,前者随机换题,后者重置整局会话,两者的数据隔离做得非常干净。
八、CORS 与跨域:一场虚惊
做纯前端项目,绕不开的一个问题是跨域。浏览器出于安全策略,默认禁止页面跨源发起请求——我的页面在 localhost:8080,AI 接口在 api-ai.gitcode.com,这种情况必须得到服务器的"点头"。
好在我在动手写页面之前就先用 curl -X OPTIONS 发了一个预检请求,检查接口的 CORS 响应头:
Access-Control-Allow-Origin: null
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH
Access-Control-Allow-Headers: Cookie,Authorization,Origin,content-type,Accept,...
Access-Control-Allow-Origin: null 意味着这个接口允许任意来源(包括 file:// 直接打开的场景)发起请求——这在国内大模型开放接口里算是相当开放的策略。这一"预检"行为让我免去了写一层 Node 中转代理的麻烦,也让整个项目可以保持纯粹的"零后端"形态。如果读者将来接入的接口不支持 CORS,最优雅的兜底方案就是写一个几十行的 Node/Express 代理,把 Authorization 头留在服务端,前端请求同源地址即可。这一点我也写进了 README 的注意事项里。
九、端到端验证:给项目装上"体检报告"
写代码的人最怕的就是"我这边明明能跑"。为了给项目一份可信的"体检报告",我用 Puppeteer 驱动无头 Chromium 对页面做了一轮完整的端到端测试。测试脚本做的事情很朴素:加载页面 → 读取当前接龙字 → 在输入框键入一个成语 → 点击"出招" → 等待流式回复结束 → 断言卡片是否渲染、接龙字是否更新。
测试随机抓到的一次真实对局是这样的:
玩家出题:天作之合
AI 接龙:覆雨翻云 → 合而为一
玩家输入"天作之合"后,AI 正确地把"合"作为接龙字,接出了"合而为一",渲染出的卡片带上了拼音 hé ér wéi yī、徽章"接龙成功"、接龙关系"合 → «同字»"、释义"把分散的东西聚合成为一个整体",并且状态栏的接龙字更新成了"一"。从输入到卡片渲染,走完了完整链路,全程零报错。 随后我又验证了刷新页面后对话历史与接龙字都能从 localStorage 完整恢复——这一项曾经在早期版本里翻过车(加载历史后接龙字印章没有刷新),补了一行 updateCharBar() 才修复,测试立了大功。
十、一次真实的踩坑记录
在开发过程中最有意思的一个 bug,藏在"事件监听之后、渲染之前"。当时我发现:页面上出现两条 AI 消息后,第一次对话正常,第二次对话的接龙字却不更新。排查了半天,最后定位到问题的根源在"思考动画占位消息"的替换逻辑上——我在成功拿到 AI 回复后,用 h.pop() 把占位的 thinking 消息弹出去,再 push 真正的回答,但早先版本里写成了 STORE.history[idx - 1] = aiMsg,而 STORE.history[idx - 1] 拿到的其实是 getter 返回的副本数组里的元素,改了副本、没写回持久层,于是 localStorage 里的历史永远是旧的。这一类"getter 返回新对象、修改后必须 set 回去"的坑,在自定义存取对象时几乎一定会碰到,分享出来,希望后来者少走弯路。
十一、用户体验的细节打磨
一个项目是否"成品",往往取决于那些看不见的细节。在功能全部跑通之后,我把大量时间花在了体验的打磨上。
输入侧。 输入框用 maxlength=30 限制长度,按回车即可发送,发送成功后立即清空;如果玩家在忙碌期间连续敲击回车,会被 busy 标志拦住,避免并发请求打乱回合秩序。发送中,输入框和"出招"按钮会进入禁用态,按钮透明度降低,从视觉上明确告诉你"AI 正在作答"。
异常侧。 网络抖动、接口限流、解析失败,这些情况我都做了兜底:请求非 2xx 时抛出带状态码的错误信息;AI 返回内容无法解析时,仍然把原始文本展示出来,并在错误框中给出"请检查网络或 API 配置后重试"的提示;模型输出 error 状态时,前端渲染一块醒目的朱红错误卡片,包含原因说明和一条"正确示范",让输掉的玩家也知道该怎么继续。
视觉侧。 消息气泡用了 rise 进场动画,新消息以轻微的上浮渐入;聊天容器始终用 scrollToBottom 钉住底部,配合 requestAnimationFrame 保证重排不在错误时机发生;滚动条被细心地染成了鎏金色,贴合整体的国风质感。连页面的 favicon 都是一枚朱红底、楷体"龙"字的印章,小细节堆叠起来,就形成了整体的第一印象。
可访问性侧。 输入框带有 aria-label,消息区域声明了 aria-live="polite",聊天内容会被屏幕阅读器平滑地播报,这是一项成本极低但很有价值的投入。
这些细节单个拿出来都微不足道,但它们共同决定了这位"接口生成的 AI"在玩家眼中到底像一个演示 Demo,还是一件可以被认真对待的作品。
十二、可扩展的方向
项目作为 1.0 版本已经可以愉快地玩耍了,但回头看,可扩展的空间其实很大:
- 难度模式:加入"出题",要求玩家在限定时间内输入成语,AI 记分;或者在提示词里允许 AI 使用冷门成语提升难度。
- 语音输入与朗读:接入 Web Speech API,玩家说话出成语,AI 用语音朗读释义,对于儿童识字场景尤其友好。
- 排行榜与积分:把每轮接龙的字数、用时、谐音次数计入积分,历史存档展示"连胜纪录"。
- 成语词库校验:在提示词之外,再引入一个本地成语词库做二次校验,防止模型偶尔虚构成语。
- 多端同步:目前用 localStorage 是本地的,未来可以接一个轻量后端,把对局同步到账号,跨设备续玩。
十三、写在最后
回看整个项目,"码道"二字多少有些应景:一条从 Python 脚本通向浏览器页面的路,一条从"只会调接口"到"能把流式、协议、状态、UI 串成完整产品"的路,也是一条不断踩坑又不断修复的路。
这个项目让我最大的收获是:大模型接口并没有那么神秘,它本质上就是一次 HTTP 请求 + 一份协议;而把"协议"设计好,是整个产品的灵魂。 提示词规定 JSON,前端就拿到结构化数据;流式协议规定 token 边界,前端就能实时渲染;CORS 规定跨域边界,前端就知道能不能直连。把每一层协议的边界都摸清楚,一个"能对话的网页游戏"就自然生长出来了。
项目的完整源代码已经开源在 GitCode,如果你想亲自体验一把"与 AI 成语对决"的乐趣,或者想看这段流式改造的真实代码,欢迎 clone 下来跑一跑。下一局,就由你先出题吧:一心一意——
更多推荐


所有评论(0)