间隔重复调度器:复习时间是按什么排的
看一个学习类项目的调度逻辑,最怕的是它把复习时间藏在一堆参数拟合里,你读半天不知道下次复习到底是怎么算出来的。DeepTutor 这块反过来:它薄到几乎没有算法——deeptutor/learning/scheduler.py 全文 101 行,复习时间就是一个整数下标在固定数组里前后走。
以下所有行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你 clone 之后行号可能已经漂了,但文件名、常量名和字段名是稳的。
先说清一件事,后面不再重复:下面出现的所有间隔天数、阈值、上限,都是这个仓库在这一版里的实现选择,不是经过验证的教学结论。 本文只讲这些参数写在哪一行、怎么运作,不评价它们在学习科学上是否有效,也不承诺任何学习效果。
一、这个文件在 learning 目录里的位置
deeptutor/learning/ 下非测试的 .py 文件有 10 个,另有 11 个 test_*.py。按行数排:service.py 295、policy.py 295、models.py 230、pending.py 166、scheduler.py 101、prompts.py 101、grading.py 64、storage.py 55、__init__.py 41、mastery.py 40。
也就是说,“什么时候再复习”(scheduler)和”下一步该干什么”(policy)、“这次答得对不对”(grading)、“掌握到什么程度”(mastery)是四个各自独立的小文件,谁也不吃谁的中间结果。这个切分方式直接决定了本文的边界:scheduler 只吃一个布尔值。
顺带一个可核的对不上:deeptutor/learning/__init__.py:3-10 的模块清单只列了 models / storage / scheduler / mastery / grading / service / prompts 七个,而目录里实际还有 policy.py(295 行)与 pending.py(166 行),两者都存在且被 mastery 工具直接依赖。以我们实读的目录为准,说完就停。
二、被调度的状态只有四个字段
RepetitionState 定义在 deeptutor/learning/models.py:144-150,字段就四个:
interval_index:当前走到间隔序列的第几档consecutive_correct:连对计数consecutive_wrong:连错计数next_review_at:下次到期的时间戳
注意这里没有掌握度。掌握度是另一套状态,存在 LearningProgress 的 mastery_levels(models.py:185-213),和 repetition_states 是并列的两个字典。这意味着一件容易被想当然的事情不成立:掌握度 0.9 的知识点和 0.4 的知识点,只要这次答对,间隔的推进方式一模一样。 调度器不读掌握度,掌握度也不改调度器的下标。
三、四条间隔序列:数组本身就是全部策略
scheduler.py:13-18,按知识点类型分了四条序列(单位:天):
| 知识点类型 | 间隔序列(天) | 档数 | 最长间隔 |
|---|---|---|---|
| MEMORY | [0, 1, 3, 7, 14, 30, 60] | 7 | 60 |
| CONCEPT | [3, 7, 14, 30] | 4 | 30 |
| PROCEDURE | [3, 7, 14] | 3 | 14 |
| DESIGN | [14, 28] | 2 | 28 |
知识点分成 MEMORY / CONCEPT / PROCEDURE / DESIGN 这四类,定义在 models.py:9-33(枚举还带着一层旧中文值的映射)。
这张表最值得留意的是两端。MEMORY 的第 0 档是 0,意思是”零天”,即这一档下算出来的下次到期时间就是当下;而 DESIGN 只有两档,第 0 档就已经是 14 天。同样一次答错、同样退到下限,MEMORY 会退成”马上再来”,DESIGN 退到底仍然是两周之后。序列长度不同带来的不是”节奏快慢”这种模糊差别,而是回退地板不同。
四、推进规则:答对答错是不对称的
核心分支在 scheduler.py:51-64,逐条抄下来:
答对时(51-58):
consecutive_wrong清零,consecutive_correct加 1- 如果
consecutive_correct达到 2,则interval_index += 2,并把连对计数清零 - 否则
interval_index += 1
答错时(59-64):
consecutive_wrong加 1,连对计数清零interval_index减 1,下限 0- 如果
consecutive_wrong达到 2,则把连错计数清零
最后 interval_index 被夹在 [0, len(intervals)-1](scheduler.py:66),再按新下标取天数算 next_review_at。
反直觉的那一处就在这两组分支的不对称上。 答对有”跳级”:连对第二次一步跨两档;答错没有对应的”跳降”,无论连错几次,每次都只退一档,而且连错到 2 就把计数清零、重新从零累计。也就是说,在这个文件里,连错这个计数从头到尾没有参与过 interval_index 的计算——它唯一的归宿是自己被清零。连对则相反,它是跳级的触发条件。(这个计数在本文件之外还被谁读过,我们没有核实,不做推断。)
把跳级规则代进数组算一遍,能看出更具体的后果:
- 一个 MEMORY 知识点从第 0 档起,连续答对四次的下标轨迹是 0 → 1 → 3 → 4 → 6,第四次就已经顶到序列末档(60 天)。中间被跳过的第 2 档(3 天)和第 5 档(30 天)根本不会被取到。
- 一个 DESIGN 知识点只有两档,下标被夹在
[0, len(intervals)-1],DESIGN 的上限就是 1。从第 0 档连对两次,interval_index先加 1 再加 2 得到 3,被夹回 1。跳级规则在这条序列上几乎没有生效空间。
这不是 bug,只是”固定数组 + 定量步长”这种设计的必然结果:序列越短,跳级越没有意义;序列越长,跳级越容易让中间几档被整档跳过。你如果要改这套参数,改数组和改步长是两件不同的事,得一起看。
至于该改成什么值——项目没有给出通用建议,这取决于你的用法,本文也不给调参建议。
五、什么时候被推进:只在交答案那一刻
调度器自己不带定时器。interval_index 的推进被固定串在一条流水线里:deeptutor/learning/service.py:174-210 的 grade_and_record() 顺序是——记录作答 → 重算掌握度 → 推进间隔重复状态 → 重建复习队列 → 持久化。
这条顺序有两个可核的含义:
其一,没有作答就没有推进。放着不管,next_review_at 停在原地,队列里的任务只会越来越”过期”,不会自己往后顺延。
其二,复习队列是每次判分后整体重建的,不是增量修补。复习队列由 scheduler.py:80-87 那段重建,逐个知识点重造任务。
落盘位置也是可核的:LearningStore 的默认根目录是 <workspace_dir>/learning,每个 book 一个 <book_id>.json(deeptutor/learning/storage.py:18,21-24),workspace_dir 解析为 <user_data_dir>/workspace(deeptutor/services/path_service.py:203-204)。保存时会刷新 updated_at 并把 version 加 1,落盘走 atomic_write_text,全程持模块级锁 _cas_lock(storage.py:13,26-31)。这是你本机上的一份数据文件,包含你的作答与进度状态,怎么备份、怎么处置请自己评估。
顺带说一句 book_id 的防护:含 /、\、..、: 会直接抛 ValueError(storage.py:21-23)。
六、到期之后谁先出:排序键只有 priority
get_due_tasks()(scheduler.py:72-76)筛出已到期的任务,按 priority 升序,默认一次最多返回 5 个。
priority 从哪来?两条规则(scheduler.py:80-87):
- 默认按知识点类型取
_TYPE_PRIORITY(scheduler.py:20-25):MEMORY 2、CONCEPT 3、PROCEDURE 4、DESIGN 5,数字小的先复习。 - 如果这个知识点上挂着状态为
active或retrying的错题记录,priority 被强制为 1,高于所有类型优先级。
这里有第二处容易想当然的地方:排序键只有 priority,没有把逾期时长算进去。逾期两周和刚到期一分钟,在同一个 priority 档里不因逾期时长而分先后;同档之间的先后取决于队列自身的生成顺序,这一点我们没有进一步核实,不下结论。
错题那条强制规则还有个联动:ErrorRecord.status 的取值受限于 active / retrying / review / graduated,默认 active(models.py:140);而答对时,同题同知识点、状态为 active / retrying 的错题记录会被改成 graduated(service.py:129-145)。改成 graduated 之后它就不再满足 priority 强制条件,这个知识点的优先级会落回它的类型档。
再往上一层,policy.py 的 next_objective() 把”有到期的间隔复习”排在优先级第二位,仅次于”有待批改的挂起问题”,排在”按模块顺序找下一个未掌握目标”之前(deeptutor/learning/policy.py:163-239)。所以到期复习是会插队到新内容前面的。闸门与掌握度那一侧的规则不在本文范围,我们另有一篇专门讲。
七、那个把一天变成一秒的开关
scheduler.py:31-34:环境变量 LEARNING_DEBUG 取 1 / true / yes 时,间隔的时间单位从 86400 秒变成 1 秒。我们只核到它改时间单位这一件事,别的影响没有核实。
含义很直接:这个开关一旦在某个环境里被打开,上面那张表里的 60 天就是 60 秒。它是进程级环境变量,所以在容器编排、CI、共享开发机这类”环境变量会被继承”的场合,值得在排查”为什么复习一直在到期”时先看一眼它。
八、你可以照着核的六步
- 打开
deeptutor/learning/scheduler.py:13-18,把四条序列抄下来,注意 MEMORY 首档是 0、DESIGN 只有两档。 - 打开
scheduler.py:51-64,对照答对与答错两个分支,确认”连对到 2 加 2 档、答错只减 1 档”这处不对称,以及consecutive_wrong在本文件里除清零外没有别的去处。 - 打开
scheduler.py:66,确认下标被夹在[0, len(intervals)-1],再回头把 DESIGN 的跳级算一遍。 - 打开
scheduler.py:72-76与80-87,确认排序键只有priority、默认上限 5 个,以及错题强制 priority 为 1。 - 打开
deeptutor/learning/service.py:174-210,确认调度推进被夹在判分流水线的第三步,前面是掌握度重算、后面是队列重建。 - 打开
deeptutor/learning/models.py:144-150,确认RepetitionState只有四个字段,掌握度不在其中。
如果你已经在用这个项目,还有第七步:直接打开 <workspace_dir>/learning/<book_id>.json,找到 repetition_states,把某个知识点的 interval_index 和 next_review_at 与上面的序列对一遍,就能确认它当前停在哪一档。
九、本文没有覆盖的部分
deeptutor/learning/tests/ 下有 233 个测试用例(按 def test_ 计数),我们只统计了数量,没有阅读断言内容——所以本文里的规则与常量全部是从实现代码读出来的,没有用测试做交叉验证。
另外,RepetitionState 初始值是怎么写进去的、知识点类型是在哪一步被判定为四类中的哪一类、review_queue 序列化到 JSON 之后的具体形状,都超出了本次核对范围,不做等价、也不替它补来源。
最后再收一次前面那条边界:以上全部是源码中的默认配置与固定常量,不构成对任何实际使用结果的保证,也不构成关于复习节奏该如何安排的建议。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。