我用 Vibe Coding 给小学生搓了个 AI 口语陪练网站
写在前面
如果你家有娃在备考 KET,你应该知道那种”报了班但开口还是那几句话”的感觉。
我老板就是。他儿子 Austin 要考 KET,口语部分是老大难。市面上的 AI 口语 App 要么贵得离谱,要么体验像 2018 年的聊天机器人——点一下等十秒,还经常听不懂娃在说什么。
老板扔给我一句话:”能不能搓一个,就练 KET 口语,手机能打开就行。”
于是有了这个故事。
前传:在 Android 上崩溃了一天
第一版方案不是网站,是 Termux 上的本地 App。
老板的想法很合理:小孩用手机,装个 Termux 跑 Python 脚本,录完音当场 ASR → LLM → TTS,全部本地完成,不需要服务器,不需要网络,完美。
理想很丰满,现实是 Termux 的噩梦。
- Termux 的 Python 环境配起来像在拼乐高——缺这个 so 文件少那个头文件,pip install 能过的包不到一半
- Pydroid 3 倒是能装包,但录音组件跟 Android 权限模型打架,麦克风权限要自己搓 Java bridge
- 尝试了 kivy、chaquopy、甚至想用 Termux:API 的意图系统绕过去——全都在某个环节卡住
- 折腾了一整天,最后卡在录音文件传不进 Python 进程这个最基础的问题上
最讽刺的是,商用 AI API 调用在 Termux 上反而是最顺的一环。卡住我们的是最基础的问题——录音文件传不进 Python 进程。
崩溃了一天后,我俩同时说出了一句话:那就做网站吧。
手机浏览器打开就能用,不需要装任何东西。录音走浏览器原生 API,后端扔服务器上,AI 调用全走服务器。Termux 那个噩梦就直接绕过去了。
\—\—
老板的发心很朴素:不要复杂的课程体系、不要 AI 老师全程陪伴、不要积分排行榜。
要的东西三样:
- 背单词 —— 英→中选择 + 中→英拼写,自动筛弱词反复出
- 口语 Part 1 —— 给个高频题,录音,AI 打分 + 给范文
- 口语 Part 2 —— 跟 AI 扮演的小孩做情景对话,6 轮结束
全程不需要注册、不需要登录、打开网页就能用。
我听完的反应是:这需求干净得像一把手术刀。
没有用户系统、没有课程管理、没有进度追踪、没有付费墙。一个极简的单页应用 + 几个后端 API,全搞定。
这种需求放在大厂里能被评审会打回三遍——”商业化路径在哪?用户留存怎么做?DAU 目标多少?”
答案是:没有。这就是给自家小孩用的。
第二问:用什么搓?
选型表长这样:
| 层 | 选型 | 理由 |
|---|---|---|
| 后端框架 | Flask | 轻,Python 生态便利,十分钟搭好骨架 |
| WSGI 容器 | gunicorn | Flask dev server 不能上生产 |
| 反向代理 | nginx (OpenResty) | 这台机器本来就装着,顺手 |
| 前端 | 纯 HTML/CSS/JS 单页 | 零构建、零依赖、手机打开即用 |
| 语音识别 (ASR) | Mimo v2.5-ASR | 价格便宜,中文英文都行 |
| 对话/评分 (LLM) | Mimo v2-Flash | 够用就行的平衡点 |
| 语音合成 (TTS) | Mimo v2.5-TTS | 限时免费,声音还行 |
| 操作系统 | CentOS 7 | 现成机器,没得选 |
| Python | 3.7.9 | CentOS 7 默认源最高就是这版 |
这选型充满了手上有什么用什么的味道。没有微服务、没有容器化、没有 TypeScript。就是一个 Flask 小后端 + 一个纯前端页面 + 三个 AI API 调用。
“能用”的优先级远高于”优雅”。
架构全景
部署架构长这样:
浏览器 ──HTTPS──→ nginx:443 ──反向代理──→ gunicorn(localhost)
│
└── 静态文件直接返回
请求链路(口语评分场景):
录音 → nginx → Flask → base64编码 → Mimo ASR → 文字
→ 文字 + 题目 → Mimo LLM → JSON评分
→ 评分结果 → 浏览器
你说这有什么好写的?不就一个 CRUD 接三个 API 吗。
别急,坑在细节里。
踩坑实录
坑一:CentOS 7 的 Python 3.7.9 是个定时炸弹
这台机器是 CentOS 7,自带 Python 2.7。yum 装的 Python 3 最高到 3.7.9。你猜怎么着?urllib3 v2 需要 OpenSSL 1.1.1+,但 CentOS 7 的 OpenSSL 是 1.0.2。
所以 pip install requests 的时候,它拉下来的 urllib3 版本必须在 2 以下。如果不手动锁定 urllib3<2,你装完的 requests 一发请求就报 SSL 错误。
修法:pip install 'urllib3<2' requests
教训:CentOS 7 上装 Python 依赖,第一个想到的不是”装什么”,而是”什么版本太低装不了”。
坑二:API Key 被 shell 截断了
这是一个半夜找了一个小时的 bug。
Mimo 的 API Key 是 51 位。部署脚本用 env MIMO_API_KEY="${KEY}" gunicorn ... 传参数。看起来没问题对吧?
但 cat /proc/$PID/environ 里读到的 Key 只有 20 位。
原因:shell 变量展开时,某些特殊字符(比如 $)被 bash 二次解析了。Key 里正好有个 $ 后面跟了数字,被当变量展开了。
修法:改成从文件读 Key,不再走环境变量传参。
# app.py
key_path = "/opt/ket-tutor/.env.key"
api_key = open(key_path).read().strip()
set_api_key(api_key)
教训:API Key 这种不需要动态变化的东西,别走环境变量传到 shell 里绕一圈。写死到文件里,Python 自己读。
坑三:ASR payload 格式——不是你想象的那样
Mimo 有专门的 ASR 模型(mimo-v2.5-asr)。调用方式跟普通 chat/completions 不同。
第一次我这么传:
{
"model": "mimo-v2.5-asr",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "请识别以下音频"},
{"type": "input_audio", "input_audio": {
"data": "AAAA...base64..."
}}
]}
]
}
返回 400:Param Incorrect。
翻文档发现两个关键点:
- ASR 模型的 messages 里只能有 input_audio,不能有 text。你想加 prompt?不行。
- audio data 必须是 data URL 格式:
data:audio/wav;base64,{base64},裸 base64 不被识别。
修完的 payload:
{
"model": "mimo-v2.5-asr",
"messages": [
{"role": "user", "content": [
{"type": "input_audio", "input_audio": {
"data": "data:audio/wav;base64,AAAA..."
}}
]}
],
"asr_options": {"language": "auto"}
}
教训:不要拿着通用 chat API 的调用方式套专用模型。专用模型的 payload 往往有自己的一套规矩。
坑四:TTS 返回的是裸 PCM16,不是 WAV
Mimo 的 TTS 模型(mimo-v2.5-tts)通过 chat/completions 返回音频。你拿到的字段是:
data["choices"][0]["message"]["audio"]["data"] # base64 string
解码后是裸 PCM16 流——没有 WAV 头、没有 RIFF 标识,就是纯粹的 16-bit 线性 PCM 采样点。
浏览器 <audio> 标签不吃裸 PCM。你得手动包一个 WAV 头:
import wave, io
def pcm16_to_wav(pcm_data: bytes, sample_rate: int = 24000) -> bytes:
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm_data)
return buf.getvalue()
教训:“AI 返回了音频”不意味着浏览器能直接播。TTS 的 output 格式可能只是最原始的采样数据,需要你自己封装。
坑五:调试时被打到 429 限流
一个下午测了二十几次录音→ASR→LLM→TTS 全链路,Mimo 开始返 429。
HTTP 429 Too Many Requests
修法:asr_transcribe 里加个检测:
if resp.status_code == 429:
return "__RATE_LIMITED__"
前端收到这个标记后弹个友好提示:”评分服务器繁忙,请稍后重试”。
教训:AI API 不是无限额度的。开发阶段适当控制测试频率,或者在代码里预埋限流降级逻辑。
坑六:前端 JS 被 AI 生成的修复合并搞崩了
这是一个特别 vibe coding 的 bug。
我让 AI 帮修前端录音功能,AI 说”我先删掉重复的录音函数”。它把 showPage(页面切换函数)也当成”重复代码”删了。我直觉觉得不对,但当时在调试录音的逻辑里没仔细看。
结果:页面切换失效,按钮怎么点都不动。
然后 AI 又帮我”修”了一次,把补回来的代码和旧代码重叠在一起,留下了 8 行孤立的 setTimeout 尾巴和一个多余的 let audioChunks = [] 声明。JS 语法错误 → 整个脚本解析失败 → 所有事件绑定全废了。
三轮才修好。过程极其狼狈。
教训:AI 修代码时,删减逻辑要仔细读 diff,别全信。语法错误(let 重复声明、括号不匹配)是 AI 修复合并时的高频雷。
坑七:nginx 的默认 server block 劫持请求
CentOS 7 上装了宝塔面板(OpenResty),里面有个 server_name 127.0.0.1 的默认 server block 用于 php-fpm 状态监控。
我配的 KET 反向代理 server block 也监听 127.0.0.1:80,而且 server_name 配了域名。
问题是:curl 请求不传 Host 头时,nginx 会路由到 default_server。 而宝塔的默认 server block 没有配 listen ... default_server,路由行为取决于配置加载顺序。
修法:给 KET 的 server block 显式加上 listen 80 default_server,或者在 curl 时传正确的 Host 头。
教训:多 server block 的 CentOS 7 + 宝塔环境,server_name 路由不是你想象的那样工作。显式声明 default_server 是最稳的做法。
1140 个词怎么来的?
KET 官方词表是 Cambridge ESOL 出的 28 页 PDF。排版是双栏、左英右中。
流程:
pdftotext -layout提取文本(双栏排版用 -layout 参数才能保序)- Python 脚本解析双栏结构,左边英文右边中文配对
- 去掉重复的(同一个词在不同分类里出现多次)
- 拿到 1140 个词条
- 用 Mimo 批量翻译成中文(因为 PDF 里只有英文,中文是后续加的)
- 分两批翻译(800 + 340),5 次 API 调用搞定
- 整合成
ket_vocab.json,结构:{"word": "apple", "chinese": "苹果"}
这一步出乎意料地顺利——PDF 解析没翻车,翻译也没翻车。大概是因为词表太整齐了,API 也没法搞错。
几个数据
- 总代码量:后端 ~220 行,前端 ~400 行(纯 HTML 内嵌 JS+CSS),Mimo 客户端 ~130 行
- API 总数:10 个端点(单词 3 + 口语 Part 1 有 2 + 口语 Part 2 有 3 + TTS 1 + 静态文件 1)
- 依赖包:Flask、Flask-Cors、gunicorn、requests、python-multipart,没了
- 部署耗时:从开始搓到全链路跑通,约 3 个半天(第一个半天调研+搭骨架,第二个半天调试录音+ASR+LLM,第三个半天修前端 bug+收尾)
一点感慨
写完这个项目,我最大的感受是:
现在做一个能用的 AI 产品,门槛已经低到令人发指了。
十年前你想做一个语音对话应用,需要:
- 自研或采购 ASR 引擎
- 写自然语言理解管道
- 封装 TTS 引擎
- 搭 WebSocket 长连接
- 做状态机管理对话流程
- 写前端录音组件(Flash 时代这本身就是一个坑)
十年后你只需要:
- 注册一个 AI API 账号
- 调三个端点(ASR / LLM / TTS)
- 写 700 行代码
- 部署到一台不知道哪个机房落灰的服务器上
然后就能用了。
当然,”能用”和”好用”之间还隔着十万八千里。但当你服务的对象只是自家一个 11 岁的小孩时,”能用”就已经是 100 分了。
技术选型、踩坑实录、代码片段基于实际项目记录。部分细节做了脱敏处理。