掌握度是怎么算出来的:`mastery.py` 与 `policy.py` 的闸门
一个学习系统里最值钱的两行代码,通常不是提示词,而是「怎么算掌握度」和「多少算通过」。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 / new(policy.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.0 | 1.0 | 可以 |
| 最近 5 次里最旧那次答错 | (4.0 − 0.5) / 4.0 | 0.875 | 否 |
| 最近 5 次里最新那次答错 | (4.0 − 1.0) / 4.0 | 0.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-33 里 KnowledgeType 是四类: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.0(policy.py:50);record_qualitative() 的处理是通过则 mastery_levels 取 max(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):
- 有待批改的挂起问题 → 先把它答完
- 有到期的间隔复习 → 先复习
- 按模块 order 与知识点顺序,找第一个未掌握的目标
- 全部掌握 →
complete
返回的 NextStep.action 只有六个取值:answer_pending / review / probe / practice / assess / complete(policy.py:106-111)。其中未接触过的目标先给 probe(“先测出去”),定性目标给 assess,其余给 practice(policy.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 工具直接依赖。两处不一致,以我们实读的仓库状态为准。说完就停,不推断原因,也不据此评价这个项目。
八、你可以照着核的五步
- 打开
deeptutor/learning/mastery.py,确认三个东西:_RECENCY_WEIGHTS(17 行)、_CONFIDENCE_CAP(21 行)、以及compute_mastery里”取最近 5 次 + 与封顶取 min”这两步(24-37 行)。 - 打开
deeptutor/learning/policy.py:32-38,确认QUANTITATIVE_GATE里只有 MEMORY 与 PROCEDURE 两项,值都是 0.9。 - 拿这两处的数字自己把第三节那张表重算一遍,特别是 5 次窗口错一次的
3.5 / 4.0。 - 打开
policy.py:163-239,对着四级优先级读一遍,确认它每一步读的都是「是否已掌握」,没有阶段计数器。 - 想看真实状态存在哪:
LearningStore的默认根目录是<workspace_dir>/learning,每个 book 一个<book_id>.json(deeptutor/learning/storage.py:18,21-24),保存时刷新updated_at并把version+1,落盘走atomic_write_text(storage.py:13,26-31)。另外replace_modules()会清掉不在新知识点集合里的mastery_levels、repetition_states、error_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.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。