开源项目:English Agent —— 一个通过旅行场景和 AI 角色学英语的 Web 应用。 GitHub:github.com/ava-agent/e…
说实话,我做这个项目的初衷挺没劲的——就是觉得背单词 APP 太无聊了。😅
你懂的,那种每天打卡「abandon」的仪式感,坚持三天就真 abandon 了。我之前也下过七八个英语学习软件,结果不是变成推送通知收集器,就是永远在复习 "hello"。所以这次想折腾点不一样的:能不能让学英语这件事,稍微像玩游戏一样?
所以这是个什么东西?
简单说就是一个情景对话英语陪练。你选一个旅行目的地(东京、巴黎、纽约这些),再选一个场景(机场、餐厅、酒店),然后跟一个 AI 角色聊天。这个 AI 不是冷冰冰的机器人,而是有自己的人设——比如 Sophia 是个美国旅行博主,说话风格比较 casual;Emma 是英国英语老师,会不自觉地纠正你的用词。
聊完之后,系统会自动把对话里出现的新词抽出来,生成释义和例句,然后丢进 FSRS 间隔重复系统里安排复习。
听起来好像很多英语学习产品都在做类似的事?但自己做一遍就会发现,魔鬼真的在细节里。
踩坑 1:LLM temperature 不是越大越好
一开始我理所当然地认为,对话嘛, creativity 越高越好,temperature 直接拉到 1.2。结果呢?AI 角色直接放飞自我,开始跟你聊她昨晚看的 Netflix 剧集,完全忘了你们是在餐厅点餐。
哈哈,用户当场懵掉。
后来老老实实做了分层策略:
// lib/llm.ts
export async function streamChatResponse(
messages: Message[],
character: Character,
scenario: Scenario
) {
const response = await openai.chat.completions.create({
model: 'glm-4-plus',
messages: buildSystemPrompt(character, scenario).concat(messages),
temperature: 0.85, // 够活泼,但不会跑偏
stream: true,
});
return response;
}
// 词汇提取用低 temperature,确保稳定性
export async function extractVocabulary(text: string) {
const response = await openai.chat.completions.create({
model: 'glm-4-plus',
messages: [{ role: 'system', content: VOCAB_PROMPT }, { role: 'user', content: text }],
temperature: 0.3, // 事实性任务,越低越稳
response_format: { type: 'json_object' },
});
return z.array(vocabSchema).parse(JSON.parse(response.choices[0].message.content!));
}
这个调整虽然看起来只是改个数字,但用户体验差别巨大。创意任务(对话、开场白)用 0.850.9,事实提取(词汇、练习)用 0.30.4。我觉得这个范围可能因模型而异,但至少对 GLM-4 Plus 来说挺合适的。
踩坑 2:流式响应存数据库,差点把我搞崩溃
Next.js App Router 的流式响应确实香,但问题是:用户看到消息的同时,你得把它写进数据库。如果等对话结束了再批量写入,那用户刷新页面就丢消息了。
我一开始的做法是在 API Route 里一边 for await 读取流,一边往 Supabase 插数据。结果流一断,数据库事务也乱了,偶尔出现消息顺序不对的情况。
后来换成了 ReadableStream.tee() 的思路:把流一分为二,一份给客户端做实时展示,一份在后台攒成完整文本再存库。
// app/api/chat/[id]/route.ts
export async function POST(request: Request, { params }: { params: { id: string } }) {
const { messages } = await request.json();
const stream = await streamChatResponse(messages, character, scenario);
// tee() 把 ReadableStream 复制成两个
const [clientStream, dbStream] = stream.tee();
// 后台任务:把完整回复写入数据库
const text = await streamToText(dbStream);
await saveMessage(params.id, { role: 'assistant', content: text });
return new Response(clientStream.toReadableStream(), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
不过说实话,这个方案也有缺点——如果用户发完消息立刻刷新,后台的 saveMessage 可能还没执行完。我后来的 workaround 是在前端加了一个 beforeunload 的本地缓冲,顺便也做了 optimistic update。算是勉强能接受吧,但离完美还差得远。
踩坑 3:FSRS 不是装上就万事大吉的
背单词的核心是复习 scheduling。我一开始用的是最简单的艾宾浩斯曲线,后来发现效果一般,用户反馈总是在 "还没忘" 和 "已经忘了" 之间反复横跳。
咬牙换成了 FSRS(Free Spaced Repetition Scheduler),也就是基于数学模型的开源间隔重复算法。用的 npm 包是 ts-fsrs。
// lib/srs.ts
import { FSRS, generatorParameters, Card, Rating } from 'ts-fsrs';
const params = generatorParameters({
request_retention: 0.9, // 希望 retention 达到 90%
maximum_interval: 365,
});
const f = new FSRS(params);
export function reviewCard(card: Card, rating: Rating) {
const scheduling = f.repeat(card, new Date());
return scheduling[rating];
}
FSRS 有 Again / Hard / Good / Easy 四个等级,算法会根据你的历史表现动态调整 interval。这个确实比固定曲线科学多了。
但问题来了:FSRS 的 Card 对象有十几个字段(state, step, last_review, due, stability, difficulty, elapsed_days, scheduled_days, reps, lapses, history),前端不小心改动一个就会导致 scheduling 错乱。
我的解决方案是:后端只暴露 due 和 stability 给前端看,真正的 reviewCard 计算必须在 Server Action 里完成。
// app/actions/review.ts
'use server';
export async function submitReview(cardId: string, rating: number) {
const card = await getCardById(cardId);
const result = reviewCard(card, rating as Rating);
await updateCard(cardId, {
due: result.card.due,
stability: result.card.stability,
difficulty: result.card.difficulty,
reps: result.card.reps,
lapses: result.card.lapses,
state: result.card.state,
});
await insertReviewLog(cardId, rating, result.log.elapsed_days, result.log.scheduled_days);
}
这个改动花了我差不多两个周末。不过用户反馈复习节奏明显顺了很多,值吧。
架构设计:四个 Agent 打配合
整个系统我拆成了四个小 Agent,每个只做一件事:
| Agent | 干啥的 | 核心文件 |
|---|---|---|
| Chat Agent | 维持人设、实时对话流 | lib/chat-ai.ts |
| Vocabulary Agent | 对话结束后提取词汇 | lib/chat-ai.ts |
| Session Builder | 编排每日学习计划 | lib/session-builder.ts |
| FSRS Agent | 算下次什么时候复习 | lib/srs.ts |
这个设计有个好处是责任和边界很清楚。比如 Chat Agent 完全不用关心词汇怎么存,它只负责把关键词用 **粗体** 标出来;Vocabulary Agent 的事后任务就是正则匹配 \*\*(.+?)\*\*,再调用 LLM 生成释义。
但坏处也挺明显的:Agent 之间共享的数据结构一旦变动,就得改好几个地方。比如我某天把 chat_conversations 表的 status 字段从字符串改成了 enum,结果 Chat Agent 和 Session Builder 的 SQL 查询都报错了。
所以后来加了 Zod 校验,所有边界数据都必须过 schema:
// lib/validation.ts
export const vocabSchema = z.object({
word: z.string().min(1),
definition: z.string(),
translation: z.string(),
example: z.string(),
category: z.enum(['travel', 'tech', 'daily']),
difficulty: z.number().min(1).max(5),
});
这个有点麻烦,但真的能少踩很多运行时坑。
一些真实的缺点
讲到这我还得泼点冷水。这个项目并不是那种「完美无缺」的 demo,实际跑起来有不少问题:
- Token 成本不乐观。GLM-4 Plus 单轮对话大概 800~1500 tokens,免费试用用户多了之后,账单还是有点小压力。🥲
- 语音能力缺失。虽然叫 "English Agent",但目前还没有语音输入和 TTS,只能靠键盘打字练习书面英语。
- 本地化做得一般。虽然也支持曼谷、首尔这些亚洲城市,但场景设计还是偏西方视角,对非英语母语者的真实痛点覆盖不够全。
- 移动端适配勉强能看。因为用了 Next.js + Tailwind,小屏上 UI 没崩,但某些交互(比如复习卡片滑动)还是不如原生 App 顺手。
这些东西如果要商业化,估计还得再磨半年。但作为开源 side project,我觉得够用了。
最后说两句
做这个项目最大的收获不是技术,而是重新理解了「语言学习」这件事的本质:不是背单词,而是在真实语境里反复犯错、慢慢习惯。AI 能提供的价值,不是替代老师,而是创造一种「安全的犯错环境」——你不会因为说错一句酒店入住对话而感到丢脸。
如果你也是 Next.js + AI 方向的开发者,或者正好也想折腾一个语言学习相关的产品,欢迎来看看代码提提 issue。目前项目开源在 GitHub,stars 不多(才 2 个哈哈),但代码应该还挺完整的。
🔗 GitHub: github.com/ava-agent/e… 🌐 在线体验: english.rxcloud.group
话说回来,你们有没有试过用 AI 学英语?是觉得挺有帮助的,还是更像在跟搜索引擎聊天?欢迎评论区聊聊~ 👇