在这里插入图片描述

楔子:一次酒令,千年风雅

「飞花令」这个名字,相信很多人都不陌生。它源自古人的雅集酒令,行令者轮流吟诵含有指定字的诗句,接不上的便罚酒。唐代诗人元稹有诗云:「筹筋随宜设,觥筹逐次行」,说的便是这种往来酬唱的热闹场面。千年之后,当我把这个古老的文字游戏与当下最前沿的大语言模型放在一起时,一个念头冒了出来:为什么不让 AI 陪我对一场飞花令?

于是便有了今天的项目「飞花令 · AI 对诗」:一个纯前端实现的诗词对战小游戏,你与 AI 以飞花令的形式来往接句,AI 既是你的对手,也是你的裁判。

这篇文章,我想完整记录这个项目从灵感到落地的全过程,包括技术选型的思考、流式接口从 Python 到 JavaScript 的移植、系统提示词与数据契约的设计、古风界面的构建,以及一路踩过的坑。希望它对同样想用 AI 做点有趣事情的朋友有所启发。

一、项目背景:为什么是飞花令,为什么是 AI

在这里插入图片描述

1.1 飞花令的魅力

飞花令之所以流传千年,魅力在于它同时考验记忆、联想与文字运用能力。给定一个「花」字,你要在脑海中快速检索与「花」有关的诗句,而且要避免与前面人说过的重复。一句「花落知多少」,一句「花重锦官城」,一来一往之间,既是文采的较量,也是知识的比拼。

这种「检索记忆 + 即时联想 + 规则约束 + 防止重复」的组合,天然适合作为大语言模型的展示场景。它不像聊天那样随性,有明确的规则和判定标准,能让用户直观感受到大模型对中文古典诗词的掌握程度。

1.2 为什么选择大模型

传统方案通常用本地诗句库 + 关键词匹配来实现飞花令机器人,但这种方案有明显的天花板:诗句库规模有限、无法灵活评判用户输入的合法性、不能给出有温度的点评。而大模型的出现改变了这一切:

其一,大模型预训练语料中蕴含海量古诗词,无需预先建库,天然具备「诗句记忆」能力;

其二,模型具备推理与理解能力,可以充当裁判,判断你的出句是否合格、是否重复;

其三,模型的语言生成能力可以让对战变得有温度,每一句点评都像是与一位博学的诗友交谈。

基于这三点,我决定以对话式大模型能力为核心来构建这个项目。

二、技术选型:为什么是 HTML5 + CSS3 + JavaScript

项目定位是一个轻量的展示型应用,我最终选择「纯前端、无构建、无后端」的架构,原因如下:

  1. 零部署成本。三个核心文件(HTML、CSS、JavaScript),一个静态服务器即可运行,甚至直接双击 HTML 文件也能打开。

  2. 上手门槛低。不需要安装 Node、不需要运行构建工具,把仓库克隆下来就能跑。

  3. AI 能力通过 HTTPS 接口直接调用。OpenAI 兼容的 Chat Completions 接口天然支持浏览器直接请求,只要在请求头带上鉴权信息即可,前端就能完成全部交互闭环。

  4. 便于分享与演示。一个仓库就是一个完整项目,尤其在类似 CodeArts 码道这样的平台上,别人看到仓库就能立刻体验,无需复杂的环境说明。

当然,纯前端方案也有其固有的缺陷,比如 API 密钥会暴露在浏览器端(这个问题后面会专门讨论),比如无法做复杂的服务端鉴权与会话持久化。但对于一个「演示 + 娱乐」导向的项目来说,利远大于弊。

三、整体架构总览

项目结构非常简单清晰:

feihualingAIduihua/
├── index.html          # 页面入口
├── css/
│   └── style.css       # 古风样式(水墨 / 朱砂 / 靛青配色)
├── js/
│   ├── config.js       # 接口地址、鉴权信息、模型参数、关键字池
│   ├── api.js          # AI 流式调用封装(fetch + ReadableStream 解析 SSE)
│   ├── game.js         # 飞花令对局逻辑与系统提示词(JSON 输出契约)
│   └── ui.js           # 页面渲染、事件绑定、交互控制
└── README.md

从职责上看:

  • config.js 统一管理一切「写死」的配置:接口地址、鉴权 Token、模型名、生成参数(温度、top_p、max_tokens 等)、可用的飞花令关键字池;
  • api.js 负责与 AI 接口通信,将流式输出一段一段地送回给上层;
  • game.js 是整个游戏的「大脑」,负责构造系统提示词、维护对局状态(关键字、已用诗句、回合数)、解析 AI 的结构化返回;
  • ui.js 负责把状态渲染成页面,处理用户的输入与交互事件;
  • index.html 与 style.css 提供骨架与视觉呈现。

