变式题生成引擎:数组输出与显式重试的工程实战
一句话摘要:
VariantEngine针对"一次生成 3 道变式题"这一数组输出场景,用json_object=False规避格式冲突、用extract_json(is_array=True)解析数组、用"重试一次 + 报错带原文"兜底失败,成为五个引擎中容错设计最完整的一个。
一、引言
分析题是"把一道题讲透",变式题是"把一道题讲活"------同一知识点,换数字、换情景、加干扰,让学生真正掌握而非背题。第一性原理:变式训练的本质,是在"知识点不变"的前提下,对"考察角度"做系统扰动。
「智学文枢」把这种扰动交给了模型:给定原题 + 知识点 + 答案 + 解题步骤,要求一次生成 3 道难度分别为 -1 / 0 / +1 的变式题。这个需求第一次给 AI 服务层带来了三个新挑战:
- 输出结构变了 :不再是单个 JSON 对象,而是 JSON 数组 ------
chat_json的json_object强制模式与数组输出天然冲突; - 失败代价变了:解析失败意味着"一道变式都存不了",不像分析失败还能兜底落库;
- 数据源变了 :知识点和解题步骤不是从界面拿的,而是从之前 AI 分析的落库结果里反查的。
services/ai/variant_engine.py 的 VariantEngine 逐个解决了这三个问题,是五个引擎中"容错设计最完整"的一个。
二、核心原理
2.1 数据依赖链:先分析、后变式
变式生成不是独立操作,它强依赖分析结果:
VariantRepository AIClient AnalysisRepository VariantEngine VariantRepository AIClient AnalysisRepository VariantEngine #mermaid-svg-OFhV1hgetTGwdO2o{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OFhV1hgetTGwdO2o .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OFhV1hgetTGwdO2o .error-icon{fill:#552222;}#mermaid-svg-OFhV1hgetTGwdO2o .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OFhV1hgetTGwdO2o .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OFhV1hgetTGwdO2o .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OFhV1hgetTGwdO2o .marker.cross{stroke:#333333;}#mermaid-svg-OFhV1hgetTGwdO2o svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OFhV1hgetTGwdO2o p{margin:0;}#mermaid-svg-OFhV1hgetTGwdO2o .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OFhV1hgetTGwdO2o text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-OFhV1hgetTGwdO2o .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OFhV1hgetTGwdO2o .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-OFhV1hgetTGwdO2o .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-OFhV1hgetTGwdO2o .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-OFhV1hgetTGwdO2o #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-OFhV1hgetTGwdO2o .sequenceNumber{fill:white;}#mermaid-svg-OFhV1hgetTGwdO2o #sequencenumber{fill:#333;}#mermaid-svg-OFhV1hgetTGwdO2o #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-OFhV1hgetTGwdO2o .messageText{fill:#333;stroke:none;}#mermaid-svg-OFhV1hgetTGwdO2o .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OFhV1hgetTGwdO2o .labelText,#mermaid-svg-OFhV1hgetTGwdO2o .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-OFhV1hgetTGwdO2o .loopText,#mermaid-svg-OFhV1hgetTGwdO2o .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-OFhV1hgetTGwdO2o .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OFhV1hgetTGwdO2o .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-OFhV1hgetTGwdO2o .noteText,#mermaid-svg-OFhV1hgetTGwdO2o .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-OFhV1hgetTGwdO2o .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OFhV1hgetTGwdO2o .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OFhV1hgetTGwdO2o .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OFhV1hgetTGwdO2o .actorPopupMenu{position:absolute;}#mermaid-svg-OFhV1hgetTGwdO2o .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-OFhV1hgetTGwdO2o .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OFhV1hgetTGwdO2o .actor-man circle,#mermaid-svg-OFhV1hgetTGwdO2o line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-OFhV1hgetTGwdO2o :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 取 core_concept 作为知识点取 step_by_step 作为解题步骤 alt解析失败 get_by_question(question.id)analysis 记录build_variant_prompt 组装chat_json(json_object=False, temp=0.4)响应文本extract_json(is_array=True)重试一次 chat_json再解析,仍失败则抛 ValueError(带原文)batch_create(variants) 批量落库
这条链决定了业务约束:必须先有分析记录,才能生成有质量的变式(没有步骤,模型只能凭空编)。引擎对此的容错是:知识点没传时从分析结果补取,分析也没有就退回"(未标注)"。
2.2 数组 vs 对象:json_object 冲突的解法
第 28 篇埋下的伏笔在这里揭晓:chat_json 默认 json_object=True 时请求体会带 response_format={"type": "json_object"},这个模式强制模型输出单个 JSON 对象 ;而变式要的是数组 。若保持默认,模型要么输出被截断的对象、要么把数组硬包成一个对象,解析必然出问题。解法就是显式传 json_object=False------格式开关必须与业务输出结构对齐。
2.3 重试策略:为什么这里敢重试
分析引擎不重试(第 32 篇),变式引擎却重试一次,因为失败代价不同:
| 场景 | 失败后果 | 重试策略 |
|---|---|---|
| 分析 | 已有兜底空记录,UI 提示重试 | 不重试(省成本) |
| 变式 | 3 道题一条都存不了 | 重试一次(值得多花一次调用) |
而且重试后仍失败时,抛出的 ValueError 携带原始响应前 200 字------UI 能直接展示"模型到底回了什么",老师可以据此判断是模型问题还是题目太偏。
三、代码实现
3.1 数据源反查:分析结果即上下文
python
# 源码位置:services/ai/variant_engine.py L29-L47
# 尝试从AI分析结果获取知识点
if not knowledge_point:
analysis = self.analysis_repo.get_by_question(question.id)
if analysis and analysis.core_concept:
knowledge_point = analysis.core_concept
# 获取解题步骤
step_by_step = []
analysis = self.analysis_repo.get_by_question(question.id)
if analysis and analysis.step_by_step:
step_by_step = analysis.step_by_step if isinstance(analysis.step_by_step, list) else []
# 构建Prompt
messages = PromptTemplates.build_variant_prompt(
question_text=question.question_text,
knowledge_point=knowledge_point,
correct_answer=question.correct_answer or "",
step_by_step=step_by_step,
)
注意 isinstance(analysis.step_by_step, list) 这层防御:step_by_step 列在 SQLite 里是 JSON 列,历史数据可能存了字符串或 NULL,不校验类型直接传给模板会得到 "无步骤" 甚至崩溃。
3.2 数组调用与重试
python
# 源码位置:services/ai/variant_engine.py L49-L71
# 调用AI前,记录完整提示词供核对组成
log_prompts("AI变式", messages, temperature=0.4)
# 变式需输出数组,禁用 json_object 强制对象模式,避免解析冲突
raw_response = self.client.chat_json(
messages, temperature=0.4, json_object=False
)
# 解析JSON数组
parsed = extract_json(raw_response, is_array=True)
# 解析失败时重试一次
if not parsed or not isinstance(parsed, list):
raw_response = self.client.chat_json(
messages, temperature=0.4, json_object=False
)
parsed = extract_json(raw_response, is_array=True)
if not parsed or not isinstance(parsed, list):
snippet = (raw_response or "").strip().replace("\n", " ")[:200]
raise ValueError(
"AI未返回可用的变式题JSON,重试后仍失败"
f"(原始响应:{snippet})。"
)
三处设计:temperature=0.4 给创造性留空间(比分析高、比追问低);isinstance(parsed, list) 双重校验(extract_json 返回 None 或对象都算失败);报错信息把原文换行压成空格再截 200 字------日志友好 + 可读性兼顾。
3.3 批量构建与落库
python
# 源码位置:services/ai/variant_engine.py L74-L91
# 构建Variant对象列表
variants = []
for item in parsed[:3]: # 最多取3道
v = Variant(
original_question_id=question.id,
variant_text=item.get("variant_text", ""),
knowledge_point=item.get("knowledge_point", knowledge_point),
difficulty_delta=item.get("difficulty_delta", "0"),
correct_answer=item.get("correct_answer", ""),
solution_steps=item.get("solution_steps", []),
hint=item.get("hint", ""),
)
variants.append(v)
# 批量存入数据库
if variants:
self.variant_repo.batch_create(variants)
return variants
parsed[:3] 是"防贪婪截断":模型偶尔输出 5-6 条,只取前 3 保持"难度 -1/0/+1"的结构完整;item.get("knowledge_point", knowledge_point) 让缺失知识点的条目继承原题知识点;difficulty_delta 从字符串 "-1"/"0"/"+1" 原样存储(模板要求字符串),UI 据此渲染难度标签。batch_create 一次性事务写入,避免 3 条逐条提交的 I/O 开销。
四、设计权衡 / 踩坑记录
1. json_object=False 是最关键的一行。 首版没关 json_object,实测模型要么返回"对象套数组"({"variants": [...]},解析器取不到数组)、要么输出被 400 拒收。传 False 后问题消失。这个坑的教训:强制格式模式是"单对象假设"的,凡输出数组的业务必须显式关掉 ------它是第 28 篇 chat_json 签名里 json_object: bool = True 参数存在的全部意义。
2. 为什么重试只一次? 重试第二次的成功率边际递减(模型对同一提示词的失败模式往往相同),而成本和延迟线性翻倍。一次重试是"成本-收益"的平衡点;若用户仍失败,错误信息里的原文片段已经能支撑"换个思路重生成"。
3. difficulty_delta 用字符串还是数字? 模板要求输出 "-1"、"0"、"+1" 字符串,是因为 JSON 里 0 与 -1 若是数字,"+1" 无法表达(+1 不是合法 JSON 数字字面量)。统一字符串保证三档难度表示一致,UI 侧 int() 转换或按字符串映射标签都行------为表达一致性牺牲类型纯度。
4. 为什么变式引擎不接 config 参数? 分析引擎接收合并后的配置(第 30 篇),变式引擎的 generate(question, knowledge_point) 却不接收------因为变式的"个性化"体现在原题与知识点本身,而不是分析偏好开关。接口按业务需要裁剪,不为了形式统一而传无用参数。若将来要支持"变式难度偏好"配置,再在签名上扩展即可。
五、小结
VariantEngine 为 AI 服务层补上了两块此前没有的拼图:
- 数组输出范式 :
json_object=False+extract_json(is_array=True)+parsed[:3]截断,让"一次生成多条"成为可能; - 显式重试范式 :以失败代价决定重试与否,重试仍败则抛"带原文"的
ValueError,把决策依据交还给用户。
它与分析引擎的差异,本质上是业务失败成本的差异在架构上的投影:分析可以"烂尾"(兜底落库),变式必须"交付"(重试到底)。下一篇的课堂教学引擎(第 35 篇)将走另一条路线------不再解析 JSON,而是直接消费自然语言讲解词,让我们看看"面向人输出"与"面向机器输出"在引擎设计上的分野。