掌握度是怎么算出来的:`mastery.py` 与 `policy.py` 的闸门

2026-08-10

一个学习系统里最值钱的两行代码,通常不是提示词,而是「怎么算掌握度」和「多少算通过」。DeepTutor 把这两件事分在了两个文件里:deeptutor/learning/mastery.py 只有 40 行,负责把作答历史压成一个 0 到 1 的分数;deeptutor/learning/policy.py 295 行,负责拿这个分数去撞闸门,并决定下一步该做什么。

先说清边界:下面出现的公式、权重、阈值,都是这个仓库里的实现选择,不是经过验证的教学结论。我们只讲它写在哪一行、怎么运作,不评价它在学习科学上是否有效,也不承诺任何学习效果。

所有行号对应我们采集的快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你 clone 之后行号可能已经漂了,但常量名是稳的。

一、40 行里的三件事

mastery.py 干的事可以拆成三条,每条都对应一个常量:

其一,近因加权。 _RECENCY_WEIGHTS = (0.5, 0.7, 0.85, 0.95, 1.0),顺序是最旧到最新(deeptutor/learning/mastery.py:17)。越近的作答权重越大。

其二,窗口只有 5。 compute_mastery 只取最近 5 次作答,加权求和再除以权重和(mastery.py:24-37)。比最近 5 次更早的作答不参与计算——它不是”累计正确率”,是”最近五次的加权正确率”。

其三,低置信封顶。 _CONFIDENCE_CAP = {1: 0.5, 2: 0.8}mastery.py:21):只有 1 次作答时,掌握度不超过 0.5;只有 2 次时不超过 0.8。算完加权分之后,与这个封顶取 min。

这三条单独看都很好理解。问题出在它们和另一个文件里的那个数字撞上的时候。

二、那个数字是 0.9

policy.py:32-38 定义了定量闸门 QUANTITATIVE_GATE。它是一张「知识点类型 → 阈值」的表,表里只有两项:MEMORY 对 0.9,PROCEDURE 对 0.9。也就是说,只有这两类知识点是用分数卡的。

源码注释在这一段里写了这个取值的出处,原文的意思是「~0.9 对应 Alpha School 的 ‘90% before you advance’」。这句是代码注释里自述的参照,我们照实转述,不做延伸评价。

同一个文件里的 gate_threshold() 除了取 QUANTITATIVE_GATE 里的值之外,还带了一个 0.9 的字典默认值(policy.py:53-58)。这个默认值在什么条件下会被真正走到,取决于那个函数的分支顺序,请自己回源码看,我们不替它下结论。旁边的 objective_status() 只返回三种状态:mastered / learning / newpolicy.py:80-87)。

三、反直觉的那一处:窗口里不能有错

把第一节的权重表和 0.9 拼起来算一遍,会得到一个从任何一侧单独看都看不出来的结论。

先把窗口填满的情形算清楚。5 次作答时,参与计算的就是完整的那一串权重,权重和是 0.5 + 0.7 + 0.85 + 0.95 + 1.0 = 4.0;分子是答对那几次的权重之和。再把只答过一两次的情形按置信封顶单独列出来:

情形依据得分能否 ≥ 0.9
只答过 1 次置信封顶 {1: 0.5}≤ 0.5
只答过 2 次置信封顶 {2: 0.8}≤ 0.8
最近 5 次全对4.0 / 4.01.0可以
最近 5 次里最旧那次答错(4.0 − 0.5) / 4.00.875
最近 5 次里最新那次答错(4.0 − 1.0) / 4.00.75

以上是按 mastery.py:17,21,24-37 三处的常量与公式手算的结果,我们没有运行过这个项目,也没有跑过它的测试,算式和结论请你自己回源码复核一遍。

两个可以直接用的推论:

第一,至少要 3 次作答。 只答 1 次,哪怕答对,封顶 0.5;答 2 次全对,封顶 0.8。两者都撞不到 0.9。所以对 MEMORY / PROCEDURE 类型来说,从零开始至少要有 3 次作答才谈得上过闸——这是置信封顶那两个常量本身决定的必要条件,至于够不够,还要看这几次答得对不对。

第二,窗口里只要有一次错,就过不了。 最宽松的情形是五次作答里最旧那次答错——权重 0.5,剩下 3.5,除以 4.0 是 0.875,仍然低于 0.9。这意味着那次错答必须先被后续作答挤出这个 5 次窗口,掌握度才可能回到 1.0。

反直觉就在这里:近因加权这套设计本身,给人的印象是「可以部分得分、早期错了后面还能补回来」;但一旦对上 0.9 这道闸,加权带来的那点缓冲全被吃掉了,窗口填满时实际生效的规则就是「这 5 次必须全对」,再叠上「至少要有 3 次作答」。权重表真正起作用的地方,不是让你能带着错误过闸,而是决定一次错答要多久才被冲出窗口。

这两个文件各自都很好读,坑在于它们要合起来看才成立。改任何一边——把闸门调低、或者把权重表拉长——另一边的实际语义都会跟着变。至于该调成多少,取决于你的用法,项目没有给通用值。

四、另一条轨:CONCEPT 与 DESIGN 根本不比分数

learning/models.py:9-33KnowledgeType 是四类:memory / concept / procedure / design。上面那套算分只管其中两类。

QUALITATIVE_TYPES = frozenset({CONCEPT, DESIGN})policy.py:40-45),这两类由导师用 mastery_assess 工具判定费曼式讲解,不做字符串评分models.py:195-198 的注释把分工写得很直白:qualitative_mastery 管 CONCEPT / DESIGN,mastery_levels 的定量闸门管 MEMORY / PROCEDURE。也就是说 LearningProgress 上同时挂着一个 dict[str, float] 和一个 dict[str, bool],各管一半。