五个模块各司其职,彼此通过简单的对象引用协作,没有任何框架依赖,也没有任何第三方库,全部是浏览器原生能力。

四、核心难点一:把 Python 流式调用移植到 JavaScript

4.1 原始 Python 原型

项目的 AI 接口调用原型是用 Python 写的,这也是很多 AI 开发者最熟悉的形态:

import os
import requests
import json

API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
    "Authorization": f"Bearer csRseW3DJ-2EPSsdbexF3tjc",
}

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:"))

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"])

这段代码的核心逻辑非常清晰:以流式方式发送 POST 请求,逐行读取返回内容,只处理 data: 前缀的行,遇到 [DONE] 标记即结束,每一行数据都是一个 JSON,取其中的 choices 字段即可拿到增量内容。

4.2 浏览器端需要什么

浏览器与 Python 环境有几个显著差异:

  1. 没有 requests,也没有 iter_lines()。需要改用 fetch 配合流式读取;
  2. 浏览器端的流式读取要借助 ReadableStream + ReadableStreamDefaultReader;
  3. 因为是 SSE(Server-Sent Events)格式,每个数据块之间以 \n\n 分隔,而且一个数据行可能被网络传输切成多段,需要做缓冲拼接;
  4. 文本解码需要 TextDecoder,并且要设置 { stream: true } 以正确处理跨块的多字节字符。

4.3 移植后的核心实现

js/api.js 最终实现如下:

const AI = (function () {
  const API_URL = CONFIG.API_URL;

  async function queryStream(payload, { onDelta, onDone, onError, signal } = {}) {
    const body = Object.assign({}, CONFIG.PARAMS, {
      model: CONFIG.MODEL,
      messages: payload.messages,
    });

    let response;
    try {
      response = await fetch(API_URL, {
        method: "POST",
        headers: CONFIG.HEADERS,
        body: JSON.stringify(body),
        signal,
      });
    } catch (err) {
      if (err && err.name === "AbortError") {
        onDone && onDone("");
      } else {
        onError && onError("网络请求失败:" + err.message);
      }
      return;
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";
    let done = false;
    const content = [];

    while (!done) {
      const { value, done: readerDone } = await reader.read();
      done = readerDone;
      if (!done) {
        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;
          const dataStr = line.slice(5).trim();
          if (dataStr === "[DONE]") { done = true; break; }
          try {
            const chunk = JSON.parse(dataStr);
            const piece = chunk && chunk.choices && chunk.choices[0] && chunk.choices[0].delta;
            const text = (piece && piece.content) || "";
            if (text) {
              content.push(text);
              onDelta && onDelta(text);
            }
          } catch (e) { /* 忽略不完整的 JSON 行 */ }
        }
      }
    }
    onDone && onDone(content.join(""));
  }

  return { queryStream };
})();

这里有几个工程细节值得展开说明:

细节一:缓冲行。 SSE 的行可能被 TCP 分片切断,所以这里维护了一个 buffer,每次读取后按 \n 切分,最后一段(可能不完整)留在 buffer 里等下一次读取拼接。这正是 Python iter_lines() 在底层做的事情,浏览器端必须自己实现。

细节二:data: 前缀处理。 每一行要先 trim() 再判断是否以 data: 开头,取 slice(5) 获得真正的数据部分。注意不同实现可能在 data: 与内容之间有空格差异,所以 trim() 是必须的。

细节三:[DONE] 哨兵。 遇到结束标记时立即跳出循环,这是流式协议的约定,必须尊重。

细节四:AbortController。 通过外部传入 signal,可以让用户在请求返回前主动取消(比如点了「换字」或「重新开局」)。取消时主动调 onDone(""),让上层走「空结果」分支,而不是报错。

这个模块最终表现得非常稳定,实际测试中无论网络波动还是跨块汉字,都能完整还原出 AI 的完整回复。

五、核心难点二:系统提示词与 JSON 数据契约

「让 HTML 更好地显示」是本项目最初的一个明确需求,落到工程上其实就一句话:控制 AI 的输出格式。模型默认返回自然语言文本,但如果我们要在页面上把「诗句」「作者」「出处」「释义」分字段展示,就必须让模型输出结构化数据。

5.1 系统提示词的构造

