Administrator
发布于 2024-10-23 / 7013 阅读
141

Prompt 工程与版本管理实践

没人动过代码,准确率从 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();
    }
}

三个设计取舍:

  1. 变量缺失直接抛异常,不要给默认值。曾经有次 {{date}} 没传,模板渲染成了空字符串,模型开始胡编日期。
  2. 文件名带版本号system.v3.st),这样新旧版本可以并存,方便灰度和回滚。
  3. 改动必须走 PR。因为变成文件了,diff 很清楚,评审会自然发生。

第二步:建回归测试集

没有测试集的 Prompt 优化就是瞎改。我们花了两周,从线上日志里抽样人工标注了 150 条标准问答,覆盖六类场景:

场景条数评判要点
政策咨询38金额、时效必须准确
流程指引32步骤不能缺
故障排查25是否命中正确的排查路径
闲聊与拒答21超纲问题要正确拒答
多轮追问19上下文是否记住
对抗样本15Prompt 注入、诱导泄密

评分用的是"模型打分 + 规则校验"双轨。规则负责硬指标,模型负责软指标:

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.284.41略好
转人工率23.1%19.4%好 3.7 个点
用户追问率31.7%28.9%略好
平均输出 token412596成本涨 45%
平均首字延迟1.31 s1.86 s慢了 0.55 秒

结论是没上。转人工率降 3.7 个点看着不错,但成本涨了 45%,首字延迟多了半秒,算下来每个减少的人工工单要多花 0.8 元的推理成本,而一个人工工单的边际成本远低于此。这个账一算,v4 就没有上线价值。

后来 v4 改了一版,加了一句"不要重复用户已经说过的信息",把输出压到 470 token,转人工率仍有 20.2%,这才全量。

小结

Prompt 本质上是一段对模型行为的配置,它跟代码的区别只有一点:代码错了会报异常,Prompt 错了只会静默地变差。所以它需要比代码更严格的管理。

我们现在的规矩是:改 Prompt 必须走 PR、必须跑回归测试集、必须灰度、必须有明确的业务指标对比。听起来繁琐,但比起每次出事都靠用户投诉才发现,这点成本值得。

另外一个体会:测试集的建设是长期投入,越早越好。150 条标注数据我们花了两个人两周,但它现在是我们判断任何 Prompt 改动的唯一依据,没有它,所有优化都是拍脑袋。

参考