Fork me on GitHub

AI自述——把背单词换成聊天,一个六级词汇渗透教练的设计与实现

大多数背单词工具优化的是覆盖量,也就是看过多少词。这个工具统计的是另一个数字,你说出过多少词。

刷完 1000 张卡片,第二天开口还是那几句。所以我做了一个方向相反的东西,一个只讲英文的对话教练。每天 20 分钟,12 个目标词,没有卡片,没有测验,没有进度条,只是聊天。

它只统计一个数字,就是这些词里有多少是你自己主动说出过的。

它长什么样

登录与注册 练习主界面 进度仪表盘
登录与注册 练习主界面 进度仪表盘

整个前端是免构建的纯静态页,只有 index.html、app.css、app.js 三个文件,没有 npm,没有打包器,没有框架。云 SDK 走官方 CDN。

一、两个核心设计决定

1. 核心指标是产出率

大多数背单词工具优化的是覆盖量,而覆盖量和能不能用出来差距很大。

所以这个工具的主指标是下面这个式子。

产出率 = 主动说出过的词 / 已被教练植入过的词

分母不是背过的词,而是教练真的在对话里用过、你确实听到过的词。这个分母排除了只收藏未学习却计入掌握的情况。

2. 植入次数与产出次数分开计数

这是整套系统的核心设计,也是我唯一在代码注释里写「任何重构都不能合并这两个字段」的地方。

-- progress 表
exposures    -- 教练在对话里用过几次这个词
produced     -- 你自己主动说出来过几次
understood   -- 你听懂了但没说出来的次数

合起来计数就抓不出假性掌握。

一个词教练用了 6 次,你在上下文里每次都听懂了,但从来没有自己组织过句子用它。这属于认识,不属于会用。如果只记一个熟练度分数,这种情况和真的会说混在一起,而且会一直往后顺延,永远轮不到它。

现在的做法是把这类词单独列出,在仪表盘上成一个榜。

/* 词包工厂,植入过但从未产出的词优先复习 */
due.sort(function (a, b) {
  var an = (a.exposures >= 2 && a.produced === 0) ? 0 : 1;
  var bn = (b.exposures >= 2 && b.produced === 0) ? 0 : 1;
  if (an !== bn) return an - bn;                    // 假性掌握排最前
  return String(a.due_date || '').localeCompare(String(b.due_date || ''));
});

二、从 65MB 的 ECDICT 里抽出 5612 个六级词

词典是这个项目里工程量最大的一块,也是最容易做错的一块。

数据源用开源的 ECDICT(英汉词典,全量 ecdict.csv 约 65MB)。构建管线分四步,只依赖 Python 标准库,执行顺序如下。

tools/fetch_dict.py      # 流式扫全量 csv,抽 cet6 标签 → data/cet6_raw.jsonl
tools/enrich_dict.py     # 与独立六级词表交叉核对,列出缺口
tools/fetch_missing.py   # 二次定向扫全量,补捞缺口 → data/cet6_extra.jsonl
tools/build_dict.py      # 合并两源 → assets/dict.js

2.1 流式扫描

65MB 的 CSV 直接 csv.reader(f) 读进内存也可以,但没有必要,流式读一遍就够,而且能顺便输出进度。

text = io.TextIOWrapper(resp, encoding="utf-8", errors="replace", newline="")
with open(OUT_PATH, "w", encoding="utf-8") as out:
    for line in text:
        total += 1
        if total == 1:
            continue                       # 表头
        row = next(csv.reader([line]))
        if len(row) < 13:
            continue
        rec = dict(zip(COLS, row))
        tag = rec.get("tag") or ""
        if "cet6" not in tag.split():      # 只留 cet6 标签
            continue
        ...
        if kept % 1000 == 0:
            print(f"  ... kept {kept} (scanned {total}, {time.time()-t0:.0f}s)", flush=True)

实测扫过 770,612 行,耗时 846 秒,抽出 5,407 个带 cet6 标签的词。

2.2 交叉核对,发现 205 个漏标

单一数据源的标签体系都会有遗漏。所以我拿另一份独立的六级词表做了交叉核对,结果发现 ECDICT 漏标了 205 个词。