js/game.js 中的 buildMessages 函数负责在每次请求时构建完整的消息序列。其系统提示词非常详尽,包含以下部分:

  1. 角色设定:「你是一位精通古典诗词的裁判兼对手,主持一场飞花令诗词对战」;
  2. 游戏规则:轮流吟诵含关键字的真实古诗、开局 AI 先出句、无效出句的判定标准(不含关键字 / 非真实古诗 / 重复)、AI 词穷需主动认输、已用诗句严禁重复;
  3. 当前战况:动态注入「已用诗句」列表,让 AI 在全局拥有「防重复」的知识;
  4. 当前回合的指令:开局时要求 AI 先出第一句;对战中要求 AI 先评判玩家上句再回新句;
  5. 输出要求:写明必须输出的 JSON 结构,逐字段说明含义。

这样一份提示词让 AI 的一次调用同时完成了「评判 + 接句 + 状态更新」三件事,把回合推进所需的全部信息打包在一个 JSON 里返回。

5.2 JSON 数据契约

前端与 AI 之间约定的数据结构如下:

{
  "valid": true,        // 玩家上一句是否有效(开局恒为 true)
  "reason": "点评内容",  // 对玩家上句的点评,无效时说明原因
  "verse": {
    "text": "白日依山尽",          // 诗句正文
    "author": "王之涣",            // 作者
    "title": "登鹳雀楼",           // 诗名
    "meaning": "夕阳依傍着西山慢慢沉没。" // 释义
  },
  "game_over": false,   // 对局是否结束
  "winner": null,       // "player" / "ai",未结束为 null
  "message": "给玩家的状态提示语"
}

这个契约的设计有几点考虑:

  • 用 valid + reason 承载裁判职能,前端可以根据 valid === false 高亮显示点评;
  • 用 verse 对象承载诗句的完整元信息,前端据此渲染卡片;
  • 用 game_over + winner 传递对局终局状态,前端据此弹出胜负宣告;
  • 用 message 承载一句引导语,始终给玩家明确的方向感。

5.3 流式数据的聚合与健壮解析

由于我们采用流式返回,AI 的 JSON 是逐字符、逐 token 传送过来的,不可能在中间时刻解析。所以前端采取「流式累积、结束后解析」的策略:onDelta 把文本累积起来,onDone 时统一解析。

解析使用 extractJson 函数:

function extractJson(text) {
  if (!text) return null;
  const cleaned = text.replace(/```json|```/g, "").trim();
  const start = cleaned.indexOf("{");
  const end = cleaned.lastIndexOf("}");
  if (start === -1 || end === -1 || end <= start) return null;
  try {
    return JSON.parse(cleaned.slice(start, end + 1));
  } catch (e) {
    return null;
  }
}

这个函数非常实用,原因在于:

  1. 有些模型不守规矩,会在 JSON 外面包一层代码围栏(json ... ),所以先 replace 把围栏剔除;
  2. 取第一个 { 到最后一个 } 之间的内容,可以容忍模型前后夹带少量废话;
  3. 即使这样仍解析失败,就返回 null,由上层走错误提示分支,绝不会让页面崩溃。

实际联调中,这套「格式化要求 + 容错解析」的组合拳效果非常好,模型绝大多数情况都能精确返回既定 JSON 结构。

5.4 一次完整的链路

把一次玩家出句的完整链路串起来看:

  1. 玩家在输入框输入「举头望明月」,前端本地先校验是否含关键字,不含则提前拦截提示;
  2. 校验通过后,GAME.ask 构造消息序列(包含最新的已用诗句列表),调用 AI.queryStream;
  3. 流式响应逐段累积,结束后 extractJson 解析出结构化结果;
  4. 若 AI 诗句非空,则追加进 usedVerses,回合数 +1;
  5. onResult 回调触发 UI 渲染:AI 席显示「点评 + 诗句卡片」,历史列表刷新,公告栏显示提示语;
  6. 若 game_over 为 true,则打开胜负宣告。

整个过程无感流畅,用户更像是在与一位诗友对谈。

六、核心难点三:对局状态与竞态处理

6.1 状态管理

游戏状态只有三个变量:

  • keyword:当前关键字;
  • usedVerses:已用诗句数组;
  • round:当前回合数。

这三个变量被封装在 GAME 模块内部,通过 getter 暴露读取接口,操作全部收敛在内部函数中(reset、ask、start、changeWord),杜绝了状态被随意改写的风险。

6.2 竞态:用户连点「换字」会怎样

