第2132篇:Prompt版本管理与工程化——把Prompt当代码来管
第2132篇:Prompt版本管理与工程化——把Prompt当代码来管
适读人群:维护多个LLM应用Prompt的工程师和团队 | 阅读时长:约18分钟 | 核心价值:建立Prompt的版本管理、测试、发布流程,让Prompt的维护和迭代有章可循
"Prompt改出问题了,但不知道改了什么,也不知道改之前的版本是什么。"
这是没有版本管理的痛苦。我见过很多团队把Prompt直接写在代码里,每次修改就直接改字符串,没有版本历史,没有测试,没有回滚。
Prompt的重要性不亚于核心业务代码:它决定了AI的行为、输出风格、安全边界。但很多工程师对待Prompt的方式,就像对待临时测试脚本一样随意。
这篇文章把Prompt管理工程化。
Prompt管理的常见混乱
/**
* 没有Prompt管理系统时的典型问题
*
* ===== 问题一:分散存储 =====
*
* Prompt藏在多个地方:
* - 硬编码在Java字符串里
* - 写在application.yml里
* - 保存在开发者本地的txt文件
* - 粘贴在Confluence某个页面
*
* 结果:没人知道"当前生产用的是哪个版本"
*
* ===== 问题二:无版本历史 =====
*
* 直接改字符串,提交代码
* git log能看到代码变更,但Prompt变更被淹没在其他改动里
* 出了问题不知道"改了什么"
*
* ===== 问题三:无测试覆盖 =====
*
* 改了Prompt,靠人工看几个例子确认"感觉对了"
* 没有回归测试,不知道之前能正确回答的问题有没有被影响
*
* ===== 问题四:多环境不一致 =====
*
* 测试环境用的Prompt和生产环境不同
* 导致"测试OK但生产有问题"
*
* 解决思路:把Prompt管理当作软件工程问题
* - 版本控制(谁改了什么)
* - 环境隔离(dev/staging/prod)
* - 自动化测试
* - 发布流程(审核/灰度/回滚)
*/Prompt存储模型
/**
* Prompt版本管理的数据模型
*/
@Entity
@Table(name = "prompt_templates")
@Data
@Builder
public class PromptTemplate {
@Id
private String templateId;
private String templateName; // "客服RAG主Prompt"
private String templateKey; // 代码中引用的key,唯一且稳定
private String description;
@Column(columnDefinition = "TEXT")
private String systemPrompt;
@Column(columnDefinition = "TEXT")
private String userPromptTemplate; // 带{{变量}}的模板
// 使用的变量列表(帮助IDE/工具校验)
@Column(columnDefinition = "TEXT")
private String variablesJson; // ["query", "context", "user_info"]
// 版本信息
private String version; // "v2.3.1"
private String changeLog; // 本次变更说明
private String previousVersion; // 上一版本的templateId
// 发布状态
@Enumerated(EnumType.STRING)
private PublishStatus publishStatus;
// 测试指标(发布前必须通过)
private Integer minTestPassRate; // 最低测试通过率(%)
private String testSuiteId; // 关联的测试集
// 元数据
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
private String approvedBy;
private LocalDateTime approvedAt;
public enum PublishStatus {
DRAFT, // 草稿
UNDER_REVIEW, // 审核中
APPROVED, // 审核通过,可发布
PUBLISHED, // 已发布(生产使用)
ARCHIVED // 已归档(不再使用)
}
/**
* 填充模板变量
*/
public String renderUserPrompt(Map<String, String> variables) {
String rendered = userPromptTemplate;
for (Map.Entry<String, String> entry : variables.entrySet()) {
rendered = rendered.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return rendered;
}
/**
* 检查所有必需变量是否都已提供
*/
public List<String> checkMissingVariables(Map<String, String> providedVars) {
List<String> required = getVariables();
return required.stream()
.filter(v -> !providedVars.containsKey(v))
.toList();
}
public List<String> getVariables() {
if (variablesJson == null) return List.of();
try {
return new ObjectMapper().readValue(variablesJson, new TypeReference<List<String>>() {});
} catch (Exception e) {
return List.of();
}
}
}Prompt加载与缓存服务
/**
* Prompt服务
*
* 负责加载、缓存、渲染Prompt模板
* 支持环境隔离和热更新
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PromptService {
private final PromptTemplateRepository templateRepo;
// Prompt缓存(热更新)
private final Cache<String, PromptTemplate> promptCache;
public PromptService(PromptTemplateRepository templateRepo) {
this.templateRepo = templateRepo;
this.promptCache = Caffeine.newBuilder()
.maximumSize(200)
.expireAfterWrite(Duration.ofMinutes(5)) // 5分钟缓存,支持热更新
.build();
}
/**
* 获取当前发布版本的Prompt(生产用)
*/
public PromptTemplate getPublishedPrompt(String templateKey) {
return promptCache.get(templateKey, key -> {
PromptTemplate template = templateRepo.findPublishedByKey(key)
.orElseThrow(() -> new IllegalArgumentException(
"未找到已发布的Prompt模板: " + key));
log.debug("Prompt缓存加载: key={}, version={}", key, template.getVersion());
return template;
});
}
/**
* 获取特定版本(用于测试或灰度)
*/
public PromptTemplate getSpecificVersion(String templateId) {
return templateRepo.findById(templateId)
.orElseThrow(() -> new IllegalArgumentException("Prompt模板不存在: " + templateId));
}
/**
* 渲染Prompt(填充变量)
*/
public RenderedPrompt render(String templateKey, Map<String, String> variables) {
PromptTemplate template = getPublishedPrompt(templateKey);
// 检查变量完整性
List<String> missing = template.checkMissingVariables(variables);
if (!missing.isEmpty()) {
throw new PromptVariableMissingException(
"Prompt变量缺失: " + missing + ",模板: " + templateKey);
}
String renderedSystem = template.getSystemPrompt();
String renderedUser = template.renderUserPrompt(variables);
return new RenderedPrompt(
template.getTemplateId(),
template.getVersion(),
renderedSystem,
renderedUser
);
}
/**
* 强制刷新缓存(在Prompt更新后调用)
*/
public void invalidateCache(String templateKey) {
promptCache.invalidate(templateKey);
log.info("Prompt缓存已刷新: key={}", templateKey);
}
record RenderedPrompt(String templateId, String version,
String systemPrompt, String userPrompt) {}
}Prompt测试框架
/**
* Prompt自动化测试
*
* 每次Prompt变更前,自动运行测试集
* 保证新版本不比旧版本差
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PromptTestRunner {
private final ChatLanguageModel llm;
private final PromptService promptService;
private final PromptTestCaseRepository testCaseRepo;
/**
* 运行Prompt测试集
*
* @param templateId 要测试的Prompt版本
* @return 测试结果报告
*/
public TestRunReport runTests(String templateId) {
PromptTemplate template = promptService.getSpecificVersion(templateId);
List<PromptTestCase> testCases = testCaseRepo.findByTemplateKey(template.getTemplateKey());
if (testCases.isEmpty()) {
log.warn("没有测试用例: templateKey={}", template.getTemplateKey());
return TestRunReport.noTests(templateId);
}
log.info("开始Prompt测试: templateId={}, cases={}", templateId, testCases.size());
List<CaseResult> results = testCases.stream()
.map(tc -> runSingleCase(template, tc))
.toList();
long passed = results.stream().filter(CaseResult::passed).count();
double passRate = (double) passed / results.size() * 100;
boolean overallPass = passRate >= (template.getMinTestPassRate() != null ?
template.getMinTestPassRate() : 80);
log.info("Prompt测试完成: passRate={:.1f}%, pass={}", passRate, overallPass);
return new TestRunReport(templateId, results.size(), (int) passed, passRate,
overallPass, results);
}
private CaseResult runSingleCase(PromptTemplate template, PromptTestCase testCase) {
try {
// 渲染Prompt
String systemPrompt = template.getSystemPrompt();
String userPrompt = template.renderUserPrompt(testCase.getInputVariables());
// 生成回答
String response = llm.generate(List.of(
SystemMessage.from(systemPrompt),
UserMessage.from(userPrompt)
));
// 评估
boolean passed = evaluate(response, testCase);
return new CaseResult(testCase.getCaseId(), testCase.getDescription(),
passed, response, null);
} catch (Exception e) {
return new CaseResult(testCase.getCaseId(), testCase.getDescription(),
false, null, e.getMessage());
}
}
/**
* 评估回答质量
*
* 支持多种评估方式
*/
private boolean evaluate(String response, PromptTestCase testCase) {
return switch (testCase.getEvaluationType()) {
case CONTAINS_ALL -> {
// 回答必须包含所有必需内容
yield testCase.getMustContain().stream()
.allMatch(required -> response.toLowerCase().contains(required.toLowerCase()));
}
case NOT_CONTAINS -> {
// 回答不能包含禁止内容
yield testCase.getMustNotContain().stream()
.noneMatch(forbidden -> response.toLowerCase().contains(forbidden.toLowerCase()));
}
case REGEX_MATCH -> {
// 回答匹配正则表达式
yield testCase.getExpectedPattern() != null &&
response.matches("(?s).*" + testCase.getExpectedPattern() + ".*");
}
case LLM_JUDGE -> {
// 用LLM评判(成本较高,用于复杂质量评估)
yield evaluateWithLlm(response, testCase.getJudgeCriteria());
}
};
}
private boolean evaluateWithLlm(String response, String criteria) {
String judgePrompt = """
请评判以下AI回答是否满足标准(只回答"是"或"否"):
标准:%s
回答:%s
是否满足:
""".formatted(criteria, response);
try {
String result = llm.generate(judgePrompt).trim();
return result.startsWith("是");
} catch (Exception e) {
return false;
}
}
record CaseResult(String caseId, String description, boolean passed,
String response, String errorMessage) {}
record TestRunReport(String templateId, int totalCases, int passedCases,
double passRate, boolean overallPass, List<CaseResult> details) {
public static TestRunReport noTests(String templateId) {
return new TestRunReport(templateId, 0, 0, 0, false, List.of());
}
}
}实践建议
Prompt的KEY应该稳定,VERSION应该变化
代码里不要硬编码Prompt内容,而是引用一个稳定的KEY(比如"customer-service-main-rag")。这样Prompt内容可以独立更新,不需要改代码、不需要重新部署。KEY是接口,VERSION是实现。当你需要在代码里找到哪个功能用了哪个Prompt,只需要搜KEY就能找到。
每次Prompt变更都要写changeLog
"改了一个词"不是可接受的changeLog。好的changeLog应该写:"修改了System Prompt里的礼貌用语约束,之前强制要求每句话都用敬语,改为自然语气。原因:用户反馈回答太生硬。测试数据:满意率从62%提升到73%。"这种记录不只是历史文档,也是团队知识的积累——为什么当初做出某个决策,以后回顾时能理解来龙去脉。
测试用例要持续维护,不能只建不更新
我见过很多团队在项目初期精心设计了测试用例,但半年后知识库扩展了、业务规则变了,测试用例还是最初版本的。结果测试一直在通过,但实际上覆盖的是旧的业务场景。建议:每次发现用户反馈的问题,如果是Prompt问题,就把它转化为新的测试用例(先失败,Prompt修好后通过)。这样测试集会随业务增长而不断丰富。
