判分与错误分类:答错之后系统认为你错在哪
一个带练习的学习系统里,最容易被想象成”很智能”的环节其实是判分。多数人默认它是模型在读你的答案、揣摩你的意思。DeepTutor 的做法不是这样:判分这件事被单独抽出来放在 deeptutor/learning/grading.py,整个文件 64 行,两个函数,全是确定性字符串比较,一次模型调用都不发。
下文所有行号与数值都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 后行号可能已经漂了,函数名与常量名是稳的。
先把一条边界写在前面:这些判对阈值、状态取值、流水线顺序,都是该项目在代码里做出的实现选择,不是经过验证的教学结论。本文只讲它们写在哪一行、怎么运作,不评价它们在学习科学上是否有效,也不承诺任何学习效果。
一、期望答案存在服务端,判分不经模型往返
要读懂判分,得先知道被比对的那个”标准答案”从哪来。
deeptutor/learning/models.py:164-182 定义了 PendingQuestion,也就是导师刚抛出、正等着你作答的那道题。这个类的注释写明了一件关键的事:期望答案存在服务端,从不经模型往返。与之配套的是 mastery 能力自带的 5 个工具,其中含 mastery_quiz 与 mastery_grade(deeptutor/capabilities/mastery/tools.py:61-67);期望答案由服务端持久化在 PendingQuestion 里(models.py:164-182),至于这两个工具之间的具体调用顺序,我们没有逐行核实。
这决定了判分的性质:模型负责出题和讲解,但”对不对”这一步不归它管。相应地,模型也就没法在评分环节改口——它拿不到重新解释标准答案的机会。
配套的还有选项这一侧。deeptutor/learning/pending.py:20,23-37 有一条解析选项标签的正则 ^\s*([A-Z])\s*[.::、))-]\s*(.+)$(忽略大小写),负责从选项文本里剥出 A、B、C 这类标签;超过 26 个选项时改用序号。另外 deeptutor/capabilities/mastery/loop.py:16-43 记录了一个动作:mastery loop 在 ask_user 调用时,会用服务端持久化的挂起问题替换模型自拟的展示数据,防止模型改题号或重排 A/B/C。选择题的判分要靠标签对齐,这一步在链路上是前置条件。
二、三种题型,三条规则
grade_answer 的签名在 deeptutor/learning/grading.py:13-47,支持的题型只有三种:choice / short / open。三条规则各自是:
| 题型 | 判对规则 | 位置 |
|---|---|---|
choice | 去空格后全等比较 | grading.py:30-33 |
short | 先全等;不等且期望答案长度 ≤ 30 时用 difflib.SequenceMatcher 比率 ≥ 0.85 判对;期望答案更长则直接判错 | grading.py:35-40 |
open | 按 [,;,;。\n]+ 切成关键词,命中比例 ≥ 0.6 判对 | grading.py:42-47 |
这张表要这么读:三条规则的宽松度不是递增的,它们各自解决不同形状的答案。choice 最严,因为它比的是一个标签;open 最宽,因为它比的是一组关键词里命中了多少;夹在中间的 short 才是最需要留神的那一条。
另外还有一条兜底:期望答案为空一律判错(grading.py:20-21)。服务层有同款保护,deeptutor/learning/service.py:182-184 在调 grade_answer 之前先要求 expected_answer 非空。这是 fail-closed 的写法——没有存下标准答案时,这次作答被记成错,而不是被记成对。这一点在排查”我明明答对了却算错”时值得先确认:先看服务端有没有把期望答案存进那道挂起问题里。
三、本篇最反直觉的一处:short 的答案越长,判分越严
大多数人对模糊匹配的直觉是”越长的答案越容易蒙到相似度”。grading.py:35-40 恰好反过来。
它的分支顺序是:先做全等比较,相同就判对;不同的话,看期望答案的长度——只有期望答案长度 ≤ 30 时才启用 SequenceMatcher,比率达到 0.85 判对;期望答案长度超过 30,直接判错,连相似度都不算。
也就是说,模糊匹配的开关不挂在你的答案上,挂在标准答案上。同样一道简答题,标准答案写成一个短词组,你写岔一两个字还可能过;标准答案写成一整句话,就退化成一字不差的全等比较,差一个标点也是错。
这条规则带来两个具体后果,都能自己去核:
其一,题目怎么出,直接决定判分的松紧。同一个知识点,期望答案定得长一点还是短一点,走的是两条完全不同的判分路径。这两个数字是写在 grading.py:35-40 分支里的字面常量;我们没有在事实卡覆盖的配置项里看到对应的可调开关,是否另有别处可配我们没有核实。
其二,长答案不该用 short 题型。open 是按分隔符切关键词再算命中比例的,它对长答案更适配。至于某道题到底该用哪种题型,取决于你自己的出题方式,项目没有给通用建议,本文也不替它补。
顺带一句,题型白名单在 deeptutor/capabilities/mastery/tools.py:69 也有一份:_QUESTION_TYPES = ("choice", "short", "open"),与 grade_answer 支持的三种一致。再往下有一层题库类型映射(tools.py:93-99):choice→choice,open→written,其余→short_answer。判分用的名字和题库里存的名字不是同一套,做对账时别把两边的字符串直接等同。
四、错误分类:确定性代码只分两类,四类细分留给后面的 LLM
标题里那半句”系统认为你错在哪”,答案要拆成两段看。
deeptutor/learning/models.py:16-45 定义的 ErrorType 确实有四类:structural / deviation / application / metacognitive(同时带一套旧中文值映射,知识结构性 / 理解偏差型 / 应用错误 / 元认知型)。
但 grading.py:52-58 的 classify_error() 只做粗分类,而且只有两个出口:
- 答案是空的 →
METACOGNITIVE - 其余一律 →
APPLICATION_ERROR
源码把理由写在 docstring 里:空答案意味着学生不知道,其他一律先按”用错了”处理,更细的四类分类留给错误诊断阶段的 LLM。
这处分工是本篇第二个值得记的点。判错的那一瞬间,系统给你贴的标签只有两种可能,它跟”你到底错在概念还是错在步骤”没有关系——那是后面的诊断阶段才做的事。所以如果你在数据里看到大量 application,先别急着解读成”学生普遍是应用型错误”:这个值有可能只是 classify_error() 的默认出口。要区分,得看这条记录后来有没有被诊断阶段改写过。LearningStage 的七个阶段里确实有独立的 error_diagnosis(models.py:61-72),但诊断阶段具体怎么改写 error_type,我们没有读到那段实现,不做推断。
五、判错之后,哪些状态跟着动
判分本身只返回一个布尔值,真正的连锁反应在服务层。deeptutor/learning/service.py:174-210 的 grade_and_record() 写死了一条固定流水线:
记录作答 → 重算掌握度 → 推进间隔重复状态 → 重建复习队列 → 持久化。
卡里记录的是这条固定顺序(service.py:174-210),是否可跳过其中某一步我们没有核实。按这条顺序,一次作答会同时改动掌握度、复习排期和落盘文件——掌握度公式与间隔序列各自另有一篇专门讲,这里只说与判分直接相关的三处:
其一,错题记录的四个状态。 ErrorRecord.status 的取值被限定在 active / retrying / review / graduated,默认 active(models.py:140)。答错时新建的记录进 active。
其二,答对会”毕业”掉旧错题。 service.py:129-145:答对时,系统会找同一题目、同一知识点、状态为 active 或 retrying 的错题记录,把它改成 graduated。注意匹配条件是题目与知识点两者都对得上——换一道题考同一个知识点,不走这条分支。
其三,未毕业的错题会插到复习队列最前面。 deeptutor/learning/scheduler.py:80-87:构建复习队列时,处于 active / retrying 状态的错题所在的知识点,priority 被强制设为 1,高于所有按知识类型排的优先级。也就是说错题的优先级不是靠间隔算出来的,是硬插的。
六、还有一条根本不走 grade_answer 的路
deeptutor/learning/models.py:195-198 的注释把分工写得很清楚:qualitative_mastery 管 CONCEPT / DESIGN 两类知识点,mastery_levels 的定量闸门管 MEMORY / PROCEDURE。
对应到判分上,deeptutor/learning/policy.py:40-45 里 QUALITATIVE_TYPES = frozenset({CONCEPT, DESIGN}) 这两类由导师用 mastery_assess 判定费曼式讲解,不做字符串评分。它们的落库走另一个入口 record_qualitative()(service.py:239-241):通过则 mastery_levels 取 max(current, 1.0),不通过则取 min(current, 0.4)。
所以”答错之后系统认为你错在哪”这个问题,对定性类型是不适用的——那条路上压根没有 grade_answer,也没有 classify_error(),自然也不会产生 ErrorType。做数据分析时如果发现某些知识点从来没有错题记录,先确认它的 KnowledgeType 是不是 CONCEPT 或 DESIGN,而不是先怀疑数据丢了。
七、一处可核实的清单不一致
deeptutor/learning/__init__.py:3-10 的模块清单只列了 models / storage / scheduler / mastery / grading / service / prompts 七个,没有列 policy.py(295 行)与 pending.py(166 行)——而这两个文件确实存在,并且被 mastery 工具直接依赖。
按纪律,这里只陈述差异并标明位置,以我们实读的仓库状态为准,不推断原因,也不据此评价项目。说完就停。
八、你可以照着核的五步
- 打开
deeptutor/learning/grading.py:35-40,确认short那条分支里长度 30 的判断挂在expected上,不是挂在用户答案上。 - 打开
grading.py:52-58,确认classify_error()只有两个返回值,四类细分不在这个函数里产生。 - 打开
grading.py:20-21,再对照deeptutor/learning/service.py:182-184,确认两层都做了”期望答案为空判错”的 fail-closed 保护。 - 打开
service.py:174-210,把那条五步流水线的顺序抄下来,与你自己系统里”作答之后要更新什么”逐条比一遍。 - 打开
deeptutor/learning/scheduler.py:80-87,确认错题优先级是硬置为 1 的,不是算出来的。
最后交代边界。本文只读了 grading.py 全文、service.py 与 models.py 中与判分直接相关的几段,error_diagnosis 阶段如何改写错误类型、mastery_grade 工具的参数校验与返回结构我们都没有逐行核实。deeptutor/learning/tests/ 下有 233 个测试用例(按 def test_ 计数),我们只统计了数量,没有读断言内容,因此本文中的规则与常量是从实现读出来的,未经测试交叉验证。
再重复一次开头那条边界:以上全部是代码里的默认实现,不是运行结果的保证,更不是对学习效果的任何承诺。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。