上一篇介绍了成品(把 523 课的 AI 工程课程,做成一个能闯关的进度站),这一篇记录怎么做出来的——包括一个只有靠真实测试才抓得到的逻辑漏洞,和我踩过的几个”看起来成功其实没验证”的坑。

成品在这里:https://nova02640.github.io/ai-engineering-quest/


一、选题:先确认”不该做什么”

项目起点是一套很对我胃口的开源课程:AI Engineering from Scratch,523 课、20 个阶段、每课都要求你手写一遍算法并留下一个可复用产物。

我最初的想法和大多数人一样:做个学习网站。但动手之前先去读了上游仓库,结论立刻变了——上游已经有一个功能完整的网站:523 课在线渲染、每课课后小测、本地进度记录、交互式图表、术语表、学习路径、认证备考题库、12 种语言界面,而且仓库每天都有提交。

如果我照着做,结果只会是两个问题:功能更少,且要永远追着上游跑。

于是任务定义从”做一个学习网站”改成了”做一个上游没有的东西”。 把上游的功能清单逐条列出来对照之后,剩下的空白很清楚:

上游已有 上游没有
每课四个勾选框(read/built/ran/evidence) 跨关卡的全局进度视角(等级、阶段通关、徽章)
evidence 只记录”已勾选”的时间戳 命令 / 退出码 / 输出 / 产物的证据留存
随堂测验由 AI 运行时生成 静态题库(可离线、可重复、可回归测试)
阅读体验完整 把”每课交付一个产物”这句话变成作品集

定位就这么定下来了:不做课程,做课程的记录层。


二、架构:薄层,而不是镜像

第二个决定同样重要:课程正文一个字都不进我的仓库。

站点只取元数据——标题、序号、路径、类型、语言、学习目标、outputs/ 与 code/ 的文件清单;所有阅读和练习深链到官方原文。

这样做的代价是站内阅读体验不完整(要跳出去看讲义),换来的是:

  • 维护成本极低:上游每天改正文,我的站不受影响
  • 版权性质完全不同:引用元数据 + 深链是工具;搬运全文是转载
  • 可一键切割:上游若改许可,删元数据即可

页面结构也随之变得很轻——四个静态页 + 一个本地存档引擎:

1
2
3
4
5
6
7
index.html        闯关地图(20 阶段 / 523 关 + HUD)
lesson.html 关卡页(四步打卡 / 小测 / 作品登记)
boss.html 阶段 Boss 关(12 题)
portfolio.html 作品集(导出 HTML / Markdown / JSON)
assets/store.js 存档引擎(localStorage + 导入导出)
data/course.json 523 关元数据
data/quiz.json 题库

没有构建步骤:纯静态文件直接由 GitHub Actions 上传,失败面比任何构建型站点都小。


三、数据管线:只取我需要的字段

元数据分两层拿:

全量地图(20 阶段 / 523 关):用 GitHub Tree API 一次拿全量文件路径,从路径推导出阶段与关卡结构;再解析上游 README.md 里的关卡表格,拿到每关的标题、类型(Learn / Build)和语言。523 关里有 438 关能从表格解析到,剩下的 85 关全是最后的 capstone 阶段(不在表格里),用路径名兜底即可。

深做阶段的细节:只为要深做的那个阶段逐关取 docs/en.md,提取一级标题和 ## Learning Objectives 列表。31 关约 492 KB,几十秒拿完。

这里有个小取舍值得一说:我一度想直接下整个仓库的压缩包,但网络受限环境下镜像通道给的包是截断的(两次分别拿到 9.2MB 和 4.6MB,都不是完整包)。与其和下载较劲,不如只取真正需要的字段——31 个小文件的元数据,比 46MB 的整仓依赖更可靠。


四、玩法:口径要照抄课程,不要自创

游戏化的诱惑是自创一套规则(经验值、等级、成就系统随便设计)。但这里有个约束:这套课程自己已经有一套明确的方法论——读 → 写 → 跑 → 存证,而且它的讲义里就是把 evidence 当成学习闭环的终点。

所以我把关卡的通关口径直接映射到这四步,而不是另造一套:

动作 XP
读完讲义 10
手敲并跑通代码 25
从仓库根执行本关命令 20
留下证据 30
每题答对 15

每 250 XP 一级;阶段全部通关解锁 Boss 关(12 题,通过线为三分之二);证据登记进作品集,可导出。

规则贴合课程本身,用户就不需要在两套心智模型之间切换——这是我在这个项目里最满意的一个决定。


五、验证:让”我觉得能跑”变成”实测能跑”

前端项目最容易糊弄自己的地方是:打开页面点两下,觉得没问题,就发布。 这次我给自己定了四条硬规矩。

1. 用 Node 在服务器上真跑前端逻辑

纯前端逻辑通常是”要开浏览器才能测”的,但其实只要把浏览器环境 stub 掉,就能在服务器上直接跑:

1
2
3
4
5
6
7
8
9
10
11
// tools/test-store.mjs 的核心几行
globalThis.localStorage = {
getItem: (k) => (k in mem ? mem[k] : null),
setItem: (k, v) => { mem[k] = String(v); },
removeItem: (k) => { delete mem[k]; }
};
globalThis.window = globalThis;

new Function(fs.readFileSync('assets/store.js', 'utf8'))(); // 真跑业务逻辑
const Store = globalThis.Store;
// 然后直接断言:通关判定、XP 计算、徽章、导入导出……

