Skip to content

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、前端路由和状态管理;省掉整条构建链,改完刷新就生效
后端框架Flask29 个接口、以同步 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 —— 而且音标、例句、发音会一起消失,因为它们本来就是这个接口返回的。

解法是运行时从页面里抓,并加一层内存缓存:

python
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 已失效,强制刷新后重试一次:

python
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 统一返回契约 ​

不管底层走的是金山、有道还是百度,翻译服务对外只吐一个固定结构,前端不需要知道数据来自谁:

python
{
    '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 定义:

python
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 三机制闭环:玩游戏真的能推进背记 ​

很多"学习游戏"的问题是玩了就玩了,跟学习数据没关系。这里的做法是让游戏直接写回单词本,靠三个机制:

  1. 自适应出题:掌握程度低、历史答错多的词优先出现
  2. 错词回炉:本局答错的词,本局内一定再考一次
  3. 连对升星:同一个词连续答对 3 次,单词本自动升 1 星(上限 3 星)

第 3 条的实现里有一处细节值得看 —— 升星后连对计数必须清零,否则"连对 3 次"会退化成"连对满 3 次之后每答对一次都升星":

python
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 词)。这里的核心问题是:词表格式升级后要能重新导入,但不能把用户的星级和复习记录冲掉。

sql
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_KEYAI 对话助手「小语」
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 忽略,不进版本库。

七、小结 ​

这个项目没有什么惊人技术,但有几条我认为可以复用的做法:

  1. 第三方 API 一律假设它会变 —— buildId 动态抓取、404 强制刷新重试、音频播放降级
  2. 区分"必须立刻有"和"可以等" —— 本地秒开加按需 AI 补全,把成本花在用户无感的地方
  3. "不写什么"也是设计 —— upsert 里刻意缺席的那四个字段,比写一堆判断更可靠
  4. 抽象等被维护成本逼出来再做 —— game-kit.js 是第 N 款游戏的痛逼出来的,不是一开始就设计好的

项目已开源(MIT):https://github.com/zhaoxuejie/moqian-wendy-translator

素材说明:项目内三张头像(柯南、小丸子、哆啦A梦)为受版权保护的动漫角色,仅用于个人家庭学习场景,不在 MIT 授权范围内。如果你要复用这个项目,记得换成原创或免版权素材。

相关阅读:RAG 知识库从零搭建:文档解析、向量检索到生成回答

📖本文阅读--次|📊全站访问--次|👥访客--人