AI 英语学习助手实战:翻译源降级、大模型按需补全与游戏化背记
本文速览
- 项目:moqian-wendy-translator(MIT 开源)
- 定位:一个给小学生用的英语学习应用,覆盖翻译、AI 对话、单词本、游戏化背记四块能力
- 适合谁读:后端开发者,尤其是想把大模型接进真实产品、但不打算为此重做一套前端工程的人
- 前置知识:Python 与 Flask 基础、RESTful 接口、SQLite(或任意关系型数据库)
- 难度:⭐⭐⭐ | 阅读时长:约 15 分钟 | 版本基准:v2.0.0
这篇不铺项目全貌。我只挑三处真正花了时间的取舍来讲 —— 翻译源怎么降级、大模型怎么按需补全省成本、游戏怎么反哺背记,再带上数据层与部署里几个值得复用的决策。
一、项目背景与整体架构
1.1 需求约束
这是给女儿做的工具,所以约束和"面向大众的 SaaS"完全不同:
- 注意力短:翻译要秒回,操作路径不能超过两步
- 要有正反馈:光背单词坚持不下来,得有游戏和徽章
- 断网也不能白屏:已经存下来的单词、例句必须离线可看
- 家长得能自己维护:一个人开发一个人运维,不能引入需要值守的中间件
1.2 整体架构
前端是原生 JS,没有构建步骤;后端是单个 Flask 进程,路由薄、业务逻辑沉在 services/;数据落 SQLite;外部依赖全是 HTTP API。
1.3 技术选型的取舍
选 Flask + 原生 JS,不是因为它"先进",而是因为这个项目的真实成本结构:
| 维度 | 选择 | 理由 |
|---|---|---|
| 前端框架 | 原生 JS | 单机自用,不需要 SSR、前端路由和状态管理;省掉整条构建链,改完刷新就生效 |
| 后端框架 | Flask | 29 个接口、以同步 IO 为主,Flask 足够;各家 SDK 与示例最全 |
| 数据库 | SQLite | 单用户单机,零运维;备份就是拷一个文件 |
| 部署 | Docker Compose | 一条命令起停,不用管 systemd 和虚拟环境 |
代价也要讲清楚:api.js 已经涨到 189 KB 单文件。这是"不建前端工程化"的直接账单,第三节会具体说它的后果。
二、翻译链路:多源降级与缓存
2.1 按输入类型分流
需求很具体:查单词要词典级信息,翻句子要通顺。这两件事的最佳数据源不一样,于是分成两条路:
| 输入 | 首选源 | 拿到什么 |
|---|---|---|
| 单个英文单词 | 金山词霸 | 词性、美式音标、发音音频、例句及例句翻译 |
| 句子 / 短语 | 有道翻译 | 通顺的整句翻译 |
| 以上都失败 | 模拟翻译 | 保证接口不返回 500,前端不至于白屏 |
2.2 降级链
2.3 金山词霸的 buildId 不能写死
这是整个项目最值得记的一个坑。金山词霸用的是 Next.js,词条接口的 URL 里带一个构建 ID:
https://www.iciba.com/_next/data/{buildId}/word.json?w=apple这个 buildId 每次发版都会变。
⚠️ 踩坑:一旦把它写死在代码里,金山发版后单词查询就会长期 404 —— 而且音标、例句、发音会一起消失,因为它们本来就是这个接口返回的。
解法是运行时从页面里抓,并加一层内存缓存:
def _fetch_iciba_build_id(self) -> str:
"""从金山词霸页面抓取当前 Next.js buildId"""
resp = requests.get("https://www.iciba.com/word?w=hello",
headers=headers, timeout=8)
if resp.status_code == 200:
m = re.search(r'"buildId"\s*:\s*"([^"]+)"', resp.text)
if m:
return m.group(1)
return ""
def _get_iciba_build_id(self, force_refresh: bool = False) -> str:
"""获取 buildId(带缓存);force_refresh=True 时强制重新抓取"""
now = time.time()
if (not force_refresh and self.iciba_build_id
and now - self.iciba_build_id_time < self.iciba_build_id_ttl):
return self.iciba_build_id
build_id = self._fetch_iciba_build_id()
if build_id:
self.iciba_build_id = build_id
self.iciba_build_id_time = now
return build_id缓存 TTL 是 3600 秒。但缓存本身会引入新问题:缓存有效期内金山发版了怎么办?所以在查询处补了一层处理 —— 404 视为 buildId 已失效,强制刷新后重试一次:
url = f"https://www.iciba.com/_next/data/{build_id}/word.json?w={word_clean}"
if resp.status_code == 404:
# buildId 已随金山发版失效,强制重新获取并重试一次
build_id = self._get_iciba_build_id(force_refresh=True)
if build_id:
url = f"https://www.iciba.com/_next/data/{build_id}/word.json?w={word_clean}"这一段合起来的效果是:金山怎么发版都不用改代码。抓到数据后按固定路径取值 —— 音标取 baesInfo.symbols.ph_am,音频取 ph_am_mp3,词性取 parts[0].part,例句取 new_sentence[0].sentences[0]。
2.4 统一返回契约
不管底层走的是金山、有道还是百度,翻译服务对外只吐一个固定结构,前端不需要知道数据来自谁:
{
'original_text': ..., # 原文
'translated_text': ..., # 译文
'part_of_speech': ..., # 词性
'pronunciation': ..., # 美式音标
'audio_url': ..., # 单词发音音频
'example_sentence': ..., # 例句
'example_translation': ..., # 例句翻译
'example_audio_url': ..., # 例句音频
'source': 'iciba', # 来源标识
}这个契约的价值在加新翻译源的时候才体现出来:新实现只要返回同样 9 个字段,路由层、缓存层、前端一行都不用改。
结果写进 translation_cache 表,同一个词第二次查询直接绕过第三方。对"孩子反复点同一个词"的使用习惯来说,命中率相当高。
2.5 两个真实踩坑
⚠️ 踩坑
- 音频 URL 会过期:第三方返回的 MP3 链接有有效期,缓存里存着隔天就播不出来。所以播放失败必须能降级到浏览器语音合成,而不是弹个报错。
- 字段可能缺失:不是每个词都有完整词典数据。取值路径必须容忍字段不存在,缺了就留空 —— 不能因为一个词缺音标,把整条链路带崩。
三、大模型接入:按需补全与成本控制
3.1 先想清楚它是谁
AI 对话角色叫「小语」,定位是"英语学习小伙伴"。这个角色的全部人格由一段 system prompt 定义:
base_prompt = """你是小语,温蒂的英语学习小伙伴和智能翻译助手。你的特点是:
1. 语言简单易懂,适合小学生理解
2. 回答生动有趣,充满鼓励
3. 善于用比喻和例子解释概念
4. 对学习问题有耐心和爱心
5. 回答要简洁明了,不要太长
6. 你是温蒂的学习伙伴,要像好朋友一样陪伴她学习
请用温暖、友好的语调回答问题,记住你的名字是小语。"""有几处是刻意写的:语言简单(读者是小学生)、回答要短(配合 max_tokens=2000,避免长篇大论)、明确"你的名字是小语、温蒂是用户"(不写清楚,模型很容易把自己和用户搞混)。
3.2 本地秒开 + 按需 AI 补全
这是我认为最值得抄的一个设计。单词详情弹窗要展示:词性、音标、最多 3 条例句、词态变化、同义词、反义词、记忆图片。
如果全部交给大模型生成,886 个词就要调 886 次 AI、每次好几秒 —— 点开一个词等几秒,体验直接崩。所以拆成两段:
- 第一段:本地数据秒开。 词性、音标、例句来自教材词表(导入时已存进
wordbook),断网可看 - 第二段:联网再补。 词态变化、同义词、反义词、记忆图片属于"锦上添花",显示一个按钮,点了才调 AI
- 生成结果写进
word_details表,同一个词只调一次 AI,下次打开直接命中
这个设计的本质是区分"必须立刻有"和"可以等、可以没有"。大模型的成本和延迟都花在后者身上,用户几乎无感。
3.3 prompt 拼装放在前端的代价
如实说,当前实现把"补全单词详情"的 prompt 拼装放在了前端:前端调 /api/chat,解析返回的 JSON,再 POST 到 /api/word/detail 持久化。
好处是后端极简,全项目只有一处大模型出口;代价也很实在 —— prompt 与解析逻辑耦合在 189 KB 的 api.js 里,没法单测,改一次 prompt 要回归整个前端。更稳妥的做法是把这类"结构化生成"抽成独立接口(例如 /api/word/enrich),prompt 与 JSON schema 校验都收回后端。
3.4 没配密钥也不能崩
所有密钥都在 .env 里,留空等于该功能不可用,但不影响其他功能:没配 DeepSeek key,对话页给一句友好提示,翻译和单词本照常工作;没配 Pixabay key,记忆图片显示不出来,其余内容照旧。
对单机应用来说,这种"部分降级"比"启动时强校验密钥"更合适 —— 孩子想查个单词,不该因为家长没配 OCR 密钥就打不开应用。
四、游戏化背记:一套底座撑起 9 款游戏
4.1 共用内核
games/ 下有 9 款游戏(单词挑战、限时挑战、连击奖励、AI 对战、单词速射、记忆配对、单词猎人、词汇拼图、单词大冒险),但选词、音效、朗读、结算这四件事只写了一遍,都收在 game-kit.js 里。
这是被维护成本逼出来的:前几款游戏各写各的,改一处音效逻辑要改好几个文件。抽出内核后,新增一款游戏只需要写"这个游戏怎么玩",通用逻辑不用再碰。
4.2 三机制闭环:玩游戏真的能推进背记
很多"学习游戏"的问题是玩了就玩了,跟学习数据没关系。这里的做法是让游戏直接写回单词本,靠三个机制:
- 自适应出题:掌握程度低、历史答错多的词优先出现
- 错词回炉:本局答错的词,本局内一定再考一次
- 连对升星:同一个词连续答对 3 次,单词本自动升 1 星(上限 3 星)
第 3 条的实现里有一处细节值得看 —— 升星后连对计数必须清零,否则"连对 3 次"会退化成"连对满 3 次之后每答对一次都升星":
if correct and streak >= MASTERY_STREAK_TARGET:
wb = cursor.execute('''SELECT id, mastery_level FROM wordbook
WHERE user_id = ? AND english_word = ?''',
(user_id, word)).fetchone()
if wb and (wb['mastery_level'] or 0) < 3:
new_level = (wb['mastery_level'] or 0) + 1
cursor.execute('''UPDATE wordbook
SET mastery_level = ?, review_count = review_count + 1,
last_reviewed = ?, updated_at = ?
WHERE id = ?''', (new_level, now_str, now_str, wb['id']))
# 升星后连对计数清零:下一次升星要再连对 3 次
cursor.execute('''UPDATE game_word_stats SET streak = 0, updated_at = ?
WHERE user_id = ? AND english_word = ?''',
(now_str, user_id, word))这三条的数据来源是 game_word_stats 表:每个词的累计对错次数、当前连对、历史最高连对。也就是说,游戏和单词本共享同一份学习数据,而不是各记各的。
4.3 断网先存本地,进度跨设备
游戏过程中的逐词成绩,断网时先写 localStorage,联网后补报。而星星、贴纸、关卡解锁这些"资产"存服务端(game_progress 表,按 game_type + progress_key 存 JSON),手机和电脑打开是同一份进度 —— 不然孩子在手机上打过的关,换台设备就白打了。
4.4 新增一款游戏要动哪几处
目录约定固定:games/<游戏名>/ 下放 xxx.html、xxx.css、xxx.js,游戏列表里注册一张卡片,统计与存档复用现成的 /api/games/* 接口。没有构建步骤,也没有路由注册中心 —— 加一个目录就能跑。
五、数据层的三个取舍
5.1 表结构
10 张表,绝大部分挂在用户维度下:
translation_cache(翻译缓存)和 word_details(AI 补全的单词详情)是两张与用户无关的公共表 —— 同一个词对所有用户都一样,没必要按用户各存一份。
5.2 导入词书:upsert,但绝不碰学习进度
教材词书导入是 v2.0.0 的主功能(人教版 9 册共 2842 词)。这里的核心问题是:词表格式升级后要能重新导入,但不能把用户的星级和复习记录冲掉。
INSERT INTO wordbook (user_id, english_word, chinese_meaning, ...)
VALUES (?, ?, ?, ...)
ON CONFLICT(user_id, english_word) DO UPDATE SET
chinese_meaning = excluded.chinese_meaning,
pronunciation = excluded.pronunciation,
part_of_speech = excluded.part_of_speech,
example_sentence = excluded.example_sentence,
example_translation = excluded.example_translation,
more_examples = excluded.more_examples,
updated_at = CURRENT_TIMESTAMP
-- 注意:DO UPDATE 里没有 mastery_level / review_count / last_reviewed / source💡 经验:"不写什么"比"写什么"更重要 —— 这四个字段故意不出现在
DO UPDATE子句里,于是重导词表天然不会影响已有进度,也不需要额外写"先查再判断再决定更新哪些列"的逻辑。导入因此是幂等的,重复点也不会产生重复词。
5.3 成就判定:定义与记录分离
achievements 表只记录"什么时候拿到的",靠 UNIQUE(user_id, achievement_type) 兜底,同一成就不会重复授予。而"怎么算达成"集中在 app.py 的 ACHIEVEMENT_DEFS 里,12 个成就一张定义表。
判定不是埋点驱动,而是打开成长页时按当前数据算一遍。好处是加新成就只加一行定义;代价是每次打开页面要跑一遍判定 —— 在这个数据量下完全可以接受。
5.4 统计口径:在学 vs 全部
单词本顶部的册次筛选有个容易搞错的细节:下拉框里的数字是还在学的词数(mastery_level < 3),不是该册总词数。因为 3 星的词已经移进成长页的「我的单词」,不该再占着筛选数字。
接口因此同时返回两个数:count(在学)和 total(全部)。另外给了一个 stats_only=1 参数 —— 学成一个词后只需要刷新下拉里那个数字,为它去拉全量词表不值得,这个参数只返回统计,响应体约 250 字节。
六、部署与密钥治理
6.1 一条命令起服务
本地是 start.bat / start.sh,内部做四件事:检查 Python 版本 → 安装依赖 → 初始化数据库 → 加载 .env 并启动,最后打开浏览器。
线上用 Docker Compose,前面挂 nginx。数据库文件通过卷挂载到宿主机 ./database/,删容器不丢数据;容器内以 FLASK_DEBUG=false 运行,避免 Werkzeug 调试器暴露。
6.2 密钥全部移出代码
这是专门做过的一次清理:所有 API 密钥统一进项目根目录的 .env,仓库里只保留不含密钥的 .env.example 模板。
| 变量 | 用途 |
|---|---|
DEEPSEEK_API_KEY | AI 对话助手「小语」 |
YOUDAO_APPKEY / YOUDAO_SECRET | 有道翻译 |
BAIDU_APPID / BAIDU_SECRET | 百度翻译 |
BAIDU_SPEECH_APP_ID / ..._API_KEY / ..._SECRET_KEY | 百度语音识别 |
BAIDU_OCR_API_KEY / BAIDU_OCR_SECRET_KEY | 百度 OCR |
PIXABAY_API_KEY | 单词记忆图片 |
只填需要的即可,留空的功能自动降级。非敏感参数(模型名、max_tokens、temperature)单独放在 deepseek_config.json,和密钥分开 —— 这个文件可以安全提交,换模型不用动环境变量。
6.3 容器化的三个坑
- 目录大小写:仓库里是
Backend/、Frontend/,容器内是/app/backend、/app/frontend。应用层做了不区分大小写的路径解析,所以两种写法都能跑 - 时区:容器默认 UTC,和宿主机差几个小时。连续学习天数、本月掌握这类按日期统计的功能,时区错了会直接算错
- 静态资源解析:目录挂载方式会影响静态文件路径,需要按文件/目录精确挂载,而不是整目录挂载
本地开发用 docker-compose.override.yml 把 Frontend/、Backend/ 挂进容器:改前端刷新即可,改后端只需 docker compose restart。这个文件被 .gitignore 忽略,不进版本库。
七、小结
这个项目没有什么惊人技术,但有几条我认为可以复用的做法:
- 第三方 API 一律假设它会变 —— buildId 动态抓取、404 强制刷新重试、音频播放降级
- 区分"必须立刻有"和"可以等" —— 本地秒开加按需 AI 补全,把成本花在用户无感的地方
- "不写什么"也是设计 —— upsert 里刻意缺席的那四个字段,比写一堆判断更可靠
- 抽象等被维护成本逼出来再做 ——
game-kit.js是第 N 款游戏的痛逼出来的,不是一开始就设计好的
项目已开源(MIT):https://github.com/zhaoxuejie/moqian-wendy-translator
素材说明:项目内三张头像(柯南、小丸子、哆啦A梦)为受版权保护的动漫角色,仅用于个人家庭学习场景,不在 MIT 授权范围内。如果你要复用这个项目,记得换成原创或免版权素材。