欢迎光临
我们一直在努力

智学文枢:提示词配置动态组装

提示词配置动态组装:三层优先级的合并之道

一句话摘要:prompt_config.py 用不到 120 行实现了"全局默认 → 学生分科 → 单题临时"三层配置的优先级合并,让同一个分析引擎面对不同学生、不同学科、不同题目时,能自动拼出千人千面的提示词配置字典。

一、引言

配置系统的第一性原理是:"能改"比"默认值多好"更重要。

AI 私教产品有一个天然的矛盾:通用提示词模板(第 29 篇)对所有学生一视同仁,但现实是——

  • 有的学生用北师大版教材,有的用人教版;
  • 有的学生目标是中考,有的是高考,还有的在冲刺竞赛;
  • 有的学生是视觉型学习者,喜欢图形讲解,有的要老师"多举生活例子";
  • 老师可能在某道题上临时想加一句"这题要用放缩法,注意边界"。

如果把所有这些差异都硬编码进模板,模板会膨胀到不可维护;如果让每个引擎各自去查数据库拼配置,逻辑又会散落到五六个地方。正解只有一个:集中一个函数,按"个性化程度"从低到高逐层合并配置,谁更贴近当前场景,谁就有更高优先级。

services/ai/prompt_config.py 里的 resolve_prompt_config(student_id, subject, temp_config) 就是这样一个函数——它是整个 AI 服务层的"配置中心":理科/文科分析、讲解、追问四个引擎在构造 Prompt 之前,都会先调用它拿到合并好的配置字典。

#mermaid-svg-gjhZScXyRzpNSMEW{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-gjhZScXyRzpNSMEW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gjhZScXyRzpNSMEW .error-icon{fill:#552222;}#mermaid-svg-gjhZScXyRzpNSMEW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gjhZScXyRzpNSMEW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gjhZScXyRzpNSMEW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gjhZScXyRzpNSMEW .marker.cross{stroke:#333333;}#mermaid-svg-gjhZScXyRzpNSMEW svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gjhZScXyRzpNSMEW p{margin:0;}#mermaid-svg-gjhZScXyRzpNSMEW .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-gjhZScXyRzpNSMEW .cluster-label text{fill:#333;}#mermaid-svg-gjhZScXyRzpNSMEW .cluster-label span{color:#333;}#mermaid-svg-gjhZScXyRzpNSMEW .cluster-label span p{background-color:transparent;}#mermaid-svg-gjhZScXyRzpNSMEW .label text,#mermaid-svg-gjhZScXyRzpNSMEW span{fill:#333;color:#333;}#mermaid-svg-gjhZScXyRzpNSMEW .node rect,#mermaid-svg-gjhZScXyRzpNSMEW .node circle,#mermaid-svg-gjhZScXyRzpNSMEW .node ellipse,#mermaid-svg-gjhZScXyRzpNSMEW .node polygon,#mermaid-svg-gjhZScXyRzpNSMEW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gjhZScXyRzpNSMEW .rough-node .label text,#mermaid-svg-gjhZScXyRzpNSMEW .node .label text,#mermaid-svg-gjhZScXyRzpNSMEW .image-shape .label,#mermaid-svg-gjhZScXyRzpNSMEW .icon-shape .label{text-anchor:middle;}#mermaid-svg-gjhZScXyRzpNSMEW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gjhZScXyRzpNSMEW .rough-node .label,#mermaid-svg-gjhZScXyRzpNSMEW .node .label,#mermaid-svg-gjhZScXyRzpNSMEW .image-shape .label,#mermaid-svg-gjhZScXyRzpNSMEW .icon-shape .label{text-align:center;}#mermaid-svg-gjhZScXyRzpNSMEW .node.clickable{cursor:pointer;}#mermaid-svg-gjhZScXyRzpNSMEW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gjhZScXyRzpNSMEW .arrowheadPath{fill:#333333;}#mermaid-svg-gjhZScXyRzpNSMEW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gjhZScXyRzpNSMEW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gjhZScXyRzpNSMEW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gjhZScXyRzpNSMEW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gjhZScXyRzpNSMEW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gjhZScXyRzpNSMEW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gjhZScXyRzpNSMEW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gjhZScXyRzpNSMEW .cluster text{fill:#333;}#mermaid-svg-gjhZScXyRzpNSMEW .cluster span{color:#333;}#mermaid-svg-gjhZScXyRzpNSMEW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gjhZScXyRzpNSMEW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gjhZScXyRzpNSMEW rect.text{fill:none;stroke-width:0;}#mermaid-svg-gjhZScXyRzpNSMEW .icon-shape,#mermaid-svg-gjhZScXyRzpNSMEW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gjhZScXyRzpNSMEW .icon-shape p,#mermaid-svg-gjhZScXyRzpNSMEW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gjhZScXyRzpNSMEW .icon-shape .label rect,#mermaid-svg-gjhZScXyRzpNSMEW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gjhZScXyRzpNSMEW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gjhZScXyRzpNSMEW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gjhZScXyRzpNSMEW :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