这里有个容易看错的细节:定性那一侧也会往数字字段里写值,但那是给展示用的。_QUALITATIVE_PASS_DISPLAY = 1.0policy.py:50);record_qualitative() 的处理是通过则 mastery_levelsmax(current, 1.0),不通过则取 min(current, 0.4)deeptutor/learning/service.py:239-241)。注意那个 0.4——它不是任何闸门的阈值,只是不通过时把展示值往下压的一个上限。所以你在数据里看到某个知识点是 0.4,不代表它”答对了四成”。

顺带一提,KnowledgeType 正好四类,两类在 QUANTITATIVE_GATE 里、两类在 QUALITATIVE_TYPES 里,这是按上面三处常量对出来的;gate_threshold() 那个 0.9 兜底具体在什么情况下会被走到,请自己回 policy.py:53-58 看分支顺序,我们不替它下结论。

五、下一步是算出来的,不是数出来的

policy.py:10-16 的模块 docstring 里有一句设计说明:推进是从已掌握的东西计算出来的,而不是靠阶段计数器跟踪。这句话在 next_objective() 里落成了一个四级优先级(policy.py:163-239):

  1. 有待批改的挂起问题 → 先把它答完
  2. 有到期的间隔复习 → 先复习
  3. 按模块 order 与知识点顺序,找第一个未掌握的目标
  4. 全部掌握 → complete

返回的 NextStep.action 只有六个取值:answer_pending / review / probe / practice / assess / completepolicy.py:106-111)。其中未接触过的目标先给 probe(“先测出去”),定性目标给 assess,其余给 practicepolicy.py:215-220)。

配套的提示词也是同一口径:mastery 能力的系统提示词原文要求,目标背后是一道 “HARD mastery gate”,未清关不得前进;test-out 不是静默跳过,必须过闸门并留下记录(deeptutor/capabilities/mastery/prompts/en/system.md:2,8)。这个提示词只有 en / zh 两份,各 14 行。

到期复习怎么排、答对答错之后 interval_index 怎么走,是 scheduler.py 的事;答案怎么判对错、错了归到哪一类,是 grading.py 的事——这两块我们另有篇目专门讲,这里不展开。

六、看数字时的两个口径陷阱

map_summary() 输出 counts(mastered / learning / new / total)、due_reviews 数、complete 布尔与模块明细,掌握度四舍五入到 3 位小数(policy.py:242-280)。拿它上面的数字去判断”是不是过闸了”时,记住你看到的是舍入后的展示值。

另一个更容易误读的是 list_progress() 里的 avg_mastery_pct。源码注释明确写了:它是当前知识点掌握度的平均值不是已掌握知识点的百分比deeptutor/learning/service.py:278-281)。一批知识点全卡在 0.875,平均值会很好看,但过闸数是零。

七、一处文档与代码对不上的地方

deeptutor/learning/__init__.py:3-10 的模块清单只列了 models / storage / scheduler / mastery / grading / service / prompts 七个,没有列 policy.py(295 行)与 pending.py(166 行)——而本文讲的闸门、next_objective()map_summary() 全在 policy.py 里,它也被 mastery 工具直接依赖。两处不一致,以我们实读的仓库状态为准。说完就停,不推断原因,也不据此评价这个项目。

八、你可以照着核的五步

  1. 打开 deeptutor/learning/mastery.py,确认三个东西:_RECENCY_WEIGHTS(17 行)、_CONFIDENCE_CAP(21 行)、以及 compute_mastery 里”取最近 5 次 + 与封顶取 min”这两步(24-37 行)。
  2. 打开 deeptutor/learning/policy.py:32-38,确认 QUANTITATIVE_GATE 里只有 MEMORY 与 PROCEDURE 两项,值都是 0.9。
  3. 拿这两处的数字自己把第三节那张表重算一遍,特别是 5 次窗口错一次的 3.5 / 4.0
  4. 打开 policy.py:163-239,对着四级优先级读一遍,确认它每一步读的都是「是否已掌握」,没有阶段计数器。
  5. 想看真实状态存在哪:LearningStore 的默认根目录是 <workspace_dir>/learning,每个 book 一个 <book_id>.jsondeeptutor/learning/storage.py:18,21-24),保存时刷新 updated_at 并把 version +1,落盘走 atomic_write_textstorage.py:13,26-31)。另外 replace_modules() 会清掉不在新知识点集合里的 mastery_levelsrepetition_stateserror_records 等字段,并把 stage_failure_counts 整体清空(deeptutor/learning/service.py:41-69)——重建模块结构不是纯增量操作。

需要交代的边界:deeptutor/learning/tests/ 下有 233 个测试用例,我们只统计了数量,没有读断言内容,所以上面的公式与常量都是从实现读出来的,没有用测试交叉验证;deeptutor/capabilities/mastery/tools.py 共 721 行,我们只核对了工具名与题型白名单,五个工具各自的返回结构没有逐行读;REST API 层怎么调这些模型,我们没有读。另外,这个项目的知识库类型里有一种 subagent 型,它指向的是一个连接的 agent,磁盘上没有路径、也没有可索引可检索的内容(deeptutor/knowledge/kb_types.py:63),与本文讲的纯计算部分不是一回事,我们另有篇目讲,这里不展开。

最后重复开头那句:本文列出的所有阈值、权重与判定规则,都是源码里的默认配置与实现选择,不是对学习效果的任何保证。


本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.mdpyproject.tomldeeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。 本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API, 因此不涉及生成质量、响应速度与教学效果的任何描述。 参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。