框架的每一个机制背后,都是一个真实发生过的失败模式。这份档案按 坑 → 后果 → 框架内的解法 记录,既解释「为什么要这个机制」,也方便你评估 这套方案能否搬到自己的项目里。全部机制都可在源码中对应到具体实现。
- 坑:判题直接在服务进程里执行学习者代码,一个
while True: pass就把整个服务拖死。 - 后果:一个初学者的一次手滑,杀死所有在线学习者的会话。
- 解法:判题一律走子进程——每次判题在独立临时目录起一个子进程跑,
timeout=10秒强杀(server.py: grade/adapters/*_adapter.py: TIMEOUT_S)。 超时不算崩溃,算一次正常的「判红」,并把报错写成教学话术:"超时:10 秒没跑完(八成是死循环,检查 while 的退出条件)"。
- 坑:让 AI 出题时,它写的测试只查一个正常值——
assert add(2,3) == 5。 错误解(漏负数、漏空输入、off-by-one)照样全绿。 - 后果:题目形同虚设:学习者写错也能过,还获得「我学会了」的错误反馈。 这比没有题更害人。
- 解法:每道题必须附 2–3 条 mutant(典型错误解),验证器逐条真跑——
每条 mutant 必须至少挂一条测试,任何 mutant 全绿即整体拒绝,报错直说
「测试有洞,必补」(
tools/validate.py,规则见CONTENT_SPEC.md第 6 节)。 mutant 是测试的测试。
- 坑:AI 出题时图省事,spec 里给出答案级代码;或 starter 直接写了一半答案。
- 后果:学习者复制粘贴 → 全绿 → 「学会了」的假象;题目失去区分度。
- 解法:三道闸:
- 测试对学习者隐藏:判题用的断言/用例不随题面下发,抄题面抄不到答案
(
server.py: /api/item只回n_tests数量,测试代码只在判题时进入沙箱)。 - 变体机制:模块门与遗忘回访抽的都是变体题(同技能、不同数据与场景),
背下原题答案无效(
server.py: start_gate随机抽 2 个变体)。 - 验证器查泄漏:spec 中出现参考解整行即拒(
CONTENT_SPEC.md验证项 7)。
- 测试对学习者隐藏:判题用的断言/用例不随题面下发,抄题面抄不到答案
(
- 坑:AI 写课文时「凭想象」写示例和输出——代码有笔误、输出是编的、报错是假的。
- 后果:零基础学习者没有辨别能力,照着错代码敲,第一次接触的概念就是错的, 且会以为是自己错了。
- 解法:验证器对课文代码块逐块真跑:普通代码块必须无错执行,块后输出块
必须与真跑输出逐字一致;标记为反例的块(
```<语言>-bad围栏)必须失败, 附带的报错文本同样真跑生成(tools/validate.py,规则见CONTENT_SPEC.md第 4.3 节)。
- 坑:刷题式学习一次通过就永久标记「完成」,两周后忘得干干净净。
- 后果:进度条 100%,能力 0%——遗忘曲线不吃「当年通过」这一套。
- 解法:每道题冷启动通过后,自动按 1 / 3 / 7 / 14 / 30 天 五档排期
变体回访(
server.py: RETENTION_STAGES+retention表):到期换一道同技能 变体题重新闭卷通过才算完成本轮复习(/api/retention/done校验变体确有 冷启动通过记录)。复习的不是原题,背答案无效。
- 坑:学习者看一眼提示、瞄一眼参考解、抄一下 AI 输出,然后标记自己「完成」。
- 后果:进度数据全是噪声;学习者带着幻觉进入下一模块,在更难处崩盘。
- 解法:进度只由证据解锁,学生无法自标完成:
- 冷启动门槛:零提示、未看过参考解、自己写出的代码跑绿,三条全满足才记「冷启动通过」
(
server.py: submit中cold判定;允许失败重试,但求助过就不再算); 看过参考解的题永远不算冷启动通过。 - 渐进披露:第 1 次失败只告知哪条挂;满 4 次失败才可解锁提示(共 3 条); 满 6 次或用完提示才可看参考解——想走捷径,先付出「自证不会」的代价。
- 模块门:模块全部题目冷启动通过后,随机抽 2 个变体限时闭卷重考,
全过才解锁下一模块(
server.py: start_gate/gates表)。
- 冷启动门槛:零提示、未看过参考解、自己写出的代码跑绿,三条全满足才记「冷启动通过」
(
- 坑:AI 按「逐模块交付」写课程文件,写文件的瞬间(半个 JSON)恰好赶上服务重启加载。
- 后果:一个未写完的文件让整个课程加载崩掉,服务起不来。
- 解法:加载器逐文件容错:course.json / module.json / c*.json 任何一个解析失败,
只跳过该文件并打日志,其余照常加载(
server.py: load_content)。 代价是「坏文件被静默跳过、题量变少」,所以配套终验全量:上线前tools/validate.py全量跑一遍,不允许「跳过的文件」混过交付。
- 坑:HTTP 服务默认绑
0.0.0.0,同网段任何人都能访问 API、污染你的学习记录。 - 后果:进度库被改、口令被爆破。
- 解法:口令门:在
config.json写{"code": "..."}后,所有/api请求 必须带X-Code头,比对用恒时比较hmac.compare_digest(防时序侧信道); 连续错 5 次锁 120 秒(429),防在线爆破(server.py: _gate_check)。config.json已列入.gitignore,口令不进仓库。
- 坑:静态服务器随手实现成「按请求路径拼目录读文件」,
GET /../server.py、GET /etc/passwd就把任意文件读走了。 - 后果:源码、配置、进度库全量泄露。
- 解法:白名单静态路由:引擎只服务两个固定路径(
/与/index.html, 映射到web/index.html),其余一律 404——请求里的用户输入从不参与文件路径拼接 (server.py: _static)。课文接口/api/lesson/<模块>/<课号>只接受已加载模块的 id 和课号索引,文件名来自内容侧的 module.json,同样不触用户输入。
- 坑:单文件前端迭代频繁,浏览器拿缓存的旧
index.html调新接口, 或旧 JS 配旧 API 字段。 - 后果:「我明明改好了,页面上还是老样子」的经典鬼故事,浪费排查时间。
- 解法:静态响应一律带
Cache-Control: no-cache(server.py: _static),每次都协商最新版本;API 响应带Content-Length与 UTF-8 charset,杜绝转码缓存歧义。
- 坑:func 模式要求提交「定义」,有学习者顺手写了
exit(),判题脚本跟着退出, 输出为空、报错丢失。 - 解法:加载学生代码时单独捕获
SystemExit,翻译成教学话术"代码里调用了 exit/quit——提交的应是定义,不该直接退出"(adapters/*_adapter.py判题脚本)。
- 坑:io 模式逐字比对 stdout,编辑器自动加的尾随空格、行尾差异造成 「逻辑全对却判红」。
- 解法:比对前做行级归一化:每行去尾随空白、整体去首尾空白
(
adapters/*_adapter.py: _norm)。宽容度仅止于此——内容本身逐字负责。
- 坑:Python 的 SQLite 连接绑定创建它的线程,并发请求共用连接直接抛异常。
- 解法:
threading.local()给每个线程独立连接(server.py: db), 建表幂等(CREATE TABLE IF NOT EXISTS),重启不丢进度。