是

否

resolve_prompt_configstudent_id, subject, temp_config

L1: 全局默认config.json → ai.prompts

有 student_id?

L2: 学生分科配置students.student_subject.prompt_config

有 temp_config?

L3: 单题临时配置update 覆盖

返回 merged

各引擎构造Prompt

二、核心原理

2.1 三层优先级模型

配置合并遵循"后写覆盖先写"的规则,与 CSS 层叠样式表同构:

层级来源粒度优先级
L1 全局默认 config.json 的 ai.prompts 节点 应用级 最低
L2 学生分科 student_subject 表的 prompt_config 字段 学生×学科 中
L3 单题临时 调用方传入的 temp_config 字典 单题单次 最高

L1 由 ConfigManager.get_prompt_defaults(subject_type) 提供,它把"数学/物理/化学"归为 science、"政治/历史/地理"归为 humanities 两类学科类型,读回对应的一组开关和模板;L2 从数据库读学生与学科交叉点上的配置;L3 直接是内存字典,调用一次就失效——不落库,天然防污染。

2.2 为什么临时配置用 update 全量覆盖?

L3 的实现只有一行 merged.update(temp_config),看起来"粗暴",实际是刻意的:临时配置来自弹窗(见第 63 篇临时提示词弹窗),老师在本道题上写的任何要求都应当原样生效,包括覆盖掉 L2 里已保存的学科开关。全量 update 保证了"老师说一不二"。

2.3 学情信息随分科配置一起注入

除了提示词开关,L2 还会把 current_level(当前水平)、textbook_version(教材版本)、exam_target(考试目标)这三个"学情字段"塞进 merged——它们是第 29 篇模板里 {current_level} 等占位符的数据源。这实现了"配置即数据":分析引擎不需要自己查学生档案,配置字典里已经带全了。

三、代码实现

3.1 入口与学科类型判定

# 源码位置:services/ai/prompt_config.py L12-L38
def resolve_prompt_config(student_id: Optional[str], subject: str,
temp_config: dict = None) –> dict:
"""按优先级解析Prompt配置

优先级(从低到高):
1. 全局默认(含学科开关) — config.json 中的 ai.prompts
2. 学生分科配置 — students.student_subject.prompt_config
3. 单题临时配置 — temp_config(最高)
"""
config = ConfigManager()

# 确定学科类型
subject_type = "science" if subject in SCIENCE_SUBJECTS else "humanities"
logger.info("[PromptConfig] 开始解析 student_id=%s subject=%s subject_type=%s",
student_id, subject, subject_type)

# 第1层:全局默认(含学科开关,由 ConfigManager 从 config.json 的 ai.prompts 读取)
merged = config.get_prompt_defaults(subject_type)
logger.info("[PromptConfig] L1-全局默认合并后: %s", merged)

subject in SCIENCE_SUBJECTS 是学科归类的最简实现——app/constants.py 里定义了两组学科常量,非理科即文科,二分类足够支撑两套模板(第 32/33 篇)。每次合并的关键节点都打日志,标出 L1/L2/L3 层级,排障时一眼看到"哪一层覆盖了哪个字段"。

3.2 L2 学生分科配置:一个 try/finally 保证连接释放

# 源码位置:services/ai/prompt_config.py L40-L106
if student_id:
repo = StudentRepository()
try:
# 第2层:学生分科配置(学生×学科更具体)
subj_cfg = repo.get_subject_config(student_id, subject)
if subj_cfg:
# 2a) 学生分科提示词配置
if subj_cfg.prompt_config:
pc = subj_cfg.prompt_config
custom_prompt = (pc.get("custom_prompt") or "").strip()
if custom_prompt:
merged["custom_prompt"] = custom_prompt
# 分析系统/用户提示词(分科可编辑,直接作为分析消息)
if pc.get("analysis_system"):
merged["analysis_system"] = pc["analysis_system"]
if pc.get("analysis_user"):
merged["analysis_user"] = pc["analysis_user"]
# 学科开关覆盖(分科可编辑,继承全局后可单独覆盖)
subj_toggles = pc.get("subject_toggles")
if subj_toggles:
merged.update(subj_toggles)
# 讲解分科配置(teaching_tone/teaching_example/teaching_custom)
if pc.get("teaching_tone"):
merged["teaching_tone"] = pc["teaching_tone"]
if pc.get("teaching_example"):
merged["teaching_example"] = pc["teaching_example"]
if pc.get("teaching_learning_style"):
merged["learning_style"] = pc["teaching_learning_style"]
...
# 2b) 学生分科学情信息(当前水平/教材版本/考试目标)
if subj_cfg.current_level:
merged["current_level"] = subj_cfg.current_level
if subj_cfg.textbook_version:
merged["textbook_version"] = subj_cfg.textbook_version
if subj_cfg.exam_target:
merged["exam_target"] = subj_cfg.exam_target
else:
logger.info("[PromptConfig] L2-学科 %s 无分科配置,跳过", subject)
finally:
repo.close()

