简介面向计算机考研统考408考生的一套题目复习平台源码与配套素材围绕需要反复巩固的统考知识点提供集中回看、标记与复盘的载体适合正在备考408、希望自建复习工具或参考前后端实现的学习者对具备Java与前端基础的备考者尤为友好。压缩包共58个文件约2.43MB以28个Java后端源码为主体配合Vue、JavaScript、HTML与CSS完成页面交互另有PNG/SVG界面截图与结构示意图、XML与YML配置、JSON数据文件、Markdown说明及ZIP归档覆盖项目配置、接口实现、页面展示与部署产物的完整结构。已有295人学习。读者可据此了解复习平台的模块组织方式参考题目归类、前后端分离写法、表结构示意和示例截图快速搭建本地环境并按需改造把项目作为408知识点反复演练的入口降低资料分散带来的复习成本。1. 从一份越记越厚的错题表说起常见的情况是这样备考 408 的人先拿表格记错题记到两百多行以后打开表格就不知道今天该看哪几道于是又退回翻书。核心问题不在于记录而在于“哪些题属于需要经常复习的题目、隔多久再看一次、这次算不算真的会了”这三件事全靠人脑记必然崩。计算机考研统考专业课 408 的题目有个特点四门课混在一起考单题分值低但知识点密度高一道 Cache 命中率计算题背后挂着地址划分、替换算法、写策略三条线错一次往往不是错一个点。所以要做的平台本质是把“高频复习队列”从人的记忆里搬到数据库里用调度算法决定今天推到眼前的 30 道题用作答记录判断哪些题该降频、哪些该加频。这篇讲的就是这样一套平台的表结构、调度参数、导入脚本和几个真会翻车的地方适合想自己搭一套、或者正在用别人的题库但觉得“推的题不对味”的人。2. 408 题库的数据建模一道综合题到底要拆成几张表2.1 只存「题干 答案」会在一周内暴露问题最容易走的弯路是把题目当成一篇文章存id、题干、答案、解析四列解决。前三天够用第四天就会遇到第一个问题——408 综合应用题基本都是一道大题带四到五个小问比如给一段指令流水线的执行过程第一问算加速比第二问找数据冒险第三问改代码消冒险。如果整道大题只存一行作答记录就只能记“这题错了”但实际错的是第二问第三问是对的。调度算法拿到这个信号会把整道题压回重学队列于是你被迫重做已经会的小问复习量凭空翻倍。第二个问题出在知识点上。一道题常常同时挂着两三个考点单选里尤其常见比如一道 TCP 的题同时涉及拥塞窗口变化和慢开始门限。如果在题目表里塞一个knowledge_point字符串字段后面想按“操作系统-内存管理-页面置换”这个层级统计掌握度就没法做了。知识点的正确形态是一棵树题目和知识点是多对多。第三个问题最隐蔽错因。同样是答错有的是概念没建立有的是公式记混有的是审题漏了“不考虑 Cache 缺失”这种限定语。这三种错对应的复习动作完全不同——概念错要回看知识点公式错要重推一遍审题错只需要在做题时加一道检查动作。错因不单独存平台就永远只能给出“再刷一遍”这一个建议。所以最小的可用建模是五张表题目、知识点、题目-知识点关联、作答记录、复习状态。下面直接给 DDL。2.2 五张核心表的 DDL 与字段取舍-- 1) 题目表parent_id 自引用综合大题和它的小问都在这张表里 CREATE TABLE question ( id BIGSERIAL PRIMARY KEY, parent_id BIGINT REFERENCES question(id) ON DELETE CASCADE, subject SMALLINT NOT NULL, -- 1数据结构 2组成原理 3操作系统 4计算机网络 qtype SMALLINT NOT NULL, -- 1单选 2综合小问 stem TEXT NOT NULL, -- 题干Markdown公式用 $...$ 包住 options JSONB, -- 单选选项 {A:...,B:...}综合题为 NULL answer TEXT NOT NULL, analysis TEXT NOT NULL, rubric JSONB, -- 综合题踩分点如 [写出页表项位数,算出页内偏移] difficulty SMALLINT DEFAULT 3, -- 1~5冷启动时用来分批放题 source TEXT, -- 套卷代称如 模拟卷A-第17题 created_at TIMESTAMPTZ DEFAULT now() ); -- 2) 知识点表树形parent_id 指向上一级 CREATE TABLE knowledge_point ( id BIGSERIAL PRIMARY KEY, parent_id BIGINT REFERENCES knowledge_point(id), subject SMALLINT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL -- 冗余全路径 操作系统/内存管理/页面置换算法便于 like 查询 ); -- 3) 题目与知识点多对多 CREATE TABLE question_kp ( question_id BIGINT NOT NULL REFERENCES question(id) ON DELETE CASCADE, kp_id BIGINT NOT NULL REFERENCES knowledge_point(id), weight REAL DEFAULT 1.0, -- 该考点在这道题里的占比用于掌握度加权 PRIMARY KEY (question_id, kp_id) ); -- 4) 作答记录只追加不更新是后面所有统计的唯一事实来源 CREATE TABLE attempt ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, question_id BIGINT NOT NULL REFERENCES question(id), answer_raw TEXT, -- 用户填的选项或手写过程截图路径 grade SMALLINT NOT NULL, -- 0错 1半对 2对 3自评提示后做对 elapsed_ms INT, -- 作答耗时用来识别秒选这种无效信号 error_tag TEXT, -- 概念混淆 / 公式记错 / 审题漏条件 / 计算失误 created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_attempt_q ON attempt (user_id, question_id, created_at DESC); -- 5) 复习状态每题一行调度算法只读写这张表 CREATE TABLE review_state ( user_id BIGINT NOT NULL, question_id BIGINT NOT NULL REFERENCES question(id) ON DELETE CASCADE, stability REAL NOT NULL DEFAULT 1.0, -- 记忆稳定性单位天 difficulty REAL NOT NULL DEFAULT 5.0, -- 题目对你个人的难度 due_at TIMESTAMPTZ NOT NULL, reps INT NOT NULL DEFAULT 0, lapses INT NOT NULL DEFAULT 0, -- 遗忘次数超过阈值判为顽固题 first_try_ok BOOLEAN, -- 首次作答是否正确只写一次 last_grade SMALLINT, PRIMARY KEY (user_id, question_id) ); CREATE INDEX idx_review_due ON review_state (user_id, due_at, question_id);建表时最值得花时间的是question.parent_id和review_state.first_try_ok这两处。前者让大题和小问各自独立调度你做对了第三问第三问的due_at往后推第二问仍然留在队列里后者解决一个统计陷阱——同一道题刷第三遍答对正确率会虚高到 90%但那个数字对你的考场表现毫无预测力。所有掌握度统计必须只用首次作答这个字段就是为此留的。stability和difficulty这两个浮点字段现在空着没关系它们的具体含义留给下一章的调度算法。error_tag用文本而不是枚举因为错因分类一定会改写成枚举每次加一类都要改表。如果后面要按错因做统计在应用层收敛成固定词表就够了。知识点树建议直接按 408 的科目结构铺三层科目 / 章节 / 考点。第三层是调度真正打交道的粒度像“Cache 映射方式与命中率计算”“PV 操作与信号量”“TCP 拥塞控制四个阶段”这种粒度太粗比如只到“内存管理”会让你看不出到底哪块弱太细拆到“组相联映射的替换算法”又会导致每个考点下只有两三道题统计没有意义。经验值是每个第三层考点下有 8 到 20 道题题量不够就先合并考点。2.3 用一条 SQL 看出「哪个考点在漏水」表建好之后第一个该写的查询不是取待复习队列而是按考点统计首次作答正确率。它决定了你后面两周的复习重心压在哪门课上。-- 按三级考点统计首次作答正确率、平均耗时、参与题数 SELECT kp.path, COUNT(*) AS n_questions, ROUND(AVG(CASE WHEN rs.first_try_ok THEN 1 ELSE 0 END)::numeric, 3) AS first_acc, ROUND(AVG(a.elapsed_ms) / 1000.0, 1) AS avg_sec FROM review_state rs JOIN question_kp qk ON qk.question_id rs.question_id JOIN knowledge_point kp ON kp.id qk.kp_id LEFT JOIN LATERAL ( SELECT elapsed_ms FROM attempt WHERE attempt.user_id rs.user_id AND attempt.question_id rs.question_id ORDER BY created_at LIMIT 1 ) a ON TRUE WHERE rs.user_id $1 AND rs.first_try_ok IS NOT NULL AND kp.parent_id IS NOT NULL -- 只看第三层考点 GROUP BY kp.path HAVING COUNT(*) 5 -- 少于 5 道题不给结论 ORDER BY first_acc ASC LIMIT 20;这条 SQL 的关键在first_try_ok IS NOT NULL和HAVING COUNT(*) 5两个过滤。前者排除掉还没做过的新题后者防止你被“只做过一道题、恰好做错”的考点吓到去猛攻一个其实没问题的方向。LATERAL子查询取的是首次作答的耗时用来辅助判断正确率低且耗时短多半是瞎蒙正确率低但耗时长是真的没建立概念。跑出来的结果通常会呈现明显的长尾——两三个考点的正确率掉到 0.5 以下其余都在 0.8 以上。这几个考点就是需要“经常复习”的真正对象后面调度算法里的加权逻辑应该读这张表的结果而不是均匀对待所有题目。3. 间隔重复怎么落地把复习量压住的三个参数3.1 SM-2 直接搬到 408 上的三处不适配间隔重复最广为人知的实现是 SM-2逻辑简单每次作答给一个 0 到 5 的质量分 q更新难度因子 EF然后按interval interval * EF推下次时间。公式是EF EF (0.1 - (5-q) * (0.08 (5-q) * 0.02))EF 下限 1.3q 小于 3 时重置为 1 天。它在背单词场景跑得很好搬到 408 上会撞三堵墙。第一堵墙是评分粒度。SM-2 的 q 是六档实际使用中几乎没人能稳定区分 q4 和 q5。408 的题目更麻烦单选可以对错二分综合题却经常是“思路对、最后一步算错”你既不想判它全错会重置间隔浪费已建立的理解也不愿判全对下次推到 10 天后结果还是算错。所以评分档位要压到三到四档错、半对思路对但过程有失分、对独立完成、提示后对。第二堵墙是 EF 的语义。SM-2 里 EF 是“这题对你有多难”但 408 里同一个考点的不同题目难度差异极大——同样是页面置换给访问序列让你手算 OPT 和问你 Belady 异常成不成立后者可能秒答。用题目级的 EF 会让简单题被反复推。正确做法是难度挂在“题目”上但调度决策要考虑“知识点”的聚合掌握度。第三堵墙是复习量的无界性。SM-2 的间隔是纯乘法增长一旦某天断更所有题的due_at全挤在今天第二天打开就是两百多道待复习。这个问题在单词软件里也存在但单词可以一天刷三百个408 一道综合题做八分钟两百道就是二十多个小时直接劝退。所以调度层建议拆成两个底层用 FSRS 思路的可提取性模型算单题间隔上层加一个每日配额与削峰逻辑。3.2 难度、稳定性、可提取性一套四十行能跑的调度实现FSRS 的核心是把记忆状态拆成三个量难度 D这题对这个人有多难、稳定性 S记忆能保持多久、可提取性 R此刻还记得的概率。遗忘曲线用幂函数近似社区里流传较广的一组取值是衰减指数 -0.5、缩放因子 19/81但这两个数最终要拿你自己的作答日志拟合照抄只能当起点。import math from datetime import datetime, timedelta DECAY -0.5 FACTOR 19.0 / 81.0 # 与 DECAY 配套使 R(S, S) 约等于 0.9 def retrievability(elapsed_days: float, stability: float) - float: 当前时刻还记得这道题的概率 return (1 FACTOR * elapsed_days / stability) ** DECAY def interval_for(stability: float, desired_r: float 0.9) - float: 反解要从 R 掉到 desired_r 需要多少天 return stability * (desired_r ** (1 / DECAY) - 1) / FACTOR def next_state(stability, difficulty, grade, elapsed_days, lapses): grade: 0 错 / 1 半对 / 2 独立做对 / 3 提示后做对 返回 (新稳定性, 新难度, 新间隔天数) r retrievability(elapsed_days, stability) # 难度答错升难度答对降难度夹在 [1, 10] difficulty min(10.0, max(1.0, difficulty (1.5 if grade 0 else -0.6))) if grade 0: # 遗忘稳定性按原值打折且难度越高折得越狠 new_s max(0.3, stability * (0.35 - 0.02 * (difficulty - 5))) interval 1.0 if lapses 2 else max(1.0, new_s) else: # 回忆成功稳定性增益随难度递减随当前 R 越低增益越大 bonus {1: 0.6, 2: 1.0, 3: 0.7}[grade] gain 1 bonus * (11 - difficulty) / 10 * (1 - r) ** 0.5 new_s stability * gain interval interval_for(new_s) return new_s, difficulty, min(interval, 180.0) # 间隔封顶 180 天这段代码里三处参数值得单独说。interval_for的desired_r默认 0.9也就是“掉到九成把握就复习”。把它调到 0.85间隔会拉长约 1.6 倍复习总量下降但遗忘率上升调到 0.95间隔缩短到约 0.5 倍题量翻倍。408 这种需要手算的科目0.88 到 0.9 是个合理区间别贪心往 0.95 调——综合题做不完比记不牢更致命。bonus字典把“半对”的增益设成比“独立做对”低但比“提示后做对”高。这个排序很重要提示后做对说明你缺的是提取路径不是理解本身间隔不该拉太长。min(interval, 180)这个封顶是为了防止长期正确的题目被推到一年后——408 的考纲和题型稳定但你的记忆会在三个月不碰之后明显衰减封顶 180 天能保证每道题在考前至少还见过一次。3.3 每日新题上限与断更削峰把复习量锁在 1.3 倍单题间隔算得再准也顶不住“断更五天”这种现实。所以队列生成这一层必须独立于调度算法加两道闸。第一道闸是新题配额。每天引入的新题数量设上限建议 20 到 30 道具体取决于你每天能投入的时间单选按 40 秒、综合题按 8 分钟估算30 道新题里如果混了 5 道综合题就是 45 分钟起步加上复习队列两小时就出去了。新题超配是复习量失控的头号原因因为新题当天必然产生一次复习第三天再产生第二次雪球就是这么滚起来的。第二道闸是断更削峰。不要一次性把逾期题全放出来而是按逾期天数分桶每天最多消化1.3 × 日常配额剩下的顺延-- 今日队列逾期题按逾期天数分桶加上今天到期的题总量封顶 WITH overdue AS ( SELECT question_id, due_at, EXTRACT(DAY FROM now() - due_at) AS overdue_days FROM review_state WHERE user_id $1 AND due_at now() ORDER BY due_at ASC LIMIT $2 -- 参数2 每日配额的 1.3 倍 ) SELECT question_id, due_at FROM overdue UNION ALL SELECT question_id, due_at FROM review_state WHERE user_id $1 AND due_at now() AND due_at now() interval 1 day ORDER BY due_at ASC, question_id ASC LIMIT $2;ORDER BY due_at ASC, question_id ASC里的第二个排序键不能省。当大量题目的due_at落在同一毫秒批量导入时会这样只按due_at排序在不同数据库、不同执行计划下的返回顺序是不稳定的你会看到同一道题今天出现两次、明天一次都不出现。加上question_id作为稳定键分页才不会漏题。配额参数怎么定给一组可以直接抄的起始值每日新题 25 道、每日复习上限 120 道、单题间隔封顶 180 天、目标保持率 0.9、连续答错 4 次判为顽固题转入独立队列。跑两周之后看两个指标再调如果日均复习量超过 150 且完成率低于 70%说明新题配额给高了如果完成率 95% 以上但考点正确率没涨说明目标保持率设得太高题在重复但没进脑子。4. 从写题到接口导入脚本与三个端点跑通闭环4.1 用 Markdown 加 front matter 写题脚本批量入库题库内容如果直接在数据库里手写改一道题要开三个客户端。更实际的做法是每道题一个 Markdown 文件头部用 YAML 元信息描述结构正文写题干和解析公式用$...$这样文件本身可读、可 diff、可批量校对。--- id: os-mem-0042 subject: 3 qtype: 2 kp: [操作系统/内存管理/页面置换算法, 操作系统/内存管理/缺页中断] difficulty: 4 rubric: [写出页框数, 逐步演算置换过程, 给出缺页次数] source: 模拟卷A-第17题 answer: 缺页 12 次 --- 某进程的页面访问序列为 $1,2,3,4,1,2,5,1,2,3,4,5$分配 3 个页框采用 LRU ... ## 解析 前三次访问必然缺页之后 ...配套的导入脚本要做四件事解析 front matter、校验知识点路径存在、把题目和小问挂到父题上、重复导入时按id幂等更新。import json, re, pathlib import yaml import psycopg FM re.compile(r^---\n(.*?)\n---\n(.*)$, re.S) def parse_file(p: pathlib.Path): m FM.match(p.read_text(encodingutf-8)) if not m: raise ValueError(f缺少 front matter: {p.name}) meta yaml.safe_load(m.group(1)) body m.group(2).strip() # 正文里 ## 解析 之后是解析之前是题干 stem, _, analysis body.partition(## 解析) return meta, stem.strip(), analysis.strip() def upsert(conn, meta, stem, analysis): with conn.cursor() as cur: cur.execute( INSERT INTO question (id, subject, qtype, stem, answer, analysis, rubric, difficulty, source) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (id) DO UPDATE SET stem EXCLUDED.stem, analysis EXCLUDED.analysis, rubric EXCLUDED.rubric, source EXCLUDED.source , (meta[id], meta[subject], meta[qtype], stem, meta[answer], analysis, json.dumps(meta.get(rubric)), meta.get(difficulty, 3), meta.get(source))) # 知识点按 path 反查找不到就报错不允许静默跳过 for path in meta[kp]: cur.execute(SELECT id FROM knowledge_point WHERE path %s, (path,)) row cur.fetchone() if row is None: raise LookupError(f知识点不存在: {path}请先建树) cur.execute(INSERT INTO question_kp (question_id, kp_id) VALUES (%s,%s) ON CONFLICT DO NOTHING, (meta[id], row[0])) conn.commit()ON CONFLICT (id) DO UPDATE让脚本可以反复跑改了题干重跑一次就同步不用手动清表——这在批量校对阶段能省大量时间一次导入两百道题发现十处笔误改完重跑比人工补丁可靠得多。知识点反查那一段故意写成“找不到就抛异常”而不是“自动创建”。自动创建看着省事实际会把操作系统/内存管理和操作系统 / 内存管理这种多一个空格的路径变成两个节点跑一个月之后你的知识点树会分裂成一堆近似重复的节点统计全废。宁可在导入时报错人工补一次树节点。4.2 公式、图片、代码块三个最容易在渲染上翻车的地方408 的题目正文里有三类特殊内容每一项都有对应的处理方式处理不好会在浏览器里原样显示成一堆美元符号。内容类型存储格式渲染方案常见翻车点数学公式$...$行内$$...$$块级前端用 KaTeX 渲染题干里出现价格符号或正则的$被误判为公式起始图片地址划分图、流水线时空图存对象存储正文写相对路径上传时改写为带签名的 URL图片列宽不做限制长图把卡片撑变形代码块数据结构题常给伪代码三重反引号加语言标记代码高亮代码里的反斜杠和花括号没转义被当成格式符第一项最难缠。题目里出现$作为普通字符的概率不低比如讲磁盘调度时写“单位成本 $/GB”。稳妥的规则是行内公式必须$紧跟非空白字符且结尾$前面是非空白落单的$按普通字符输出。这个判断放在前端渲染前的预处理里二十行正则就能覆盖绝大多数情况。图片要注意的是存储路径和正文解耦。正文里写![地址划分](../../assets/os-0042-1.png)导入时统一改写成平台内的资源 ID删除题目时按 ID 级联清理不然半年后存储里会堆几百张没有任何题引用它的图你自己也分不清哪张还能删。图片展示上给一个max-width: 100%和固定高度上限408 的流水线时空图动辄半屏不做限制会把复习卡片的节奏打乱。代码块的处理相对简单但有个细节数据结构题里的伪代码经常有\续行和{}花括号如果正文还要走一遍模板渲染这两类字符必须转义否则页面直接白屏。最省事的做法是题干和解析都按纯 Markdown 存、按纯 Markdown 渲染中间不插入任何模板引擎。4.3 三个端点跑通「今天刷什么」的最小闭环后端不需要复杂三个端点就够撑起整个复习流程取队列、交答案、看考点统计。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() app.get(/api/queue) def get_queue(user_id: int, limit: int 120): # 队列逾期题 今日到期题稳定排序见 3.3 的 SQL rows db.fetch_all(QUEUE_SQL, user_id, limit) return {items: rows, count: len(rows)} class GradeIn(BaseModel): user_id: int question_id: str grade: int # 0错 1半对 2独立对 3提示后对 elapsed_ms: int error_tag: str | None None rubric_hits: list[int] [] # 综合题勾选的踩分点下标 app.post(/api/grade) def submit_grade(body: GradeIn): # 1) 追加作答记录不改历史统计只认这张表 db.execute(INSERT_ATTEMPT, body) # 2) 读旧状态算新状态写回 review_state st db.fetch_one(GET_STATE, body.user_id, body.question_id) elapsed (now() - st[due_at]).days if st else 1 s, d, ivl next_state(st[stability], st[difficulty], body.grade, max(elapsed, 0.1), st[lapses] or 0) db.execute(UPDATE_STATE, body, s, d, ivl) return {next_due_days: round(ivl, 1), stability: round(s, 2)} app.get(/api/kp-stats) def kp_stats(user_id: int): # 直接复用 2.3 的考点统计 SQL return {rows: db.fetch_all(KP_STATS_SQL, user_id)}/api/grade里的elapsed用now() - due_at而不是now() - 上次作答时间因为调度模型算的是“距离应该复习的那天过了多久”逾期三天和提前两天复习对稳定性的影响方向完全不同。这个细节写错你的间隔会越算越短最后所有题都堆在一块。rubric_hits是综合题专用。前端把rubric数组渲染成勾选框用户做完题自己勾踩分点勾满了算 grade2勾一半算 1一个没勾算 0。这比让用户直接自评“对/错”可靠得多因为人对着检查项做判断时不容易自我欺骗。前端复习卡片只需要展示五样东西题干、当前题所属的三级考点、上次作答时间和结果、错因标签下拉、计时器。再加一个键盘快捷键1/2/3 对应三档评分让手不用离开键盘。这个细节听起来小实际能把一轮复习的节奏从每题 15 秒操作时间压到 3 秒。题量大的时候操作开销比做题本身更消耗人。5. 避坑排查四个让复习计划当场崩掉的现场5.1 待复习量一夜之间从 40 涨到 260现象出差或生病断了四天没打开平台第五天进去队列显示 264 道待复习按每题两分钟算要八个多小时。硬着头皮做完第二天又是 180 道第三天开始就放弃了从此不再打开。原因有两层。表层是所有题目的due_at在断更期间陆续过期全都落进了“逾期”集合。深层原因是新题配额没有独立于复习队列——那几天系统还在按天放新题新题当天到期、第三天二次到期逾期题和新题叠加量级直接翻倍。解决分三步。第一步给队列加削峰见 3.3 的LIMIT 每日配额 × 1.3逾期题按due_at升序分几天消化最老的最先处理。第二步断更超过三天时自动暂停新题投放直到逾期积压消化到日常配额以下再恢复。第三步给用户一个显式的“休整模式”开启后一周内只推错题和顽固题不推新题也不累计逾期惩罚——参数上就是把这段时间的elapsed_days截断在 7 天以内不让它把稳定性打成负增益。这里有个反直觉的点逾期题的复习顺序不能按“逾期最久”排而应该按“稳定性最低”排。逾期 20 天但稳定性 60 天的题此刻还记得的概率仍有七成逾期 3 天但稳定性只有 2 天的题早忘干净了。按稳定性升序处理同样的时间投入能捞回更多记忆。5.2 考点正确率 90%换一道同考点的新题还是错现象某个考点的首次作答正确率显示 0.9你判定这块没问题把时间挪去别的科目。两周后做套卷同考点的题照样错而且错法一模一样。原因是你记住的是题目的答案不是考点的解法。408 的题库里同一考点下的题目数量有限反复刷会形成“看到这个访问序列就写 12 次缺页”的题面记忆。首次作答统计本该挡住这种幻觉但如果同一道题被标记为“新题”重复投放比如导入时 id 变了生成了两条 question 记录first_try_ok就会被重写统计失真。解决有两处。其一在review_state之上加一层“变式题池”同一考点的题目按question_kp归组一道题连续两次答对后下次调度不再推它改推同组的另一道未做题整组都做过之后才回到按间隔推。这个逻辑加在队列生成 SQL 里用一个NOT EXISTS排除掉“本考点已有更该复习的题”的情况。其二导入时做去重校验题干前 80 个字符加答案做哈希命中已有记录就合并而不是新建。更深一层的判断是看耗时。同考点新题的正确率如果显著低于旧题比如旧题 0.9、新题 0.5说明掌握的是题面不是解法这时候应该把该考点的目标保持率临时调到 0.95让间隔缩短同时把新题配额往这个考点倾斜。5.3 综合题自评“做对了”实际步骤分丢一半现象复习时觉得思路清晰勾了“独立做对”间隔被推到 12 天后。真正模考时同样类型的题结果算错中间过程扣了 6 分。原因是综合题的自评没有判据。人对“我会不会”的判断在刚看完解析之后极度乐观尤其在已经做过一遍的题上。单选可以对错二分综合题的“对”必须拆成踩分点。解决办法是把rubric字段用起来每道综合题在导入时写 3 到 5 个踩分点复习时逐项勾选勾选比例决定 grade。全勾满才是 2勾了 2/3 算 1半对低于一半算 0。这个机制的价值不只是评分准更在于它强迫你在做题时按踩分点组织答案——408 的综合题阅卷就是按点给分的平时按点自检考场上写字都有条理。配套要改的是调度grade1半对时稳定性增益按 0.6 倍算别按 1.0 算。半对比全对的信息量差很多间隔拉长了下次还是半对。5.4 题库里两成题目永远轮不到现象题库导入了 800 道题跑了两个月统计显示有 160 道题一次都没出现过。检查review_state这些题根本没有记录。原因通常在冷启动逻辑。新题投放如果按question.id升序取早期导入的题会一直占着配额后导入的永远排不上如果按created_at降序取反过来老题永远轮不到。另一个常见原因是due_at批量初始化成了同一个时间戳队列按due_atquestion_id排序每次都取前 25 个后面的永远在队尾。解决是按考点覆盖度做冷启动取样而不是按 id 顺序。每天的新题从“当前review_state里题数最少的考点”里取取的时候在同考点内随机加上ORDER BY random()的种子固定到日期保证同一天多次刷新队列结果一致。这样跑两周各考点的题量会自然摊平不会出现某个考点一道题没做过的情况。同时加一个监控查询每周跑一次找出从未进入过review_state的题目SELECT q.id, q.source, q.subject FROM question q LEFT JOIN review_state rs ON rs.question_id q.id AND rs.user_id $1 WHERE rs.question_id IS NULL ORDER BY q.created_at LIMIT 50;返回结果超过题库总量的 5%就说明投放逻辑有问题别等到考前一个月才发现某门课有整章没刷过。6. 用复习日志反推参数而不是相信默认值调度参数不该拍脑袋定也不该照抄别人的配置。你能拿到的、最贴合自己的数据就是attempt表里的作答记录用它做一次校准比调十次参数都管用。具体做法是算校准曲线。取所有满足“上次作答后间隔超过 3 天”的记录用当时的stability和实际间隔算出预测的R再看这次作答的实际结果grade ≥ 1 算记住。按预测概率分桶每桶 0.1 宽统计实际记住的比例import pandas as pd df load_review_log(user_id) # 含 stability, elapsed_days, grade df df[df.elapsed_days 3].copy() df[pred_r] (1 FACTOR * df.elapsed_days / df.stability) ** DECAY df[recalled] (df.grade 1).astype(int) df[bucket] (df.pred_r * 10).round() / 10 calib df.groupby(bucket).agg( n(recalled, size), pred(pred_r, mean), actual(recalled, mean) ).query(n 20) # 样本太少的桶不看跑出来的表最有信息量的地方是偏差方向。如果pred0.9的桶里actual只有 0.75说明模型高估了你的记忆力稳定性增长给得太快需要把gain的系数整体乘 0.85 左右反过来actual高于pred说明间隔太保守复习量白白浪费。预测保持率桶样本数实际记住率处置0.91420.76稳定性增益偏高乘 0.850.8960.79基本吻合不动0.7510.72吻合0.6230.52样本少观察一周调完再跑两周重新算一次曲线。收敛到各桶偏差在 ±0.05 以内就够了不用追求完美——408 的题目难度跨度太大模型能抓住大方向已经够用。还有个我自己的习惯每周固定花二十分钟看三件事逾期题里稳定性最低的前十道、连续答错四次以上的顽固题清单、以及从未出现在复习状态里的题。前两个决定了下一周的重点第三个决定了题库投放有没有漏洞。这三件事加起来比刷一百道题更能决定最终分数因为它们管的是方向不是量。调度参数这个东西我在上面吃过最大的亏是把它当成一次配置——设好就不动了。实际上它会随着你的基础变化而失准前期需要短间隔高频巩固后期需要长间隔保持手感同一套参数跑到底要么前期复习量不够要么后期被无效复习拖死。每隔两周拿日志校一次成本二十分钟收益是把有限的时间压在真正没掌握的题上。希望帮到你。本文还有配套的精品资源点击获取