在线程模型上,JavaScript 单线程,但异步请求是并发的。如果用户在 AI 回复前点了「重新开局」,理论上会出现「旧请求的回归结果覆盖新对局界面」的竞态。

解决方案是 AbortController:

function startNew(changeKeyword) {
  cancelStream();                    // abort 旧请求
  setBusy(true);
  abortController = new AbortController();  // 新建可取消句柄
  ...
}

旧请求被 abort 后,api.js 会走 onDone("") 分支,game.js 识别到空文本直接走 onAbort 回调,不触发任何 UI 更新。同时 ui.js 用 busy 标志锁定按钮,避免重复提交。两层防护基本消除了竞态问题。

6.3 防重复的「内」与「外」

防止重复诗句,项目做了两道防线:

  • 内部防线:usedVerses 列表随每次请求注入系统提示词,AI 在生成时必须参考已用列表避免重复;
  • 外部兜底:前端在玩家提交时只校验关键字,诗句重复与否交由 AI 裁判判定,因为模型借助已用列表通常能给出准确判断,且会将重复信息写入 reason 告知玩家。

七、界面设计:让古风触手可及

一个飞花令项目如果界面是现代扁平风,多少会有些违和。所以在视觉上我倾注了不少心思,目标是「打开页面就仿佛铺开一卷宣纸」。

7.1 配色系统

  • 宣纸底:#f7f2e7 打底,模拟熟宣的颜色与质感,再用两层径向渐变模拟「旧纸泛光」;
  • 墨色文字:#2b2b28 作主文字,#5c5a54 作辅助文字,浓淡两墨相映;
  • 朱砂点缀:#a13d2d 用于标题与主按钮,呼应印章的赭红色;
  • 靛青辅助:#3f5a6b 用于 AI 席位标识,形成冷暖对撞;
  • 鎏金描边:#b5893a 用于 hover 高亮与诗句卡片左边条,低调而有质感。

7.2 版式与细节

  • 左右双席位布局:AI 对阵席(蓝)与玩家席位(红),中间以回合计与公告栏衔接,战斗氛围十足;
  • 关键字印章:一个 52×52 的「朱砂色字块」,像钤在宣纸上的印章,一眼可见当前关键字;
  • 诗句卡片:左侧鎏金竖条 + 大号诗句 + 作者/诗名小字 + 释义灰字,信息层级分明;
  • 历史列表:玩家已出诗句以编号清单形式实时滚动展示,成就感可视化;
  • 楷体字体栈:Kaiti SC / STKaiti / KaiTi / Noto Serif SC,从系统楷体到思源宋体兜底,最大程度保持书写感;
  • 宣纸肌理背景:用 CSS 径向渐变加微小的点缀伪随机分布,模拟纸张的纤维颗粒。

7.3 交互反馈

  • 「出句」按钮在请求期间变为「对诗中…」并禁用,避免连点;
  • 无效出句用 toast 提示「你的诗句里没有『花』字,再想想~」;
  • AI 点评用斜体灰字呈现,与诗句卡片区分;
  • 对局结束弹出胜负横幅,胜者用 🏆 标记,仪式感拉满;
  • 输入框始终自动聚焦,回车即发送,操作路径极短。

所有这些细节加起来,最终呈现的是一个「打开即是画卷」的沉浸式体验。

八、踩坑记录与工程化反思

这个项目虽小,但过程中踩的坑不少,值得记录:

8.1 SSE 行缓冲的「幽灵问题」

第一个版本没有按行缓冲,直接 dataStr = value.split,导致中文字符被 TCP 分片切断时出现乱码。排查后发现两个问题叠加:一是 stream 需要按行拼接缓冲,二是 TextDecoder 必须传 { stream: true } 才能在跨块字符上正确解码。修复后乱码彻底消失。

8.2 JSON 围栏问题

