加一道题,改一天代码:题库即数据的降维打击

9 阅读9分钟

一套 JSON Schema,同时喂饱规则判题和未来的 AI 判题。

小学生也能看懂,前端同学可以放心转给后端。


一、加一道题,暴露了你的架构水平

产品经理走过来:“加一道手写防抖题,今天能上吗?”

1 年经验的小 A:打开判题文件,找到 if (questionId === 'js-debounce'),复制一段,改吧改吧。再加一道,再复制一段。加到第 10 道,文件 800 行,谁都不敢动。

10 年经验的老 B:打开题库 JSON,加一条(只展示关键字段):

{
  "id": "js-debounce",
  "type": "code",
  "title": "手写防抖"
}

提交,收工。判题引擎一行没改。

差距在哪?

不是老 B 打字快,也不是他会的框架多。而是他先问了一个问题:

题目,到底是代码,还是数据?

结论先行:

题目是数据,判题是引擎。数据描述“是什么”,引擎负责“怎么判”。

flowchart LR
    A["加一道题"] --> B{"题目存在哪?"}
    B -->|"写进代码"| C["改判题引擎"]
    C --> D["重测所有老题"]
    D --> E["加班发布"]
    B -->|"存成数据"| F["加一条 JSON"]
    F --> G["校验格式"]
    G --> H["收工"]

图 1 · 加一道题,两条路。


二、你写的不是题库,是一堆 if

把题目写进代码,一开始很爽。

第一道题,一个 if。第二道题,再来一个。第十道题,判题文件变成这样:

if (id === 'js-debounce') { ... }
else if (id === 'js-throttle') { ... }
else if (id === 'js-deep-clone') { ... }
// 还有 7 个 else if ...

看起来能跑。但它有三个致命问题:

  1. 加题要改引擎:每加一道题,判题引擎都要动刀。
  2. 改引擎要重测所有题:你永远不知道这次改动会不会碰坏老题。
  3. 知识全散在代码里:深拷贝为什么用 WeakMap、Promise.all 为什么要按顺序,全写在 if 里,AI 想用都用不上。

这不是题库。这是一座“特殊案例博物馆”。

最痛的是第三点。某天你改了一个公共判断,老题集体变红,你从晚上 8 点查到凌晨 1 点,最后发现是新加的 else if 把老题的判断截胡了。那一刻你会明白:这不是在维护题库,是在给历史擦屁股。

