没人动过代码,准确率从 87% 掉到 71%
10 月中旬的一个周二,运营反馈客服机器人的回答质量明显变差。我看了一圈发布记录,前后三天没有任何代码上线。但监控上的数字是实打实的:
chat_answer_quality_score{date="2024-10-14"} 0.871
chat_answer_quality_score{date="2024-10-15"} 0.869
chat_answer_quality_score{date="2024-10-16"} 0.712 # 断崖
chat_answer_quality_score{date="2024-10-17"} 0.704
查了半天才发现,问题出在 Prompt 上。我们的 Prompt 当时是硬编码在 Java 类里的字符串常量,有个同事为了修复"回答太长"的问题,改了其中一句话,把"请详细解答"改成了"请简洁回答"。改动只有四个字,没有任何评审记录。
// 出问题的那次改动
- private static final String SYSTEM = "你是客服助手。请参考资料详细解答用户问题。";
+ private static final String SYSTEM = "你是客服助手。请参考资料简洁回答用户问题。";
"简洁"这个词太强了,模型开始牺牲完整性,把退款流程从五个步骤压缩成一句话,用户看不懂就开始转人工。
根因:Prompt 没有当成代码来管理
这事暴露的问题不是"改错了",而是我们对 Prompt 的管理方式。当时的状态是:
- Prompt 散落在 6 个 Java 类里,有的在常量,有的在
String.format,还有两个在StringBuilder里拼 - 没有版本号,改了就是改了,回滚要重新发版
- 没有测试,改完之后只能靠人工试几个问题
- 没有评审,因为 diff 里看着就是一行字符串
代码有 Git、有 Code Review、有单测,Prompt 什么都没有,但它对效果的影响比大部分代码都大。
第一步:模板化管理
我们把所有 Prompt 抽成独立文件,放在 src/main/resources/prompts/ 下,用简单的占位符语法。
# prompts/customer_service/system.v3.st
你是{{company}}的在线客服助手。
要求:
1. 只能依据【参考资料】回答,资料里没有的内容必须说明"暂未查到相关信息"
2. 回答分步骤时,每步单独一行
3. 单次回答控制在 {{max_words}} 字以内
4. 涉及金额、时效、政策条款,必须原文引用
当前日期:{{date}}
加载和渲染的实现不复杂,我们用了一个轻量的封装。当时 Spring AI 还处在里程碑版本(1.0.0-M3 前后),API 每次升级都在变,不敢用在生产链路,就自己写了个不到 100 行的模板器:
public final class PromptTemplate {
private final String body;
private static final Pattern VAR = Pattern.compile("\\{\\{\\s*(\\w+)\\s*\\}\\}");
public static PromptTemplate of(String classpath) {
try (InputStream in = PromptTemplate.class.getResourceAsStream(classpath)) {
return new PromptTemplate(new String(in.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public String render(Map<String, Object> vars) {
Matcher m = VAR.matcher(body);
StringBuilder sb = new StringBuilder();
while (m.find()) {
Object v = vars.get(m.group(1));
if (v == null) throw new IllegalArgumentException("缺少变量: " + m.group(1));
m.appendReplacement(sb, Matcher.quoteReplacement(v.toString()));
}
m.appendTail(sb);
return sb.toString();
}
}
三个设计取舍:
- 变量缺失直接抛异常,不要给默认值。曾经有次
{{date}}没传,模板渲染成了空字符串,模型开始胡编日期。 - 文件名带版本号(
system.v3.st),这样新旧版本可以并存,方便灰度和回滚。 - 改动必须走 PR。因为变成文件了,diff 很清楚,评审会自然发生。
第二步:建回归测试集
没有测试集的 Prompt 优化就是瞎改。我们花了两周,从线上日志里抽样人工标注了 150 条标准问答,覆盖六类场景:
| 场景 | 条数 | 评判要点 |
|---|---|---|
| 政策咨询 | 38 | 金额、时效必须准确 |
| 流程指引 | 32 | 步骤不能缺 |
| 故障排查 | 25 | 是否命中正确的排查路径 |
| 闲聊与拒答 | 21 | 超纲问题要正确拒答 |
| 多轮追问 | 19 | 上下文是否记住 |
| 对抗样本 | 15 | Prompt 注入、诱导泄密 |
评分用的是"模型打分 + 规则校验"双轨。规则负责硬指标,模型负责软指标:
class Evaluator {
// 规则:能从答案里提取出来的确定性判断
List<Rule> rules = List.of(
new MustContain("金额", "退款政策类问题必须出现具体金额"),
new MustNotContain("根据我的知识", "禁止模型用自身知识回答"),
new LengthBetween(30, 300),
new NoHallucinatedDate()
);
// 模型:让另一个模型按 rubric 打分(1-5)
double llmScore(String question, String answer, String reference) {
String judge = """
你是评分员。按以下标准给【回答】打分,只输出 1-5 的整数。
5=完全正确且引用了资料;4=正确但表达啰嗦;3=基本正确有小遗漏;
2=部分错误;1=完全错误或幻觉。
问题:%s
参考资料:%s
回答:%s
""".formatted(question, reference, answer);
return llm.chat(judge, 0.0); // 温度设 0,保证评分稳定
}
}
评分跑在 CI 里,规则不通过直接失败,模型打分低于基线 0.05 就要人工确认:
$ mvn test -Dtest=PromptRegressionTest
[INFO] 场景=政策咨询 通过 36/38 平均分 4.31 (基线 4.28)
[INFO] 场景=流程指引 通过 30/32 平均分 4.02 (基线 4.35) ⚠ 下降 0.33
[ERROR] 流程指引场景平均分低于基线 0.05,构建失败
[ERROR] 失败用例: q-0137 "海外订单怎么退货"(缺少第 3 步)
这套东西上线后,抓到过三次回归。最近一次是有人为了省 token 把 few-shot 从 3 个例子减到 1 个,规则没挂,但"拒答"场景的模型打分从 4.6 掉到 3.8。
把 System / Few-shot / User 分开管
模板化之后我们进一步拆了层。以前所有内容糊在一个字符串里,改一处要小心翼翼。现在分三个文件:
prompts/customer_service/
├── system.v3.st # 角色、约束、输出格式
├── fewshot.v3.st # 3 组示例,每组 一对问答
└── user.v3.st # 资料 + 问题的组装方式
这样做的好处是可以单独灰度。比如我们怀疑 few-shot 拖长了输出,就只把 fewshot 从 3 组减到 1 组,system 不动。跑回归测试发现拒答场景掉分,就只回滚 few-shot,不用动其他部分。
ChatRequest req = ChatRequest.builder()
.system(systemTemplate.render(ctx))
.messages(fewshotLoader.load(1)) // 组数可以运行时配置,方便灰度
.user(userTemplate.render(ctx))
.temperature(0.2)
.build();
few-shot 的条数我们做成了配置项,通过配置中心动态调整。有一次线上出现新的作弊话术,运营临时加了两条例到 few-shot 里,五分钟生效,不用发版。这种灵活性在出问题时很救命。
第三步:A/B 测试才决定要不要上
测试集只能证明"没变差",不能证明"变好了"。要不要上线还得看真实流量。我们的分流是按用户 ID 哈希,保证同一个用户始终命中同一版本,避免体验跳变。
public String pickVariant(long userId, String experiment) {
int bucket = Math.floorMod(hash(userId + ":" + experiment), 100);
return switch (experiment) {
case "cs_prompt_v4" -> bucket < 10 ? "v4" : "v3"; // 10% 灰度
default -> "v3";
};
}
观测的指标,除了模型打分,还有业务指标:
| 指标 | v3(对照组) | v4(实验组) | 结论 |
|---|---|---|---|
| 模型平均分 | 4.28 | 4.41 | 略好 |
| 转人工率 | 23.1% | 19.4% | 好 3.7 个点 |
| 用户追问率 | 31.7% | 28.9% | 略好 |
| 平均输出 token | 412 | 596 | 成本涨 45% |
| 平均首字延迟 | 1.31 s | 1.86 s | 慢了 0.55 秒 |
结论是没上。转人工率降 3.7 个点看着不错,但成本涨了 45%,首字延迟多了半秒,算下来每个减少的人工工单要多花 0.8 元的推理成本,而一个人工工单的边际成本远低于此。这个账一算,v4 就没有上线价值。
后来 v4 改了一版,加了一句"不要重复用户已经说过的信息",把输出压到 470 token,转人工率仍有 20.2%,这才全量。
小结
Prompt 本质上是一段对模型行为的配置,它跟代码的区别只有一点:代码错了会报异常,Prompt 错了只会静默地变差。所以它需要比代码更严格的管理。
我们现在的规矩是:改 Prompt 必须走 PR、必须跑回归测试集、必须灰度、必须有明确的业务指标对比。听起来繁琐,但比起每次出事都靠用户投诉才发现,这点成本值得。
另外一个体会:测试集的建设是长期投入,越早越好。150 条标注数据我们花了两个人两周,但它现在是我们判断任何 Prompt 改动的唯一依据,没有它,所有优化都是拍脑袋。