一个训练营实验课思路:要求学生先列出 AI 助手的未声明假设(认证、队列、供应商),再写代码。旨在对抗「代码能跑就是对的」思维惯性,培养对 AI 填充细节的警惕性。
廉价的 AI 代码不是难点。未声明的假设才是。
学生输入"添加一个候补名单接口"。AI 返回了 JWT、Redis、Postgres 和一封欢迎邮件。看起来很有经验。实际上大部分都是虚构的。这个实验课先把猜測记录表打分,diff 排在第二位。
如果这听起来很刺耳,那就对了。培训班一直在交付从来没有机会成真的功能。
写这篇文章是因为,处理一个没有指定产品的 Pull Request 是一场评分噩梦。
需求文档只写了 POST /waitlist 接受一个 email。就这样。没有认证方案、没有服务商、没有队列。AI 模型把每个空白都填上了供应商。学生接受了这个故事,因为代码能编译。
AI 是带自信的自动补全。自信不是证据。这个实验课教一个机械习惯:在每个实质性假设被标记之前,不要写代码。
我们会用一个很小的 Node 校验器、一个冻结的需求文件、和一个四关卡评分标准。你可以在笔记本上跑完整套流程。如果你删掉下面可选的基础设施段落,实验课依然成立。
学生拿到一页纸。讲师不在 Slack 里"澄清"。模糊性就是重点。
产品需求(冻结版):
JSON body: { "email": string }
Response: 201 with { "ok": true }
允许使用内存存储
无登录、无计费、无邮件发送
这就是整个产品。其他都是猜测。
Fork 一个 starter,里面有只含健康检查的 server.js,外加 REQUIREMENTS.md(上面的需求)和一个空的 ASSUMPTIONS.md。
选任何一个你已经在用的编码 AI。付费的、本地的、浏览器标签页的,都行。
可选的基础设施,用于零账单 AI 循环:把 AI 对准 MonkeyCode。披露:本文是 MonkeyCode 产品推广的一部分。我在这里使用的唯一能力是免费模型访问和免费服务器选项。没有模型名、没有配额、没有硬件声明。如果这些对课程大纲重要,在发布日期之前到产品页面核实。
安装 Node 20+。评分器不需要额外的包。
node -v
npm init -y
node scripts/check-assumptions.mjs
如果校验器在空仓库上失败,这是正确的。空的假设日志是实验课不及格,不是空白画布。
产物:一份假设预算
学生必须把 ASSUMPTIONS.md 保持成这个形状:
# Assumptions
| id | claim | status | evidence |
|----|-------|--------|----------|
| A1 | POST /waitlist 接受 JSON `{ email }` | CONFIRMED | REQUIREMENTS.md |
| A2 | 允许使用内存存储 | CONFIRMED | REQUIREMENTS.md |
| A3 | 重复 email 应返回 409 | GUESSED | brief 里没有 |
| A4 | 我们应该发送一封确认邮件 | REJECTED | 超出范围 |
status 是一个封闭枚举:
CONFIRMED — 引用自 REQUIREMENTS.md 或书面讲师回答
REJECTED — 已考虑且明确超出范围
GUESSED — AI(或学生)填入了一个空白
预算规则:提交的代码只能依赖 CONFIRMED 行。GUESSED 行放在表里。它们不得作为分支、依赖或环境变量出现。REJECTED 行不是待办列表。它们是一道围栏。
为什么用表格而不是感性的架构备注?因为我能评分一个表格。我没法评分"我们思考了架构"。
校验器(在 CI 里跑)
保存为 scripts/check-assumptions.mjs。它故意很挑剔。把它当作实验课工具,而不是生产级 linter。
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const MAX_GUESSED_IN_CODE = 0;
const SMELLS = [
/jsonwebtoken/i,
/express-session/i,
/redis/i,
/nodemailer/i,
/sendgrid/i,
/mongoose|prisma|sequelize/i,
/postgres|mongodb/i,
/process\.env\.[A-Z0-9_]+/,
];
function read(file) {
return fs.readFileSync(path.join(ROOT, file), "utf8");
}
function parseAssumptions(md) {
const rows = [];
for (const line of md.split("\n")) {
if (!/^\|\s*A\d+/i.test(line)) continue;
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
if (cols.length < 4) continue;
rows.push({
id: cols[0],
claim: cols[1],
status: cols[2].toUpperCase(),
evidence: cols[3],
});
}
return rows;
}
function walk(dir, acc = []) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (["node_modules", ".git", "scripts"].includes(ent.name)) continue;
const p = path.join(dir, ent.name);
if (ent.isDirectory()) walk(p, acc);
else if (/\.(js|mjs|cjs|ts)$/.test(ent.name)) acc.push(p);
}
return acc;
}
const rows = parseAssumptions(read("ASSUMPTIONS.md"));
if (rows.length < 3) {
console.error("Need at least 3 assumption rows. Silence is not a design.");
process.exit(1);
}
const allowed = new Set(["CONFIRMED", "REJECTED", "GUESSED"]);
for (const r of rows) {
if (!allowed.has(r.status)) {
console.error(`${r.id} has illegal status ${r.status}`);
process.exit(1);
}
if (r.status === "CONFIRMED" && !/REQUIREMENTS\.md|instructor/i.test(r.evidence)) {
console.error(`${r.id} is CONFIRMED without evidence`);
process.exit(1);
}
}
const guessed = rows.filter((r) => r.status === "GUESSED");
const sourceFiles = walk(ROOT);
const smellHits = [];
for (const file of sourceFiles) {
const txt = fs.readFileSync(file, "utf8");
for (const re of SMELLS) {
if (re.test(txt)) smellHits.push({ file, re: String(re) });
}
}
if (smellHits.length) {
console.error("Infrastructure smells need a CONFIRMED or REJECTED row, not hope.");
for (const h of smellHits) console.error(` ${h.file} ~ ${h.re}`);
process.exit(1);
}
if (guessed.length > MAX_GUESSED_IN_CODE) {
// GUESSED rows are allowed in the table. They are not allowed to drive code.
// The smell pack above is the cheap proxy for "drove code."
}
console.log(
`ok: ${rows.length} rows, ${guessed.length} parked guesses, ${sourceFiles.length} source files`
);
像跑单元测试一样跑它:
node scripts/check-assumptions.mjs
echo $? # 0 是第二关评分的唯一及格分数
smell 列表是完整的吗?不是。它是一道教学围栏。把 nodemailer 改名为 mailer.mjs 然后照样调用 SMTP 的学生依然会在人工审核里挂掉。脚本存在是为了让明显的虚构死在 CI 里。
Checkpoint 0 — 冻结需求文档
把 REQUIREMENTS.md 粘贴到仓库里。不要编辑它。如果 AI 重写了需求文档,这一关自动零分。
为什么这么严格?因为"贴心"的改写就是候补名单变成增长技术栈的方式。
Checkpoint 1 — 代码前先写日志
在 server.js 添加路由之前,先在 ASSUMPTIONS.md 里填至少三行。单独提交那个文件。我要一个能证明日志先于代码的 git log。
向 AI 提一个无礼的 prompt 然后停在那里:
Read REQUIREMENTS.md. List every assumption you would need
to implement POST /waitlist. Tag each CONFIRMED, GUESSED,
or REJECTED. Do not write code. Do not add dependencies.
如果它依然打开一个 Prisma schema,那你是流程 bug,不是模型 bug。
Checkpoint 2 — 在预算内实现
添加 POST /waitlist。存储保留在一个模块级数组里。返回 201。除非有一个 CONFIRMED 行命名了它们,否则不要加额外的包。然后跑校验器。
合法的快乐路径看起来很无聊。无聊就是重点。
// server.js — 实验课的标注示例,不是框架推荐
import http from "node:http";
const waitlist = [];
const server = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/waitlist") {
const chunks = [];
for await (const c of req) chunks.push(c);
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "invalid_json" }));
return;
}
if (typeof body?.email !== "string" || !body.email.includes("@")) {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "invalid_email" }));
return;
}
waitlist.push({ email: body.email, at: Date.now() });
res.writeHead(201, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(404);
res.end();
});
server.listen(3000);
那个 @ 检查是不是发明了一个验证规则?是的。把它作为 GUESSED 停车,或者删掉它。看看习惯出现得有多快?
不用额外库做冒烟测试:
node server.js &
curl -sS -D - -o /tmp/wl.json \
-H 'content-type: application/json' \
-d '{"email":"dev@example.com"}' \
http://127.0.0.1:3000/waitlist
cat /tmp/wl.json
你要 201 和 {"ok":true}。其他任何结果要么是一个挂了的学生,要么是一份挂了的需求。不要通过加 Redis 来"修复"它。
Checkpoint 3 — 变动需求文档
讲师加一句话:重复 email 返回 409。学生必须:
把唯一性从 GUESSED 移到 CONFIRMED,evidence 指向新加的这句话
如果他们改了代码但没有碰表,即使 HTTP 行为正确也挂掉。我们在评分的耦合,不是状态码。
如果你的学员是多语言选手,加一个 Python 或 Go 的 smell 包。同一张表。不同的 walker。
用 lockfile diff 替换正则 smell:任何新依赖都需要一个 CONFIRMED 行来命名这个包。
录制一段 3 分钟的录像,学生和 AI 争论:"不要加 Redis。"把它作为 checkpoint 1 的证据。
把 REJECTED 行变成必须不通过的测试。一个被拒绝的欢迎邮件就是一个断言没有 SMTP 调用发生的测试。
公平评分标准(100 分)
自动零分:重写需求文档、提交 secrets、为了"完成"实验课添加付费 API 调用、或者删除校验器。
我不评分 prose 风格。我不评分候补名单看起来有多"生产就绪"。生产就绪就是我们得到假邮件管道的原因。
局限性(复制评分标准之前读这个)
这是一个教学协议。它不是架构审查委员会。
校验器是启发式的。聪明的学生可以把 SMTP 藏在 https.request 后面。这就是为什么 10 分留给人工。
假设表不是威胁建模。POST /waitlist 如果放到互联网上仍然需要防滥用思考。这个实验课应该留在 localhost。
免费模型访问和免费服务器选项可能会变。不要打印依赖单一供应商保持免费的课程目录承诺。保留一个本地模型备份。
纯英文表格会让部分学员吃亏。如果需要就翻译状态枚举。不要去掉枚举。
谁不应该用这个:已经写 ADRs 的 senior; capstone 团队与真实用户交流(他们需要发现,不是零猜测预算);任何把免费服务器当生产托管的人。
如果你的课是"构建任意内容",这个实验课会像是一个嘴套。用在第二周,不是第十二周。
我希望学生感受到的
AI 依然会猜测。那是它的工作。你的工作是让猜测可见、廉价、且可解雇。
跑校验器。故意失败一次。然后让模型列出假设然后停下。如果你时间不够,那个 prompt 就是整门课。
无论如何都把这个校验器偷走。如果你需要一个零账单盒子来做 AI 那半的练习,MonkeyCode 的免费模型访问和免费服务器选项是这个实验课保持大纲基础设施行为为空的方式。