Prompt模板工程化管理-从硬编码到可维护
> **本文适合谁**
Prompt 模板工程化管理:从硬编码到可维护
本文适合谁
AI 应用规模扩大后,需要系统管理十几个 Prompt 的开发者。从散落在代码里的硬编码字符串,到可版本控制、可测试、可复用的 Prompt 模板体系。
随着 LLM 应用规模扩大,Prompt 管理会成为一个真实的工程问题。一个中型 RAG(Retrieval-Augmented Generation,检索增强生成,让 AI 先从知识库中检索相关内容再回答的技术)系统里通常有十几个不同的 Prompt:问题改写、文档摘要、最终回答生成、查询路由……这些 Prompt 如果散落在代码各处,会带来一系列麻烦。
硬编码 Prompt 的问题
Prompt 模板管理四阶段演进——从硬编码到版本管理系统,对应不同项目成熟度
先看一个典型的反面案例:
# service/qa_service.py
def answer_question(question, context):
prompt = f"你是一个知识库助手。根据以下内容回答问题:\n\n{context}\n\n问题:{question}"
return llm.invoke(prompt)
# service/summary_service.py
def summarize_doc(text):
# 和上面几乎一样,但没人知道
prompt = f"请总结以下文档:\n\n{text}\n\n要求:简洁,不超过200字。"
return llm.invoke(prompt)
# api/chat_handler.py
def handle_chat(messages):
# 一个月后来维护,发现这里还有一个 Prompt
system = "你是客服助手,不要讨论竞争对手。"
...
这种写法的问题:
- 散落在代码各处:搜索 Prompt 要全局 grep,改一个措辞要改多个文件。
- 无法版本管理:无法回滚 Prompt,不知道上个版本的效果更好还是更差。
- 无法复用:两个服务写了几乎一样的 Prompt,但没人知道,也没人维护一致性。
- 无法测试:Prompt 嵌在业务逻辑里,不好单独测试不同版本的效果。
- 变量拼接脆弱:用 f-string 拼接,一旦变量名改了或者内容有特殊字符,容易出 bug。
PromptTemplate 的设计模式
LangChain 的 PromptTemplate(提示词模板类,用来管理带变量的 Prompt 字符串)解决了变量管理的问题。它用 {变量名} 作为占位符,和 Python 的 f-string(一种字符串格式化方式,如 f"你好,{name}")相比,主要优势在于声明式——模板和变量分离,模板可以序列化(即可以存储到文件中再加载)。
from langchain_core.prompts import PromptTemplate, ChatPromptTemplate
from langchain_core.messages import SystemMessage, HumanMessagePromptTemplate
# 基础用法:单一文本模板
qa_template = PromptTemplate(
input_variables=["context", "question"],
template="""你是一个知识库问答助手。请根据提供的上下文回答问题。
如果上下文中没有足够信息,请如实说明,不要编造。
上下文:
{context}
问题:{question}
回答:""",
# 元数据:记录模板的用途和版本,方便追踪
metadata={"version": "1.2", "task": "qa", "author": "team-ai"}
)
# 格式化时传入变量,生成最终 Prompt 字符串
prompt_str = qa_template.format(
context="Redis 是一个开源的内存数据库,支持多种数据结构。",
question="Redis 是什么?"
)
# 对话模型用 ChatPromptTemplate,区分 system/human/assistant 角色
chat_template = ChatPromptTemplate.from_messages([
("system", "你是一名专业的 {role},用简洁专业的语言回答问题。"),
("human", "{question}"),
])
# from_messages 接受 (role, template_str) 元组列表,比手写 Message 对象更简洁
messages = chat_template.format_messages(
role="Python 工程师",
question="什么是 GIL?"
)
PromptTemplate 还支持偏应用(partial)——提前固定一部分变量,后续只传入剩余变量:
# 固定 role,后续不同调用只需传 question
partial_template = chat_template.partial(role="数据库专家")
messages = partial_template.format_messages(question="B+ 树和 B 树有什么区别?")
用 YAML 文件管理 Prompt 模板
变量管理解决了,但 Prompt 内容本身还在代码里。把 Prompt 内容提取到独立的配置文件中,才能真正做到"Prompt 变更不需要改代码"。
YAML 格式是比较好的选择,可读性强,天然支持多行字符串:
# prompts/qa_answer.yaml
version: "1.3"
task: "knowledge_base_qa"
description: "知识库问答,根据检索到的上下文生成回答"
author: "team-ai"
updated_at: "2024-03-15"
system_template: |
你是一个专业的知识库问答助手。
回答规则:
1. 只根据提供的上下文作答,不要引用外部知识
2. 如果上下文不足以回答,明确说"文档中没有相关信息"
3. 回答简洁,避免重复上下文原文
human_template: |
上下文:
{context}
问题:{question}
input_variables:
- context
- question
# 测试用例,方便评估 Prompt 效果
test_cases:
- context: "Python 的 GIL 是全局解释器锁,同一时刻只允许一个线程执行 Python 字节码。"
question: "什么是 GIL?"
expected_keywords: ["全局解释器锁", "线程"]
对应的加载代码:
import yaml
from pathlib import Path
from langchain_core.prompts import ChatPromptTemplate
from dataclasses import dataclass
from typing import List, Dict, Any
@dataclass
class PromptConfig:
version: str
task: str
description: str
system_template: str
human_template: str
input_variables: List[str]
metadata: Dict[str, Any]
def load_prompt_from_yaml(yaml_path: str) -> tuple[PromptConfig, ChatPromptTemplate]:
"""从 YAML 文件加载 Prompt 配置和模板对象"""
path = Path(yaml_path)
if not path.exists():
raise FileNotFoundError(f"Prompt 配置文件不存在:{yaml_path}")
with open(path, "r", encoding="utf-8") as f:
config_dict = yaml.safe_load(f)
config = PromptConfig(
version=config_dict["version"],
task=config_dict["task"],
description=config_dict.get("description", ""),
system_template=config_dict["system_template"],
human_template=config_dict["human_template"],
input_variables=config_dict["input_variables"],
metadata={k: v for k, v in config_dict.items()
if k not in ("system_template", "human_template", "input_variables")}
)
# 根据配置构建 ChatPromptTemplate
template = ChatPromptTemplate.from_messages([
("system", config.system_template),
("human", config.human_template),
])
return config, template
# 使用示例
config, template = load_prompt_from_yaml("prompts/qa_answer.yaml")
print(f"加载 Prompt:{config.task} v{config.version}")
messages = template.format_messages(
context="Redis 支持 String、List、Hash、Set、ZSet 五种基本数据类型。",
question="Redis 有哪些数据类型?"
)
Prompt 版本管理:Git 追踪
Prompt 文件放到 Git 仓库里,就自动获得了完整的版本历史。最佳实践是把所有 Prompt 文件集中在一个目录:
prompts/
├── qa/
│ ├── qa_answer.yaml # 当前版本
│ └── qa_answer_v1.yaml # 保留旧版本供对比
├── routing/
│ └── query_router.yaml
├── summarization/
│ └── doc_summary.yaml
└── __init__.py # 统一导出,供其他模块 import
prompts/__init__.py 集中管理所有模板加载:
# prompts/__init__.py
from pathlib import Path
from .loader import load_prompt_from_yaml
PROMPTS_DIR = Path(__file__).parent
# 在模块加载时一次性读取所有 Prompt,避免每次调用都读文件
QA_ANSWER = load_prompt_from_yaml(PROMPTS_DIR / "qa" / "qa_answer.yaml")
QUERY_ROUTER = load_prompt_from_yaml(PROMPTS_DIR / "routing" / "query_router.yaml")
DOC_SUMMARY = load_prompt_from_yaml(PROMPTS_DIR / "summarization" / "doc_summary.yaml")
业务代码里直接 import 使用,不再关心 Prompt 的具体内容:
# service/qa_service.py
from prompts import QA_ANSWER
config, template = QA_ANSWER
def answer_question(question: str, context: str) -> str:
messages = template.format_messages(context=context, question=question)
return llm.invoke(messages).content
Prompt 生命周期管理流程
A/B 测试不同 Prompt 版本
版本管理的目的之一是支持 A/B 测试——让两个 Prompt 版本同时运行,用数据决定哪个更好:
import random
import time
from dataclasses import dataclass, field
from typing import Dict, List
from langchain_openai import ChatOpenAI
import os
@dataclass
class ABTestResult:
version: str
latency_ms: float
response: str
# 实际项目中还需要记录用户评分、点赞/踩等反馈
metadata: Dict = field(default_factory=dict)
class PromptABTester:
def __init__(self, variants: Dict[str, any], weights: List[float] = None):
"""
variants: {"v1": template_v1, "v2": template_v2}
weights: 各版本的流量权重,默认均等分配
"""
self.variants = variants
self.weights = weights or [1.0 / len(variants)] * len(variants)
self.results: List[ABTestResult] = []
def _select_variant(self) -> str:
"""按权重随机选择一个版本"""
names = list(self.variants.keys())
return random.choices(names, weights=self.weights, k=1)[0]
def run(self, llm, **kwargs) -> ABTestResult:
"""运行一次推断,记录结果"""
version_name = self._select_variant()
template = self.variants[version_name]
messages = template.format_messages(**kwargs)
start = time.time()
response = llm.invoke(messages)
latency = (time.time() - start) * 1000
result = ABTestResult(
version=version_name,
latency_ms=round(latency, 2),
response=response.content,
)
self.results.append(result)
return result
def report(self) -> Dict:
"""汇总各版本的统计数据"""
from collections import defaultdict
stats = defaultdict(lambda: {"count": 0, "total_latency": 0.0})
for r in self.results:
stats[r.version]["count"] += 1
stats[r.version]["total_latency"] += r.latency_ms
return {
version: {
"count": data["count"],
"avg_latency_ms": round(data["total_latency"] / data["count"], 2)
}
for version, data in stats.items()
}
# 使用示例
llm = ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
temperature=0,
)
# 加载两个版本的 Prompt
_, template_v1 = load_prompt_from_yaml("prompts/qa/qa_answer.yaml")
_, template_v2 = load_prompt_from_yaml("prompts/qa/qa_answer_v2.yaml")
tester = PromptABTester(
variants={"v1": template_v1, "v2": template_v2},
weights=[0.5, 0.5], # 各 50% 流量
)
# 模拟 10 次请求
test_context = "Python 3.12 引入了 per-interpreter GIL,允许多个子解释器并行执行。"
test_question = "Python 3.12 的 GIL 有什么变化?"
for _ in range(10):
result = tester.run(llm, context=test_context, question=test_question)
print(f"[{result.version}] {result.latency_ms}ms | {result.response[:50]}...")
print("\n统计报告:")
print(tester.report())
各方案对比
| 方案 | 维护性 | 版本追踪 | 团队协作 | A/B 测试 | 实现成本 |
|---|---|---|---|---|---|
| 硬编码在代码里 | 差 | 无 | 困难 | 不支持 | 最低 |
| 代码里的常量 | 一般 | 依赖 git diff | 一般 | 手动 | 低 |
| YAML 文件 + git | 好 | 完整 | 好 | 支持 | 中 |
| LangSmith Prompt Hub | 很好 | 完整 + UI | 很好 | 内置支持 | 高(需要服务) |
对于大多数项目,YAML 文件(一种人类可读的配置文件格式,用缩进表示层级结构,常用于存储配置信息)+ git 是性价比最高的方案。LangSmith Prompt Hub(LangChain 官方提供的 Prompt 管理平台)提供了 Web UI 和在线编辑功能,适合团队规模较大、Prompt 迭代非常频繁的场景,但引入了外部依赖。
落地步骤:把所有 Prompt 提取到 YAML 文件,集中放在 prompts/ 目录;用 PromptTemplate / ChatPromptTemplate 包装,替代字符串拼接;纳入 git 版本管理,Prompt 变更和代码变更同步追踪;建立评估测试集,Prompt 变更后自动跑评估。
下一篇讲思维链进阶:Tree of Thoughts 和 Self-Consistency,解决标准 CoT 单条推理链容易走偏的问题。