码道:用大模型做猜歌小游戏|纯前端流式歌词猜歌名 AI 开发实录

本文完整记录了一个基于 HTML5 + CSS3 + JavaScript 的 AI 对话项目「歌词猜歌名 AI 对话」从构思、设计、编码、联调到发布的全过程,包含大模型接口调用、SSE 流式解析、系统提示词工程、JSON 数据契约设计、前端界面实现与端到端测试等核心环节,希望为同样想动手做 AI 应用的朋友提供一份可复用的实战参考。
仓库地址:https://gitcode.com/IHYAMamg/sfjklsds.git
码道项目生成:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>歌词猜歌名</title>
<style>
/* ========== 基础样式 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
body {
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 50%, #0d1b2a 100%);
background-attachment: fixed;
overflow-x: hidden;
padding: 20px;
}
/* ========== 游戏容器样式 ========== */
#app {
width: 100%;
max-width: 600px;
}
.game-container {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
padding: 40px 30px;
}
/* ========== 游戏信息区样式 ========== */
.game-info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.game-info span {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 0.5px;
}
#difficulty-display {
background: rgba(99, 102, 241, 0.25);
border: 1px solid rgba(99, 102, 241, 0.4);
}
#score-display {
background: rgba(34, 197, 94, 0.25);
border: 1px solid rgba(34, 197, 94, 0.4);
}
#high-score-display {
background: rgba(251, 191, 36, 0.25);
border: 1px solid rgba(251, 191, 36, 0.4);
}
#streak-display {
background: rgba(244, 63, 94, 0.25);
border: 1px solid rgba(244, 63, 94, 0.4);
}
/* ========== 歌词展示区样式 ========== */
#lyrics-display {
min-height: 120px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
#lyrics-display p {
font-size: 26px;
font-weight: 500;
font-style: italic;
color: #ffd700;
text-shadow:
0 0 20px rgba(255, 215, 0, 0.5),
0 0 40px rgba(255, 215, 0, 0.3);
line-height: 1.6;
letter-spacing: 1px;
}
/* ========== 输入区样式 ========== */
.input-area {
display: flex;
gap: 12px;
margin-bottom: 25px;
}
#guess-input {
flex: 1;
padding: 15px 20px;
font-size: 16px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
outline: none;
transition: all 0.3s ease;
}
#guess-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
#guess-input:focus {
border-color: #6366f1;
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
}
/* ========== 按钮样式 ========== */
button {
padding: 15px 24px;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: 14px;
cursor: pointer;
transition: all 0.3s ease;
color: #fff;
letter-spacing: 0.5px;
}
#submit-btn {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
}
#submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
}
#submit-btn:active {
transform: translateY(0);
}
.action-area {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
#next-btn {
flex: 1;
background: linear-gradient(135deg, #3b82f6 0%, #0ea5e9 100%);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
min-width: 120px;
}
#next-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
}
#hint-btn {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4);
}
#hint-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.5);
}
#change-difficulty-btn {
background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%);
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
}
#change-difficulty-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.5);
}
/* ========== 反馈区样式 ========== */
#feedback {
margin-top: 25px;
padding: 18px;
border-radius: 14px;
text-align: center;
font-size: 18px;
font-weight: 500;
opacity: 0;
transform: scale(0.9);
transition: all 0.3s ease;
pointer-events: none;
}
#feedback.show {
opacity: 1;
transform: scale(1);
}
#feedback.correct {
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.5);
color: #4ade80;
animation: bounce 0.6s ease;
}
#feedback.incorrect {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
color: #f87171;
animation: shake 0.5s ease;
}
/* ========== 动画效果 ========== */
@keyframes bounce {
0%, 100% { transform: scale(1); }
25% { transform: scale(1.08); }
50% { transform: scale(0.98); }
75% { transform: scale(1.03); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10% { transform: translateX(-10px); }
20% { transform: translateX(10px); }
30% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
50% { transform: translateX(-6px); }
60% { transform: translateX(6px); }
70% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
90% { transform: translateX(-2px); }
}
/* ========== 响应式样式 ========== */
@media (max-width: 640px) {
.game-container {
padding: 30px 20px;
border-radius: 20px;
}
.game-info {
flex-direction: column;
align-items: stretch;
}
.game-info span {
justify-content: center;
text-align: center;
}
#lyrics-display p {
font-size: 20px;
}
.input-area {
flex-direction: column;
}
#guess-input {
font-size: 18px;
padding: 14px 18px;
}
#submit-btn {
width: 100%;
padding: 16px;
}
.action-area {
flex-direction: column;
}
#next-btn,
#hint-btn,
#change-difficulty-btn {
width: 100%;
}
button {
padding: 16px;
}
}
@media (max-width: 380px) {
#lyrics-display p {
font-size: 18px;
}
.game-container {
padding: 20px 15px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="game-container">
<!-- 游戏信息区 -->
<div class="game-info">
<span id="difficulty-display">难度:简单</span>
<span id="score-display">得分:0</span>
<span id="high-score-display">最高分:0</span>
<span id="streak-display">连击:0</span>
</div>
<!-- 歌词展示区 -->
<div id="lyrics-display">
<p>歌词将在这里显示...</p>
</div>
<!-- 输入区 -->
<div class="input-area">
<input type="text" id="guess-input" placeholder="请输入歌名...">
<button id="submit-btn">提交</button>
</div>
<!-- 反馈区 -->
<div id="feedback">
<p>反馈信息将在这里显示...</p>
</div>
<!-- 操作区 -->
<div class="action-area">
<button id="next-btn">下一题</button>
<button id="hint-btn">提示</button>
<button id="change-difficulty-btn">切换难度</button>
</div>
</div>
</div>
<script>
// ========== 歌曲库数据 ==========
const songLibrary = [
{
id: 1,
title: "晴天",
artist: "周杰伦",
lyrics: {
easy: "从前从前,有个人爱你很久",
medium: "故事的小黄花,从出生那年就飘着",
hard: "刮风这天,我试过握着你手,但偏偏,雨渐渐,大到我看你不见"
}
},
{
id: 2,
title: "七里香",
artist: "周杰伦",
lyrics: {
easy: "雨下整夜,我的爱溢出就像雨水",
medium: "窗外的麻雀,在电线杆上多嘴",
hard: "你突然对我说,七里香的名字很美"
}
},
{
id: 3,
title: "青花瓷",
artist: "周杰伦",
lyrics: {
easy: "天青色等烟雨,而我在等你",
medium: "素胚勾勒出青花,笔锋浓转淡",
hard: "冉冉檀香透过窗,心事我了然,宣纸上走笔至此搁一半"
}
},
{
id: 4,
title: "夜曲",
artist: "周杰伦",
lyrics: {
easy: "为你弹奏萧邦的夜曲,纪念我死去的爱情",
medium: "一群嗜血的蚂蚁,被腐肉所吸引",
hard: "我用漂亮的押韵,形容被掠夺一空爱情"
}
},
{
id: 5,
title: "稻香",
artist: "周杰伦",
lyrics: {
easy: "还记得你说家是唯一的城堡,随着稻香河流继续奔跑",
medium: "对这个世界如果你有太多的抱怨,跌倒了就不敢继续往前走",
hard: "不要哭,让萤火虫带着你逃跑,乡间的歌谣,永远的依靠"
}
},
{
id: 6,
title: "告白气球",
artist: "周杰伦",
lyrics: {
easy: "亲爱的,爱上你,从那天起,甜蜜的很轻易",
medium: "塞纳河畔,左岸的咖啡,我手一杯,品尝你的美",
hard: "你说你有点难追,想让我知难而进,礼物不需挑最贵,只要香榭的落叶"
}
},
{
id: 7,
title: "江南",
artist: "林俊杰",
lyrics: {
easy: "风到这里就是黏,黏住过客的思念",
medium: "圈圈圆圆圈圈,天天年年天天的我,深深看你的脸",
hard: "不懂怎么表现温柔的我们,还以为殉情只是古老的传言"
}
},
{
id: 8,
title: "曹操",
artist: "林俊杰",
lyrics: {
easy: "不是英雄,不读三国,若是英雄,怎么能不懂寂寞",
medium: "独自走下长坂坡,月光太温柔,曹操不啰嗦,一心要那荆州",
hard: "用阴谋阳谋,明说暗夺淡泊,东汉末年分三国,烽火连天不休"
}
},
{
id: 9,
title: "修炼爱情",
artist: "林俊杰",
lyrics: {
easy: "修炼爱情的悲欢,我们这些努力不简单",
medium: "凭什么要失望,藏眼泪到心脏,几年前的幻想,几年后的原谅",
hard: "几年前的歌,听了几百遍,心还是会疼,想你的时候,就唱这首歌"
}
},
{
id: 10,
title: "突然好想你",
artist: "五月天",
lyrics: {
easy: "突然好想你,你会在哪里,过得快乐或委屈",
medium: "最怕空气突然安静,最怕朋友突然的关心",
hard: "突然锋利的回忆,突然模糊的眼睛,我们像一首最美丽的歌曲"
}
},
{
id: 11,
title: "倔强",
artist: "五月天",
lyrics: {
easy: "我和我最后的倔强,握紧双手绝对不放",
medium: "逆风的方向,更适合飞翔,我不怕千万人阻挡,只怕自己投降",
hard: "我的手越肮脏,眼神越是发光,你不在乎我的过往,看到了我的翅膀"
}
},
{
id: 12,
title: "知足",
artist: "五月天",
lyrics: {
easy: "如果我爱上你的笑容,要怎么收藏怎么拥有",
medium: "当一阵风吹来,风筝飞上天空,为了你,而祈祷,而祝福,而感动",
hard: "怎么去拥有一道彩虹,怎么去拥抱一夏天的风,天上的星星笑地上的人"
}
},
{
id: 13,
title: "海阔天空",
artist: "Beyond",
lyrics: {
easy: "原谅我这一生不羁放纵爱自由,也会怕有一天会跌倒",
medium: "今天我,寒夜里看雪飘过,怀着冷却了的心窝飘远方",
hard: "多少次,迎着冷眼与嘲笑,从没有放弃过心中的理想"
}
},
{
id: 14,
title: "光辉岁月",
artist: "Beyond",
lyrics: {
easy: "钟声响起归家的讯号,在他生命里,仿佛带点唏嘘",
medium: "年月把拥有变做失去,疲倦的双眼带着期望",
hard: "今天只有残留的躯壳,迎接光辉岁月,风雨中抱紧自由"
}
},
{
id: 15,
title: "后来",
artist: "刘若英",
lyrics: {
easy: "后来,我总算学会了,如何去爱,可惜你,早已远去,消失在人海",
medium: "栀子花,白花瓣,落在我蓝色百褶裙上,爱你,你轻声说",
hard: "那时候的爱情,为什么就能那样简单,而又是为什么,人年深日久"
}
},
{
id: 16,
title: "小幸运",
artist: "田馥甄",
lyrics: {
easy: "原来你是我最想留住的幸运,原来我们和爱情曾经靠得那么近",
medium: "我听见雨滴落在青青草地,我听见远方下课钟声响起",
hard: "那为我对抗世界的决定,那为我淋的雨,一幕幕都是你,一尘不染的真心"
}
},
{
id: 17,
title: "演员",
artist: "薛之谦",
lyrics: {
easy: "简单点,说话的方式简单点,递进的情绪请省略,你又不是个演员",
medium: "其实说分开也不难,我也没那么难堪,反正感情总是这样",
hard: "该配合你演出的我演视而不见,在逼一个最爱你的人即兴表演"
}
},
{
id: 18,
title: "丑八怪",
artist: "薛之谦",
lyrics: {
easy: "如果像你一样总有人赞美,围绕着我的卑微也许能消退",
medium: "丑八怪,能否别把灯打开,我要的爱,出没在漆黑的舞台",
hard: "其实我根本没人说,其实我没你不能活,其实我给你的爱比你想的多"
}
},
{
id: 19,
title: "泡沫",
artist: "邓紫棋",
lyrics: {
easy: "全都是泡沫,只一刹的花火,你所有承诺,全部都太脆弱",
medium: "阳光下的泡沫,是彩色的,就像被骗的,我是幸福的",
hard: "美丽的泡沫,虽然一刹花火,你所有承诺,虽然都太脆弱"
}
},
{
id: 20,
title: "光年之外",
artist: "邓紫棋",
lyrics: {
easy: "缘分让我们相遇乱世以外,命运却要我们危难中相爱",
medium: "感受停在我发端的指尖,如何瞬间冻结时间",
hard: "我没想到为了你我能疯狂到,山崩海啸没有你根本不想逃"
}
},
{
id: 21,
title: "十年",
artist: "陈奕迅",
lyrics: {
easy: "十年之前,我不认识你,你不属于我,我们还是一样,陪在一个陌生人左右",
medium: "怀抱既然不能逗留,何不在离开的时候,一边享受,一边泪流",
hard: "直到和你做了多年朋友,才明白我的眼泪,不是为你而流,也为别人而流"
}
},
{
id: 22,
title: "因为爱情",
artist: "陈奕迅",
lyrics: {
easy: "因为爱情,不会轻易悲伤,所以一切都是幸福的模样",
medium: "给你一张过去的CD,听听那时我们的爱情,有时会突然忘了,我还在爱着你",
hard: "再唱不出那样的歌曲,听到都会红着脸躲避,虽然会经常忘了我依然爱着你"
}
},
{
id: 23,
title: "红玫瑰",
artist: "陈奕迅",
lyrics: {
easy: "得不到的永远在骚动,被偏爱的都有恃无恐",
medium: "梦里梦到醒不来的梦,红线里被软禁的红",
hard: "从背后抱你的时候,期待的却是她的面容,说来实在嘲讽,我不太懂"
}
},
{
id: 24,
title: "单车",
artist: "陈奕迅",
lyrics: {
easy: "难离难舍想抱紧些,茫茫人生好像荒野",
medium: "如孩童能伏于爸爸的肩膊,谁要下车,难离难舍总有一些",
hard: "不要不要假设我知道,一切一切也都是为我而做"
}
},
{
id: 25,
title: "小情歌",
artist: "苏打绿",
lyrics: {
easy: "就算大雨让整座城市颠倒,我会给你怀抱",
medium: "这是一首简单的小情歌,唱着人们心肠的曲折",
hard: "你知道,就算大雨让这座城市颠倒,我会给你怀抱,受不了,看见你背影来到"
}
},
{
id: 26,
title: "我好想你",
artist: "苏打绿",
lyrics: {
easy: "我好想你,好想你,却不露痕迹",
medium: "开了灯,眼前的模样,偌大的房,寂寞的床",
hard: "我还踮着脚思念,我还任记忆盘旋,我还闭着眼流泪,我还装作无所谓"
}
},
{
id: 27,
title: "遇见",
artist: "孙燕姿",
lyrics: {
easy: "我遇见谁,会有怎样的对白,我等的人,他在多远的未来",
medium: "听见,冬天的离开,我在某年某月,醒过来",
hard: "我往前,飞过一片时间海,我们也常在爱情里受伤害"
}
},
{
id: 28,
title: "开始懂了",
artist: "孙燕姿",
lyrics: {
easy: "开始懂了,快乐是选择",
medium: "相信你只是怕伤害我,不是骗我,很爱过谁会舍得",
hard: "我的眼泪你的战利品,我的爱情你的纪念品"
}
},
{
id: 29,
title: "红豆",
artist: "王菲",
lyrics: {
easy: "有时候,有时候,我会相信一切有尽头,相聚离开,都有时候",
medium: "还没好好的感受,雪花绽放的气候,我们一起颤抖,会更明白,什么是温柔",
hard: "可能在我左右,你才追求,孤独的自由,陪你把想念的酸拥抱成温暖"
}
},
{
id: 30,
title: "听海",
artist: "张惠妹",
lyrics: {
easy: "听海哭的声音,叹惜着谁又被伤了心,却还不清醒",
medium: "写信告诉我,今天海是什么颜色,灰色的,还是蓝色的",
hard: "一定不是我,至少我很冷静,可是泪水,就连泪水,也都不相信"
}
}
];
// ========== 游戏逻辑变量 ==========
let currentSongIndex = 0;
let currentDifficulty = 'easy';
let score = 0;
let highScore = localStorage.getItem('lyrics_guess_high_score') || 0;
let streak = 0;
let usedSongs = [];
// ========== DOM 元素 ==========
const lyricsDisplay = document.getElementById('lyrics-display');
const guessInput = document.getElementById('guess-input');
const submitBtn = document.getElementById('submit-btn');
const nextBtn = document.getElementById('next-btn');
const hintBtn = document.getElementById('hint-btn');
const changeDifficultyBtn = document.getElementById('change-difficulty-btn');
const feedback = document.getElementById('feedback');
const difficultyDisplay = document.getElementById('difficulty-display');
const scoreDisplay = document.getElementById('score-display');
const highScoreDisplay = document.getElementById('high-score-display');
const streakDisplay = document.getElementById('streak-display');
// ========== 难度配置 ==========
const difficultyConfig = {
easy: { text: '简单', next: 'medium', points: 10 },
medium: { text: '中等', next: 'hard', points: 20 },
hard: { text: '困难', next: 'easy', points: 30 }
};
// ========== 初始化游戏 ==========
function initGame() {
highScoreDisplay.textContent = '最高分:' + highScore;
startNewRound();
}
// ========== 开始新的一轮 ==========
function startNewRound() {
// 如果所有歌曲都用过了,重新随机
if (usedSongs.length >= songLibrary.length) {
usedSongs = [];
}
// 随机选择一首没用过的歌曲
let randomIndex;
do {
randomIndex = Math.floor(Math.random() * songLibrary.length);
} while (usedSongs.includes(randomIndex) && usedSongs.length < songLibrary.length);
currentSongIndex = randomIndex;
usedSongs.push(randomIndex);
// 显示歌词
const song = songLibrary[currentSongIndex];
lyricsDisplay.innerHTML = '<p>' + song.lyrics[currentDifficulty] + '</p>';
// 重置输入和反馈
guessInput.value = '';
feedback.className = '';
feedback.textContent = '';
// 更新显示
difficultyDisplay.textContent = '难度:' + difficultyConfig[currentDifficulty].text;
scoreDisplay.textContent = '得分:' + score;
streakDisplay.textContent = '连击:' + streak;
}
// ========== 提交答案 ==========
function submitAnswer() {
const userGuess = guessInput.value.trim();
if (!userGuess) return;
const song = songLibrary[currentSongIndex];
const isCorrect = userGuess.toLowerCase() === song.title.toLowerCase() ||
userGuess.includes(song.title) ||
song.title.includes(userGuess);
if (isCorrect) {
handleCorrectAnswer();
} else {
handleIncorrectAnswer();
}
}
// ========== 处理正确答案 ==========
function handleCorrectAnswer() {
const points = difficultyConfig[currentDifficulty].points;
score += points;
streak++;
// 更新最高分
if (score > highScore) {
highScore = score;
localStorage.setItem('lyrics_guess_high_score', highScore);
highScoreDisplay.textContent = '最高分:' + highScore;
}
// 显示正确反馈
feedback.className = 'show correct';
feedback.innerHTML = '<p>回答正确!</p><p>歌曲:' + songLibrary[currentSongIndex].title + ' - ' + songLibrary[currentSongIndex].artist + '</p><p>+' + points + ' 分</p>';
scoreDisplay.textContent = '得分:' + score;
streakDisplay.textContent = '连击:' + streak;
}
// ========== 处理错误答案 ==========
function handleIncorrectAnswer() {
streak = 0;
feedback.className = 'show incorrect';
feedback.innerHTML = '<p>回答错误!</p><p>正确答案是:' + songLibrary[currentSongIndex].title + '</p>';
streakDisplay.textContent = '连击:' + streak;
}
// ========== 切换难度 ==========
function changeDifficulty() {
currentDifficulty = difficultyConfig[currentDifficulty].next;
difficultyDisplay.textContent = '难度:' + difficultyConfig[currentDifficulty].text;
startNewRound();
}
// ========== 提示功能 ==========
function showHint() {
const song = songLibrary[currentSongIndex];
let hintText = '歌手:' + song.artist;
// 显示歌名首字
if (song.title.length > 0) {
hintText += ' | 歌名首字:' + song.title.charAt(0);
}
feedback.className = 'show correct';
feedback.innerHTML = '<p>提示信息</p><p>' + hintText + '</p>';
}
// ========== 事件监听 ==========
submitBtn.addEventListener('click', submitAnswer);
guessInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
submitAnswer();
}
});
nextBtn.addEventListener('click', startNewRound);
hintBtn.addEventListener('click', showHint);
changeDifficultyBtn.addEventListener('click', changeDifficulty);
// ========== 启动游戏 ==========
initGame();
</script>
</body>
</html>
一、缘起:为什么做了这个项目
1.1 一个偶然的想法
听歌猜歌名是很多人休闲时喜欢的小游戏。聚会的时候,有人哼一段歌词,其他人抢答歌名;短视频里也经常有歌词猜歌挑战。这类游戏门槛很低,不需要专业乐理知识,只要听过这首歌就能参与。猜歌游戏不只是简单的问答,它承载着听歌时的回忆,考验听歌积累与反应速度。
如今大语言模型(LLM)理解文本的能力很强,可以识别歌词、介绍歌曲信息,那让 AI 来当猜歌游戏的出题人兼裁判,会是什么体验?某天这个想法冒了出来:不再只是让 AI 单纯介绍歌曲,而是构建一套带回合机制、有答题对错判定的互动猜歌小游戏。于是「歌词猜歌名 AI 对话」项目就此诞生。
1.2 项目定位与目标
项目启动之前,我为它定下了几个明确的目标:
玩法闭环:AI 给出一段歌词 → 用户猜测歌名 → AI 校验答案 → AI 出下一题,形成完整回合游戏循环;
规则可解释:AI 不仅要判断对错,还要清晰告诉玩家猜对 / 猜错的原因,猜错时给出提示;
结果结构化:AI 回复不能是杂乱自由文本,输出结构化数据,方便前端渲染;
纯前端落地:不搭建后端服务,HTML + CSS + JavaScript 三个核心文件,零依赖、无需构建打包;
体验流畅:采用 SSE 流式输出,实现逐字生成效果,给用户实时反馈。
1.3 技术选型:为什么是纯前端三件套
在各类前端框架遍地开花的今天,我依旧选择最朴素的 HTML5 + CSS3 + JavaScript 组合,理由有三:
第一,零门槛交付。项目不依赖 Node.js 环境、不需要 npm 安装包,无需打包工具,任何设备只要能打开浏览器,就能直接运行,交付成本极低。
第二,教学价值最大化。剥离框架封装,SSE 流式解析、DOM 操作、fetch 请求底层原理全部直观展现。对于初学者,原生 JS 完整实现一遍,远比直接套用框架更容易理解大模型对话应用底层运行逻辑。
第三,部署极简。静态页面托管在任意静态站点服务(GitHub Pages、Gitee Pages、Nginx 等),几乎零运维成本。
选型不是炫技,核心初衷就是:更多人可以运行、看懂、二次修改这个项目。
二、需求拆解与方案设计
2.1 游戏规则的定义
做游戏项目,第一步就是定义清晰规则,规则模糊,后续开发都会根基不稳。我为歌词猜歌名设计了下面这套游戏规则:
出题规则:AI 每次抽取一段歌曲原文歌词,隐藏歌名,作为题目;歌词片段不宜过长也不宜太短,优先选择传唱度高的流行歌曲;
答案判定:玩家输入歌名,AI 判断是否匹配;支持别名,允许少量错别字容错;
提示规则:玩家答不出时,可以输入「提示」,AI 给出线索,例如歌手名、歌曲发行年份;
去重约束:本局游戏内,AI 不能重复出同一首歌曲的题目;
指令支持:玩家输入 “开始游戏”“重新开局”“下一题”“提示”,触发对应游戏逻辑。
这几条规则看着简单,却是系统提示词和前端逻辑判断的基础。规则描述越细致,AI 运行稳定性越强。
2.2 JSON 数据契约设计
项目核心设计点之一,就是定义大模型与前端之间的数据契约。猜歌游戏的一次 AI 回复同时携带多种信息:校验结果、提示文案、本轮歌词题目、正确歌名、歌手信息、额外提示。如果让 AI 自由输出自然文本,前端再去解析提取内容,代码脆弱,极易出错。
解决方案:强制 AI 输出合法 JSON 对象,契约定义如下:
```
{
"valid": true,
"message": "回答正确!太棒啦",
"lyric": "还记得你说家是唯一的城堡,随着稻香河流继续奔跑",
"song_name": "稻香",
"singer": "周杰伦",
"hint": "",
"isNewQuestion": true
}
```
字段设计遵循职责单一原则:
valid:布尔值,前端根据这个值渲染成功 / 失败样式;
message:展示给玩家的提示文本,答对祝贺,答错说明理由;
lyric:本轮展示给玩家的歌词片段,猜题核心内容;
song_name:歌曲正确名称;
singer:歌曲歌手;
hint:提示内容,玩家请求提示时填入,无提示为空字符串;
isNewQuestion:布尔标记,代表是否开启一道全新题目。
当 valid 为 false(答错)时,`isNewQuestion` 默认为 false,不会刷新歌词题目;玩家请求提示时 valid 保持原有状态,只填充 hint 字段。前端依靠这些字段,决定是否刷新题目卡片。契约先行,是提升开发效率最关键的一步。
2.3 系统提示词工程
数据契约确定之后,核心难点就是让大模型稳定按照契约格式输出。这就是系统提示词(System Prompt)工程发挥作用的地方。
我将系统提示词拆成三层结构:
角色与职责:开篇设定:你是歌词猜歌名小游戏的出题人兼裁判,使用简体中文,严格执行猜歌游戏规则;
规则枚举:逐条列出 2.1 中的全部游戏规则,重点标注:不能重复出题、歌词片段选择要求、答案容错、提示规则;
输出约束:最重要部分。强制**只能输出一段合法 JSON,禁止额外文字、注释、markdown 代码块标记**,逐字段解释含义,附带成功示例、失败示例。
实践发现:提示词内带上完整示例,模型输出稳定性提升非常明显。写入成功 JSON 样例和失败 JSON 样例之后,模型绝大多数情况都可以产出前端可直接解析的 JSON。
踩坑记录:早期版本我要求模型输出 ```json 包裹的代码块,流式解析时会连带 markdown 标记一起返回,JSON 解析直接报错。修改提示词,明确禁止 markdown 标记后,问题彻底解决。
三、Python 参考实现与 JavaScript 改写
3.1 原始参考代码分析
项目参考 Python 调用大模型接口示例代码,核心逻辑:通过 requests.post 携带 Authorization 请求头发起流式请求,逐行解析 SSE(Server-Sent Events)返回数据。
```
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"))
```
这段代码体现 SSE 协议核心特点:
数据使用 `data:` 前缀逐段推送;
每一行是独立 JSON 数据块(chunk);
流结束标识为 `data: [DONE]`;
每个 chunk 结构:`{"choices":[{"delta":{"content":"...","reasoning_content":"..."}}]}`。
理解 SSE 协议原理,才能够把代码迁移到其他编程语言。
3.2 JavaScript 改写的关键差异
不能直接把 Python 代码逐行翻译成浏览器 JS,浏览器环境和后端 Python 环境存在三处本质差异:
区别一:网络 API 不同。Python 依靠 requests 库做同步请求;浏览器端使用异步 fetch API。`requests.iter_lines()` 对应 `response.body.getReader()`;Python yield 生成器,在 JS 中依靠 while 循环 + await reader.read () 实现异步迭代读取流。
区别二:CORS 跨域限制。浏览器同源策略,跨域请求会触发 OPTIONS 预检请求。需要确认大模型 API 配置了正确的 Access-Control-Allow-Origin,并且请求头白名单包含 Authorization、Content-Type,否则浏览器直接拦截请求,联调阶段优先验证跨域。
区别三:字节流与字符串处理。Python 中处理字节流,使用`decode("utf-8")`;浏览器端使用`TextDecoder("utf-8")`解码。流式读取会遇到**粘包问题**:单次读取拿到的数据可能只有半行,需要维护缓冲区,按换行符分割完整行,剩余字符缓存,留给下一轮解析。
改写后的核心循环示意代码:
```
const reader = res.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 });
// 按换行分割完整行,过滤非data:开头数据
}
```
处理完粘包、半行、空行、[DONE] 结束标记等边界场景,前端打字机流式效果就实现了。
四、前端界面设计与实现
4.1 视觉风格的确定
猜歌项目自带音乐氛围感,视觉风格放弃冰冷科技感,选择柔和音乐主题:
主色:#3A4F70,静谧蓝,呼应音乐安静沉浸的氛围;
强调色:#E68A4E,暖橙色,突出歌词卡片、提示信息;
背景:柔和渐变 #f7f9fc → #eef2f8,阅读舒适;
消息气泡区分:AI 卡片浅蓝底色,用户消息暖灰底色,一眼区分人机。
整体采用「卡片 + 圆角 + 轻阴影」现代简约风格,把歌词作为核心视觉重点,营造听歌猜谜的轻松氛围。
4.2 布局与交互
页面采用经典的三段式纵向布局:
头部:标题「歌词猜歌名 AI 对话」+ 副标题 “根据歌词,猜猜是什么歌”;
消息区:可滚动对话流,AI 消息居左、用户消息居右,头像使用文字(“AI”/“我”),规避不同设备 emoji 渲染乱码;
输入区:文本框 + 发送按钮,支持回车发送,支持输入关键词:提示、下一题、重新开始。
核心设计细节是歌词卡片:AI 返回的歌词单独渲染卡片,左侧橙色装饰线条,依次展示歌词片段、提示信息,答对后自动展开歌曲名称与歌手信息。信息层级清晰,玩家第一眼聚焦歌词内容。
4.3 思考状态的呈现
接口返回的数据流中,delta 同时携带 reasoning_content(模型思考过程)和 content(最终回答)两类内容。如果都展示给用户,体验会很拖沓。我的处理策略是:
收到 reasoning_content 时,展示 "AI 正在构思歌词..." 动态提示(带闪烁光标动画);
一旦开始收到 content,立即切换为逐字渲染最终回答,思考提示自动消失。
这样既保留了流式输出的实时感,又不让思考过程干扰阅读。细节虽小,但对体验的提升非常明显。
五、端到端测试与踩坑记录
5.1 用 Playwright 做真实浏览器测试
项目完成后,不能只停留在 "代码能跑" 的层面。我引入了 Playwright 做端到端验证,脚本在本地起一个静态服务器,用无头 Chromium 真实模拟用户操作:
打开页面,等待页面加载完成;
输入 “开始游戏”,点击发送;
等待 AI 的歌词卡片渲染出来,截图存档;
输入歌曲名字进行作答,验证答对 / 答错两条分支;
抓取页面控制台的所有错误日志,作为回归依据。
测试脚本断言两条核心路径:歌词卡片正常渲染、答题对错逻辑正常,同时监控 console 错误。真机验证的价值在于,它能暴露纯静态分析看不见的问题 —— 比如某个元素没渲染、某个异步回调没触发、某个网络请求被 CORS 拦截。
5.2 踩过的四个坑
整个开发过程中踩坑不少,挑四个最有代表性的记录下来:
坑一:流式数据粘包。第一次实现时按行解析,发现偶发的 JSON 解析失败。排查后发现浏览器分块返回数据时,一个 chunk 里可能包含多行甚至半行,必须维护缓冲区按 \n 切分。这属于 SSE 解析的经典问题。
坑二:markdown 代码块污染。提示词早期要求 "输出 JSON 代码块",结果模型真的在开头结尾加了 `json 和 `,流式拼接后 JSON.parse 必挂。解决方案是提示词里明确 "禁止任何 markdown 标记",双管齐下:前端解析失败时也做了兜底提示,不让用户面对裸奔的报错。
坑三:favicon 404。测试时控制台报了一个 404,排查发现是浏览器自动请求 favicon.ico 导致的。用内联 SVG data URL 声明了图标后彻底解决。虽然不起眼,但既然是 "零报错交付",这些细节也应该处理掉。
坑四:答案匹配问题。早期模型判定答案过于严格,用户输入简称、少量错别字直接判定错误。优化提示词,增加 “别名容错” 规则,解决这个问题。
5.3 测试结果
最终端到端测试全部通过:答题、获取提示、下一题、重新开局链路正常、无控制台错误、无视觉布局问题。截图复核显示:文字清晰无乱码、卡片排版美观、信息层级分明。测试不只是 "验收",更是项目质量的一道保险。
六、部署、README 与仓库管理
6.1 部署方式
由于是纯静态项目,部署异常简单:python3 -m http.server 8000 即可本地运行,线上则可直接托管到任意静态站点服务。唯一需要注意的是,API Key 直接写在前端代码中属于公开信息,生产环境如需保密,应当增加一层后端代理转发请求,由代理持有 Key 并转发到上游大模型接口。
6.2 代码仓库规范化
项目代码写完后,我没有急着丢进仓库,而是做了三步规范化:
编写 .gitignore,排除工具目录与日志文件,保持仓库干净;
编写 README.md,包含项目简介、功能特性、技术栈、目录结构、快速开始、API 配置、JSON 契约说明、游戏规则共八个板块,让任何首次接触项目的人都能在五分钟内跑起来;
规范的提交信息,例如 初始化:歌词猜歌名 AI 对话页面(HTML5+CSS3+JS,SSE 流式输出 JSON),让历史可追溯。
README 的价值常常被低估:它既是给用户的说明书,也是给未来的自己的备忘录。写 README 的时候,正是重新审视整个项目的绝佳时机 —— 能不能让别人不看代码就理解你的设计?这一问,往往会倒逼出更清晰的项目表达。
七、项目成果展示
经过完整的开发、联调与测试流程,最终交付的项目包含四个文件:
```
├── index.html # 页面结构
├── style.css # 样式(音乐柔和蓝橙主题)
├── main.js # 逻辑:SSE 流式请求 + JSON 渲染
└── README.md # 项目说明文档
```
项目已推送到 AtomGit 平台,main 分支包含完整的可运行代码。整个项目的核心价值可以总结为三点:
一个可直接游玩的猜歌互动游戏:AI 给出歌词片段,玩家猜歌名,支持提示、下一题;
一份可复用的 SSE 流式解析范式:任何大模型对话应用都可以移植这段解析逻辑;
一套系统提示词工程的实践范本:如何通过 "角色 + 规则 + 输出约束 + 示例" 四件套稳定产出结构化 JSON。
七点五、常见问题 FAQ
在开发与分享的过程中,不少朋友问过类似的问题,这里集中整理回答:
Q1:为什么不用 WebSocket 而用 SSE?
大模型对话是典型的 "单向流" 场景 —— 客户端发一次请求,服务端持续推送。SSE(Server-Sent Events)基于 HTTP,天然支持这种模式,且实现简单、天然兼容断线重连。WebSocket 是双向全双工协议,功能更强,但对这个场景是 "杀鸡用牛刀",还要多维护一套连接状态。选型的原则永远是 "够用就好"。
Q2:流式输出和一次性输出有什么区别?
一次性输出要等模型生成完毕才返回,长回答往往要等十几秒才能看到第一个字,体验很差。流式输出边生成边推送,用户几乎零等待就能看到文字逐字蹦出来。这里的 "打字机效果" 不仅是视觉呈现问题,更是对注意力经济的尊重 —— 人在等待超过两秒就会产生焦躁情绪。
Q3:系统提示词里的 JSON 要求会不会被模型无视?
有可能,但不是无限度。增强约束效力的手段有:角色锚定、规则编号、显式示例、禁止性措辞(“禁止输出任何其他文字”),以及前端解析失败时的兜底提示。多重保险叠加后,模型名义上的 "自由度" 已经被压缩到了极小空间。实测几十轮游戏中,JSON 解析失败率趋近于零。
Q4:答案容错会不会导致 AI 误判?
容错是做了限制,只支持常见别名、轻微错别字,不支持完全无关的歌名。如果规则设置成完全严格匹配,用户输入 “七里香” 写成 “七里乡” 就判错,游玩体验会很差。在提示词中限定容错边界,平衡趣味性和准确性。
Q5:接下来最想加什么功能?
我最想做的是难度分级:简单模式歌词片段辨识度高;困难模式选取冷门歌曲歌词。另外增加计分系统,统计答对题目数量。如果接入后端,可以做游戏记录、排行榜,保存玩家的闯关记录。
八、复盘与展望
8.1 项目总结
复盘整个项目,我认为最值得借鉴的三条经验是:
第一,契约先行。 先定义清楚前端与大模型之间的数据格式,再动手写代码,开发效率至少提升一倍。AI 应用的难点之一就是 "模型输出不可控",而一份明确的输出契约加严谨的提示词,能把不可控性降到最低。
第二,规则即提示词。 游戏规则写得越明确,AI 的表现越稳定。把 "重复出题、答案容错、提示机制" 这些特例全部显式写进系统提示词,比让模型自己去 "领悟" 要可靠得多。
第三,验证靠真机。 静态检查替代不了真实浏览器测试。流式粘包、CORS、DOM 渲染这类问题,只有在真实环境里才会现形。
8.2 后续演进方向
项目目前是 MVP 版本,后续至少有三个明确的演进方向:
难度分级:区分简单 / 困难模式,简单使用热门歌曲,困难模式选用小众歌曲;
积分统计:记录答对题数、剩余答题机会,增加游戏竞技感;
歌曲拓展:答对歌曲之后,展示歌曲简介、创作背景,增加知识拓展内容。
如果未来接入后端,还可以做用户战绩持久化、猜歌排行榜等社区化功能。
8.3 一点感言
写代码的乐趣,在于把一个模糊的想法变成可以运行的现实。从 "让 AI 和我玩歌词猜歌名" 这个念头,到四个人人都能看懂的静态文件,再到浏览器里流畅的逐字猜歌对话 —— 整个过程有设计的兴奋,有踩坑的抓狂,更有跑通那一刻的成就感。
猜歌游戏是文字和旋律的碰撞,做项目其实也是。好的需求是前奏,扎实的实现是副歌,不断迭代打磨细节,才会有完整作品。这也是我把这篇文章放在「码道」下记录的原因 —— 技术之路,本就是这样一环扣一环地走下去。
回看整个开发过程,我越发觉得所谓的 "AI 应用开发",本质上是人机协作的接口设计:你要像对待一个聪明但需要明确指令的伙伴一样,把规则讲清楚、把边界划明白、把输出约束好,剩下的就交给它发挥。而作为开发者,我们要把注意力放在最容易出彩也最容易被忽视的地方 —— 体验的细节:流式的节奏感、思考状态的提示、卡片的层级、失败时的兜底文案。这些细节叠加在一起,才构成一个 "好用" 的产品的全部。
最后想对读到这里的读者说:如果你也有一个 "让 AI 做点什么有趣的事" 的念头,不要犹豫,把它变成一个小项目吧。不必追求宏大,一个歌词猜歌名、一个成语接龙、一个读书笔记助手,都足够让你跑通 "从想法到产品" 的完整链路。这条路上一旦启程,你会发现自己停不下来 —— 因为每完成一题,下一段歌词已经准备就绪。
愿每一个读到这里的开发者,都能在自己的项目里,找到属于自己的那首歌。
更多推荐


所有评论(0)