这套 39 条断言的回归测试,抓出了一个真 bug:

原来的通关判定,是靠”这一关有没有作答记录”来判断它有没有题库。于是出现了一个荒诞的结果——有题库的关卡,只要一道题都不答,四个勾打完就算通关。题库门禁形同虚设,而且从界面上完全看不出来(四勾显示完整、状态显示已通关)。

修法是让加载层把题库索引显式注册给存档层,判定优先级变成「注册索引 > 本机作答记录」:

1
2
3
4
function hasQuizFor(path, l) {
if (Object.prototype.hasOwnProperty.call(quizIndex, path)) return quizIndex[path] > 0;
return !!(l && Object.keys(l.quiz).length); // 退化路径:本机是否作答过
}

这个 bug 不写测试是发现不了的——它只在”用户从不答题”这条路径上出现,而开发者自己测试时一定会去点那两道题。

2. 数据一致性测试

第二个测试文件专门校验数据:题库的每个 key 都是真实关卡路径、每关题目数量、ID 唯一性、选项数、答案下标范围、题干语言、以及课程数据与题库的对应关系。480 条断言,全绿才允许提交。

3. 线上逐字节校验,并识别”截断误报”

部署完的验收不能只看首页 200。我的做法是把关键文件在线上取回来,和本地比 sha256。

第一次校验就出现了一个可疑数字:data/course.json 线上读到 99681 字节,本地是 162046 字节。

看起来像产物损坏,但这其实是 CDN 单次传输截断的经典指纹。判定口径有三条:

  • 连续多次取回,看大小是否每次都不一样(截断的特征)
  • wget -c 续传后比对 sha256
  • 用 curl -r 0-0 -D - 读响应头的 content-range: bytes 0-0/162046,直接拿到真实总长

复核结果:重取四次全部 162046 字节,sha256 与本地完全一致,JSON 也解析正常(20 阶段 / 523 关都在)。问题在取数,不在产物。 如果当时直接下结论”文件坏了”,就会白查一轮。

4. 不要相信”子代理”自报的数字

题库我拆成三个并行的 AI 子任务分工出题,每个任务完成后回报:”共 26 题 / 24 题 / 20 题”。

合并前我按 schema 自己复算了一遍:实测是每关正好 2 题,总共 62 题。

三个数字全是错的(它们把自己的计数算重了)。如果我直接引用自报数字写进 README,就会把一个假数据固化进项目文档。凡是子任务交付结构化数据,合并前必须自己复算:覆盖度、条数、唯一性、字段范围,一个都不能省。


六、版权与边界

上游是 MIT 许可,允许复用,但”允许”不等于”可以随便抄”。这个项目的处理是三件事:

  1. 署名齐全:仓库 ATTRIBUTION.md + 站点页脚 + 首页「关于」区,三处都写明上游仓库、作者、MIT 许可原文
  2. 零正文镜像:只引元数据,深链原文,不复制图表、图片、界面文案
  3. 不冒名:不使用上游的名称和 Logo 作为自己的品牌(MIT 覆盖代码,不覆盖商标),并明确声明本项目与上游没有官方关系

另外一个细节:题库是我自己出的(上游的随堂测验是运行时生成的,没有静态题库)。出题依据是公开讲义,但题目和解析都是转述,不是抄录。合并前我做了一次机械检查——把每道题的文本和讲义原文做”连续 N 字符重合”扫描,命中的三处全部是英文 API/代码片段(函数签名、HTTP 响应头这类),属于必须原样出现的技术术语,不是复制正文。


七、隐私与安全:写公开技术文章的自律

这篇是开发记录,很容易顺手写出不该写的东西。发布前我按清单逐项过了一遍,规则是:

不出现的:

  • 内网 IP、主机名、机器别名
  • 内部存储路径(挂载点、卷目录这一类)
  • API key / token / 密钥 / 任何服务凭据与访问链接
  • 内部工具名、任务日志、会话内容

泛化处理的:

  • 环境叙述只说”一台内网 Linux 主机””服务器”,不写型号、地址、主机名
  • 涉及网络受限时,只描述方法与结论(走哪个 API、为什么选小文件而不是整包),不写代理方案细节
  • 配置示例一律用占位符(<token>、<host>),绝不贴真实值

顺手做的一次历史检查: 我扫了一遍已发布的老文章,确认此前写过的网络类文章里只有通用配置,没有凭证、服务地址或内网信息——没有泄露。这个检查值得定期做,因为”当时看起来无害的配置片段”,很多是事后才意识到敏感的。


八、反思

做对的两件事:

  1. 先查上游再动手。如果一开始就闷头做”学习网站”,两天的成果就是一个注定要废弃的镜像站。
  2. 口径照抄课程。不自创规则,用户不需要在两套心智模型之间切换。

做得不够的一件事:

形态这件事,我其实是先做完再给使用者看的。更省钱的做法是先做一个阶段的小样(一个阶段 20~30 关,真实内容 + 完整交互),确认形态和信息密度之后再铺量——这个项目里我只深做了一个阶段就停手,还算踩在线内,但下一个项目我打算把这个顺序固定下来。


小结

如果你也在做同类项目,这篇里最值得拿走的大概是两条:

  • 上游已经有的,不要重做;把上游的功能清单列出来,差异化的空间自然浮现
  • 纯前端逻辑也可以有真回归:stub 掉浏览器环境,在服务器上跑断言,它救我的那一次就是最好的理由

站点:https://nova02640.github.io/ai-engineering-quest/