WRITING
我能出五万种题,孩子问我这套一共有几道

孩子做完一套题,抬头问我:这上面一共有多少道题啊。我答不上来。
不是我记不清,是这个问题在我当时的设计里根本没有答案。我造的不是题库,是 74 个随机数生成器:每次点开现场摇一道出来,做完就没了。你问它有几道题,等于问一个骰子有几个点数序列。
那一刻我才想明白,我把两件事混成了一件:「能不停出新题」和「有一个查得到的题库」,是彻底不同的两个东西。 前者是我作为工程师的本能——重复劳动能自动化就自动化;后者是家长和孩子的本能——我得知道我在练什么,练了多少,还剩多少。
这个站从 8 月 8 号开工,五天里我推翻了四次。四次的根因是同一个:我把「技术上能做到」当成了「产品该长这样」。 这篇文章把构建思路和使用方法一次写完。
一、起点是一份卷子,不是一个想法
先说一件我认为最要紧的事:这个站上的每一道题,都能溯源回一份真实的卷子。
起点是 2025 学年第二学期的三年级语文期末卷。阅读题第 17 题写得明明白白:「请你在文中找出三个成语」。答案就印在《虾趣》最后一段里——多彩多姿、出神入化,再加第②段的 栩栩如生。抬眼就能抄。
孩子写上去的是「生动形象」和「胸有成竹」。这两个词文章里一个字都没出现过。
这不是词汇量的问题,是行为的问题:题目说「在文中找」,他没回文中找。同一份卷子第 16 题也一样,第⑤段明明写了虾「前进」和「后退」两件事,他只概括成「前进」,丢了一半。
于是语文线的第一站就叫「回原文找依据」,第三站叫「理清顺序」,全部对着这份卷子上的真实失分点长出来。这是我给自己定的第一条硬规矩:
凭空想出来的知识点,做完也提不了分。先有卷子进档案,归出真实失分点,再开课。
首页上小学十门学科全摆着,但只有语文和数学是开着的,另外八门标着「筹备中」,各自写清为什么还没开。这不是偷懒——空着的那八个格子本身就是下一步的活,比一张假装很全的课程表诚实得多。校验器是硬的:一门学科要么有课,要么标 soon;标了 soon 就不许再挂真课;占位学科必须写明理由。三条都拦不住的写法,渲染直接拒绝。
二、第一次推翻:无限流是个坏设计
第一版我做成了「无限流」:一次显示一道题,答完判对错,下一题自动补上,永远做不完,算法始终挑你最弱的那个知识点喂给你。
技术上这是最优解——单位时间内练到最弱点的效率最高。我当时还挺得意。
孩子做了十分钟,说:我不知道我前面做了什么。
他是对的,而且他说的比我想的更准。无限流优化的是吞吐量,代价是没有边界、没有痕迹。做题的人拿不到任何一个「我完成了什么」的信号。托福、期末卷、任何一场真实的考试,都不是这么组织的:一套就是一套,有第 1 题也有第 12 题,做到第 8 题你知道自己在哪。
于是重做成套卷。默认 12 题,有头有尾:
| 件 | 怎么运作 |
|---|---|
| 题号条 | 题卡最上面 1..12,绿=做对、红=做错、空=没做、蓝圈=当前。点题号直接跳 |
| 翻题 | 上一题 / 下一题,键盘左右键,平板上左右滑 |
| 回看 | 答过的题翻回去原样还原(填的数、选的项、当时的讲解),并且全部锁死不能再答 |
| 跳过 | 只是先放着,不判错也不计分,题号条上仍空着,随时能回来做 |
| 交卷 | 没做的判「未作答」,出总览:逐题一行 + 得分 + 点行跳回看讲解 |
| 再来一套 | 重新组卷,全新的题 |
组卷时弱项和错过的题型优先,但抽完就定住——一套里不再变。由易到难排。
关键在这句:「无限」和「无结构」是两件事。 题目仍然可以无限地出,只是要一套一套地出。我当初把这两个词绑在一起,纯粹是因为我脑子里装的是 Anki 那类抽认卡模型,而不是因为孩子需要它。
三、第二次推翻:错题本不能存原题
第二版的错题本,我做的是最直觉的那种:答错了,把这道题存下来,复习的时候再放一遍。
孩子刷了两轮就把答案背下来了。第三轮全对,看着像学会了,其实一道都没会。
所以现在的错题本记题型,不记题目。条目里只有 {gid, 题型名, 题面摘要, 日期, 错了几次, 连对几次}——物理上就没存答案和选项,想重放原题也放不了。复习的时候用同一个题型重新 make() 一道新的:换数字、换选项、换文本。
这条改动还顺手拆掉了一个更脏的东西。原题重放那条路上,题目对象是从存档里反序列化出来的,没有闭包,跑不了它自己的验算函数——当时我给它塞了一个 verify: () => true。
一个永远返回 true 的验算,比没有验算更糟,因为它让自检永远是绿的。这条路删掉之后,这行代码没有存在的理由了。现在有一道门专门扫全仓的恒真验算,写回去就红。
订正规则也调了两处:
- 一次做对才算订正一次,连着 2 次才划掉。一次蒙对就当学会了,下回照样错。
- 答错就把连对清零、错误数加一。反复错的题型在下次组卷里权重更高。
- 普通套卷也给错过的题型加权(+6)——不用专门去点「练错题」,它也会自己找上门。
四、第三次推翻:生成器不是题库
这就回到开头那个问题了。
孩子问「一共几道题」的时候,我手上有 74 个生成器,没有一张表能回答他。更要命的是我自己也不知道题出得好不好——我没法把 74 个生成器的产物摊开来看一眼,只能靠随机点几下抽检。
所以建了 bank.db,SQLite 单文件,一道题一行:
items 题面 / 问法 / 答案 / 解析 / 提示 / 题型 / 难度 / 知识点 /
来源卷子 + 题号 / 孩子当时怎么答的 / 收录日期
options 选择题的每个选项(哪个对、错在哪)
topics 知识点 papers 来源卷子 texts 课文原文
gens 题型登记:现场生成(open)还是已固化(enumerated)
sources 各页出题代码的指纹 —— 漂移门靠它
站本身还是零后端、零 CDN、双击就能打开的单文件 HTML。题库不是运行时依赖,是构建时的事实源:建库 → 内联进各练习页 → 另导一张总览页。
总览页能筛(学科/知识点/题型/难度/来源)、能全文搜、能勾选导出打印卷、能按知识点看弱项、能点一道题看它来自哪份卷子第几题。加题走终端,不做管理界面——这是我自己拍的板,理由很简单:管理界面我要写要维护要验证,而我加题的频率是一周几次,bank.py add --json 就够了,多出来的每一行界面代码都是白付的成本。
五、第四次推翻:全枚举会炸
建库那天我做了件蠢事:把 74 个生成器全部枚举进库。
思路听起来很正当——生成器能出的题是有限的,那就反复 make() 直到不出新的为止,全存下来,这样题库就是完整的。技术上完全成立,我甚至写了收敛判定:连续 8000 次没出新题算枚举完。
结果:1787 道。一篇《虾趣》炸出 380 道,《鹰和蜜蜂》那套炸出 1226 道,前 4 个题型就占了 79%。word-precision.html 从一百多 KB 涨到 912KB。
这不是题库,是词表 × 干扰项的笛卡尔积。而且它的增长是组合级的:再录三份卷子,页面就没法看了。
真正的问题在于我搞错了「完整」的定义。孩子要的完整是**「卷子上那 13 道原题一道都不能少」**,不是「这个生成器的输出空间被穷举了」。
推倒重来,规矩改成一句话:库以卷子原题为骨,生成器不预先摊开,类似题按需造。
| origin | 是什么 | 进套卷吗 |
|---|---|---|
paper | 卷子上的原题——带孩子当时的作答、判定、失分。核心资产 | 优先出,每套最多占 1/3 |
sample | 每个题型 2~3 道样例,让人看得见它长什么样 | 不出,只供查 |
hand | 手写的题 | 出 |
kept | 页面上「构建类似题目」造出来、我点了「留下来」的 | 出 |
现在库里是 227 道:13 道卷子原题 + 214 道样例。页面回到 134KB。体量跟卷子数线性走,不跟组合数走——再录十份卷子也就是加十份的量。
出题仍然走生成器现场出。gens 表里记着每个题型到底能出多少种,合计 50,725 种——那才是多样性的真实指标。库里存几道样例(n_items)不是。我一开始拿 n_items 当指标,差点把所有题型都误判成「变化太少」。
每道题的题头现在有三个按钮:
| 按钮 | 干什么 |
|---|---|
| ☆ 收进错题本 | 收的是这一类题,下次出同类新题。答对答错都能点,再点取消 |
| + 构建类似题目 | 按这道题的考点现造一道,追加到本套末尾(原题和类似题都留着)。变不出新的会明说 |
| 📋 留下来 | 站是静态零后端、浏览器写不了库——所以是把这道题复制成终端能吃的 JSON |
卷子原题没有出题规则,点「构建类似题目」会提示去终端造。那条路上我用模型按考点写新题,但判定权不给它:新题里引用的每一句原文证据,必须逐字回到课文里搜得到,搜不到就丢掉重来。检索和执行可以外包,答案对不对这件事不外包。
六、验算必须走另一条路
这是整个工程里我最看重的一条,也是唯一一条我一次都没让过步的。
每道题的 make() 必须返回一个 verify(),而且 verify 要走和出题完全不同的路子。
| 出题侧 | verify 侧 |
|---|---|
ans = (r+1)*q + r(公式直接算) | 穷举除数 1~400,每个真做一次带余除法,挑最小的 |
ans = Math.ceil(t/p)(进一法) | 一份一份真的装进去,数用了几个容器 |
| 手写「这三个成语在文中」 | 回原文 indexOf:该在的必须搜得到,干扰项必须搜不到 |
| 手写填空句的原词 | 把选项填回句子,整句拿回原文搜——干扰词填进去必须搜不到 |
| 手写情节顺序 | 每个情节挂一句原文锚点,量出位置重排一遍,必须和答案一致 |
| 手写判断题的对错 | 证据句在不在原文 + 对错标记与「陷阱说明」自洽 |
为什么必须两条路?因为复用同一个公式去「验算」等于测了个替身。 错了两边一起错,自检永远绿。这是伪装成调查的不调查——它比不调查更隐蔽,因为它看起来很像在调查。
引擎启动时每个题型跑 120 次 verify,一次不过就把原因写进页面,渲染门直接红。这套东西真的抓到过东西:我把某个成语抄错一个字,自检当场就红了。
也得说清它抓不到什么。回原文查证的那部分和数学一样硬;纯词语辨析(错别字、近义词)只能拿独立词表反查,抓得住「选项标反 / 正确项不唯一 / 干扰项重复」,抓不住「词表本身写错」。判断题的对错标反能抓(靠陷阱说明的自洽性,这条是被漏过一次之后补的),但「标对了、陷阱也编得像模像样、可惜和原文其实不一致」抓不住——那是语义,只能自己对着原文读一遍。
所以 check_all.py 跑完所有门之后,最后一屏专门列没有任何门在管的事:题面中文读不读得通、讲解适不适合三年级、版面手机上挤不挤、档案录得忠不忠实。报绿不等于事情对,这句话得印在输出里,不能只写在文档里。
七、我写过一道永远不会红的门
建库的时候我加了一道门:渲染页面时,比对「页面内联的题数」和「库里的题数」,对不上就红。
看着很像一道门。实际上它是在渲染那一刻比对,两边同一次运行、同一个函数产出的,自己跟自己比,永远一致。我是在给这道门写反向验证探针的时候才发现的——我怎么改都红不了。
修法是让两边真的分开:页面里写一枚覆盖整个内联载荷的指纹(不只是题目 ID,改题面而题数不变也得能查出来),另有一道独立的门去比对库和页面。同时把载荷的构造收进一个函数,渲染器和陈旧门共用——两处各写一份,迟早算出不同的数。
这件事之后我立了一条规矩,并且把它做成了机器检查:
每加一道门,就要登记它的反向验证——把这道门该抓的 bug 放回去,确认它真的拦下来。 写门的时候先回答一句:这道门在什么情况下会红? 答不上来的,它就不是门。
现在 reverse_checks.yaml 里登记着 41 条探针,gate_audit.py 强制对账:跑批里有的门,这里必须有;这里有的门,那边必须还在。允许欠账,但必须写 waiver: 说明为什么欠——豁免是看得见的欠账,不是静默跳过。
gate_audit.py 自己还管另外三件事,全是这轮翻过的车:
- 恒真验算——
verify: () => true这类写法出现即红 - 产物体积上限——练习页 300KB、题库页 800KB。就是被 912KB 那次逼出来的
- 文案里承诺的命令必须真实存在——我在页面弹窗里写过「去终端跑
bank.py similar」,而当时这个子命令还没实现
第 3 条尤其值得说:文档里写了一条不存在的命令,读文档的人(包括半年后的我)会照着敲,然后卡住。现在它是机器检查的一部分。
八、上瘾机制在引擎里,不在某一页
孩子愿不愿意点第二次,靠的不是题目质量,是**「差一点就完成」始终在眼前**。
这套东西我全放进引擎,所有练习页重渲后一起生效,不给单页贴补丁:
| 机制 | 怎么运作 |
|---|---|
| 今日任务 | 顶栏下一条任务条,永远写着「还差 N 题」;剩 ≤3 题时变金色 + 脉冲;完成给 +50 经验、彩带、勋章 |
| 经验与等级 | 一次做对 +12(连击有倍数)、订正对 +4、答错 +2(敢试就有分);等级条写着「再练 X 经验就升级」 |
| 连击倍数 | 连对 3 次 ×1.5、6 次 ×2、10 次 ×3;每 5 连一次全屏彩带 |
| 连续天数 | 第一次答题才打卡——点进来不做题不算来过 |
| 勋章墙 | 16 枚,没解锁的也灰着摆出来并写明条件,下一枚要什么一眼看得见 |
存档分两份:单课的掌握度和错题本存在课自己的键下,经验/等级/勋章/连续天数/今日任务存在全局键下。所以跨学科是同一条成长线,不是语文一条数学一条。
三条踩出来的硬约束:
- 特效层一律
pointer-events:none。彩带和弹卡盖在题目上面,只要吃掉一次点击,整页就点不动。冒烟有一条专门测这个。 - 勋章「长什么样」只写在一份 JSON 里,练习页和首页读同一份,判定函数按 key 对齐,对不上直接终止渲染。
- 每日任务量的唯一来源在课程表里,页面可以覆盖,但默认不许各写各的——否则改一次要改十遍。
九、把一份 Excel 变成算得清的账
孩子的积分制度原本是一张 Excel:81 条规则,按学校科目分区,奖分扣分并列。我把它逐条结构化进了系统,现在是 83 条规则 + 12 件可兑换商品。
五种规则形态,分值一律服务端算,请求里报什么都不算数:
| 形态 | 干什么 | 数量 |
|---|---|---|
fixed | 固定分,点一下就记 | 49 |
range | 家长在区间内填 | 17 |
per | 按个数,配的是单个的分值 | 9 |
calc | 查有序档位,从上往下第一个命中的生效 | 5 |
tv | 换电视时间(第二种货币) | 3 |
几条我认为值得单独拎出来的判断:
档位表达式用白名单求值,绝不用 eval。 表达式来自配置文件,eval 等于把任意代码执行权交出去。门里有 6 条注入用例(__import__ / __subclasses__ / open / lambda / exec / 列表推导)反向验证过。
页面预览和实际记账走同一个计算函数。 家长看到的是预览,孩子拿到的是记账,两边各算各的迟早算出不同的数——而那时候你已经解释不清了。
档位表是有序的,第一个命中就返回。 这就是原表里「不重复累计」的实现。跳绳 3 组 190 个必须先于 3 组 180 个匹配,顺序错了就给低了。
扣分可以扣成负数,兑换不许透支。 扣分条款本来就是「欠着下次挣回来」;但花钱买东西不能赊账。两条路径的开关不一样。
管理密码不发 cookie,每次操作现输。 孩子拿到已登录的平板也加不了分。能自己给自己加分的积分系统,第二天就变成刷分游戏,正向激励当场失效。
刷题积分服务端每日封顶 30 分,空交卷不给分。 那是唯一不需要家长参与的加分通道,所以额度必须在服务端算。空交卷这条有个坑:判定要数做对的题,而不是数总览里的行数——按行数判会把「一道不做直接交卷」算成一套全对,那就是明摆着的刷分路。
账本 append-only,余额永远从流水累计。 单独存一个余额字段就会和流水漂移,而漂移的账本比没有账本更糟。撤销走红冲:反向流水 + 原条目盖作废戳,不删记录。
还有一条我犹豫过但最后写进去了:开局那 30 天的历史全部带 seed 标记,事由后面缀「(开局模拟)」。要「从 0 波动涨到 1000」的观感可以给,但账本不能撒谎——半年后得一眼分得清哪些是真的发生过的。铺历史的命令在账本里有真实流水时会拒绝执行。
登录门也是同样的思路:它在服务端,不在前端。 前端写个密码框等于没有——右键看源码就有,直接敲深层 URL 就绕过去了。服务端自己发静态文件并在发之前查会话。这么分不只是为了安全,更是为了本地能跑同一条路:如果把门放在 nginx 那一层,本地根本测不到它,等于没有门。
十、使用说明
加一课
三步,每一步都有门:
# 1. 写 primary-<科目>/<slug>.practice.md
# frontmatter 必须有 title / slug / date / out,建议有 source / unit
# 正文 = 短讲解 + 一段 gens 围栏(出题代码)
python3 engine/practice_render.py primary-<科目>/<slug>.practice.md
# 2. 课程表里加一个节点,然后重渲首页
python3 engine/path_render.py
# 3. 全部门(含真浏览器端到端冒烟)
python3 engine/check_all.py
date 是必填的,渲染器没日期直接拒渲。理由:半年后想知道「这道题为什么在这儿」,得能顺着日期回到当时那份卷子。页面上三处显示日期,首页每张课卡也显示——而且是现读源文件的,课程表里不另存一份,免得两处漂移。
加一道题
cat > /tmp/q.json <<'EOF'
{ "type":"choice", "q":"题面", "ask":"问法", "level":2,
"options":[{"t":"对的那个","ok":true},{"t":"干扰项","ok":false,"why":"错在哪"}],
"sol":"分步讲解(必填)", "hint":"指方向不给答案" }
EOF
python3 engine/bank.py add --topic chinese-text-evidence --json /tmp/q.json
python3 engine/practice_render.py primary-chinese/text-evidence.practice.md
落库那一刻立刻过一遍门,不过就自动回滚。加完必须重渲对应页面——陈旧门会拦,但别等它拦。
录一份卷子
一卷一个目录:原文 / 题目 / 标准答案 / 孩子的作答 / 错因 + 原图。规矩只有两条,但都是硬的:
- 读不准就标
confidence: low并留空,不猜。 翻拍件红蓝笔叠写是常态,猜出来的作答会污染错因统计,比缺数据更糟。所有 low 条目必须出现在待核对清单里,机器强制对账,漏一条就红。 - 习作逐字照录,错别字和拼音代字原样留着——那是识字写字的失分证据,改了就是篡改。
正因为原题要逐字照录,「中文句子里禁用半角双引号」这条内容红线对卷子原题豁免。规矩服务于事实,不能反过来。
发布
档案目录永不上线。上线目录是白名单组装的,另有一道 fail-closed 的门:出现档案里登记的姓名、引用档案路径、混进非 HTML 文件——中止不发。姓名黑名单从档案文件里现取,不在部署脚本里另存一份。
这道门真的拦过东西,而且是我自己写的两处:课程表里一句描述和一行 JS 注释里带了本地路径。
站本身没做邮箱验证码那种强认证——孩子每次做题都过一遍验证码,这个站就用不起来了。取而代之是账号密码 + 搜索引擎不收录 + 档案根本不上传。这是个有意识的取舍,不是疏忽。
十一、五天,四次推翻,一个根因
回头看,这四次推翻是同一个错误的四种形状:
| 我做的 | 我该做的 |
|---|---|
| 验证了生成器能枚举完 → 就全枚举进库 | 落地前先算增长量级:再来十份卷子会变成多大? |
| 抄了抽认卡模型的默认形态当需求答案 | 开工先把**「用户提的」和「我推断的」拆开** |
| 为了让守卫过,给它塞一个恒真验算 | 伪造守卫的输入,比没有守卫更糟 |
| 写了道渲染时自己跟自己比的门 | 写门先答一句:它什么情况下会红? |
共同点是:我每次都验证了「这件事能做」,然后把它当成了「这件事该做」。 技术可行性是个很有诱惑力的答案,因为它可以被证明,而「该不该」不行——「该不该」只能靠对着真实的人看它有没有用。
这个站现在有 7,240 行引擎代码、17 道机器门、41 条反向验证探针、227 道查得到的题、50,725 种能出的题。
但最有价值的那次改动,是把「一共有多少道题」这个问题变得有答案。那不是一行代码,那是一个我原本不觉得需要回答的问题。
提问的人是个三年级学生。
相关文章
订阅
新文章都先发在这里。用 RSS 订阅: /feed.xml