Methodology for building new exam question generation pipelines from scratch. Use this skill when building a question generation system for ANY exam type — TOEFL reading/listening/speaking, IELTS, GRE, SAT, or any standardized test. Triggers on: "build a pipeline", "new question type", "出题管线", "生成管线", "add listening/speaking questions", "create question generator", or any task involving systematic exam question generation with AI.
本 skill 总结了从零搭建三套出题管线(CTW/RDL/AP)的完整经验,提炼成可复用的方法论。适用于任何标准化考试的新题型开发。
搭建出题管线不是"写一个 prompt 让 AI 出题"。它是一个数据驱动的工程流程:
真题采集 → 量化分析 → Profile 建模 → Prompt 工程 → 校验体系 → AI 审核 → 压力测试 → 迭代优化
每一步都有明确的输入、输出和质量标准。跳过任何一步都会导致最终出题质量不达标。
搞清楚"真题的味道到底是什么" — 把模糊的"感觉像真题"变成可量化的指标。这是整个管线最重要的阶段,深度决定了后续出题质量的上限。
1.1 题型规格确认
"题型名" sample questions ETS official、"题型名" 样题 真题 解析1.2 样题采集(广度搜集)
data/{section}/samples/{taskType}-reference.json1.3 初轮量化分析(基础统计)
对采集的样题做至少 8 个维度的量化分析:
| 维度 | 分析什么 | 输出指标 |
|---|---|---|
| 词汇 | 学术词覆盖率、词长分布、词频 | AWL 2.7%, avg 5.7 chars |
| 句法 | 被动语态、从句、句长变化 | passive 0.23/sent, CV 0.344 |
| 篇章 | 过渡词、模糊表达、定义模式 | hedging 0.93%, transitions 8.5/篇 |
| 题干 | 措辞模式、平均长度、开头词频 | "According to" 29% |
| 选项 | 长度平衡、语法平行、正确选项偏长比 | parallel 97%, longest 34% |
| 干扰项 | 每种题型的干扰项策略分布 | wrong_detail 31%, not_mentioned 29% |
| 结构 | 修辞模式、段落角色、主题句规律 | 100% topic sentence, 91% cohesion |
| 映射 | 正确答案与原文的改写策略、词汇重叠率 | factual 58%, inference 32% |
1.4 深度分析(ETS 味道拆解)
初轮统计只能告诉你"是什么",深度分析要回答"为什么这样设计"。以下是必须做的深度维度:
1.4.1 干扰项工程(Distractor Engineering)
逐题拆解每个干扰项的设计机制。不是笼统标注"这是个错答案",而是精确分类其错误类型和制造手法:
| 干扰项类型 | 机制 | 制造公式 |
|---|---|---|
| 语义联想陷阱 | 取 speaker/passage 关键词,围绕其关联概念造句 | 关键词 → 联想概念 → 合理句子 |
| 离题但合法 | 语法完美、独立成立、但和语境无关 | 在另一个对话/语境中完全合理的句子 |
| 答非所问 | 回答了另一类问题(问 where 答 when) | 识别问题类型 → 换一种类型回答 |
| 多义词陷阱 | 同一个词的不同含义 | 识别关键词的其他含义 → 围绕该含义造句 |
| 时态/语境错位 | 正确内容但时间框架或社交场合不对 | 正确概念 + 错误时态/场合 |
必须量化的指标:
实操方法:写分析脚本(如 scripts/analyze-{taskType}-samples.mjs),提取共享词汇、分类干扰项类型、统计分布。
1.4.2 正确答案范式(Answer Paradigms)
不要只看"正确答案是什么",要分析"正确答案WHY是对的"——它用了什么策略来正确回应。
以 LCR(听力选回应)为例,我们发现了 5 种正确答案范式:
| 范式 | 占比 | 难度 | 机制 |
|---|---|---|---|
| context_shift | 31% | Hard | 不回答字面问题,解决背后真正需求 |
| idiomatic | 25% | Med-Hard | 使用习语/固定搭配(I'm all ears) |
| counter_question | 19% | Medium | 用反问推进对话(How about tomorrow?) |
| marker_led_indirect | 19% | Medium | 话语标记 + 间接回应(Actually..., Well...) |
| direct_topical | 6% | Easy | 直接回答字面问题 |
关键发现:如果 AI 主要生成 direct_topical(直接回答),味道就完全不对。真题中 94% 的正确答案是某种形式的间接回应。
对每种题型都要:
1.4.3 选项间关系分析(Option Interplay)
4 个选项不是独立的——它们作为一个整体构成考查点。分析:
1.4.4 会话自然度模型(Conversation Naturalness)
对于涉及对话的题型(听力、口语),分析什么让一个回应"自然":
| 因素 | 描述 | 量化指标 |
|---|---|---|
| 会话动力 | 正确答案是否推进对话 | 31% 能引出对方下一句话 |
| 语域匹配 | speaker 和 answer 的正式度是否一致 | 69% 中性, 0% 正式 |
| 情感确认 | 对方表达困扰时是否先确认情绪 | 表达困扰 → 正确答案含 softener |
| 信息经济 | 回答是否刚好够用,不多不少 | 平均 5.7 词, max 10 |
| 话语标记 | 是否用 Actually/Well/Maybe 等信号词 | 37.5% 含话语标记 |
1.4.5 难度杠杆识别(Difficulty Levers)
找出控制题目难度的独立维度。每个维度都可以独立调节。
例如 LCR 的 4 个难度杠杆:
| 杠杆 | Easy | Medium | Hard |
|---|---|---|---|
| 正确答案直接度 | 直接给事实 | 间接但相关 | 完全不回答字面问题 |
| Word trap 强度 | 明显不对 | 看似合理 | 多义词陷阱 |
| Speaker 句子类型 | 明确特殊疑问句 | 是非/否定问 | 陈述句(需推断意图) |
| 习语要求 | 无 | 常见话语标记 | 需识别习语 |
1.5 ETS 味道公式(Flavor Scoring)
将所有分析浓缩为一个可量化的"味道分数":
flavor_score = Σ(marker_weight × marker_present) - Σ(anti_pattern_penalty)
每种题型定义 5-8 个加权 flavor marker 和对应的 anti-pattern。例如:
| Marker | 权重 | 目标值 | 检测方法 |
|---|---|---|---|
| 间接正确答案 | 25% | 40-50% of items | 分析正确答案是否直接回答字面问题 |
| Word trap 干扰项 | 20% | 80%+ of items | 检测干扰项与原文的词汇重叠 |
| 干扰项类型多样 | 15% | ≥2 种/题 | 统计每题的干扰项类型数 |
| 自然口语语域 | 15% | 60%+ 含缩写 | 检测 contractions |
| 建设性正确基调 | 10% | 100% | 正确答案是否帮助/推进 |
目标分数:≥0.70。低于此值说明生成质量与真题有显著差距。
1.6 差距分析(Gap Analysis)
在首轮 AI 生成后,对比生成结果与真题 profile 的差距:
| 差距 | 严重度 | 修复方向 |
|---|---|---|
| AI 正确答案都是直接回答 | 高 | 在 prompt 中强制 40-50% 间接回答 |
| 答案位置聚集 B/C | 高 | prompt 中预分配答案位置 |
| Word trap 质量不够 | 中 | 在 prompt 中给出 word trap 制造公式+实例 |
| 正确答案太长 | 中 | 设上限(如 ≤10 词) |
| 缺话语标记 | 低 | 要求 30%+ 含 Actually/Well/Maybe |
data/{section}/samples/{taskType}-reference.json — 结构化样题(含逐题干扰项分析)data/{section}/profile/{taskType}-ets-profile.json — 基础量化 profiledata/{section}/profile/{taskType}-deep-analysis.json — 深度分析(干扰项工程+答案范式+选项关系+会话自然度)data/{section}/profile/{taskType}-flavor-model.json — ETS 味道公式(加权指标+反模式+难度杠杆+设计清单)scripts/analyze-{taskType}-samples.mjs — 可重复运行的分析脚本lib/{section}Bank/profile.js — Profile 常量模块Step 1: WebSearch 搜集 → 16 道样题(ETS官方+TestSucceed+TOEFLResources+知乎+TestDaily)
Step 2: 存入 data/listening/samples/lcr-reference.json(含完整干扰项分析)
Step 3: 写分析脚本 scripts/analyze-lcr-samples.mjs 提取量化指标
Step 4: 基础 profile → lcr-ets-profile.json(9 维度量化)
Step 5: 深度分析 → lcr-deep-analysis-v2.json(干扰项工程+答案范式+选项关系+会话逻辑)
Step 6: 味道模型 → lcr-flavor-model.json(5个答案范式+4种干扰项+4个难度杠杆+15项检查清单)
Step 7: 差距分析 → 对比 AI 生成 vs 真题,识别 6 个具体差距并提出修复方案
写出能让 AI 生成"味道对"的题目的 prompt。
一个好的出题 prompt 包含 5 层约束:
Layer 1: 文本约束(长度、段落数、句式要求)
Layer 2: 风味约束(从 Profile 提取的量化指标)
Layer 3: 题目约束(题型分布、答案位置预分配)
Layer 4: 干扰项约束(每种题型的干扰项制造策略)
Layer 5: 反面约束(禁止第一人称、禁止反问句等)
原则 1:用 Example 而不是 Rule
原则 2:答案位置预分配
原则 3:干扰项策略要具体到百分比
原则 4:话题去重
function buildPrompt(count, opts = {}) {
const { excludeSubjects = [], rejectionFeedback = [] } = opts;
// 1. 话题选择 + 去重
const selected = selectTopics(count, excludeSubjects);
// 2. 答案位置预分配
const posPool = shufflePositions(count * questionsPerItem);
// 3. 组装 item specs
const itemSpecs = selected.map((s, i) => {
const positions = posPool.splice(0, questionsPerItem);
return `${i+1}. Topic: ${s.topic}/${s.subtopic}\n Questions: ${positions.join(", ")}`;
});
// 4. 拼接 prompt(约束层叠加)
return `${PASSAGE_REQUIREMENTS}\n${QUESTION_REQUIREMENTS}\n${DISTRACTOR_ENGINEERING}\n${itemSpecs}\n${EXCLUSIONS}\n${OUTPUT_FORMAT}`;
}
lib/readingGen/{taskType}PromptBuilder.js建立三级校验 + ETS 味道评分,自动拦截不合格的生成结果。
Level 1: Schema 校验(硬性错误 → 直接拒绝)
- 必填字段是否存在
- 数值范围是否合规(词数、题数、选项数)
- 枚举值是否有效(题型、答案位置)
Level 2: Profile 校验(软性警告 → 通过但标记)
- ETS 风味指标(hedging、passive、contrast)
- FK 可读性等级
- 选项长度分布
- 歧义风险检测(见下方 3.1)
Level 3: ETS Flavor 评分(0-1 分,加权量化)
- 基于 Phase 1 研究的味道公式
- 每道题算分,低于阈值标记警告
- 批次平均分用于整体质量判断
这是从 LCR 题库质审中学到的最重要教训。 103 道 AI 生成的题中有 7 道(6.8%)存在"干扰项其实也是合理回应"的问题。全部被人工发现后删除。
歧义检测的机械规则:
// 检测 1: "off_topic" 干扰项与 speaker 共享 ≥3 个内容词
const shared = sharedContentWords(speaker, distractor);
if (shared.length >= 3) warn("ambiguity_risk: shares keywords");
// 检测 2: "Do you know...?" 类问句 + 干扰项以 "Yes" 开头 + 包含话题词
if (/^do you know/i.test(speaker) && /^yes/i.test(distractor) && shared.length >= 1)
warn("ambiguity_risk: Yes + topic word");
// 检测 3: Where/When 问句 + 干扰项给了相关方位/时间信息
if (/^where/i.test(speaker) && /^it('s| is) (on|at|near)/i.test(distractor))
warn("ambiguity_risk: gives location for where-question");
常见歧义模式(从真实审核中总结):
| Speaker 模式 | 坏干扰项 | 为什么有歧义 |
|---|---|---|
| "Do you know which chapters...?" | "I haven't read the chapters yet either." | 表达同理心,是自然回应 |
| "Do you know if this journal is online?" | "I read it online yesterday." | 间接确认了"有线上版" |
| "Where's the best place to park?" | "It's on the north side." | 给了有用方位信息 |
| "Do you know when the shuttle leaves?" | "Yes, I know the schedule." | 自然的对话开头 |
| "Is the gym still open?" | "Yes, I was there yesterday." | 暗示gym是开放的 |
防御原则:每个干扰项在写完后必须过一次"现实对话测试"——如果有人在现实中这么回答,对方会不会觉得很自然?如果会,就不能用作干扰项。
对每道题计算加权味道分,用于量化"真题感":
function scoreFlavor(item) {
const scores = {};
scores.indirect_answer = isIndirect ? 1 : 0; // 权重 0.25
scores.word_trap = hasWordTrapDistractor ? 1 : 0; // 权重 0.20
scores.distractor_diversity = uniqueTypes >= 2 ? 1 : 0; // 权重 0.15
scores.natural_register = hasContraction ? 0.6 : 0; // 权重 0.15
scores.constructive_tone = isConstructive ? 1 : 0; // 权重 0.10
scores.plausible_distractors = allPlausible ? 1 : 0; // 权重 0.10
scores.length_neutrality = correctNotLongest ? 1 : 0; // 权重 0.05
return weightedSum(scores); // 0-1, target ≥ 0.70
}
lib/{examType}Gen/{taskType}Validator.js用 AI 作为"第二审核官",从两个角度验证题目质量:
题目质量问题不能只靠一层防御。经 LCR 实战验证,需要三层:
Layer 1: Prompt 防御(预防)
↓ 从源头减少问题生成
Layer 2: Validator 检测(预警)
↓ 机械规则标记可疑项
Layer 3: AI Audit(判定)
↓ AI 独立作答,最终裁决
→ 通过 / 拒绝
在 prompt 中加入真实失败案例,让 AI 知道哪些模式会导致题目被删除:
### AMBIGUITY PREVENTION (READ CAREFULLY):
The most common fatal flaw: a distractor that ALSO works as a valid response.
Before writing each distractor, ask: "If someone said this in real life,
would it be a reasonable reply?" If YES → rewrite it.
REAL FAILURES FROM OUR QUALITY AUDIT:
- Speaker: "Do you know which chapters we're supposed to read?"
BAD distractor: "I haven't read the chapters yet either."
← THIS IS A NATURAL RESPONSE, not a distractor!
- Speaker: "Do you know if the gym is still open?"
BAD distractor: "Yes, I was there yesterday."
← IMPLIES THE GYM EXISTS AND OPENS!
关键原则:用真实被删除的坏题做反面教材,比抽象规则有效 10 倍。每次人工审核发现新的歧义模式,都要回填到 prompt 中。
参见 Phase 3 的 3.1 歧义风险检测。机械检测不能替代 AI 审核,但能提前标记可疑项,在日志中醒目显示。
这是最关键的一层——AI 独立做题,检测答案是否唯一正确。
模式 A: 阅读/听力 MCQ(RDL/AP/LCR 等)
Pass 1: 独立作答 + 逐项评级
async function auditItem(item, callAI) {
const prompt = `Rate each option as valid/partially_valid/invalid,
then pick the SINGLE BEST response.
Speaker: "${item.speaker}"
A. ${item.options.A} B. ${item.options.B}
C. ${item.options.C} D. ${item.options.D}
Return JSON: { ratings: {A:..., B:..., C:..., D:...}, best: "X", reasoning: "..." }`;
const result = await callAI(prompt);
const parsed = JSON.parse(result);
const match = parsed.best === item.answer;
const validCount = Object.values(parsed.ratings).filter(r => r === "valid").length;
const ambiguous = validCount > 1;
return { match, ambiguous, ratings: parsed.ratings };
}
Pass 2: 不看原文猜题(仅阅读题型,验证可猜性)
| 题型 | 批量大小 | Audit 拦截率 | 典型问题 |
|---|---|---|---|
| RDL | 10 | 5-10% | AI 答案与标注不一致 |
| AP | 5 | 3-5% | 答案与原文不匹配 |
| LCR | 10 | 10-20% | 干扰项也是合理回应(歧义) |
| CTW | 10 | 0% | 机械挖空无需审核 |
LCR 的歧义问题是所有题型中最严重的(20%),因为对话回应天然有多种合理方式。这就是为什么 LCR 需要三层防御而不是仅靠 AI audit。
审核发现的问题要回流到前面的层:
AI Audit 发现新的歧义模式
→ 加入 Prompt 的反面案例(Layer 1)
→ 加入 Validator 的检测规则(Layer 2)
→ 更新 flavor model 的 anti-pattern(Phase 1 数据)
每次批量生成后,检查被 audit 拦截的题目,分析失败模式,更新管线。这个循环持续运转,管线质量会越来越好。
lib/{examType}Gen/{taskType}Auditor.js — 题型专用审核模块lib/readingGen/answerAuditor.js — 阅读题通用审核(可跨题型复用)串联以上所有模块的编排脚本。
1. 解析 CLI 参数 (--count, --difficulty, --skip-audit, --dry-run)
2. 从 staging 目录读取已有 subjects 做去重
3. 调用 PromptBuilder 构建 prompt
4. 调用 AI API(DeepSeek/GPT/Claude)
5. JSON 解析 + 部分响应 salvage
6. 逐 item 校验(schema + profile + quality)
7. 批次校验(答案分布、话题多样性)
8. [可选] AI 审核(移除 mismatch 的 item)
9. 保存到 staging 目录
10. 输出统计摘要
AI 的 JSON 输出经常被截断(尤其是大批量时)。必须有 salvage 逻辑:
function salvagePartialJson(text) {
const body = text.slice(text.indexOf("[") + 1);
const items = [];
let depth = 0, objStart = -1;
for (let i = 0; i < body.length; i++) {
if (body[i] === "{" && depth === 0) objStart = i;
if (body[i] === "{") depth++;
if (body[i] === "}") depth--;
if (body[i] === "}" && depth === 0 && objStart >= 0) {
try { items.push(JSON.parse(body.slice(objStart, i + 1))); } catch {}
objStart = -1;
}
}
return items;
}
scripts/generate-{taskType}.mjs| 测试项 | 目标 | 方法 |
|---|---|---|
| 通过率 | ≥60% | 生成 30 题,统计 accepted/total |
| AI 审核准确率 | ≥80% 一致 | 对 accepted 的题跑审核 |
| 答案分布 | 各 25%±10% | 统计 A/B/C/D 分布 |
| 话题覆盖 | ≥5 种不同话题 | topic breadth 分析 |
| 跨 item 相似度 | <20% Jaccard | pairwise 词汇重叠 |
| 用户体验 | 能正常做题 | 实际做一遍 |
| 问题 | 根因 | 解法 |
|---|---|---|
| 通过率太低 (20%) | 校验太严 or prompt 约束冲突 | 把 hedging/passive 从 error 降级为 warning |
| 全部 hard 难度 | 难度词表太窄 | 扩充 EASY_WORDS 列表 |
| 文章太短 | AI 把"easy"理解为"短" | 去掉 difficulty 参数,统一生成 medium |
| 答案偏 B | AI 默认偏好 | 在 prompt 中预分配答案位置 |
| 金额缺失 | prompt 说了但 AI 没听 | 在 item spec 里直接指定 "MUST INCLUDE $" |
| 话题重复 | 没有去重 | 传 excludeSubjects 到 prompt |
| JSON 截断 | 批量太大 | 限制 MAX_BATCH ≤ 10 |
| 干扰项太弱 | 未指定策略 | 按题型列出干扰项制造百分比 |
生成 → 统计 → 发现问题 → 改 prompt/validator → 再生成 → 再统计 → ...
一般需要 3-5 轮迭代才能稳定。
/reading?type={taskType} 渲染做题组件saveSess() 保存完整题目数据必须保存完整题目数据(不只是对错),这样练习记录才能显示原题回顾:
saveSess({
type: "reading",
correct: result.correct,
total: result.total,
details: {
subtype: "ap",
passage: item.passage, // 完整原文
questions: item.questions, // 完整题目+选项+解析
results: result.results, // 用户作答+对错
},
});
这是从写作模块真实线上事故中学到的教训。 用户花 7 分钟写完一篇邮件,提交评分后 AI 超时,退出页面后发现整条记录消失——作文内容彻底丢失,无法重新评分。
任何涉及异步 AI 评分(写作、口语)的任务都有以下丢失风险:
| 场景 | 触发条件 | 后果 |
|---|---|---|
| AI 评分超时 | DeepSeek 响应 >90s | catch 执行,但如果只在 success 分支调 saveSess(),记录丢失 |
| AI 评分解析失败 | 返回内容格式异常 | 同上,catch 路径没有保存 |
| 评分进行中关闭页面 | 用户关闭浏览器标签 | fetch 被 abort,catch 可能不执行 |
| 评分进行中路由切换 | 用户点"返回"或浏览器后退 | 组件卸载,async 函数被中断 |
| 网络断开 | fetch 失败 | catch 执行但用户可能已离开 |
Layer 1: catch 分支保存(评分失败时)
↓ 异常被捕获时立刻 saveSess({scoringFailed: true, userText, promptData})
Layer 2: beforeunload 保存(关闭标签页时)
↓ window.addEventListener("beforeunload", savePartialOnExit)
Layer 3: useEffect cleanup 保存(路由切换/组件卸载时)
↓ return () => { savePartialOnExit(); }
// 1. 追踪是否已保存(防重复保存)
const sessionSavedRef = useRef(false);
// 2. useEffect 同时挂 beforeunload + cleanup
useEffect(() => {
if (!persistSession) return;
function savePartialOnExit() {
if (sessionSavedRef.current) return; // 已保存则跳过
const hasText = text && text.trim().length > 10;
const needsSave = hasText && (phase === "scoring" || (phase === "done" && !fb));
if (!needsSave || !promptData) return;
sessionSavedRef.current = true;
saveSess({
type, score: null, band: null, wordCount: wc(text),
scoringFailed: true,
scoringError: "用户在评分完成前离开了页面",
details: { promptData, userText: text, feedback: null },
});
}
window.addEventListener("beforeunload", savePartialOnExit);
return () => {
savePartialOnExit(); // 组件卸载时也触发
window.removeEventListener("beforeunload", savePartialOnExit);
};
}, [persistSession, text, promptData, fb, type]);
// 3. 评分成功时标记已保存
if (persistSession) {
saveSess(successPayload);
sessionSavedRef.current = true;
}
// 4. catch 分支也保存 + 标记
catch (e) {
if (persistSession && promptData) {
saveSess({ ...partialPayload, scoringFailed: true, scoringError: errMsg });
sessionSavedRef.current = true;
}
}
// 5. 切换新题目时重置
sessionSavedRef.current = false;
失败记录必须包含足够的数据来支持后续重新评分:
{
type: "email",
score: null, // 未评分
band: null,
wordCount: 95,
scoringFailed: true, // 标记为失败
scoringError: "网络超时(90秒),请重试",
details: {
promptData: { ... }, // 完整题目(用于重新评分)
userText: "Dear ...", // 完整作文(用于重新评分)
feedback: null, // 无 AI 反馈
}
}
saveSess() 在 success 分支和 catch 分支都有调用beforeunload 事件监听器已注册useEffect cleanup 函数中调用了保底保存sessionSavedRef 防止重复保存promptData 和 userText(可重新评分)sessionSavedRef.current = falsepersistSession=false 时跳过所有保存(模考有自己的保存机制)persistSession=false,它们有自己的 deferred scoring 流程,不应触发独立保存。lib/{examType}Bank/
├── {taskType}Profile.js # Profile 常量
lib/{examType}Gen/
├── {taskType}PromptBuilder.js # Prompt 构建器
├── {taskType}Validator.js # 校验器
├── answerAuditor.js # AI 审核(可跨题型复用)
└── {taskType}Difficulty.js # [可选] 难度估算
scripts/
├── generate-{taskType}.mjs # 生成脚本
├── analyze-{taskType}-flavor.mjs # [可选] 味道分析脚本
└── audit-{taskType}-staging.mjs # [可选] 独立审核脚本
data/{examType}/
├── samples/{taskType}/ # 样题数据
├── profile/ # 分析结果
├── bank/ # 题库(部署后)
└── staging/ # 生成暂存区
| 指标 | CTW | RDL | AP |
|---|---|---|---|
| 开发周期 | 1 天 | 2 天 | 1.5 天 |
| 通过率 | 90% | 96-100% | 100% |
| AI 审核一致率 | 98% | 93% | 100% |
| 平均每批生成 | 10 题 | 10 题 | 3 题 |
| max_tokens | 4000 | 8000 | 8192 |
| 迭代轮数 | 5 轮 | 4 轮 | 2 轮 |
| 核心难点 | 文章太短 | 金额/时间缺失 | 干扰项质量 |