Prompt Engineering 工程化——从写 Prompt 到管理 Prompt 的完整体系
Prompt Engineering 工程化——从写 Prompt 到管理 Prompt 的完整体系
适读人群:AI 应用开发者、产品技术团队 | 阅读时长:约18分钟 | 核心价值:建立系统化的 Prompt 工程体系,告别随意拍脑袋写 Prompt
上个月我们系统出了一个故障:生产环境的客服 AI 突然开始给用户推荐竞品。
排查了半天,发现是研发同学直接修改了生产数据库里的 Prompt,改完没测试就上了。原始 Prompt 里有一条"不得提及竞争对手产品"的约束,他改的时候不小心删掉了。
这个事故暴露了一个问题:我们把 Prompt 当配置项随便改,没有版本管理,没有测试流程,没有回滚机制。
Prompt 不是普通配置,它是 AI 应用的"核心业务逻辑",需要工程化管理。
Prompt 工程化的四个层次
很多团队的 Prompt 管理停留在第一层:
- 随意期:Prompt 硬编码在代码里,谁想改就改
- 配置期:Prompt 提取到配置文件/数据库,但没有版本控制
- 规范期:有模板、有版本、有测试用例,但测试还是手动
- 平台期:有专门的 Prompt 管理平台,支持 A/B 测试、自动评估、灰度发布
中小团队至少要达到第三层,大团队建议做到第四层。
Prompt 设计的核心原则
在讲管理之前,先把写好 Prompt 的基础打牢。
原则一:角色 + 目标 + 约束 + 格式
你是[角色],专门负责[主要职责]。
【任务目标】
{明确描述你想要的输出}
【约束条件】
- 约束1
- 约束2
- 禁止事项
【输出格式】
{明确指定输出的结构}原则二:Few-shot 示例要代表性强
以下是几个分类示例:
用户消息:我的订单什么时候到?
分类:物流查询
用户消息:我要退货
分类:售后申请
用户消息:这个商品有优惠吗?
分类:商品咨询
请对以下消息分类:
用户消息:{message}
分类:Few-shot 示例中要包含:
- 典型的正面案例
- 容易混淆的边界案例
- 特殊格式/语气的案例
原则三:思维链(CoT)让推理过程可见
请按以下步骤分析用户投诉:
步骤1:识别核心诉求(投诉了什么)
步骤2:判断严重等级(高/中/低)
步骤3:确定处理部门(客服/技术/财务)
步骤4:给出处理建议
用户投诉:{complaint}Prompt 模板管理
版本化管理方案:
@Entity
@Table(name = "prompt_templates")
public class PromptTemplate {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
private String name; // 模板名称,如 "customer-service-system"
private String category; // 分类:system/user/few-shot
private String content; // 模板内容,支持 {variable} 占位符
private String version; // 语义化版本:1.0.0
private boolean active; // 是否激活
private String createdBy;
private LocalDateTime createdAt;
@ElementCollection
private Map<String, String> variables; // 变量说明
@ElementCollection
private List<String> testCaseIds; // 关联的测试用例
}@Service
public class PromptTemplateService {
private final PromptTemplateRepository repository;
private final PromptTemplateCache cache; // Redis 缓存
/**
* 获取激活的模板版本
*/
public String getActiveTemplate(String name) {
// 先查缓存
return cache.get(name).orElseGet(() -> {
PromptTemplate template = repository.findByNameAndActiveTrue(name)
.orElseThrow(() -> new TemplateNotFoundException(name));
cache.set(name, template.getContent(), Duration.ofMinutes(10));
return template.getContent();
});
}
/**
* 渲染模板(替换变量)
*/
public String render(String templateName, Map<String, String> variables) {
String template = getActiveTemplate(templateName);
for (Map.Entry<String, String> entry : variables.entrySet()) {
template = template.replace("{" + entry.getKey() + "}", entry.getValue());
}
return template;
}
/**
* 发布新版本
*/
@Transactional
public void publish(String name, String content, String version, String operator) {
// 1. 先跑测试用例
List<TestResult> results = runTestCases(name, content);
long passRate = results.stream().filter(TestResult::passed).count() * 100 / results.size();
if (passRate < 90) {
throw new PromptPublishException(
String.format("测试通过率 %d%% 低于阈值 90%%,发布失败", passRate));
}
// 2. 废弃旧版本
repository.deactivateByName(name);
// 3. 保存新版本
PromptTemplate template = new PromptTemplate();
template.setName(name);
template.setContent(content);
template.setVersion(version);
template.setActive(true);
template.setCreatedBy(operator);
repository.save(template);
// 4. 清缓存
cache.evict(name);
// 5. 记录审计日志
auditService.log(operator, "PROMPT_PUBLISH", name, version);
}
}Prompt 测试框架
这是很多团队缺失的环节,但很重要:
@Entity
public class PromptTestCase {
private String id;
private String templateName; // 关联的模板
private String inputVariables; // JSON 格式的输入变量
private String expectedOutput; // 期望输出(可以是关键词、正则、或完整内容)
private String assertionType; // CONTAINS/REGEX/EXACT/SEMANTIC
private String description; // 测试用例说明
}@Service
public class PromptTestRunner {
private final ChatModel chatModel;
private final SemanticSimilarityEvaluator semanticEvaluator;
public TestResult runTestCase(PromptTestCase testCase, String templateContent) {
long startTime = System.currentTimeMillis();
// 渲染模板
Map<String, String> vars = parseJson(testCase.getInputVariables());
String prompt = renderTemplate(templateContent, vars);
// 调用模型
String actualOutput = chatModel.generate(prompt);
long latency = System.currentTimeMillis() - startTime;
// 断言
boolean passed = switch (testCase.getAssertionType()) {
case "CONTAINS" -> actualOutput.contains(testCase.getExpectedOutput());
case "REGEX" -> actualOutput.matches(testCase.getExpectedOutput());
case "EXACT" -> actualOutput.trim().equals(testCase.getExpectedOutput().trim());
case "SEMANTIC" -> semanticEvaluator.isSimilar(
actualOutput, testCase.getExpectedOutput(), 0.85);
default -> throw new IllegalArgumentException("未知断言类型: " + testCase.getAssertionType());
};
return TestResult.builder()
.testCaseId(testCase.getId())
.passed(passed)
.actualOutput(actualOutput)
.latencyMs(latency)
.build();
}
/**
* 批量运行所有测试用例
*/
public List<TestResult> runAll(String templateName, String templateContent) {
List<PromptTestCase> cases = testCaseRepository.findByTemplateName(templateName);
return cases.parallelStream()
.map(tc -> runTestCase(tc, templateContent))
.collect(toList());
}
}A/B 测试
@Service
public class PromptABTestService {
private final PromptTemplateRepository repository;
private final MetricsService metricsService;
/**
* 根据用户 ID 决定使用哪个版本
*/
public String getTemplateForUser(String templateName, String userId) {
Optional<ABTestConfig> abTest = repository.findActiveABTest(templateName);
if (abTest.isEmpty()) {
return getActiveTemplate(templateName); // 没有 A/B 测试,返回默认版本
}
ABTestConfig config = abTest.get();
// 用用户 ID hash 保证同一用户始终命中同一版本
int bucket = Math.abs(userId.hashCode()) % 100;
String variant = bucket < config.getTrafficPercent() ? "B" : "A";
// 记录分配
metricsService.recordABAssignment(templateName, userId, variant);
return variant.equals("B") ? config.getVariantContent() : config.getControlContent();
}
/**
* 查看 A/B 测试结果
*/
public ABTestReport getReport(String templateName) {
ABTestMetrics metrics = metricsService.getABTestMetrics(templateName);
return ABTestReport.builder()
.controlSatisfactionRate(metrics.controlSatisfactionRate())
.variantSatisfactionRate(metrics.variantSatisfactionRate())
.controlAvgLatency(metrics.controlAvgLatency())
.variantAvgLatency(metrics.variantAvgLatency())
.sampleSize(metrics.totalSamples())
.statisticalSignificance(calculatePValue(metrics))
.recommendation(metrics.variantSatisfactionRate() > metrics.controlSatisfactionRate() + 0.03
? "推荐切换到 Variant B" : "继续观察")
.build();
}
}踩坑实录
坑一:Prompt 里的中文标点导致格式解析失败
现象:要求模型输出 JSON,但有时模型把 JSON 里的引号变成中文引号 "" 而不是英文 "",导致解析失败。
原因:Prompt 里混用了中英文标点,模型受此影响在输出时也混用。
解法:
- Prompt 里关于格式的要求全用英文写
- 要求 JSON 格式时明确说"使用英文双引号"
- 在代码里对输出做预处理,替换中文标点
坑二:Prompt 过长导致"中间遗忘"
现象:System Prompt 写了 3000 字,但模型有时忽视其中某些约束,仿佛没看到。
原因:Large context 下模型对中间部分的内容注意力较低("Lost in the Middle"问题)。
解法:
- 把最重要的约束放在 System Prompt 的开头和结尾
- 在 User Message 里也重申关键约束
- 核心约束不超过 10 条,去掉可有可无的
坑三:不同模型对同一 Prompt 响应差异大
现象:在 GPT-4o 上调好的 Prompt,切换到 Claude 后格式输出经常不对。
原因:不同模型有不同的"喜好",GPT 系列对结构化 Prompt(用标题、列表)响应好,Claude 对自然语言描述理解更好。
解法:维护多套 Prompt 模板,针对不同模型分别优化:
public String getTemplate(String name, String modelId) {
// 先查模型特定版本
Optional<PromptTemplate> modelSpecific =
repository.findByNameAndModel(name, modelId);
if (modelSpecific.isPresent()) return modelSpecific.get().getContent();
// 降级到通用版本
return repository.findByNameAndActiveTrue(name)
.map(PromptTemplate::getContent)
.orElseThrow();
}Prompt 管理平台功能清单
如果你们团队要自建 Prompt 管理平台,最低功能集:
不想自建可以用开源的 Langfuse 或商业的 Humanloop,都支持 Prompt 版本管理和评估。
Langfuse 实战——最好的 Prompt 管理工具之一
如果你不想从零自建 Prompt 管理平台,Langfuse 是目前开源生态里功能最完整的选择,支持自部署。
快速集成(Java):
<dependency>
<groupId>com.langfuse</groupId>
<artifactId>langfuse-java</artifactId>
<version>2.0.0</version>
</dependency>@Configuration
public class LangfuseConfig {
@Bean
public Langfuse langfuse(
@Value("${langfuse.public-key}") String publicKey,
@Value("${langfuse.secret-key}") String secretKey,
@Value("${langfuse.host}") String host) {
return Langfuse.builder()
.publicKey(publicKey)
.secretKey(secretKey)
.host(host) // 自部署地址,或 https://cloud.langfuse.com
.build();
}
}@Service
public class TrackedChatService {
private final ChatModel chatModel;
private final Langfuse langfuse;
public String chat(String userId, String message) {
// 创建 Trace(一次完整的用户交互)
Trace trace = langfuse.trace()
.name("customer-service-chat")
.userId(userId)
.input(message)
.build();
// 记录 Generation(一次模型调用)
Generation generation = trace.generation()
.name("gpt-4o-call")
.model("gpt-4o")
.input(List.of(new ChatMessage("user", message)))
.build();
long start = System.currentTimeMillis();
String response = chatModel.generate(message);
long latency = System.currentTimeMillis() - start;
// 更新 Generation 结果
generation.update()
.output(response)
.usage(new Usage(countTokens(message), countTokens(response)))
.latency(latency)
.end();
// 更新 Trace 输出
trace.update().output(response).end();
return response;
}
// 记录用户反馈
public void recordFeedback(String traceId, boolean positive) {
langfuse.score()
.traceId(traceId)
.name("user-feedback")
.value(positive ? 1.0 : 0.0)
.comment(positive ? "用户点赞" : "用户点踩")
.build();
}
}有了 Langfuse,你可以在 Web 界面上看到:每次对话的完整记录、Token 消耗趋势、延迟分布、用户满意度统计。
Prompt 版本管理(Langfuse):
// 从 Langfuse 获取 Prompt(支持版本控制)
@Service
public class PromptService {
private final Langfuse langfuse;
public String getPrompt(String name) {
// Langfuse 的 Prompt 管理功能
// 可以在 Web UI 里管理 Prompt 版本,这里直接拉取激活版本
return langfuse.getPrompt(name).getPromptString();
}
public String renderPrompt(String name, Map<String, String> variables) {
String template = getPrompt(name);
for (Map.Entry<String, String> entry : variables.entrySet()) {
template = template.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return template;
}
}Langfuse 的 Prompt 管理界面很直观:可以创建新版本、对比不同版本的差异、设置哪个版本是激活的,而不需要改代码或配置。
Prompt 最常见的反模式
在 Review 别人代码里的 Prompt 时,我总结了几个最常见的反模式,拿出来说说:
反模式一:Prompt 里有硬编码的动态内容
# 坏的:日期硬编码
你是一个智能助手。今天是2025年1月15日。请回答用户的问题。
# 好的:用变量
你是一个智能助手。今天是{current_date}。请回答用户的问题。一旦日期变了就要改代码,这是典型的配置和代码没分离。
反模式二:约束写得太啰嗦
# 坏的:废话太多
请注意,你需要非常认真仔细地阅读用户的问题,并且确保你的回答是完全准确的,
不要包含任何可能不准确的信息。如果你不确定某个信息,请明确告知用户你不确定。
同时,请注意保持回答的专业性,不要使用过于口语化的表达......(还有 200 字)
# 好的:简洁直接
不确定的信息明确说不确定,不要猜测或捏造。回答要专业简洁。模型理解简洁指令反而比冗长指令效果更好。过度解释反而会让模型不知道重点在哪里。
反模式三:没有输出格式示例
# 坏的:只说要求,没有示例
请将用户意图分类为以下几种:产品咨询、价格询问、投诉、退换货
# 好的:提供示例
将用户意图分类,只返回分类名称,不要解释。
可选值:产品咨询、价格询问、投诉、退换货、其他
示例:
输入:这个产品支持什么规格? 输出:产品咨询
输入:能不能便宜点? 输出:价格询问有了示例,格式一致性会大幅提升,尤其是结构化输出的场景。