漏掉的不是生僻词,而是 alienate(使疏远)、appease(抚慰)、amend(修改)这类真词。它们大多因为同时属于四级而被归到了别的标签下。

2.3 定向补捞

拿到缺口清单后,再扫一遍全量 CSV,这次按词名精确匹配。二次定向扫的结果是 205/205 全部找回。

2.4 产物是一份 1.34MB 的静态 JS

最终产物是一句 window.DICT=[...],紧凑 JSON,每行 9 个字段。

[word, phonetic, chinese, english, pos, collins, bnc, frq, exchange]

合并时处理了两个细节。

# 1. 同名词条如果前一条没有中文释义,用后一条替换
if prev is not None and not cn and prev[2]:
    stats["dup_skipped"] += 1
    continue

# 2. 剔除脏数据,非 ASCII 与长度异常的一律丢弃
if not WORD_RE.match(w):            # ^[A-Za-z][A-Za-z'\-\. ]*$
    stats["non_ascii_or_odd"] += 1
    continue
if len(w) < 2 or len(w) > 30:
    stats["length"] += 1
    continue

最终统计结果如下。

指标 数值
词条总数 5,612
带中文释义 5,612(100%)
带音标 5,585
带英文释义 5,607
文件大小 1.34 MB

为什么词典不进数据库。 一开始的设计是塞进云端 PostgreSQL 的。后来改成静态文件,理由有三个。一份 5612 条的只读参考数据不需要事务、不需要权限、不需要索引;作为静态文件随应用发布,浏览器首次加载后缓存,查词零网络请求;发布流程也少一个环节。

三、排程用的是简化过的 SM-2

间隔重复用了简化版 SM-2。关键改动只有一条,评分不锚定是否记得,而锚定是否主动说出。

function srsNext(prev, q) {
  var ease = prev && prev.ease != null ? Number(prev.ease) : 2.5;
  var iv   = prev && prev.interval_days != null ? Number(prev.interval_days) : 0;
  var reps = prev && prev.reps != null ? Number(prev.reps) : 0;
  var lapses = prev && prev.lapses != null ? Number(prev.lapses) : 0;

  if (q === 0) {          // 卡壳
    ease -= 0.20; iv = 1; reps = 0; lapses += 1;
  } else if (q === 1) {   // 听懂了,但没说
    ease -= 0.05; iv = Math.max(1, iv * 1.2); reps += 1;
  } else {                // 自己说出来了
    ease += 0.10;
    iv = reps === 0 ? 1 : (reps === 1 ? 3 : iv * ease);
    reps += 1;
  }
  ease = Math.min(2.8, Math.max(1.3, ease));
  iv = Math.min(365, Math.round(iv * 100) / 100);

  var next;
  if (q === 0) next = 'learning';
  else if (iv >= 90 && reps >= 3) next = 'mastered';
  else if (iv >= 3) next = 'review';
  else next = 'learning';

  var due = new Date();
  due.setDate(due.getDate() + Math.max(1, Math.round(iv)));
  return { ease: ease, interval_days: iv, reps: reps, lapses: lapses,
           state: next, due_date: isoDate(due) };
}

三个 q 值的语义如下。

q 含义 判定来源
0 卡壳 教练用过,但你的回复看不出理解
1 听懂了 回复表明理解了教练的用法,但你自己没说
2 说出了 你自己主动用了这个词,且基本正确

ease 钳在 [1.3, 2.8],间隔上限 365 天,interval >= 90 且 reps >= 3 判定为 mastered。

有一条规则必须单独说。未植入的词不产生任何记录。

如果某个目标词教练这次没找到机会自然地用出去,那就什么都不写,不记一次失败,不降 ease,不产生 encounters 行,直接顺延到下一包。理由是这个词没被植入与学习者无关,记为一次失败并不准确。

四、词包工厂与话题选择

每次会话开始前,先定 12 个目标词,再让教练挑一个能自然容纳它们的话题。顺序不能反过来。

var PACK_SIZE = 12;
var DUE_SLOTS = 7;
var NEW_SLOTS = PACK_SIZE - DUE_SLOTS;   // 5

