LinguaSpark 单词复习算法的缺陷发现与改进实践
在 LinguaSpark 智能外语学习平台上线运行后,我通过日志监控与数据分析,发现了单词背诵模块中 SM-2 间隔重复算法存在的严重缺陷——用户可以通过高频刷同一个单词,使间隔天数指数膨胀至数百天,导致遗忘曲线算法完全失效。针对这一问题,我深入排查了根因,设计了基于实际经过天数的改进方案,并重新规划了推送接口与主动复习接口的职责边界。改进后的算法从根本上杜绝了数据膨胀问题,同时使间隔计算更精准地反映用户真实的记忆周期。
一、核心业务场景
系统涉及两个关键接口:
| 接口 | 路径 | 功能 |
|---|---|---|
| 更新复习记录 | /api/words/user-words/update | 用户背诵单词后提交评估结果(记得住/模糊/没记住),系统计算下次复习时间 |
| 推送需复习单词 | /api/words/user-words/review | 根据遗忘曲线推送已到复习时间的单词,供用户回顾 |
此外,平台还提供了一个单词书自由复习功能——用户可自主选择任意单词进行背诵,不受推送机制约束。这一设计带来了额外的数据安全挑战。
二、初版实现与问题发现
2.1 初版算法设计
初版实现采用了经典的 SM-2 公式,以repetitions(连续背诵成功次数)作为间隔计算的核心驱动因子:
如果状态为"记得住": repetitions += 1 如果 repetitions == 1:interval = 1 天 如果 repetitions == 2:interval = 6 天 否则:interval = int(interval × ease_factor) # 指数增长 如果状态为"模糊": interval = max(1, int(interval × 0.8)) # 略微缩短 # repetitions 不变 如果状态为"没记住": repetitions = 0 # 清零连续正确次数 interval = 1 # 重置为1天这一设计的理论假设是:用户严格按系统推送的节奏复习,repetitions的递增能准确反映记忆巩固的进程。
2.2 上线后的数据异常
系统上线后,我通过日志监控发现了两个严重的数据异常:
异常一:间隔天数异常膨胀
某测试用户在 5 天内连续复习同一个单词,每次选择"记得住",间隔天数出现了非理性增长:
| 第 N 天 | 实际间隔 | repetitions | interval(计算值) | 问题分析 |
|---|---|---|---|---|
| 1 | — | 1 | 1 天 | 正常 |
| 2 | 1 天 | 2 | 6 天 | ⚠️ 实际才隔 1 天,却跳到 6 天 |
| 3 | 1 天 | 3 | 15 天 | ⚠️ 仍在每天刷,间隔却到 15 天 |
| 4 | 1 天 | 4 | 37 天 | ❌ 严重脱节 |
| 5 | 1 天 | 5 | 92 天 | ❌ 数据完全失真 |
5 天后,该单词的间隔被计算为92 天(超过 3 个月),而用户实际每天只隔了1 天。这种指数膨胀使遗忘曲线算法完全失去意义——用户的数据被"刷"高了。
异常二:迟到复习未获正向激励
与此相反,另一个场景中,某单词的预计复习日期已过 20 天,用户才回来复习且仍然记得住。但初版算法不考虑这个"20 天"的实际间隔:
实际经过:20 天 用户表现:记得住(说明记忆很强) 算法计算:repetitions=3 → interval=15 天 应得间隔:20 × 2.5 = 50 天 差距:15 天 vs 50 天,相差 3.3 倍用户用 20 天的实际表现证明了对这个词的掌握,但算法只给了 15 天间隔,这是一种负向反馈——好的记忆表现没有获得应有的奖励。
2.3 根因分析
经过深入排查,我确认了三个根本缺陷:
缺陷一:算法完全依赖repetitions计数,不包含时间维度信息。无论用户是隔了 1 天还是 30 天才来复习,只要repetitions++,间隔就按指数增长。
缺陷二:推送接口存在兜底逻辑,当到期单词不足时会推送未到期单词。这导致用户可能在单词尚未到期时就被推送,从而在不合理的短间隔内反复更新记录。
缺陷三:update接口缺少防刷保护,结合前端单词书的自由复习功能,用户可以反复刷同一个单词使数据膨胀。
三、算法改进方案设计
3.1 核心改进思路:引入实际经过天数
改进的核心思想是将计算驱动因子从"虚的"repetitions切换为"实的"actual_days(实际经过天数):
实际经过天数 (actual_days) = max(1, 今天 - 上次复习日期) 新间隔 = int(actual_days × ease_factor) 保护机制: 如果 new_interval ≤ 记录中已有的 interval_days, 则拒绝更新 interval 和 repetitions(防止刷数据)为什么使用max(1, ...)保证至少为 1?这是为了处理用户在同一天内多次复习的极端情况——即使同一天内,也按 1 天计算,避免出现除零等问题。
3.2 改进后的全量算法
actual_days = max(1, (today - last_review_date).days) 状态 = "记得住" (status == 1): ┌─────────────────────────────────────────────────────┐ │ if actual_days <= 1: new_interval = 1 │ │ elif actual_days == 2: new_interval = max(3, int(2×ef)) │ │ else: new_interval = int(actual_days × ease_factor) │ │ │ │ new_ease_factor = min(2.5, ease_factor + 0.1) │ │ │ │ ★ 保护判断: │ │ if new_interval <= record.interval_days: │ │ interval = 不变 (保护) │ │ repetitions = 不变 (保护) │ │ ease_factor = new_ease_factor (可以微增) │ │ else: │ │ interval = new_interval (更新) │ │ repetitions += 1 (正常递增) │ │ ease_factor = new_ease_factor │ └─────────────────────────────────────────────────────┘ 状态 = "模糊" (status == 2): repetitions = 0 interval = 1 ease_factor = max(1.3, ease_factor - 0.15) 状态 = "没记住" (status == 3): repetitions = 0 interval = 1 ease_factor = max(1.3, ease_factor - 0.2)3.3 推送接口的职责分离
推送接口/user-words/review也进行了两次迭代优化:
第一轮:去掉了other_words兜底逻辑。原本当到期+即将到期单词不足时,会用 7 天外的未到期单词补齐,这破坏了记忆曲线的节奏。
第二轮(最终版):去掉了upcoming_words(7 天内即将到期单词),只推送真正已到期的单词(next_review_date <= today)。
设计理念如下:
┌─────────────────────────────────────────────────────────┐ │ 用户背单词的两条路径 │ ├──────────────────────┬──────────────────────────────────┤ │ 推送复习(被动提醒) │ 主动复习(单词书自由选择) │ │ /user-words/review │ /user-words/update │ ├──────────────────────┼──────────────────────────────────┤ │ 只推送已到期单词 │ 接受任意单词的复习请求 │ │ 保证用户按科学节奏复习 │ 保护机制防止数据被刷 │ │ next_review_date≤today │ new_interval≤记录interval→不更新 │ ├──────────────────────┴──────────────────────────────────┤ │ 两条路径互补:推送控制节奏,主动复习允许自由但数据安全 │ └─────────────────────────────────────────────────────────┘推送是被动提醒——系统告诉用户"该复习了"。如果推送未到期单词,就会打破遗忘曲线的科学节奏。而主动复习是用户自主行为——想多学几个是合理的,但通过保护机制确保数据不被刷坏。
四、改进效果验证
4.1 场景一:用户每天刷同一个单词(刷数据行为)
天数 实际经过 new_interval 记录中interval 是否更新 repetitions ──────────────────────────────────────────────────────────────────── 1 1天 1 1(初始) ✓ 更新 1 2 1天 1×2.5=2.5→2 1 ✗ 保护 1 3 1天 1×2.6=2.6→2 1 ✗ 保护 1 4 1天 1×2.6=2.6→2 1 ✗ 保护 1 5 1天 1×2.6=2.6→2 1 ✗ 保护 1 6 1天 1×2.6=2.6→2 1 ✗ 保护 1 7 1天 1×2.6=2.6→2 1 ✗ 保护 1结论:保护机制生效。从第 2 天开始,new_interval始终为 2,小于等于记录中的 1,触发保护。interval 和 repetitions 保持稳定,不会被刷高。
4.2 场景二:用户按正常间隔复习(正常使用)
天数 实际经过 状态 new_interval 记录中interval 是否更新 repetitions ──────────────────────────────────────────────────────────────────────────── 1 - 创建 1 1 - 1 2 1天 记得住 1×2.5=2→2 1 ✗ 保护 1 8 6天 记得住 6×2.5=15 1 ✓ 更新 2 23 15天 记得住 15×2.6=39 15 ✓ 更新 3结论:当用户真正等了足够天数(第 8 天复习时实际经过 6 天),new_interval=15大于记录中的1,触发正常更新。间隔天数从 1→15→39,合理反映记忆巩固过程。
4.3 场景三:到期后很久才复习,但还记得住
条件:上次 interval=6 天,next_review_date=第10天 用户第30天才复习(逾期20天),选择"记得住" 实际经过 = 30 - 2 = 28 天 旧算法:repetitions=3 → interval = int(6×2.5) = 15 天 新算法:28 × 2.5 = 70 天 差距:70 天 vs 15 天(4.7 倍)结论:新算法正确奖励了用户的强记忆。间隔 28 天仍记住 → 说明掌握非常好 → 给予 70 天的长间隔,减少不必要的复习负担。
4.4 场景四:推送接口行为
假设用户有 20 条记录(难度=cet4,每日目标=10): 状态 数量 next_review_date 推送? ───────────────────────────────────────────────── 已到期(逾期3天) 3条 2026-06-02 ✓ 已到期(今天到期) 2条 2026-06-05 ✓ 即将到期(明天) 5条 2026-06-06 ✗ 即将到期(3天后) 4条 2026-06-08 ✗ 未到期(8天后) 6条 2026-06-13 ✗ 推送结果:只推送 5 条已到期单词,不补齐到 10 条结论:推送接口严格只推送已到期单词,不会为了让数量凑够而推送未到期内容。前端可引导用户"当前没有需要复习的单词,可以主动去单词书学习"。
五、改进前后对比总结
| 对比维度 | 改进前 | 改进后 |
|---|---|---|
| 间隔计算驱动因子 | repetitions(纯计数,无时间维度) | actual_days(实际经过天数,精确到日) |
| 刷数据行为 | 可被利用(间隔指数膨胀:1→6→15→37→92…) | 被阻止(保护机制拒绝更新) |
| 逾期后仍记住 | 间隔不足(仅 15 天,未奖励强记忆) | 间隔充足(70 天,准确反映记忆水平) |
| 模糊/没记住处理 | 间隔略微减少 | 直接清零,间隔回到 1 天 |
| 推送已到期单词 | ✓ | ✓ |
| 推送即将到期单词 | — | ✗(已移除) |
| 推送未到期单词 | 会(other_words 兜底) | ✗(已移除) |
| 主动复习保护 | 无(数据可被刷) | 有(保护机制兜底) |
| 整体数据真实性 | 低(与实际间隔脱节) | 高(直接反映实际间隔) |
六、工程实现要点
6.1 技术栈
- 后端框架:FastAPI(Python 3.10+)
- ORM:SQLAlchemy
- 数据库:MySQL(AWS RDS)
- 缓存:Redis(打卡状态、每日计数、分布式锁)
- 容器化:Docker + Docker Compose
6.2 核心代码实现
@router.post("/user-words/update")asyncdefupdate_user_word_review(data:WordUserUpdate,db:Session):"""基于实际天数的 SM-2 改进算法"""record=db.query(WordUser).filter(...).first()ifnotrecord:raiseHTTPException(status_code=404)today=datetime.now().date()# 1. 计算实际经过天数(核心改进点)ifrecord.last_review_date:actual_days=max(1,(today-record.last_review_date).days)else:actual_days=1ease_factor=record.ease_factor/100.0interval=record.interval_days repetitions=record.repetitionsifdata.status==1:# 记得住# 2. 基于实际天数计算新间隔ifactual_days<=1:new_interval=1elifactual_days==2:new_interval=max(3,int(actual_days*ease_factor))else:new_interval=int(actual_days*ease_factor)new_ease_factor=min(2.5,ease_factor+0.1)# 3. 保护机制:间隔不足则不更新ifnew_interval<=record.interval_days:interval=record.interval_days# 保持不变# repetitions 保持不变ease_factor=new_ease_factorelse:interval=new_interval repetitions+=1ease_factor=new_ease_factorelifdata.status==2:# 模糊repetitions=0interval=1ease_factor=max(1.3,ease_factor-0.15)else:# 没记住repetitions=0interval=1ease_factor=max(1.3,ease_factor-0.2)# 4. 持久化更新record.ease_factor=int(ease_factor*100)record.interval_days=interval record.repetitions=repetitions record.next_review_date=today+timedelta(days=interval)record.last_review_date=today record.last_status=data.status db.commit()db.refresh(record)return{"message":"复习记录更新成功","ease_factor":ease_factor,"interval_days":interval,"repetitions":repetitions,"next_review_date":record.next_review_date.isoformat(),"actual_days":actual_days}七、总结
7.1 项目成果
- 完成了 SM-2 算法的工程化实现与改进,解决了初版中"间隔与实际脱节"、"数据可被刷高"两个核心缺陷
- 引入了
actual_days作为间隔计算驱动因子,使算法具备真实的时间维度感知能力 - 设计了保护机制,确保推送复习和主动复习两条路径数据安全
- 编写了完整的技术文档,涵盖算法原理、缺陷分析、改进方案和效果验证
7.2 技术亮点
- 从
repetitions到actual_days的驱动因子迁移:这是整个改进的核心——让算法从"计数驱动"变为"时间驱动",从根本上解决了数据失真问题 - 保护机制的巧妙设计:仅用一个不等式
new_interval <= record.interval_days即可实现防刷保护,无需额外状态标记,简洁且高效 - 推送与主动复习的职责分离:通过不同的接口行为约束,既保证了科学复习节奏,又保留了用户的学习自由度
- 全面的测试验证:通过多个场景的模拟验证,确保改进后的算法在各种边界条件下都能正确运行
以下为提示词
需求:修改
/user-words/update接口的复习逻辑一、核心问题
当前接口存在以下问题:
- 即使所有单词都未到期,调用接口仍会推送并更新未到期的单词
- 导致:
- 初始单词被反复推送,间隔天数过度增长
- 无法利用用户逾期后仍记得清晰的真实表现
二、改进方案
1. 改用“实际天数”计算下次间隔
公式:
实际天数 = 原间隔天数 + (当前日期 − 过期日期)
处理逻辑:
比较结果 操作 实际天数 ≤ 原间隔天数 不更新间隔、不更新连续成功次数 实际天数 > 原间隔天数 按实际天数更新间隔 2. 用户选择“模糊 / 忘记”时
- 连续成功次数 → 清零
- 下次间隔 → 重置为 1 天
3. 最终更新
- 根据最终确定的间隔天数,重新计算并更新过期时间
三、为什么这样设计
- 防止用户通过单词书反复背诵同一单词来刷数据
- 真实反映用户记忆水平
- 不推送未到期单词,避免间隔失控
四、效果对比示例
场景 原逻辑 新逻辑 单词应 10 天后复习,用户 20 天后才复习且记得 仍按原间隔增长 使用实际间隔 20 天 反复调用接口背同一单词 间隔不断增长 实际间隔未超过记录时不更新 逾期后仍记得清晰 仍按原计划增长 可延长间隔,减少冗余复习 五、输出要求
- 接口修改说明(字段、计算逻辑)
- 改进前后数据对比示例
- 能避免的异常行为说明(如刷数据、间隔失控)