部分模型返回时会习惯性给 JSON 包一层 ```````json ````代码围栏。如果前端直接 JSON.parse 必然失败。后来的 extractJson 兼容处理解决了这个问题,而且顺带容忍了模型夹带的任何前后缀文本。这是「让模型守格式」与「前端兜底」两条腿走路的典型场景。

8.3 abort 后的空回调

初版在用户主动 cancel 时会走到 onError,弹出一个生硬的错误提示。后来把「用户主动取消」与「真实错误」区分开:取消走 onDone("") + onAbort,只有真正的网络异常或 HTTP 非 2xx 才走 onError。这个区分让换字、重开等操作变得丝滑无感。

8.4 缓存导致的「旧版设置面板」

开发后期发现有些用户打开页面仍能看到旧版的「对局设置」弹窗(要求填写密钥与温度)。排查确认是新版本已经将 API 地址、鉴权 Token、温度等参数全部写死在 config.js 中,但浏览器缓存了旧版 HTML。解决方式是给 HTML 增加 no-cache 相关 meta,并给静态资源追加版本号查询参数(?v=2),强制刷新拿到最新版。这个细节提醒我:前端页面在更新迭代时,缓存策略必须一并进行。

8.5 配置写死的权衡

最终版把 API_URL 与 Authorization 完全写死在 js/config.js 中。这有利有弊:

  • 利:开箱即用,用户零配置;
  • 弊:密钥在前端必然暴露。任何打开页面的访问者都能在 DevTools 网络面板里看到 Bearer Token。

因此 README 中特别注明:该方案仅适合演示场景,生产环境应使用带额度限制的临时令牌,或改为服务端代理转发。这个权衡必须如实告知使用者,这也是一种工程诚信。

九、效果展示与体验

项目部署后,实际操作体验如下:

  1. 打开页面,AI 自动开局,随机抽到关键字(如「山」),并率先出句:「相看两不厌,只有敬亭山。」带作者、出处与释义卡片;
  2. 玩家输入「空山不见人」,回车提交,AI 席显示「评委:妙哉!」点评,并回一句「千山鸟飞绝」,同时标注柳宗元《江雪》与释义;
  3. 玩家输入不含关键字的句子会被前端直接拦截;输入重复诗句时 AI 会指出重复并判定无效;
  4. 多轮之后,若 AI 词穷会主动认输,弹出「🏆 AI 认输,这一局你赢了!」的胜局宣告;
  5. 点击「换字」可随机切换关键字开新局,点击「重新开局」则换回流程再战。

整套体验流畅自然,AI 对古诗词的掌握程度远超预期,它甚至能引用冷门诗句,并给出颇为准确的释义与出处,很多本地诗库方案完全无法比拟。

十、延伸思考:用数据契约驯服对话模型

这个项目最大的方法论收获,是「结构化输出」的价值。

很多人把大模型当作聊天机,认为它只能输出自然语言。但通过精心设计的系统提示词,完全可以要求模型输出指定 Schema 的 JSON,从而把模型能力接入更复杂的应用逻辑。这套方法可以推广到很多场景:

  • 让模型输出函数调用参数(Function Calling 的轻量版);
  • 让模型输出数据库查询条件(NL2SQL 的雏形);
  • 让模型输出多字段表单信息(信息抽取);
  • 让模型输出状态机的转移条件(游戏逻辑、对话流程编排)。

关键在于三点:Schema 要简单明确、字段要有详尽的中文说明、前端要有容错解析兜底。三者缺一不可。本项目就是这套方法论最小而完整的示范。

十一、未来规划

项目目前已完成核心闭环,未来如果继续演进,方向大约有四个:

  1. 引入差异化难度:按关键字生僻度分级(「花月春风」为入门,「龙、鹤、舟」为进阶),挑战性更强;
  2. 多人联机:利用轻后端或 WebRTC 局域网能力,实现真正的人人对战,AI 只做裁判;
  3. 语音交互:通过 Web Speech API 支持「口吟诗句」,进一步还原宴席间的行令氛围;
  4. 诗文知识库增强:在提示词之外挂载本地经整理的诗句索引,提升对冷门诗句判定的准确性。

结语:让代码也有一颗诗心

从一段 Python 流式调用的小原型,到一个可以顺畅对战、界面雅致的完整前端项目,整个过程让我最深的感受是:技术在干巴巴的接口和繁琐的工程之外,也可以很有趣、很有温度。

飞花令是千年前中国人的浪漫,大模型是这个时代最前沿的智慧结晶。把它们放在一起,无非是想说:技术的终点永远是服务于人与文化的需求。当我们用代码描述「落霞与孤鹜齐飞」时,代码本身也就有了一颗诗心。

感谢「码道」平台提供了如此便捷的托管与协作环境,让这个小小的项目可以轻松地被更多人看见、体验与改进。如果你也感兴趣,欢迎克隆仓库,与 AI 来一场跨越千年的飞花令对决——说不定,你能赢过它呢。


项目地址:https://atomgit.com/gcw_QdkCfEgt/feihualingAIduihua
技术栈:HTML5 + CSS3 + JavaScript(原生,零依赖)
核心能力:SSE 流式对话、结构化 JSON 输出、古风交互界面

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