码道 · 歌词大比拼开发手记:AI 出上一句,等你来接下句
·
码道 · 歌词大比拼开发手记:AI 出上一句,等你来接下句
> 用 HTML5 + CSS3 + JavaScript 做了一个歌词竞猜 AI 对话小游戏:AI 主持人挑一首热门歌,说出其中一句歌词,玩家负责接出下一句,答对加分,答错给提示。核心工作有两块,一是把一段 Python 的流式对话代码翻译成浏览器里能跑的 JavaScript,二是设计一份让 AI 按状态机输出的系统提示词,让它的每句话都能直接变成页面上的卡片。这篇文章把整个开发过程如实记下来,包括玩法的由来、提示词的反复修改、接口验证时的折腾,以及最后发布到 GitCode 的收尾。
## 一、开头:作业又来了,但这次我想做点有胜负心的东西
上一门课的 AI 对话作业,我做的是成语接龙,纯前端,一个 HTML 文件就能打开。做完以后最大的感受是:AI 对话类项目其实是个特别好发挥的载体,接口是一套,但套上什么样的玩法,它就是什么样的产品。这一次的课程作业再次要求做一个 AI 对话类的项目,参考代码甚至和上次一模一样,还是那个 `requests` 流式调用,还是那个叫 `query` 的函数。
如果照抄上次的思路再做一遍,估计半天就交付了。但我不想这样。一方面是想试试自己能不能在同一个技术底座上做出风格完全不同的东西,另一方面是我注意到上次项目有个明显的短板:成语接龙是个"小圈子"玩法,成语库有限,玩几轮就容易绕回来,新鲜感掉得很快。
那什么东西天然量大、自带热度、人人都能插上话?我第一个想到的是歌词。
华语流行歌的存量足够大,随便挑几句都是现成的题库。关键是,歌词竞猜有一种天然的对错感:下一句唱什么,唱错了就是错了,对错清清楚楚,不需要太多裁判规则。这种"有标准答案"的属性,让 AI 主持人的角色变得很干净:它只需要出题、判定、揭晓、计分,而不用像成语接龙那样整天去裁决同音字这种灰色地带。
于是项目就定为:歌词大比拼,AI 出上句,玩家接下句,答对加分。
## 二、玩法设计:先想清楚"大比拼"比拼的是什么
定了主题以后,我没有急着写代码,先花了一个晚上把玩法在纸上画清楚。原因很简单,对话类的游戏,交互入口就一个输入框,玩法规则如果不够简单直接,玩家会在一分钟内失去耐心。
最终定下来的规则是这样:
- 玩家输入「开始」或点「出题」,AI 说出某首热门歌曲的一句歌词,比如「后来我总算学会了如何去爱」,要求玩家接下一句。
- 玩家在输入框里填自己猜的歌词,AI 判定对不对。判定标准我放宽了:语义一致就算对,允许个别字词的差异。比如「可惜你早已远去,消失在人海」和标准歌词「可惜你早已远去消失在人海」,就应当算对。
- 答对一题加 10 分,AI 揭开歌名和歌手,把上下句排在一起展示,然后自动出下一题。
- 答错了不给分,AI 给一句提示,从歌曲主题、歌手风格、歌词字数这些侧面帮玩家回忆,但坚决不直接说出歌名和歌手。
- 卡住了可以点「提示」,AI 揭示答案并换一题,这一题不计分。
- 点「结束」结算本局总分。
这套规则里有一个被我反复权衡过的点:谁来判定对错?答案很直接,交给 AI。因为歌词的下一句在模型的训练语料里是现成的,判定"这句话和标准答案是否语义一致"对模型来说并不困难。既然裁判和出题人都是 AI,那前端就只剩两件正事:维护对话状态,以及把 AI 返回的结构化数据渲染成好看的卡片。
计分没有做成复杂的规则,就是固定每题 10 分。我本来想过答错扣分、连续答对连击加成这些花样,后来都砍了。原因是我在纸上模拟了几轮以后发现,扣分机制会让玩家在不确定的时候不敢猜,而"敢猜"才是这种游戏最宝贵的部分。稳扎稳打加到 150 分,不如大起大落来得有意思,于是最后只保留了最基本的加分。
## 三、技术底座:把上次的流式翻译原样搬过来
接口调用这部分,我几乎直接复用了上次项目里验证过的代码。先看一眼这次的参考 Python 代码:
```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() in (b"data:[DONE]", b"data: [DONE]"):
return
yield json.loads(line.decode("utf-8").lstrip("data:"))
```
它在浏览器里对应的 JavaScript 版本,核心依然是一个 `fetch` 加一个 `ReadableStream` 的循环。我把上月写好的 `api.js` 直接拿过来改改用,逻辑一行没动,心里其实有点感慨,同一个函数,换一个系统提示词喂给它,它就从一个陪着玩成语的文人,变成了一个出题考人的音乐主持。
翻译过程中的几个关键点,在 README 里有一张完整的对应表,这里只说最核心的三条:
1. `requests.post(..., stream=True)` 对应 `fetch` 加上 `response.body.getReader()`,从响应体里一段一段读字节。
2. `iter_lines()` 帮 Python 按行切好了数据,JavaScript 没有这个现成功能,必须自己维护一个缓冲区,每次 decode 出新内容就按换行符切开,最后一行先留着,因为可能是不完整的。
3. `data:[DONE]` 是流的终止符,读到它就结束。
这三条看着简单,写的时候还是有几处抠细节的地方。最值得一提的就是 buffer 那个处理:服务端的字节不是按行到达的,一次 `read()` 可能跨过两条 data,也可能恰好一条 data 被劈成两截。我曾经因为没处理这个边界,看 `JSON.parse` 报错报得莫名其妙,排查了半天才发现是行被截断了。后来把"保留末行、等下一块补齐再处理"这个逻辑加上以后,这个报错就再没出现过。另外解析每一行 data 时我全程用 `try...catch` 包了一层,个别脏数据行直接跳过,不打断整个流,这一点和 Python 版本里逐行迭代出错的容忍度是一回事。
流式那边处理完,重头戏其实在提示词上。
## 四、系统提示词:让 AI 按状态机说话
想把一个对话接口变成游戏,光靠客户端解析自然语言是不够的,我的做法和上次成语接龙一致:在系统提示词里规定 AI 的输出格式,让它的每句话都是一个固定结构的 JSON,前端解析以后直接渲染。
但这次和上次有一个本质区别。成语接龙只需要一种返回结构,而歌词比拼的对话在五种状态之间流转,每种状态返回的字段还不一样:
- `question`,开局或者换题,只给一句新歌词当题目。
- `correct`,玩家答对,揭开歌名歌手,把上下句并排展示,送出新题。
- `wrong`,玩家答错,给提示线索。
- `give_up`,玩家跳过,揭示答案,换新题。
- `game_over`,玩家结束,结算总分。
这五个状态在系统提示词里被描述成一个完整的状态机,每一行的触发条件、该填哪些字段、哪些字段必须留空,都写得清清楚楚。比如要求"在玩家答对或跳过之前,不要透露歌名和歌手",就是防止 AI 嘴快把答案说漏了。这个约定在前两轮的测试里确实生效过,模型会老老实实把 `song` 和 `singer` 留空。
统一返回结构的示例被写死在提示词里,字段包括 `status`、`round`、`question_lyric`、`answer_lyric`、`song`、`singer`、`comment`、`hint`、`gained_score`、`total_score`、`next_question`。
字段不多不少,是我删了两轮以后定下来的。最初我加过 `album`、`year`、`difficulty`,后来全删了。理由很朴素:字段越多,模型在一轮回复里漏填的概率越大,前端拿到的卡片就越残缺。歌词卡片真正需要的就是那几样:题目、答案、歌名、歌手、提示、分数。
另外一个被我写进提示词的细节是出题约束:优先选传唱度高的中文流行歌曲,避免过于生僻;本局已经出现过的歌不要重复出。这直接从源头上保证了对局的体验,至少不会出现同一首歌隔三题再考一遍的尴尬。
## 五、验证实录:一场堪比赌博的验收过程
代码写完以后,我按老办法在命令行里做端到端验证:用 Node 把 `config.js` 和 `api.js` 读进来,eval 进全局作用域,然后模拟玩家一步步发消息。
第一轮验证很顺利,出题、答错、跳过、结束,四个状态全部符合预期。但等到我要单独验证"答对加分"这条路径时,遇到了一个没想到的麻烦:AI 的出题是随机的。
每一次跑测试脚本,模型都会从整个华语曲库里挑一句不同的歌词。第一轮它出了《平凡之路》的「我曾经跨过山和大海」,第二轮变成了《吻别》的「我和你吻别在无人的街」,第三轮是《七里香》的「窗外的麻雀,在电线杆上多嘴」,第四轮是《月亮代表我的心》的「你问我爱你有多深」。
我没办法在脚本里硬编码正确答案,因为我不知道它会考哪首歌。于是我想了个土办法:在测试脚本里维护一张关键词映射表,把常见题的正确答案都存进去,出题以后先检测关键词,命中就自动应答。
```javascript
const known = [
['山和大海', '也穿过人山人海'],
['吻别在无人的街', '让风痴笑我不能拒绝'],
['学会了如何去爱', '可惜你早已远去,消失在人海'],
['麻雀,在电线杆上多嘴', '你说这一句,很有夏天的感觉'],
['你问我爱你有多深', '我爱你有几分'],
// ... 连续补充了十几条,还是偶尔命中不了
];
```
这张表我一路补到十几条,仍有漏网之鱼。正当我准备放弃"强制答对"这个验证目标的时候,跑了大约第六七轮,脚本终于命中了一次,出题是《后来》的「后来我总算学会了如何去爱」,我自动应答了正确答案。终端里打出来的结果,现在还能背出来:
```
status: correct | gained: 10 | total: 10
歌名: 后来 | 歌手: 刘若英
标准下句: 可惜你早已远去消失在人海
下一题: 没有什么能够阻挡
```
判断题、加分题、揭晓题、换题题,四条路径一气呵成。那一刻是真的松了一口气。为了迎合无常的出题,我还被迫在脚本里建了一个小曲库,这算是验证过程送给我的额外纪念品吧。
## 六、验证中发现并修掉的两个问题
这次验证不是一帆风顺的,有两个问题是在跑流程时暴露出来并当场修掉的,我觉得比项目本身更值得记一笔。
第一个问题是 `game_over` 分支会丢掉计数。第一版提示词里我写的是"status 为 game_over 时,comment 写本局总结与总分,其余字段留空"。问题就在这里,"其余字段留空"把 `round` 和 `total_score` 也一起留空了。测试时我清楚地看到,结束一局后返回的 JSON 是 `"round":0,"total_score":0`,明明前面答对了一题,结算却显示零分。排查原因很容易,就是提示词写得不够精确。修复也简单,把这一行改成"round 填当前已进行的轮数,total_score 填当前累计总分"。
第二个问题是答错提示会剧透。`wrong` 状态要给提示,但我发现模型在给提示时管不住嘴,第一版它直接来了句「歌手是朴树,歌名《平凡之路》」,等于把答案全招了。这个细节很影响游戏体验,提示本来是让玩家再猜一次的,结果提示完答案已经明牌了。修法是收紧提示词,明确要求"从歌曲主题、歌手人数与风格、歌词字数特征等侧面提示,但不要在提示中直接说出歌名或歌手"。
这两个问题都是"提示词写得不够细"导致的,修复成本极低,但如果不做真实流程验证,它们都会变成上线后的体验事故。这也是我坚持每次都做端到端验证的原因,系统提示词这种东西,不跑到真实接口上走一遍,你永远不知道模型领会到了哪一层。
## 七、界面:这回收起朱砂,换成黑胶
上一版成语接龙我做的是国风,宣纸底、朱砂红、鎏金边。这一次歌词主题,我换了一套完全相反的视觉语言:深夜舞台风。页面背景是深紫色的夜空底,点缀着两团霓虹光晕,整个界面的气质从"书房"变成了"livehouse"。
最花心思的是一个装饰元素:黑胶唱片。我在没用任何图片素材的前提下,用纯 CSS 画了一张小唱片放在标题旁边。它的实现是一个圆形 div,背景叠了三层:最外层是 repeating-radial-gradient 画出的唱片纹路,一圈一圈深浅交替;中间是中心的标签区域,一圈鎏金描边;最里面是一圈同心圆模拟唱片孔。就这么一个 46 像素的小圆片,我调了大半个小时的渐变参数,最后的效果是它看起来真的像一张在转的黑胶。
其他的视觉约定包括:玩家消息是紫色渐变气泡,AI 出题卡片用鎏金标签,答对卡片用绿色标签配金色答案行,答错卡片配红色标签和半透明红底的提示框,结束结算直接用发光的金色大字号。所有颜色都走霓虹紫金这个基调,没有引入任何 UI 库。
歌词卡片的核心排版,我参照了卡拉OK的样式:题目的下一句后面放一个会闪烁的金色光标,暗示"轮到你唱了"。这个细节是从终端里的打字效果偷的师,浏览器里用 CSS keyframes 就能实现,成本几乎为零,但效果很讨喜。答对或跳过时,上下句会并排出现在一个深色块里,下一句用金色标出,歌名和歌手一左一右,歌名用白色加粗,歌手用青色,一眼就能扫到。
移动端的适配当初也费了点心思。聊天区用 flex 布局撑满整屏,输入区固定在底部,利用 `env(safe-area-inset-bottom)` 避开 iPhone 的 home 条。快捷按钮「出题」「提示」「结束」排在输入框上方一排,等分撑开,拇指不用够到角落。做这些事的时候我偶尔会犯嘀咕,就是这么一个小细节,把按钮做得大小合适、间距得当,真的能决定这个页面看起来是随手交差的作业,还是认真打磨过的作品。
## 八、再聊两句关于"裁判兼出题人"这件事
把出题和判题都交给同一个模型,这个设计我曾经犹豫过。按理来说,一个严肃的题库系统,出题和判定应该分离,题目来自静态曲库,判定用字符串匹配或者向量相似度,谁去裁判都不如确定性的算法可靠。但放进"娱乐向教学工具"这个场景里审视,交给模型的好处也很实在:零成本维护题库,不需要准备几百首歌的歌词数据库,每一次出题都是新鲜的,连歌词在网络上常见的变体都能自动容忍。
代价当然也有。模型偶尔会把一个明显不对的答案判成正确,或者反过来。我在测试里就见过一次,答非所问的句子被它放了过去,不过发生的概率很低,而且对娱乐场景来说,后果无非是这一题白送,玩家高兴还来不及。我在 README 的注意事项里专门提了一句"判对判错由模型完成,个别生僻歌曲可能判定偏差,以娱乐为宜",算是把丑话说在了前头。
如果有一天要把它升级成严肃的竞猜产品,正确的做法是把曲库和答案落到本地数据里,一次请求同时做"判分"和"取下一题",模型只负责跟玩家打圆场。这个方向我在文章的最后一节留了个念想。
## 九、文档和发布:README 里最有用的东西
项目写完后,照例给仓库写了一篇 README。这次我在文档结构上做了一点调整,加了一节「项目验证实录」,把命令行跑通的完整对局贴了进去,包括出题、答对、跳过的每一轮返回。加这一节的原因是我自己看别人的项目时最头疼的,就是文档写得漂漂亮亮,一跑起来根本不是那么回事。我把真实返回贴出来,至少能证明这套接口和提示词是能跑通的,比我写一百句"运行良好"都有说服力。
发布环节这次比上次利索。仓库地址是 `https://gitcode.com/2501_93886340/shiliubushiliu66.git`,初始是空的,HTTPS 加个人访问令牌推上去,一条命令。提交信息我写了四行正文,把"流式转换、五状态 JSON、计分玩法、黑胶界面"四件事列清楚,以后翻历史能直接看懂这次提交干了什么。
推送完成以后我照例用 `git ls-remote` 验证了一下远程分支的哈希,和本地提交一致才收工。这个习惯是上次项目养成的,推完不验证一下,心里总不踏实。
## 十、如果时间允许,我想继续做的事
收尾之前,照例把想要但还没做的功能列个清单,按优先级排:
第一,本地曲库落地。把常见歌曲的上下句做成静态数据,加上难度标签(传唱度越高难度越低),出题和判分都走本地逻辑,AI 只负责聊天和点评。这样判定精度和游戏稳定性都能上一个台阶。
第二,听歌识曲模式。把玩法从"看词答词"扩展成"给出首句放旋律"或者"给出歌名猜歌词",甚至接到浏览器大声播放,让玩法更立体。这个需要音频内容源,开发量会明显增加。
第三,双人对战。两个人轮流答题,AI 当裁判,答错或者超时换人,最后比较总分。这需要房间和实时通信,是另一个项目的大小了。
第四,难度动态调整。连续答对几题后,自动提高歌曲的冷门程度,反之则降到热门口水歌。这一条实现起来其实不难,只要本地曲库打好标签就可以,比前几条都务实。
要不要往下做,主要看它还会不会被课堂以外的场景使用。作为课程作业,这个项目到这里已经完整了。
## 十一、几句散场的感想
复盘整个项目,技术含量其实不高。流式接口的翻译是上个月就做过的老活,JSON 结构化提示词谈不上新方法,界面也是 CSS 的基础功。这个项目的价值恰好在于它让我把已经会的三样东西重新组合了一遍,而且组合后产生了明显大于各部分之和的效果:同样的 Python 参考代码,上个月它陪我玩成语,这个月它陪我猜歌词,中间只换了一份系统提示词。
这句话我想在今天特别记下来:接口是通用的,提示词决定了产品的性格。
代码在 `https://gitcode.com/2501_93886340/shiliubushiliu66`,打开 `index.html`,输一个「开始」,看看 AI 会考你哪首歌。愿你的下一句,永远对得上。


更多推荐


所有评论(0)