课堂教学引擎:把分析结果翻译成"讲给人听"的话
一句话摘要:
TeachingEngine不走 JSON 解析路线,而是把 AI 分析结果、学生姓名、VARK 学习风格、讲解语气逐层翻译成自然语言讲解词,并在 System 消息中强制注入 LaTeX 书写规范,解决"模型会做题但不会讲题"的问题。
一、引言
分析引擎回答"学生错在哪",讲解引擎回答"老师该怎么讲"——这是两个完全不同的任务。
第一性原理:"会做题"和"会讲题"是两种能力。一个模型可以把一道函数题算得明明白白,但让它"像老师一样讲给学生听",它可能会:开头就来一句"显然可得"(学生一脸懵)、全程抽象术语轰炸(视觉型学生完全抓不住)、公式写得七零八落(前端 LaTeX 渲染直接崩)。
「智学文枢」的课堂教学引擎 services/ai/teaching_engine.py 是五个引擎里最"人格化"的一个——它的输入不仅是题目,还包括学生是谁(姓名)、学生怎么学(VARK 学习风格)、老师怎么讲(语气策略、举例偏好、私教备注),输出则是一段可以照着读的讲解词。它有三点与其他引擎的显著分野:
- 不解析 JSON:讲解词是给人看的,
chat直接返回文本,无需extract_json; - 配置即人设:语气、举例、学习风格三套策略表,把离散配置值翻译成连续的自然语言指令;
- 公式规范硬注入:System 消息里追加一段"公式书写规范",从源头约束模型输出可渲染的 LaTeX。
二、核心原理
2.1 三层翻译:从配置到讲解策略
_translate_preferences(config) 是讲解引擎的"人设翻译器":配置字典里的离散值(如 teaching_tone="encouraging")经三张策略表映射为具体的讲解指令:
注意 _TONE_STRATEGY / _EXAMPLE_STRATEGY / _LEARNING_STYLE_STRATEGY 三张表是模块级常量(写死在代码里),而 teaching_tone 等值来自第 30 篇合并的配置——配置决定选哪套策略,代码决定策略长什么样,两者解耦。
2.2 VARK 学习风格的注入
learning_style 字段来自学生分科配置(第 30 篇 L2 层把 teaching_learning_style 合并为 learning_style),取值范围 visual / auditory / kinesthetic / mixed 对应 VARK 模型。它解决了教学法的核心问题:同一个知识点,给不同认知通道的学生讲,方式必须不同——视觉型要"想象这个图形",动觉型要"在草稿纸上跟着写"。
2.3 LaTeX 规范硬注入
_LATEX_FORMATTING_RULES 是一段专门约束公式书写的规则文本,追加到 System 消息末尾(若已有则不重复追加)。它针对实战中反复出现的四类公式病:开标签损坏(\( 写成 \2)、括号错乱((2-x) 漏右括号)、公式内换行、公式内混中文标点。把渲染层的约束前置到生成层,比前端容错更治本。
三、代码实现
3.1 主流程:组装与调用
# 源码位置:services/ai/teaching_engine.py L45-L91
def generate(self, question, analysis, grade: str = "",
student_id: str = None, config: dict = None) -> str:
"""生成讲解词
Args:
question: Question ORM对象
analysis: AIAnalysis 对象(用于提取分析摘要)
grade: 学生年级
student_id: 学生ID(用于取姓名与偏好)
config: 已合并的Prompt配置(含 tone/example_preference/custom_notes/custom_prompt)
"""
config = config or {}
student_name = self._get_student_name(student_id)
strategy = self._translate_preferences(config)
summary = self._extract_summary(analysis)
# 讲解配置来源:config(全局→学生·学科 合并) 优先,空则回退代码常量
user_template = config.get("teaching_user") or TEACHING_USER_DEFAULT
user_prompt = self._safe_format(
user_template,
grade=grade or "未知",
subject=question.subject or "",
question_text=question.question_text or "",
correct_answer=question.correct_answer or "",
student_preferences=strategy,
analysis_summary=summary,
)
if student_name:
user_prompt = f"【学生姓名】{student_name}\n" + user_prompt
system_prompt = config.get("teaching_system") or TEACHING_SYSTEM_DEFAULT
if _LATEX_FORMATTING_RULES not in system_prompt:
system_prompt = (system_prompt.rstrip() + "\n\n" + _LATEX_FORMATTING_RULES).strip()
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
# 调用AI前,记录完整提示词供核对组成
log_prompts("AI讲解", messages, temperature=TEACHING_TEMPERATURE)
return self.client.chat(
messages, temperature=TEACHING_TEMPERATURE
).strip()
五个细节:_get_student_name 查库拿姓名(讲解词里"小明,这一步你想得很对"比冷冰冰的"你"更有温度);strategy 是偏好翻译结果、summary 是分析摘要(两者都插进 user 模板的占位符);if student_name: 把姓名拼在 user 消息最前面;_LATEX_FORMATTING_RULES not in system_prompt 防重复注入;最后 .strip() 裁掉模型爱加的前后空行。
3.2 策略翻译器与摘要提取
# 源码位置:services/ai/teaching_engine.py L112-L157
def _translate_preferences(self, config: dict) -> str:
"""把偏好翻译为可执行的讲解策略"""
lines = []
tone = config.get("teaching_tone") or config.get("tone", "")
if tone in _TONE_STRATEGY:
lines.append(f"【讲解语气】{_TONE_STRATEGY[tone]}")
example = config.get("teaching_example") or config.get("example_preference", "")
if example in _EXAMPLE_STRATEGY:
lines.append(f"【举例方式】{_EXAMPLE_STRATEGY[example]}")
style = config.get("learning_style", "")
if style in _LEARNING_STYLE_STRATEGY:
lines.append(f"【学习风格】{_LEARNING_STYLE_STRATEGY[style]}")
notes = (config.get("custom_notes") or "").strip()
if notes:
lines.append(f"【私教备注】{notes}")
teaching_custom = (config.get("teaching_custom") or "").strip()
if teaching_custom:
lines.append(f"【学生·学科专属要求】{teaching_custom}")
return "\n".join(lines) if lines else "按标准方式讲解即可。"
config.get("teaching_tone") or config.get("tone", "") 是"新键优先、旧键兜底"的双向兼容:新版本配置走 teaching_tone,老数据只有 tone,两者都能生效。if tone in _TONE_STRATEGY 用 in 而非直接取值——配置里若是未知值(如用户手改配置),直接跳过该策略而不是报错。
3.3 摘要提取器
# 源码位置:services/ai/teaching_engine.py L137-L157
def _extract_summary(self, analysis) -> str:
"""从分析结果提取对讲解有用的摘要"""
parts = []
core = getattr(analysis, "core_concept", None)
if core:
parts.append(f"核心概念/公式:{core}")
gap = getattr(analysis, "knowledge_gap", None)
if gap:
parts.append(f"学生可能的知识断层:{gap}")
steps = getattr(analysis, "step_by_step", None) or []
if isinstance(steps, list) and steps:
preview = " → ".join(str(s) for s in steps[:3])
parts.append(f"解题关键步骤:{preview}")
remediation = getattr(analysis, "remediation_suggestion", None)
if remediation:
parts.append(f"补漏建议:{remediation}")
return "\n".join(parts) if parts else "(无分析摘要)"
getattr(analysis, ..., None) 而非直接 analysis.core_concept——analysis 可能为 None(没分析过就点讲解),getattr 保证空对象也不崩;steps[:3] 只摘前 3 步,用 → 连接成一行预览,避免把整段分析原文塞给模型(控制 token、聚焦重点)。
四、设计权衡 / 踩坑记录
1. 为什么讲解引擎不解析 JSON? 分析/变式输出的是"数据"(要落库、要结构化展示),讲解输出的是"话语"(要朗读、要感受语气)。若强行让讲解也输出 JSON,模型会把注意力分给格式而牺牲自然度——实测讲解词质量明显下降。面向人的输出用 chat,面向机器的输出用 chat_json,这是引擎选型的根本依据。
2. _LATEX_FORMATTING_RULES 为什么注入 System 而不是 User? System 消息决定"模型如何表现",公式规范是跨题目、跨学生的全局约束,放 System 最合适;User 消息承载具体题目内容,若把规则放 User,每次组装都要重复拼接、且用户自定义 user 模板时会弄丢规则。not in system_prompt 的判断防止用户自己写了类似规则时重复注入。
3. teaching_tone 与 tone 的双键兼容是过渡期产物。 配置字段从 tone 演进到 teaching_tone(为了区分"分析语气"与"讲解语气"),存量数据库里还有旧键。用 or 兼容两代,等数据迁移完成后再删旧键——兼容代码可以丑,但不能让用户数据失效。
4. 姓名注入的时机。 把 【学生姓名】xxx 拼在 user 消息最前面而不是放进模板,是为了兼容用户自定义的 teaching_user 模板——自定义模板里没有 {student_name} 占位符,若依赖模板注入会直接丢失;前置拼接保证"无论模板长什么样,姓名都在场"。
五、小结
| 机制 | 实现 | 解决的问题 |
|---|---|---|
| 策略翻译 | 三张策略表 + if in 判断 | 配置值 → 自然语言指令 |
| VARK 注入 | learning_style → 学习风格段落 | 按认知通道定制讲解方式 |
| 公式约束 | System 注入 _LATEX_FORMATTING_RULES | 模型输出可渲染的 LaTeX |
| 摘要提取 | getattr + steps[:3] 拼预览 | 把分析结果压缩成讲解上下文 |
| 输出通道 | chat 而非 chat_json | 自然语言讲解词,不解析 |
TeachingEngine 展示了 AI 服务层的另一种形态:没有流水线式的"组装-调用-解析-落库",只有"翻译人设 → 拼装上下文 → 生成话语"。它证明了统一架构的弹性——五条引擎共享客户端与配置底座,但各自按业务形态生长出完全不同的内部结构。最后一篇的追问引擎(第 36 篇)将把"上下文拼装"推向极致:让模型带着整段对话历史回答学生的追问。
本文介绍‘课堂教学引擎’的设计与实现,聚焦将AI分析结果、学生VARK学习风格、教师语气策略及LaTeX公式规范,逐层翻译为可直接朗读的自然语言讲解词。引擎摒弃JSON解析,采用配置驱动的三层翻译机制,在System消息中硬注入LaTeX书写约束,确保输出可渲染、适配多认知通道,并通过姓名注入、双键兼容、摘要截取等工程设计提升鲁棒性与教学人格化水平。
513

被折叠的 条评论
为什么被折叠?



