做一个“AI or Not”式的维基百科答题小游戏,核心并不是把两段文本并排展示给用户那么简单。真正的工程链路是:先从维基百科取到人类编辑撰写的真实词条摘要,再调用大模型生成同一主题的“AI 风格”文本,最后把两段文本随机打乱,让用户判断哪一段来自 AI,判完立刻给反馈并记录得分。
这类小游戏在 AI 应用开发里很有代表性。它不依赖高成本数据和复杂模型,只需要把维基百科 API、大模型接口和基础前后端组织起来,就能形成一个可交互、可演示、可扩展的完整产品。背后涉及的问题包括:如何区分人写文本和模型生成文本、大模型提示词如何稳定输出、接口异常如何降级、以及这类能力在内容标注和模型评测中怎么用。
适合读者:想用大模型做一个真实可玩项目的开发者,正在学习 FastAPI 或 Python 后端的前端工程师,以及对 AI 应用好奇的产品经理。全文会带你把服务完整搭起来,从环境准备到接口调试,最后给出常见问题排查和生产化建议。
1. 为什么用“维基百科文本”来做 AI 检测游戏
1.1 这个游戏到底在测什么
“AI or Not”类游戏表面上是在测用户,实际上是在测模型和文本风格。人类写的维基百科词条开篇通常追求信息密度,句子紧凑,结构稳定,习惯先给定义再展开背景。大模型生成的文本虽然表面上也有百科风格,但细节上容易出现几种可感知的特征:
- 喜欢用“值得注意的是”“在当今时代”这类过渡句。
- 信息密度偏低,一句话能说完的内容会扩展成三句。
- 偶尔出现事实错误,也就是 AI 幻觉。
- 语言非常“平均”,缺乏个人编辑带来的句式变化。
所以游戏判断的不只是内容真假,而是文本风格、信息密度和事实准确性。用户答对之后如果能看到模型原文和人类原文的差异,就天然理解了一次 AI 文本生成的特点。
1.2 维基百科作为题目源的四个优势
选择维基百科而不是自己积累语料,是因为它提供的题目源是现成、稳定且合法的。
| 优势 | 说明 |
|---|---|
| 数据现成 | REST API 直接返回词条摘要,不需要自己抓网页洗数据 |
| 覆盖话题广 | 随机词条可以覆盖技术、历史、地理、人物等多类主题 |
| 质量有保证 | 词条经过多轮编辑,开篇摘要相对规范,适合作为“人类文本”基准 |
| 内容协议清楚 | 维基百科内容遵循开放许可,做学习项目和产品原型时更容易说清来源 |
对学习项目来说,这意味着你可以把大部分精力放在题目逻辑和 AI 生成上,而不是花一整周去清洗语料。
1.3 为什么不是直接调用现成 AI 检测服务
市面上有商用 AI 检测服务,也能看到“某段文本是人类写的还是 AI 写的”的概率分数。但如果项目只用现成检测 API,你就无法控制题目生成过程,也无法解释检测结果背后的逻辑。更重要的是,检测服务通常只给一个概率,用户不知道这个概率是怎么来的。
自己实现“人类文本 + AI 生成文本”的组合,等于把整个链路拆开:哪一段是人类写的、哪一段是 AI 写的,在服务端是已知的。这样你能验证提示词对文本风格的影响,也能控制题目难度,甚至后续可以把用户答案收集成标注数据集。这个学习价值是单纯调用检测 API 给不了的。
2. 系统设计与一回合的数据结构
2.1 模块划分
整个服务按职责拆成五个模块,各自只处理一件事:
| 模块 | 职责 | 关键技术点 |
|---|---|---|
| wikipedia_client | 获取真实词条摘要 | 调用维基百科 REST API,清洗 extract |
| ai_generator | 生成 AI 风格同主题文本 | 调用 OpenAI 兼容接口,控制提示词 |
| quiz_service | 组装题目、洗牌、判题 | 维护题目 ID、选项 ID、正解 |
| api 层 | 暴露 HTTP 接口 | FastAPI 路由,负责请求参数校验 |
| 前端 | 展示题目、接收选择 | 原生 HTML/CSS/JS,fetch 调用接口 |
这样拆分的好处是:你可以单独测试维基百科接口是否可用,也可以单独测试模型生成的文本质量。如果以后要换成别的百科源或者别的模型,只需要替换对应模块。
2.2 一回合完整数据流
先梳理清楚一次答题从开始到结束经过哪些环节,再写代码就不容易乱。
- 用户点击“下一题”,前端请求
GET /api/quiz。 - 后端调用维基百科随机词条接口,拿到词条标题和人类摘要。
- 后端把标题和人类摘要发给大模型,要求写一段同主题百科简介。
- 后端把人类文本和 AI 文本打乱,生成两个选项,并记录哪个选项是 AI。
- 把题目 ID、词条标题、两个选项文本返回前端。
- 用户选择 A 或 B,前端请求
POST /api/answer。 - 后端根据题目 ID 取出正解,比较用户选择,返回是否正确和当前得分。
注意第 3 步是最耗时的一步。大模型生成通常需要 1 到 5 秒,所以接口设计时不要把“生成题目”和“判题”放在一个请求里做完而不考虑超时;实际项目中可以拆成“创建题目”和“获取结果”两步,或者先做异步生成。
2.3 关键取舍:题目实时生成还是预生成
实时生成的优点是不需要存储大量题目,每次请求都是新题;缺点是用户每次等待模型返回,而且维基百科接口和模型接口任何一个抖动都会影响体验。
预生成的优点是把耗时前置,管理员提前批量生成 100 题存在数据库里,用户打开页面秒出题;缺点是题目库会陈旧,而且生成成本固定。
学习阶段建议先做实时生成,逻辑更直观。等到产品需要稳定体验时,再加一个本地缓存:把生成过的题目存到 SQLite 或 Redis,标题相同的题目直接复用。下面代码都按实时生成的最小闭环来写。
3. 环境准备与依赖安装
3.1 运行环境要求
开发环境建议使用 Python 3.10 及以上,因为 FastAPI 和 Pydantic 的较新版本依赖新语法。操作系统不限,Windows、macOS、Linux 都可以。
依赖项如下表所示,安装时以你当前环境能拉到的最新稳定版本为准。
| 依赖包 | 作用 |
|---|---|
| fastapi | Web 框架,提供 API 路由和参数校验 |
| uvicorn | ASGI 服务器,用来启动 FastAPI |
| httpx | 发起维基百科 HTTP 请求 |
| openai | 调用大模型接口的官方 SDK |
| python-dotenv | 读取 .env 环境变量文件 |
开发环境需要能访问维基百科的 REST 接口,以及你的模型供应商接口。生产环境还要注意网络策略、超时时间和密钥管理,这些在排错一节会提到。
3.2 项目结构与依赖文件
先建立项目目录,目录结构保持简单,方便后面扩展。
ai-or-not-quiz/ ├── app.py # FastAPI 入口 ├── quiz/ │ ├── __init__.py │ ├── wikipedia_client.py # 维基百科摘要获取 │ ├── ai_generator.py # AI 文本生成 │ ├── quiz_service.py # 题目组装与判定 │ └── models.py # Pydantic 数据模型 ├── static/ │ ├── index.html │ ├── style.css │ └── app.js ├── requirements.txt ├── .env.example └── README.mdrequirements.txt 内容如下:
fastapi uvicorn[standard] httpx openai python-dotenv安装依赖:
pip install -r requirements.txt如果你的环境同时存在多个 Python 版本,建议先创建虚拟环境再安装:
python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txtWindows 下激活虚拟环境命令是.venv\Scripts\activate,注意区分。
3.3 环境变量配置
模型密钥不能写死在代码里,所以通过环境变量注入。新建.env文件,内容参考.env.example:
LLM_API_KEY=你的模型APIKey LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini如果你的大模型服务使用 OpenAI 兼容接口,LLM_BASE_URL指向对应服务地址即可。没有填写LLM_API_KEY时,程序应该能启动但生成题目的接口返回明确错误,而不是直接崩溃。
4. 实现维基百科摘要获取模块
4.1 选择随机词条接口
维基百科提供 REST API,其中/page/random/summary可以直接拿到一个随机词条的摘要。这个接口的好处是响应体已经包含标题、摘要和页面链接,不需要再解析 HTML。
实现quiz/wikipedia_client.py:
import httpx WIKIPEDIA_REST_BASE = "https://en.wikipedia.org/api/rest_v1" def fetch_random_article_summary() -> dict: url = f"{WIKIPEDIA_REST_BASE}/page/random/summary" headers = {"User-Agent": "ai-or-not-quiz/0.1 (learning project)"} with httpx.Client(timeout=10.0, follow_redirects=True) as client: resp = client.get(url, headers=headers) resp.raise_for_status() data = resp.json() extract = data.get("extract", "").strip() if not extract: raise ValueError("维基百科摘要为空,请重新获取") return { "title": data.get("title", ""), "extract": extract, "pageid": data.get("pageid"), "url": data.get("content_urls", {}).get("desktop", {}).get("page"), }关键点:
timeout=10.0防止请求长时间挂起。User-Agent要声明项目名和用途,维基百科文档建议这样做,能降低被限流的概率。extract字段就是词条开篇的纯文本摘要,默认已经很干净。
如果想按指定主题出题,可以使用按标题获取摘要的接口:
from urllib.parse import quote def fetch_article_summary_by_title(title: str) -> dict: encoded_title = quote(title.replace(" ", "_")) url = f"{WIKIPEDIA_REST_BASE}/page/summary/{encoded_title}" # 后续逻辑与 fetch_random_article_summary 一致4.2 读取和清洗 extract 字段
extract虽然是纯文本,但直接使用时还要做三件事:
- 去掉首尾空格。
- 控制长度。有些词条摘要比较长,超过 300 个单词后,用户阅读负担会增加。
- 判断文本是否过短。如果摘要只有几个单词,说明这个词条可能质量较低,建议放弃并重新随机。
长度控制和异常处理可以合并成一个函数:
def normalize_extract(extract: str, max_chars: int = 600) -> str: text = extract.strip().replace("\n", " ") if len(text) < 80: raise ValueError("摘要过短,不适合作为题目") if len(text) > max_chars: text = text[:max_chars].rsplit(" ", 1)[0] + "..." return text这里用rsplit(" ", 1)在空格处截断,避免把一个单词切成两半。
4.3 中英文词条与语言切换
基础版本使用英文维基百科,词条覆盖广,模型对英文百科风格也更熟悉。想支持中文,只需把域名换成https://zh.wikipedia.org:
WIKIPEDIA_REST_BASE = "https://zh.wikipedia.org/api/rest_v1"同时 AI 生成模块的提示词也要改成中文,否则会出现“英文模型生成英文,中文词条生成中文”的语言错位。建议把语言作为配置项,不要写死。
5. AI 同主题文本生成模块
5.1 提示词设计:让模型写出“像维基百科”的文本
提示词决定题目难度。如果提示词太弱,AI 文本一眼就能看出来;如果提示词太强,模型可能直接复制人类摘要,用户读起来像同源文本。
推荐提示词要强调三点:客观语气、独立写作、接近百科开篇的信息密度。
SYSTEM_PROMPT = """你是一名百科词条编辑。请用客观、中立的语气,写一段关于指定主题的百科简介。 要求: 1. 全文 120 到 180 个英文单词。 2. 不要使用“作为一个AI模型”“我不能”等表述。 3. 不要输出标题,直接输出正文。 4. 不要复制参考文本,而是基于主题独立写作。 5. 信息密度尽量接近维基百科的开篇段落。"""注意,提示词里明确“不要复制参考文本”是为了保证 AI 文本和人类文本不完全重复,否则游戏会退化成“两段内容是否相同”,而不是“风格是否像 AI”。
5.2 调用 OpenAI 兼容接口
使用 openai 官方 SDK,并支持通过base_url切换到其他兼容服务,这是最常见的接入方式:
import os from openai import OpenAI client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"), ) def generate_ai_extract(title: str, human_extract: str) -> str: user_prompt = ( f"主题:{title}\n" f"参考背景(不要直接复制):\n{human_extract[:500]}\n" "请写一段独立的百科简介。" ) resp = client.chat.completions.create( model=os.getenv("LLM_MODEL", "gpt-4o-mini"), messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=0.7, max_tokens=300, ) text = resp.choices[0].message.content.strip() if not text: raise ValueError("模型返回空文本") return text5.3 控制输出长度和稳定性
大模型生成不是确定性的,同一提示词两次结果可能不同。为了控制题目质量,需要理解几个参数:
| 参数 | 作用 | 建议值 | 调大影响 | 调小影响 |
|---|---|---|---|---|
| temperature | 控制随机性 | 0.7 | 文本更多样,可能跑题 | 更稳定,但容易重复 |
| max_tokens | 限制最大输出长度 | 300 | 可输出更长文本 | 可能截断正文 |
| top_p | 采样范围 | 默认即可 | 更发散 | 更保守 |
如果题目需要“让用户更难猜”,可以尝试temperature=0.9;如果作为评测基准,建议固定temperature=0.3到0.5,保证结果相对稳定。实际中不要同时调大temperature和top_p,会让输出不可控。
6. 题目组装与判定服务
6.1 题目数据模型
拿到两段文本后,把它们封装成一个题目对象。用 Pydantic 定义模型,方便接口层做响应序列化。
from pydantic import BaseModel class QuizOption(BaseModel): id: str text: str is_ai: bool class QuizQuestion(BaseModel): question_id: str title: str options: list[QuizOption] class QuizAnswer(BaseModel): question_id: str selected_id: stris_ai字段只存在于服务端,真正返回给前端的时候要把它剥离,否则用户看一眼 JSON 就知道答案了。这里先讲清楚模型,接口层返回时再处理。
6.2 随机洗牌与答案记录
洗牌是保证游戏公平的关键。如果不洗牌,AI 文本永远固定在 A 或 B,用户连续玩几次就能猜出规律。
import random import uuid class QuizRound: def __init__(self, title: str, human_text: str, ai_text: str): self.question_id = uuid.uuid4().hex self.title = title self.options = [ {"id": "A", "text": human_text, "is_ai": False}, {"id": "B", "text": ai_text, "is_ai": True}, ] random.shuffle(self.options) self.answer_id = next( opt["id"] for opt in self.options if opt["is_ai"] ) def public_view(self) -> dict: return { "question_id": self.question_id, "title": self.title, "options": [ {"id": opt["id"], "text": opt["text"]} for opt in self.options ], } def check(self, selected_id: str) -> bool: return selected_id == self.answer_id这里用uuid生成题目 ID,避免自增数字被用户猜测。答案只保存在内存对象里,判题时按question_id取出。
6.3 计分与会话管理
最小闭环可以用内存字典保存题目和分数,简单但不适合多进程部署:
class QuizStore: def __init__(self): self.rounds: dict[str, QuizRound] = {} self.scores: dict[str, int] = {} quiz_store = QuizStore()生产环境需要把question_id和分数存到 Redis 或数据库,并且设置过期时间,防止内存无限增长。学习阶段用内存即可,但要清楚重启服务后所有数据会丢失。
7. FastAPI 接口实现
7.1 接口设计
接口设计要遵循一个原则:用户只拿到必要信息,服务端保留答案和分数。
| 接口 | 方法 | 请求参数 | 返回内容 |
|---|---|---|---|
| /api/quiz | GET | 无 | 题目 ID、标题、两个选项文本 |
| /api/answer | POST | question_id, selected_id | 是否正确、正解选项 ID、当前分数 |
| / | GET | 无 | 前端页面 |
没必要把“生成题目”和“获取正解”放在同一个接口里,那样用户可以直接通过网络请求看到答案字段。
7.2 核心接口代码
实现app.py:
import os from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from quiz.ai_generator import generate_ai_extract from quiz.models import QuizAnswer, QuizQuestion from quiz.quiz_service import QuizRound, QuizStore from quiz.wikipedia_client import ( fetch_random_article_summary, normalize_extract, ) load_dotenv() app = FastAPI(title="AI or Not Quiz") store = QuizStore() app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/api/quiz") def create_quiz(): try: article = fetch_random_article_summary() human_text = normalize_extract(article["extract"]) ai_text = generate_ai_extract(article["title"], human_text) except Exception as exc: # 这里要记录完整异常到日志,给用户的提示保持简洁 raise HTTPException(status_code=503, detail=f"题目生成失败: {exc}") round_obj = QuizRound(article["title"], human_text, ai_text) store.rounds[round_obj.question_id] = round_obj return round_obj.public_view() @app.post("/api/answer") def submit_answer(answer: QuizAnswer): round_obj = store.rounds.get(answer.question_id) if round_obj is None: raise HTTPException(status_code=404, detail="题目不存在或已过期") correct = round_obj.check(answer.selected_id) if correct: store.scores["default_user"] = store.scores.get("default_user", 0) + 1 return { "correct": correct, "answer_id": round_obj.answer_id, "score": store.scores.get("default_user", 0), }注意:generate_ai_extract和fetch_random_article_summary都是同步阻塞调用,FastAPI 的异步事件循环会被阻塞。学习项目里问题不大,但并发上来后要改成async或放入线程池。这一点在最佳实践里会再强调。
7.3 请求与响应示例
创建题目:
curl http://127.0.0.1:8000/api/quiz响应示例:
{ "question_id": "3f9a1c2e8b6d4f0a9e7c1a2b3c4d5e6f", "title": "Machine learning", "options": [ { "id": "A", "text": "Machine learning is a field ..." }, { "id": "B", "text": "Machine learning (ML) is a field of study ..." } ] }提交答案:
curl -X POST http://127.0.0.1:8000/api/answer \ -H "Content-Type: application/json" \ -d '{"question_id": "3f9a1c2e8b6d4f0a9e7c1a2b3c4d5e6f", "selected_id": "B"}'响应示例:
{ "correct": true, "answer_id": "B", "score": 1 }8. 前端交互页面
8.1 页面结构
前端保持极简,负责三件事:拉取题目、展示两个选项、提交答案。static/index.html核心结构如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AI or Not Quiz</title> <link rel="stylesheet" href="/static/style.css"> </head> <body> <main> <h1>哪一段文字是 AI 写的?</h1> <p id="title">加载中...</p> <div id="options"></div> <p id="result"></p> <button id="next" style="display:none;">下一题</button> </main> <script src="/static/app.js"></script> </body> </html>8.2 用 fetch 调用接口
static/app.js实现加载题目和提交答案:
const titleEl = document.getElementById("title"); const optionsEl = document.getElementById("options"); const resultEl = document.getElementById("result"); const nextBtn = document.getElementById("next"); let currentQuestionId = null; async function loadQuiz() { resultEl.textContent = ""; nextBtn.style.display = "none"; optionsEl.innerHTML = ""; const resp = await fetch("/api/quiz"); const data = await resp.json(); currentQuestionId = data.question_id; titleEl.textContent = data.title; data.options.forEach((opt) => { const card = document.createElement("button"); card.className = "option"; card.textContent = opt.text; card.onclick = () => submitAnswer(opt.id); optionsEl.appendChild(card); }); } async function submitAnswer(selectedId) { const resp = await fetch("/api/answer", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question_id: currentQuestionId, selected_id: selectedId, }), }); const data = await resp.json(); if (data.correct) { resultEl.textContent = `答对了!正解是 ${data.answer_id},当前得分 ${data.score}`; } else { resultEl.textContent = `答错了,正解是 ${data.answer_id},当前得分 ${data.score}`; } nextBtn.style.display = "block"; } nextBtn.onclick = loadQuiz; loadQuiz();这里把选项渲染成按钮,点击后立即提交。更完整的版本应该在高亮选项后再提交,避免用户误触。
8.3 反馈与下一题逻辑
判题之后要同时展示“是否正确”和“正解是哪一个”,否则用户只能盲目猜。下一题按钮复用loadQuiz,重新请求新题。
学习阶段可以不考虑用户体系,分数默认记在内存里的固定 key 上。需要区分用户时,可以引入 session_id 或 JWT,把分数绑定到具体用户。
9. 运行验证与结果质量分析
9.1 启动与本地验证
启动服务:
uvicorn app:app --reload --port 8000启动成功的标志是控制台输出类似Uvicorn running on http://127.0.0.1:8000。浏览器打开http://127.0.0.1:8000/,应该能看到页面。
建议按下面顺序做冒烟测试:
- 单独请求维基百科接口,确认外网可访问。
- 单独调用
generate_ai_extract,确认模型 Key 有效。 - 请求
/api/quiz,确认返回两个选项且文本不重复。 - 提交一个错误答案和一个正确答案,确认判定正确。
- 刷新页面连续玩 10 题,确认没有明显报错。
9.2 curl 验证接口返回
直接依赖浏览器调试有时看不到完整报错,配合 curl 更高效:
curl -i http://127.0.0.1:8000/api/quiz用-i能同时看到 HTTP 状态码和响应体。如果返回 503,说明题目的某个上游服务出了问题,查看服务端日志定位是维基百科超时还是模型返回空。
9.3 判断题目质量的检查清单
“能跑”不等于“题目能玩”。每生成一题,建议按以下清单检查:
- [ ] 两段文本长度是否接近,避免用户靠长度猜测。
- [ ] AI 文本是否出现“作为一个 AI”“我不能”这类提示词痕迹。
- [ ] AI 文本是否复述了人类文本的大段原句。
- [ ] 词条标题是否过于生僻,用户完全无法判断。
- [ ] 随机词条是否重复出现,游戏后期是否疲劳。
- [ ] 模型返回是否偶发截断,截断后选项是否残缺。
其中“AI 文本是否复述原句”是最常出现的问题。如果复述严重,说明提示词的“不要复制参考文本”约束不够,需要加强或者在生成后用文本相似度做过滤。
10. 常见问题与排查路径
10.1 常见问题速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| /api/quiz 返回 503 | 维基百科接口超时或模型 Key 无效 | 单独 curl 维基百科接口;单独调用模型 SDK 示例 | 检查网络、密钥、模型名;加超时和重试 |
| 两个选项文本几乎一样 | 模型复制了参考文本 | 对比两段文本相似度;查看提示词 | 在提示词中加强“独立写作”约束,或过滤高相似度结果 |
| AI 文本明显带“AI 痕迹” | 提示词没有禁用口语化表达 | 查看生成文本 | 增加禁用句式,提高 temperature |
| 模型返回被截断 | max_tokens 太小 | 查看响应里的 finish_reason | 调大 max_tokens,或检测截断后重新生成 |
| 刷新页面后分数丢失 | 分数存在内存 | 检查存储位置 | 学习阶段可接受;生产环境改用 Redis 或数据库 |
| 请求 /api/quiz 很慢 | 模型生成是同步阻塞调用 | 观察日志耗时 | 改成异步生成,或预生成题目 |
| 随机词条重复出现 | 维基百科随机接口没有去重 | 记录最近题目标题 | 维护一个已用过标题的队列,临时跳过 |
10.2 从现象到根因的排查顺序
遇到问题不要先看最后一行堆栈,按下面顺序排查:
- 确认请求本身没问题,路径和参数正确。
- 确认上游依赖可用:维基百科接口、模型接口。
- 确认环境变量已加载,
.env文件路径正确。 - 查看服务端完整日志,识别是网络报错、鉴权报错还是模型响应格式问题。
- 用最小脚本单独复现问题,隔离模块。
- 如果只是偶发问题,考虑超时和重试策略,而不是改提示词。
例如“题目生成失败”是一个非常粗的报错,必须把原始异常记录到日志里。给用户的 detail 保持简洁,但服务端日志要能定位到是httpx.ConnectTimeout还是openai.AuthenticationError。
11. 生产化最佳实践与扩展方向
11.1 工程化建议清单
学习项目跑通后,如果要把它做成真正可用的服务,建议对照下面的清单补强:
- [ ] 模型 Key 统一从环境变量或密钥管理系统读取,不要提交到代码仓库。
- [ ] 维基百科和模型接口都设置超时和重试,并区分“不可重试”的错误类型。
- [ ] 题目和答案不要混在同一个存储里,答案字段不返回给前端。
- [ ] 用异步方式处理耗时操作,避免阻塞 FastAPI 事件循环。
- [ ] 建立基本日志体系,记录请求耗时、上游错误、生成文本长度。
- [ ] 对生成文本做基础过滤,检测空文本、敏感词、超长文本。
- [ ] 为已生成的题目增加缓存,减少重复请求模型。
- [ ] 增加简单限流,防止随机词条接口和模型接口被高频调用。
其中“答案字段不返回给前端”是这类产品最容易踩的坑。前端调试时可以查看网络响应,如果响应里带is_ai: true,玩家直接看开发者工具就能作弊。
11.2 可以扩展的方向
这个项目可以从一个答题游戏扩展成多个方向的工程实践:
- 标注平台:把“用户判断是否正确”收集起来,形成人工标注数据集,用于训练或评估文本分类器。
- 模型评测工具:固定一批维基百科词条,让不同模型生成同主题文本,比较哪个模型更容易被用户识别为 AI。
- AI Agent 能力测试:把“生成百科简介”封装成一个 Agent 工具调用,观察不同提示词策略下的效果差异。
- AI 幻觉观察器:对生成文本做事实校验,标记出与维基百科原文不一致的地方,帮助理解大模型的幻觉问题。
- 前后端功能增强:加入用户登录、排行榜、题目历史、多语言支持。
如果希望把生成和判题做成更通用的能力,可以借鉴 Spring AI 这类框架的模块化思路,把模型供应商、提示词模板和任务链路解耦。小项目不需要一步到位,但模块边界一开始就要清楚。
11.3 一点学习建议
把“AI or Not”做完,真正值得记住的不是代码本身,而是几个判断:
第一,AI 应用不能只关心“模型能不能生成”,更要关心生成结果如何进入业务链路、如何验证、如何兜底。第二,两段文本对比是一个非常直观的评测方式,它比“AI 检测率 90%”这类数字更能说明问题。第三,一个最小闭环比一个宏伟架构更有学习价值,先把 500 行代码跑通,再去考虑异步、缓存、Kubernetes 这些生产化能力。
如果自己已经能独立完成这个项目,下一步可以试着不依赖维基百科,换成自己的文档库或博客语料,做一套“识别 AI 改写内容”的内部工具。你会发现,核心难点从“接 API”变成了“定义什么才算 AI 风格”,那才是这个领域真正值得深挖的地方。