flowchart TD
    A["题目 A 的逻辑"] --> E["判题引擎"]
    B["题目 B 的逻辑"] --> E
    C["题目 C 的逻辑"] --> E
    D["题目 D 的逻辑"] --> E
    E --> F[&#34;加第 100 道题?<br/>继续往引擎里塞&#34;]
    style E fill:#fff1f0,stroke:#cf1322

图 2 · 题目越多,引擎越像一团毛线。

真正的做法是反过来:

引擎只认识“格式”,不认识“每一道题”。


三、把题目变成一张“点菜单”

去饭店点菜,菜单有固定栏目:菜名、价格、辣不辣、主要食材。

题库也一样。一道题必须有固定栏目,引擎才知道怎么处理。

栏目作用例子
id身份证,不能重复js-deep-clone
type题型:code / algorithm / theorycode
title题面手写深拷贝
difficulty难度 1~32
hint题目要求支持 Date、循环引用
functionName判题找哪个函数deepClone
testCases考卷见第四节
keyPoints理论题采分点见第六节

说明:标题里的“JSON Schema”,指的是“用结构约束 JSON 数据”这套思路;代码里实际用的是 zod。

我们用 zod 把这张表写成“带检查的模板”:

export const QuestionBaseSchema = z.object({
  id: z.string(),
  type: z.enum(['code', 'algorithm', 'theory']),
  title: z.string(),
  category: z.enum([...]),
  difficulty: z.union([z.literal(1), z.literal(2), z.literal(3)]),
  hint: z.string(),
})

export const CodeQuestionSchema = QuestionBaseSchema.extend({
  type: z.literal('code'),
  starterCode: z.string(),
  testCases: z.array(TestCaseSchema),
  functionName: z.string().optional(),
})

export const TheoryQuestionSchema = QuestionBaseSchema.extend({
  type: z.literal('theory'),
  keyPoints: z.array(TheoryKeyPointSchema),
  reference: z.string(),
})

三种题型,共用一张基础表,各自再加自己的栏目。

flowchart TD
    A[&#34;公共栏目<br/>id / type / title / 难度 / 题干&#34;] --> B{&#34;type?&#34;}
    B -->|&#34;code&#34;| C[&#34;+ 起始代码<br/>+ 测试用例<br/>+ 函数名&#34;]
    B -->|&#34;algorithm&#34;| D[&#34;+ 函数名<br/>+ 测试用例<br/>+ 超时&#34;]
    B -->|&#34;theory&#34;| E[&#34;+ 采分点<br/>+ 参考答案&#34;]

图 3 · 三种题型,一张基础表。

注意一个边界:加一种全新的题型,引擎当然要改;但加一道已有题型的题,只加数据。真正值钱的是:

让 99% 的日常加题,不动引擎。


四、两种考卷:直接调用是正门,脚本是后门

判题引擎要考你写的函数,有两种考法。

1. 直接调用型:能简单,就别复杂

{
  "id": "c1",
  "args": [{ "a": 1, "b": [1, 2] }],
  "expected": { "a": 1, "b": [1, 2] }
}

引擎找到 deepClone,把 args 传进去,拿返回值跟 expected 比。

优点:一眼看懂、人工好写、AI 好生成、不容易藏坏心思。

能用直接调用,就优先用直接调用。

2. 脚本型:有些题,简单数据真的考不了

比如深拷贝要证明“拷贝出来的 Date 还是 Date”:

{
  "args": [{}],
  "expected": [true, true],
  "script": "const d = new Date('2026-01-01T00:00:00Z'); const c = lib.deepClone(d); return [c instanceof Date, c.getTime() === d.getTime()];"
}

为什么不能写成 args: [new Date(...)]?

因为 JSON 里没有 Date。Date 一写进 JSON 就变成字符串,instanceof Date 永远是 false。考不出真本事。

再比如 Promise.all 的失败分支:

{
  "args": [],
  "expected": "boom",
  "script": "return lib.promiseAll([Promise.resolve(1), Promise.reject('boom')]).catch((e) => e);"
}

这段脚本要在参数里放 Promise、要捕获失败、还要把失败原因当成结果返回。直接调用型做不到——参数会被 JSON 复印,Promise 会变成空对象;失败会被判题器当成“代码报错”,没法跟 expected 比。

脚本型的规则是:引擎把用户交出来的整个工具箱 lib 递给脚本,脚本自己决定怎么用,最后 return 一个实际结果。

flowchart TD
    A[&#34;要考一个函数&#34;] --> B{&#34;参数和结果<br/>能用 JSON 写吗?&#34;}
    B -->|&#34;能&#34;| C[&#34;直接调用型<br/>args + expected&#34;]
    B -->|&#34;不能&#34;| D{&#34;需要 Date / 类实例<br/>Promise 参数 / 多次调用 / 副作用?&#34;}
    D -->|&#34;需要&#34;| E[&#34;脚本型<br/>script + lib&#34;]
    D -->|&#34;不需要&#34;| C

图 4 · 默认走正门,特殊需求才开后门。

一句话总结取舍:

直接调用是正门,脚本是后门。后门只给真正需要的人开。

每多一段 script,就多一段要写、要审、要维护的代码。能不用,就不用。


五、zod:脏数据别进门

题目变成数据后,新的灾难是:数据写错。

比如:

  • difficulty 写成 "很难";
  • type 写成 "codee";
  • 两道题用了同一个 id;
  • 理论题忘了写 keyPoints。

如果这些错误等到用户点“开始判题”才爆炸,排查起来就像在黑屋里找黑猫。

所以 Tigan 在题库门口放了一个检查员:zod。

const parsed = z.array(QuestionSchema).safeParse(bank.data)
if (!parsed.success) {
  console.error(parsed.error.errors)
  throw new Error(`题库 ${bank.name}.json 校验失败`)
}

只要有一项不对,整个题库不许进门,控制台直接告诉你哪个文件、哪一栏错了。

flowchart TD
    A[&#34;题库 JSON&#34;] --> B[&#34;zod 检查&#34;]
    B --> C{&#34;合格?&#34;}
    C -->|&#34;是&#34;| D[&#34;应用启动&#34;]
    C -->|&#34;否&#34;| E[&#34;打印错误<br/>直接不许启动&#34;]
    style E fill:#fff1f0,stroke:#cf1322

图 5 · 数据不对,就在门口拦住。

后端还有第二道门:发布前检查“能不能判”。

  • 理论题至少 1 个 keyPoints;
  • 代码题至少 1 个测试用例,或者一个 behaviorTest;
  • 有直接调用型用例,就必须知道函数名。

一句话:

错误要在离源头最近的地方爆炸。

脏数据进了门,后面全是加班。


六、keyPoints:一份采分点,两个老师用

理论题没有函数可跑,怎么判?

Tigan 的答案是:给每道理论题一份 采分点清单,叫 keyPoints。

"keyPoints": [
  {
    "point": "闭包 = 函数 + 它能访问的外部词法作用域",
    "synonyms": ["函数和其作用域", "闭包的定义"]
  },
  {
    "point": "用途:私有变量、函数柯里化、防抖节流、模块化",
    "synonyms": ["私有变量", "保存状态", "模块化"]
  }
]

注意:这里没有写“怎么匹配”,只写了“一个好答案应该覆盖什么”。

这就是最关键的解耦:

采分点负责说“考什么”,引擎负责决定“怎么判断有没有答到”。

同一份 keyPoints,现在有两个老师在用。

规则老师:不聪明,但快、免费、永远在线。它用三层办法判断“答到没有”:

  1. 整句包含;
  2. 关键词命中一半以上;
  3. 中文字符二元组重合一半以上。

最后算覆盖率,≥ 50% 算通过。

AI 老师:更聪明,但要花钱。它拿到的是同一份 keyPoints,被编成编号清单,按固定 JSON 交卷:

{
  "covered": [0, 2],
  "missing": [1, 3],
  "score": 50,
  "suggestion": "建议补充……",
  "confidence": "high"
}

后端还会再批改一遍:不存在的编号删掉;missing 不信 AI,直接用“全集减 covered”重算;分数夹在 0~100;confidence 只允许三个值。

flowchart TD
    A[&#34;同一份 keyPoints&#34;] --> B[&#34;规则老师&#34;]
    A --> C[&#34;AI 老师&#34;]
    B --> D[&#34;covered / missing / coverage&#34;]
    C --> E[&#34;covered / missing / score / suggestion&#34;]
    D --> F[&#34;同一张成绩单&#34;]
    E --> F
    E -. &#34;AI 失败&#34; .-> B

图 6 · 一份采分点,两个老师,同一张成绩单。

AI 老师请假,规则老师顶上,判题不中断。这就是“可降级”的底气。

为什么这件事值钱?

明年换了更强的 AI 模型:只改 AI 那一层。产品说“多考一个点”:只在 keyPoints 加一条,规则和 AI 自动都多检查一个点。

一份数据,两个消费者,零复制。

反过来,如果你把采分点抄进规则代码一份、再抄进 AI 提示词一份,迟早会出现“规则说答到了,AI 说没答到”的鬼故事。

别让任何一个引擎,独占题目的知识。

现在 AI 判的是理论题。将来让 AI 参与代码题判题,它读的还是同一份题目数据:题干、函数签名、测试用例、参考答案。数据不变,引擎慢慢长。


七、10 年和 1 年的分水岭

回到开头的小 A 和老 B。

差距不是工龄,是三个习惯。

习惯一:先定数据,再写引擎。

1 年经验先想“这个功能怎么写”;10 年经验先想“这个东西由哪些数据组成,谁产生,谁消费,会不会变”。

习惯二:在边界上校验,不让脏数据进门。

题库导入用 zod,接口入口用 zod,发布前再检查“能不能判”。

习惯三:一份数据,服务多个消费者。

规则判题、AI 判题、AI 提示助手,都读同一份 keyPoints。谁负责“怎么判”可以换,“考什么”只有一个答案。

flowchart LR
    subgraph ONE[&#34;1 年经验:每个引擎一份数据&#34;]
        A1[&#34;规则代码里的采分点&#34;] --> A2[&#34;规则判题&#34;]
        A3[&#34;AI 提示词里的采分点&#34;] --> A4[&#34;AI 判题&#34;]
        A1 -. &#34;改一边就对不上&#34; .-> A3
    end
    subgraph TEN[&#34;10 年经验:一份数据,多个引擎&#34;]
        B1[&#34;题库里的 keyPoints&#34;] --> B2[&#34;规则判题&#34;]
        B1 --> B3[&#34;AI 判题&#34;]
        B1 --> B4[&#34;AI 提示助手&#34;]
    end

图 7 · 复制数据,还是共享数据。

所以分水岭不是“会不会某个框架”,而是:

你能不能让变化只发生在数据层,而不是每次都推倒引擎。


八、五句话带走

  1. 题目是数据,不是代码:加题加 JSON,引擎不动。
  2. 三种题型共用基础表:type 决定还需要哪些栏目。
  3. 测试用例优先直接调用:Date、类实例、Promise 参数、多次调用、副作用,才用 script 注入 lib。
  4. zod 在门口检查:格式不对、缺栏目、id 重复,不许进门。
  5. keyPoints 只存一份:规则按它打勾,AI 按它评分,谁都能换,它不用换。

九、这套题库,长在一个真实的小系统里

它是我自己写的前端面试刷题系统,叫 题感 Tigan。

你刚看到的这些,都在里面真实跑着:

  • 三种题型的 JSON 题库,zod 导入时体检;
  • 代码题在一次性 Web Worker 沙箱里判题,支持直接调用和脚本两种考卷;
  • 理论题用 keyPoints 规则匹配,登录后再请 AI 补一份点评;
  • 后台从掘金抓文章,AI 提取题目、对比重复,再人工审核入库。

系统还在上线前的最后准备,上线后会免费开放。