Prompt 模板引擎:别把规则直接写死进代码里

📅 2026/7/7 12:19:43
Prompt 模板引擎:别把规则直接写死进代码里
Prompt 模板引擎别把规则直接写死进代码里一、当你的 Prompt 开始失控散落在代码中的规则碎片调试过生产环境 Prompt 的工程师都经历过这样的场景一个流程被拆分成了三段 Prompt 调用每段之间通过字符串拼接传递上下文。需求变更时你需要先找到第一段里那条请用 JSON 格式输出的指令再确认第二段里对 JSON 字段名的假设没被改乱。这种规则写死进代码的方式有三个致命问题第一修改成本极高。一行 Prompt 调整需要走完代码提交、审核、发布的完整流程而你的调整可能只是把准确改成精确。第二一致性无法保证。同一个角色设定分别散落在三个文件中版本演进时很容易出现文件 A 的 system prompt 说你是专家文件 B 又悄悄让你谦卑的冲突。第三调试信息丢失。当模型输出不符合预期时你很难回溯究竟是哪一层的 Prompt 出了问题。日志里只有一堆字符串没有变量解析过程。你开始怀念前端开发中的模板引擎——HTML 模板与业务逻辑分离变量替换、条件渲染、循环生成都在声明层面完成。Prompt 为什么不能这样做二、Prompt 模板引擎的抽象层次与渲染管道问题的本质在于Prompt 是一个文本生成系统的输入而文本生成系统本身也需要被工程化地管理。核心思路是将 Prompt 拆分为三个抽象层模板层声明 Prompt 的整体结构包含占位符、控制语句条件/循环变量层定义模板中变量的来源与默认值支持运行时注入渲染层负责将模板与变量合并为最终发送给 LLM 的文本flowchart LR A[模板定义br/Template] -- B[变量解析br/Variable Resolver] C[运行时上下文br/Runtime Context] -- B B -- D[条件展开br/Conditional Expansion] D -- E[循环渲染br/Loop Rendering] E -- F[后处理br/Trim/Normalize] F -- G[最终 Promptbr/Final Prompt] style A fill:#e1f5fe style C fill:#e1f5fe style G fill:#c8e6c9管道设计的关键决策点变量解析在前条件展开在后。这意味着变量必须先被完全解析才能作为条件判断的输入。如果反过来——先用占位符做条件判断再注入实际值——你就永远无法根据业务条件切换 Prompt 路径。后处理不可省略。Jinja2 等模板引擎会保留控制语句产生的空白行直接发给 LLM 会浪费 token 预算。需要专门的 Trim/Normalize 步骤。三、一个生产可用的 Prompt 模板引擎实现以下实现基于 Python核心只依赖标准库。设计要点支持{{ variable }}变量替换、{% if condition %}...{% endif %}条件、{% for item in list %}...{% endfor %}循环变量默认值机制防止遗漏导致运行时崩溃后处理自动压缩多余空白行import re from typing import Dict, List, Any, Optional from dataclasses import dataclass, field dataclass class PromptTemplate: Prompt 模板核心数据结构 这样设计是因为模板需要同时承载原始文本和编译后的AST 便于预编译一次、多次渲染减少重复解析开销 source: str # 原始模板文本 variables: Dict[str, Any] field(default_factorydict) # 变量默认值表 _compiled: Optional[List] None # 编译后的指令序列惰性编译 class PromptRenderer: Prompt 模板渲染引擎 设计原则渲染与模板定义分离。同一个 PromptRenderer 实例 可以接收不同的 PromptTemplate 对象适配多场景复用。 # 正则编译为类变量避免每次渲染时重复编译 # 为什么用正则而不是 Jinja2因为引入模板引擎依赖会增加部署体积 # 而 Prompt 模板中极少需要 Jinja2 的高级特性 VAR_PATTERN re.compile(r\{\{\s*(\w)\s*\}\}) IF_PATTERN re.compile(r\{%\s*if\s(\w)\s*%\}) ENDIF_PATTERN re.compile(r\{%\s*endif\s*%\}) ELSE_PATTERN re.compile(r\{%\s*else\s*%\}) FOR_PATTERN re.compile(r\{%\s*for\s(\w)\sin\s(\w)\s*%\}) ENDFOR_PATTERN re.compile(r\{%\s*endfor\s*%\}) def resolve_variables(self, template: str, variables: Dict[str, Any]) - str: 解析变量占位符将 {{ var }} 替换为实际值 def _replace(match): key match.group(1) value variables.get(key, ) # 将 None 转为空字符串避免 None 字符串混入 Prompt return str(value) if value is not None else return self.VAR_PATTERN.sub(_replace, template) def expand_conditionals(self, template: str, variables: Dict[str, Any]) - str: 展开条件语句根据变量布尔值决定保留哪段文本 lines template.split(\n) result_lines [] stack [] # 条件栈记录当前条件是否满足 skip_depth 0 # 跳过的嵌套层数 for line in lines: if_match self.IF_PATTERN.match(line.strip()) endif_match self.ENDIF_PATTERN.match(line.strip()) else_match self.ELSE_PATTERN.match(line.strip()) if if_match: condition_name if_match.group(1) condition_value bool(variables.get(condition_name, False)) if skip_depth 0 and not condition_value: skip_depth 1 stack.append(condition_value) continue if endif_match: if stack: stack.pop() if skip_depth 0: skip_depth - 1 continue if else_match: if stack and not stack[-1] and skip_depth 1: skip_depth 0 elif stack and stack[-1] and skip_depth 0: skip_depth 1 continue if skip_depth 0: result_lines.append(line) return \n.join(result_lines) def render(self, template: PromptTemplate, runtime_vars: Optional[Dict] None) - str: 完整的渲染流程合并默认变量 → 变量替换 → 条件展开 → 后处理 为什么先做变量替换再做条件展开 因为条件表达式中的变量本身也可能通过 {{ var }} 方式引用 必须先完成替换才能正确求值条件 # 合并模板默认值与运行时变量运行时优先覆盖默认值 all_vars {**template.variables} if runtime_vars: all_vars.update(runtime_vars) result template.source result self.expand_conditionals(result, all_vars) result self.resolve_variables(result, all_vars) # 后处理清理多余空白行节约 token result re.sub(r\n{3,}, \n\n, result) # 最多保留一个空行 result result.strip() return result # # 使用示例 # if __name__ __main__: # 定义模板时使用声明式语法所有规则逻辑在模板文本中集中管理 template_text 你是一个专业的技术文档审核助手。 {% if use_strict_mode %} 你必须严格遵循以下审核标准 1. 代码块必须有语言标识 2. 术语使用必须前后一致 {% else %} 你可以根据上下文灵活调整审核力度。 {% endif %} 需要审核的内容类型{{ content_type }} 审核范围{{ scope }} {% for role in reviewer_roles %} - 从{{ role }}角度审查内容 {% endfor %} 请给出审核结果。 # 模板与默认变量分离不同业务场景只需定义不同的变量集 template PromptTemplate( sourcetemplate_text, variables{ use_strict_mode: True, content_type: 技术博客, scope: 全篇, reviewer_roles: [架构师, 初级开发者, 运维工程师] } ) renderer PromptRenderer() result renderer.render(template) print(result)这套实现的核心设计选择有两个一是正则手写而非依赖模板引擎因为 Prompt 模板的控制语法非常简单引入 Jinja2 会带来不必要的依赖和安全风险二是默认值 运行时覆盖的变量合并策略既保证了模板的自文档化一看声明就知道接收哪些变量又保留了运行时的灵活性。四、模板引擎并非银弹你需要正视的架构代价任何抽象层的引入都伴随成本Prompt 模板引擎也不例外。调试复杂度上升。之前你直接拼接字符串一眼能看到最终 Prompt。引入模板引擎后你需要先检查变量值是否正确再确认条件分支是否进入了预期路径最后才是最终输出。见证奇迹的时刻在于当你的模板有 30 个变量时排查一个某段话没出现的问题可能需要跑三遍渲染流程。版本管理挑战。模板文件和代码文件是两套版本系统。当代码重构修改变量名时模板文件里的{{ old_var_name }}会静默转为空字符串——没有编译期检查没有 IDE 提示。团队协作摩擦。产品经理想调一句 Prompt 用词现在需要理解模板语法才能安全修改。你需要一个约束模板语法只判断保留/删除某段不判断生成某段。所有需要生成的内容通过变量注入而非在模板中写逻辑。性能考量。正则匹配和字符串替换在 Prompt 量级通常 1K-10K 字符下几乎无感。但当你的模板需要在每次 API 调用时重新渲染而非编译一次、多次使用在大批量调用场景下会有可测量的开销。建议在服务启动时预编译模板。五、总结Prompt 模板引擎解决的问题不是如何写出更好的 Prompt而是如何工程化地管理 Prompt。核心做法是将规则定义与业务代码分离通过变量替换、条件展开、循环渲染三个机制让 Prompt 像前端模板一样可维护。关键约束三条一是语法极简非必要的控制流不应引入二是始终保留完整 Prompt 可打印的调试入口哪怕它占用一行日志预算三是模板里的逻辑只做结构控制显示/隐藏不做内容生成内容生成永远留给变量注入或 LLM 自身。