给孩子搭一个剑桥 KET/PET 英语陪练网站:从零到上线完整教程
如果你手上有一个懂代码开发的 AI Agent,直接把这一整篇文章发给它就行。
下面把架构、依赖、模块划分、接口清单、部署步骤和踩过的坑都写清楚了,它能照着把整套东西搭起来。
没有 Agent 也没关系,按章节顺序做,一台云服务器 + 一个域名就能跑起来。
一、这东西是干什么的
一套给孩子备考剑桥 KET / PET 的在线陪练网站,手机浏览器打开就能用,不需要装 App。三个模块:
| 模块 | 孩子做什么 | AI 做什么 |
|---|---|---|
| 单词冲刺 | 看中文选英文 / 看中文拼写 | 按错题和掌握度动态出题 |
| 写作批改 | 手写在纸上拍照上传,或直接打字 | 识别手写 → 按剑桥四维标准打分 → 逐条讲错 |
| 口语四部曲 | 对着手机说话 | 转文字 → 扮演考官/同学 → 评分给建议 |
设计目标是家长零技术门槛使用:一个网址、一个通行口令,打开就能练。技术上尽量”土”——不引入前端框架、不搞微服务、不建数据库,单机单进程能跑,坏了一眼能看懂哪儿坏了。
二、整体架构
手机/电脑浏览器
│ HTTPS
▼
┌─────────────┐
│ nginx │ 443 端口,Let's Encrypt 证书
└──────┬──────┘
│
┌───────┴────────┐
│ │
/api/* 转发 其余路径
│ │
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ gunicorn │ │ 静态文件目录 │
│ + Flask │ │ index.html / ket.html │
│ 127.0.0.1 │ │ pet.html(单文件应用)│
│ :端口A │ └──────────────────────┘
└──────┬───────┘
│
├──► 语音识别 API(ASR)
├──► 对话/评分 API(LLM)
├──► 语音合成 API(TTS)
└──► 多模态批改 API(看图评分)
三个要点:
- 前后端同源:前端静态文件由 nginx 直接发,
/api/前缀转发给后端。没有跨域问题,也不需要配 CORS。 - 后端只监听 127.0.0.1,不直接暴露公网,所有流量必须过 nginx。省掉一整类安全问题。
- 无数据库:词库是 JSON 文件,会话状态是 JSON 文件,学习进度存在浏览器 localStorage。整套东西备份就是
tar一下目录。
三、技术选型(以及为什么这么选)
前端:原生 HTML/CSS/JS,不用框架
一个页面一个 .html 文件,CSS 和 JS 全部内联。
为什么不用 React/Vue:这站点的交互复杂度不高(切页面、录音、请求接口),但可维护性要求很高——出问题的时候得有人能在十分钟内看懂。原生写法没有构建步骤、没有 node_modules、没有版本地狱,改完刷新就是新的。手机上直接跑,不用等打包。
代价是代码会有点重复(三个页面各有一份样式),但这点重复换来的是”十年后还能修”。
后端:Python 3 + Flask + gunicorn
- Flask:轻、够用。所有接口加起来四十来个,单文件一千行出头,一个人能通读。
- gunicorn:生产环境跑 Flask 的标准做法,2 个 worker 足够撑住一个班的孩子。
- systemd 托管:开机自启、崩了自动拉起,不用写复杂的守护脚本。
反向代理:nginx + Let’s Encrypt
nginx 负责三件事:发静态文件、转发 API、管 HTTPS 证书。证书用 certbot 一条命令自动申请 + 自动续期,到期前自动换,不用管。
模型:两家分工
| 用途 | 选型思路 |
|---|---|
| 语音识别 ASR | 用支持直接传音频的多模态大模型,不用单独接语音识别服务 |
| 对话 / 评分 / 出题 LLM | 用便宜、快、够用的中小模型 |
| 语音合成 TTS | 用同一家的服务,音色要适合孩子听 |
| 写作批改 | 必须多模态(要识别手写照片),换另一家 |
为什么批改单独换一家:手写识别对多模态能力要求高,而且评分要求严格的 JSON 结构化输出。让”便宜模型干日常活、能力强的多模态模型干批改”是最省钱的分工。
具体模型名和参数在第九节。
四、服务器准备
配置要求:2 核 2G 内存、5M 带宽、40G 硬盘就够一个班用。地域选离孩子近的(延迟低,语音上传快)。
系统:Ubuntu 22.04 或 24.04 LTS。
要做的事:
- 云控制台开放安全组端口 80 和 443(80 是申请证书用的,443 是正式访问)。
- 买一个域名,加一条 A 记录指向服务器公网 IP。
- SSH 登录,用 root 或 sudo 用户操作。
⚠️ 别把 SSH 端口对全网开放,也别用弱密码。后面第五节会讲怎么加固。
五、环境搭建
5.1 系统依赖
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-venv python3-pip nginx certbot python3-certbot-nginx
5.2 创建项目目录和虚拟环境
sudo mkdir -p /opt/your-tutor
sudo chown -R $USER:$USER /opt/your-tutor
cd /opt/your-tutor
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install flask gunicorn requests
依赖就三个包:flask(Web 框架)、gunicorn(生产服务器)、requests(调外部 API)。不需要数据库驱动、不需要 ORM、不需要前端构建工具。
5.3 密钥管理(重要)
绝对不要把 API Key 写进代码。 用环境变量 + 一个不进版本库的文件:
# /opt/your-tutor/.env —— 非敏感配置,可读
MIMO_API_KEY_FILE=/opt/your-tutor/.env.key
# /opt/your-tutor/.env.key —— 只放密钥,权限 600
your-actual-api-key-here
chmod 600 /opt/your-tutor/.env.key
代码里按”先读环境变量、读不到再读文件”的顺序取:
api_key = os.environ.get("MIMO_API_KEY", "")
if not api_key:
try:
with open("/opt/your-tutor/.env.key") as f:
api_key = f.read().strip()
except Exception:
pass
第二家的密钥单独放一个文件(比如 data/other_provider_key.txt),别混在一起——将来换供应商的时候只动一个地方。
六、目录结构
/opt/your-tutor/
├── venv/ # Python 虚拟环境
├── wsgi.py # gunicorn 入口,只有十行
├── .env # 非敏感环境变量
├── .env.key # 密钥(chmod 600)
├── backend/
│ ├── __init__.py
│ ├── app.py # 所有路由 + 业务逻辑(约 1000 行)
│ ├── model_client.py # 模型 API 封装:ASR / LLM / TTS
│ ├── questions.py # 题库(按主题分类的字典)
│ └── pet_questions.py # 另一级考试的题库
├── frontend/
│ ├── index.html # 登录页 + 门厅(选考试级别)
│ ├── ket.html # KET 主页面
│ └── pet.html # PET 主页面
└── data/
├── vocab_a.json # 词库(列表,每个词一条记录)
├── vocab_b.json # 另一级词库
├── uploads/ # 上传的音频临时目录
└── access_password.txt # 通行口令(chmod 600)
设计原则:app.py 里放路由和编排,model_client.py 里放所有对外部 API 的调用,题库单独成文件。这样换模型只改一个文件,改题库不碰代码。
wsgi.py 全文:
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from backend.app import app
if __name__ == "__main__":
app.run()
七、后端模块详解
7.1 词汇模块
数据:词库是一个 JSON 数组,每个元素形如
{"word": "environment", "chinese": "环境", "from_ket": false}
from_ket 这个字段用来支持”只练新词、跳过上一级已学过的词”。
出题算法(核心逻辑,很简单但有效):
1. 按开关过滤词池(是否排除上一级词汇)
2. 优先出「做错过、但还没掌握」的词
3. 其次出「没做过、也没掌握」的词
4. 都没有就整个池子随机
5. 随机取一个
两种题型:
- 选择题:正确答案的中文 + 3 个随机干扰项(从其他词的中文里抽),打乱顺序。前端只需要拿到
options和answer。 - 拼写题:给中文,孩子拼英文。前端做比对,支持大小写不敏感。
进度存在哪:浏览器 localStorage,两个数组——wrong_words 和 mastered_words,每次请求都带上。服务端不存任何孩子数据,这点对家长很重要。
7.2 口语评分(KET 版)
流程:
前端录音 → WAV 二进制 → POST 上传
→ 后端存临时文件
→ 调 ASR 转文字
→ 把「题目 + 孩子说的内容」丢给 LLM 评分
→ 返回分数 + 中文点评
关键点:ASR 返回空字符串时不要直接判零分,要区分”没说话”和”识别失败”。识别失败应该提示重录,而不是打击孩子。
限流处理:ASR 接口返回 429 时,后端返回一个明确的状态码,前端提示”服务器繁忙,稍后再试”,而不是报一个看不懂的错误。
7.3 口语四部曲(PET 版,最复杂的一块)
剑桥 PET 口语分四个 Part,实现方式各不相同:
| Part | 形式 | 实现 |
|---|---|---|
| Part 1 | 考官问个人问题 | 题库随机出题 → 孩子录一段 → ASR → LLM 评分 |
| Part 2 | 描述一张图 | 用文字情境卡替代图片(省掉图片版权和加载)→ AI 追问 |
| Part 3 | 和搭档讨论 | AI 扮演同学,来回 6 轮(各 3 次)→ 最后统一评分 |
| Part 4 | 延伸问答 | 题库随机 → 录音 → 评分 |
Part 3 的多轮对话是整站最值得说的部分:
- 后端给每次练习生成一个 UUID 会话 ID,状态存成
data/session_<uuid>.json,里面是对话历史 + 轮次计数。 - 每一轮:孩子录音 → ASR 转文字 → 追加到历史 → 调 LLM 以”同学”身份回复(限制最多 2 句话,防止 AI 抢戏)→ 返回给前端朗读出来。
- 第 6 轮结束后,把整段对话交给 LLM,一次性评分:是否参与讨论、是否表达观点、是否回应搭档、是否尝试达成一致。
- 评完删除会话文件。
省钱小技巧:Part 2 的考官开场白是固定模板,代码里直接写死字符串,不调 LLM。考官话术本来就模板化,省一次调用就是省一笔钱。
会话过期处理:会话文件找不到时返回”会话已过期”,前端引导重新开始,不要崩。
7.4 写作批改(多模态)
支持两种输入:
- 拍照:
multipart/form-data上传图片,后端校验大小(比如限制 8MB),转 base64 塞进多模态消息。 - 打字:纯 JSON 提交文本。
Prompt 设计(这是整套系统里最需要调的部分):
- System prompt 里明确”你是剑桥 PET 考官”,给出四个评分维度:内容 / 交流达成 / 结构 / 语言,各 0-5 分。
- 强制只输出 JSON,不给 markdown 代码块,并在 prompt 里给出完整的 JSON schema 示例。
- 要求
recognized_text字段回填手写识别结果——这一步很重要,家长能核对孩子写的到底被识别成什么,识别错了能看出来。 - 要求
mistakes列出最多 6 条最重要的语言错误,每条给”错的 / 对的 / 中文解释”。 - 要求
rewritten给一版润色后的范文。 - 点评和解释用中文(孩子看得懂),
recognized_text和rewritten保持英文。
解析容错:模型有时会加 “`json 围栏或前后废话。解析前先剥掉围栏,再用正则提取第一个完整 JSON 对象,最后兜底给一个默认分数。永远不要让解析失败变成 500 错误。
7.5 TTS 朗读
统一一个 /api/tts 接口,前端传文本,后端返回音频二进制。前端再包成 Blob 用 Audio 播放。
注意:录音时要先把正在播放的朗读停掉,否则麦克风会录进去回声。
7.6 登录与限流
登录:极简方案——一个通行口令文件,验证通过后种一个长时效 cookie。口令通过家长群发给家长,不用注册、不用记用户名。
限流:按 IP 每分钟最多尝试 8 次,超出返回 429。记录只保留最近 100 个 IP,避免文件无限增长。这是防暴力破解的最低成本手段。
7.7 接口清单
POST /api/auth/login 口令登录
POST /api/vocab/question 出词题(选择题/拼写题)
POST /api/vocab/check 校验答案
GET /api/vocab/stats 学习进度统计
GET /api/speaking/part1/question 随机一道 Part1 题
GET /api/speaking/part1/list 全部 Part1 题目
POST /api/speaking/part1/score 上传录音 → 评分
POST /api/speaking/part2/start 开始 Part2(返回情境卡 + 开场白)
POST /api/speaking/part2/turn 一轮对话
GET /api/speaking/part2/list 全部情境卡
POST /api/tts 文本转语音
POST /api/pet/writing/grade 写作批改(图片或文本)
POST /api/pet/vocab/question 出题(支持排除上一级词汇)
GET /api/pet/vocab/stats 进度统计
GET /api/pet/speaking/part1/question 随机题
POST /api/pet/speaking/part1/score 录音评分
GET /api/pet/speaking/part2/card 随机情境卡
POST /api/pet/speaking/part2/start 开始 Part2
POST /api/pet/speaking/part2/turn 一轮对话
GET /api/pet/speaking/part3/list 讨论话题列表
POST /api/pet/speaking/part3/start 开始讨论(AI 扮同学)
POST /api/pet/speaking/part3/turn 一轮讨论
GET /api/pet/speaking/part4/question 随机延伸问题
POST /api/pet/speaking/part4/score 录音评分
GET / 返回 index.html
GET /<path> 静态文件兜底
命名规则:/api/<考试级别>/<模块>/<动作>,一眼能看出是哪个模块的哪个环节。
八、前端模块详解
8.1 三个页面
index.html:登录页 + 门厅(两张卡片选 KET 或 PET)ket.html/pet.html:主应用,顶部三个 Tab(单词 / 写作 / 口语)
8.2 录音:为什么不用 MediaRecorder
这是整个前端最容易踩坑的地方。
浏览器原生的 MediaRecorder 输出的是 webm/opus 格式,采样率不可控,很多 ASR 接口只认标准 WAV。所以这里用底层 API 自己采 PCM 再手写 WAV 头:
// 1. 取麦克风,强制 16kHz 单声道
const stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true }
});
// 2. 用 AudioContext 采样
const ctx = new AudioContext({ sampleRate: 16000 });
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(4096, 1, 1);
// 3. 每帧把 Float32 转成 Int16 PCM,攒进数组
processor.onaudioprocess = e => {
const input = e.inputBuffer.getChannelData(0);
const pcm16 = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
pcm16[i] = Math.max(-32768, Math.min(32767, input[i] * 32768));
}
pcmData.push(pcm16);
};
停止录音后,手写 44 字节 WAV 头(RIFF / WAVE / fmt / data 四段),拼成 Blob:
function pcmToWav(chunks) {
// 算总长度 → 分配 ArrayBuffer(44 + 样本数×2)
// 依次写 'RIFF'、文件长度、'WAVE'、'fmt '、格式块长度、
// 编码=1(PCM)、声道=1、采样率=16000、字节率=32000、块对齐=2、位深=16
// 再写 'data'、数据长度
// 最后把每个 Int16 按小端写进去
}
WAV 头里那几个数值必须是 16000 / 32000 / 2 / 16,写错一个 ASR 就认不出。
8.3 录音交互细节
- 15 秒自动停止:防止孩子忘了点停止,传一个巨大的文件上去。
- 短于 0.5 秒直接拒绝:提示”录音太短,请检查麦克风权限”——这比让后端返回一个空识别结果友好得多。
- 录音时停掉 TTS:避免回声。
- 权限失败要提示:
navigator.mediaDevices不存在时明确告诉用户”请用 Safari 或 Chrome”。
8.4 进度存储
全部用 localStorage:错题列表、已掌握列表、当前进度。不上传服务器。换手机进度会丢,但换来的是”孩子的学习数据不落在任何服务器上”,这个取舍值得。
8.5 移动端适配
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">- 按钮最小高度 44px(手指点击友好)
- 用
:active { transform: scale(0.98) }给点击反馈 - 不依赖 hover 状态
8.6 TTS 开关
页面上放一个”自动朗读”开关,状态也存 localStorage。有些孩子喜欢听着练,有些嫌吵,给选择权。
九、模型接入
9.1 分工
| 能力 | 用途 | 备注 |
|---|---|---|
| ASR | 口语录音转文字 | 直接把音频塞进多模态消息,不需要单独接语音识别服务 |
| LLM | 出题、评分、扮演同学/考官 | 用便宜快的小模型 |
| TTS | 朗读题目、AI 回复 | 音色选适合孩子听的 |
| 多模态 | 手写照片识别 + 写作评分 | 换另一家能力更强的 |
当前实际用的是这几款(都可以替换):
| 能力 | 模型 | 说明 |
|---|---|---|
| ASR | 小米 MiMo 的语音识别模型 | 中文/英文/方言都认,按小时计费,很便宜 |
| LLM | 小米 MiMo 的通用模型(非 Pro 版) | 出题、评分、扮演角色都够用,价格是同级里最低的一档 |
| TTS | 小米 MiMo 的语音合成模型 | 有多个预置音色,挑一个孩子听着舒服的 |
| 多模态 | 智谱 GLM 的轻量多模态模型 | 手写识别 + 按剑桥标准打分,开思考模式、max_tokens 给 4096 |
为什么分两家:日常对话和语音用便宜的(量大),批改用多模态能力强的(质量要求高)。这是成本和效果之间最划算的切分。两家的接口都兼容 OpenAI 格式,所以 model_client.py 里只有 base_url、模型名、认证头三处不同。
注意:不同厂商的认证头写法不一样——有的用
Authorization: Bearer <key>,有的用api-key: <key>。换供应商时这是第一个要改的地方。
9.2 统一封装
把所有模型调用封装在一个 model_client.py 里,对外暴露四个函数:
asr_transcribe(audio_bytes) -> str # 音频 → 文字
llm_chat(messages, system_prompt) -> dict # 对话/评分
tts_speak(text, voice) -> bytes # 文字 → WAV
好处:换供应商只改这一个文件,app.py 一行都不用动。
9.3 调用参数上的几个坑
这些都是实测踩出来的,照着做能省几小时:
① 音频怎么传
{
"messages": [{
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": { "data": "data:audio/wav;base64,<base64字符串>" }
}]
}],
"asr_options": { "language": "auto" }
}
用 data URL 形式内联 base64,不要试图传公网 URL(省掉一个文件托管)。
② 关掉思考模式
很多新模型默认开启”深度思考”,会把 max_tokens 全部烧在思考过程上,最后 content 返回空字符串——而且状态码还是 200,看起来调用成功了,实际啥也没拿到。跑这类短任务必须显式关闭:
"thinking": {"type": "disabled"}
③ TTS 必须显式指定音色和格式
有些 TTS 接口改版后,不传 audio 字段直接返回 500:
{
"model": "tts-model",
"messages": [{"role": "assistant", "content": "要朗读的文本"}],
"audio": { "voice": "音色名", "format": "wav" },
"stream": false
}
音色做成环境变量可切换,方便按孩子喜好调整。
④ 音频要单声道 16kHz WAV
采样率、声道数、位深必须和 WAV 头里声明的一致,否则识别结果会是乱码或者空。
⑤ 评分任务给足 max_tokens
结构化 JSON 输出容易写到一半被截断。给 4096,然后在解析层做容错(剥围栏 + 正则提取 + 默认值兜底)。
⑥ 图片大小先在前端压
上传前用 canvas 压到合理尺寸再传,别让家长用手机拍的 12MP 原图直冲接口。后端也要校验大小(比如 8MB 上限)。
9.4 成本控制
这类应用的成本结构很特殊:输入远大于输出。每轮对话都要把题目、对话历史、系统提示词重新发一遍,输入 token 是输出的几十倍。
所以省钱的杠杆不在”换个更便宜的模型”,而在:
- 用缓存命中的输入价——同一段系统提示词反复发送,命中的部分价格能低一个数量级。
- 压缩对话历史——Part 3 讨论不要无限累积,到轮次上限就结算。
- 能写死的别调模型——固定话术(考官开场白)直接写字符串。
- 限制 max_tokens——评分 600、对话回复 150,够用就行,别给 4096。
十、部署上线
10.1 gunicorn 启动
cd /opt/your-tutor
source venv/bin/activate
gunicorn wsgi:app \
-b 127.0.0.1:8000 \
--timeout 90 \
--workers 2 \
--pid /tmp/your-tutor.pid \
--daemon
127.0.0.1绑定:只有本机 nginx 能访问--timeout 90:多模态批改比较慢,超时要给够--workers 2:2 核机器 2 个 worker,够用
10.2 systemd 托管
# /etc/systemd/system/your-tutor.service
[Unit]
Description=English Tutor gunicorn service
After=network.target
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/opt/your-tutor
EnvironmentFile=/opt/your-tutor/.env
ExecStart=/opt/your-tutor/venv/bin/gunicorn wsgi:app -b 127.0.0.1:8000 --timeout 90 --workers 2
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now your-tutor
sudo systemctl status your-tutor
密钥不要写进 unit 文件(unit 文件权限通常比较宽)。用 EnvironmentFile 指向 .env,密钥本身留在 600 权限的单独文件里。
10.3 nginx 配置
server {
server_name your-domain.com;
# 前端静态文件
location / {
root /opt/your-tutor/frontend;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端 API
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
listen 443 ssl; # 以下三行由 certbot 自动生成
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
X-Real-IP 这个头很重要——后端限流靠它拿真实 IP,不然后面所有请求看起来都来自 nginx 自己。
10.4 申请证书
sudo certbot --nginx -d your-domain.com
sudo systemctl reload nginx
certbot 会自动改配置、自动续期。之后什么都不用管。
10.5 验证
# 后端活着吗
curl -s http://127.0.0.1:8000/api/pet/vocab/stats
# 走 nginx 通吗
curl -s https://your-domain.com/api/pet/vocab/stats
十一、安全与成本
安全清单
- [x] 后端只监听
127.0.0.1,公网只能通过 nginx 访问 - [x] 密钥文件
chmod 600,不进代码仓库 - [x] 通行口令文件
chmod 600,口令通过私密渠道分发 - [x] 登录接口按 IP 限流(每分钟 8 次)
- [x] 上传文件大小限制
- [x] HTTPS 强制(80 端口 301 跳转)
- [x] SSH 禁用密码登录、改用密钥(这条别忘了)
- [x] 定期备份整个
/opt/your-tutor目录
成本估算(量级参考)
一个孩子每天练 20 分钟,主要是:
- 口语录音 ASR:几十段短音频
- LLM 评分/对话:几十次调用
- 写作批改:每天 1-2 次(多模态,最贵)
- TTS:几十次短文本
一个班(十几个孩子)的月成本通常是个位数的几十元量级,比线下辅导便宜几个数量级。真正的省钱点在第九节第 4 条。
十二、踩过的坑(真实清单)
| 现象 | 原因 | 解法 |
|---|---|---|
| 口语自动朗读全线失效,接口 500 | TTS 接口改版,必须显式传 audio.voice 和 audio.format |
加上 audio 字段 |
| 模型调用返回 200,但内容是空的 | 默认开启思考模式,max_tokens 全被思考过程吃光 |
显式 thinking: disabled,并把 max_tokens 提到 4096 |
| ASR 识别结果为空 | WAV 头的采样率/声道/位深和实际数据不一致 | 手写 WAV 头时四个数值必须严格对应 |
| 孩子点一下就开始录,结果录了 30 秒 | 没有自动停止 | 加 15 秒上限 |
| 点停止后上传了一个几乎空的文件 | 录音时间过短 | 小于 0.5 秒直接拒绝并提示 |
| 批改接口报 500 | 模型输出带 “`json 围栏或前后废话,JSON 解析失败 | 剥围栏 + 正则提取 + 默认值兜底 |
| 图片上传失败 | 手机原图太大 | 前端 canvas 压缩 + 后端 8MB 校验 |
| 语音识别偶发 429 | 供应商并发限流 | 后端转成明确状态码,前端提示稍后重试 |
| 限流记录文件越来越大 | 没做清理 | 只保留最近 100 个 IP |
| AI 在 Part 3 抢戏,一个人说不停 | 回复没限长度 | prompt 里明确”最多两句话” |
十三、后续可以怎么扩展
- 加真人语音评测:目前是”ASR 转文字 → LLM 评分”,可以再叠加发音打分,但成本会上去。
- 错题本导出:把 localStorage 的错题导出成 PDF 给家长看。
- 多孩子账号:现在是一个口令共用,可以加个昵称区分进度。
- 听力模块:TTS 已经通了,加个听写练习成本很低。
- 换个更好的批改模型:
model_client.py换一个函数就行。
十四、总结
这套东西的技术含量不在复杂度,在取舍:
- 不用框架 → 换来十年后还能改
- 不用数据库 → 换来备份就是 tar 一下
- 不做账号体系 → 换来家长零门槛
- 后端只监听本地 → 换来一整类安全问题消失
- 把模型调用全封装在一处 → 换来换供应商不用改业务代码
如果你有一个懂代码的 Agent,把这篇发给它,让它按第六节的目录结构建骨架、按第七节实现接口、按第十节部署。跑起来之后,第一节的三个模块就能用了。
剩下的时间,就交给孩子去练口语吧。