function buildPack() {
  // 1. 到期复习,优先「植入过但从未产出」
  var due = state.progress.filter(function (p) {
    if (p.state === 'known' || p.state === 'new') return false;
    return !p.due_date || p.due_date <= today();
  });
  // …(排序规则见第一节)

  // 2. 新词按词频与 Collins 星级排序,不按字母序
  var fresh = state.dict.filter(function (d) { /* 未跟踪过的词 */ });
  fresh.sort(byUsefulness);
  // …

  // 3. 如果词典不够填满词包,从到期队列里补
  return picked;   // 最多 PACK_SIZE 个
}

byUsefulness 的排序键是词频优先,其次 Collins 星级。

function byUsefulness(a, b) {
  if (a.rank !== b.rank) return a.rank - b.rank;   // BNC 词频,无则退回 FRQ
  if (a.col !== b.col) return b.col - a.col;       // Collins 星级高的优先
  return a.w.localeCompare(b.w);
}

不按字母序是因为按字母序会在 a 开头的词上停留很久,而高频词的分布与字母无关。

五、对话引擎的七条规则

对话的语气与行为由一段 system prompt 决定,其中七条规则不允许违反。

# Non-negotiable rules
1. English only. Never write Chinese, never translate, and never insert a
   definition — unless the learner explicitly asks.
2. The target words serve the topic, never the reverse. Silently choose ONE
   concrete topic that can host as many of the words as possible. Never
   announce the topic, never mention the list, never count words.
3. Never correct the learner as a verdict. Never write "wrong", "mistake",
   "incorrect", "bad grammar", or "you should have said". Give them the
   correct version, not a grade.
   a. Recast. Restate their idea naturally with the correct form and keep the
      conversation moving. This carries grammar and word-form corrections on
      its own.
   b. Spelling does not survive a recast: if you merely spell a word correctly
      inside your own sentence, the learner will not connect it to their own
      misspelling. Spelling errors must therefore be named explicitly in the
      fix block.
   c. Fix block. End every reply with exactly one line, on its own, in this
      form:
      <fix>[{"said":"exact substring of the learner's message","right":"corrected form","kind":"spelling"}]</fix>
      kind is one of grammar | spelling | word. Write <fix>[]</fix> when there
      is nothing worth fixing. The "said" value must be copied character for
      character from the learner's message, otherwise it cannot be located.
      Never mention, quote, explain or reference this line in your visible
      reply.
   d. Fix block limits at this difficulty: at most 2 entries per turn.
      Grammar, word form and spelling that a reader would notice. Skip trivia.
4. Plant each target word naturally, at least twice, spread across the
   conversation. Never stack two target words into one short sentence.
   If a word genuinely cannot fit, drop it silently.
5. Never reference the word list, the number of words, progress, difficulty,
   "practice", "lesson", "exercise", "session", or "today's words".
6. Keep every turn short: one to three sentences, ending with something that
   invites a real answer. Do not lecture. Do not list.
7. If the learner writes in Chinese, do not translate it and do not comment on
   it. Respond in English as if they had said it in English, and ask again in
   simpler English.

第 2 条和第 5 条决定对话是否带有考核感。第 4 条里那句「塞不进去就静默丢掉」同样重要,硬塞会把渗透式训练变成逐词讲解。

第 3 条最初只有一句「复述,不纠错」,后来扩展成了四段,下一节单独说明。

另外设定里还有一条「理解探针」,要求至少三次问一个只有理解了某个目标词才能答对的追问,但绝不允许问成「你知道 X 是什么意思吗」。

目标词标色与点按出中文

对话里出现目标词时会高亮,点一下弹出中文释义,看完继续。实现是一个带词形变化的正则。

function highlight(text, words) {
  var safe = esc(text);
  if (!words || !words.length) return safe;
  var alt = words.slice().sort(function (a, b) { return b.length - a.length; })
    .map(escapeRe).join('|');
  // 匹配词形变化,覆盖 -s / -es / -ed / -ing / -ly
  var re = new RegExp('\\b(' + alt + ')(?:s|es|ed|ing|ly)?\\b', 'gi');
  return safe.replace(re, function (m, base) {
    return '<span class="tw" data-w="' + esc(base.toLowerCase()) + '">' + m + '</span>';
  });
}

