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[最终 Prompt<br/>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_factory=dict) # 变量默认值表 _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+)\s+in\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( source=template_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 自身。