三个细节值得注意:

  • try/finally 而非 with:StudentRepository 没有实现上下文管理器,用 finally 保证无论中间走哪个分支,连接都关闭——桌面应用常驻进程,SQLite 连接泄漏会随分析次数累积;
  • 分科字段刻意"白名单"化:不是把 pc 整个 update 进 merged,而是逐个 if 判断后写入,因为 prompt_config 是 JSON 字段,里面可能存了历史遗留的无关键,白名单能防脏数据串扰;
  • 讲解字段提前合并:teaching_tone / teaching_learning_style 等虽然是给第 35 篇讲解引擎用的,但在这里就一并合并——因为配置解析只做一次,各引擎共享同一个结果。

3.3 L3 临时配置与最终返回

# 源码位置:services/ai/prompt_config.py L110-L119
# 第3层:单题临时配置(最高优先级)
if temp_config:
logger.info("[PromptConfig] L3-临时配置覆盖前: %s", merged)
merged.update(temp_config)
logger.info("[PromptConfig] L3-临时配置覆盖后: %s", merged)
else:
logger.info("[PromptConfig] L3-无临时配置,跳过")

logger.info("[PromptConfig] 最终合并配置: %s", merged)
return merged

整个函数的核心逻辑其实只有:merged = L1() → 可能 merged = L2(merged) → 可能 merged.update(L3)。前面几十行日志都是围绕这一个"叠蛋糕"过程展开的——先读默认、再被更具体的覆盖、最后被最临时的覆盖。

四、设计权衡 / 踩坑记录

1. 为什么不把三层配置存一张表? 全局配置在 config.json(文件)、分科配置在 SQLite(数据库)、临时配置在内存(字典)——三个来源三个存储,看似不统一,实则各得其所:全局配置要跟着安装包分发所以用文件;分科配置要按学生查询所以入库;临时配置"用一次就丢"所以放内存。resolve_prompt_config 正是三者的"汇流点"。

2. subject_toggles 的 update 是刻意"危险"的。 分科开关里可能只配了 2 个开关(如 force_step_by_step: true),但 merged.update(subj_toggles) 会把整个开关子集覆盖进来。若 JSON 里混进了未知键(如 current_events: "yes" 字符串而非布尔),会被第 29 篇的 _build_extra_requirements 的 if config.get(…) 判定为真值而注入指令——所以 UI 端写库时必须严格序列化开关类型,这是第 88 篇学科提示词配置面板要守住的边界。

3. 顺序敏感:L2 内部先提示词后学情。 如果学情字段先写入、再被分科提示词 update 覆盖,理论上没问题;但反过来,若分科 prompt_config 里也存了 current_level 就会跟 subj_cfg.current_level 冲突。踩过一次后,代码固定为"先合并提示词、再合并学情",用约定规避歧义。

4. 全量日志的双刃剑。 每个层级合并前后都打完整 dict 日志,好处是排障直观;代价是 prompt 配置可能含自定义提示词全文,日志量偏大。权衡后保留——因为 logger 本身有级别过滤,生产可调 WARNING 关闭,调试时开 DEBUG 就能拿到完整链路。

五、小结

层级来源写入方式典型内容
L1 全局默认 config.json ai.prompts 初始化 merged 学科开关、默认模板
L2 学生分科 student_subject.prompt_config 白名单逐字段写入 自定义提示词、学科开关、学情信息、讲解配置
L3 单题临时 内存字典 全量 update 老师单题备注、临时指令

resolve_prompt_config 把"配置从哪来、谁覆盖谁"的复杂度全部收敛进一个纯函数:输入三个参数,输出一份合并字典,不碰数据库写操作、不产生副作用。任何引擎拿到这份字典,就可以交给 PromptTemplates(第 29 篇)组装消息了——配置层与模板层的职责边界,在这一进一出之间被划得干干净净。

赞(0)
未经允许不得转载:171主机测评 » 智学文枢:提示词配置动态组装
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址