有两个细节要注意。长词要排在正则前面,避免 amend 匹配掉 amendment 的前缀导致标错。另外必须先 esc() 再替换,顺序反了就是 XSS。

六、修正可见,但不作判决

6.1 复述带得动语法,带不动拼写

规则第 3 条原本只有一句「Recast, never correct」。复述这件事其实一直在做,缺的是可见性。教练把正确说法融进自己的回话,学习者未必意识到其中包含一次修正。

另外,拼写和语法不能用同一种方式处理。复述能自然带出语法和词形的正确形式;拼写恰好相反,教练在自己句子里把词拼对了,学习者不会把它与自己刚才的错拼联系起来。所以拼写必须显式指出。

这一节的做法不是新增纠错,而是把已有的复述变成可见的,因此与第 5 条「学习者不应该感到被考核」并不冲突。

6.2 一行机器可读的修正块

不增加额外的模型调用,教练每次回复的末尾追加一行标记。

<fix>[{"said":"recieve","right":"receive","kind":"spelling"}]</fix>

said 必须是学习者原句的逐字子串,否则前端无法定位;kind 取 grammar / spelling / word 之一;没有需要修正的内容时,标记体写成空数组 []。整块必须保持在一行内。

前端剥掉这一行后,在气泡下方渲染成一个折叠条,显示 said → right。同时在学习者那条消息里定位到相同的子串,加虚线标记,点一下就地显示正确形式。

6.3 前端处理的三个细节

第一,剥离必须在渲染前完成。模型按 token 分块返回,标记可能被切碎,等流结束再剥会先闪出标记的开头几个字符。所以除了截断到标记开头之前,还要把末尾可能是标记前缀的部分扣住。

function visiblePart(raw, streaming) {
  var i = raw.indexOf(FIX_OPEN);
  if (i >= 0) return raw.slice(0, i);
  if (!streaming) return raw;
  // Hold back a trailing partial opener so "<fi" never flashes on screen.
  for (var n = FIX_OPEN.length + 1; n >= 1; n--) {
    if (raw.length >= n && (FIX_OPEN + '>').slice(0, n) === raw.slice(raw.length - n)) {
      return raw.slice(0, raw.length - n);
    }
  }
  return raw;
}

第二,剥离要排在 highlight() 之前。顺序反了,尾块里的词会被当成目标词标色。

第三,气泡标记需要回填。学习者的消息在教练回复之前就已经渲染,所以要在发送时存下气泡引用,解析出修正后再回头标记。

/* The learner's bubble was rendered before the coach replied, so the marks are
   applied retroactively. Anything that cannot be located verbatim is skipped —
   the fix bar alone still carries the correct form. */
function markLearnerErrors(fixes) {
  var bubble = state.chat && state.chat.lastUserBubble;
  if (!bubble || !fixes.length) return;
  var text = bubble.textContent || '';
  if (!text) return;

  var hits = [];
  fixes.forEach(function (f) {
    var idx = text.toLowerCase().indexOf(f.said.toLowerCase());
    if (idx < 0) return;
    hits.push({ start: idx, end: idx + f.said.length, fix: f });
  });
  if (!hits.length) return;
  // …去重叠后逐段拼接高亮
}

said 定位失败时降级为只显示修正条,不标气泡,界面不会因此报错。

6.4 每轮上限

修正条数按难度设上限,写入 prompt,同时决定前端的展开状态。

// How hard the coach may correct at each difficulty. `max` is a hard cap per
// turn: without one the learner starts writing shorter sentences to avoid being
// corrected, which kills the production rate this whole app optimises for.
var FIX_LIMITS = {
  easy: {
    max: 1,
    scope: 'Only errors that block understanding. Prefer silence over nitpicking.',
    expand: false
  },
  normal: {
    max: 2,
    scope: 'Grammar, word form and spelling that a reader would notice. Skip trivia.',
    expand: false
  },
  hard: {
    max: 4,
    scope: 'Anything a native speaker would notice. For grammar entries you may add a short "why" inside the right field, for example "went - past tense after yesterday".',
    expand: true
  }
};
档位 每轮上限 取舍范围 呈现
easy 1 条 只纠影响理解的 折叠
normal 2 条 读者会注意到的语法 / 词形 / 拼写 折叠
hard 4 条 母语者会注意到的都收,语法条目可附简短原因 默认展开

