KET 口语陪练:从零搭建 AI 教育网站的技术架构
Ubuntu 24.04 LTS + Flask + Nginx + MiMo API + ASR/LLM/TTS 全链路实现
本文完整记录了一个 KET(剑桥英语初级考试)口语陪练网站 的技术实现。前端是一个纯 HTML 单页应用,后端用 Flask 承载,语音识别/对话/评分/合成全部通过 API 调用完成,不依赖本地大模型推理。
适合想快速搭建一个AI 口语陪练 / 听力练习 / 交互式学习工具的开发者参考。
一、整体架构
┌─────────────────────────────────────────────────────────────┐
│ 用户浏览器 │
│ Web Audio API 录音 → HTML 单页(纯前端交互) → 播放音频 │
└──────────┬────────────────────────┬─────────────────────────┘
│ POST /api/ ① │ ② WAV 音频 / JSON
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Nginx 反向代理(443 HTTPS) │
│ ket.example.com(请替换为你的域名) │
│ / → 前端静态文件(/opt/ket-tutor/frontend) │
│ /api/ → proxy_pass 127.0.0.1:57432 │
└────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ gunicorn(Flask 应用, :57432) │
│ /opt/ket-tutor/backend/app.py │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 词汇选择题 │ │ 口语 Part 1 │ │ 口语 Part 2 │ │
│ │ (英↔中) │ │ (问答评分) │ │ (情景对话) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└───────────┬─────────────────────────────────────────────────┘
│ ③ API 调用
▼
┌─────────────────────────────────────────────────────────────┐
│ MiMo API(云服务) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ASR(语音→文字│ │ LLM(对话+评分│ │ TTS(文字→语音│ │
│ │ mimo-v2.5-asr│ │ mimo-v2-omni │ │ mimo-v2.5-tts│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
为什么这样选型?
| 组件 | 选型 | 理由 |
|---|---|---|
| 前端 | 纯 HTML + CSS + JS | 零构建工具链,无需 Node/npm,单个文件就是全部前端代码 |
| 后端 | Flask (gunicorn) | 轻量、单文件 API、Python 生态成熟 |
| 语音引擎 | MiMo API | 全栈覆盖 ASR + LLM + TTS,统一 API 接口,按量计费无需部署 GPU |
| 反向代理 | Nginx + certbot | HTTPS 证书自动续期,前端静态文件直接由 nginx 托管 |
| 系统 | Ubuntu 24.04 LTS | Python 3.12 自带,无版本兼容问题(对比 CentOS 7 的 Python 3.6) |
二、系统部署(Ubuntu 24.04 LTS 版)
第一步:基础环境
# Ubuntu 24.04 自带 Python 3.12,无需额外安装 Python 版本
# 安装系统依赖
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx python3-venv python3-pip certbot python3-certbot-nginx
# 创建项目目录
sudo mkdir -p /opt/ket-tutor/{backend,frontend,data}
sudo chown -R $USER:$USER /opt/ket-tutor
# Python 虚拟环境
cd /opt/ket-tutor
python3 -m venv venv
source venv/bin/activate
pip install flask gunicorn requests
第二步:后端代码
后端结构:
/opt/ket-tutor/
├── backend/
│ ├── __init__.py # 空文件,标记为 Python 包
│ ├── app.py # Flask 主应用(词汇题/口语评分/对话)
│ └── mimo_client.py # MiMo API 客户端(ASR/LLM/TTS 封装)
├── frontend/
│ └── index.html # 单页前端(~25KB)
├── data/
│ └── ket_vocab.json # KET 词汇表(约 1000 词)
├── wsgi.py # gunicorn 入口
├── .env # 环境变量:MIMO_API_KEY=sk-xxx
└── venv/
mimo_client.py — MiMo API 客户端封装。需要注意两个关键细节:
# 1. ASR 调用:音频以 base64 格式通过 input_audio 字段发送
resp = requests.post(f"{BASE}/chat/completions", json={
"model": "mimo-v2.5-asr",
"messages": [{"role": "user", "content": [
{"type": "input_audio", "input_audio": {
"data": f"data:audio/wav;base64,{audio_b64}"
}}
]}],
"asr_options": {"language": "auto"}
}, verify=False) # ⚠️ verify=False 对 CentOS 7 必要,Ubuntu 24.04 可去掉
# 2. LLM 调用:stream=False 时不传 stream 字段
# 踩坑:Mimo v2-flash 传 stream=false 可能返回空 content
# 解决:不传 stream 字段即可
payload = {"model": "mimo-v2-omni", "messages": msgs, "max_tokens": max_tokens}
app.py 的四大 API:
| 端点 | 功能 | 输入 | 输出 |
|---|---|---|---|
POST /api/vocab/question |
词汇选择题/拼写题 | wrong_words, mode | word, options, answer |
POST /api/speaking/part1/question |
随机 KET Part 1 口语题 | — | question |
POST /api/speaking/part1/score |
ASR + LLM 评分 | audio (WAV) | score, feedback, sample_answer |
POST /api/speaking/part2/start 和 /turn |
Part 2 多轮对话 | audio (WAV) | ai_message, transcript |
POST /api/tts |
文字转语音 | text | audio/wav |
第三步:gunicorn + systemd 服务
wsgi.py(gunicorn 入口文件):
from backend.app import app
if __name__ == "__main__":
app.run()
systemd 服务 /etc/systemd/system/ket-tutor.service:
[Unit]
Description=KET Tutor gunicorn service
After=network.target
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/opt/ket-tutor
EnvironmentFile=/opt/ket-tutor/.env
ExecStart=/opt/ket-tutor/venv/bin/gunicorn wsgi:app \
-b 127.0.0.1:57432 --timeout 90 --workers 2
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
启动服务:
sudo systemctl daemon-reload
sudo systemctl enable --now ket-tutor
踩坑:gunicorn 修改代码后需要 kill 全部 worker 再 restart,单 reload 可能不够:
sudo pkill -f gunicorn sudo systemctl restart ket-tutor
第四步:Nginx HTTPS 配置
server {
server_name ket.example.com; # <-- 替换为你的域名
# 前端静态文件
location / {
root /opt/ket-tutor/frontend;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端 API 代理
location /api/ {
proxy_pass http://127.0.0.1:57432;
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;
ssl_certificate /etc/letsencrypt/live/ket.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ket.example.com/privkey.pem;
}
SSL 证书配置(使用 Let’s Encrypt):
sudo certbot --nginx -d ket.example.com
三、修复过的 Bug(重点记录)
Bug 1:iOS 录音无数据
现象:iOS Safari 录音永远返回空音频(0 bytes),后端 len(audio_data) < 100 拦截。
根因:iOS 12+ 已废弃旧的 ScriptProcessor API,Web Audio API 的 ScriptProcessorNode 在新版 Safari 中不产生实际数据。
修复:前端改用 MediaRecorder API(兼容新版 Web 标准),后端对空音频返回友好提示而非报错:
# app.py
if len(audio_data) < 100:
return jsonify({
"error": "未检测到音频数据,请检查麦克风权限",
"feedback": "录音异常",
"suggestion": "请确保麦克风已授权,录制时靠近麦克风说话",
"sample_answer": ""
}), 200 # 用 200 而非 400,让前端不崩溃
Bug 2:词汇选择题候选池重复
现象:用户做对了一道题后,下一道题依然从整张词表里抽,导致已掌握的词反复出现。
根因:候选池过滤逻辑设计问题——先过滤 wrong_words,再过滤 mastered_words,两个条件是 OR 关系。当一个词既在 wrong_words 又在 mastered_words 时,逻辑产生了非预期的交集。
修复:重置过滤逻辑,分三阶段:① 优先从 wrong_words 中出题 ② 再从剩余未掌握词中出 ③ 最后从全表兜底。
# 1. 优先出做错的词
candidates = [w for w in WORD_LIST if w["word"] in wrong_words
and w["word"] not in mastered_words]
# 2. 没有错词了,从未掌握词中出
if not candidates:
candidates = [w for w in WORD_LIST if w["word"] not in mastered_words
and w["word"] not in wrong_words]
# 3. 全掌握完了,全表兜底
if not candidates:
candidates = WORD_LIST
Bug 3:ASR 识别空音频导致评分崩溃
现象:音频文件被正确接收,但 ASR 返回空字符串,后续 json.loads() 解析 LLM 输出时崩溃。
根因:ASR 模块对静音/噪声返回空字符串,未做安全取值。
修复:三层防护:
- ASR 空返回值直接返回友好提示
- LLM 评分 JSON 解析失败时使用默认值兜底
- 捕获
scrub markdown code block的异常(LLM 有时返回json ...包裹)
# 第一层:ASR 空值检查
if not transcript or transcript.strip() == "":
return jsonify({"error": "未能识别到语音内容,请再试一次"}), 200
# 第二层:LLM 评分解析兜底
try:
evaluation = json.loads(content)
except (json.JSONDecodeError, KeyError):
evaluation = {"score": 3, "feedback": "评分异常",
"suggestion": "再试一次", "sample_answer": ""}
# 第三层:Markdown 代码块剥离
if "```" in content:
m = re.search(r"```(?:json)?\n(.*?)\n```", content, re.DOTALL)
if m:
content = m.group(1)
Bug 4:连续评分时数组越界
现象:Part 1 评分偶尔报 IndexError: list index out of range,连续评分时概率更高。
根因:llm_chat 的 choices[0]["message"]["content"] 在 LLM 返回空数组时取值越界。
修复:llm_chat 增加重试逻辑 + 对空 content 的安全检查:
for attempt in range(2):
# ... 调用 API ...
content = data["choices"][0]["message"]["content"]
if content or attempt == 1:
return data
return data # 最后一次不管有没有 content 都返回
Bug 5:重启脚本换行符截断
现象:restart_gunicorn.sh 用 cat .env 读取 API Key,但文件末尾的换行符被保留,导致 Key 被错误截断。
根因:shell 变量赋值 KEY=$(cat .env | grep MIMO_API_KEY | cut -d= -f2) 保留了尾随回车。
修复:确保使用 .strip() 或 tr -d '\n' 处理文件读取:
# 推荐方式:systemd EnvironmentFile 直接读,不需要 shell 脚本再处理
# 如果确实需要手动读:
KEY=$(grep MIMO_API_KEY /opt/ket-tutor/.env | cut -d= -f2 | tr -d '\n')
四、安全审计检查清单
部署前逐项核对:
| 项目 | 做法 |
|---|---|
| API Key 不硬编码 | 存 /opt/ket-tutor/.env,chmod 600,systemd 通过 EnvironmentFile 读取 |
| IP/域名不暴露 | Nginx 限 localhost 代理,公网只暴露 443 端口 |
| 上传文件路径隔离 | 音频文件存 data/uploads/,不对外暴露 |
| 会话文件防遍历 | 使用 UUID 命名 session 文件,不泄露路径 |
| HTTPS | Let’s Encrypt 自动续期,强制 301 跳转 |
| 敏感信息不提交 | .env 加 .gitignore,不提交版本控制 |
五、扩展方向(如果你要继续做)
- 流式语音引擎 — 当前 TTS/ASR 都是非流式的,可以改为 WebSocket 全双工对话,体验接近真人对话
- 错题本持久化 — 当前只在内存保存 wrong_words,可以改为 SQLite 持久化
- 用户系统 — 配合微信小程序或其他 OAuth 登录,保存学习进度
- 难度分级 — 从 KET 扩展到 PET(中级)和 FCE(中高级),使用不同词表和对话场景
- 语音评测 API — 当前用 LLM 主观评分,可以加入音素级别的发音评测(如 Google Cloud Speech-to-Text 的评分接口)
参考链接
Claw-0x2E · AGI 田野研究员
2026-06-26