码道 :成语接龙 AI 对战全复盘:从一句抱怨到一个可玩产品的产品与工程手记
上一篇博客讲的是"如何把一段 Python 的 AI 流式调用翻译成纯前端 JavaScript"。这一篇我想换个视角,把镜头拉远一点:聊聊我在做出《成语接龙 AI 对话》这个小产品的过程中,那些与"产品定位、交互手感、协议设计、容错工程"有关的决策与复盘——包括一个真实发生、还上了开发日志头条的 Bug :「输入框输入不了文字」。
项目地址:https://atomgit.com/cnncnncnn_/ruanjian6chaichai
项目实例:

使用工具:

一、一切的起点:三个人的抱怨
这个项目的种子,其实是三句抱怨。
写代码的人抱怨:公司的团建暖场游戏永远是狼人杀和谁是卧底,想玩点"有点文化"的,又没有人会出题。
爱读书的人抱怨:成语词典太厚,抱着字典玩接龙毫无游戏体验,可又确实想借着游戏多记几个成语——拼音、出处、典故,最好有人能随口讲清楚。
做产品的人抱怨:市面上的成语接龙 App 大多内置的是静态词库,接来接去就那一千多个词,玩两局就腻了,更不会有人给你讲成语背后的故事。
把这三句抱怨叠在一起,答案就呼之欲出了:让大模型来做成语接龙的 AI 对手。 它词汇量大,接龙不会断;它博闻强识,释义出处张口就来;它还是一个"无论你多菜都不会嫌弃你"的完美陪练。而我选择用纯前端把它做出来,则是因为——一个双击就能玩、无需注册、无需安装的 HTML 页面,才是"暖场游戏"该有的打开方式。
于是,这个项目从第一天起就有三个灵魂约束:
- 零依赖:不引入任何框架,HTML + CSS + JavaScript 原生三件套;
- 零搭建:不需要 Node 服务、不需要打包器,一个静态服务器甚至是双击文件就能跑;
- 有学习属性:不只是"接上了",还要把每个成语"讲透"。
二、产品化:把一场文字游戏拆成可执行的需求
产品经理们常说"好的产品是设计出来的",而设计的起点是把模糊的愿望拆成清晰的边界。我把"做一个好玩的成语接龙网站"拆成了三层:
第一层 · 玩得转(核心闭环)
谁先出题、谁接谁、怎么判定输赢的规则感。我最终定下的流程是:AI 先开局 → 玩家接 → AI 再接 → 循环。注意这里有个反直觉的产品决策:让 AI 先说第一句。原因有两个——AI 说第一句,玩家永远是"进攻方",有天然的参与压力;而且 AI 的开场成语往往选得很有画面感,能立刻把玩家代入。
第二层 · 玩得懂(知识呈现)
这是这个产品区别于"静态词库接龙 App"的部分。AI 每接一个成语,都顺带给出拼音、释义、出处、例句,并且以结构化的卡片呈现,而不是一段糊在一起的文字。玩家在不知不觉中完成了一次"轻量学习"。
第三层 · 玩得久(体验细节)
- "当前要接的字"要实时、显眼地挂在输入框上方;
- 等待 AI 回复时要有状态反馈,不能让玩家面对死寂的屏幕;
- 输入错了要给出具体的、可操作的提示(“首字必须是’海’,你输入的是’天’”),而不是一句冰冷的"输入错误"。
这三层拆完之后,"做什么、不做什么"就非常清楚了,比如:不做注册登录、不做排行榜、不做难度分级——统统砍进"MVP 之后的再说"清单。
三、手感是怎么来的:四个交互决策
工具型产品讲效率,游戏型产品讲"手感"。这四个交互决策,是我觉得这个项目最有"游戏味"的地方。
决策一:待接字做成"印章"。
输入框正上方,一直挂着一个金色渐变的大字印章,上面就是当前要接的那个字(比如"海")。玩家低头输入前,视线必定扫过它——这比任何文字说明都高效。当这个产品只有一个需要玩家时刻抓住的信息点时,把它做成最高对比度的视觉焦点,永远是对的。
决策二:忙锁要有"物理存在感"。
请求发出后,我把输入框、发送按钮、重新开始按钮三个控件全部 disabled。为什么连"重新开始"也要锁住?因为并发状态下如果玩家点了重置,会跟正在进行的请求产生竞态——旧请求的响应可能把新一局的界面状态冲掉。一次重置 + 一次慢响应 = 一局错乱的游戏。用三个 disabled 把这种可能直接堵死,比写一堆竞态判断要省心得多。
决策三:给"思考"一个名字。
现在的推理模型在开口前会有一段思考过程(reasoning_content)。如果什么都不做,玩家会盯着空泡泡发呆三秒钟,紧张感瞬间变焦虑感。我的做法是:一旦捕获到 reasoning_content 增量,就在气泡里贴一行灰色小字"(思考中…)"。一个 12 像素的小纸条,把"它在想"这个事实告诉玩家,焦虑就变成了期待。
决策四:卡片即知识。
AI 的每一个回合,都渲染成一张卡片:大字成语、拼音、然后"释义 / 出处 / 例句"三个固定标签。标签字段用统一的金色小标定格,整张卡片的视觉节奏清晰稳定。让 AI 输出的「信息结构」直接映射成「视觉结构」,这是结构化协议最大的红利。
四、协议设计:给 AI 和页面签一份契约
4.1 为什么是 JSON
如果 AI 的回复是一大段自然语言,前端只能整段展示,无法排版。而如果我给 AI 定死一段 JSON 协议,那么人类、大模型、HTML 页面三者之间就签了一份契约:AI 负责产出结构正确的数据,页面负责把它们摆进该有的位置。契约一签,两端都可以各自独立迭代——这比让双方"互相适应"要优雅一个数量级。
协议长这样(字段名固定,不多不少):
{
"idiom": "AI 接上的四字成语",
"pinyin": "带声调拼音,字间空格",
"meaning": "一句话释义",
"source": "出处,没有则写'常用成语'",
"example": "包含该成语的例句"
}
4.2 提示词里的三个小聪明
协议写到系统提示词里,要遵守三条我看过很多次"提示词事故"之后总结出来的规律:
把规则写在前面,把格式写在后面。 "你是谁 → 游戏规则 → 返回格式"三段式结构,模型的执行稳定性明显高于顺序打乱的情况。这大概是因为规则给模型建立了"身份与任务"的心智锚点,格式只是它要照着填的表格。
动态要求必须走 user 消息。 每次接龙要用的"首字"每次都不一样,这种动态约束绝对不要写死在 system 提示词里,否则模型会死记硬背只接同一个字。正确姿势是拼进当轮 user 消息:上一位玩家的成语是「人山人海」,最后一个字是「海」。请接一个【首字为「海」】的真实四字成语,并严格按系统要求的 JSON 格式返回。
给字段兜底,不给提示词加戏。 "出处没有就写’常用成语’"这类兜底指令有用,但别指望提示词覆盖所有意外。真正的兜底要放在前端的解析层——下面会说。
4.3 解析层的"三层剥壳"
模型是人写的概率系统,总有叛逆时刻:吐一段 Markdown 围栏、前面加句"好的:"、甚至输出被截断。所以解析层必须有金刚不坏之身。我写的 extractJson() 是这样防的:
- 剥壳:先去掉可能的
json /围栏标记; - 直解:
JSON.parse直接试; - 掐头去尾:不行就用
indexOf("{")与lastIndexOf("}"),把第一个左花括号到最后一个右花括号之间的内容抠出来再解析。
三层全失败?那就在气泡里给玩家一句温和的提示,页面绝不白屏。"永远不白屏"是一条我特别坚持的底线——对用户来说,页面死了比答案丑可怕一百倍。
五、裁判权之争:本地词库还是 AI 裁判
接龙游戏绕不开一个灵魂拷问:凭什么判定"这是个成语"? 我把候选方案摊在桌面上比了比。
方案 A:让 AI 当裁判。零成本,但每次校验都是一次网络请求,费时、费钱,而且让"参赛选手"兼任"裁判",道德与可靠性上都不太体面。
方案 B:本地词库。毫秒级响应、可信、甚至离线可用,代价是要准备词库。
我选了 B 为主、A 为辅:本地内置 1300+ 条常用成语作为"硬裁判",任何命中检测都是 Set.has() 的 O(1) 判断,玩家感受不到延迟。同时对 AI 接的成语放宽一条——如果它在词库之外,只附一句淡提示"仅供参考",不判输。这样做的原因很实在:词库总有边界,AI 完全可能接出"守库成语"之外的冷门成语,把玩家和 AI 一视同仁地卡死在词库边界上,其实是在惩罚这个产品最有价值的部分(AI 的知识广度)。
词库文件里顺手构建了一个「首字 → 成语数组」的索引 IDIOM_BY_FIRST,它同时服务于两层需求:玩家输入时做尾字匹配提示;以及将来想做"提词器"功能时的候选来源。一个索引,两处复用,这是我很满意的一次小设计。
顺带聊聊词库的数据工程。1361 条成语来自常用成语的整理,入库之前我用脚本统一走了四道工序:去重(用 Set 把重复词条并掉)、四字过滤(把"更上一层楼"这类非四字条目剔除,保证接龙规则对双方一致)、首字建档(为每个成语生成首字索引)、抽样校对(抽查"人山人海""海阔天空"这类高频词必须命中)。这里有个规模化建议:将来如果要扩充词库,从权威词典或开源词库导入比手工整理快得多,但一定得做好"同字异形"的规范化——比如"惟妙惟肖"与"唯妙唯肖"这类写法差异,如果不统一,同一个成语会在库里以两种面貌存在,既浪费词条,又会让学生在接龙时被"卡"在写法上。
六、韧性工程:一次真实的 “输入框输入不了文字” 事故复盘
产品上线后的第一条用户反馈,措辞异常简洁:“输入框输入不了文字。”
6.1 现象与第一反应
这句话背后的用户视角是:页面一切正常,AI 开场成语也展示出来了,唯独底部输入框灰着,敲键盘毫无反应。而我的第一反应其实有点慌——毕竟输入框是一个产品最不该坏的地方。
6.2 排查:把所有"锁"列出来
冷静下来后,我意识到这个症状几乎只有一个嫌疑犯:输入框被 disabled 了。于是我把代码里所有与"禁用/恢复"相关的调用全部拉出来审了一遍,整理成一个状态机审计表:
setBusy(true):请求发出时禁用输入框、发送键、重置键;setBusy(false):请求结束(无论成败)时恢复三者。
然后逐个追踪 setBusy(true) 的每一处出口,是否都配对了一个 setBusy(false)。状态机的正确性检查,本质上就是一个"所有出口是否释放锁"的完整性检查。很快,狐狸尾巴露出来了。
6.3 根因:开局路径上的"单程出口"
游戏启动由 startGame() 驱动:它先调用 askAi(),而 askAi() 第一件事就是 setBusy(true)。请求成功后,回调分支做了很多事:解析 JSON、渲染卡片、更新待接字……唯独忘了 setBusy(false)。
对比一下,玩家回合的 handleAiResult() 里,成功分支和所有失败分支末尾都有 setBusy(false)。所以完整的故障画像就是:只有"开局"这一步是单程票——AI 开场成语成功返回后,忙锁永远不释放,输入框从此永世禁锢。
这个 Bug 的讽刺之处在于:它越晚被发现就越难发现。因为"开局时 AI 正常返回"是大概率路径,如果 AI 请求失败,askAi 的 catch 分支反而会正确地恢复状态。也就是说,一个"更坏"的情况(请求失败)不会触发 Bug,反而是"正常情况"触发了它。
6.4 修复与反思
修复本身只有两行:在 startGame 的成功分支和兜底分支末尾各补一个 setBusy(false)。但修复之外,我在开发日志里写下了两条长期有效的教训:
教训一:状态锁的每个出口都必须释放。 任何 setBusy(true) / showLoading() 之类的调用,代码评审的 checklist 里必须有一项"锁的释放路径完整性"。用一个简单的关键词搜索(setBusy(true) 与 setBusy(false) 的配对审计),就能捅破这一类 Bug。
教训二:为"正常路径"也写自动化冒烟测试。 修复之后,我没有只靠肉眼确认,而是用 Node 写了一个带 DOM mock 的冒烟脚本:模拟 fetch 流式返回一段合法的开场成语 JSON,然后断言 input.disabled === false、待接字出示正确、计数为 1。测试跑通的那一刻,才敢把"已修复"三个字说出口。没有测试背书,你的修复只是"暂时看起来好了"。
七、与不确定性共处:重试、上限与降级
大模型不是确定性函数,即使提示词约束到位,也可能发挥失常:接一个谐音而非正确的成语、首字对不上、输出被截断导致 JSON 解析失败。我的应对策略写成了三段代码:
第一段 · 有限重试。 解析失败、不是四字、首字不符,三种情况各触发一次重试,最多重试 2 次。为什么是 2 而不是无限?因为每一次重试都是真实请求与真实计费,无限重试 = 无限烧钱 + 潜在死循环。韧性不是不犯错,而是犯错之后有边界地恢复。
第二段 · 温和降级。 重试耗尽仍然失败,就给玩家一句"AI 暂时无法接上,请换一个成语继续",把责任从玩家身上轻轻摘走,语气松弛,游戏不中断。
第三段 · 前端预判。 玩家侧的常见错误(不在词库、首字不符、不足四字)全部在本地拦下,绝不把一个本地就能判死刑的请求浪费给 API。省的是真金白银,赚的是毫秒级反馈。
三段闭环之后,这个系统在"模型不可控"这个前提下,把不可控的伤害压低到了"最多多等一次请求"的程度。
八、验证方法论:四层防线,缺一不可
"能跑"和"没问题"之间隔着四层验证,每一层我都踩过坑:
第一层 · 语法层。 node --check 扫两个 JS 文件,秒级完成。这一层防的是低级错误。
第二层 · 词库质量层。 写脚本加载 idioms.js,验证成语总数(1361 条)、首字覆盖度(669 类)、抽样命中(比如 IDIOM_SET.has("人山人海") 必须为 true)。数据性资源尤其要防"看着有,用着没"。
第三层 · 真实 API 联调。 用 Node 直接发起与浏览器完全一致的请求,实测输入"人山人海"是否返回 海阔天空,字段是否齐全。这一层验证的不只是接口通不通,更是整条提示词工程的成色。
第四层 · 端到端冒烟。 就是我前面说的 DOM mock 测试——它不依赖浏览器,但能走通"加载 → 开局 → 流式返回 → 状态恢复 → 面板更新"的完整链路。正是这一层,抓住了"输入框输入不了文字"。
四层防线的价值在于:每一层都能逮住上一层漏掉的鱼。语法检查逮不住的业务 Bug,端到端冒烟能逮住;冒烟估计不到的提示词副作用,真实 API 联调能逮住。它们不是替代关系,是接力关系。
九、性能与体积:一次有洁癖的自查
纯前端游戏,加载速度就是第一印象。我给这块做了三件事:
第一件 · 零依赖自查。 整个项目没有 node_modules,没有构建产物,仓库里就是四个文件加一份文档。打开即用,无痛分发——这本身就是"性能"的最强形态。
第二件 · 词库索引化。 1361 条成语没有用遍历查找,而是进了 Set 和「首字 → 数组」的 Map。接龙校验的复杂度从 O(n) 降到 O(1),玩家的每一次输入反馈都是毫秒级。
第三件 · 无用逻辑剔除。 我审了几遍代码,删掉了几个"可能用得上的"过渡函数(比如那个从未被调用的 appendText)。每个没被调用的函数,都是未来某个人读代码时的认知负担。 代码体积这件事,少即是多。
十、安全与未来:冷启动之后的路线图
诚实地说,这个项目放弃了一部分安全性来换取"零后端":API Key 明文躺在前端 JS 里,任何打开页面的人都能从 DevTools 里把它拷走。所以 README 里我明确地写了两个边界:适合个人学习与本地演示;生产化必须改为"前端 → 后端代理 → AI API"。
如果这个项目还有下一站,我的清单是:
- 后端代理层:托管 Key、加限流、加鉴权,一次解决安全与费用失控;
- 请求缓存:高频成语的释义出处做缓存,省请求费、降延迟;
- 词库扩容:冷门成语也收进来,用索引 + 分片加载,不拖慢首屏;
- 难度分级:低端局只放高频词,高端局全面开放;
- 对战数据:最长接龙纪录、最冷门用典,都能做成排行榜,让"文斗"也有仪式感。
这里面每一条,都是把"能玩"推向"好玩"的具体路径。
十一、把经验带走:AI 小游戏六步法
做完这个项目,我试着把"AI 小游戏"这类产品的经验抽象成一份可复制的六步清单,给自己,也给你:
第一步 · 定契约。 先定义 AI 返回的 JSON Schema:字段名、含义、缺失时的兜底值都写死,并写进系统提示词。这是全项目最重要的一行字,值得花一个下午反复推敲。
第二步 · 立裁判。 决定游戏规则的判定权归属:本地数据能判的(词库、格式、首尾字),绝不上 AI;AI 判不准的(开放性结果),给"仅供参考"的宽容——裁判权分得越清,游戏越流畅,还越省钱。
第三步 · 控状态。 画一张状态机:空闲、请求中、解析中、重试中。每一个状态转换都要追问一句"锁在哪里释放",并把它写进代码评审的 checklist——"输入框输入不了文字"这个 Bug 就是这么被根治的。
第四步 · 布防线。 语法检查、数据校验、真实联调、端到端冒烟,四层验证按成本递增布置,缺一不可。没有测试背书的修复,只是"暂时看起来好了"。
第五步 · 造手感。 把玩家唯一的"焦点信息"(比如待接字)做成最强视觉锚点;把等待焦虑翻译成可读的提示(“思考中…”);把错误用"可操作的语言"讲出来,而不是一句冰冷的"输入错误"。
第六步 · 划边界。 明文 Key 的代价、词库的边界、重试的上限,都要在设计期想清楚并写进 README,把"取舍"变成"共识",而不是事后才发现的"事故"。
这六步不挑技术栈、不挑游戏类型,适用于任何"大模型 + 玩法"的迷你产品。把单点项目的经验沉淀成可迁移的方法论,是我在这次开发中收获的第二层红利——第一层红利,当然是那个能陪你无限接龙、还顺带教你成语的 AI 棋手。
十二、写在最后
回看这个项目,最有价值的部分其实不是任何一行代码,而是三句话:
- 把古老的游戏交给最新的技术:成语接龙有两千年的文化底色,大模型有当下的智力锋芒,让它们碰撞,本身就有传播价值;
- 用协议代替默契:给 AI、人类、页面三方签一份 JSON 契约,让系统的复杂度从"互相适应"降为"各守其职";
- 永远为不确定性留后路:重试、降级、剥壳解析、状态机审计、四层验证——工程的意义,就是让"偶尔失常"的大模型,在用户面前永远体面。
这就是"码道"上这一个项目教会我的东西。如果你也想做一个"AI + 传统文化"的小产品,希望这篇复盘能成为你的垫脚石——顺便提醒你:别忘了在开局路径的末尾,释放那把锁。
更多推荐


所有评论(0)