不设上限的后果是具体的。一句复杂长句会被贴四五条,学习者会开始只写短句来避开修正,而那会直接打掉产出量,也就是这套系统的核心指标。

修正记录写入 sessions.summary.fixes,JSONB 字段,不需要数据库迁移。

6.5 三条硬约束

  • 标记在任何情况下都不得出现在界面上,包括流式过程中间。
  • 畸形或缺失的标记一律当作「无修正」,绝不能报错。
  • 修正块不进对话记录,评估器看不到它。

七、后端的三个数据表与行级权限

后端用的是托管的 PostgreSQL(PostgREST 风格接口)。三张表都开了行级权限。

表 作用
progress 每个词的掌握状态、SM-2 参数、到期日,植入次数与产出次数分开计数
sessions 每次会话的话题、词包、难度、token 用量、完整对话文本、评估结果(含 summary.fixes)
encounters 逐词信号流水(0 卡壳 / 1 听懂 / 2 说出),可回溯当时怎么用的

关键约定是前端永远不发送 owner_id,它由数据库侧按登录身份自动填充,行级策略才能生效。

这里踩过一个很典型的坑。写入时 .select() 返回空数组,不代表写入成功,它可能是被行级策略挡掉了。所以每次写都要显式检查。

var up = await db().from('progress')
  .upsert(progressRows, { onConflict: 'owner_id,word' })
  .select('word');
if (up.error) throw new Error('Progress save failed: ' + up.error.message);
var affected = Array.isArray(up.data) ? up.data : [];
if (affected.length === 0) {
  throw new Error('Progress was not saved — the rows were not accepted. Check that you are signed in.');
}

八、平台的几条硬约束

这个项目跑在一个托管云平台上,有几条约束不看文档是想不到的,而且违反时的报错都很不直观。

约束 后果
Web 应用只支持邮箱登录,手机验证码与微信登录仅小程序可用 登录方式没得选
认证与模型调用只在已发布域名上生效,localhost 与预览被来源校验拒绝 发布不是可选项,是关键路径,本地改完测不了,必须先发布
模型接口只支持 stream: true,不存在非流式响应 想一次性拿结构化 JSON,也得流式收完再解析
每个请求 messages[0] 必须是应用自己的 system message 忘了就报错

第二条影响最大,它意味着不能在本地跑一个完整的开发循环。这个约束直接改变了验证策略,见第十节。

九、上线后的第一个真实 bug 是没有重试

第一版上线后,用户试了一次,数据库里留下这样一行 sessions。

turn_count = 0
total_tokens = 0
transcript = 空
ended_at = null

会话行建好了,但教练开场那句话的模型调用失败了。

排查顺序是先定位层次,再怀疑内容。

  1. 看 sessions 行,pack_n = 12、model = 'auto' 都正常 → 词包工厂和建行逻辑没问题
  2. 在线上域名直接调一次模型,返回 "ok",27 tokens → 模型服务是健康的
  3. 原样复刻应用的调用(长 system prompt + AbortSignal + stream_options),连试两次都成功 → 不是调用姿势的问题
  4. 结论是瞬时故障,而代码里没有任何重试,把错误直接抛给了用户。

这是实现缺陷,不是平台问题。修法是一个分类重试。

/* Transient failures are worth retrying; parameter/auth failures are not. */
function isTransientLlmError(err) {
  var code = String((err && err.error && err.error.code) || '');
  if (code === 'quota_rate_limited') return true;
  if (code === 'gateway_stream_interrupted') return true;
  if (code.indexOf('gateway_') === 0 ||
      code.indexOf('model_') === 0 ||
      code.indexOf('internal_') === 0) return true;
  var st = err && err.status;
  if (typeof st === 'number' && st >= 500) return true;
  if (!code && err && (err.name === 'TypeError' ||
      /network|fetch|timeout|aborted/i.test(String(err.message || '')))) return true;
  return false;
}

streamChat 外层最多重试 2 次(退避 700ms / 1400ms),期间气泡显示 Connection hiccup — retrying。

分类的意义在于界定不重试什么。参数错误、鉴权错误、来源校验失败不会因为重试而成功,只会增加等待时间。只有网关类、模型服务类、5xx、网络类才值得重试。

另外做了两处改进。错误信息里附带原始 error.code([...]),便于诊断;失败气泡加 Try again 按钮,原地重试不丢会话。

还有一个连带的坑。因为会话行是「开始时建、结束时更新」,失败的尝试会在数据库里留下 turn_count = 0 的空行。它们必须被仪表盘过滤掉,否则趋势图和连续天数都会受到干扰。

// A session row is written when a session starts, so rows with no completed
// turn are aborted attempts. They must not pollute the dashboard.
sessions = (res.data || []).filter(function (s) { return (Number(s.turn_count) || 0) > 0; });

十、用模拟 SDK 夹具验证主链路

第八节说过,认证后的功能在本地根本跑不起来,因为来源校验会拒绝。这意味着「开始会话 → 落库 → 算产出率」这条主链路,按常规办法一行都验不了。

我的解法是造一个夹具页,用模拟的 SDK 换掉真的云服务,具体四步。

  1. 写 _harness-mock.js,伪造 window.WorkBuddyCloud,返回 mock 的 auth / database / llm
    • mock 的 DB builder 做成 thenable(实现 then),这样每次 .from().insert().select() 调用都会被记录表名、操作、载荷,塞进 window.__DBLOG
    • mock 的 LLM 前两次故意抛瞬时错误,用来验证重试路径是否真的生效
    • 用一个特征(system prompt 里是否含 assessment engine)区分教练调用和评估调用,评估调用再正则提取词表、动态造 JSON 返回
  2. 用 Python 读 index.html,在 assets/app.js 之前注入 mock 脚本、去掉 CDN SDK 那一行,写成 _harness.html
  3. 起本地静态服务,用浏览器自动化打开夹具页,点 Start,等回复,点 End,再用 eval 读出 window.__DBLOG
  4. 验证完必须删掉夹具文件,因为部署会上传整个目录,夹具页会被公开,还会干扰入口文件识别

这套办法验出来的结果如下。

验证项 结果
重试路径 llmCalls = 3,2 次故意失败加第 3 次成功
落库链路 progress select → sessions insert → progress upsert → encounters insert → sessions update,顺序正确
SM-2 数值 q=2 得 ease 2.6;q=1 得 ease 2.45;interval 1;reps 1;due 为今天加 1 天
顺延规则 12 个目标词只有 8 个被植入,只 upsert 8 行,其余 4 词零记录

修正机制同样用夹具验证,用 30 字符的分块故意把标记切碎,专测前缀扣字符的逻辑。

场景 结果
正常块(2 条) 修正条 2 条、气泡 2 处标记、点击出正确形式、流式零泄漏
空数组块 0 修正条、0 标记、无残留
畸形块(JSON 截断) 当作「无修正」,界面干净、不报错
hard 档 修正条默认展开
会话落库 summary.fixes 4 条;transcript 不含标记
回归 SM-2 数值、植入与产出计数不受影响

这套办法的价值在于,依赖的外部服务无法在测试环境提供时,用行为等价的模拟实现把整条链路跑通,比放弃验证或编造数据更接近真实。mock 出的 __DBLOG 还能断言写入顺序和具体行数,这两点在真实环境里反而看不到。

十一、已知限制

  • 单次会话的自然植入上限约 8–12 词,这是对话本身的性质决定的,不是实现限制。一次 20 分钟自然对话的教练输出约 600–1000 词,要让目标词出现得不刻意,大约每 80–120 词承载 1 个词。想提速需要增加通道,比如阅读。
  • 按 1800 个薄弱词、每次 5 个新词计算,360 次会话,约 12 个月。这个数字在方案阶段算错过,当时以为两三个月能过完一遍。
  • 目标词标色只覆盖词形变化有限的形态,-ness 这类派生词不会被标出。这只影响点按查词,不影响记忆与排程。
  • 注册登录闭环、会话落库往返、模型实际回复质量这三项需要真实邮箱注册后人工完成,目前只有夹具验证。认证后的三个界面截图是注入样本数据的渲染验证,不是真实登录流程的记录。
posted @ 2026-09-27 12:03  郭幸坤  阅读(6)  评论(0)    收藏  举报
1